mirror of
https://github.com/Nikolay-Shirokov/cc-1c-skills.git
synced 2026-07-13 14:25:17 +03:00
Compare commits
653 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 71309d2bc2 | |||
| e1f81a0cde | |||
| e8cb5440d8 | |||
| c496047c6c | |||
| df9541470c | |||
| 27ecfb707d | |||
| 35bec4a2a1 | |||
| e635a5be52 | |||
| dfb258dd3f | |||
| 1866c1335a | |||
| 4813ec5cf2 | |||
| 06ad2f8f99 | |||
| c8ac191a01 | |||
| c9d83b1c92 | |||
| b1a7e414d0 | |||
| 7736ad68a0 | |||
| 501bb58ddb | |||
| cd3a242b12 | |||
| 4f9d9aee97 | |||
| f3466e19fd | |||
| ebf92a8780 | |||
| 3d80191ca7 | |||
| 6c60398406 | |||
| 60de083a05 | |||
| 5690c82ab8 | |||
| dc0382cc06 | |||
| b1e29253d5 | |||
| 07b2ec36af | |||
| b4514dc350 | |||
| 8ad5d80f7f | |||
| fc19df604b | |||
| 76800fc92b | |||
| 36cd63d8bb | |||
| e216db5734 | |||
| a59be4b914 | |||
| 4b8d3b67dc | |||
| 6650d2b516 | |||
| 6b63177687 | |||
| 13174b63b1 | |||
| 433a2955b5 | |||
| 42b96bbd21 | |||
| bd3b40852a | |||
| 336dade274 | |||
| 99e9c1e0b8 | |||
| 3c3ed2ff46 | |||
| d75b4d96ca | |||
| 704fbc9f28 | |||
| cd74afa5e0 | |||
| 370873511d | |||
| 9ced410d8f | |||
| 50ba38a9f6 | |||
| d4b105bbe4 | |||
| 204a262746 | |||
| 210bde7b2e | |||
| a932841418 | |||
| 576f6dda8a | |||
| 1a5b788305 | |||
| b39da27d20 | |||
| 1f23afe6f9 | |||
| eda7279de0 | |||
| b3c2602439 | |||
| 853313faed | |||
| b7fbede819 | |||
| bdc38caffa | |||
| 2a86df1c98 | |||
| 2e1487fd17 | |||
| 3729b63b89 | |||
| c3ec51e174 | |||
| fb58a04700 | |||
| a30b720c89 | |||
| 54d47aadad | |||
| 3f23be8219 | |||
| f5dd677ac8 | |||
| 02e9053d00 | |||
| bd462f4cc3 | |||
| be74d224be | |||
| 1b46eb4d85 | |||
| 1cea9e794e | |||
| 00fafd4af5 | |||
| e30b518935 | |||
| afdfc97fb1 | |||
| 82e70d2c30 | |||
| aedf6df674 | |||
| 0d31d17204 | |||
| 2206c4cf3e | |||
| 8a9f285da9 | |||
| e5e3f199f2 | |||
| 3ee715939b | |||
| b8ab791456 | |||
| 97a2ef91d5 | |||
| a41897a966 | |||
| 076bbcb9d5 | |||
| 3c48451704 | |||
| 801ceea2c0 | |||
| 63de8bd27c | |||
| d1e770c843 | |||
| e3069ceb36 | |||
| 0b2c09f8d9 | |||
| 0d5d3451ff | |||
| 7a8f437e77 | |||
| dc4ffa1fc8 | |||
| 6805b9da02 | |||
| aa93031a3f | |||
| 037062c728 | |||
| 9cbda1989a | |||
| d34ddd41ff | |||
| 96d1dea552 | |||
| 83b289de32 | |||
| 384e68cab4 | |||
| 46e065adb9 | |||
| fdfe4ac2f4 | |||
| afacaa5ade | |||
| 73242ee60e | |||
| e2924c4ae0 | |||
| 123fc41b06 | |||
| 338faa253d | |||
| d3520a8945 | |||
| e731bde7f0 | |||
| 321e426f98 | |||
| 54c04cfe76 | |||
| 9727635e5d | |||
| 6404016afb | |||
| 17afd807d2 | |||
| 9bfc431a6a | |||
| d755c41233 | |||
| 87e636f644 | |||
| ae1dcaac07 | |||
| d155086444 | |||
| 940eafb8e4 | |||
| e4dcef8c90 | |||
| 08688f5cab | |||
| 1bc5e8f07a | |||
| 358830c65b | |||
| 3ba6072660 | |||
| 20adf4f463 | |||
| d5aacc9e60 | |||
| 3bd69baae6 | |||
| b0fdc32053 | |||
| 731a652cae | |||
| dd88f78969 | |||
| ff068202e3 | |||
| 91798e3838 | |||
| 9620c3846a | |||
| ffc34904c5 | |||
| b008c820f9 | |||
| e5697d6f5c | |||
| 29ee294de6 | |||
| d72cbacfd6 | |||
| c8a7ba4683 | |||
| ea8b28280d | |||
| eebc2a0679 | |||
| 974e8ff5e4 | |||
| 47c2e5d48f | |||
| e56a932ee2 | |||
| 69f5e0b7ae | |||
| 84462e3dd9 | |||
| d574320849 | |||
| 90ff1d53b6 | |||
| 09bc0d00b8 | |||
| 66c6dc7aa1 | |||
| 1d89b3ec69 | |||
| 43d1d09ee8 | |||
| 404e6c6851 | |||
| 009022d04b | |||
| 70e684d189 | |||
| 72bad1aaaa | |||
| 29a5cbae4c | |||
| be1bbb2d26 | |||
| 5281fd54f2 | |||
| f697ba3ff3 | |||
| 0778cc89ee | |||
| 28b2765f68 | |||
| 0d116863ec | |||
| bc6bc01047 | |||
| 972cd5061d | |||
| 4565808b77 | |||
| 250978c2fd | |||
| 4551525718 | |||
| 9422c66df4 | |||
| a1b3fdd4e2 | |||
| d6d44b8b35 | |||
| b480fa0b49 | |||
| 44a84f8ce7 | |||
| dcacecff7f | |||
| 8b38f8f78d | |||
| 74b3f76a32 | |||
| 671be7c6b5 | |||
| 4a697db47a | |||
| 34f582ddef | |||
| eec626eb6f | |||
| 312d058412 | |||
| 95776a4248 | |||
| 347722ef0d | |||
| 9f6793abae | |||
| 0ddb675502 | |||
| d2dfcfd160 | |||
| 6c01f3a261 | |||
| 506f0b84df | |||
| f5c02144cb | |||
| d982c5082a | |||
| cce00a4def | |||
| bc4ee63986 | |||
| a314ec32fc | |||
| baefeaa05b | |||
| 2d88cdc864 | |||
| f7695a9534 | |||
| 252105396b | |||
| 42df4cd6b1 | |||
| b9a04b235f | |||
| 9bc0240e95 | |||
| 4cfcaaaa1c | |||
| f9c3792528 | |||
| 9090a81e43 | |||
| f037324ee9 | |||
| 6f36e36166 | |||
| ca0dac2693 | |||
| ab7d82ba20 | |||
| 669b5f42f2 | |||
| 5b6fcc7c74 | |||
| df47128994 | |||
| ea453e5c97 | |||
| 14b0782904 | |||
| b7e3bd876d | |||
| b68f4145ce | |||
| dc2056a5d5 | |||
| bef4a13ba9 | |||
| c280e52932 | |||
| c86bf8779f | |||
| a4e0faaeb3 | |||
| 9d6ceae4f1 | |||
| 18ad662378 | |||
| 05fc7eba27 | |||
| ffa3189442 | |||
| bb02c1b0bd | |||
| 46f0e6be8c | |||
| 38ccded7d9 | |||
| 4e0ce5ba0f | |||
| e948d39adb | |||
| d9e7d9c107 | |||
| 1da43109fc | |||
| 21a0e360ef | |||
| 9f5e244f68 | |||
| 18a198d12b | |||
| 8fca42193a | |||
| b603c7e3fa | |||
| b00289e62c | |||
| 97820111d5 | |||
| 0a2a36dbc6 | |||
| b36232ae19 | |||
| 1479f944f5 | |||
| 9cffa81bcc | |||
| 6667ab38ee | |||
| 26c5e849a5 | |||
| ca681676b4 | |||
| e3a9be0036 | |||
| 361354cdd4 | |||
| 2ce7b12c4c | |||
| f244d5e507 | |||
| 6cb54a8f96 | |||
| 75f56603b6 | |||
| 55ab172ba4 | |||
| e9e00c906d | |||
| 31792a8a2d | |||
| fa2e3dd3e7 | |||
| 3e8a0a792f | |||
| d5e4bc3f21 | |||
| 7a6e63078d | |||
| 55b0ffa4fd | |||
| d06ecb940f | |||
| 21de2a4749 | |||
| 07be2bcafd | |||
| 24a48b4a9f | |||
| 91b5204ab2 | |||
| f2bd42c54c | |||
| 7e56cd79db | |||
| 1abc44334c | |||
| 0ca2faa6a6 | |||
| 767b9fcaf0 | |||
| 598e223288 | |||
| f7a27e750d | |||
| 15840ed1ca | |||
| 733418e630 | |||
| b15fb999cf | |||
| 5414ac373e | |||
| 0c4addaedc | |||
| cc0d577fc5 | |||
| 160a845bd8 | |||
| 184e4773a4 | |||
| 97105486b5 | |||
| 23f4cc0bbd | |||
| c5c67600b3 | |||
| 85191dc759 | |||
| 064e42842e | |||
| 4507d9b59c | |||
| 17be0ca009 | |||
| 8f59d3bc66 | |||
| f93a1560a5 | |||
| 298589d7cc | |||
| c6a84e2f91 | |||
| de7e943de1 | |||
| 4855cd1e01 | |||
| 7abe26afee | |||
| 512562ca50 | |||
| 84d078bd05 | |||
| 8584021446 | |||
| e7e6c885c7 | |||
| b5a779bd5d | |||
| 6df64ae1c1 | |||
| 007b4ec69c | |||
| cf5eae6428 | |||
| c72f2210b5 | |||
| 06584c29b2 | |||
| d94ffdea99 | |||
| 9f370dd499 | |||
| 903f1f2750 | |||
| 29b124f3fd | |||
| ffdee04a95 | |||
| b2a2534b5a | |||
| 422e397381 | |||
| 191706b984 | |||
| 93e4130ff2 | |||
| 3f091b209c | |||
| 5d4ee6c58c | |||
| 86c8440531 | |||
| 42cc7acdbe | |||
| 72a4015a8d | |||
| 843916642c | |||
| 84f3662c02 | |||
| 4cdd3377ae | |||
| 730daf1089 | |||
| c0255c71d0 | |||
| cf1cd1a711 | |||
| 5a1974be4e | |||
| 589091510b | |||
| 19667caccb | |||
| 97fc6dbd7f | |||
| 49108f72dc | |||
| dbb39b7a3e | |||
| 87dc18b120 | |||
| ca328e3e8f | |||
| c2b90a97ef | |||
| e717d4a57f | |||
| 6051972391 | |||
| 24751a59de | |||
| b824759a05 | |||
| fb23cbda56 | |||
| 69c89d3d70 | |||
| dee85e19d5 | |||
| 87348ec73c | |||
| dd81c85366 | |||
| c6f499a099 | |||
| b5826a61c4 | |||
| 3097811dde | |||
| f01f9d6ae8 | |||
| 0a1f8888dc | |||
| 66eb0b19ec | |||
| 872675652f | |||
| fde6d346d7 | |||
| 4490aeb533 | |||
| 6958cc558b | |||
| 8a286d7191 | |||
| a5fa730747 | |||
| 53219cae6c | |||
| b1187865ec | |||
| 22bf17ee00 | |||
| 2e0dcb773b | |||
| c958f3f818 | |||
| 684e97e518 | |||
| ce5651a5d7 | |||
| e706aca958 | |||
| 90b3d5d2e9 | |||
| 1b37bad331 | |||
| 8b0664b18d | |||
| 2af73d25bf | |||
| 449e2f667e | |||
| e032143157 | |||
| 09fc3a7f43 | |||
| 01315839fb | |||
| 6505fb1986 | |||
| 9e022c60f9 | |||
| e23a235ffa | |||
| 5bccca2475 | |||
| a8d8007846 | |||
| 37d2a5ee15 | |||
| 050d42a457 | |||
| 5acb586bbf | |||
| 7f6ea32533 | |||
| e9b53505ac | |||
| 6ce36f7d9b | |||
| a13f16e49d | |||
| 8f130f2e83 | |||
| 26ea09e2ff | |||
| 98c416b5a7 | |||
| 98ece6206e | |||
| 0c0a1aea49 | |||
| 5870fa200f | |||
| 7c10e5e4b1 | |||
| 665d5579d2 | |||
| 0fec18bbc4 | |||
| cc751a344e | |||
| f956749e1e | |||
| 2a6f4b1d5f | |||
| 9318d05535 | |||
| bb07bfae14 | |||
| 38c82f4a2f | |||
| d5fa5349d4 | |||
| ebc63c0264 | |||
| 99a5c7168a | |||
| 751a6a8f12 | |||
| 43a2691d6a | |||
| e6da514b67 | |||
| 1e5c42fa56 | |||
| 59627b0699 | |||
| 7b69228d23 | |||
| da0cedf256 | |||
| cc6b170488 | |||
| f9439dce6d | |||
| 336466ae82 | |||
| 59d223d72b | |||
| f56f5dc849 | |||
| 1fd3d8ffb8 | |||
| d6befb0dc8 | |||
| 5120b135f5 | |||
| 5d18bf4501 | |||
| 366378c4b5 | |||
| 56203e2b71 | |||
| ff14880871 | |||
| a17a81fe98 | |||
| e58f5c1f82 | |||
| 0fbdd298ca | |||
| 68fd9bb4a7 | |||
| e18e8cd284 | |||
| 5ea6414585 | |||
| 75558fe46c | |||
| 90ca2a7c4a | |||
| 500e05703e | |||
| ebda3e6608 | |||
| 328cb60f18 | |||
| 5061d83282 | |||
| b10802d4d7 | |||
| ae0c3e3278 | |||
| 00ec14aed3 | |||
| 102a7b8849 | |||
| 68e346d645 | |||
| c8f58b5461 | |||
| ba963739d8 | |||
| afe760af4b | |||
| c6eae770f5 | |||
| d28bea8f0a | |||
| 475b6d9544 | |||
| 2d69ecf709 | |||
| 88f74e96f0 | |||
| bce2d7c85c | |||
| 8f3bde5cfc | |||
| 85cdea925e | |||
| 679f6bae94 | |||
| c5abfc3a4f | |||
| d9ce7b03cc | |||
| d6abb2b651 | |||
| 86a959a354 | |||
| 8e7125d850 | |||
| fa9fae49c2 | |||
| c0e18ed428 | |||
| 64453ebe08 | |||
| c91ad0a158 | |||
| 6b5992de34 | |||
| 9d5cbaab9f | |||
| fe8fa4bc3f | |||
| 028c5292d7 | |||
| 31debfb61b | |||
| a110c17066 | |||
| b535dff609 | |||
| 96b400ddb9 | |||
| b68013b2f2 | |||
| e2f765fcc0 | |||
| a3e9e3c907 | |||
| bc4778d6cc | |||
| 2a0b896f5d | |||
| bc9087957f | |||
| 78e252af1e | |||
| 4c5e10a958 | |||
| 7d0d2dbf3d | |||
| 89d1ad3a12 | |||
| 9c75e9392e | |||
| 5f7ee6fcae | |||
| 6f32e18e37 | |||
| 3565e1c97f | |||
| 28b967f591 | |||
| 9c1985b710 | |||
| 99d37fb494 | |||
| 48de4cdc2a | |||
| 544893b781 | |||
| 8ecc5764ed | |||
| 329d94dc55 | |||
| 7268b169d8 | |||
| 2a4d2bf8df | |||
| 6e7284fc79 | |||
| c9e991e1e8 | |||
| 2a0fff1416 | |||
| 1364f22df3 | |||
| 4b0304d0c8 | |||
| cfcc633257 | |||
| d7f012b307 | |||
| 27bb08d127 | |||
| 151ded9bae | |||
| d84a2fb4cf | |||
| 05362aeb96 | |||
| 01d02065a3 | |||
| a711f05acc | |||
| 7003a46ad0 | |||
| e29c184f8e | |||
| 38c0ce11a9 | |||
| fcc85c9c6e | |||
| 9331aa7825 | |||
| d6ad1eacdb | |||
| 52875b4dea | |||
| 2698a2b80b | |||
| 8e8660c52b | |||
| 5d3eccc8eb | |||
| b8e3107d14 | |||
| aae69664c5 | |||
| bd947f2c0b | |||
| 1b6ab2f144 | |||
| a6a708d14d | |||
| 3ec2d2594c | |||
| 08d29c0341 | |||
| 37ecb77256 | |||
| 9cfb62c5b1 | |||
| fff77d97ca | |||
| 484ee44387 | |||
| 47174ee236 | |||
| d02ca602a4 | |||
| f3b3f26af9 | |||
| be47935973 | |||
| 1312cd7db4 | |||
| a81c26afda | |||
| 7044f125ed | |||
| 989f4e49d9 | |||
| cb4b5ec935 | |||
| 7c5700e40f | |||
| a6bf6520ce | |||
| ed45f54594 | |||
| 099de2d850 | |||
| 2d79c0b0ee | |||
| acee4bd188 | |||
| 01b20426fa | |||
| 04e5360e52 | |||
| 33fa6dbe9a | |||
| 11152d2443 | |||
| daea0a72d1 | |||
| b4905fc3e3 | |||
| 23555b513f | |||
| 8aef749471 | |||
| bd30e48dae | |||
| 92dc00e152 | |||
| 45c2587e4a | |||
| 08722ff285 | |||
| 89c0775c65 | |||
| 5c0a55e090 | |||
| 2f2ecca88b | |||
| acb7a4eadc | |||
| 28b8061d64 | |||
| 703c3e6f80 | |||
| 2d0835ba17 | |||
| a9b7a90672 | |||
| 69213930b3 | |||
| 151d1d77cb | |||
| 70bb5074c8 | |||
| 91d254b718 | |||
| 1382194201 | |||
| 7e587b9a3e | |||
| 4594c74d21 | |||
| 2650b1a063 | |||
| f23a3e5536 | |||
| eb6f8379e6 | |||
| 91e4e1948f | |||
| f4d60d67bf | |||
| b67f4b21e6 | |||
| d573f84c9d | |||
| c306d18648 | |||
| d72dd1678e | |||
| 0ca6d3f089 | |||
| dddb9e3dac | |||
| f211ffa2f0 | |||
| 21dded4d1c | |||
| 941fa73803 | |||
| 24358f212f | |||
| d210d21079 | |||
| 2949682ce8 | |||
| e86620dbd7 | |||
| 9922f118d1 | |||
| 8c833f042c | |||
| 1d593af63a | |||
| be216cd064 | |||
| 55b1ef4e06 | |||
| e229fc4873 | |||
| 3a4c1905f4 | |||
| 7aaa9fc070 | |||
| 8b8bc1225a | |||
| dc10422f10 | |||
| 65afdf2e55 | |||
| f87c94e832 | |||
| 6681758121 | |||
| 89f0788585 | |||
| d27dfbf8e1 | |||
| fc24524c75 | |||
| 2c96c90d45 | |||
| 8fc30c9b9a | |||
| ecfb473827 | |||
| dc9e1fe3db | |||
| 5c07dec82c | |||
| c5fa794cf3 | |||
| 7e0e30730c | |||
| ad412dffa2 | |||
| c665800916 | |||
| d05199d048 | |||
| a9e59265dd | |||
| 99f57a5ff2 | |||
| a813c7fd46 | |||
| cc2595b57a | |||
| 580124a971 | |||
| 044bc18974 | |||
| 3819a5f7af | |||
| 56b1458545 | |||
| bca022fba8 | |||
| 0e680d151d | |||
| 8092015807 | |||
| 03a6820f69 | |||
| bfbef3c361 | |||
| 298d503c64 | |||
| 1ed5d783e8 | |||
| f0bb8c860f | |||
| 2f260ddb7f | |||
| 2771424c71 | |||
| 96890598f9 | |||
| 50dc9ceec9 | |||
| 455c12e6b6 | |||
| 1a79e84598 | |||
| 76a0dd80be | |||
| fdf661192d | |||
| be3a31ea94 | |||
| 061c73b23d | |||
| 9f9a022659 | |||
| 215319382a | |||
| d6660a4d00 | |||
| 4bc86decf8 | |||
| 34e88d2914 | |||
| b3dac25729 | |||
| c2348b6b68 | |||
| aaa4f65be1 | |||
| d34cbf7272 | |||
| e3daaf233d | |||
| 43333f1481 |
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"name": "cc-1c-skills",
|
||||||
|
"interface": {
|
||||||
|
"displayName": "1C Skills"
|
||||||
|
},
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "1c-skills",
|
||||||
|
"source": {
|
||||||
|
"source": "url",
|
||||||
|
"url": "https://github.com/Nikolay-Shirokov/cc-1c-skills.git",
|
||||||
|
"ref": "port-codex"
|
||||||
|
},
|
||||||
|
"policy": {
|
||||||
|
"installation": "AVAILABLE"
|
||||||
|
},
|
||||||
|
"category": "Development"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "1c-skills-py",
|
||||||
|
"source": {
|
||||||
|
"source": "url",
|
||||||
|
"url": "https://github.com/Nikolay-Shirokov/cc-1c-skills.git",
|
||||||
|
"ref": "port-codex-py"
|
||||||
|
},
|
||||||
|
"policy": {
|
||||||
|
"installation": "AVAILABLE"
|
||||||
|
},
|
||||||
|
"category": "Development"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,226 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# db-create v1.6 — Create 1C information base
|
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import atexit
|
|
||||||
import glob
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import random
|
|
||||||
import re
|
|
||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import tempfile
|
|
||||||
|
|
||||||
|
|
||||||
def _find_project_v8path():
|
|
||||||
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
|
||||||
d = os.getcwd()
|
|
||||||
while True:
|
|
||||||
pf = os.path.join(d, ".v8-project.json")
|
|
||||||
if os.path.isfile(pf):
|
|
||||||
try:
|
|
||||||
with open(pf, encoding="utf-8-sig") as f:
|
|
||||||
data = json.load(f)
|
|
||||||
v = data.get("v8path")
|
|
||||||
if v:
|
|
||||||
return v
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return None
|
|
||||||
parent = os.path.dirname(d)
|
|
||||||
if parent == d:
|
|
||||||
return None
|
|
||||||
d = parent
|
|
||||||
|
|
||||||
|
|
||||||
def _version_dir(p):
|
|
||||||
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
|
|
||||||
parent = os.path.dirname(p)
|
|
||||||
if os.path.basename(parent).lower() == "bin":
|
|
||||||
parent = os.path.dirname(parent)
|
|
||||||
return os.path.basename(parent)
|
|
||||||
|
|
||||||
|
|
||||||
def _version_key(p):
|
|
||||||
"""Numeric sort key from version dir name."""
|
|
||||||
return [int(x) for x in re.findall(r"\d+", _version_dir(p))]
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_v8path(v8path):
|
|
||||||
"""Resolve path to a 1C executable (1cv8; ibcmd only when given explicitly)."""
|
|
||||||
if not v8path:
|
|
||||||
v8path = _find_project_v8path()
|
|
||||||
if not v8path:
|
|
||||||
if os.name == "nt":
|
|
||||||
candidates = (
|
|
||||||
glob.glob(r"C:\Program Files\1cv8\*\bin\1cv8.exe")
|
|
||||||
+ glob.glob(r"C:\Program Files (x86)\1cv8\*\bin\1cv8.exe")
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# PY-only: PS-порт на *nix не исполняется, поэтому *nix-раскладки нет в .ps1.
|
|
||||||
candidates = glob.glob("/opt/1cv8/*/1cv8")
|
|
||||||
if candidates:
|
|
||||||
v8path = max(candidates, key=_version_key)
|
|
||||||
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
|
|
||||||
else:
|
|
||||||
print("Error: 1C executable not found. Specify -V8Path", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
if os.path.isdir(v8path):
|
|
||||||
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
|
|
||||||
exe = "1cv8.exe" if os.name == "nt" else "1cv8"
|
|
||||||
v8path = os.path.join(v8path, exe)
|
|
||||||
if not os.path.isfile(v8path):
|
|
||||||
print(f"Error: 1C executable not found at {v8path}", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
return v8path
|
|
||||||
|
|
||||||
|
|
||||||
IBCMD_NOUSER_HINT = (
|
|
||||||
"[ibcmd] No -UserName/-Password given; the infobase may require authentication. "
|
|
||||||
"On Windows ibcmd reads credentials from the console (stdin is ignored), so this "
|
|
||||||
"call may block instead of failing. If it does not return promptly, abort and "
|
|
||||||
"re-run with -UserName and -Password.\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
|
||||||
"""Run an ibcmd command non-interactively.
|
|
||||||
|
|
||||||
input="" closes stdin (EOF) so ibcmd's auth prompt fast-fails instead of hanging.
|
|
||||||
On Windows without -UserName ibcmd reads the console directly and may still block —
|
|
||||||
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
|
|
||||||
"""
|
|
||||||
if warn_no_user and os.name == "nt" and not has_username:
|
|
||||||
sys.stderr.write(IBCMD_NOUSER_HINT)
|
|
||||||
sys.stderr.flush()
|
|
||||||
return subprocess.run(cmd, input="", capture_output=True, encoding="utf-8", errors="replace")
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
sys.stdout.reconfigure(encoding="utf-8")
|
|
||||||
sys.stderr.reconfigure(encoding="utf-8")
|
|
||||||
parser = argparse.ArgumentParser(
|
|
||||||
description="Create 1C information base",
|
|
||||||
allow_abbrev=False,
|
|
||||||
)
|
|
||||||
parser.add_argument("-V8Path", default="")
|
|
||||||
parser.add_argument("-InfoBasePath", default="")
|
|
||||||
parser.add_argument("-InfoBaseServer", default="")
|
|
||||||
parser.add_argument("-InfoBaseRef", default="")
|
|
||||||
parser.add_argument("-UseTemplate", default="")
|
|
||||||
parser.add_argument("-AddToList", action="store_true")
|
|
||||||
parser.add_argument("-ListName", default="")
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
v8path = resolve_v8path(args.V8Path)
|
|
||||||
engine = "ibcmd" if os.path.basename(v8path).lower().startswith("ibcmd") else "1cv8"
|
|
||||||
|
|
||||||
# --- Validate connection ---
|
|
||||||
if engine == "ibcmd":
|
|
||||||
if not args.InfoBasePath:
|
|
||||||
print("Error: ibcmd supports file infobases only (use -InfoBasePath)", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
|
|
||||||
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
# --- Validate template ---
|
|
||||||
if args.UseTemplate and not os.path.exists(args.UseTemplate):
|
|
||||||
print(f"Error: template file not found: {args.UseTemplate}", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
# --- ibcmd branch (file infobase only) ---
|
|
||||||
if engine == "ibcmd":
|
|
||||||
arguments = ["infobase", "create", f"--db-path={args.InfoBasePath}", "--create-database"]
|
|
||||||
if args.UseTemplate:
|
|
||||||
if os.path.splitext(args.UseTemplate)[1].lower() == ".dt":
|
|
||||||
arguments.append(f"--restore={args.UseTemplate}")
|
|
||||||
else:
|
|
||||||
arguments.extend([f"--load={args.UseTemplate}", "--apply"])
|
|
||||||
ib_data = tempfile.mkdtemp(prefix="ibcmd_data_")
|
|
||||||
atexit.register(shutil.rmtree, ib_data, ignore_errors=True)
|
|
||||||
arguments.append(f"--data={ib_data}")
|
|
||||||
print(f"Running: ibcmd {' '.join(arguments)}")
|
|
||||||
result = run_ibcmd([v8path] + arguments, warn_no_user=False)
|
|
||||||
if result.returncode == 0:
|
|
||||||
print(f"Information base created successfully: {args.InfoBasePath}")
|
|
||||||
else:
|
|
||||||
print(f"Error creating information base (code: {result.returncode})", file=sys.stderr)
|
|
||||||
if result.stdout:
|
|
||||||
print(result.stdout)
|
|
||||||
if result.stderr:
|
|
||||||
print(result.stderr, file=sys.stderr)
|
|
||||||
sys.exit(result.returncode)
|
|
||||||
|
|
||||||
# --- Temp dir ---
|
|
||||||
temp_dir = os.path.join(tempfile.gettempdir(), f"db_create_{random.randint(0, 999999)}")
|
|
||||||
os.makedirs(temp_dir, exist_ok=True)
|
|
||||||
|
|
||||||
try:
|
|
||||||
# --- Build arguments ---
|
|
||||||
arguments = ["CREATEINFOBASE"]
|
|
||||||
|
|
||||||
if args.InfoBaseServer and args.InfoBaseRef:
|
|
||||||
# No embedded quotes: subprocess quotes the whole token; 1C's argv parser
|
|
||||||
# strips outer quotes. Inner quotes get escaped by list2cmdline and break parsing.
|
|
||||||
arguments.append(f'Srvr={args.InfoBaseServer};Ref={args.InfoBaseRef}')
|
|
||||||
else:
|
|
||||||
arguments.append(f'File={args.InfoBasePath}')
|
|
||||||
|
|
||||||
# --- Template ---
|
|
||||||
if args.UseTemplate:
|
|
||||||
arguments.extend(["/UseTemplate", args.UseTemplate])
|
|
||||||
|
|
||||||
# --- Add to list ---
|
|
||||||
if args.AddToList:
|
|
||||||
if args.ListName:
|
|
||||||
arguments.extend(["/AddToList", args.ListName])
|
|
||||||
else:
|
|
||||||
arguments.append("/AddToList")
|
|
||||||
|
|
||||||
# --- Output ---
|
|
||||||
out_file = os.path.join(temp_dir, "create_log.txt")
|
|
||||||
arguments.extend(["/Out", out_file])
|
|
||||||
arguments.append("/DisableStartupDialogs")
|
|
||||||
|
|
||||||
# --- Execute ---
|
|
||||||
print(f"Running: 1cv8.exe {' '.join(arguments)}")
|
|
||||||
result = subprocess.run(
|
|
||||||
[v8path] + arguments,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
exit_code = result.returncode
|
|
||||||
|
|
||||||
# --- Result ---
|
|
||||||
if exit_code == 0:
|
|
||||||
if args.InfoBaseServer and args.InfoBaseRef:
|
|
||||||
print(f"Information base created successfully: {args.InfoBaseServer}/{args.InfoBaseRef}")
|
|
||||||
else:
|
|
||||||
print(f"Information base created successfully: {args.InfoBasePath}")
|
|
||||||
else:
|
|
||||||
print(f"Error creating information base (code: {exit_code})", file=sys.stderr)
|
|
||||||
|
|
||||||
if os.path.isfile(out_file):
|
|
||||||
try:
|
|
||||||
with open(out_file, "r", encoding="utf-8-sig") as f:
|
|
||||||
log_content = f.read()
|
|
||||||
if log_content:
|
|
||||||
print("--- Log ---")
|
|
||||||
print(log_content)
|
|
||||||
print("--- End ---")
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
sys.exit(exit_code)
|
|
||||||
|
|
||||||
finally:
|
|
||||||
if os.path.isdir(temp_dir):
|
|
||||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,230 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# db-dump-cf v1.6 — Dump 1C configuration to CF file
|
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import atexit
|
|
||||||
import glob
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import random
|
|
||||||
import re
|
|
||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import tempfile
|
|
||||||
|
|
||||||
|
|
||||||
def _find_project_v8path():
|
|
||||||
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
|
||||||
d = os.getcwd()
|
|
||||||
while True:
|
|
||||||
pf = os.path.join(d, ".v8-project.json")
|
|
||||||
if os.path.isfile(pf):
|
|
||||||
try:
|
|
||||||
with open(pf, encoding="utf-8-sig") as f:
|
|
||||||
data = json.load(f)
|
|
||||||
v = data.get("v8path")
|
|
||||||
if v:
|
|
||||||
return v
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return None
|
|
||||||
parent = os.path.dirname(d)
|
|
||||||
if parent == d:
|
|
||||||
return None
|
|
||||||
d = parent
|
|
||||||
|
|
||||||
|
|
||||||
def _version_dir(p):
|
|
||||||
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
|
|
||||||
parent = os.path.dirname(p)
|
|
||||||
if os.path.basename(parent).lower() == "bin":
|
|
||||||
parent = os.path.dirname(parent)
|
|
||||||
return os.path.basename(parent)
|
|
||||||
|
|
||||||
|
|
||||||
def _version_key(p):
|
|
||||||
"""Numeric sort key from version dir name."""
|
|
||||||
return [int(x) for x in re.findall(r"\d+", _version_dir(p))]
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_v8path(v8path):
|
|
||||||
"""Resolve path to a 1C executable (1cv8; ibcmd only when given explicitly)."""
|
|
||||||
if not v8path:
|
|
||||||
v8path = _find_project_v8path()
|
|
||||||
if not v8path:
|
|
||||||
if os.name == "nt":
|
|
||||||
candidates = (
|
|
||||||
glob.glob(r"C:\Program Files\1cv8\*\bin\1cv8.exe")
|
|
||||||
+ glob.glob(r"C:\Program Files (x86)\1cv8\*\bin\1cv8.exe")
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# PY-only: PS-порт на *nix не исполняется, поэтому *nix-раскладки нет в .ps1.
|
|
||||||
candidates = glob.glob("/opt/1cv8/*/1cv8")
|
|
||||||
if candidates:
|
|
||||||
v8path = max(candidates, key=_version_key)
|
|
||||||
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
|
|
||||||
else:
|
|
||||||
print("Error: 1C executable not found. Specify -V8Path", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
if os.path.isdir(v8path):
|
|
||||||
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
|
|
||||||
exe = "1cv8.exe" if os.name == "nt" else "1cv8"
|
|
||||||
v8path = os.path.join(v8path, exe)
|
|
||||||
if not os.path.isfile(v8path):
|
|
||||||
print(f"Error: 1C executable not found at {v8path}", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
return v8path
|
|
||||||
|
|
||||||
|
|
||||||
IBCMD_NOUSER_HINT = (
|
|
||||||
"[ibcmd] No -UserName/-Password given; the infobase may require authentication. "
|
|
||||||
"On Windows ibcmd reads credentials from the console (stdin is ignored), so this "
|
|
||||||
"call may block instead of failing. If it does not return promptly, abort and "
|
|
||||||
"re-run with -UserName and -Password.\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
|
||||||
"""Run an ibcmd command non-interactively.
|
|
||||||
|
|
||||||
input="" closes stdin (EOF) so ibcmd's auth prompt fast-fails instead of hanging.
|
|
||||||
On Windows without -UserName ibcmd reads the console directly and may still block —
|
|
||||||
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
|
|
||||||
"""
|
|
||||||
if warn_no_user and os.name == "nt" and not has_username:
|
|
||||||
sys.stderr.write(IBCMD_NOUSER_HINT)
|
|
||||||
sys.stderr.flush()
|
|
||||||
return subprocess.run(cmd, input="", capture_output=True, encoding="utf-8", errors="replace")
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
sys.stdout.reconfigure(encoding="utf-8")
|
|
||||||
sys.stderr.reconfigure(encoding="utf-8")
|
|
||||||
parser = argparse.ArgumentParser(
|
|
||||||
description="Dump 1C configuration to CF file",
|
|
||||||
allow_abbrev=False,
|
|
||||||
)
|
|
||||||
parser.add_argument("-V8Path", default="")
|
|
||||||
parser.add_argument("-InfoBasePath", default="")
|
|
||||||
parser.add_argument("-InfoBaseServer", default="")
|
|
||||||
parser.add_argument("-InfoBaseRef", default="")
|
|
||||||
parser.add_argument("-UserName", default="")
|
|
||||||
parser.add_argument("-Password", default="")
|
|
||||||
parser.add_argument("-OutputFile", required=True)
|
|
||||||
parser.add_argument("-Extension", default="")
|
|
||||||
parser.add_argument("-AllExtensions", action="store_true")
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
v8path = resolve_v8path(args.V8Path)
|
|
||||||
engine = "ibcmd" if os.path.basename(v8path).lower().startswith("ibcmd") else "1cv8"
|
|
||||||
|
|
||||||
# --- Validate connection ---
|
|
||||||
if engine == "ibcmd":
|
|
||||||
if not args.InfoBasePath:
|
|
||||||
print("Error: ibcmd supports file infobases only (use -InfoBasePath)", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
|
|
||||||
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
# --- Ensure output directory exists ---
|
|
||||||
out_dir = os.path.dirname(args.OutputFile)
|
|
||||||
if out_dir and not os.path.isdir(out_dir):
|
|
||||||
os.makedirs(out_dir, exist_ok=True)
|
|
||||||
|
|
||||||
# --- ibcmd branch (file infobase only) ---
|
|
||||||
if engine == "ibcmd":
|
|
||||||
if args.AllExtensions:
|
|
||||||
print("Error: ibcmd config save does not support -AllExtensions (use -Extension)", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
arguments = ["infobase", "config", "save", f"--db-path={args.InfoBasePath}"]
|
|
||||||
if args.Extension:
|
|
||||||
arguments.append(f"--extension={args.Extension}")
|
|
||||||
arguments.append(args.OutputFile)
|
|
||||||
ib_data = tempfile.mkdtemp(prefix="ibcmd_data_")
|
|
||||||
atexit.register(shutil.rmtree, ib_data, ignore_errors=True)
|
|
||||||
if args.UserName:
|
|
||||||
arguments.append(f"--user={args.UserName}")
|
|
||||||
if args.Password:
|
|
||||||
arguments.append(f"--password={args.Password}")
|
|
||||||
arguments.append(f"--data={ib_data}")
|
|
||||||
print(f"Running: ibcmd {' '.join(arguments)}")
|
|
||||||
result = run_ibcmd([v8path] + arguments, bool(args.UserName))
|
|
||||||
if result.returncode == 0:
|
|
||||||
print(f"Configuration dumped successfully to: {args.OutputFile}")
|
|
||||||
else:
|
|
||||||
print(f"Error dumping configuration (code: {result.returncode})", file=sys.stderr)
|
|
||||||
if result.stdout:
|
|
||||||
print(result.stdout)
|
|
||||||
if result.stderr:
|
|
||||||
print(result.stderr, file=sys.stderr)
|
|
||||||
sys.exit(result.returncode)
|
|
||||||
|
|
||||||
# --- Temp dir ---
|
|
||||||
temp_dir = os.path.join(tempfile.gettempdir(), f"db_dump_cf_{random.randint(0, 999999)}")
|
|
||||||
os.makedirs(temp_dir, exist_ok=True)
|
|
||||||
|
|
||||||
try:
|
|
||||||
# --- Build arguments ---
|
|
||||||
arguments = ["DESIGNER"]
|
|
||||||
|
|
||||||
if args.InfoBaseServer and args.InfoBaseRef:
|
|
||||||
arguments.extend(["/S", f"{args.InfoBaseServer}/{args.InfoBaseRef}"])
|
|
||||||
else:
|
|
||||||
arguments.extend(["/F", args.InfoBasePath])
|
|
||||||
|
|
||||||
if args.UserName:
|
|
||||||
arguments.append(f"/N{args.UserName}")
|
|
||||||
if args.Password:
|
|
||||||
arguments.append(f"/P{args.Password}")
|
|
||||||
|
|
||||||
arguments.extend(["/DumpCfg", args.OutputFile])
|
|
||||||
|
|
||||||
# --- Extensions ---
|
|
||||||
if args.Extension:
|
|
||||||
arguments.extend(["-Extension", args.Extension])
|
|
||||||
elif args.AllExtensions:
|
|
||||||
arguments.append("-AllExtensions")
|
|
||||||
|
|
||||||
# --- Output ---
|
|
||||||
out_file = os.path.join(temp_dir, "dump_cf_log.txt")
|
|
||||||
arguments.extend(["/Out", out_file])
|
|
||||||
arguments.append("/DisableStartupDialogs")
|
|
||||||
|
|
||||||
# --- Execute ---
|
|
||||||
print(f"Running: 1cv8.exe {' '.join(arguments)}")
|
|
||||||
result = subprocess.run(
|
|
||||||
[v8path] + arguments,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
exit_code = result.returncode
|
|
||||||
|
|
||||||
# --- Result ---
|
|
||||||
if exit_code == 0:
|
|
||||||
print(f"Configuration dumped successfully to: {args.OutputFile}")
|
|
||||||
else:
|
|
||||||
print(f"Error dumping configuration (code: {exit_code})", file=sys.stderr)
|
|
||||||
|
|
||||||
if os.path.isfile(out_file):
|
|
||||||
try:
|
|
||||||
with open(out_file, "r", encoding="utf-8-sig") as f:
|
|
||||||
log_content = f.read()
|
|
||||||
if log_content:
|
|
||||||
print("--- Log ---")
|
|
||||||
print(log_content)
|
|
||||||
print("--- End ---")
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
sys.exit(exit_code)
|
|
||||||
|
|
||||||
finally:
|
|
||||||
if os.path.isdir(temp_dir):
|
|
||||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
---
|
|
||||||
name: db-dump-dt
|
|
||||||
description: Выгрузка информационной базы 1С в DT-файл (вся база — конфигурация + данные). Используй когда нужно выгрузить информационную базу, выгрузить архив базы, сделать бэкап, выгрузить dt
|
|
||||||
argument-hint: "[database] [output.dt]"
|
|
||||||
allowed-tools:
|
|
||||||
- Bash
|
|
||||||
- Read
|
|
||||||
- Glob
|
|
||||||
- AskUserQuestion
|
|
||||||
---
|
|
||||||
|
|
||||||
# /db-dump-dt — Выгрузка информационной базы в DT-файл
|
|
||||||
|
|
||||||
Выгружает информационную базу целиком (конфигурация **+ данные**) в DT-файл — полный снимок ИБ.
|
|
||||||
|
|
||||||
> В отличие от `/db-dump-cf` (только конфигурация), `.dt` содержит **всю базу**: данные,
|
|
||||||
> настройки, пользователей. Это бэкап/точка отката, а не выгрузка метаданных.
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
```
|
|
||||||
/db-dump-dt [database] [output.dt]
|
|
||||||
/db-dump-dt dev backup.dt
|
|
||||||
/db-dump-dt — база по умолчанию, имя файла по базе и дате
|
|
||||||
```
|
|
||||||
|
|
||||||
## Параметры подключения
|
|
||||||
|
|
||||||
Прочитай `.v8-project.json` из корня проекта. Возьми `v8path` (путь к платформе) и разреши базу:
|
|
||||||
1. Если пользователь указал параметры подключения (путь, сервер) — используй напрямую
|
|
||||||
2. Если указал базу по имени — ищи по id / alias / name в `.v8-project.json`
|
|
||||||
3. Если не указал — сопоставь текущую ветку Git с `databases[].branches`
|
|
||||||
4. Если ветка не совпала — используй `default`
|
|
||||||
Если `v8path` не задан — скрипт сам попытается определить платформу (`.v8-project.json` → Program Files).
|
|
||||||
Если файла нет — предложи `/db-list add`.
|
|
||||||
Если использованная база не зарегистрирована — после выполнения предложи добавить через `/db-list add`.
|
|
||||||
|
|
||||||
## Команда
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
python ".augment/skills/db-dump-dt/scripts/db-dump-dt.py" <параметры>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Параметры скрипта
|
|
||||||
|
|
||||||
| Параметр | Обязательный | Описание |
|
|
||||||
|----------|:------------:|----------|
|
|
||||||
| `-V8Path <путь>` | нет | Каталог bin платформы, или полный путь к `1cv8.exe` / `ibcmd.exe` |
|
|
||||||
| `-InfoBasePath <путь>` | * | Файловая база |
|
|
||||||
| `-InfoBaseServer <сервер>` | * | Сервер 1С (для серверной базы) |
|
|
||||||
| `-InfoBaseRef <имя>` | * | Имя базы на сервере |
|
|
||||||
| `-UserName <имя>` | нет | Имя пользователя |
|
|
||||||
| `-Password <пароль>` | нет | Пароль |
|
|
||||||
| `-OutputFile <путь>` | да | Путь к выходному DT-файлу |
|
|
||||||
|
|
||||||
> `*` — нужен либо `-InfoBasePath`, либо пара `-InfoBaseServer` + `-InfoBaseRef`
|
|
||||||
|
|
||||||
## Примеры
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
# Выгрузка ИБ (файловая база)
|
|
||||||
python ".augment/skills/db-dump-dt/scripts/db-dump-dt.py" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin" -OutputFile "C:\backup\base.dt"
|
|
||||||
|
|
||||||
# Серверная база
|
|
||||||
python ".augment/skills/db-dump-dt/scripts/db-dump-dt.py" -InfoBaseServer "srv01" -InfoBaseRef "MyApp_Dev" -UserName "Admin" -Password "secret" -OutputFile "base.dt"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Связанные навыки
|
|
||||||
|
|
||||||
- `/db-load-dt` — загрузка ИБ из DT (обратная операция)
|
|
||||||
- `/db-dump-cf` — выгрузка только конфигурации (без данных)
|
|
||||||
- `/db-create` — создать новую базу (в т.ч. из DT-шаблона)
|
|
||||||
@@ -1,226 +0,0 @@
|
|||||||
# db-dump-dt v1.5 — Dump 1C information base to DT file
|
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
|
||||||
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
|
||||||
<#
|
|
||||||
.SYNOPSIS
|
|
||||||
Выгрузка информационной базы 1С в DT-файл
|
|
||||||
|
|
||||||
.DESCRIPTION
|
|
||||||
Выгружает информационную базу целиком (конфигурация + данные) в DT-файл.
|
|
||||||
|
|
||||||
.PARAMETER V8Path
|
|
||||||
Путь к каталогу bin платформы или к 1cv8.exe
|
|
||||||
|
|
||||||
.PARAMETER InfoBasePath
|
|
||||||
Путь к файловой информационной базе
|
|
||||||
|
|
||||||
.PARAMETER InfoBaseServer
|
|
||||||
Сервер 1С (для серверной базы)
|
|
||||||
|
|
||||||
.PARAMETER InfoBaseRef
|
|
||||||
Имя базы на сервере
|
|
||||||
|
|
||||||
.PARAMETER UserName
|
|
||||||
Имя пользователя 1С
|
|
||||||
|
|
||||||
.PARAMETER Password
|
|
||||||
Пароль пользователя
|
|
||||||
|
|
||||||
.PARAMETER OutputFile
|
|
||||||
Путь к выходному DT-файлу
|
|
||||||
|
|
||||||
.EXAMPLE
|
|
||||||
.\db-dump-dt.ps1 -InfoBasePath "C:\Bases\MyDB" -OutputFile "backup.dt"
|
|
||||||
#>
|
|
||||||
|
|
||||||
[CmdletBinding()]
|
|
||||||
param(
|
|
||||||
[Parameter(Mandatory=$false)]
|
|
||||||
[string]$V8Path,
|
|
||||||
|
|
||||||
[Parameter(Mandatory=$false)]
|
|
||||||
[string]$InfoBasePath,
|
|
||||||
|
|
||||||
[Parameter(Mandatory=$false)]
|
|
||||||
[string]$InfoBaseServer,
|
|
||||||
|
|
||||||
[Parameter(Mandatory=$false)]
|
|
||||||
[string]$InfoBaseRef,
|
|
||||||
|
|
||||||
[Parameter(Mandatory=$false)]
|
|
||||||
[string]$UserName,
|
|
||||||
|
|
||||||
[Parameter(Mandatory=$false)]
|
|
||||||
[string]$Password,
|
|
||||||
|
|
||||||
[Parameter(Mandatory=$true)]
|
|
||||||
[string]$OutputFile
|
|
||||||
)
|
|
||||||
|
|
||||||
$OutputEncoding = [System.Text.Encoding]::UTF8
|
|
||||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
|
||||||
|
|
||||||
# --- Resolve V8Path ---
|
|
||||||
function Find-ProjectV8Path {
|
|
||||||
$dir = (Get-Location).Path
|
|
||||||
while ($dir) {
|
|
||||||
$pf = Join-Path $dir ".v8-project.json"
|
|
||||||
if (Test-Path $pf) {
|
|
||||||
try {
|
|
||||||
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
|
|
||||||
if ($j.v8path) { return [string]$j.v8path }
|
|
||||||
} catch {}
|
|
||||||
return $null
|
|
||||||
}
|
|
||||||
$parent = Split-Path $dir -Parent
|
|
||||||
if (-not $parent -or $parent -eq $dir) { break }
|
|
||||||
$dir = $parent
|
|
||||||
}
|
|
||||||
return $null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (-not $V8Path) {
|
|
||||||
$V8Path = Find-ProjectV8Path
|
|
||||||
}
|
|
||||||
if (-not $V8Path) {
|
|
||||||
$found = Get-ChildItem @("C:\Program Files\1cv8\*\bin\1cv8.exe", "C:\Program Files (x86)\1cv8\*\bin\1cv8.exe") -ErrorAction SilentlyContinue |
|
|
||||||
Sort-Object { try { [version]$_.Directory.Parent.Name } catch { [version]"0.0" } } -Descending |
|
|
||||||
Select-Object -First 1
|
|
||||||
if ($found) {
|
|
||||||
$V8Path = $found.FullName
|
|
||||||
Write-Host "Auto-selected platform $($found.Directory.Parent.Name): $V8Path" -ForegroundColor Yellow
|
|
||||||
} else {
|
|
||||||
Write-Host "Error: 1C executable not found. Specify -V8Path" -ForegroundColor Red
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (Test-Path $V8Path -PathType Container) {
|
|
||||||
$V8Path = Join-Path $V8Path "1cv8.exe"
|
|
||||||
}
|
|
||||||
|
|
||||||
if (-not (Test-Path $V8Path)) {
|
|
||||||
Write-Host "Error: 1C executable not found at $V8Path" -ForegroundColor Red
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- Detect engine (ibcmd vs 1cv8) by exe name ---
|
|
||||||
function Invoke-IbcmdProcess {
|
|
||||||
# Run ibcmd non-interactively: a closed stdin pipe (EOF) makes ibcmd's auth prompt
|
|
||||||
# fast-fail instead of hanging. Returns @{ Output; ExitCode }. cp866 decodes ibcmd's
|
|
||||||
# native OEM output. The 1cv8/DESIGNER branch keeps using Start-Process.
|
|
||||||
param([string]$Exe, [string[]]$IbArgs)
|
|
||||||
$psi = New-Object System.Diagnostics.ProcessStartInfo
|
|
||||||
$psi.FileName = $Exe
|
|
||||||
$psi.Arguments = ($IbArgs | ForEach-Object { if ($_ -match '[\s"]') { '"' + ($_ -replace '"', '\"') + '"' } else { $_ } }) -join ' '
|
|
||||||
$psi.UseShellExecute = $false
|
|
||||||
$psi.CreateNoWindow = $true
|
|
||||||
$psi.RedirectStandardInput = $true
|
|
||||||
$psi.RedirectStandardOutput = $true
|
|
||||||
$psi.RedirectStandardError = $true
|
|
||||||
try {
|
|
||||||
$psi.StandardOutputEncoding = [System.Text.Encoding]::GetEncoding(866)
|
|
||||||
$psi.StandardErrorEncoding = [System.Text.Encoding]::GetEncoding(866)
|
|
||||||
} catch {}
|
|
||||||
$p = [System.Diagnostics.Process]::Start($psi)
|
|
||||||
$p.StandardInput.Close()
|
|
||||||
$out = $p.StandardOutput.ReadToEnd()
|
|
||||||
$err = $p.StandardError.ReadToEnd()
|
|
||||||
$p.WaitForExit()
|
|
||||||
if ($err) { $out += $err }
|
|
||||||
return [pscustomobject]@{ Output = $out; ExitCode = $p.ExitCode }
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
$engine = if ((Split-Path $V8Path -Leaf) -match '^ibcmd') { "ibcmd" } else { "1cv8" }
|
|
||||||
|
|
||||||
# --- Validate connection ---
|
|
||||||
if ($engine -eq "ibcmd") {
|
|
||||||
if (-not $InfoBasePath) {
|
|
||||||
Write-Host "Error: ibcmd supports file infobases only (use -InfoBasePath)" -ForegroundColor Red
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
} elseif (-not $InfoBasePath -and (-not $InfoBaseServer -or -not $InfoBaseRef)) {
|
|
||||||
Write-Host "Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef" -ForegroundColor Red
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- Ensure output directory exists ---
|
|
||||||
$outDir = Split-Path $OutputFile -Parent
|
|
||||||
if ($outDir -and -not (Test-Path $outDir)) {
|
|
||||||
New-Item -ItemType Directory -Path $outDir -Force | Out-Null
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- Temp dir ---
|
|
||||||
$tempDir = Join-Path $env:TEMP "db_dump_dt_$(Get-Random)"
|
|
||||||
New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
|
|
||||||
|
|
||||||
try {
|
|
||||||
if ($engine -eq "ibcmd") {
|
|
||||||
# --- ibcmd branch (file infobase only) ---
|
|
||||||
$arguments = @("infobase", "dump", "--db-path=$InfoBasePath")
|
|
||||||
if ($UserName) { $arguments += "--user=$UserName" }
|
|
||||||
if ($Password) { $arguments += "--password=$Password" }
|
|
||||||
$arguments += "$OutputFile"
|
|
||||||
|
|
||||||
$arguments += "--data=$tempDir"
|
|
||||||
Write-Host "Running: ibcmd $($arguments -join ' ')"
|
|
||||||
$__ib = Invoke-IbcmdProcess $V8Path $arguments
|
|
||||||
$output = $__ib.Output
|
|
||||||
$exitCode = $__ib.ExitCode
|
|
||||||
if ($exitCode -eq 0) {
|
|
||||||
Write-Host "Information base dumped successfully to: $OutputFile" -ForegroundColor Green
|
|
||||||
} else {
|
|
||||||
Write-Host "Error dumping information base (code: $exitCode)" -ForegroundColor Red
|
|
||||||
}
|
|
||||||
if ($output) { Write-Host ($output | Out-String) }
|
|
||||||
exit $exitCode
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- 1cv8 branch ---
|
|
||||||
# --- Build arguments ---
|
|
||||||
$arguments = @("DESIGNER")
|
|
||||||
|
|
||||||
if ($InfoBaseServer -and $InfoBaseRef) {
|
|
||||||
$arguments += "/S", "`"$InfoBaseServer/$InfoBaseRef`""
|
|
||||||
} else {
|
|
||||||
$arguments += "/F", "`"$InfoBasePath`""
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($UserName) { $arguments += "/N`"$UserName`"" }
|
|
||||||
if ($Password) { $arguments += "/P`"$Password`"" }
|
|
||||||
|
|
||||||
$arguments += "/DumpIB", "`"$OutputFile`""
|
|
||||||
|
|
||||||
# --- Output ---
|
|
||||||
$outFile = Join-Path $tempDir "dump_dt_log.txt"
|
|
||||||
$arguments += "/Out", "`"$outFile`""
|
|
||||||
$arguments += "/DisableStartupDialogs"
|
|
||||||
|
|
||||||
# --- Execute ---
|
|
||||||
Write-Host "Running: 1cv8.exe $($arguments -join ' ')"
|
|
||||||
$process = Start-Process -FilePath $V8Path -ArgumentList $arguments -NoNewWindow -Wait -PassThru
|
|
||||||
$exitCode = $process.ExitCode
|
|
||||||
|
|
||||||
# --- Result ---
|
|
||||||
if ($exitCode -eq 0) {
|
|
||||||
Write-Host "Information base dumped successfully to: $OutputFile" -ForegroundColor Green
|
|
||||||
} else {
|
|
||||||
Write-Host "Error dumping information base (code: $exitCode)" -ForegroundColor Red
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Test-Path $outFile) {
|
|
||||||
$logContent = Get-Content $outFile -Raw -ErrorAction SilentlyContinue
|
|
||||||
if ($logContent) {
|
|
||||||
Write-Host "--- Log ---"
|
|
||||||
Write-Host $logContent
|
|
||||||
Write-Host "--- End ---"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
exit $exitCode
|
|
||||||
|
|
||||||
} finally {
|
|
||||||
if (Test-Path $tempDir) {
|
|
||||||
Remove-Item -Path $tempDir -Recurse -Force -ErrorAction SilentlyContinue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,217 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# db-dump-dt v1.5 — Dump 1C information base to DT file
|
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import atexit
|
|
||||||
import glob
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import random
|
|
||||||
import re
|
|
||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import tempfile
|
|
||||||
|
|
||||||
|
|
||||||
def _find_project_v8path():
|
|
||||||
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
|
||||||
d = os.getcwd()
|
|
||||||
while True:
|
|
||||||
pf = os.path.join(d, ".v8-project.json")
|
|
||||||
if os.path.isfile(pf):
|
|
||||||
try:
|
|
||||||
with open(pf, encoding="utf-8-sig") as f:
|
|
||||||
data = json.load(f)
|
|
||||||
v = data.get("v8path")
|
|
||||||
if v:
|
|
||||||
return v
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return None
|
|
||||||
parent = os.path.dirname(d)
|
|
||||||
if parent == d:
|
|
||||||
return None
|
|
||||||
d = parent
|
|
||||||
|
|
||||||
|
|
||||||
def _version_dir(p):
|
|
||||||
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
|
|
||||||
parent = os.path.dirname(p)
|
|
||||||
if os.path.basename(parent).lower() == "bin":
|
|
||||||
parent = os.path.dirname(parent)
|
|
||||||
return os.path.basename(parent)
|
|
||||||
|
|
||||||
|
|
||||||
def _version_key(p):
|
|
||||||
"""Numeric sort key from version dir name."""
|
|
||||||
return [int(x) for x in re.findall(r"\d+", _version_dir(p))]
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_v8path(v8path):
|
|
||||||
"""Resolve path to a 1C executable (1cv8; ibcmd only when given explicitly)."""
|
|
||||||
if not v8path:
|
|
||||||
v8path = _find_project_v8path()
|
|
||||||
if not v8path:
|
|
||||||
if os.name == "nt":
|
|
||||||
candidates = (
|
|
||||||
glob.glob(r"C:\Program Files\1cv8\*\bin\1cv8.exe")
|
|
||||||
+ glob.glob(r"C:\Program Files (x86)\1cv8\*\bin\1cv8.exe")
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# PY-only: PS-порт на *nix не исполняется, поэтому *nix-раскладки нет в .ps1.
|
|
||||||
candidates = glob.glob("/opt/1cv8/*/1cv8")
|
|
||||||
if candidates:
|
|
||||||
v8path = max(candidates, key=_version_key)
|
|
||||||
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
|
|
||||||
else:
|
|
||||||
print("Error: 1C executable not found. Specify -V8Path", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
if os.path.isdir(v8path):
|
|
||||||
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
|
|
||||||
exe = "1cv8.exe" if os.name == "nt" else "1cv8"
|
|
||||||
v8path = os.path.join(v8path, exe)
|
|
||||||
if not os.path.isfile(v8path):
|
|
||||||
print(f"Error: 1C executable not found at {v8path}", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
return v8path
|
|
||||||
|
|
||||||
|
|
||||||
IBCMD_NOUSER_HINT = (
|
|
||||||
"[ibcmd] No -UserName/-Password given; the infobase may require authentication. "
|
|
||||||
"On Windows ibcmd reads credentials from the console (stdin is ignored), so this "
|
|
||||||
"call may block instead of failing. If it does not return promptly, abort and "
|
|
||||||
"re-run with -UserName and -Password.\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
|
||||||
"""Run an ibcmd command non-interactively.
|
|
||||||
|
|
||||||
input="" closes stdin (EOF) so ibcmd's auth prompt fast-fails instead of hanging.
|
|
||||||
On Windows without -UserName ibcmd reads the console directly and may still block —
|
|
||||||
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
|
|
||||||
"""
|
|
||||||
if warn_no_user and os.name == "nt" and not has_username:
|
|
||||||
sys.stderr.write(IBCMD_NOUSER_HINT)
|
|
||||||
sys.stderr.flush()
|
|
||||||
return subprocess.run(cmd, input="", capture_output=True, encoding="utf-8", errors="replace")
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
sys.stdout.reconfigure(encoding="utf-8")
|
|
||||||
sys.stderr.reconfigure(encoding="utf-8")
|
|
||||||
parser = argparse.ArgumentParser(
|
|
||||||
description="Dump 1C information base to DT file",
|
|
||||||
allow_abbrev=False,
|
|
||||||
)
|
|
||||||
parser.add_argument("-V8Path", default="")
|
|
||||||
parser.add_argument("-InfoBasePath", default="")
|
|
||||||
parser.add_argument("-InfoBaseServer", default="")
|
|
||||||
parser.add_argument("-InfoBaseRef", default="")
|
|
||||||
parser.add_argument("-UserName", default="")
|
|
||||||
parser.add_argument("-Password", default="")
|
|
||||||
parser.add_argument("-OutputFile", required=True)
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
v8path = resolve_v8path(args.V8Path)
|
|
||||||
engine = "ibcmd" if os.path.basename(v8path).lower().startswith("ibcmd") else "1cv8"
|
|
||||||
|
|
||||||
# --- Validate connection ---
|
|
||||||
if engine == "ibcmd":
|
|
||||||
if not args.InfoBasePath:
|
|
||||||
print("Error: ibcmd supports file infobases only (use -InfoBasePath)", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
|
|
||||||
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
# --- Ensure output directory exists ---
|
|
||||||
out_dir = os.path.dirname(args.OutputFile)
|
|
||||||
if out_dir and not os.path.isdir(out_dir):
|
|
||||||
os.makedirs(out_dir, exist_ok=True)
|
|
||||||
|
|
||||||
# --- ibcmd branch (file infobase only) ---
|
|
||||||
if engine == "ibcmd":
|
|
||||||
arguments = ["infobase", "dump", f"--db-path={args.InfoBasePath}"]
|
|
||||||
if args.UserName:
|
|
||||||
arguments.append(f"--user={args.UserName}")
|
|
||||||
if args.Password:
|
|
||||||
arguments.append(f"--password={args.Password}")
|
|
||||||
arguments.append(args.OutputFile)
|
|
||||||
ib_data = tempfile.mkdtemp(prefix="ibcmd_data_")
|
|
||||||
atexit.register(shutil.rmtree, ib_data, ignore_errors=True)
|
|
||||||
arguments.append(f"--data={ib_data}")
|
|
||||||
print(f"Running: ibcmd {' '.join(arguments)}")
|
|
||||||
result = run_ibcmd([v8path] + arguments, bool(args.UserName))
|
|
||||||
if result.returncode == 0:
|
|
||||||
print(f"Information base dumped successfully to: {args.OutputFile}")
|
|
||||||
else:
|
|
||||||
print(f"Error dumping information base (code: {result.returncode})", file=sys.stderr)
|
|
||||||
if result.stdout:
|
|
||||||
print(result.stdout)
|
|
||||||
if result.stderr:
|
|
||||||
print(result.stderr, file=sys.stderr)
|
|
||||||
sys.exit(result.returncode)
|
|
||||||
|
|
||||||
# --- Temp dir ---
|
|
||||||
temp_dir = os.path.join(tempfile.gettempdir(), f"db_dump_dt_{random.randint(0, 999999)}")
|
|
||||||
os.makedirs(temp_dir, exist_ok=True)
|
|
||||||
|
|
||||||
try:
|
|
||||||
# --- Build arguments ---
|
|
||||||
arguments = ["DESIGNER"]
|
|
||||||
|
|
||||||
if args.InfoBaseServer and args.InfoBaseRef:
|
|
||||||
arguments.extend(["/S", f"{args.InfoBaseServer}/{args.InfoBaseRef}"])
|
|
||||||
else:
|
|
||||||
arguments.extend(["/F", args.InfoBasePath])
|
|
||||||
|
|
||||||
if args.UserName:
|
|
||||||
arguments.append(f"/N{args.UserName}")
|
|
||||||
if args.Password:
|
|
||||||
arguments.append(f"/P{args.Password}")
|
|
||||||
|
|
||||||
arguments.extend(["/DumpIB", args.OutputFile])
|
|
||||||
|
|
||||||
# --- Output ---
|
|
||||||
out_file = os.path.join(temp_dir, "dump_dt_log.txt")
|
|
||||||
arguments.extend(["/Out", out_file])
|
|
||||||
arguments.append("/DisableStartupDialogs")
|
|
||||||
|
|
||||||
# --- Execute ---
|
|
||||||
print(f"Running: 1cv8.exe {' '.join(arguments)}")
|
|
||||||
result = subprocess.run(
|
|
||||||
[v8path] + arguments,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
exit_code = result.returncode
|
|
||||||
|
|
||||||
# --- Result ---
|
|
||||||
if exit_code == 0:
|
|
||||||
print(f"Information base dumped successfully to: {args.OutputFile}")
|
|
||||||
else:
|
|
||||||
print(f"Error dumping information base (code: {exit_code})", file=sys.stderr)
|
|
||||||
|
|
||||||
if os.path.isfile(out_file):
|
|
||||||
try:
|
|
||||||
with open(out_file, "r", encoding="utf-8-sig") as f:
|
|
||||||
log_content = f.read()
|
|
||||||
if log_content:
|
|
||||||
print("--- Log ---")
|
|
||||||
print(log_content)
|
|
||||||
print("--- End ---")
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
sys.exit(exit_code)
|
|
||||||
|
|
||||||
finally:
|
|
||||||
if os.path.isdir(temp_dir):
|
|
||||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,285 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# db-dump-xml v1.8 — Dump 1C configuration to XML files
|
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import atexit
|
|
||||||
import glob
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import random
|
|
||||||
import re
|
|
||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import tempfile
|
|
||||||
|
|
||||||
|
|
||||||
def _find_project_v8path():
|
|
||||||
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
|
||||||
d = os.getcwd()
|
|
||||||
while True:
|
|
||||||
pf = os.path.join(d, ".v8-project.json")
|
|
||||||
if os.path.isfile(pf):
|
|
||||||
try:
|
|
||||||
with open(pf, encoding="utf-8-sig") as f:
|
|
||||||
data = json.load(f)
|
|
||||||
v = data.get("v8path")
|
|
||||||
if v:
|
|
||||||
return v
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return None
|
|
||||||
parent = os.path.dirname(d)
|
|
||||||
if parent == d:
|
|
||||||
return None
|
|
||||||
d = parent
|
|
||||||
|
|
||||||
|
|
||||||
def _version_dir(p):
|
|
||||||
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
|
|
||||||
parent = os.path.dirname(p)
|
|
||||||
if os.path.basename(parent).lower() == "bin":
|
|
||||||
parent = os.path.dirname(parent)
|
|
||||||
return os.path.basename(parent)
|
|
||||||
|
|
||||||
|
|
||||||
def _version_key(p):
|
|
||||||
"""Numeric sort key from version dir name."""
|
|
||||||
return [int(x) for x in re.findall(r"\d+", _version_dir(p))]
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_v8path(v8path):
|
|
||||||
"""Resolve path to a 1C executable (1cv8; ibcmd only when given explicitly)."""
|
|
||||||
if not v8path:
|
|
||||||
v8path = _find_project_v8path()
|
|
||||||
if not v8path:
|
|
||||||
if os.name == "nt":
|
|
||||||
candidates = (
|
|
||||||
glob.glob(r"C:\Program Files\1cv8\*\bin\1cv8.exe")
|
|
||||||
+ glob.glob(r"C:\Program Files (x86)\1cv8\*\bin\1cv8.exe")
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# PY-only: PS-порт на *nix не исполняется, поэтому *nix-раскладки нет в .ps1.
|
|
||||||
candidates = glob.glob("/opt/1cv8/*/1cv8")
|
|
||||||
if candidates:
|
|
||||||
v8path = max(candidates, key=_version_key)
|
|
||||||
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
|
|
||||||
else:
|
|
||||||
print("Error: 1C executable not found. Specify -V8Path", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
if os.path.isdir(v8path):
|
|
||||||
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
|
|
||||||
exe = "1cv8.exe" if os.name == "nt" else "1cv8"
|
|
||||||
v8path = os.path.join(v8path, exe)
|
|
||||||
if not os.path.isfile(v8path):
|
|
||||||
print(f"Error: 1C executable not found at {v8path}", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
return v8path
|
|
||||||
|
|
||||||
|
|
||||||
IBCMD_NOUSER_HINT = (
|
|
||||||
"[ibcmd] No -UserName/-Password given; the infobase may require authentication. "
|
|
||||||
"On Windows ibcmd reads credentials from the console (stdin is ignored), so this "
|
|
||||||
"call may block instead of failing. If it does not return promptly, abort and "
|
|
||||||
"re-run with -UserName and -Password.\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
|
||||||
"""Run an ibcmd command non-interactively.
|
|
||||||
|
|
||||||
input="" closes stdin (EOF) so ibcmd's auth prompt fast-fails instead of hanging.
|
|
||||||
On Windows without -UserName ibcmd reads the console directly and may still block —
|
|
||||||
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
|
|
||||||
"""
|
|
||||||
if warn_no_user and os.name == "nt" and not has_username:
|
|
||||||
sys.stderr.write(IBCMD_NOUSER_HINT)
|
|
||||||
sys.stderr.flush()
|
|
||||||
return subprocess.run(cmd, input="", capture_output=True, encoding="utf-8", errors="replace")
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
sys.stdout.reconfigure(encoding="utf-8")
|
|
||||||
sys.stderr.reconfigure(encoding="utf-8")
|
|
||||||
parser = argparse.ArgumentParser(
|
|
||||||
description="Dump 1C configuration to XML files",
|
|
||||||
allow_abbrev=False,
|
|
||||||
)
|
|
||||||
parser.add_argument("-V8Path", default="", help="Path to 1cv8.exe or its bin directory")
|
|
||||||
parser.add_argument("-InfoBasePath", default="", help="Path to file infobase")
|
|
||||||
parser.add_argument("-InfoBaseServer", default="", help="1C server (for server infobase)")
|
|
||||||
parser.add_argument("-InfoBaseRef", default="", help="Infobase name on server")
|
|
||||||
parser.add_argument("-UserName", default="", help="1C user name")
|
|
||||||
parser.add_argument("-Password", default="", help="1C user password")
|
|
||||||
parser.add_argument("-ConfigDir", required=True, help="Directory for configuration dump")
|
|
||||||
parser.add_argument(
|
|
||||||
"-Mode",
|
|
||||||
default="Changes",
|
|
||||||
choices=["Full", "Changes", "Partial", "UpdateInfo"],
|
|
||||||
help="Dump mode (default: Changes)",
|
|
||||||
)
|
|
||||||
parser.add_argument("-Objects", default="", help="Comma-separated metadata object names (for Partial mode)")
|
|
||||||
parser.add_argument("-Extension", default="", help="Extension name to dump")
|
|
||||||
parser.add_argument("-AllExtensions", action="store_true", help="Dump all extensions")
|
|
||||||
parser.add_argument(
|
|
||||||
"-Format",
|
|
||||||
default="Hierarchical",
|
|
||||||
choices=["Hierarchical", "Plain"],
|
|
||||||
help="Dump format (default: Hierarchical)",
|
|
||||||
)
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
# --- Resolve V8Path ---
|
|
||||||
v8path = resolve_v8path(args.V8Path)
|
|
||||||
engine = "ibcmd" if os.path.basename(v8path).lower().startswith("ibcmd") else "1cv8"
|
|
||||||
|
|
||||||
# --- Validate connection ---
|
|
||||||
if engine == "ibcmd":
|
|
||||||
if not args.InfoBasePath:
|
|
||||||
print("Error: ibcmd supports file infobases only (use -InfoBasePath)", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
|
|
||||||
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
# --- Validate Partial mode ---
|
|
||||||
if args.Mode == "Partial" and not args.Objects:
|
|
||||||
print("Error: -Objects required for Partial mode", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
# --- Create output dir if needed ---
|
|
||||||
if not os.path.exists(args.ConfigDir):
|
|
||||||
os.makedirs(args.ConfigDir, exist_ok=True)
|
|
||||||
print(f"Created output directory: {args.ConfigDir}")
|
|
||||||
|
|
||||||
# --- ibcmd branch (file infobase only; hierarchical Full/Changes) ---
|
|
||||||
if engine == "ibcmd":
|
|
||||||
if args.Format == "Plain":
|
|
||||||
print("Error: ibcmd config export supports hierarchical format only (use -Format Hierarchical or 1cv8)", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
if args.AllExtensions:
|
|
||||||
arguments = ["infobase", "config", "export", "all-extensions", args.ConfigDir, f"--db-path={args.InfoBasePath}"]
|
|
||||||
elif args.Mode == "UpdateInfo":
|
|
||||||
print("Error: ibcmd config export does not support Mode UpdateInfo; use 1cv8", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
elif args.Mode == "Partial":
|
|
||||||
obj_list = [o.strip() for o in args.Objects.split(",") if o.strip()]
|
|
||||||
arguments = ["infobase", "config", "export", "objects"] + obj_list
|
|
||||||
arguments += [f"--out={args.ConfigDir}", f"--db-path={args.InfoBasePath}"]
|
|
||||||
if args.Extension:
|
|
||||||
arguments.append(f"--extension={args.Extension}")
|
|
||||||
else:
|
|
||||||
arguments = ["infobase", "config", "export", f"--db-path={args.InfoBasePath}"]
|
|
||||||
if args.Extension:
|
|
||||||
arguments.append(f"--extension={args.Extension}")
|
|
||||||
arguments.append(args.ConfigDir)
|
|
||||||
ib_data = tempfile.mkdtemp(prefix="ibcmd_data_")
|
|
||||||
atexit.register(shutil.rmtree, ib_data, ignore_errors=True)
|
|
||||||
if args.UserName:
|
|
||||||
arguments.append(f"--user={args.UserName}")
|
|
||||||
if args.Password:
|
|
||||||
arguments.append(f"--password={args.Password}")
|
|
||||||
arguments.append(f"--data={ib_data}")
|
|
||||||
print(f"Running: ibcmd {' '.join(arguments)}")
|
|
||||||
result = run_ibcmd([v8path] + arguments, bool(args.UserName))
|
|
||||||
if result.returncode == 0:
|
|
||||||
print(f"Configuration exported successfully to: {args.ConfigDir}")
|
|
||||||
else:
|
|
||||||
print(f"Error exporting configuration (code: {result.returncode})", file=sys.stderr)
|
|
||||||
if result.stdout:
|
|
||||||
print(result.stdout)
|
|
||||||
if result.stderr:
|
|
||||||
print(result.stderr, file=sys.stderr)
|
|
||||||
sys.exit(result.returncode)
|
|
||||||
|
|
||||||
# --- Temp dir ---
|
|
||||||
temp_dir = os.path.join(tempfile.gettempdir(), f"db_dump_xml_{random.randint(0, 999999)}")
|
|
||||||
os.makedirs(temp_dir, exist_ok=True)
|
|
||||||
|
|
||||||
try:
|
|
||||||
# --- Build arguments ---
|
|
||||||
arguments = ["DESIGNER"]
|
|
||||||
|
|
||||||
if args.InfoBaseServer and args.InfoBaseRef:
|
|
||||||
arguments += ["/S", f"{args.InfoBaseServer}/{args.InfoBaseRef}"]
|
|
||||||
else:
|
|
||||||
arguments += ["/F", args.InfoBasePath]
|
|
||||||
|
|
||||||
if args.UserName:
|
|
||||||
arguments.append(f"/N{args.UserName}")
|
|
||||||
if args.Password:
|
|
||||||
arguments.append(f"/P{args.Password}")
|
|
||||||
|
|
||||||
arguments += ["/DumpConfigToFiles", args.ConfigDir]
|
|
||||||
arguments += ["-Format", args.Format]
|
|
||||||
|
|
||||||
if args.Mode == "Full":
|
|
||||||
print("Executing full configuration dump...")
|
|
||||||
elif args.Mode == "Changes":
|
|
||||||
print("Executing incremental configuration dump...")
|
|
||||||
arguments.append("-update")
|
|
||||||
arguments.append("-force")
|
|
||||||
elif args.Mode == "Partial":
|
|
||||||
print("Executing partial configuration dump...")
|
|
||||||
object_list = [obj.strip() for obj in args.Objects.split(",") if obj.strip()]
|
|
||||||
|
|
||||||
list_file = os.path.join(temp_dir, "dump_list.txt")
|
|
||||||
with open(list_file, "w", encoding="utf-8-sig") as f:
|
|
||||||
f.write("\n".join(object_list))
|
|
||||||
|
|
||||||
arguments += ["-listFile", list_file]
|
|
||||||
print(f"Objects to dump: {len(object_list)}")
|
|
||||||
for obj in object_list:
|
|
||||||
print(f" {obj}")
|
|
||||||
elif args.Mode == "UpdateInfo":
|
|
||||||
print("Updating ConfigDumpInfo.xml...")
|
|
||||||
arguments.append("-configDumpInfoOnly")
|
|
||||||
|
|
||||||
# --- Extensions ---
|
|
||||||
if args.Extension:
|
|
||||||
arguments += ["-Extension", args.Extension]
|
|
||||||
elif args.AllExtensions:
|
|
||||||
arguments.append("-AllExtensions")
|
|
||||||
|
|
||||||
# --- Output ---
|
|
||||||
out_file = os.path.join(temp_dir, "dump_log.txt")
|
|
||||||
arguments += ["/Out", out_file]
|
|
||||||
arguments.append("/DisableStartupDialogs")
|
|
||||||
|
|
||||||
# --- Execute ---
|
|
||||||
print(f"Running: 1cv8.exe {' '.join(arguments)}")
|
|
||||||
result = subprocess.run(
|
|
||||||
[v8path] + arguments,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
exit_code = result.returncode
|
|
||||||
|
|
||||||
# --- Result ---
|
|
||||||
if exit_code == 0:
|
|
||||||
print("Dump completed successfully")
|
|
||||||
print(f"Configuration dumped to: {args.ConfigDir}")
|
|
||||||
else:
|
|
||||||
print(f"Error dumping configuration (code: {exit_code})", file=sys.stderr)
|
|
||||||
|
|
||||||
if os.path.isfile(out_file):
|
|
||||||
try:
|
|
||||||
with open(out_file, "r", encoding="utf-8-sig") as f:
|
|
||||||
log_content = f.read()
|
|
||||||
if log_content:
|
|
||||||
print("--- Log ---")
|
|
||||||
print(log_content)
|
|
||||||
print("--- End ---")
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
sys.exit(exit_code)
|
|
||||||
|
|
||||||
finally:
|
|
||||||
if os.path.exists(temp_dir):
|
|
||||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,230 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# db-load-cf v1.6 — Load 1C configuration from CF file
|
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import atexit
|
|
||||||
import glob
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import random
|
|
||||||
import re
|
|
||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import tempfile
|
|
||||||
|
|
||||||
|
|
||||||
def _find_project_v8path():
|
|
||||||
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
|
||||||
d = os.getcwd()
|
|
||||||
while True:
|
|
||||||
pf = os.path.join(d, ".v8-project.json")
|
|
||||||
if os.path.isfile(pf):
|
|
||||||
try:
|
|
||||||
with open(pf, encoding="utf-8-sig") as f:
|
|
||||||
data = json.load(f)
|
|
||||||
v = data.get("v8path")
|
|
||||||
if v:
|
|
||||||
return v
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return None
|
|
||||||
parent = os.path.dirname(d)
|
|
||||||
if parent == d:
|
|
||||||
return None
|
|
||||||
d = parent
|
|
||||||
|
|
||||||
|
|
||||||
def _version_dir(p):
|
|
||||||
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
|
|
||||||
parent = os.path.dirname(p)
|
|
||||||
if os.path.basename(parent).lower() == "bin":
|
|
||||||
parent = os.path.dirname(parent)
|
|
||||||
return os.path.basename(parent)
|
|
||||||
|
|
||||||
|
|
||||||
def _version_key(p):
|
|
||||||
"""Numeric sort key from version dir name."""
|
|
||||||
return [int(x) for x in re.findall(r"\d+", _version_dir(p))]
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_v8path(v8path):
|
|
||||||
"""Resolve path to a 1C executable (1cv8; ibcmd only when given explicitly)."""
|
|
||||||
if not v8path:
|
|
||||||
v8path = _find_project_v8path()
|
|
||||||
if not v8path:
|
|
||||||
if os.name == "nt":
|
|
||||||
candidates = (
|
|
||||||
glob.glob(r"C:\Program Files\1cv8\*\bin\1cv8.exe")
|
|
||||||
+ glob.glob(r"C:\Program Files (x86)\1cv8\*\bin\1cv8.exe")
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# PY-only: PS-порт на *nix не исполняется, поэтому *nix-раскладки нет в .ps1.
|
|
||||||
candidates = glob.glob("/opt/1cv8/*/1cv8")
|
|
||||||
if candidates:
|
|
||||||
v8path = max(candidates, key=_version_key)
|
|
||||||
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
|
|
||||||
else:
|
|
||||||
print("Error: 1C executable not found. Specify -V8Path", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
if os.path.isdir(v8path):
|
|
||||||
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
|
|
||||||
exe = "1cv8.exe" if os.name == "nt" else "1cv8"
|
|
||||||
v8path = os.path.join(v8path, exe)
|
|
||||||
if not os.path.isfile(v8path):
|
|
||||||
print(f"Error: 1C executable not found at {v8path}", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
return v8path
|
|
||||||
|
|
||||||
|
|
||||||
IBCMD_NOUSER_HINT = (
|
|
||||||
"[ibcmd] No -UserName/-Password given; the infobase may require authentication. "
|
|
||||||
"On Windows ibcmd reads credentials from the console (stdin is ignored), so this "
|
|
||||||
"call may block instead of failing. If it does not return promptly, abort and "
|
|
||||||
"re-run with -UserName and -Password.\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
|
||||||
"""Run an ibcmd command non-interactively.
|
|
||||||
|
|
||||||
input="" closes stdin (EOF) so ibcmd's auth prompt fast-fails instead of hanging.
|
|
||||||
On Windows without -UserName ibcmd reads the console directly and may still block —
|
|
||||||
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
|
|
||||||
"""
|
|
||||||
if warn_no_user and os.name == "nt" and not has_username:
|
|
||||||
sys.stderr.write(IBCMD_NOUSER_HINT)
|
|
||||||
sys.stderr.flush()
|
|
||||||
return subprocess.run(cmd, input="", capture_output=True, encoding="utf-8", errors="replace")
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
sys.stdout.reconfigure(encoding="utf-8")
|
|
||||||
sys.stderr.reconfigure(encoding="utf-8")
|
|
||||||
parser = argparse.ArgumentParser(
|
|
||||||
description="Load 1C configuration from CF file",
|
|
||||||
allow_abbrev=False,
|
|
||||||
)
|
|
||||||
parser.add_argument("-V8Path", default="")
|
|
||||||
parser.add_argument("-InfoBasePath", default="")
|
|
||||||
parser.add_argument("-InfoBaseServer", default="")
|
|
||||||
parser.add_argument("-InfoBaseRef", default="")
|
|
||||||
parser.add_argument("-UserName", default="")
|
|
||||||
parser.add_argument("-Password", default="")
|
|
||||||
parser.add_argument("-InputFile", required=True)
|
|
||||||
parser.add_argument("-Extension", default="")
|
|
||||||
parser.add_argument("-AllExtensions", action="store_true")
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
v8path = resolve_v8path(args.V8Path)
|
|
||||||
engine = "ibcmd" if os.path.basename(v8path).lower().startswith("ibcmd") else "1cv8"
|
|
||||||
|
|
||||||
# --- Validate connection ---
|
|
||||||
if engine == "ibcmd":
|
|
||||||
if not args.InfoBasePath:
|
|
||||||
print("Error: ibcmd supports file infobases only (use -InfoBasePath)", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
|
|
||||||
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
# --- Validate input file ---
|
|
||||||
if not os.path.isfile(args.InputFile):
|
|
||||||
print(f"Error: input file not found: {args.InputFile}", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
# --- ibcmd branch (file infobase only) ---
|
|
||||||
if engine == "ibcmd":
|
|
||||||
if args.AllExtensions:
|
|
||||||
print("Error: ibcmd config load does not support -AllExtensions (use -Extension)", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
arguments = ["infobase", "config", "load", f"--db-path={args.InfoBasePath}"]
|
|
||||||
if args.Extension:
|
|
||||||
arguments.append(f"--extension={args.Extension}")
|
|
||||||
arguments.append(args.InputFile)
|
|
||||||
ib_data = tempfile.mkdtemp(prefix="ibcmd_data_")
|
|
||||||
atexit.register(shutil.rmtree, ib_data, ignore_errors=True)
|
|
||||||
if args.UserName:
|
|
||||||
arguments.append(f"--user={args.UserName}")
|
|
||||||
if args.Password:
|
|
||||||
arguments.append(f"--password={args.Password}")
|
|
||||||
arguments.append(f"--data={ib_data}")
|
|
||||||
print(f"Running: ibcmd {' '.join(arguments)}")
|
|
||||||
result = run_ibcmd([v8path] + arguments, bool(args.UserName))
|
|
||||||
if result.returncode == 0:
|
|
||||||
print(f"Configuration loaded successfully from: {args.InputFile}")
|
|
||||||
else:
|
|
||||||
print(f"Error loading configuration (code: {result.returncode})", file=sys.stderr)
|
|
||||||
if result.stdout:
|
|
||||||
print(result.stdout)
|
|
||||||
if result.stderr:
|
|
||||||
print(result.stderr, file=sys.stderr)
|
|
||||||
sys.exit(result.returncode)
|
|
||||||
|
|
||||||
# --- Temp dir ---
|
|
||||||
temp_dir = os.path.join(tempfile.gettempdir(), f"db_load_cf_{random.randint(0, 999999)}")
|
|
||||||
os.makedirs(temp_dir, exist_ok=True)
|
|
||||||
|
|
||||||
try:
|
|
||||||
# --- Build arguments ---
|
|
||||||
arguments = ["DESIGNER"]
|
|
||||||
|
|
||||||
if args.InfoBaseServer and args.InfoBaseRef:
|
|
||||||
arguments.extend(["/S", f"{args.InfoBaseServer}/{args.InfoBaseRef}"])
|
|
||||||
else:
|
|
||||||
arguments.extend(["/F", args.InfoBasePath])
|
|
||||||
|
|
||||||
if args.UserName:
|
|
||||||
arguments.append(f"/N{args.UserName}")
|
|
||||||
if args.Password:
|
|
||||||
arguments.append(f"/P{args.Password}")
|
|
||||||
|
|
||||||
arguments.extend(["/LoadCfg", args.InputFile])
|
|
||||||
|
|
||||||
# --- Extensions ---
|
|
||||||
if args.Extension:
|
|
||||||
arguments.extend(["-Extension", args.Extension])
|
|
||||||
elif args.AllExtensions:
|
|
||||||
arguments.append("-AllExtensions")
|
|
||||||
|
|
||||||
# --- Output ---
|
|
||||||
out_file = os.path.join(temp_dir, "load_cf_log.txt")
|
|
||||||
arguments.extend(["/Out", out_file])
|
|
||||||
arguments.append("/DisableStartupDialogs")
|
|
||||||
|
|
||||||
# --- Execute ---
|
|
||||||
print(f"Running: 1cv8.exe {' '.join(arguments)}")
|
|
||||||
result = subprocess.run(
|
|
||||||
[v8path] + arguments,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
exit_code = result.returncode
|
|
||||||
|
|
||||||
# --- Result ---
|
|
||||||
if exit_code == 0:
|
|
||||||
print(f"Configuration loaded successfully from: {args.InputFile}")
|
|
||||||
else:
|
|
||||||
print(f"Error loading configuration (code: {exit_code})", file=sys.stderr)
|
|
||||||
|
|
||||||
if os.path.isfile(out_file):
|
|
||||||
try:
|
|
||||||
with open(out_file, "r", encoding="utf-8-sig") as f:
|
|
||||||
log_content = f.read()
|
|
||||||
if log_content:
|
|
||||||
print("--- Log ---")
|
|
||||||
print(log_content)
|
|
||||||
print("--- End ---")
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
sys.exit(exit_code)
|
|
||||||
|
|
||||||
finally:
|
|
||||||
if os.path.isdir(temp_dir):
|
|
||||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
---
|
|
||||||
name: db-load-dt
|
|
||||||
description: Загрузка информационной базы 1С из DT-файла — полная перезапись базы (конфигурация + данные). Используй когда нужно загрузить архив информационной базы, восстановить базу, загрузить dt
|
|
||||||
disable-model-invocation: true
|
|
||||||
argument-hint: <input.dt> [database]
|
|
||||||
allowed-tools:
|
|
||||||
- Bash
|
|
||||||
- Read
|
|
||||||
- Glob
|
|
||||||
- AskUserQuestion
|
|
||||||
---
|
|
||||||
|
|
||||||
# /db-load-dt — Загрузка информационной базы из DT-файла
|
|
||||||
|
|
||||||
Восстанавливает информационную базу целиком (конфигурация **+ данные**) из DT-файла.
|
|
||||||
|
|
||||||
> ⚠️ **Необратимая операция.** Загрузка `.dt` **полностью перезаписывает базу** — и
|
|
||||||
> конфигурацию, и все данные. Текущее содержимое базы будет потеряно. После загрузки
|
|
||||||
> `/db-update` **не нужен** — конфигурация БД уже синхронна внутри снимка.
|
|
||||||
|
|
||||||
## Когда НЕ использовать
|
|
||||||
|
|
||||||
- Нужно создать **новую** базу из `.dt` → используй `/db-create` (из DT-шаблона), а не загрузку
|
|
||||||
в существующую.
|
|
||||||
- Нужно обновить только конфигурацию (без данных) → `/db-load-cf` или `/db-load-xml`.
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
```
|
|
||||||
/db-load-dt <input.dt> [database]
|
|
||||||
/db-load-dt backup.dt dev
|
|
||||||
```
|
|
||||||
|
|
||||||
## Порядок действий перед загрузкой
|
|
||||||
|
|
||||||
1. Предложи пользователю сначала сделать `/db-dump-dt` текущего состояния базы — это точка
|
|
||||||
отката (восстановиться будет нечем, если не сохранить).
|
|
||||||
2. Запроси **явное подтверждение**: вся база (данные + конфигурация) будет перезаписана.
|
|
||||||
3. Только после подтверждения выполняй загрузку.
|
|
||||||
|
|
||||||
## Параметры подключения
|
|
||||||
|
|
||||||
Прочитай `.v8-project.json` из корня проекта. Возьми `v8path` (путь к платформе) и разреши базу:
|
|
||||||
1. Если пользователь указал параметры подключения (путь, сервер) — используй напрямую
|
|
||||||
2. Если указал базу по имени — ищи по id / alias / name в `.v8-project.json`
|
|
||||||
3. Если не указал — сопоставь текущую ветку Git с `databases[].branches`
|
|
||||||
4. Если ветка не совпала — используй `default`
|
|
||||||
Если `v8path` не задан — скрипт сам попытается определить платформу (`.v8-project.json` → Program Files).
|
|
||||||
Если файла нет — предложи `/db-list add`.
|
|
||||||
Если использованная база не зарегистрирована — после выполнения предложи добавить через `/db-list add`.
|
|
||||||
|
|
||||||
## Команда
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
python ".augment/skills/db-load-dt/scripts/db-load-dt.py" <параметры>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Параметры скрипта
|
|
||||||
|
|
||||||
| Параметр | Обязательный | Описание |
|
|
||||||
|----------|:------------:|----------|
|
|
||||||
| `-V8Path <путь>` | нет | Каталог bin платформы, или полный путь к `1cv8.exe` / `ibcmd.exe` |
|
|
||||||
| `-InfoBasePath <путь>` | * | Файловая база |
|
|
||||||
| `-InfoBaseServer <сервер>` | * | Сервер 1С (для серверной базы) |
|
|
||||||
| `-InfoBaseRef <имя>` | * | Имя базы на сервере |
|
|
||||||
| `-UserName <имя>` | нет | Имя пользователя |
|
|
||||||
| `-Password <пароль>` | нет | Пароль |
|
|
||||||
| `-InputFile <путь>` | да | Путь к DT-файлу |
|
|
||||||
| `-JobsCount <N>` | нет | Число фоновых заданий загрузки (0 = по числу процессоров) |
|
|
||||||
| `-UnlockCode <код>` | нет | Код разблокировки (`/UC`), если заблокировано начало сеансов |
|
|
||||||
|
|
||||||
> `*` — нужен либо `-InfoBasePath`, либо пара `-InfoBaseServer` + `-InfoBaseRef`
|
|
||||||
|
|
||||||
## После выполнения
|
|
||||||
|
|
||||||
Если база занята (активные сеансы), загрузка не выполнится — для серверной базы можно
|
|
||||||
передать `-UnlockCode`; иначе освободи базу и повтори.
|
|
||||||
|
|
||||||
## Примеры
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
# Файловая база
|
|
||||||
python ".augment/skills/db-load-dt/scripts/db-load-dt.py" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin" -InputFile "C:\backup\base.dt"
|
|
||||||
|
|
||||||
# Серверная база с ускорением загрузки
|
|
||||||
python ".augment/skills/db-load-dt/scripts/db-load-dt.py" -InfoBaseServer "srv01" -InfoBaseRef "MyApp_Test" -UserName "Admin" -Password "secret" -InputFile "base.dt" -JobsCount 4
|
|
||||||
```
|
|
||||||
|
|
||||||
## Связанные навыки
|
|
||||||
|
|
||||||
- `/db-dump-dt` — выгрузка ИБ в DT (обратная операция, точка отката перед загрузкой)
|
|
||||||
- `/db-create` — создать новую базу (в т.ч. из DT-шаблона)
|
|
||||||
@@ -1,242 +0,0 @@
|
|||||||
# db-load-dt v1.5 — Load 1C information base from DT file
|
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
|
||||||
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
|
||||||
<#
|
|
||||||
.SYNOPSIS
|
|
||||||
Загрузка информационной базы 1С из DT-файла
|
|
||||||
|
|
||||||
.DESCRIPTION
|
|
||||||
Загружает информационную базу целиком (конфигурация + данные) из DT-файла.
|
|
||||||
ВНИМАНИЕ: операция полностью перезаписывает базу.
|
|
||||||
|
|
||||||
.PARAMETER V8Path
|
|
||||||
Путь к каталогу bin платформы или к 1cv8.exe
|
|
||||||
|
|
||||||
.PARAMETER InfoBasePath
|
|
||||||
Путь к файловой информационной базе
|
|
||||||
|
|
||||||
.PARAMETER InfoBaseServer
|
|
||||||
Сервер 1С (для серверной базы)
|
|
||||||
|
|
||||||
.PARAMETER InfoBaseRef
|
|
||||||
Имя базы на сервере
|
|
||||||
|
|
||||||
.PARAMETER UserName
|
|
||||||
Имя пользователя 1С
|
|
||||||
|
|
||||||
.PARAMETER Password
|
|
||||||
Пароль пользователя
|
|
||||||
|
|
||||||
.PARAMETER InputFile
|
|
||||||
Путь к DT-файлу для загрузки
|
|
||||||
|
|
||||||
.PARAMETER JobsCount
|
|
||||||
Количество фоновых заданий для загрузки (0 = по числу процессоров)
|
|
||||||
|
|
||||||
.PARAMETER UnlockCode
|
|
||||||
Код разблокировки базы (/UC) — если заблокировано начало сеансов
|
|
||||||
|
|
||||||
.EXAMPLE
|
|
||||||
.\db-load-dt.ps1 -InfoBasePath "C:\Bases\MyDB" -InputFile "backup.dt"
|
|
||||||
#>
|
|
||||||
|
|
||||||
[CmdletBinding()]
|
|
||||||
param(
|
|
||||||
[Parameter(Mandatory=$false)]
|
|
||||||
[string]$V8Path,
|
|
||||||
|
|
||||||
[Parameter(Mandatory=$false)]
|
|
||||||
[string]$InfoBasePath,
|
|
||||||
|
|
||||||
[Parameter(Mandatory=$false)]
|
|
||||||
[string]$InfoBaseServer,
|
|
||||||
|
|
||||||
[Parameter(Mandatory=$false)]
|
|
||||||
[string]$InfoBaseRef,
|
|
||||||
|
|
||||||
[Parameter(Mandatory=$false)]
|
|
||||||
[string]$UserName,
|
|
||||||
|
|
||||||
[Parameter(Mandatory=$false)]
|
|
||||||
[string]$Password,
|
|
||||||
|
|
||||||
[Parameter(Mandatory=$true)]
|
|
||||||
[string]$InputFile,
|
|
||||||
|
|
||||||
[Parameter(Mandatory=$false)]
|
|
||||||
[int]$JobsCount = 0,
|
|
||||||
|
|
||||||
[Parameter(Mandatory=$false)]
|
|
||||||
[string]$UnlockCode
|
|
||||||
)
|
|
||||||
|
|
||||||
$OutputEncoding = [System.Text.Encoding]::UTF8
|
|
||||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
|
||||||
|
|
||||||
# --- Resolve V8Path ---
|
|
||||||
function Find-ProjectV8Path {
|
|
||||||
$dir = (Get-Location).Path
|
|
||||||
while ($dir) {
|
|
||||||
$pf = Join-Path $dir ".v8-project.json"
|
|
||||||
if (Test-Path $pf) {
|
|
||||||
try {
|
|
||||||
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
|
|
||||||
if ($j.v8path) { return [string]$j.v8path }
|
|
||||||
} catch {}
|
|
||||||
return $null
|
|
||||||
}
|
|
||||||
$parent = Split-Path $dir -Parent
|
|
||||||
if (-not $parent -or $parent -eq $dir) { break }
|
|
||||||
$dir = $parent
|
|
||||||
}
|
|
||||||
return $null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (-not $V8Path) {
|
|
||||||
$V8Path = Find-ProjectV8Path
|
|
||||||
}
|
|
||||||
if (-not $V8Path) {
|
|
||||||
$found = Get-ChildItem @("C:\Program Files\1cv8\*\bin\1cv8.exe", "C:\Program Files (x86)\1cv8\*\bin\1cv8.exe") -ErrorAction SilentlyContinue |
|
|
||||||
Sort-Object { try { [version]$_.Directory.Parent.Name } catch { [version]"0.0" } } -Descending |
|
|
||||||
Select-Object -First 1
|
|
||||||
if ($found) {
|
|
||||||
$V8Path = $found.FullName
|
|
||||||
Write-Host "Auto-selected platform $($found.Directory.Parent.Name): $V8Path" -ForegroundColor Yellow
|
|
||||||
} else {
|
|
||||||
Write-Host "Error: 1C executable not found. Specify -V8Path" -ForegroundColor Red
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (Test-Path $V8Path -PathType Container) {
|
|
||||||
$V8Path = Join-Path $V8Path "1cv8.exe"
|
|
||||||
}
|
|
||||||
|
|
||||||
if (-not (Test-Path $V8Path)) {
|
|
||||||
Write-Host "Error: 1C executable not found at $V8Path" -ForegroundColor Red
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- Detect engine (ibcmd vs 1cv8) by exe name ---
|
|
||||||
function Invoke-IbcmdProcess {
|
|
||||||
# Run ibcmd non-interactively: a closed stdin pipe (EOF) makes ibcmd's auth prompt
|
|
||||||
# fast-fail instead of hanging. Returns @{ Output; ExitCode }. cp866 decodes ibcmd's
|
|
||||||
# native OEM output. The 1cv8/DESIGNER branch keeps using Start-Process.
|
|
||||||
param([string]$Exe, [string[]]$IbArgs)
|
|
||||||
$psi = New-Object System.Diagnostics.ProcessStartInfo
|
|
||||||
$psi.FileName = $Exe
|
|
||||||
$psi.Arguments = ($IbArgs | ForEach-Object { if ($_ -match '[\s"]') { '"' + ($_ -replace '"', '\"') + '"' } else { $_ } }) -join ' '
|
|
||||||
$psi.UseShellExecute = $false
|
|
||||||
$psi.CreateNoWindow = $true
|
|
||||||
$psi.RedirectStandardInput = $true
|
|
||||||
$psi.RedirectStandardOutput = $true
|
|
||||||
$psi.RedirectStandardError = $true
|
|
||||||
try {
|
|
||||||
$psi.StandardOutputEncoding = [System.Text.Encoding]::GetEncoding(866)
|
|
||||||
$psi.StandardErrorEncoding = [System.Text.Encoding]::GetEncoding(866)
|
|
||||||
} catch {}
|
|
||||||
$p = [System.Diagnostics.Process]::Start($psi)
|
|
||||||
$p.StandardInput.Close()
|
|
||||||
$out = $p.StandardOutput.ReadToEnd()
|
|
||||||
$err = $p.StandardError.ReadToEnd()
|
|
||||||
$p.WaitForExit()
|
|
||||||
if ($err) { $out += $err }
|
|
||||||
return [pscustomobject]@{ Output = $out; ExitCode = $p.ExitCode }
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
$engine = if ((Split-Path $V8Path -Leaf) -match '^ibcmd') { "ibcmd" } else { "1cv8" }
|
|
||||||
|
|
||||||
# --- Validate connection ---
|
|
||||||
if ($engine -eq "ibcmd") {
|
|
||||||
if (-not $InfoBasePath) {
|
|
||||||
Write-Host "Error: ibcmd supports file infobases only (use -InfoBasePath)" -ForegroundColor Red
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
} elseif (-not $InfoBasePath -and (-not $InfoBaseServer -or -not $InfoBaseRef)) {
|
|
||||||
Write-Host "Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef" -ForegroundColor Red
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- Validate input file ---
|
|
||||||
if (-not (Test-Path $InputFile)) {
|
|
||||||
Write-Host "Error: input file not found: $InputFile" -ForegroundColor Red
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- Temp dir ---
|
|
||||||
$tempDir = Join-Path $env:TEMP "db_load_dt_$(Get-Random)"
|
|
||||||
New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
|
|
||||||
|
|
||||||
try {
|
|
||||||
if ($engine -eq "ibcmd") {
|
|
||||||
# --- ibcmd branch (file infobase only) ---
|
|
||||||
$arguments = @("infobase", "restore", "--db-path=$InfoBasePath")
|
|
||||||
if (-not (Test-Path (Join-Path $InfoBasePath "1Cv8.1CD"))) { $arguments += "--create-database" }
|
|
||||||
if ($UserName) { $arguments += "--user=$UserName" }
|
|
||||||
if ($Password) { $arguments += "--password=$Password" }
|
|
||||||
$arguments += "$InputFile"
|
|
||||||
|
|
||||||
$arguments += "--data=$tempDir"
|
|
||||||
Write-Host "Running: ibcmd $($arguments -join ' ')"
|
|
||||||
$__ib = Invoke-IbcmdProcess $V8Path $arguments
|
|
||||||
$output = $__ib.Output
|
|
||||||
$exitCode = $__ib.ExitCode
|
|
||||||
if ($exitCode -eq 0) {
|
|
||||||
Write-Host "Information base restored successfully from: $InputFile" -ForegroundColor Green
|
|
||||||
} else {
|
|
||||||
Write-Host "Error restoring information base (code: $exitCode)" -ForegroundColor Red
|
|
||||||
}
|
|
||||||
if ($output) { Write-Host ($output | Out-String) }
|
|
||||||
exit $exitCode
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- 1cv8 branch ---
|
|
||||||
# --- Build arguments ---
|
|
||||||
$arguments = @("DESIGNER")
|
|
||||||
|
|
||||||
if ($InfoBaseServer -and $InfoBaseRef) {
|
|
||||||
$arguments += "/S", "`"$InfoBaseServer/$InfoBaseRef`""
|
|
||||||
} else {
|
|
||||||
$arguments += "/F", "`"$InfoBasePath`""
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($UserName) { $arguments += "/N`"$UserName`"" }
|
|
||||||
if ($Password) { $arguments += "/P`"$Password`"" }
|
|
||||||
if ($UnlockCode) { $arguments += "/UC`"$UnlockCode`"" }
|
|
||||||
|
|
||||||
$arguments += "/RestoreIB", "`"$InputFile`""
|
|
||||||
if ($JobsCount -gt 0) { $arguments += "-JobsCount", "$JobsCount" }
|
|
||||||
|
|
||||||
# --- Output ---
|
|
||||||
$outFile = Join-Path $tempDir "load_dt_log.txt"
|
|
||||||
$arguments += "/Out", "`"$outFile`""
|
|
||||||
$arguments += "/DisableStartupDialogs"
|
|
||||||
|
|
||||||
# --- Execute ---
|
|
||||||
Write-Host "Running: 1cv8.exe $($arguments -join ' ')"
|
|
||||||
$process = Start-Process -FilePath $V8Path -ArgumentList $arguments -NoNewWindow -Wait -PassThru
|
|
||||||
$exitCode = $process.ExitCode
|
|
||||||
|
|
||||||
# --- Result ---
|
|
||||||
if ($exitCode -eq 0) {
|
|
||||||
Write-Host "Information base restored successfully from: $InputFile" -ForegroundColor Green
|
|
||||||
} else {
|
|
||||||
Write-Host "Error restoring information base (code: $exitCode)" -ForegroundColor Red
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Test-Path $outFile) {
|
|
||||||
$logContent = Get-Content $outFile -Raw -ErrorAction SilentlyContinue
|
|
||||||
if ($logContent) {
|
|
||||||
Write-Host "--- Log ---"
|
|
||||||
Write-Host $logContent
|
|
||||||
Write-Host "--- End ---"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
exit $exitCode
|
|
||||||
|
|
||||||
} finally {
|
|
||||||
if (Test-Path $tempDir) {
|
|
||||||
Remove-Item -Path $tempDir -Recurse -Force -ErrorAction SilentlyContinue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,225 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# db-load-dt v1.5 — Load 1C information base from DT file
|
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import atexit
|
|
||||||
import glob
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import random
|
|
||||||
import re
|
|
||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import tempfile
|
|
||||||
|
|
||||||
|
|
||||||
def _find_project_v8path():
|
|
||||||
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
|
||||||
d = os.getcwd()
|
|
||||||
while True:
|
|
||||||
pf = os.path.join(d, ".v8-project.json")
|
|
||||||
if os.path.isfile(pf):
|
|
||||||
try:
|
|
||||||
with open(pf, encoding="utf-8-sig") as f:
|
|
||||||
data = json.load(f)
|
|
||||||
v = data.get("v8path")
|
|
||||||
if v:
|
|
||||||
return v
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return None
|
|
||||||
parent = os.path.dirname(d)
|
|
||||||
if parent == d:
|
|
||||||
return None
|
|
||||||
d = parent
|
|
||||||
|
|
||||||
|
|
||||||
def _version_dir(p):
|
|
||||||
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
|
|
||||||
parent = os.path.dirname(p)
|
|
||||||
if os.path.basename(parent).lower() == "bin":
|
|
||||||
parent = os.path.dirname(parent)
|
|
||||||
return os.path.basename(parent)
|
|
||||||
|
|
||||||
|
|
||||||
def _version_key(p):
|
|
||||||
"""Numeric sort key from version dir name."""
|
|
||||||
return [int(x) for x in re.findall(r"\d+", _version_dir(p))]
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_v8path(v8path):
|
|
||||||
"""Resolve path to a 1C executable (1cv8; ibcmd only when given explicitly)."""
|
|
||||||
if not v8path:
|
|
||||||
v8path = _find_project_v8path()
|
|
||||||
if not v8path:
|
|
||||||
if os.name == "nt":
|
|
||||||
candidates = (
|
|
||||||
glob.glob(r"C:\Program Files\1cv8\*\bin\1cv8.exe")
|
|
||||||
+ glob.glob(r"C:\Program Files (x86)\1cv8\*\bin\1cv8.exe")
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# PY-only: PS-порт на *nix не исполняется, поэтому *nix-раскладки нет в .ps1.
|
|
||||||
candidates = glob.glob("/opt/1cv8/*/1cv8")
|
|
||||||
if candidates:
|
|
||||||
v8path = max(candidates, key=_version_key)
|
|
||||||
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
|
|
||||||
else:
|
|
||||||
print("Error: 1C executable not found. Specify -V8Path", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
if os.path.isdir(v8path):
|
|
||||||
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
|
|
||||||
exe = "1cv8.exe" if os.name == "nt" else "1cv8"
|
|
||||||
v8path = os.path.join(v8path, exe)
|
|
||||||
if not os.path.isfile(v8path):
|
|
||||||
print(f"Error: 1C executable not found at {v8path}", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
return v8path
|
|
||||||
|
|
||||||
|
|
||||||
IBCMD_NOUSER_HINT = (
|
|
||||||
"[ibcmd] No -UserName/-Password given; the infobase may require authentication. "
|
|
||||||
"On Windows ibcmd reads credentials from the console (stdin is ignored), so this "
|
|
||||||
"call may block instead of failing. If it does not return promptly, abort and "
|
|
||||||
"re-run with -UserName and -Password.\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
|
||||||
"""Run an ibcmd command non-interactively.
|
|
||||||
|
|
||||||
input="" closes stdin (EOF) so ibcmd's auth prompt fast-fails instead of hanging.
|
|
||||||
On Windows without -UserName ibcmd reads the console directly and may still block —
|
|
||||||
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
|
|
||||||
"""
|
|
||||||
if warn_no_user and os.name == "nt" and not has_username:
|
|
||||||
sys.stderr.write(IBCMD_NOUSER_HINT)
|
|
||||||
sys.stderr.flush()
|
|
||||||
return subprocess.run(cmd, input="", capture_output=True, encoding="utf-8", errors="replace")
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
sys.stdout.reconfigure(encoding="utf-8")
|
|
||||||
sys.stderr.reconfigure(encoding="utf-8")
|
|
||||||
parser = argparse.ArgumentParser(
|
|
||||||
description="Load 1C information base from DT file",
|
|
||||||
allow_abbrev=False,
|
|
||||||
)
|
|
||||||
parser.add_argument("-V8Path", default="")
|
|
||||||
parser.add_argument("-InfoBasePath", default="")
|
|
||||||
parser.add_argument("-InfoBaseServer", default="")
|
|
||||||
parser.add_argument("-InfoBaseRef", default="")
|
|
||||||
parser.add_argument("-UserName", default="")
|
|
||||||
parser.add_argument("-Password", default="")
|
|
||||||
parser.add_argument("-InputFile", required=True)
|
|
||||||
parser.add_argument("-JobsCount", type=int, default=0)
|
|
||||||
parser.add_argument("-UnlockCode", default="")
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
v8path = resolve_v8path(args.V8Path)
|
|
||||||
engine = "ibcmd" if os.path.basename(v8path).lower().startswith("ibcmd") else "1cv8"
|
|
||||||
|
|
||||||
# --- Validate connection ---
|
|
||||||
if engine == "ibcmd":
|
|
||||||
if not args.InfoBasePath:
|
|
||||||
print("Error: ibcmd supports file infobases only (use -InfoBasePath)", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
|
|
||||||
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
# --- Validate input file ---
|
|
||||||
if not os.path.isfile(args.InputFile):
|
|
||||||
print(f"Error: input file not found: {args.InputFile}", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
# --- ibcmd branch (file infobase only) ---
|
|
||||||
if engine == "ibcmd":
|
|
||||||
arguments = ["infobase", "restore", f"--db-path={args.InfoBasePath}"]
|
|
||||||
if not os.path.isfile(os.path.join(args.InfoBasePath, "1Cv8.1CD")):
|
|
||||||
arguments.append("--create-database")
|
|
||||||
if args.UserName:
|
|
||||||
arguments.append(f"--user={args.UserName}")
|
|
||||||
if args.Password:
|
|
||||||
arguments.append(f"--password={args.Password}")
|
|
||||||
arguments.append(args.InputFile)
|
|
||||||
ib_data = tempfile.mkdtemp(prefix="ibcmd_data_")
|
|
||||||
atexit.register(shutil.rmtree, ib_data, ignore_errors=True)
|
|
||||||
arguments.append(f"--data={ib_data}")
|
|
||||||
print(f"Running: ibcmd {' '.join(arguments)}")
|
|
||||||
result = run_ibcmd([v8path] + arguments, bool(args.UserName))
|
|
||||||
if result.returncode == 0:
|
|
||||||
print(f"Information base restored successfully from: {args.InputFile}")
|
|
||||||
else:
|
|
||||||
print(f"Error restoring information base (code: {result.returncode})", file=sys.stderr)
|
|
||||||
if result.stdout:
|
|
||||||
print(result.stdout)
|
|
||||||
if result.stderr:
|
|
||||||
print(result.stderr, file=sys.stderr)
|
|
||||||
sys.exit(result.returncode)
|
|
||||||
|
|
||||||
# --- Temp dir ---
|
|
||||||
temp_dir = os.path.join(tempfile.gettempdir(), f"db_load_dt_{random.randint(0, 999999)}")
|
|
||||||
os.makedirs(temp_dir, exist_ok=True)
|
|
||||||
|
|
||||||
try:
|
|
||||||
# --- Build arguments ---
|
|
||||||
arguments = ["DESIGNER"]
|
|
||||||
|
|
||||||
if args.InfoBaseServer and args.InfoBaseRef:
|
|
||||||
arguments.extend(["/S", f"{args.InfoBaseServer}/{args.InfoBaseRef}"])
|
|
||||||
else:
|
|
||||||
arguments.extend(["/F", args.InfoBasePath])
|
|
||||||
|
|
||||||
if args.UserName:
|
|
||||||
arguments.append(f"/N{args.UserName}")
|
|
||||||
if args.Password:
|
|
||||||
arguments.append(f"/P{args.Password}")
|
|
||||||
if args.UnlockCode:
|
|
||||||
arguments.append(f"/UC{args.UnlockCode}")
|
|
||||||
|
|
||||||
arguments.extend(["/RestoreIB", args.InputFile])
|
|
||||||
if args.JobsCount > 0:
|
|
||||||
arguments.extend(["-JobsCount", str(args.JobsCount)])
|
|
||||||
|
|
||||||
# --- Output ---
|
|
||||||
out_file = os.path.join(temp_dir, "load_dt_log.txt")
|
|
||||||
arguments.extend(["/Out", out_file])
|
|
||||||
arguments.append("/DisableStartupDialogs")
|
|
||||||
|
|
||||||
# --- Execute ---
|
|
||||||
print(f"Running: 1cv8.exe {' '.join(arguments)}")
|
|
||||||
result = subprocess.run(
|
|
||||||
[v8path] + arguments,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
exit_code = result.returncode
|
|
||||||
|
|
||||||
# --- Result ---
|
|
||||||
if exit_code == 0:
|
|
||||||
print(f"Information base restored successfully from: {args.InputFile}")
|
|
||||||
else:
|
|
||||||
print(f"Error restoring information base (code: {exit_code})", file=sys.stderr)
|
|
||||||
|
|
||||||
if os.path.isfile(out_file):
|
|
||||||
try:
|
|
||||||
with open(out_file, "r", encoding="utf-8-sig") as f:
|
|
||||||
log_content = f.read()
|
|
||||||
if log_content:
|
|
||||||
print("--- Log ---")
|
|
||||||
print(log_content)
|
|
||||||
print("--- End ---")
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
sys.exit(exit_code)
|
|
||||||
|
|
||||||
finally:
|
|
||||||
if os.path.isdir(temp_dir):
|
|
||||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,418 +0,0 @@
|
|||||||
# db-load-xml v1.12 — Load 1C configuration from XML files
|
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
|
||||||
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
|
||||||
<#
|
|
||||||
.SYNOPSIS
|
|
||||||
Загрузка конфигурации 1С из XML-файлов
|
|
||||||
|
|
||||||
.DESCRIPTION
|
|
||||||
Загружает конфигурацию в информационную базу из XML-файлов.
|
|
||||||
Поддерживает полную и частичную загрузку.
|
|
||||||
|
|
||||||
.PARAMETER V8Path
|
|
||||||
Путь к каталогу bin платформы или к 1cv8.exe
|
|
||||||
|
|
||||||
.PARAMETER InfoBasePath
|
|
||||||
Путь к файловой информационной базе
|
|
||||||
|
|
||||||
.PARAMETER InfoBaseServer
|
|
||||||
Сервер 1С (для серверной базы)
|
|
||||||
|
|
||||||
.PARAMETER InfoBaseRef
|
|
||||||
Имя базы на сервере
|
|
||||||
|
|
||||||
.PARAMETER UserName
|
|
||||||
Имя пользователя 1С
|
|
||||||
|
|
||||||
.PARAMETER Password
|
|
||||||
Пароль пользователя
|
|
||||||
|
|
||||||
.PARAMETER ConfigDir
|
|
||||||
Каталог XML-исходников конфигурации
|
|
||||||
|
|
||||||
.PARAMETER Mode
|
|
||||||
Режим загрузки: Full или Partial (по умолчанию Full)
|
|
||||||
|
|
||||||
.PARAMETER Files
|
|
||||||
Относительные пути файлов через запятую (для режима Partial)
|
|
||||||
|
|
||||||
.PARAMETER ListFile
|
|
||||||
Путь к файлу со списком файлов (альтернатива -Files, для режима Partial)
|
|
||||||
|
|
||||||
.PARAMETER Extension
|
|
||||||
Имя расширения для загрузки
|
|
||||||
|
|
||||||
.PARAMETER AllExtensions
|
|
||||||
Загрузить все расширения
|
|
||||||
|
|
||||||
.PARAMETER Format
|
|
||||||
Формат файлов: Hierarchical или Plain (по умолчанию Hierarchical)
|
|
||||||
|
|
||||||
.EXAMPLE
|
|
||||||
.\db-load-xml.ps1 -InfoBasePath "C:\Bases\MyDB" -ConfigDir "C:\src" -Mode Full
|
|
||||||
|
|
||||||
.EXAMPLE
|
|
||||||
.\db-load-xml.ps1 -InfoBasePath "C:\Bases\MyDB" -ConfigDir "C:\src" -Mode Partial -Files "Catalogs/Номенклатура.xml,Catalogs/Номенклатура/Ext/ObjectModule.bsl"
|
|
||||||
#>
|
|
||||||
|
|
||||||
[CmdletBinding()]
|
|
||||||
param(
|
|
||||||
[Parameter(Mandatory=$false)]
|
|
||||||
[string]$V8Path,
|
|
||||||
|
|
||||||
[Parameter(Mandatory=$false)]
|
|
||||||
[string]$InfoBasePath,
|
|
||||||
|
|
||||||
[Parameter(Mandatory=$false)]
|
|
||||||
[string]$InfoBaseServer,
|
|
||||||
|
|
||||||
[Parameter(Mandatory=$false)]
|
|
||||||
[string]$InfoBaseRef,
|
|
||||||
|
|
||||||
[Parameter(Mandatory=$false)]
|
|
||||||
[string]$UserName,
|
|
||||||
|
|
||||||
[Parameter(Mandatory=$false)]
|
|
||||||
[string]$Password,
|
|
||||||
|
|
||||||
[Parameter(Mandatory=$true)]
|
|
||||||
[string]$ConfigDir,
|
|
||||||
|
|
||||||
[Parameter(Mandatory=$false)]
|
|
||||||
[ValidateSet("Full", "Partial")]
|
|
||||||
[string]$Mode = "Full",
|
|
||||||
|
|
||||||
[Parameter(Mandatory=$false)]
|
|
||||||
[string]$Files,
|
|
||||||
|
|
||||||
[Parameter(Mandatory=$false)]
|
|
||||||
[string]$ListFile,
|
|
||||||
|
|
||||||
[Parameter(Mandatory=$false)]
|
|
||||||
[string]$Extension,
|
|
||||||
|
|
||||||
[Parameter(Mandatory=$false)]
|
|
||||||
[switch]$AllExtensions,
|
|
||||||
|
|
||||||
[Parameter(Mandatory=$false)]
|
|
||||||
[ValidateSet("Hierarchical", "Plain")]
|
|
||||||
[string]$Format = "Hierarchical",
|
|
||||||
|
|
||||||
[Parameter(Mandatory=$false)]
|
|
||||||
[switch]$UpdateDB,
|
|
||||||
|
|
||||||
[Parameter(Mandatory=$false)]
|
|
||||||
[switch]$StrictLog
|
|
||||||
)
|
|
||||||
|
|
||||||
$OutputEncoding = [System.Text.Encoding]::UTF8
|
|
||||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
|
||||||
|
|
||||||
# --- Resolve V8Path ---
|
|
||||||
function Find-ProjectV8Path {
|
|
||||||
$dir = (Get-Location).Path
|
|
||||||
while ($dir) {
|
|
||||||
$pf = Join-Path $dir ".v8-project.json"
|
|
||||||
if (Test-Path $pf) {
|
|
||||||
try {
|
|
||||||
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
|
|
||||||
if ($j.v8path) { return [string]$j.v8path }
|
|
||||||
} catch {}
|
|
||||||
return $null
|
|
||||||
}
|
|
||||||
$parent = Split-Path $dir -Parent
|
|
||||||
if (-not $parent -or $parent -eq $dir) { break }
|
|
||||||
$dir = $parent
|
|
||||||
}
|
|
||||||
return $null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (-not $V8Path) {
|
|
||||||
$V8Path = Find-ProjectV8Path
|
|
||||||
}
|
|
||||||
if (-not $V8Path) {
|
|
||||||
$found = Get-ChildItem @("C:\Program Files\1cv8\*\bin\1cv8.exe", "C:\Program Files (x86)\1cv8\*\bin\1cv8.exe") -ErrorAction SilentlyContinue |
|
|
||||||
Sort-Object { try { [version]$_.Directory.Parent.Name } catch { [version]"0.0" } } -Descending |
|
|
||||||
Select-Object -First 1
|
|
||||||
if ($found) {
|
|
||||||
$V8Path = $found.FullName
|
|
||||||
Write-Host "Auto-selected platform $($found.Directory.Parent.Name): $V8Path" -ForegroundColor Yellow
|
|
||||||
} else {
|
|
||||||
Write-Host "Error: 1C executable not found. Specify -V8Path" -ForegroundColor Red
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (Test-Path $V8Path -PathType Container) {
|
|
||||||
$V8Path = Join-Path $V8Path "1cv8.exe"
|
|
||||||
}
|
|
||||||
|
|
||||||
if (-not (Test-Path $V8Path)) {
|
|
||||||
Write-Host "Error: 1C executable not found at $V8Path" -ForegroundColor Red
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- Detect engine (ibcmd vs 1cv8) by exe name ---
|
|
||||||
function Invoke-IbcmdProcess {
|
|
||||||
# Run ibcmd non-interactively: a closed stdin pipe (EOF) makes ibcmd's auth prompt
|
|
||||||
# fast-fail instead of hanging. Returns @{ Output; ExitCode }. cp866 decodes ibcmd's
|
|
||||||
# native OEM output. The 1cv8/DESIGNER branch keeps using Start-Process.
|
|
||||||
param([string]$Exe, [string[]]$IbArgs)
|
|
||||||
$psi = New-Object System.Diagnostics.ProcessStartInfo
|
|
||||||
$psi.FileName = $Exe
|
|
||||||
$psi.Arguments = ($IbArgs | ForEach-Object { if ($_ -match '[\s"]') { '"' + ($_ -replace '"', '\"') + '"' } else { $_ } }) -join ' '
|
|
||||||
$psi.UseShellExecute = $false
|
|
||||||
$psi.CreateNoWindow = $true
|
|
||||||
$psi.RedirectStandardInput = $true
|
|
||||||
$psi.RedirectStandardOutput = $true
|
|
||||||
$psi.RedirectStandardError = $true
|
|
||||||
try {
|
|
||||||
$psi.StandardOutputEncoding = [System.Text.Encoding]::GetEncoding(866)
|
|
||||||
$psi.StandardErrorEncoding = [System.Text.Encoding]::GetEncoding(866)
|
|
||||||
} catch {}
|
|
||||||
$p = [System.Diagnostics.Process]::Start($psi)
|
|
||||||
$p.StandardInput.Close()
|
|
||||||
$out = $p.StandardOutput.ReadToEnd()
|
|
||||||
$err = $p.StandardError.ReadToEnd()
|
|
||||||
$p.WaitForExit()
|
|
||||||
if ($err) { $out += $err }
|
|
||||||
return [pscustomobject]@{ Output = $out; ExitCode = $p.ExitCode }
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
$engine = if ((Split-Path $V8Path -Leaf) -match '^ibcmd') { "ibcmd" } else { "1cv8" }
|
|
||||||
|
|
||||||
# --- Validate connection ---
|
|
||||||
if ($engine -eq "ibcmd") {
|
|
||||||
if (-not $InfoBasePath) {
|
|
||||||
Write-Host "Error: ibcmd supports file infobases only (use -InfoBasePath)" -ForegroundColor Red
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
} elseif (-not $InfoBasePath -and (-not $InfoBaseServer -or -not $InfoBaseRef)) {
|
|
||||||
Write-Host "Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef" -ForegroundColor Red
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- Validate config dir ---
|
|
||||||
if (-not (Test-Path $ConfigDir)) {
|
|
||||||
Write-Host "Error: config directory not found: $ConfigDir" -ForegroundColor Red
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- Validate Partial mode ---
|
|
||||||
if ($Mode -eq "Partial" -and -not $Files -and -not $ListFile) {
|
|
||||||
Write-Host "Error: -Files or -ListFile required for Partial mode" -ForegroundColor Red
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- Temp dir ---
|
|
||||||
$tempDir = Join-Path $env:TEMP "db_load_xml_$(Get-Random)"
|
|
||||||
New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
|
|
||||||
|
|
||||||
try {
|
|
||||||
if ($engine -eq "ibcmd") {
|
|
||||||
# --- ibcmd branch (file infobase only; hierarchical full-directory import) ---
|
|
||||||
if ($Format -eq "Plain") {
|
|
||||||
Write-Host "Error: ibcmd config import supports hierarchical format only (use -Format Hierarchical or 1cv8)" -ForegroundColor Red
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
if ($AllExtensions) {
|
|
||||||
$arguments = @("infobase", "config", "import", "all-extensions", "$ConfigDir", "--db-path=$InfoBasePath")
|
|
||||||
} elseif ($Mode -eq "Partial" -or $Files -or $ListFile) {
|
|
||||||
# partial: import specific files (relative to ConfigDir)
|
|
||||||
$fileList = @()
|
|
||||||
if ($ListFile) {
|
|
||||||
if (-not (Test-Path $ListFile)) {
|
|
||||||
Write-Host "Error: list file not found: $ListFile" -ForegroundColor Red
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
$fileList = @(Get-Content -Path $ListFile -Encoding UTF8 | ForEach-Object { $_.Trim() } | Where-Object { $_ })
|
|
||||||
} elseif ($Files) {
|
|
||||||
$fileList = @($Files -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ })
|
|
||||||
}
|
|
||||||
if ($fileList.Count -eq 0) {
|
|
||||||
Write-Host "Error: -Files or -ListFile required for partial import" -ForegroundColor Red
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
$arguments = @("infobase", "config", "import", "files") + $fileList
|
|
||||||
$arguments += "--base-dir=$ConfigDir", "--db-path=$InfoBasePath"
|
|
||||||
if ($Extension) { $arguments += "--extension=$Extension" }
|
|
||||||
} else {
|
|
||||||
$arguments = @("infobase", "config", "import", "--db-path=$InfoBasePath")
|
|
||||||
if ($Extension) { $arguments += "--extension=$Extension" }
|
|
||||||
$arguments += "$ConfigDir"
|
|
||||||
}
|
|
||||||
if ($UserName) { $arguments += "--user=$UserName" }
|
|
||||||
if ($Password) { $arguments += "--password=$Password" }
|
|
||||||
$arguments += "--data=$tempDir"
|
|
||||||
Write-Host "Running: ibcmd $($arguments -join ' ')"
|
|
||||||
$__ib = Invoke-IbcmdProcess $V8Path $arguments
|
|
||||||
$output = $__ib.Output
|
|
||||||
$exitCode = $__ib.ExitCode
|
|
||||||
if ($exitCode -ne 0) {
|
|
||||||
Write-Host "Error loading configuration from files (code: $exitCode)" -ForegroundColor Red
|
|
||||||
if ($output) { Write-Host ($output | Out-String) }
|
|
||||||
exit $exitCode
|
|
||||||
}
|
|
||||||
Write-Host "Configuration loaded successfully from: $ConfigDir" -ForegroundColor Green
|
|
||||||
if ($output) { Write-Host ($output | Out-String) }
|
|
||||||
|
|
||||||
if ($UpdateDB) {
|
|
||||||
$applyArgs = @("infobase", "config", "apply", "--db-path=$InfoBasePath", "--force")
|
|
||||||
if ($UserName) { $applyArgs += "--user=$UserName" }
|
|
||||||
if ($Password) { $applyArgs += "--password=$Password" }
|
|
||||||
$applyArgs += "--data=$tempDir"
|
|
||||||
Write-Host "Running: ibcmd $($applyArgs -join ' ')"
|
|
||||||
$__ib = Invoke-IbcmdProcess $V8Path $applyArgs
|
|
||||||
$applyOut = $__ib.Output
|
|
||||||
$exitCode = $__ib.ExitCode
|
|
||||||
if ($exitCode -eq 0) {
|
|
||||||
Write-Host "Database configuration updated successfully" -ForegroundColor Green
|
|
||||||
} else {
|
|
||||||
Write-Host "Error updating database configuration (code: $exitCode)" -ForegroundColor Red
|
|
||||||
}
|
|
||||||
if ($applyOut) { Write-Host ($applyOut | Out-String) }
|
|
||||||
}
|
|
||||||
exit $exitCode
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- 1cv8 branch ---
|
|
||||||
# --- Build arguments ---
|
|
||||||
$arguments = @("DESIGNER")
|
|
||||||
|
|
||||||
if ($InfoBaseServer -and $InfoBaseRef) {
|
|
||||||
$arguments += "/S", "`"$InfoBaseServer/$InfoBaseRef`""
|
|
||||||
} else {
|
|
||||||
$arguments += "/F", "`"$InfoBasePath`""
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($UserName) { $arguments += "/N`"$UserName`"" }
|
|
||||||
if ($Password) { $arguments += "/P`"$Password`"" }
|
|
||||||
|
|
||||||
$arguments += "/LoadConfigFromFiles", "`"$ConfigDir`""
|
|
||||||
|
|
||||||
if ($Mode -eq "Full") {
|
|
||||||
Write-Host "Executing full configuration load..."
|
|
||||||
} else {
|
|
||||||
Write-Host "Executing partial configuration load..."
|
|
||||||
|
|
||||||
# Build list file
|
|
||||||
$rawList = @()
|
|
||||||
if ($ListFile) {
|
|
||||||
if (-not (Test-Path $ListFile)) {
|
|
||||||
Write-Host "Error: list file not found: $ListFile" -ForegroundColor Red
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
$rawList = @(Get-Content -Path $ListFile -Encoding UTF8 | ForEach-Object { $_.Trim() } | Where-Object { $_ })
|
|
||||||
} else {
|
|
||||||
$rawList = @($Files -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ })
|
|
||||||
}
|
|
||||||
|
|
||||||
# Support-state service files are NOT partially loadable — exclude with a hint.
|
|
||||||
$supportRe = 'ParentConfigurations\.bin$|(^|[\\/])ConfigDumpInfo\.xml$'
|
|
||||||
$supportFiles = @($rawList | Where-Object { $_ -match $supportRe })
|
|
||||||
$fileList = @($rawList | Where-Object { $_ -notmatch $supportRe })
|
|
||||||
if ($supportFiles.Count -gt 0) {
|
|
||||||
Write-Host "[ВНИМАНИЕ] Служебные файлы состояния поддержки исключены из частичной загрузки (частично не грузятся):" -ForegroundColor Yellow
|
|
||||||
foreach ($sf in $supportFiles) { Write-Host " - $sf" -ForegroundColor Yellow }
|
|
||||||
Write-Host " Смена состояния поддержки применяется только полной загрузкой: -Mode Full." -ForegroundColor Yellow
|
|
||||||
}
|
|
||||||
if ($fileList.Count -eq 0) {
|
|
||||||
Write-Host "Error: после исключения служебных файлов поддержки загружать нечего. Для смены поддержки используйте -Mode Full." -ForegroundColor Red
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
$generatedListFile = Join-Path $tempDir "load_list.txt"
|
|
||||||
$utf8Bom = New-Object System.Text.UTF8Encoding($true)
|
|
||||||
[System.IO.File]::WriteAllLines($generatedListFile, $fileList, $utf8Bom)
|
|
||||||
Write-Host "Files to load: $($fileList.Count)"
|
|
||||||
foreach ($f in $fileList) { Write-Host " $f" }
|
|
||||||
|
|
||||||
$arguments += "-listFile", "`"$generatedListFile`""
|
|
||||||
$arguments += "-partial"
|
|
||||||
$arguments += "-updateConfigDumpInfo"
|
|
||||||
}
|
|
||||||
|
|
||||||
$arguments += "-Format", $Format
|
|
||||||
|
|
||||||
# --- Extensions ---
|
|
||||||
if ($Extension) {
|
|
||||||
$arguments += "-Extension", "`"$Extension`""
|
|
||||||
} elseif ($AllExtensions) {
|
|
||||||
$arguments += "-AllExtensions"
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- UpdateDB ---
|
|
||||||
if ($UpdateDB) {
|
|
||||||
$arguments += "/UpdateDBCfg"
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- Output ---
|
|
||||||
$outFile = Join-Path $tempDir "load_log.txt"
|
|
||||||
$arguments += "/Out", "`"$outFile`""
|
|
||||||
$arguments += "/DisableStartupDialogs"
|
|
||||||
|
|
||||||
# --- Execute ---
|
|
||||||
Write-Host "Running: 1cv8.exe $($arguments -join ' ')"
|
|
||||||
$process = Start-Process -FilePath $V8Path -ArgumentList $arguments -NoNewWindow -Wait -PassThru
|
|
||||||
$exitCode = $process.ExitCode
|
|
||||||
|
|
||||||
# --- Read log ---
|
|
||||||
$logContent = $null
|
|
||||||
if (Test-Path $outFile) {
|
|
||||||
$logContent = Get-Content $outFile -Raw -ErrorAction SilentlyContinue
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- Scan log for silent rejections ---
|
|
||||||
# Platform often writes load-time rejections into /Out but exits with code 0.
|
|
||||||
# These patterns flag cases where metadata was dropped or rejected silently.
|
|
||||||
$fatalLogPatterns = @(
|
|
||||||
'Неверное свойство объекта метаданных',
|
|
||||||
'не входит в состав объекта метаданных',
|
|
||||||
'Неизвестное имя типа',
|
|
||||||
'Неизвестный объект метаданных',
|
|
||||||
'Ни один из документов не является регистратором для регистра',
|
|
||||||
'Неверное значение перечисления',
|
|
||||||
'не может быть приведен к типу'
|
|
||||||
)
|
|
||||||
$silentFailures = @()
|
|
||||||
if ($logContent) {
|
|
||||||
foreach ($line in ($logContent -split "`r?`n")) {
|
|
||||||
foreach ($pat in $fatalLogPatterns) {
|
|
||||||
if ($line -match [regex]::Escape($pat)) {
|
|
||||||
$silentFailures += $line.Trim()
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- Result ---
|
|
||||||
# Default: mirror platform's verdict via exit code. Log content (including any
|
|
||||||
# rejection warnings) is always printed to stdout for visibility. With -StrictLog,
|
|
||||||
# elevate exit code to 1 when rejection patterns are found even if platform said 0.
|
|
||||||
if ($exitCode -eq 0) {
|
|
||||||
Write-Host "Load completed successfully" -ForegroundColor Green
|
|
||||||
} else {
|
|
||||||
Write-Host "Error loading configuration (code: $exitCode)" -ForegroundColor Red
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($logContent) {
|
|
||||||
Write-Host "--- Log ---"
|
|
||||||
Write-Host $logContent
|
|
||||||
Write-Host "--- End ---"
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($silentFailures.Count -gt 0) {
|
|
||||||
$msg = "[warning] log contains $($silentFailures.Count) rejection(s) — platform loaded config but dropped properties/refs"
|
|
||||||
if (-not $StrictLog) { $msg += " (pass -StrictLog to treat as error)" }
|
|
||||||
Write-Host $msg -ForegroundColor Yellow
|
|
||||||
foreach ($f in $silentFailures) { Write-Host " $f" -ForegroundColor Yellow }
|
|
||||||
if ($StrictLog -and $exitCode -eq 0) { $exitCode = 1 }
|
|
||||||
}
|
|
||||||
|
|
||||||
exit $exitCode
|
|
||||||
|
|
||||||
} finally {
|
|
||||||
if (Test-Path $tempDir) {
|
|
||||||
Remove-Item -Path $tempDir -Recurse -Force -ErrorAction SilentlyContinue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,382 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# db-load-xml v1.12 — Load 1C configuration from XML files
|
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import atexit
|
|
||||||
import glob
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import random
|
|
||||||
import re
|
|
||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import tempfile
|
|
||||||
|
|
||||||
|
|
||||||
def _find_project_v8path():
|
|
||||||
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
|
||||||
d = os.getcwd()
|
|
||||||
while True:
|
|
||||||
pf = os.path.join(d, ".v8-project.json")
|
|
||||||
if os.path.isfile(pf):
|
|
||||||
try:
|
|
||||||
with open(pf, encoding="utf-8-sig") as f:
|
|
||||||
data = json.load(f)
|
|
||||||
v = data.get("v8path")
|
|
||||||
if v:
|
|
||||||
return v
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return None
|
|
||||||
parent = os.path.dirname(d)
|
|
||||||
if parent == d:
|
|
||||||
return None
|
|
||||||
d = parent
|
|
||||||
|
|
||||||
|
|
||||||
def _version_dir(p):
|
|
||||||
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
|
|
||||||
parent = os.path.dirname(p)
|
|
||||||
if os.path.basename(parent).lower() == "bin":
|
|
||||||
parent = os.path.dirname(parent)
|
|
||||||
return os.path.basename(parent)
|
|
||||||
|
|
||||||
|
|
||||||
def _version_key(p):
|
|
||||||
"""Numeric sort key from version dir name."""
|
|
||||||
return [int(x) for x in re.findall(r"\d+", _version_dir(p))]
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_v8path(v8path):
|
|
||||||
"""Resolve path to a 1C executable (1cv8; ibcmd only when given explicitly)."""
|
|
||||||
if not v8path:
|
|
||||||
v8path = _find_project_v8path()
|
|
||||||
if not v8path:
|
|
||||||
if os.name == "nt":
|
|
||||||
candidates = (
|
|
||||||
glob.glob(r"C:\Program Files\1cv8\*\bin\1cv8.exe")
|
|
||||||
+ glob.glob(r"C:\Program Files (x86)\1cv8\*\bin\1cv8.exe")
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# PY-only: PS-порт на *nix не исполняется, поэтому *nix-раскладки нет в .ps1.
|
|
||||||
candidates = glob.glob("/opt/1cv8/*/1cv8")
|
|
||||||
if candidates:
|
|
||||||
v8path = max(candidates, key=_version_key)
|
|
||||||
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
|
|
||||||
else:
|
|
||||||
print("Error: 1C executable not found. Specify -V8Path", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
if os.path.isdir(v8path):
|
|
||||||
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
|
|
||||||
exe = "1cv8.exe" if os.name == "nt" else "1cv8"
|
|
||||||
v8path = os.path.join(v8path, exe)
|
|
||||||
if not os.path.isfile(v8path):
|
|
||||||
print(f"Error: 1C executable not found at {v8path}", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
return v8path
|
|
||||||
|
|
||||||
|
|
||||||
IBCMD_NOUSER_HINT = (
|
|
||||||
"[ibcmd] No -UserName/-Password given; the infobase may require authentication. "
|
|
||||||
"On Windows ibcmd reads credentials from the console (stdin is ignored), so this "
|
|
||||||
"call may block instead of failing. If it does not return promptly, abort and "
|
|
||||||
"re-run with -UserName and -Password.\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
|
||||||
"""Run an ibcmd command non-interactively.
|
|
||||||
|
|
||||||
input="" closes stdin (EOF) so ibcmd's auth prompt fast-fails instead of hanging.
|
|
||||||
On Windows without -UserName ibcmd reads the console directly and may still block —
|
|
||||||
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
|
|
||||||
"""
|
|
||||||
if warn_no_user and os.name == "nt" and not has_username:
|
|
||||||
sys.stderr.write(IBCMD_NOUSER_HINT)
|
|
||||||
sys.stderr.flush()
|
|
||||||
return subprocess.run(cmd, input="", capture_output=True, encoding="utf-8", errors="replace")
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
sys.stdout.reconfigure(encoding="utf-8")
|
|
||||||
sys.stderr.reconfigure(encoding="utf-8")
|
|
||||||
parser = argparse.ArgumentParser(
|
|
||||||
description="Load 1C configuration from XML files",
|
|
||||||
allow_abbrev=False,
|
|
||||||
)
|
|
||||||
parser.add_argument("-V8Path", default="", help="Path to 1cv8.exe or its bin directory")
|
|
||||||
parser.add_argument("-InfoBasePath", default="", help="Path to file infobase")
|
|
||||||
parser.add_argument("-InfoBaseServer", default="", help="1C server (for server infobase)")
|
|
||||||
parser.add_argument("-InfoBaseRef", default="", help="Infobase name on server")
|
|
||||||
parser.add_argument("-UserName", default="", help="1C user name")
|
|
||||||
parser.add_argument("-Password", default="", help="1C user password")
|
|
||||||
parser.add_argument("-ConfigDir", required=True, help="Directory with XML configuration sources")
|
|
||||||
parser.add_argument(
|
|
||||||
"-Mode",
|
|
||||||
default="Full",
|
|
||||||
choices=["Full", "Partial"],
|
|
||||||
help="Load mode (default: Full)",
|
|
||||||
)
|
|
||||||
parser.add_argument("-Files", default="", help="Comma-separated relative file paths (for Partial mode)")
|
|
||||||
parser.add_argument("-ListFile", default="", help="Path to file list (alternative to -Files, for Partial mode)")
|
|
||||||
parser.add_argument("-Extension", default="", help="Extension name to load")
|
|
||||||
parser.add_argument("-AllExtensions", action="store_true", help="Load all extensions")
|
|
||||||
parser.add_argument(
|
|
||||||
"-Format",
|
|
||||||
default="Hierarchical",
|
|
||||||
choices=["Hierarchical", "Plain"],
|
|
||||||
help="File format (default: Hierarchical)",
|
|
||||||
)
|
|
||||||
parser.add_argument("-UpdateDB", action="store_true", help="Also update database configuration after load")
|
|
||||||
parser.add_argument(
|
|
||||||
"-StrictLog",
|
|
||||||
action="store_true",
|
|
||||||
help="Treat silent rejection warnings in the log as errors (elevate exit code to 1)",
|
|
||||||
)
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
# --- Resolve V8Path ---
|
|
||||||
v8path = resolve_v8path(args.V8Path)
|
|
||||||
|
|
||||||
engine = "ibcmd" if os.path.basename(v8path).lower().startswith("ibcmd") else "1cv8"
|
|
||||||
|
|
||||||
# --- Validate connection ---
|
|
||||||
if engine == "ibcmd":
|
|
||||||
if not args.InfoBasePath:
|
|
||||||
print("Error: ibcmd supports file infobases only (use -InfoBasePath)", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
|
|
||||||
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
# --- Validate config dir ---
|
|
||||||
if not os.path.exists(args.ConfigDir):
|
|
||||||
print(f"Error: config directory not found: {args.ConfigDir}", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
# --- Validate Partial mode ---
|
|
||||||
if args.Mode == "Partial" and not args.Files and not args.ListFile:
|
|
||||||
print("Error: -Files or -ListFile required for Partial mode", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
# --- ibcmd branch (file infobase only; hierarchical full-directory import) ---
|
|
||||||
if engine == "ibcmd":
|
|
||||||
if args.Format == "Plain":
|
|
||||||
print("Error: ibcmd config import supports hierarchical format only (use -Format Hierarchical or 1cv8)", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
if args.AllExtensions:
|
|
||||||
arguments = ["infobase", "config", "import", "all-extensions", args.ConfigDir, f"--db-path={args.InfoBasePath}"]
|
|
||||||
elif args.Mode == "Partial" or args.Files or args.ListFile:
|
|
||||||
# partial: import specific files (relative to ConfigDir)
|
|
||||||
if args.ListFile:
|
|
||||||
if not os.path.isfile(args.ListFile):
|
|
||||||
print(f"Error: list file not found: {args.ListFile}", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
with open(args.ListFile, encoding="utf-8-sig") as f:
|
|
||||||
file_list = [ln.strip() for ln in f if ln.strip()]
|
|
||||||
elif args.Files:
|
|
||||||
file_list = [p.strip() for p in args.Files.split(",") if p.strip()]
|
|
||||||
else:
|
|
||||||
file_list = []
|
|
||||||
if not file_list:
|
|
||||||
print("Error: -Files or -ListFile required for partial import", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
arguments = ["infobase", "config", "import", "files"] + file_list
|
|
||||||
arguments += [f"--base-dir={args.ConfigDir}", f"--db-path={args.InfoBasePath}"]
|
|
||||||
if args.Extension:
|
|
||||||
arguments.append(f"--extension={args.Extension}")
|
|
||||||
else:
|
|
||||||
arguments = ["infobase", "config", "import", f"--db-path={args.InfoBasePath}"]
|
|
||||||
if args.Extension:
|
|
||||||
arguments.append(f"--extension={args.Extension}")
|
|
||||||
arguments.append(args.ConfigDir)
|
|
||||||
ib_data = tempfile.mkdtemp(prefix="ibcmd_data_")
|
|
||||||
atexit.register(shutil.rmtree, ib_data, ignore_errors=True)
|
|
||||||
if args.UserName:
|
|
||||||
arguments.append(f"--user={args.UserName}")
|
|
||||||
if args.Password:
|
|
||||||
arguments.append(f"--password={args.Password}")
|
|
||||||
arguments.append(f"--data={ib_data}")
|
|
||||||
print(f"Running: ibcmd {' '.join(arguments)}")
|
|
||||||
result = run_ibcmd([v8path] + arguments, bool(args.UserName))
|
|
||||||
if result.returncode != 0:
|
|
||||||
print(f"Error loading configuration from files (code: {result.returncode})", file=sys.stderr)
|
|
||||||
if result.stdout:
|
|
||||||
print(result.stdout)
|
|
||||||
if result.stderr:
|
|
||||||
print(result.stderr, file=sys.stderr)
|
|
||||||
sys.exit(result.returncode)
|
|
||||||
print(f"Configuration loaded successfully from: {args.ConfigDir}")
|
|
||||||
if result.stdout:
|
|
||||||
print(result.stdout)
|
|
||||||
exit_code = 0
|
|
||||||
if args.UpdateDB:
|
|
||||||
apply_args = ["infobase", "config", "apply", f"--db-path={args.InfoBasePath}", "--force"]
|
|
||||||
if args.UserName:
|
|
||||||
apply_args.append(f"--user={args.UserName}")
|
|
||||||
if args.Password:
|
|
||||||
apply_args.append(f"--password={args.Password}")
|
|
||||||
apply_args.append(f"--data={ib_data}")
|
|
||||||
print(f"Running: ibcmd {' '.join(apply_args)}")
|
|
||||||
ar = run_ibcmd([v8path] + apply_args, bool(args.UserName))
|
|
||||||
exit_code = ar.returncode
|
|
||||||
if exit_code == 0:
|
|
||||||
print("Database configuration updated successfully")
|
|
||||||
else:
|
|
||||||
print(f"Error updating database configuration (code: {exit_code})", file=sys.stderr)
|
|
||||||
if ar.stdout:
|
|
||||||
print(ar.stdout)
|
|
||||||
if ar.stderr:
|
|
||||||
print(ar.stderr, file=sys.stderr)
|
|
||||||
sys.exit(exit_code)
|
|
||||||
|
|
||||||
# --- Temp dir ---
|
|
||||||
temp_dir = os.path.join(tempfile.gettempdir(), f"db_load_xml_{random.randint(0, 999999)}")
|
|
||||||
os.makedirs(temp_dir, exist_ok=True)
|
|
||||||
|
|
||||||
try:
|
|
||||||
# --- Build arguments ---
|
|
||||||
arguments = ["DESIGNER"]
|
|
||||||
|
|
||||||
if args.InfoBaseServer and args.InfoBaseRef:
|
|
||||||
arguments += ["/S", f"{args.InfoBaseServer}/{args.InfoBaseRef}"]
|
|
||||||
else:
|
|
||||||
arguments += ["/F", args.InfoBasePath]
|
|
||||||
|
|
||||||
if args.UserName:
|
|
||||||
arguments.append(f"/N{args.UserName}")
|
|
||||||
if args.Password:
|
|
||||||
arguments.append(f"/P{args.Password}")
|
|
||||||
|
|
||||||
arguments += ["/LoadConfigFromFiles", args.ConfigDir]
|
|
||||||
|
|
||||||
if args.Mode == "Full":
|
|
||||||
print("Executing full configuration load...")
|
|
||||||
else:
|
|
||||||
print("Executing partial configuration load...")
|
|
||||||
|
|
||||||
# Build list file
|
|
||||||
if args.ListFile:
|
|
||||||
if not os.path.isfile(args.ListFile):
|
|
||||||
print(f"Error: list file not found: {args.ListFile}", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
with open(args.ListFile, encoding="utf-8-sig") as f:
|
|
||||||
raw_list = [ln.strip() for ln in f if ln.strip()]
|
|
||||||
else:
|
|
||||||
raw_list = [f.strip() for f in args.Files.split(",") if f.strip()]
|
|
||||||
|
|
||||||
# Support-state service files are NOT partially loadable — exclude with a hint.
|
|
||||||
support_re = re.compile(r"ParentConfigurations\.bin$|(^|[\\/])ConfigDumpInfo\.xml$")
|
|
||||||
support_files = [x for x in raw_list if support_re.search(x)]
|
|
||||||
file_list = [x for x in raw_list if not support_re.search(x)]
|
|
||||||
if support_files:
|
|
||||||
print("[ВНИМАНИЕ] Служебные файлы состояния поддержки исключены из частичной загрузки (частично не грузятся):", file=sys.stderr)
|
|
||||||
for sf in support_files:
|
|
||||||
print(f" - {sf}", file=sys.stderr)
|
|
||||||
print(" Смена состояния поддержки применяется только полной загрузкой: -Mode Full.", file=sys.stderr)
|
|
||||||
if not file_list:
|
|
||||||
print("Error: после исключения служебных файлов поддержки загружать нечего. Для смены поддержки используйте -Mode Full.", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
generated_list_file = os.path.join(temp_dir, "load_list.txt")
|
|
||||||
with open(generated_list_file, "w", encoding="utf-8-sig") as f:
|
|
||||||
f.write("\n".join(file_list))
|
|
||||||
print(f"Files to load: {len(file_list)}")
|
|
||||||
for fl in file_list:
|
|
||||||
print(f" {fl}")
|
|
||||||
|
|
||||||
arguments += ["-listFile", generated_list_file]
|
|
||||||
arguments.append("-partial")
|
|
||||||
arguments.append("-updateConfigDumpInfo")
|
|
||||||
|
|
||||||
arguments += ["-Format", args.Format]
|
|
||||||
|
|
||||||
# --- Extensions ---
|
|
||||||
if args.Extension:
|
|
||||||
arguments += ["-Extension", args.Extension]
|
|
||||||
elif args.AllExtensions:
|
|
||||||
arguments.append("-AllExtensions")
|
|
||||||
|
|
||||||
# --- UpdateDB ---
|
|
||||||
if args.UpdateDB:
|
|
||||||
arguments.append("/UpdateDBCfg")
|
|
||||||
|
|
||||||
# --- Output ---
|
|
||||||
out_file = os.path.join(temp_dir, "load_log.txt")
|
|
||||||
arguments += ["/Out", out_file]
|
|
||||||
arguments.append("/DisableStartupDialogs")
|
|
||||||
|
|
||||||
# --- Execute ---
|
|
||||||
print(f"Running: 1cv8.exe {' '.join(arguments)}")
|
|
||||||
result = subprocess.run(
|
|
||||||
[v8path] + arguments,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
exit_code = result.returncode
|
|
||||||
|
|
||||||
# --- Read log ---
|
|
||||||
log_content = ""
|
|
||||||
if os.path.isfile(out_file):
|
|
||||||
try:
|
|
||||||
with open(out_file, "r", encoding="utf-8-sig") as f:
|
|
||||||
log_content = f.read()
|
|
||||||
except Exception:
|
|
||||||
log_content = ""
|
|
||||||
|
|
||||||
# --- Scan log for silent rejections ---
|
|
||||||
# Platform often writes load-time rejections into /Out but exits with code 0.
|
|
||||||
# These patterns flag cases where metadata was dropped or rejected silently.
|
|
||||||
fatal_log_patterns = [
|
|
||||||
"Неверное свойство объекта метаданных",
|
|
||||||
"не входит в состав объекта метаданных",
|
|
||||||
"Неизвестное имя типа",
|
|
||||||
"Неизвестный объект метаданных",
|
|
||||||
"Ни один из документов не является регистратором для регистра",
|
|
||||||
"Неверное значение перечисления",
|
|
||||||
"не может быть приведен к типу",
|
|
||||||
]
|
|
||||||
silent_failures = []
|
|
||||||
if log_content:
|
|
||||||
for line in log_content.splitlines():
|
|
||||||
for pat in fatal_log_patterns:
|
|
||||||
if pat in line:
|
|
||||||
silent_failures.append(line.strip())
|
|
||||||
break
|
|
||||||
|
|
||||||
# --- Result ---
|
|
||||||
# Default: mirror platform's verdict via exit code. Log content (including any
|
|
||||||
# rejection warnings) is always printed to stdout for visibility. With -StrictLog,
|
|
||||||
# elevate exit code to 1 when rejection patterns are found even if platform said 0.
|
|
||||||
if exit_code == 0:
|
|
||||||
print("Load completed successfully")
|
|
||||||
else:
|
|
||||||
print(f"Error loading configuration (code: {exit_code})", file=sys.stderr)
|
|
||||||
|
|
||||||
if log_content:
|
|
||||||
print("--- Log ---")
|
|
||||||
print(log_content)
|
|
||||||
print("--- End ---")
|
|
||||||
|
|
||||||
if silent_failures:
|
|
||||||
suffix = "" if args.StrictLog else " (pass -StrictLog to treat as error)"
|
|
||||||
print(
|
|
||||||
f"[warning] log contains {len(silent_failures)} rejection(s) — "
|
|
||||||
f"platform loaded config but dropped properties/refs{suffix}",
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
for f in silent_failures:
|
|
||||||
print(f" {f}", file=sys.stderr)
|
|
||||||
if args.StrictLog and exit_code == 0:
|
|
||||||
exit_code = 1
|
|
||||||
|
|
||||||
sys.exit(exit_code)
|
|
||||||
|
|
||||||
finally:
|
|
||||||
if os.path.exists(temp_dir):
|
|
||||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,239 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# db-update v1.6 — Update 1C database configuration
|
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import atexit
|
|
||||||
import glob
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import random
|
|
||||||
import re
|
|
||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import tempfile
|
|
||||||
|
|
||||||
|
|
||||||
def _find_project_v8path():
|
|
||||||
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
|
||||||
d = os.getcwd()
|
|
||||||
while True:
|
|
||||||
pf = os.path.join(d, ".v8-project.json")
|
|
||||||
if os.path.isfile(pf):
|
|
||||||
try:
|
|
||||||
with open(pf, encoding="utf-8-sig") as f:
|
|
||||||
data = json.load(f)
|
|
||||||
v = data.get("v8path")
|
|
||||||
if v:
|
|
||||||
return v
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return None
|
|
||||||
parent = os.path.dirname(d)
|
|
||||||
if parent == d:
|
|
||||||
return None
|
|
||||||
d = parent
|
|
||||||
|
|
||||||
|
|
||||||
def _version_dir(p):
|
|
||||||
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
|
|
||||||
parent = os.path.dirname(p)
|
|
||||||
if os.path.basename(parent).lower() == "bin":
|
|
||||||
parent = os.path.dirname(parent)
|
|
||||||
return os.path.basename(parent)
|
|
||||||
|
|
||||||
|
|
||||||
def _version_key(p):
|
|
||||||
"""Numeric sort key from version dir name."""
|
|
||||||
return [int(x) for x in re.findall(r"\d+", _version_dir(p))]
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_v8path(v8path):
|
|
||||||
"""Resolve path to a 1C executable (1cv8; ibcmd only when given explicitly)."""
|
|
||||||
if not v8path:
|
|
||||||
v8path = _find_project_v8path()
|
|
||||||
if not v8path:
|
|
||||||
if os.name == "nt":
|
|
||||||
candidates = (
|
|
||||||
glob.glob(r"C:\Program Files\1cv8\*\bin\1cv8.exe")
|
|
||||||
+ glob.glob(r"C:\Program Files (x86)\1cv8\*\bin\1cv8.exe")
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# PY-only: PS-порт на *nix не исполняется, поэтому *nix-раскладки нет в .ps1.
|
|
||||||
candidates = glob.glob("/opt/1cv8/*/1cv8")
|
|
||||||
if candidates:
|
|
||||||
v8path = max(candidates, key=_version_key)
|
|
||||||
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
|
|
||||||
else:
|
|
||||||
print("Error: 1C executable not found. Specify -V8Path", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
if os.path.isdir(v8path):
|
|
||||||
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
|
|
||||||
exe = "1cv8.exe" if os.name == "nt" else "1cv8"
|
|
||||||
v8path = os.path.join(v8path, exe)
|
|
||||||
if not os.path.isfile(v8path):
|
|
||||||
print(f"Error: 1C executable not found at {v8path}", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
return v8path
|
|
||||||
|
|
||||||
|
|
||||||
IBCMD_NOUSER_HINT = (
|
|
||||||
"[ibcmd] No -UserName/-Password given; the infobase may require authentication. "
|
|
||||||
"On Windows ibcmd reads credentials from the console (stdin is ignored), so this "
|
|
||||||
"call may block instead of failing. If it does not return promptly, abort and "
|
|
||||||
"re-run with -UserName and -Password.\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
|
||||||
"""Run an ibcmd command non-interactively.
|
|
||||||
|
|
||||||
input="" closes stdin (EOF) so ibcmd's auth prompt fast-fails instead of hanging.
|
|
||||||
On Windows without -UserName ibcmd reads the console directly and may still block —
|
|
||||||
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
|
|
||||||
"""
|
|
||||||
if warn_no_user and os.name == "nt" and not has_username:
|
|
||||||
sys.stderr.write(IBCMD_NOUSER_HINT)
|
|
||||||
sys.stderr.flush()
|
|
||||||
return subprocess.run(cmd, input="", capture_output=True, encoding="utf-8", errors="replace")
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
sys.stdout.reconfigure(encoding="utf-8")
|
|
||||||
sys.stderr.reconfigure(encoding="utf-8")
|
|
||||||
parser = argparse.ArgumentParser(
|
|
||||||
description="Update 1C database configuration",
|
|
||||||
allow_abbrev=False,
|
|
||||||
)
|
|
||||||
parser.add_argument("-V8Path", default="")
|
|
||||||
parser.add_argument("-InfoBasePath", default="")
|
|
||||||
parser.add_argument("-InfoBaseServer", default="")
|
|
||||||
parser.add_argument("-InfoBaseRef", default="")
|
|
||||||
parser.add_argument("-UserName", default="")
|
|
||||||
parser.add_argument("-Password", default="")
|
|
||||||
parser.add_argument("-Extension", default="")
|
|
||||||
parser.add_argument("-AllExtensions", action="store_true")
|
|
||||||
parser.add_argument("-Dynamic", default="", choices=["", "+", "-"])
|
|
||||||
parser.add_argument("-Server", action="store_true")
|
|
||||||
parser.add_argument("-WarningsAsErrors", action="store_true")
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
v8path = resolve_v8path(args.V8Path)
|
|
||||||
|
|
||||||
engine = "ibcmd" if os.path.basename(v8path).lower().startswith("ibcmd") else "1cv8"
|
|
||||||
|
|
||||||
# --- Validate connection ---
|
|
||||||
if engine == "ibcmd":
|
|
||||||
if not args.InfoBasePath:
|
|
||||||
print("Error: ibcmd supports file infobases only (use -InfoBasePath)", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
|
|
||||||
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
# --- ibcmd branch (file infobase only) ---
|
|
||||||
if engine == "ibcmd":
|
|
||||||
if args.AllExtensions:
|
|
||||||
print("Error: ibcmd config apply does not support -AllExtensions (use -Extension)", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
arguments = ["infobase", "config", "apply", f"--db-path={args.InfoBasePath}", "--force"]
|
|
||||||
if args.Dynamic == "+":
|
|
||||||
arguments.append("--dynamic=auto")
|
|
||||||
elif args.Dynamic == "-":
|
|
||||||
arguments.append("--dynamic=disable")
|
|
||||||
if args.Extension:
|
|
||||||
arguments.append(f"--extension={args.Extension}")
|
|
||||||
ib_data = tempfile.mkdtemp(prefix="ibcmd_data_")
|
|
||||||
atexit.register(shutil.rmtree, ib_data, ignore_errors=True)
|
|
||||||
if args.UserName:
|
|
||||||
arguments.append(f"--user={args.UserName}")
|
|
||||||
if args.Password:
|
|
||||||
arguments.append(f"--password={args.Password}")
|
|
||||||
arguments.append(f"--data={ib_data}")
|
|
||||||
print(f"Running: ibcmd {' '.join(arguments)}")
|
|
||||||
result = run_ibcmd([v8path] + arguments, bool(args.UserName))
|
|
||||||
if result.returncode == 0:
|
|
||||||
print("Database configuration updated successfully")
|
|
||||||
else:
|
|
||||||
print(f"Error updating database configuration (code: {result.returncode})", file=sys.stderr)
|
|
||||||
if result.stdout:
|
|
||||||
print(result.stdout)
|
|
||||||
if result.stderr:
|
|
||||||
print(result.stderr, file=sys.stderr)
|
|
||||||
sys.exit(result.returncode)
|
|
||||||
|
|
||||||
# --- Temp dir ---
|
|
||||||
temp_dir = os.path.join(tempfile.gettempdir(), f"db_update_{random.randint(0, 999999)}")
|
|
||||||
os.makedirs(temp_dir, exist_ok=True)
|
|
||||||
|
|
||||||
try:
|
|
||||||
# --- Build arguments ---
|
|
||||||
arguments = ["DESIGNER"]
|
|
||||||
|
|
||||||
if args.InfoBaseServer and args.InfoBaseRef:
|
|
||||||
arguments.extend(["/S", f"{args.InfoBaseServer}/{args.InfoBaseRef}"])
|
|
||||||
else:
|
|
||||||
arguments.extend(["/F", args.InfoBasePath])
|
|
||||||
|
|
||||||
if args.UserName:
|
|
||||||
arguments.append(f"/N{args.UserName}")
|
|
||||||
if args.Password:
|
|
||||||
arguments.append(f"/P{args.Password}")
|
|
||||||
|
|
||||||
arguments.append("/UpdateDBCfg")
|
|
||||||
|
|
||||||
# --- Options ---
|
|
||||||
if args.Dynamic:
|
|
||||||
arguments.append(f"-Dynamic{args.Dynamic}")
|
|
||||||
if args.Server:
|
|
||||||
arguments.append("-Server")
|
|
||||||
if args.WarningsAsErrors:
|
|
||||||
arguments.append("-WarningsAsErrors")
|
|
||||||
|
|
||||||
# --- Extensions ---
|
|
||||||
if args.Extension:
|
|
||||||
arguments.extend(["-Extension", args.Extension])
|
|
||||||
elif args.AllExtensions:
|
|
||||||
arguments.append("-AllExtensions")
|
|
||||||
|
|
||||||
# --- Output ---
|
|
||||||
out_file = os.path.join(temp_dir, "update_log.txt")
|
|
||||||
arguments.extend(["/Out", out_file])
|
|
||||||
arguments.append("/DisableStartupDialogs")
|
|
||||||
|
|
||||||
# --- Execute ---
|
|
||||||
print(f"Running: 1cv8.exe {' '.join(arguments)}")
|
|
||||||
result = subprocess.run(
|
|
||||||
[v8path] + arguments,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
exit_code = result.returncode
|
|
||||||
|
|
||||||
# --- Result ---
|
|
||||||
if exit_code == 0:
|
|
||||||
print("Database configuration updated successfully")
|
|
||||||
else:
|
|
||||||
print(f"Error updating database configuration (code: {exit_code})", file=sys.stderr)
|
|
||||||
|
|
||||||
if os.path.isfile(out_file):
|
|
||||||
try:
|
|
||||||
with open(out_file, "r", encoding="utf-8-sig") as f:
|
|
||||||
log_content = f.read()
|
|
||||||
if log_content:
|
|
||||||
print("--- Log ---")
|
|
||||||
print(log_content)
|
|
||||||
print("--- End ---")
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
sys.exit(exit_code)
|
|
||||||
|
|
||||||
finally:
|
|
||||||
if os.path.isdir(temp_dir):
|
|
||||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,233 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# epf-dump v1.6 — Dump external data processor or report (EPF/ERF) to XML sources
|
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import atexit
|
|
||||||
import glob
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import random
|
|
||||||
import re
|
|
||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import tempfile
|
|
||||||
|
|
||||||
|
|
||||||
def _find_project_v8path():
|
|
||||||
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
|
||||||
d = os.getcwd()
|
|
||||||
while True:
|
|
||||||
pf = os.path.join(d, ".v8-project.json")
|
|
||||||
if os.path.isfile(pf):
|
|
||||||
try:
|
|
||||||
with open(pf, encoding="utf-8-sig") as f:
|
|
||||||
data = json.load(f)
|
|
||||||
v = data.get("v8path")
|
|
||||||
if v:
|
|
||||||
return v
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return None
|
|
||||||
parent = os.path.dirname(d)
|
|
||||||
if parent == d:
|
|
||||||
return None
|
|
||||||
d = parent
|
|
||||||
|
|
||||||
|
|
||||||
def _version_dir(p):
|
|
||||||
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
|
|
||||||
parent = os.path.dirname(p)
|
|
||||||
if os.path.basename(parent).lower() == "bin":
|
|
||||||
parent = os.path.dirname(parent)
|
|
||||||
return os.path.basename(parent)
|
|
||||||
|
|
||||||
|
|
||||||
def _version_key(p):
|
|
||||||
"""Numeric sort key from version dir name."""
|
|
||||||
return [int(x) for x in re.findall(r"\d+", _version_dir(p))]
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_v8path(v8path):
|
|
||||||
"""Resolve path to a 1C executable (1cv8; ibcmd only when given explicitly)."""
|
|
||||||
if not v8path:
|
|
||||||
v8path = _find_project_v8path()
|
|
||||||
if not v8path:
|
|
||||||
if os.name == "nt":
|
|
||||||
candidates = (
|
|
||||||
glob.glob(r"C:\Program Files\1cv8\*\bin\1cv8.exe")
|
|
||||||
+ glob.glob(r"C:\Program Files (x86)\1cv8\*\bin\1cv8.exe")
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# PY-only: PS-порт на *nix не исполняется, поэтому *nix-раскладки нет в .ps1.
|
|
||||||
candidates = glob.glob("/opt/1cv8/*/1cv8")
|
|
||||||
if candidates:
|
|
||||||
v8path = max(candidates, key=_version_key)
|
|
||||||
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
|
|
||||||
else:
|
|
||||||
print("Error: 1C executable not found. Specify -V8Path", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
if os.path.isdir(v8path):
|
|
||||||
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
|
|
||||||
exe = "1cv8.exe" if os.name == "nt" else "1cv8"
|
|
||||||
v8path = os.path.join(v8path, exe)
|
|
||||||
if not os.path.isfile(v8path):
|
|
||||||
print(f"Error: 1C executable not found at {v8path}", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
return v8path
|
|
||||||
|
|
||||||
|
|
||||||
IBCMD_NOUSER_HINT = (
|
|
||||||
"[ibcmd] No -UserName/-Password given; the infobase may require authentication. "
|
|
||||||
"On Windows ibcmd reads credentials from the console (stdin is ignored), so this "
|
|
||||||
"call may block instead of failing. If it does not return promptly, abort and "
|
|
||||||
"re-run with -UserName and -Password.\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
|
||||||
"""Run an ibcmd command non-interactively.
|
|
||||||
|
|
||||||
input="" closes stdin (EOF) so ibcmd's auth prompt fast-fails instead of hanging.
|
|
||||||
On Windows without -UserName ibcmd reads the console directly and may still block —
|
|
||||||
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
|
|
||||||
"""
|
|
||||||
if warn_no_user and os.name == "nt" and not has_username:
|
|
||||||
sys.stderr.write(IBCMD_NOUSER_HINT)
|
|
||||||
sys.stderr.flush()
|
|
||||||
return subprocess.run(cmd, input="", capture_output=True, encoding="utf-8", errors="replace")
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
sys.stdout.reconfigure(encoding="utf-8")
|
|
||||||
sys.stderr.reconfigure(encoding="utf-8")
|
|
||||||
parser = argparse.ArgumentParser(
|
|
||||||
description="Dump external data processor or report (EPF/ERF) to XML sources",
|
|
||||||
allow_abbrev=False,
|
|
||||||
)
|
|
||||||
parser.add_argument("-V8Path", default="", help="Path to 1cv8.exe or its bin directory")
|
|
||||||
parser.add_argument("-InfoBasePath", default="", help="Path to file infobase")
|
|
||||||
parser.add_argument("-InfoBaseServer", default="", help="1C server (for server infobase)")
|
|
||||||
parser.add_argument("-InfoBaseRef", default="", help="Infobase name on server")
|
|
||||||
parser.add_argument("-UserName", default="", help="1C user name")
|
|
||||||
parser.add_argument("-Password", default="", help="1C user password")
|
|
||||||
parser.add_argument("-InputFile", required=True, help="Path to EPF/ERF file")
|
|
||||||
parser.add_argument("-OutputDir", required=True, help="Directory for dumped XML sources")
|
|
||||||
parser.add_argument(
|
|
||||||
"-Format",
|
|
||||||
default="Hierarchical",
|
|
||||||
choices=["Hierarchical", "Plain"],
|
|
||||||
help="Dump format (default: Hierarchical)",
|
|
||||||
)
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
# --- Resolve V8Path ---
|
|
||||||
v8path = resolve_v8path(args.V8Path)
|
|
||||||
engine = "ibcmd" if os.path.basename(v8path).lower().startswith("ibcmd") else "1cv8"
|
|
||||||
|
|
||||||
# --- Validate database connection ---
|
|
||||||
if not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
|
|
||||||
print("Error: database connection required. Specify -InfoBasePath or -InfoBaseServer/-InfoBaseRef", file=sys.stderr)
|
|
||||||
print("Dump in an empty database loses reference types (CatalogRef, DocumentRef, etc.) irreversibly.")
|
|
||||||
sys.exit(1)
|
|
||||||
if engine == "ibcmd":
|
|
||||||
if not args.InfoBasePath:
|
|
||||||
print("Error: ibcmd supports file infobases only (use -InfoBasePath)", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
if args.Format == "Plain":
|
|
||||||
print("Error: ibcmd config export supports hierarchical format only (use -Format Hierarchical or 1cv8)", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
# --- Validate input file ---
|
|
||||||
if not os.path.isfile(args.InputFile):
|
|
||||||
print(f"Error: input file not found: {args.InputFile}", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
# --- Ensure output directory exists ---
|
|
||||||
if not os.path.exists(args.OutputDir):
|
|
||||||
os.makedirs(args.OutputDir, exist_ok=True)
|
|
||||||
|
|
||||||
# --- Temp dir ---
|
|
||||||
temp_dir = os.path.join(tempfile.gettempdir(), f"epf_dump_{random.randint(0, 999999)}")
|
|
||||||
os.makedirs(temp_dir, exist_ok=True)
|
|
||||||
|
|
||||||
try:
|
|
||||||
if engine == "ibcmd":
|
|
||||||
# --- ibcmd branch: dump EPF/ERF via config export --file ---
|
|
||||||
arguments = ["infobase", "config", "export", f"--file={args.InputFile}", args.OutputDir, f"--db-path={args.InfoBasePath}"]
|
|
||||||
ib_data = tempfile.mkdtemp(prefix="ibcmd_data_")
|
|
||||||
atexit.register(shutil.rmtree, ib_data, ignore_errors=True)
|
|
||||||
if args.UserName:
|
|
||||||
arguments.append(f"--user={args.UserName}")
|
|
||||||
if args.Password:
|
|
||||||
arguments.append(f"--password={args.Password}")
|
|
||||||
arguments.append(f"--data={ib_data}")
|
|
||||||
print(f"Running: ibcmd {' '.join(arguments)}")
|
|
||||||
result = run_ibcmd([v8path] + arguments, warn_no_user=False)
|
|
||||||
if result.returncode == 0:
|
|
||||||
print(f"External data processor/report dumped successfully to: {args.OutputDir}")
|
|
||||||
else:
|
|
||||||
print(f"Error dumping external data processor/report (code: {result.returncode})", file=sys.stderr)
|
|
||||||
if result.stdout:
|
|
||||||
print(result.stdout)
|
|
||||||
if result.stderr:
|
|
||||||
print(result.stderr, file=sys.stderr)
|
|
||||||
sys.exit(result.returncode)
|
|
||||||
|
|
||||||
# --- Build arguments ---
|
|
||||||
arguments = ["DESIGNER"]
|
|
||||||
|
|
||||||
if args.InfoBaseServer and args.InfoBaseRef:
|
|
||||||
arguments += ["/S", f"{args.InfoBaseServer}/{args.InfoBaseRef}"]
|
|
||||||
else:
|
|
||||||
arguments += ["/F", args.InfoBasePath]
|
|
||||||
|
|
||||||
if args.UserName:
|
|
||||||
arguments.append(f"/N{args.UserName}")
|
|
||||||
if args.Password:
|
|
||||||
arguments.append(f"/P{args.Password}")
|
|
||||||
|
|
||||||
arguments += ["/DumpExternalDataProcessorOrReportToFiles", args.OutputDir, args.InputFile]
|
|
||||||
arguments += ["-Format", args.Format]
|
|
||||||
|
|
||||||
# --- Output ---
|
|
||||||
out_file = os.path.join(temp_dir, "dump_log.txt")
|
|
||||||
arguments += ["/Out", out_file]
|
|
||||||
arguments.append("/DisableStartupDialogs")
|
|
||||||
|
|
||||||
# --- Execute ---
|
|
||||||
print(f"Running: 1cv8.exe {' '.join(arguments)}")
|
|
||||||
result = subprocess.run(
|
|
||||||
[v8path] + arguments,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
exit_code = result.returncode
|
|
||||||
|
|
||||||
# --- Result ---
|
|
||||||
if exit_code == 0:
|
|
||||||
print(f"Dump completed successfully to: {args.OutputDir}")
|
|
||||||
else:
|
|
||||||
print(f"Error dumping (code: {exit_code})", file=sys.stderr)
|
|
||||||
|
|
||||||
if os.path.isfile(out_file):
|
|
||||||
try:
|
|
||||||
with open(out_file, "r", encoding="utf-8-sig") as f:
|
|
||||||
log_content = f.read()
|
|
||||||
if log_content:
|
|
||||||
print("--- Log ---")
|
|
||||||
print(log_content)
|
|
||||||
print("--- End ---")
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
sys.exit(exit_code)
|
|
||||||
|
|
||||||
finally:
|
|
||||||
if os.path.exists(temp_dir):
|
|
||||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
# Оформление
|
|
||||||
|
|
||||||
Два независимых механизма: **оформление элемента** (постоянные цвета/шрифт/граница на конкретном элементе) и **условное оформление формы** (`conditionalAppearance` — правила, применяемые при выполнении условия).
|
|
||||||
|
|
||||||
## Оформление элемента (цвета / шрифты / граница)
|
|
||||||
|
|
||||||
Свойства задаются прямо на элементе. Применимо к полям (`input`/`check`/`radio`/`labelField`/`picField`/`calendar`), декорациям (`label`/`picture`), кнопкам (`button`), группам (`group`/`columnGroup`), страницам (`page`/`pages`), попапам (`popup`) и таблицам (`table`). Каждое свойство необязательно.
|
|
||||||
|
|
||||||
| Ключ | Что задаёт |
|
|
||||||
|------|-----------|
|
|
||||||
| `textColor` | Цвет текста |
|
|
||||||
| `backColor` | Цвет фона |
|
|
||||||
| `borderColor` | Цвет рамки |
|
|
||||||
| `font` | Шрифт |
|
|
||||||
| `border` | Граница |
|
|
||||||
| `titleTextColor` / `titleBackColor` / `titleFont` | Цвет текста / цвет фона / шрифт заголовка колонки (`labelField`, колонки таблицы); у `page`/`pages`/`popup` — `titleTextColor`/`titleFont` заголовка страницы/попапа |
|
|
||||||
| `footerTextColor` / `footerBackColor` / `footerFont` | Цвет текста / цвет фона / шрифт подвала колонки |
|
|
||||||
|
|
||||||
Те же свойства доступны и через словарь `appearance` элемента — под русскими именами параметров платформы: `ЦветТекста`, `ЦветФона`, `ЦветРамки`, `Шрифт`, `Граница`, `ЦветТекстаЗаголовка`, `ЦветФонаЗаголовка`, `ШрифтЗаголовка`, `ЦветТекстаПодвала`, `ЦветФонаПодвала`, `ШрифтПодвала`. Это та же запись, что и в правилах условного оформления (ниже) и в `appearance` поля дин-списка.
|
|
||||||
|
|
||||||
### Цвет
|
|
||||||
|
|
||||||
Строка в одной из форм:
|
|
||||||
|
|
||||||
| Форма | Значение |
|
|
||||||
|-------|----------|
|
|
||||||
| `web:Имя` | Цвет из web-палитры, напр. `web:Red`, `web:FireBrick`, `web:HoneyDew` |
|
|
||||||
| `win:Имя` | Системный цвет Windows, напр. `win:MenuBar`, `win:ButtonText`, `win:DisabledText` |
|
|
||||||
| `style:ИмяСтиля` | Ссылка на элемент стиля конфигурации/платформы, напр. `style:FormBackColor`, `style:BorderColor` |
|
|
||||||
| `#RRGGBB` | RGB-hex, напр. `#FF0000` |
|
|
||||||
|
|
||||||
Имя должно существовать в своей палитре (несуществующий web-/win-цвет или ссылка на отсутствующий `style:`-элемент — ошибка загрузки формы).
|
|
||||||
|
|
||||||
### Шрифт (`font` / `titleFont` / `footerFont`)
|
|
||||||
|
|
||||||
- Строка `"style:ИмяСтиля"` — шрифт из элемента стиля. Минимальная форма.
|
|
||||||
- Объект — задаются только нужные атрибуты:
|
|
||||||
|
|
||||||
| Ключ | Назначение |
|
|
||||||
|------|-----------|
|
|
||||||
| `ref` | Ссылка на стиль (`"style:X"`) или системный шрифт (`"sys:…"`) |
|
|
||||||
| `faceName` | Имя гарнитуры (для собственного шрифта) |
|
|
||||||
| `height` | Размер |
|
|
||||||
| `bold` / `italic` / `underline` / `strikeout` | `true`/`false` — начертание |
|
|
||||||
| `scale` | Масштаб, % |
|
|
||||||
| `kind` | `Absolute` (собственный шрифт — с `faceName`+`height`) / `WindowsFont` (системный — с `ref:"sys:…"`) |
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "label": "Внимание!", "textColor": "web:FireBrick",
|
|
||||||
"font": { "faceName": "Arial", "height": 12, "bold": true, "kind": "Absolute", "scale": 100 } }
|
|
||||||
```
|
|
||||||
|
|
||||||
### Граница (`border`)
|
|
||||||
|
|
||||||
- Строка `"style:ИмяСтиля"` (или объект `{ "ref": "style:X" }`) — граница из стиля.
|
|
||||||
- Объект `{ "width": N, "style": "..." }` — собственная граница. `style` — один из: `Single`, `Double`, `Underline`, `DoubleUnderline`, `Overline`, `Embossed`, `Indented`, `WithoutBorder`.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "input": "Цена", "path": "Объект.Цена", "textColor": "#FF0000",
|
|
||||||
"borderColor": "style:BorderColor", "border": { "width": 1, "style": "Single" } }
|
|
||||||
{ "labelField": "Код", "titleTextColor": "web:HoneyDew", "border": "style:ControlBorder" }
|
|
||||||
```
|
|
||||||
|
|
||||||
## Условное оформление формы (`conditionalAppearance`)
|
|
||||||
|
|
||||||
Форменный ключ верхнего уровня — массив правил. Каждое правило применяет оформление к перечисленным полям, когда выполняется его условие.
|
|
||||||
|
|
||||||
```json
|
|
||||||
"conditionalAppearance": [
|
|
||||||
{ "selection": ["ОбычноеПоле"], "filter": ["ЧисловоеПоле > 100"],
|
|
||||||
"appearance": { "ЦветФона": "style:FormBackColor" },
|
|
||||||
"presentation": { "ru": "Подсветка", "en": "Highlight" } }
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
| Ключ | Тип | Назначение |
|
|
||||||
|------|-----|-----------|
|
|
||||||
| `selection` | array | Имена форматируемых полей формы |
|
|
||||||
| `filter` | array | Условие применения (грамматика — ниже) |
|
|
||||||
| `appearance` | object | Словарь «параметр платформы: значение» |
|
|
||||||
| `presentation` | string / object | Подпись правила в списке настроек |
|
|
||||||
| `use` | bool | `false` — правило отключено |
|
|
||||||
| `viewMode` | string | Режим отображения настройки |
|
|
||||||
| `userSettingID` | string | Идентификатор пользовательской настройки; `"auto"` — сгенерировать |
|
|
||||||
|
|
||||||
### filter
|
|
||||||
|
|
||||||
Та же грамматика, что в отборе списка — shorthand `"Поле оператор значение @флаги"` или объект:
|
|
||||||
|
|
||||||
```json
|
|
||||||
"filter": [
|
|
||||||
"Статус = 3",
|
|
||||||
{ "field": "Сумма", "op": ">=", "value": 1000 },
|
|
||||||
{ "group": "Or", "items": [ "Просрочено = true", "Заблокирован = true" ] }
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
- **Операторы:** `=` `<>` `>` `>=` `<` `<=`, `in` / `notIn`, `inHierarchy`, `contains` / `notContains`, `beginsWith` / `notBeginsWith`, `like` / `notLike` (`%`-шаблон), `filled` / `notFilled`.
|
|
||||||
- **Флаги:** `@off` (отключён), `@user`, `@quickAccess`; `_` = пустое значение.
|
|
||||||
- **Группа:** `{ "group": "And"|"Or"|"Not", "items": [...], "use"? }`.
|
|
||||||
- **Дата-значение:** ISO-дата `"2024-01-01T00:00:00"` — фиксированная дата; именованный относительный период — строкой `"BeginningOfThisWeek"` с `"valueType": "v8:StandardBeginningDate"` (варианты `BeginningOfThisDay`/`BeginningOfThisWeek`/`BeginningOfThisMonth`/`BeginningOfThisYear`/…).
|
|
||||||
|
|
||||||
### appearance
|
|
||||||
|
|
||||||
Словарь «параметр платформы: значение». Имена параметров — русские: `ЦветТекста`, `ЦветФона`, `Шрифт`, `Граница`, `Текст`, `Заголовок`, `Формат`, `ВидимостьЭлемента`, `Доступность` и другие параметры оформления компоновки.
|
|
||||||
|
|
||||||
Значения:
|
|
||||||
- **Цвет** (`ЦветТекста`/`ЦветФона`/…) и **шрифт** (`Шрифт`) — те же формы, что в оформлении элемента выше (`web:`/`win:`/`style:`/`#RRGGBB`; шрифт — строка `"style:X"` или объект).
|
|
||||||
- **Текстовые параметры** (`Текст`/`Заголовок`/`Формат`) — по форме значения:
|
|
||||||
- голая строка → нелокализованный литерал (`""` → пустое значение);
|
|
||||||
- объект `{ "ru": "...", "en": "..." }` → локализуемая строка;
|
|
||||||
- объект `{ "field": "путь" }` → ссылка на поле компоновки.
|
|
||||||
|
|
||||||
```json
|
|
||||||
"conditionalAppearance": [
|
|
||||||
{ "selection": ["Остаток"], "filter": ["Остаток < 0"],
|
|
||||||
"appearance": { "ЦветТекста": "web:Red", "Шрифт": { "bold": true } } },
|
|
||||||
{ "selection": ["Комментарий"], "filter": ["Комментарий notFilled"],
|
|
||||||
"appearance": { "Текст": { "ru": "— нет данных —" }, "ЦветТекста": "win:DisabledText" } }
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
> Условное оформление **самого дин-списка** задаётся не здесь, а в `settings.conditionalAppearance` реквизита-списка — см. `references/dynamic-list.md`.
|
|
||||||
@@ -1,143 +0,0 @@
|
|||||||
# Диаграммы, диаграмма Ганта, планировщик
|
|
||||||
|
|
||||||
Поле-диаграмма (`chart` / `ganttChart`), поле-планировщик (`planner`) и дендрограмма выводят значение из реквизита соответствующего типа. Конструкция всегда двойная:
|
|
||||||
|
|
||||||
1. **Реквизит** chart/planner-типа (несёт данные и, при необходимости, design-time конфиг).
|
|
||||||
2. **Элемент** формы, привязанный к реквизиту через `path`.
|
|
||||||
|
|
||||||
Минимум — реквизит нужного типа плюс элемент с тем же `path`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
"attributes": [ { "name": "Диаграмма", "type": "d5p1:Chart" } ],
|
|
||||||
"items": [ { "chart": "ПолеДиаграммы", "path": "Диаграмма" } ]
|
|
||||||
```
|
|
||||||
|
|
||||||
Реквизит, заполняемый в коде (без встроенной настройки), достаточно объявить типом — элемент привязывается и работает.
|
|
||||||
|
|
||||||
## Типы реквизита и элемента
|
|
||||||
|
|
||||||
| Элемент | Ключ типа | Тип реквизита | Что несёт элемент дополнительно |
|
|
||||||
|---------|-----------|---------------|---------------------------------|
|
|
||||||
| Диаграмма | `chart` | `d5p1:Chart` | — |
|
|
||||||
| Диаграмма Ганта | `ganttChart` | `d5p1:GanttChart` | `ganttTable` — вложенная таблица (см. ниже) |
|
|
||||||
| Планировщик | `planner` | `pl:Planner` | — |
|
|
||||||
| График. схема | `graphicalSchema` | `d5p1:FlowchartContextType` | `edit`, `warningOnEditRepresentation` |
|
|
||||||
| Период | `periodField` | `v8:StandardPeriod` | — |
|
|
||||||
| Дендрограмма | `dendrogram` | — | — |
|
|
||||||
|
|
||||||
Имя элемента — значение ключа (`"chart": "ПолеДиаграммы"`); `path` — короткое имя реквизита.
|
|
||||||
|
|
||||||
### Элемент диаграммы Ганта (`ganttTable`)
|
|
||||||
|
|
||||||
У поля Ганта внутри лежит полноценная таблица — задаётся ключом `ganttTable` (та же грамматика, что у обычной `table`):
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "ganttChart": "Ганта", "path": "Ганта",
|
|
||||||
"ganttTable": { "table": "ТаблицаГанта", "path": "Ганта", "height": 3 } }
|
|
||||||
```
|
|
||||||
|
|
||||||
## Design-time конфиг диаграммы (`chart`)
|
|
||||||
|
|
||||||
Реквизит типа `d5p1:Chart` / `d5p1:GanttChart` может нести встроенную настройку диаграммы — объект `chart` на реквизите. Платформа всегда пишет полный набор свойств (~127: тип, серии, легенда, заголовок, шкалы, цвета, шрифты, оси), поэтому **авторинг с нуля непрактичен** — возьмите рабочую диаграмму за основу и правьте смысловое ядро.
|
|
||||||
|
|
||||||
Ключи `chart` = канонические имена свойств диаграммы; задавайте только те, что меняете:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "name": "Диаграмма", "type": "d5p1:Chart", "chart": {
|
|
||||||
"chartType": "Line",
|
|
||||||
"isSeriesDesign": true, "realSeriesCount": "2",
|
|
||||||
"realSeriesData": [
|
|
||||||
{ "id": "1", "color": "auto", "line": {"width":2,"gap":false,"style":"Solid"},
|
|
||||||
"marker": "Auto", "text": "Серия 1", "strIsChanged": false, "isExpand": false,
|
|
||||||
"isIndicator": false, "colorPriority": false }
|
|
||||||
],
|
|
||||||
"isShowTitle": true, "title": "Продажи",
|
|
||||||
"isShowLegend": true, "legendPlacement": "Bottom",
|
|
||||||
"paletteKind": "Auto"
|
|
||||||
} }
|
|
||||||
```
|
|
||||||
|
|
||||||
Смысловое ядро для правки:
|
|
||||||
|
|
||||||
| Ключ | Назначение |
|
|
||||||
|------|------------|
|
|
||||||
| `chartType` | Тип: `Line` / `Pie` / `Bar` / `Histogram` / `Column` / `Area` / … |
|
|
||||||
| `realSeriesData` | Массив серий — объекты `{ id, text, color, line, marker, … }` |
|
|
||||||
| `isShowTitle` + `title` | Показ и текст заголовка |
|
|
||||||
| `isShowLegend` + `legendPlacement` | Показ и расположение легенды (`Bottom` / `Right` / …) |
|
|
||||||
| `paletteKind` | Палитра (`Auto` / …) |
|
|
||||||
| `bkgColor` / `labelsColor` / … | Базовые цвета |
|
|
||||||
|
|
||||||
Формы значений внутри `chart`:
|
|
||||||
|
|
||||||
- **Цвета** — verbatim: `auto`, `style:ИмяСтиля`, `web:Red`, `#hex`.
|
|
||||||
- **`line`** — `{ width, gap, style }` (стиль линии: `Solid` / …).
|
|
||||||
- **`border`** — `{ width, style }`.
|
|
||||||
- **`font`** — `{ kind: "AutoFont" }` либо атрибуты шрифта.
|
|
||||||
- **Локализуемые строки** (`title`, `vsFormat`, `lbFormat`, `labelFormat`, серия `text`, …) — голая строка либо `{ "ru": "…", "en": "…" }`.
|
|
||||||
- **Области** (`elementsChart` / `elementsLegend` / `elementsTitle`) — `{ left, right, top, bottom }`.
|
|
||||||
- **Серии** (`realSeriesData` / `realExSeriesData`) — массивы объектов.
|
|
||||||
|
|
||||||
Любое из ~127 свойств переопределяется по каноническому имени; остальное оставляйте дефолтным (не указывайте — берётся из основы).
|
|
||||||
|
|
||||||
### Диаграмма Ганта (`d5p1:GanttChart`)
|
|
||||||
|
|
||||||
Реквизит типа `d5p1:GanttChart` использует **тот же** ключ `chart`. Внутри — вложенный полный `chart`-блок плюс гант-специфика (`points` / `series` / `timeScale` / `drawEmpty` / …). Так же берите рабочую диаграмму Ганта за основу.
|
|
||||||
|
|
||||||
> **Ограничение.** Диаграммы (Chart/Gantt) с заполненными **точками/осями** (`realPointData` / `realDataItems`, заполненные `valuesAxis` / `pointsAxis`) генерик-движком не поддержаны — это редкий вариант. Частые дашборд-диаграммы и диаграммы Ганта (серии / легенда / оформление / шкалы) поддержаны полностью.
|
|
||||||
|
|
||||||
## Design-time конфиг планировщика (`planner`)
|
|
||||||
|
|
||||||
Реквизит типа `pl:Planner` несёт встроенную настройку планировщика — объект `planner`. Компилятор подставляет умолчания для пропущенных ключей, поэтому авторинг может быть кратким:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "name": "Планировщик", "type": "pl:Planner", "planner": {
|
|
||||||
"items": [
|
|
||||||
{ "text": "Встреча", "begin": "2026-06-09T01:00:00", "end": "2026-06-09T04:00:00",
|
|
||||||
"borderColor": "auto", "backColor": "auto", "deleted": false, "editMode": "EnableEdit" }
|
|
||||||
],
|
|
||||||
"period": { "begin": "2026-06-09T00:00:00", "end": "2026-06-09T23:59:59" },
|
|
||||||
"displayCurrentDate": true, "itemsTimeRepresentation": "BeginTime",
|
|
||||||
"timeScale": { "placement": "Left", "levels": [ { "measure": "Hour", "interval": 1 } ] }
|
|
||||||
} }
|
|
||||||
```
|
|
||||||
|
|
||||||
Минимум — один `item`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
"planner": { "items": [ { "text": "Встреча", "begin": "2026-06-09T01:00:00", "end": "2026-06-09T04:00:00" } ] }
|
|
||||||
```
|
|
||||||
|
|
||||||
| Ключ `planner` | Тип | Назначение |
|
|
||||||
|----------------|-----|------------|
|
|
||||||
| `items` | array | Элементы расписания. Поля элемента: `text`, `tooltip`, `begin`, `end`, `value`, `borderColor`, `backColor`, `textColor`, `font`, `border`, `replacementDate`, `deleted` (bool), `editMode` (`EnableEdit` / …), `id` (необязательно — авто-GUID), `textFormatted` |
|
|
||||||
| `dimensions` | array | Измерения (разрезы) планировщика. Поля: `value` (объект разреза — ссылка `Enum.X.EnumValue.Y` / `Справочник.X`; опустить → пусто), `text` (заголовок), `borderColor`, `backColor`, `textColor`, `font`, `textFormatted`, `elements`. `elements` — элементы измерения, рекурсивны (могут нести вложенные `elements`): `value`, `text`, цвета, `font`, `showOnlySubordinatesAreas` (bool), `textFormatted` |
|
|
||||||
| `period` | object | Отображаемый период `{ begin, end }` (необязательно) |
|
|
||||||
| `timeScale` | object | Шкала времени (см. ниже) |
|
|
||||||
| `borderColor` / `backColor` / `textColor` / `lineColor` | color | Цвета (умолч. `auto`) |
|
|
||||||
| `font` | font | Шрифт (умолч. `{ kind: "AutoFont" }`) |
|
|
||||||
| `border` | border | Рамка `{ width, style }` |
|
|
||||||
| `beginOfRepresentationPeriod` / `endOfRepresentationPeriod` | dateTime | Период представления |
|
|
||||||
| `displayCurrentDate` / `displayWrapHeaders` / `displayTimeScaleWrapHeaders` / `alignElementsOfTimeScale` | bool | Флаги отображения |
|
|
||||||
| `timeScaleWrapHeadersFormat` | ML | Формат перенесённых заголовков шкалы |
|
|
||||||
| `timeScaleWrapBeginIndent` / `timeScaleWrapEndIndent` | int | Отступы переноса шкалы |
|
|
||||||
| `periodicVariantUnit` / `periodicVariantRepetition` | value / int | Единица и кратность периодического варианта |
|
|
||||||
| `itemsTimeRepresentation` | value | Представление времени элементов (`BeginTime` / …) |
|
|
||||||
| `itemsBehaviorWhenSpaceInsufficient` / `newItemsTextType` / `fixDimensionsHeader` / `fixTimeScaleHeader` | value | Поведение элементов и заголовков |
|
|
||||||
| `autoMinColumnWidth` / `autoMinRowHeight` | bool | Авто-минимум размеров |
|
|
||||||
| `minColumnWidth` / `minRowHeight` | int | Минимальные размеры |
|
|
||||||
|
|
||||||
Шкала времени (`timeScale`):
|
|
||||||
|
|
||||||
```json
|
|
||||||
"timeScale": {
|
|
||||||
"placement": "Left",
|
|
||||||
"levels": [ { "measure": "Hour", "interval": 1 } ]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Ключи: `placement`, `levels` (массив уровней), `transparent`, `backColor`, `textColor`, `currentLevel`. Уровень: `measure` (`Hour` / `Day` / …), `interval`, `show`, `line` (`{ width, gap, style }`), `scaleColor`, `dayFormatRule`, `format` (ML), `labels` (`{ ticks }`), `backColor`, `textColor`, `showPereodicalLabels`.
|
|
||||||
|
|
||||||
Формы значений в `planner` те же, что у диаграммы: цвета verbatim (`auto` / `style:X` / `web:Red` / `#hex`); шрифт `{ kind: "AutoFont" }` либо ref-строка; граница `{ width, style }`; ML-форматы — строка или `{ "ru": …, "en": … }`.
|
|
||||||
|
|
||||||
> **Ограничение.** Привязка элемента расписания к элементам измерений (`item.dimensionValues`) пока всегда пустая. Сами измерения (`dimensions`) задавать можно.
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
# Параметры выбора и связь по типу
|
|
||||||
|
|
||||||
Свойства поля ввода (`input`), управляющие выбором значения: чем ограничен список выбора и каким будет тип значения. Имена параметров — строки 1С как есть (`"Отбор.Х"`).
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "input": "Контрагент", "path": "Объект.Контрагент",
|
|
||||||
"choiceParameters": [
|
|
||||||
{ "name": "Отбор.Активный", "value": true },
|
|
||||||
{ "name": "Отбор.ВидПродукции", "value": ["Enum.Виды.Агрохимикат", "Enum.Виды.Пестицид"] }
|
|
||||||
],
|
|
||||||
"choiceParameterLinks": [
|
|
||||||
{ "name": "Отбор.Организация", "dataPath": "Объект.Организация" },
|
|
||||||
{ "name": "Отбор.Тип", "dataPath": "Объект.Тип", "valueChange": "DontChange" }
|
|
||||||
],
|
|
||||||
"typeLink": { "dataPath": "Объект.ЗначениеДата", "linkItem": 0 }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Параметры выбора (`choiceParameters`)
|
|
||||||
|
|
||||||
Фиксированные значения параметров выбора, отбирающие список значений независимо от данных формы. Массив объектов `{ name, value }`:
|
|
||||||
|
|
||||||
- `name` — имя параметра (`"Отбор.Активный"`).
|
|
||||||
- `value` — значение. Допустимы: bool, число, строка, ISO-дата (`"2020-01-01T00:00:00"`), ссылка-путь (`Enum.X.Y`, `Catalog.X`). **Массив** значений задаёт фиксированный массив.
|
|
||||||
|
|
||||||
Короткая форма — строки `"name=value"`; значение с запятыми становится массивом, `true`/`false` → bool, число → число, остальное → строка/ссылка:
|
|
||||||
|
|
||||||
```json
|
|
||||||
"choiceParameters": [
|
|
||||||
"Отбор.Активный=true",
|
|
||||||
"Отбор.ВидПродукции=Enum.Виды.Агрохимикат, Enum.Виды.Пестицид"
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
## Связи параметров выбора (`choiceParameterLinks`)
|
|
||||||
|
|
||||||
Параметры выбора, значение которых берётся из **другого поля формы** (а не задано фиксированно). Типовой случай — отбор списка договоров по выбранному контрагенту. Массив объектов `{ name, dataPath, valueChange? }`:
|
|
||||||
|
|
||||||
- `name` — имя параметра выбора.
|
|
||||||
- `dataPath` — путь к полю формы, чьё значение подставляется в параметр.
|
|
||||||
- `valueChange` — что делать с уже выбранным значением при смене источника: `Clear` (очистить, необязательно — поведение по умолчанию) / `DontChange` (не менять).
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "input": "Договор", "path": "Объект.Договор",
|
|
||||||
"choiceParameterLinks": [
|
|
||||||
{ "name": "Отбор.Владелец", "dataPath": "Объект.Контрагент" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Короткая форма — строки `"name=dataPath"`, опциональный хвост `:Clear` / `:DontChange`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
"choiceParameterLinks": [ "Отбор.Организация=Объект.Организация", "Отбор.Тип=Объект.Тип:DontChange" ]
|
|
||||||
```
|
|
||||||
|
|
||||||
## Связь по типу (`typeLink`)
|
|
||||||
|
|
||||||
Тип значения поля определяется другим полем формы (напр. поле «Значение» субконто, тип которого задаётся выбранным видом субконто). Объект `{ dataPath, linkItem }`:
|
|
||||||
|
|
||||||
- `dataPath` — путь к полю, задающему тип.
|
|
||||||
- `linkItem` — индекс элемента связи (необязательно, по умолчанию `0`).
|
|
||||||
|
|
||||||
```json
|
|
||||||
"typeLink": { "dataPath": "Объект.ВидСубконто", "linkItem": 0 }
|
|
||||||
```
|
|
||||||
|
|
||||||
Короткая форма — строка `"dataPath"` либо `"dataPath#linkItem"`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
"typeLink": "Объект.ВидСубконто"
|
|
||||||
"typeLink": "Объект.ВидСубконто#1"
|
|
||||||
```
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
# Командный интерфейс формы
|
|
||||||
|
|
||||||
Форменный ключ `commandInterface` управляет расстановкой команд по двум панелям формы:
|
|
||||||
|
|
||||||
- `commandBar` — командная панель формы;
|
|
||||||
- `navigationPanel` — панель навигации.
|
|
||||||
|
|
||||||
Указывать нужно **только команды, у которых меняется расстановка по умолчанию** (видимость, группа, порядок). Команды, которые платформа размещает автоматически и без изменений, в блок не включают.
|
|
||||||
|
|
||||||
```json
|
|
||||||
"commandInterface": {
|
|
||||||
"commandBar": [
|
|
||||||
{ "command": "Form.Command.Печать", "defaultVisible": false, "group": "FormCommandBarImportant",
|
|
||||||
"visible": { "common": false, "roles": { "Бухгалтер": true } } },
|
|
||||||
"CommonCommand.История"
|
|
||||||
],
|
|
||||||
"navigationPanel": {
|
|
||||||
"important": [ { "command": "CommonCommand.СвязанныеДокументы", "defaultVisible": false, "visible": false } ],
|
|
||||||
"seeAlso": [ { "command": "CommonCommand.Заметки", "defaultVisible": false, "visible": false } ]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Элемент-команда
|
|
||||||
|
|
||||||
Каждый элемент панели — объект, либо строка-shorthand (= голый `command` со всеми остальными свойствами по умолчанию):
|
|
||||||
|
|
||||||
```json
|
|
||||||
"CommonCommand.История"
|
|
||||||
```
|
|
||||||
|
|
||||||
| Ключ | Тип | Назначение |
|
|
||||||
|------|-----|-----------|
|
|
||||||
| `command` | string | Ссылка на команду дословно: `CommonCommand.X`, `Document.X.StandardCommand.Y`, `Form.Command.X`, `Form.StandardCommand.OK`, `"0"` (пустой / разделитель) |
|
|
||||||
| `type` | string | `Auto` (по умолчанию, необязательно) или `Added` |
|
|
||||||
| `defaultVisible` | bool | Видимость по умолчанию. На практике задаётся только `false` — чтобы скрыть команду, которая иначе видна |
|
|
||||||
| `visible` | bool / object | Видимость с исключениями по ролям: `bool` либо `{ "common": bool, "roles": { "Имя": bool } }` |
|
|
||||||
| `group` | string | Группа размещения дословно: предопределённая (`FormCommandBarImportant`, `FormNavigationPanelGoTo`, …), именованная (`CommandGroup.X`) или GUID-группа расширения |
|
|
||||||
| `index` | int | Порядок команды внутри группы |
|
|
||||||
| `attribute` | string | Путь реквизита для элемента панели навигации |
|
|
||||||
|
|
||||||
## Две формы записи панели
|
|
||||||
|
|
||||||
Панель можно описать **плоским массивом** или **деревом по группам** — выбирайте любую.
|
|
||||||
|
|
||||||
**Плоский массив** — каждый элемент при необходимости несёт собственный `group`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
"commandBar": [
|
|
||||||
{ "command": "Form.Command.Печать", "group": "FormCommandBarImportant", "defaultVisible": false },
|
|
||||||
{ "command": "CommonCommand.История", "group": "FormCommandBarImportant", "index": 1 }
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
**Дерево** — объект `{ группа: [команды] }`; группа берётся из ключа, элементы её не повторяют:
|
|
||||||
|
|
||||||
```json
|
|
||||||
"navigationPanel": {
|
|
||||||
"important": [ "CommonCommand.СвязанныеДокументы" ],
|
|
||||||
"goTo": [ { "command": "Document.Заказ.StandardCommand.Movements", "defaultVisible": false, "visible": false } ],
|
|
||||||
"seeAlso": [ "CommonCommand.Заметки" ]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Ключи-группы дерева зависят от панели:
|
|
||||||
|
|
||||||
- `navigationPanel`: `important`, `goTo`, `seeAlso` (можно по-русски — `важное`, `перейти`, `смТакже`);
|
|
||||||
- `commandBar`: `important`, `createBasedOn`;
|
|
||||||
- любой другой ключ (`CommandGroup.X` или GUID) подставляется в группу дословно.
|
|
||||||
|
|
||||||
## Скрыть видимую команду
|
|
||||||
|
|
||||||
Самый частый случай — убрать команду, которую платформа показывает по умолчанию:
|
|
||||||
|
|
||||||
```json
|
|
||||||
"commandBar": [
|
|
||||||
{ "command": "Form.Command.Печать", "defaultVisible": false, "visible": false }
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
Показать команду только некоторым ролям:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "command": "Form.Command.Печать", "defaultVisible": false,
|
|
||||||
"visible": { "common": false, "roles": { "Бухгалтер": true } } }
|
|
||||||
```
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
# Companion-панели и расширенная подсказка элемента
|
|
||||||
|
|
||||||
Любой элемент формы может нести свой собственный контент в трёх companion-свойствах: расширенную подсказку (`extendedTooltip`), командную панель (`commandBar`) и контекстное меню (`contextMenu`). Все три задаются ключами прямо на объекте элемента.
|
|
||||||
|
|
||||||
```jsonc
|
|
||||||
{ "table": "Список", "path": "Список",
|
|
||||||
"commandBar": { "children": [ … ] },
|
|
||||||
"contextMenu": { "children": [ … ] },
|
|
||||||
"extendedTooltip": "Двойной клик открывает карточку" }
|
|
||||||
```
|
|
||||||
|
|
||||||
## Расширенная подсказка (`extendedTooltip`)
|
|
||||||
|
|
||||||
Подсказка-надпись рядом с элементом. Две формы записи.
|
|
||||||
|
|
||||||
**Текст-форма** — просто текст подсказки:
|
|
||||||
|
|
||||||
```jsonc
|
|
||||||
"extendedTooltip": "Укажите ИНН контрагента"
|
|
||||||
"extendedTooltip": { "ru": "Сумма с НДС", "en": "Amount incl. VAT" }
|
|
||||||
"extendedTooltip": { "text": "Всего <b>с НДС</b>", "formatted": true }
|
|
||||||
```
|
|
||||||
|
|
||||||
- строка — ru-текст;
|
|
||||||
- `{ "ru": …, "en": … }` — многоязычный (как `title`);
|
|
||||||
- `{ "text": …, "formatted": true }` — форматированный текст (inline-разметка 1С: `<b>…</>`, `<i>`, `<u>`, `<color web:Red>…</>`, `<bgColor …>`, `<font …>`, `<fontSize …>`, `<link URL>…</>`, `<img …>`; закрывающий тег — `</>`). `formatted` нужен только когда текст содержит такую разметку.
|
|
||||||
|
|
||||||
**Own-content форма** — объект с раскладкой/оформлением/флагами, когда подсказке нужны размеры, цвет, гиперссылка и т.п.:
|
|
||||||
|
|
||||||
```jsonc
|
|
||||||
"extendedTooltip": {
|
|
||||||
"text": "Перейти к инструкции",
|
|
||||||
"hyperlink": true,
|
|
||||||
"textColor": "web:Blue",
|
|
||||||
"events": { "URLProcessing": "ПодсказкаОбработкаНавигационнойСсылки" }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Ключи own-content объекта (все необязательны):
|
|
||||||
|
|
||||||
| Ключ | Тип | Назначение |
|
|
||||||
|------|-----|-----------|
|
|
||||||
| `text` | string/ML | Текст подсказки (с `formatted` — форматированный) |
|
|
||||||
| `formatted` | bool | Интерпретировать inline-разметку в `text` |
|
|
||||||
| `tooltip` | string/ML | Всплывающая подсказка самой расширенной подсказки (редко; ≠ обычному `tooltip` элемента) |
|
|
||||||
| `hyperlink` | bool | Сделать подсказку гиперссылкой |
|
|
||||||
| `visible` / `enabled` | bool | Видимость / доступность подсказки |
|
|
||||||
| `width` / `height` | number | Размеры |
|
|
||||||
| `maxWidth` / `autoMaxWidth` | number / bool | Максимальная ширина / авто-максимум |
|
|
||||||
| `titleHeight` | number | Высота заголовка |
|
|
||||||
| `horizontalStretch` | bool | Горизонтальное растяжение |
|
|
||||||
| `verticalAlign` | string | Вертикальное выравнивание |
|
|
||||||
| `textColor` / `font` | string/object | Цвет текста / шрифт (см. `references/appearance.md`) |
|
|
||||||
| `events` | object | Обработчики событий подсказки, напр. `{ "URLProcessing": "Имя" }` у гиперссылочной подсказки |
|
|
||||||
|
|
||||||
## Командная панель (`commandBar`)
|
|
||||||
|
|
||||||
Собственная командная панель элемента (обычно таблицы или группы).
|
|
||||||
|
|
||||||
**Значение** — массив или объект:
|
|
||||||
|
|
||||||
```jsonc
|
|
||||||
"commandBar": [ { "button": "Создать", "command": "СоздатьЭлемент" } ]
|
|
||||||
|
|
||||||
"commandBar": {
|
|
||||||
"autofill": false,
|
|
||||||
"horizontalAlign": "Right",
|
|
||||||
"children": [
|
|
||||||
{ "button": "Создать", "command": "СоздатьЭлемент" },
|
|
||||||
{ "buttonGroup": "Печать", "children": [ … ] }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- массив `[ … ]` — краткая запись для `{ "children": [ … ] }`;
|
|
||||||
- объект — `children` плюс необязательные `autofill` и `horizontalAlign`.
|
|
||||||
|
|
||||||
| Ключ | Тип | Назначение |
|
|
||||||
|------|-----|-----------|
|
|
||||||
| `children` | array | Содержимое панели — обычная грамматика кнопок (см. основную инструкцию) |
|
|
||||||
| `autofill` | bool | `false` — подавить автозаполнение панели стандартными командами. Необязательно (по умолчанию панель автозаполняется) |
|
|
||||||
| `horizontalAlign` | string | Горизонтальное выравнивание содержимого: `Left` / `Center` / `Right`. Необязательно |
|
|
||||||
|
|
||||||
`children` — кнопки: `button` (с `command` / `commandName` / `stdCommand`), `buttonGroup`, `popup` — как в основной инструкции по кнопкам.
|
|
||||||
|
|
||||||
> Для таблицы динамического списка панель по умолчанию подавлена (чтобы не дублировать командную панель формы). Чтобы оставить автозаполняемую панель у самой таблицы — задайте `commandBar: { "autofill": true }`.
|
|
||||||
|
|
||||||
## Контекстное меню (`contextMenu`)
|
|
||||||
|
|
||||||
Собственное контекстное меню элемента. Грамматика та же, что у `commandBar`, но без `horizontalAlign`.
|
|
||||||
|
|
||||||
```jsonc
|
|
||||||
"contextMenu": [ { "button": "Карта маршрута", "commandName": "CommonCommand.КартаМаршрута" } ]
|
|
||||||
|
|
||||||
"contextMenu": {
|
|
||||||
"autofill": false,
|
|
||||||
"children": [
|
|
||||||
{ "button": "Скопировать ссылку", "command": "СкопироватьСсылку" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
| Ключ | Тип | Назначение |
|
|
||||||
|------|-----|-----------|
|
|
||||||
| `children` | array | Пункты меню — обычная грамматика кнопок |
|
|
||||||
| `autofill` | bool | `false` — подавить автозаполнение меню. Необязательно |
|
|
||||||
|
|
||||||
## Пример: таблица со своим меню и инфо-баннером
|
|
||||||
|
|
||||||
```jsonc
|
|
||||||
{ "table": "Заказы", "path": "Объект.Заказы",
|
|
||||||
"extendedTooltip": {
|
|
||||||
"text": "Строки с просрочкой выделены <color web:FireBrick>красным</>",
|
|
||||||
"formatted": true
|
|
||||||
},
|
|
||||||
"commandBar": {
|
|
||||||
"autofill": false,
|
|
||||||
"horizontalAlign": "Right",
|
|
||||||
"children": [
|
|
||||||
{ "button": "Добавить", "command": "ДобавитьЗаказ" },
|
|
||||||
{ "button": "Удалить", "command": "УдалитьЗаказ" }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"contextMenu": {
|
|
||||||
"children": [
|
|
||||||
{ "button": "Открыть документ", "command": "ОткрытьЗаказ" },
|
|
||||||
{ "buttonGroup": "Экспорт", "children": [
|
|
||||||
{ "button": "В Excel", "command": "ВыгрузитьВExcel" } ] }
|
|
||||||
]
|
|
||||||
} }
|
|
||||||
```
|
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
# Динамический список
|
|
||||||
|
|
||||||
Реквизит с `type: "DynamicList"` (обычно `main: true`) — основа формы списка. Объект `settings` описывает источник данных и настройки списка. Минимум — указать источник:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "name": "Список", "type": "DynamicList", "main": true,
|
|
||||||
"settings": { "mainTable": "Catalog.Контрагенты" } }
|
|
||||||
```
|
|
||||||
|
|
||||||
К списку привязывается таблица-элемент (`table`), ссылающаяся на реквизит через `path` — см. основную инструкцию.
|
|
||||||
|
|
||||||
## Источник данных
|
|
||||||
|
|
||||||
Два взаимоисключающих режима:
|
|
||||||
|
|
||||||
**Таблично-ориентированный** — основная таблица метаданных:
|
|
||||||
|
|
||||||
```json
|
|
||||||
"settings": { "mainTable": "Catalog.Контрагенты" }
|
|
||||||
```
|
|
||||||
|
|
||||||
**Запросный** — произвольный запрос:
|
|
||||||
|
|
||||||
```json
|
|
||||||
"settings": {
|
|
||||||
"query": "ВЫБРАТЬ Т.Ссылка, Т.Наименование, Т.Сумма ИЗ Документ.Заказ КАК Т ГДЕ Т.Сумма > &Порог",
|
|
||||||
"mainTable": "Document.Заказ"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
| Ключ | Тип | Назначение |
|
|
||||||
|------|-----|-----------|
|
|
||||||
| `mainTable` | string | Основная таблица (`Catalog.X` / `Document.X` / …). Можно вместе с `query` |
|
|
||||||
| `query` | string | Текст запроса. Поддерживает `@file.sql` (путь к файлу запроса рядом с JSON) |
|
|
||||||
| `keyType` | string | Запросный список без `mainTable`: тип ключа набора — `FieldValue` / `RowKey` / `RowNumber` |
|
|
||||||
| `keyFields` | array | Поля ключа набора (для `keyType` без `mainTable`) |
|
|
||||||
|
|
||||||
Параметры запроса (`&Имя`) задаются в `parameters` (ниже).
|
|
||||||
|
|
||||||
`"dynamicDataRead": false` отключает динамическое считывание (список читается обычным запросом, без фонового обновления) — нужно для тяжёлых/агрегатных запросов.
|
|
||||||
|
|
||||||
## Параметры запроса (`parameters`)
|
|
||||||
|
|
||||||
Значения для `&параметров` текста запроса. Shorthand `"Имя [Заголовок]: тип = Значение"` (всё кроме имени необязательно) либо объект:
|
|
||||||
|
|
||||||
```json
|
|
||||||
"settings": {
|
|
||||||
"query": "… ГДЕ Т.Артикул = &Артикул И Т.Цена ПОДОБНО &Маска",
|
|
||||||
"parameters": [
|
|
||||||
"Артикул",
|
|
||||||
"Маска: string = %",
|
|
||||||
{ "name": "ВидЦен", "valueListAllowed": true },
|
|
||||||
{ "name": "Период", "type": "dateTime" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Ключи объекта: `name`, `title`, `type` (грамматика типов — см. основную инструкцию), `value`, `valueListAllowed` (разрешить список значений), `availableValues` (`[{ value, presentation }]`), `expression`, `use`.
|
|
||||||
|
|
||||||
## Значения параметров в настройках (`dataParameters`)
|
|
||||||
|
|
||||||
Предустановленные значения параметров на уровне настроек списка. Shorthand `"Имя = Значение"` или объект `{ parameter, value?, use?, viewMode? }`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
"dataParameters": [ "Организация = _", "ВидЦен" ]
|
|
||||||
```
|
|
||||||
|
|
||||||
## Поля набора (`fields`)
|
|
||||||
|
|
||||||
Обычно поля выводятся из источника сами — `fields` нужен **только чтобы переопределить** свойства отдельного поля:
|
|
||||||
|
|
||||||
```json
|
|
||||||
"fields": [
|
|
||||||
{ "field": "Сумма", "title": "Сумма, руб", "appearance": { "Формат": "ЧДЦ=2" } },
|
|
||||||
{ "field": "Остаток", "valueType": "number(15,2)" }
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
Ключи поля: `field`, `dataPath`, `title`, `valueType`, `appearance` (как в условном оформлении), `presentationExpression`, `inputParameters` (связь по параметрам выбора), `typeLink` (`{ field, linkItem }` — связь по типу, напр. субконто).
|
|
||||||
|
|
||||||
## Вычисляемые поля (`calculatedFields`)
|
|
||||||
|
|
||||||
Поля, считаемые выражением. Shorthand `"Имя [Заголовок]: тип = Выражение"`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
"calculatedFields": [
|
|
||||||
"Метка = Code + \" \" + Description",
|
|
||||||
"Маржа [Маржа, руб]: number(15,2) = Цена - Закупка"
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
Объектная форма — для `presentationExpression` / `orderExpression`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "dataPath": "Сорт", "expression": "Code", "title": "Сорт",
|
|
||||||
"valueType": "string(10)", "presentationExpression": "Code" }
|
|
||||||
```
|
|
||||||
|
|
||||||
## Отбор (`filter`)
|
|
||||||
|
|
||||||
Shorthand `"Поле оператор значение @флаги"` или объект:
|
|
||||||
|
|
||||||
```json
|
|
||||||
"filter": [
|
|
||||||
"Организация = _ @off @user",
|
|
||||||
"Сумма > 1000",
|
|
||||||
{ "field": "Дата", "op": ">=", "value": "2024-01-01T00:00:00" },
|
|
||||||
{ "group": "Or", "items": [ "Статус = 1", "Статус = 2" ] }
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
- **Операторы:** `=` `<>` `>` `>=` `<` `<=`, `in` / `notIn`, `inHierarchy`, `contains` / `notContains`, `beginsWith` / `notBeginsWith`, `like` / `notLike` (`%`-шаблон), `filled` / `notFilled`.
|
|
||||||
- **Флаги:** `@off` (отключён), `@user` (в пользовательских настройках), `@quickAccess`; `_` = пустое значение.
|
|
||||||
- **Группа:** `{ group: "And"|"Or"|"Not", items: [...] }`.
|
|
||||||
- **Дата-значение:** ISO-дата `"2024-01-01T00:00:00"` — фиксированная дата. Именованный относительный период — строкой с типом: `{ "value": "BeginningOfThisWeek", "valueType": "v8:StandardBeginningDate" }` (варианты `BeginningOfThisDay`/`BeginningOfThisWeek`/`BeginningOfThisMonth`/`BeginningOfThisYear`/…).
|
|
||||||
|
|
||||||
## Сортировка (`order`)
|
|
||||||
|
|
||||||
Строка `"Поле"` (по возр.) / `"Поле desc"`, либо объект `{ field, direction? }`. `"Auto"` — автосортировка:
|
|
||||||
|
|
||||||
```json
|
|
||||||
"order": [ "Дата desc", "Наименование", "Auto" ]
|
|
||||||
```
|
|
||||||
|
|
||||||
## Группировка строк (`grouping`)
|
|
||||||
|
|
||||||
Линейная цепочка уровней (внешний → внутренний). Шорткат `>` или массив:
|
|
||||||
|
|
||||||
```json
|
|
||||||
"grouping": "Контрагент > Договор"
|
|
||||||
"grouping": [ "Контрагент", { "field": "Дата", "groupType": "Hierarchy" } ]
|
|
||||||
```
|
|
||||||
|
|
||||||
Ключи уровня-объекта: `field`, `groupType` (`Items` / `Hierarchy`).
|
|
||||||
|
|
||||||
## Условное оформление (`conditionalAppearance`)
|
|
||||||
|
|
||||||
```json
|
|
||||||
"conditionalAppearance": [
|
|
||||||
{ "filter": [ "Просрочено = true" ], "appearance": { "ЦветТекста": "web:Red" } }
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
`filter` — та же грамматика, что выше. `appearance` — словарь «параметр платформы: значение» (`ЦветТекста`, `ЦветФона`, `Шрифт`, `Текст`, `Формат`, …). Значение `Текст`/`Заголовок`/`Формат`: голая строка — нелокализованный литерал; `{ru,en}` — локализуемая строка; `{ field: "путь" }` — ссылка на поле. Подробнее об оформлении — `references/appearance.md`.
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
# Продвинутая раскладка
|
|
||||||
|
|
||||||
Тонкая настройка размещения элемента внутри родителя сверх базовой геометрии (`width`/`height`/`horizontalStretch`/`verticalStretch`/`visible`/`enabled` и ориентации групп/страниц — они в основной инструкции). Все ключи ниже задаются прямо на элементе и **необязательны** — без них действует поведение платформы по умолчанию.
|
|
||||||
|
|
||||||
## Выравнивание внутри родителя
|
|
||||||
|
|
||||||
Различают **выравнивание самого элемента** в отведённой ему ячейке и **выравнивание содержимого** элемента.
|
|
||||||
|
|
||||||
| Ключ | Значения | Что выравнивает |
|
|
||||||
|------|----------|-----------------|
|
|
||||||
| `groupHorizontalAlign` | `Left` / `Center` / `Right` | Положение **элемента** по горизонтали в родительской группе (когда элемент у́же доступного места) |
|
|
||||||
| `groupVerticalAlign` | `Top` / `Center` / `Bottom` | Положение **элемента** по вертикали в родительской группе |
|
|
||||||
| `horizontalAlign` | `Left` / `Center` / `Right` | Выравнивание **содержимого** (текста/значения) внутри самого элемента |
|
|
||||||
| `verticalAlign` | `Top` / `Center` / `Bottom` | Выравнивание содержимого по вертикали внутри элемента |
|
|
||||||
|
|
||||||
`group*Align` отвечает на вопрос «куда сдвинуть нерастянутый элемент в его ячейке», `horizontalAlign`/`verticalAlign` — «как разместить текст внутри элемента». Это разные оси настройки, их часто комбинируют.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "button": "ОК", "groupHorizontalAlign": "Right" }
|
|
||||||
{ "input": "Сумма", "path": "Объект.Сумма", "horizontalAlign": "Right" }
|
|
||||||
{ "label": "Итого", "groupHorizontalAlign": "Center", "horizontalAlign": "Center" }
|
|
||||||
```
|
|
||||||
|
|
||||||
## Ограничение максимального размера
|
|
||||||
|
|
||||||
По умолчанию растягивающийся элемент имеет авто-вычисляемый предел ширины/высоты. Чтобы задать жёсткий предел или вовсе снять авто-предел:
|
|
||||||
|
|
||||||
| Ключ | Значения | Назначение |
|
|
||||||
|------|----------|-----------|
|
|
||||||
| `maxWidth` | число | Жёсткий максимум ширины элемента |
|
|
||||||
| `maxHeight` | число | Жёсткий максимум высоты элемента |
|
|
||||||
| `autoMaxWidth` | `false` | Отключить авто-предел ширины (элемент тянется без ограничения сверху) |
|
|
||||||
| `autoMaxHeight` | `false` | Отключить авто-предел высоты |
|
|
||||||
|
|
||||||
`autoMaxWidth: false` нужен, например, для широкого многострочного поля или растянутого по всей форме поля ввода, чтобы платформа не «прижимала» его к авто-пределу. Указывают именно отклонение от дефолта; обычное значение `true` писать не нужно.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "input": "Комментарий", "path": "Объект.Комментарий", "multiLine": true,
|
|
||||||
"horizontalStretch": true, "autoMaxWidth": false }
|
|
||||||
{ "input": "Поиск", "path": "СтрокаПоиска", "horizontalStretch": true, "maxWidth": 600 }
|
|
||||||
```
|
|
||||||
|
|
||||||
## Поведение при вводе и активации
|
|
||||||
|
|
||||||
| Ключ | Значения | Назначение |
|
|
||||||
|------|----------|-----------|
|
|
||||||
| `skipOnInput` | `true` / `false` | Пропускать элемент при обходе по Enter/Tab (фокус через него не проходит). Указывают явно, в т.ч. `false` чтобы вернуть в обход поле, которое платформа пропустила бы |
|
|
||||||
| `defaultItem` | `true` | Элемент получает фокус по умолчанию при открытии формы (поле/таблица для немедленного ввода) |
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "input": "Идентификатор", "path": "Объект.Идентификатор", "skipOnInput": true }
|
|
||||||
{ "input": "Штрихкод", "path": "Штрихкод", "defaultItem": true }
|
|
||||||
```
|
|
||||||
|
|
||||||
`skipOnInput: true` — для служебных/расчётных полей, которые видны, но не редактируются вводом с клавиатуры в общем потоке. `defaultItem: true` ставят на одном элементе формы — точке, с которой пользователь начнёт работу.
|
|
||||||
|
|
||||||
## Перетаскивание
|
|
||||||
|
|
||||||
| Ключ | Значения | Назначение |
|
|
||||||
|------|----------|-----------|
|
|
||||||
| `enableStartDrag` | `true` | Разрешить начинать перетаскивание из элемента (источник drag-n-drop) |
|
|
||||||
|
|
||||||
Для таблиц приём/перемещение строк управляется ключами таблицы (`enableDrag`, `changeRowOrder`) — см. основную инструкцию; `enableStartDrag` — общий низкоуровневый флаг «этот элемент может быть источником перетаскивания».
|
|
||||||
|
|
||||||
## Закрепление колонки в таблице (`fixingInTable`)
|
|
||||||
|
|
||||||
Свойство поля-колонки внутри таблицы: закрепить колонку у края, чтобы она не уходила при горизонтальной прокрутке.
|
|
||||||
|
|
||||||
| Значения |
|
|
||||||
|----------|
|
|
||||||
| `None` (по умолчанию — не закреплена) / `Left` / `Right` |
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "table": "Товары", "path": "Объект.Товары", "columns": [
|
|
||||||
{ "input": "Номенклатура", "path": "Объект.Товары.Номенклатура", "fixingInTable": "Left" },
|
|
||||||
{ "input": "Количество", "path": "Объект.Товары.Количество" },
|
|
||||||
{ "input": "Сумма", "path": "Объект.Товары.Сумма", "fixingInTable": "Right" } ] }
|
|
||||||
```
|
|
||||||
|
|
||||||
Закрепляют ключевые колонки (идентифицирующую слева, итоговую справа), чтобы они оставались видны при прокрутке широкой таблицы.
|
|
||||||
|
|
||||||
## Ячейки колонок: шапка и подвал
|
|
||||||
|
|
||||||
Для поля-колонки внутри таблицы (и `columnGroup`) — размещение в шапке/подвале и выравнивание текста ячеек. Применять только к элементам внутри `columns` таблицы.
|
|
||||||
|
|
||||||
| Ключ | Значения | Назначение |
|
|
||||||
|------|----------|-----------|
|
|
||||||
| `showInHeader` | `true` / `false` | Показывать колонку в шапке таблицы |
|
|
||||||
| `showInFooter` | `true` / `false` | Показывать колонку в подвале (нужно для итогов; подвал самой таблицы включается `footer: true`) |
|
|
||||||
| `headerHorizontalAlign` | `Left` / `Right` / `Center` / `Auto` | Выравнивание текста в шапке колонки |
|
|
||||||
| `footerHorizontalAlign` | `Left` / `Right` / `Center` | Выравнивание текста в подвале колонки |
|
|
||||||
| `autoCellHeight` | `true` / `false` | Авто-высота ячейки (перенос содержимого на несколько строк) |
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "table": "Товары", "path": "Объект.Товары", "footer": true, "columns": [
|
|
||||||
{ "input": "Номенклатура", "path": "Объект.Товары.Номенклатура", "autoCellHeight": true },
|
|
||||||
{ "input": "Сумма", "path": "Объект.Товары.Сумма",
|
|
||||||
"headerHorizontalAlign": "Right", "showInFooter": true, "footerHorizontalAlign": "Right" } ] }
|
|
||||||
```
|
|
||||||
|
|
||||||
## Адаптивная важность (`displayImportance`)
|
|
||||||
|
|
||||||
| Значения |
|
|
||||||
|----------|
|
|
||||||
| `VeryHigh` / `High` / `Usual` / `VeryLow` / `Low` |
|
|
||||||
|
|
||||||
Приоритет элемента при адаптивной перекомпоновке формы на узких/мобильных экранах: элементы с меньшей важностью сворачиваются/прячутся первыми. Применимо к любому элементу.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "input": "Комментарий", "path": "Объект.Комментарий", "displayImportance": "Low" }
|
|
||||||
```
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
# Форма отчёта
|
|
||||||
|
|
||||||
Форма, подключённая к объекту-отчёту (`Report`). Кроме обычных свойств формы у неё есть несколько свойств в `properties`, связывающих форму с механизмом компоновки (СКД): куда выводится результат, где данные расшифровки, какого она типа. Все они задаются в блоке `properties` верхнего уровня.
|
|
||||||
|
|
||||||
```json
|
|
||||||
"properties": {
|
|
||||||
"reportFormType": "Main",
|
|
||||||
"reportResult": "РезультатОтчета",
|
|
||||||
"detailsData": "ДанныеРасшифровки"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Ни одно из этих свойств не обязательно — указывайте только те, что нужны конкретной форме.
|
|
||||||
|
|
||||||
## Тип формы отчёта (`reportFormType`)
|
|
||||||
|
|
||||||
Роль формы в составе отчёта:
|
|
||||||
|
|
||||||
| Значение | Назначение |
|
|
||||||
|----------|-----------|
|
|
||||||
| `Main` | Основная форма отчёта (результат + настройки) |
|
|
||||||
| `Settings` | Форма настроек |
|
|
||||||
| `Variant` | Форма варианта |
|
|
||||||
|
|
||||||
```json
|
|
||||||
"reportFormType": "Main"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Привязка к компоновке
|
|
||||||
|
|
||||||
| Ключ | Тип | Назначение |
|
|
||||||
|------|-----|-----------|
|
|
||||||
| `reportResult` | string | Имя реквизита-результата формы (табличный документ, куда выводится отчёт) |
|
|
||||||
| `detailsData` | string | Имя реквизита данных расшифровки |
|
|
||||||
| `variantAppearance` | string | Имя реквизита оформления варианта |
|
|
||||||
|
|
||||||
Значение каждого ключа — имя реквизита формы (а не путь к данным). Реквизит с таким именем должен присутствовать в `attributes` формы.
|
|
||||||
|
|
||||||
## Группа пользовательских настроек (`customSettingsFolder`)
|
|
||||||
|
|
||||||
Группа-элемент формы, в которую генерируются пользовательские настройки компоновщика. Задаётся **по имени** элемента-группы:
|
|
||||||
|
|
||||||
```json
|
|
||||||
"customSettingsFolder": "ГруппаПользовательскихНастроек"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Прочие свойства компоновки
|
|
||||||
|
|
||||||
Редкие, задавайте только при явной необходимости:
|
|
||||||
|
|
||||||
| Ключ | Значения | Назначение |
|
|
||||||
|------|----------|-----------|
|
|
||||||
| `autoShowState` | `Auto`, `DontShow`, `ShowOnComposition` | Автопоказ состояния формирования |
|
|
||||||
| `reportResultViewMode` | `Auto` | Режим просмотра результата |
|
|
||||||
| `viewModeApplicationOnSetReportResult` | `Auto` | Применение режима просмотра при установке результата |
|
|
||||||
|
|
||||||
## Реалистичный пример
|
|
||||||
|
|
||||||
Основная форма отчёта со СКД: реквизит-результат, данные расшифровки и группа пользовательских настроек.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"properties": {
|
|
||||||
"reportFormType": "Main",
|
|
||||||
"reportResult": "РезультатОтчета",
|
|
||||||
"detailsData": "ДанныеРасшифровки",
|
|
||||||
"customSettingsFolder": "ГруппаПользовательскихНастроек"
|
|
||||||
},
|
|
||||||
"attributes": [
|
|
||||||
{ "name": "РезультатОтчета", "type": "SpreadsheetDocument" },
|
|
||||||
{ "name": "ДанныеРасшифровки", "type": "DataCompositionDetailsData" }
|
|
||||||
],
|
|
||||||
"elements": [
|
|
||||||
{ "group": "vertical", "name": "ГруппаПользовательскихНастроек" },
|
|
||||||
{ "spreadsheet": "РезультатОтчета", "path": "РезультатОтчета",
|
|
||||||
"titleLocation": "none" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
# Доступ по ролям
|
|
||||||
|
|
||||||
Единый механизм платформы для разграничения по ролям: задаётся общее значение для всех ролей плюс исключения для конкретных ролей. Один и тот же формат значения у четырёх ключей — каждый на своём владельце:
|
|
||||||
|
|
||||||
| Ключ | Владелец | Смысл |
|
|
||||||
|------|----------|-------|
|
|
||||||
| `userVisible` | элемент формы | пользовательская видимость элемента |
|
|
||||||
| `view` | реквизит формы | право просмотра |
|
|
||||||
| `edit` | реквизит формы | право редактирования |
|
|
||||||
| `use` | команда формы | доступность команды |
|
|
||||||
|
|
||||||
Ключ необязателен: его отсутствие = полный доступ для всех ролей.
|
|
||||||
|
|
||||||
## Значение
|
|
||||||
|
|
||||||
Две формы (одинаковы для всех четырёх ключей):
|
|
||||||
|
|
||||||
**Скаляр** `true` / `false` — общее значение для всех ролей, без исключений:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "input": "Поле", "userVisible": false }
|
|
||||||
```
|
|
||||||
|
|
||||||
**Объект** `{ "common": <bool>, "roles": { "ИмяРоли": <bool>, … } }` — общее значение `common` плюс явные исключения по ролям:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "name": "Реквизит",
|
|
||||||
"edit": { "common": false, "roles": { "ПолныеПрава": true } } }
|
|
||||||
```
|
|
||||||
|
|
||||||
Роль, **не указанная** в `roles`, наследует `common`. Указанная — задаёт явный `true`/`false` (может и совпадать с `common`).
|
|
||||||
|
|
||||||
## Имя роли
|
|
||||||
|
|
||||||
Ключи в `roles` — имена ролей конфигурации (`ПолныеПрава`, `Бухгалтер`, …).
|
|
||||||
|
|
||||||
## Примеры
|
|
||||||
|
|
||||||
Элемент скрыт у всех пользователей:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "input": "Комментарий", "userVisible": false }
|
|
||||||
```
|
|
||||||
|
|
||||||
Реквизит не виден никому и редактируется только одной ролью:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "name": "СуммаБонуса",
|
|
||||||
"view": false,
|
|
||||||
"edit": { "common": false, "roles": { "ПолныеПрава": true } } }
|
|
||||||
```
|
|
||||||
|
|
||||||
Поле доступно для просмотра всем, но редактируемо только администратору:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "name": "Статус",
|
|
||||||
"view": true,
|
|
||||||
"edit": { "common": false, "roles": { "Администратор": true } } }
|
|
||||||
```
|
|
||||||
|
|
||||||
Команда недоступна по умолчанию, разрешена только бухгалтеру:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "name": "ПровестиЗакрытие",
|
|
||||||
"use": { "common": false, "roles": { "Бухгалтер": true } } }
|
|
||||||
```
|
|
||||||
|
|
||||||
Обратный случай — доступно всем, кроме одной роли:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "name": "РедактироватьЦену",
|
|
||||||
"edit": { "common": true, "roles": { "Кладовщик": false } } }
|
|
||||||
```
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
# Спец-поля «документ/датчик»
|
|
||||||
|
|
||||||
Поля для отображения специальных данных: табличный документ, HTML, текст, форматированный документ, индикатор, ползунок. Каждое привязывается к реквизиту своего платформенного типа.
|
|
||||||
|
|
||||||
Структурно это обычные поля — поддерживают общий скелет поля (`path`, `title`, `titleLocation`, флаги `readOnly`/`enabled`/`visible`, `layout`, оформление, события). Ниже — только ключ `type` (имя элемента задаётся значением ключа) и собственные скаляры каждого семейства. Все скаляры необязательны.
|
|
||||||
|
|
||||||
| Ключ типа | Тип реквизита |
|
|
||||||
|-----------|---------------|
|
|
||||||
| `spreadsheet` | `mxl:SpreadsheetDocument` (ТабличныйДокумент) |
|
|
||||||
| `html` | `string` |
|
|
||||||
| `textDoc` | `d5p1:TextDocument` (ТекстовыйДокумент) |
|
|
||||||
| `formattedDoc` | `fd:FormattedDocument` (ФорматированныйДокумент) |
|
|
||||||
| `progressBar` | число |
|
|
||||||
| `trackBar` | число |
|
|
||||||
|
|
||||||
## spreadsheet — поле табличного документа
|
|
||||||
|
|
||||||
Просмотр/редактирование табличного документа (отчёт, печатная форма).
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "spreadsheet": "ТаблицаОтчета", "path": "ТаблицаОтчета",
|
|
||||||
"titleLocation": "none", "readOnly": true,
|
|
||||||
"output": "Disable", "protection": true }
|
|
||||||
```
|
|
||||||
|
|
||||||
| Ключ | Тип | Назначение |
|
|
||||||
|------|-----|-----------|
|
|
||||||
| `output` | string | Использование вывода: `Enable` / `Disable` |
|
|
||||||
| `protection` | bool | Защита от изменений |
|
|
||||||
| `edit` | bool | Разрешить редактирование |
|
|
||||||
| `showGrid` | bool | Показывать сетку |
|
|
||||||
| `showHeaders` | bool | Показывать заголовки строк/колонок |
|
|
||||||
| `showGroups` | bool | Показывать группировки |
|
|
||||||
| `showRowAndColumnNames` | bool | Показывать имена строк и колонок |
|
|
||||||
| `showCellNames` | bool | Показывать имена ячеек |
|
|
||||||
| `verticalScrollBar` / `horizontalScrollBar` | string | Режим полос прокрутки |
|
|
||||||
| `viewScalingMode` | string | Режим масштабирования просмотра |
|
|
||||||
| `selectionShowMode` | string | Режим отображения выделения |
|
|
||||||
| `pointerType` | string | Тип указателя |
|
|
||||||
| `enableDrag` / `enableStartDrag` | bool | Разрешить перетаскивание / начало перетаскивания |
|
|
||||||
|
|
||||||
## html — поле HTML-документа
|
|
||||||
|
|
||||||
Просмотр HTML. Реквизит — строка (содержит HTML-текст или адрес).
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "html": "Просмотр", "path": "СодержимоеHTML", "titleLocation": "none",
|
|
||||||
"output": "Enable", "warningOnEditRepresentation": false }
|
|
||||||
```
|
|
||||||
|
|
||||||
| Ключ | Тип | Назначение |
|
|
||||||
|------|-----|-----------|
|
|
||||||
| `output` | string | Использование вывода: `Enable` / `Disable` |
|
|
||||||
| `warningOnEditRepresentation` | bool | Предупреждать при изменении представления |
|
|
||||||
|
|
||||||
## textDoc — поле текстового документа
|
|
||||||
|
|
||||||
Просмотр/редактирование текстового документа.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "textDoc": "Текст", "path": "ТекстДокумента", "editMode": "Edit" }
|
|
||||||
```
|
|
||||||
|
|
||||||
| Ключ | Тип | Назначение |
|
|
||||||
|------|-----|-----------|
|
|
||||||
| `editMode` | string | Режим редактирования (напр. `Edit` / `View`) |
|
|
||||||
|
|
||||||
## formattedDoc — поле форматированного документа
|
|
||||||
|
|
||||||
Просмотр/редактирование форматированного документа.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "formattedDoc": "Описание", "path": "ФорматированноеОписание", "editMode": "Edit" }
|
|
||||||
```
|
|
||||||
|
|
||||||
| Ключ | Тип | Назначение |
|
|
||||||
|------|-----|-----------|
|
|
||||||
| `editMode` | string | Режим редактирования (напр. `Edit` / `View`) |
|
|
||||||
|
|
||||||
## progressBar — поле индикатора
|
|
||||||
|
|
||||||
Индикатор прогресса. Реквизит — числовой.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "progressBar": "Прогресс", "path": "Прогресс",
|
|
||||||
"minValue": 0, "maxValue": 100, "showPercent": true }
|
|
||||||
```
|
|
||||||
|
|
||||||
| Ключ | Тип | Назначение |
|
|
||||||
|------|-----|-----------|
|
|
||||||
| `minValue` / `maxValue` | число | Минимальное / максимальное значение |
|
|
||||||
| `showPercent` | bool | Показывать проценты |
|
|
||||||
|
|
||||||
## trackBar — поле ползунка
|
|
||||||
|
|
||||||
Регулятор-ползунок. Реквизит — числовой.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "trackBar": "Масштаб", "path": "Масштаб",
|
|
||||||
"minValue": 20, "maxValue": 400, "markingStep": 20 }
|
|
||||||
```
|
|
||||||
|
|
||||||
| Ключ | Тип | Назначение |
|
|
||||||
|------|-----|-----------|
|
|
||||||
| `minValue` / `maxValue` | число | Минимальное / максимальное значение |
|
|
||||||
| `step` | число | Шаг изменения |
|
|
||||||
| `largeStep` | число | Крупный шаг |
|
|
||||||
| `markingStep` | число | Шаг разметки |
|
|
||||||
| `markingAppearance` | string | Оформление разметки |
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
# Таблица — продвинутые возможности
|
|
||||||
|
|
||||||
Базовый элемент таблицы (`type: "table"`, колонки, основные свойства) описан в основной инструкции, раздел «Таблица (table)». Здесь — продвинутые возможности: дополнения командной панели, специфика таблицы динамического списка и неочевидные свойства/режимы.
|
|
||||||
|
|
||||||
## Представление (`representation`)
|
|
||||||
|
|
||||||
Как таблица рисует строки:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "table": "Список", "path": "Список", "representation": "Tree" }
|
|
||||||
```
|
|
||||||
|
|
||||||
`List` — плоский список (по умолчанию), `Tree` — дерево, `HierarchicalList` — иерархический список (группы + элементы на одном уровне).
|
|
||||||
|
|
||||||
Для дерева/иерархии управляйте раскрытием уровней через `initialTreeView` (`ExpandTopLevel` / `ExpandAllLevels` / `NoExpand`).
|
|
||||||
|
|
||||||
## Выделение и текущая строка
|
|
||||||
|
|
||||||
| Ключ | Значения | Назначение |
|
|
||||||
|------|----------|-----------|
|
|
||||||
| `selectionMode` | `SingleRow` / `MultiRow` | Режим выделения строк |
|
|
||||||
| `multipleChoice` | bool | Разрешить множественный выбор (для форм выбора) |
|
|
||||||
| `currentRowUse` | `DontUse` / `Use` / `SelectionPresentation` / `SelectionPresentationAndChoice` / `Choice` | Использование текущей строки таблицы |
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "table": "Список", "path": "Список", "selectionMode": "MultiRow", "multipleChoice": true }
|
|
||||||
```
|
|
||||||
|
|
||||||
## Поиск при вводе (`searchOnInput`)
|
|
||||||
|
|
||||||
Поведение встроенного поиска при наборе текста в таблице:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "table": "Список", "path": "Список", "searchOnInput": "Use" }
|
|
||||||
```
|
|
||||||
|
|
||||||
`Auto` (по умолчанию) / `Use` (искать) / `DontUse` (не искать).
|
|
||||||
|
|
||||||
Где располагать сами элементы поиска — управляется `searchStringLocation` / `viewStatusLocation` / `searchControlLocation` (`None` / `Top` / `Bottom` / `CommandBar` / `Auto`).
|
|
||||||
|
|
||||||
## Прочие свойства таблицы
|
|
||||||
|
|
||||||
| Ключ | Тип | Назначение |
|
|
||||||
|------|-----|-----------|
|
|
||||||
| `useAlternationRowColor` | bool | Чередование цвета строк |
|
|
||||||
| `verticalLines` / `horizontalLines` | bool | Линии сетки (укажите `false`, чтобы скрыть) |
|
|
||||||
| `markIncomplete` | bool | Автоотметка незаполненных ячеек |
|
|
||||||
| `heightInTableRows` | int | Высота элемента в строках (отдельно от `height`) |
|
|
||||||
| `autoInsertNewRow` | bool | Автодобавление новой строки при вводе в последнюю |
|
|
||||||
| `rowsPicture` | string \| object | Картинка строк. Ссылка (`"CommonPicture.X"`, `"abs:..."`) либо объект `{ src, loadTransparent?, transparentPixel? }` |
|
|
||||||
| `tooltipRepresentation` | string | Режим показа подсказки таблицы: `None`, `Button`, `ShowBottom`, `ShowTop`, `ShowLeft`, `ShowRight`, `ShowAuto`, `Balloon` |
|
|
||||||
|
|
||||||
## Фиксация колонки (`fixingInTable`)
|
|
||||||
|
|
||||||
Свойство **колонки** (на `input` / `labelField` / `check` / `picField` внутри `columns`), а не самой таблицы. Закрепляет колонку у края при горизонтальной прокрутке:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "table": "Товары", "path": "Объект.Товары", "columns": [
|
|
||||||
{ "input": "Номенклатура", "path": "Объект.Товары.Номенклатура", "fixingInTable": "Left" },
|
|
||||||
{ "input": "Количество", "path": "Объект.Товары.Количество" }
|
|
||||||
]}
|
|
||||||
```
|
|
||||||
|
|
||||||
`Left` / `Right` / `None`.
|
|
||||||
|
|
||||||
## Исключённые команды (`excludedCommands`)
|
|
||||||
|
|
||||||
Убрать стандартные команды редактора таблицы (кнопки добавления/перемещения/сортировки):
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "table": "Товары", "path": "Объект.Товары",
|
|
||||||
"excludedCommands": [ "Add", "Delete", "MoveUp", "SortListAsc" ] }
|
|
||||||
```
|
|
||||||
|
|
||||||
Свойство работает на любом поле и на уровне формы; для таблицы значимы команды вида `Add` / `Delete` / `MoveUp` / `MoveDown` / `SortListAsc` / `SortListDesc`.
|
|
||||||
|
|
||||||
## Дополнения командной панели (`additions`)
|
|
||||||
|
|
||||||
Дополнения — это «представления» встроенного поиска таблицы:
|
|
||||||
|
|
||||||
- `searchString` — отображение строки поиска,
|
|
||||||
- `viewStatus` — состояние просмотра,
|
|
||||||
- `searchControl` — управление поиском.
|
|
||||||
|
|
||||||
Каждое дополнение — полноценный элемент (полный набор свойств поля). Размещать их можно двумя способами.
|
|
||||||
|
|
||||||
**(1) Стандартные дополнения** генерирует платформа на уровне таблицы. В DSL указывайте **только отклонения** от стандартного вида — через карту `additions` (ключ = тип дополнения):
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "table": "Список", "path": "Список",
|
|
||||||
"additions": { "viewStatus": { "horizontalLocation": "left" } } }
|
|
||||||
```
|
|
||||||
|
|
||||||
**(2) Кастомное дополнение**, размещённое прямо в командной панели — обычный элемент в `commandBar` с ключом-типом:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "table": "Список", "path": "Список", "commandBar": [
|
|
||||||
{ "searchString": "ПоискСписка", "source": "Список", "width": 15, "horizontalStretch": true }
|
|
||||||
]}
|
|
||||||
```
|
|
||||||
|
|
||||||
- Тип-ключ: `searchString` / `viewStatus` / `searchControl`.
|
|
||||||
- `source` — имя таблицы-источника; необязательно, по умолчанию = имя родительской таблицы.
|
|
||||||
- `horizontalLocation`: `auto` (по умолчанию) / `left` / `right`. Применимо и к обычным элементам командных панелей.
|
|
||||||
- Прочие свойства как у поля: `title`, `visible`, `userVisible`, `enabled`, `tooltip`, оформление, `width` / `maxWidth` / `autoMaxWidth` / `horizontalStretch` / `groupHorizontalAlign` и др.
|
|
||||||
|
|
||||||
## Таблица динамического списка
|
|
||||||
|
|
||||||
Когда `path` таблицы указывает на реквизит `type: "DynamicList"` (см. `references/dynamic-list.md`), доступен блок специфичных свойств. Указывайте **только отличия** от умолчания.
|
|
||||||
|
|
||||||
| Ключ | Тип | Умолчание | Назначение |
|
|
||||||
|------|-----|-----------|-----------|
|
|
||||||
| `rowPictureDataPath` | string | картинка осн. таблицы | Путь к картинке строки. `""` — подавить картинку |
|
|
||||||
| `rowsPicture` | string | — | Картинка строк (`"CommonPicture.X"`) |
|
|
||||||
| `autoRefresh` | bool | `false` | Автообновление списка |
|
|
||||||
| `autoRefreshPeriod` | int | `60` | Период автообновления, сек |
|
|
||||||
| `updateOnDataChange` | string | `Auto` | Обновлять при изменении данных: `Auto` / `DontUpdate` |
|
|
||||||
| `choiceFoldersAndItems` | string | `Items` | Что выбирать: `Items` / `Folders` / `FoldersAndItems` |
|
|
||||||
| `restoreCurrentRow` | bool | `false` | Восстанавливать текущую строку при обновлении |
|
|
||||||
| `showRoot` | bool | `true` | Показывать корень |
|
|
||||||
| `allowRootChoice` | bool | `false` | Разрешить выбор корня |
|
|
||||||
| `allowGettingCurrentRowURL` | bool | `true` | Разрешить получение URL текущей строки |
|
|
||||||
| `userSettingsGroup` | string | — | Группа пользовательских настроек (привязка к одноимённой группе настроек) |
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "table": "Список", "path": "Список",
|
|
||||||
"representation": "Tree",
|
|
||||||
"rowPictureDataPath": "Список.DefaultPicture",
|
|
||||||
"choiceFoldersAndItems": "FoldersAndItems",
|
|
||||||
"allowRootChoice": true,
|
|
||||||
"updateOnDataChange": "DontUpdate" }
|
|
||||||
```
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
# Продвинутые конструкции типов
|
|
||||||
|
|
||||||
Примитивы (`string(n)`, `number(p,s)`, `boolean`, `date`/`dateTime`, …) и одиночные ссылки (`CatalogRef.Контрагенты`, `DocumentRef.Заказ`, `EnumRef.X`, …) описаны в основной инструкции. Здесь — типы, которые нельзя выразить одним именем: составные типы, наборы типов и платформенные наборы ссылок.
|
|
||||||
|
|
||||||
Любая из этих конструкций пишется в поле `type` реквизита, реквизита-параметра или поля.
|
|
||||||
|
|
||||||
## Составные типы
|
|
||||||
|
|
||||||
Несколько типов на одном реквизите — части перечисляются через разделитель `" | "` (можно `+`). Реквизит сможет принимать значение любого из перечисленных типов:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "name": "Плательщик",
|
|
||||||
"type": "CatalogRef.Организации | CatalogRef.ИндивидуальныеПредприниматели" }
|
|
||||||
```
|
|
||||||
|
|
||||||
Смешивать можно типы из разных категорий — ссылки, примитивы, наборы типов:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "name": "Источник",
|
|
||||||
"type": "CatalogRef.Контрагенты | DocumentRef.Заказ | string(150)" }
|
|
||||||
```
|
|
||||||
|
|
||||||
Каждая часть — самостоятельный токен из этого файла или из основной инструкции. Порядок частей произвольный.
|
|
||||||
|
|
||||||
## Наборы типов (TypeSet)
|
|
||||||
|
|
||||||
«Набор типов» подставляется вместо конкретного типа — это один токен, а не перечисление. Применимо и в составном типе как одна из частей.
|
|
||||||
|
|
||||||
| Токен `type` | Смысл |
|
|
||||||
|------|-------|
|
|
||||||
| `"DefinedType.ИмяТипа"` | определяемый тип конфигурации |
|
|
||||||
| `"Characteristic.ИмяПлана"` | тип значения характеристики (по плану видов характеристик) |
|
|
||||||
| `"AnyRef"` | любая ссылка |
|
|
||||||
| `"AnyIBRef"` | любая ссылка информационной базы |
|
|
||||||
|
|
||||||
Определяемый тип — реквизит принимает то, что задано в определяемом типе конфигурации (например `DefinedType.ДенежнаяСумма`):
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "name": "Сумма", "type": "DefinedType.ДенежнаяСумма" }
|
|
||||||
```
|
|
||||||
|
|
||||||
Характеристика — тип значения берётся из плана видов характеристик:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "name": "Значение", "type": "Characteristic.ДополнительныеРеквизиты" }
|
|
||||||
```
|
|
||||||
|
|
||||||
## Платформенные наборы ссылок
|
|
||||||
|
|
||||||
«Голый» ссылочный токен **без `.Имя`** означает «любая ссылка этой категории объектов»:
|
|
||||||
|
|
||||||
| Токен `type` | Смысл |
|
|
||||||
|------|-------|
|
|
||||||
| `"CatalogRef"` | любая ссылка справочника |
|
|
||||||
| `"DocumentRef"` | любая ссылка документа |
|
|
||||||
| `"EnumRef"` | любая ссылка перечисления |
|
|
||||||
| `"ExchangePlanRef"` | любая ссылка плана обмена |
|
|
||||||
| `"TaskRef"` | любая ссылка задачи |
|
|
||||||
| `"BusinessProcessRef"` | любая ссылка бизнес-процесса |
|
|
||||||
| `"ChartOfCharacteristicTypesRef"` | любая ссылка плана видов характеристик |
|
|
||||||
| `"ChartOfAccountsRef"` | любая ссылка плана счетов |
|
|
||||||
| `"ChartOfCalculationTypesRef"` | любая ссылка плана видов расчёта |
|
|
||||||
|
|
||||||
Различие с одиночной ссылкой — только в наличии `.Имя`:
|
|
||||||
|
|
||||||
- `"CatalogRef.Валюты"` — конкретный справочник «Валюты»;
|
|
||||||
- `"CatalogRef"` — любой справочник.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "name": "ЛюбойСправочник", "type": "CatalogRef" }
|
|
||||||
```
|
|
||||||
|
|
||||||
Эти наборы тоже комбинируются в составном типе:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "name": "Объект", "type": "CatalogRef | DocumentRef" }
|
|
||||||
```
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,48 +0,0 @@
|
|||||||
---
|
|
||||||
name: form-decompile
|
|
||||||
description: Декомпиляция управляемой формы 1С (Form.xml) в JSON-черновик в формате form-compile. Используй для scaffold новой формы по образцу или структурного рефакторинга. Не для точечных правок
|
|
||||||
argument-hint: <FormPath> [-OutputPath <out.json>]
|
|
||||||
disable-model-invocation: true
|
|
||||||
allowed-tools:
|
|
||||||
- Bash
|
|
||||||
- Read
|
|
||||||
- Write
|
|
||||||
- Glob
|
|
||||||
---
|
|
||||||
|
|
||||||
# /form-decompile — JSON-черновик из Form.xml управляемой формы
|
|
||||||
|
|
||||||
Читает Form.xml и эмитит компактный JSON в формате `form-compile`. **Результат — черновик**, а не обратимое представление: см. раздел «Что получаешь».
|
|
||||||
|
|
||||||
## Когда использовать
|
|
||||||
|
|
||||||
- **Scaffold новой формы по образцу** — взять существующую форму, получить JSON, поправить и скомпилировать в новую.
|
|
||||||
- **Структурный рефакторинг** — перебрать дерево элементов, реквизиты, команды.
|
|
||||||
|
|
||||||
## Когда **не** использовать
|
|
||||||
|
|
||||||
- **Точечные правки готовой формы** (добавить элемент, реквизит, команду) → `/form-edit`. Цикл «декомпиляция → правка JSON → компиляция» переписывает форму целиком, может терять непокрытые конструкции и даёт большой diff. `/form-edit` правит адресно.
|
|
||||||
|
|
||||||
## Параметры
|
|
||||||
|
|
||||||
| Параметр | Описание |
|
|
||||||
|----------|----------|
|
|
||||||
| `FormPath` | Путь к Form.xml (обязательный) |
|
|
||||||
| `OutputPath` | Путь к выходному JSON. Если не задан — JSON в stdout |
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
python ".augment/skills/form-decompile/scripts/form-decompile.py" -FormPath "<Form.xml>" -OutputPath "<out.json>"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Что получаешь
|
|
||||||
|
|
||||||
JSON-черновик в формате `/form-compile` — **не полное обратимое представление**: раундтрип `xml → json → xml` не гарантируется, часть конструкций DSL не покрывает и **теряет молча**.
|
|
||||||
|
|
||||||
Критичные конструкции (`ConditionalAppearance` со scope, design-time диаграммы/планировщики на реквизите, неизвестный тип элемента, не-Form root) → скрипт падает с ненулевым кодом и сообщением в stderr; для правок такой формы — `/form-edit`.
|
|
||||||
|
|
||||||
## Workflow
|
|
||||||
|
|
||||||
1. `/form-decompile <Form.xml> -OutputPath draft.json` — получить черновик.
|
|
||||||
2. Поправить JSON под задачу.
|
|
||||||
3. `/form-compile -JsonPath draft.json -OutputPath new/Form.xml` — собрать обратно.
|
|
||||||
4. `/form-validate` + `/form-info` — проверить результат.
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,259 +0,0 @@
|
|||||||
# help-add v1.7 — Add built-in help to 1C object
|
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
|
||||||
param(
|
|
||||||
[Parameter(Mandatory)]
|
|
||||||
[string]$ObjectName,
|
|
||||||
|
|
||||||
[string]$Lang = "ru",
|
|
||||||
|
|
||||||
[string]$SrcDir = "src"
|
|
||||||
)
|
|
||||||
|
|
||||||
$ErrorActionPreference = "Stop"
|
|
||||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
|
||||||
[Console]::InputEncoding = [System.Text.Encoding]::UTF8
|
|
||||||
|
|
||||||
# --- Support guard (Ext/ParentConfigurations.bin) ---
|
|
||||||
# See docs/1c-support-state-spec.md. Blocks edits of vendor objects "на замке" /
|
|
||||||
# read-only configs unless allowed. Trigger = bin present; reaction from
|
|
||||||
# .v8-project.json editingAllowedCheck (deny|warn|off, default deny). Never
|
|
||||||
# throws — guard errors degrade to allow.
|
|
||||||
function Get-RootUuid([string]$xmlPath) {
|
|
||||||
if (-not (Test-Path $xmlPath)) { return $null }
|
|
||||||
try {
|
|
||||||
[xml]$mx = Get-Content -Path $xmlPath -Encoding UTF8
|
|
||||||
$el = $mx.DocumentElement.FirstChild
|
|
||||||
while ($el -and $el.NodeType -ne 'Element') { $el = $el.NextSibling }
|
|
||||||
if ($el) { $u = $el.GetAttribute("uuid"); if ($u) { return $u } }
|
|
||||||
} catch {}
|
|
||||||
return $null
|
|
||||||
}
|
|
||||||
function Find-V8Project([string]$startDir) {
|
|
||||||
$d = $startDir
|
|
||||||
for ($i = 0; $i -lt 20 -and $d; $i++) {
|
|
||||||
$pj = Join-Path $d ".v8-project.json"
|
|
||||||
if (Test-Path $pj) { return $pj }
|
|
||||||
$parent = [System.IO.Path]::GetDirectoryName($d)
|
|
||||||
if ($parent -eq $d) { break }
|
|
||||||
$d = $parent
|
|
||||||
}
|
|
||||||
return $null
|
|
||||||
}
|
|
||||||
function Get-EditMode([string]$cfgDir) {
|
|
||||||
try {
|
|
||||||
$pj = Find-V8Project (Get-Location).Path
|
|
||||||
if (-not $pj) { $pj = Find-V8Project $cfgDir }
|
|
||||||
if (-not $pj) { return 'deny' }
|
|
||||||
$proj = Get-Content -Raw $pj | ConvertFrom-Json
|
|
||||||
$cfgFull = [System.IO.Path]::GetFullPath($cfgDir).TrimEnd('\', '/')
|
|
||||||
if ($proj.databases) {
|
|
||||||
foreach ($db in $proj.databases) {
|
|
||||||
if ($db.configSrc) {
|
|
||||||
$src = [System.IO.Path]::GetFullPath($db.configSrc).TrimEnd('\', '/')
|
|
||||||
if ($cfgFull -eq $src -or $cfgFull.StartsWith($src + [System.IO.Path]::DirectorySeparatorChar)) {
|
|
||||||
if ($db.editingAllowedCheck) { return $db.editingAllowedCheck }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if ($proj.editingAllowedCheck) { return $proj.editingAllowedCheck }
|
|
||||||
return 'deny'
|
|
||||||
} catch { return 'deny' }
|
|
||||||
}
|
|
||||||
function Assert-EditAllowed([string]$targetPath, [string]$require) {
|
|
||||||
try {
|
|
||||||
$rp = $targetPath
|
|
||||||
try { $rp = (Resolve-Path $targetPath -ErrorAction Stop).Path } catch {}
|
|
||||||
$elemUuid = Get-RootUuid $rp
|
|
||||||
$cfgDir = $null; $binPath = $null
|
|
||||||
$d = if (Test-Path $rp -PathType Container) { $rp } else { [System.IO.Path]::GetDirectoryName($rp) }
|
|
||||||
for ($i = 0; $i -lt 12 -and $d; $i++) {
|
|
||||||
if (-not $elemUuid) { $elemUuid = Get-RootUuid "$d.xml" }
|
|
||||||
if (-not $cfgDir) {
|
|
||||||
$cand = Join-Path (Join-Path $d "Ext") "ParentConfigurations.bin"
|
|
||||||
if ((Test-Path $cand) -or (Test-Path (Join-Path $d "Configuration.xml"))) { $cfgDir = $d; $binPath = $cand }
|
|
||||||
}
|
|
||||||
if ($elemUuid -and $cfgDir) { break }
|
|
||||||
$parent = [System.IO.Path]::GetDirectoryName($d)
|
|
||||||
if ($parent -eq $d) { break }
|
|
||||||
$d = $parent
|
|
||||||
}
|
|
||||||
# New object (no element file): fall back to config root uuid.
|
|
||||||
if (-not $elemUuid -and $cfgDir) { $elemUuid = Get-RootUuid (Join-Path $cfgDir "Configuration.xml") }
|
|
||||||
if (-not $binPath -or -not (Test-Path $binPath)) { return }
|
|
||||||
$bytes = [System.IO.File]::ReadAllBytes($binPath)
|
|
||||||
if ($bytes.Length -le 32) { return }
|
|
||||||
$start = 0
|
|
||||||
if ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) { $start = 3 }
|
|
||||||
$text = [System.Text.Encoding]::UTF8.GetString($bytes, $start, $bytes.Length - $start)
|
|
||||||
$hm = [regex]::Match($text, '^\{6,(\d+),(\d+),')
|
|
||||||
if (-not $hm.Success) { return }
|
|
||||||
$G = [int]$hm.Groups[1].Value
|
|
||||||
$K = [int]$hm.Groups[2].Value
|
|
||||||
if ($K -eq 0) { return }
|
|
||||||
$best = $null
|
|
||||||
if ($elemUuid) {
|
|
||||||
$u = [regex]::Escape($elemUuid.ToLower())
|
|
||||||
foreach ($m in [regex]::Matches($text, "([0-2]),0,$u")) {
|
|
||||||
$f1 = [int]$m.Groups[1].Value
|
|
||||||
if ($null -eq $best -or $f1 -lt $best) { $best = $f1 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$blocked = $false; $code = ""; $reason = ""
|
|
||||||
if ($G -eq 1) { $blocked = $true; $code = "capability-off"; $reason = "возможность изменения конфигурации выключена (вся конфигурация read-only)" }
|
|
||||||
elseif ($require -eq 'removed') {
|
|
||||||
if ($null -ne $best -and $best -ne 2) { $blocked = $true; $code = "not-removed"; $reason = "объект не снят с поддержки — удаление сломает обновления" }
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
if ($null -ne $best -and $best -eq 0) { $blocked = $true; $code = "locked"; $reason = "объект на замке — редактирование сломает обновления" }
|
|
||||||
}
|
|
||||||
if (-not $blocked) { return }
|
|
||||||
$mode = Get-EditMode $cfgDir
|
|
||||||
if ($mode -eq 'off') { return }
|
|
||||||
# Use Console.Error (not Write-Error) — under ErrorActionPreference=Stop the
|
|
||||||
# latter throws and would be swallowed by this function's own catch.
|
|
||||||
if ($mode -eq 'warn') { [Console]::Error.WriteLine("[support-guard] ПРЕДУПРЕЖДЕНИЕ: $reason. Цель: $rp"); return }
|
|
||||||
$head = "[support-guard] Редактирование отклонено: это объект типовой конфигурации на поддержке поставщика, прямое редактирование молча сломает будущие обновления."
|
|
||||||
$cfe = "Рекомендуемый путь: внести доработку в расширение (навыки cfe-borrow / cfe-patch-method) — состояние поддержки менять не нужно, обновления вендора сохраняются."
|
|
||||||
$offNote = "Снять проверку для этой базы: editingAllowedCheck = warn|off в .v8-project.json."
|
|
||||||
if ($code -eq "capability-off") {
|
|
||||||
$state = "Состояние: у всей конфигурации выключена возможность изменения (режим read-only «из коробки») — поэтому объект «$rp» редактировать нельзя."
|
|
||||||
$fix = "Либо снять защиту явно (навык support-edit, два шага):`n 1. support-edit -Path ""$cfgDir"" -Capability on — включить возможность изменения (объекты пока остаются на замке);`n 2. support-edit -Path ""$rp"" -Set editable — открыть этот объект для редактирования.`n Изменение применяется в базу полной загрузкой выгрузки и обходит механизм обновлений вендора."
|
|
||||||
} elseif ($code -eq "not-removed") {
|
|
||||||
$state = "Состояние: объект «$rp» на поддержке (не снят с поддержки) — его удаление разорвёт обновления вендора."
|
|
||||||
$fix = "Либо сначала снять объект с поддержки, затем удалять:`n support-edit -Path ""$rp"" -Set off-support — объект уходит из-под обновлений, после этого удаление безопасно."
|
|
||||||
} else {
|
|
||||||
$state = "Состояние: объект «$rp» на замке (возможность изменения конфигурации включена, но сам объект не редактируется)."
|
|
||||||
$fix = "Либо разрешить редактирование этого объекта (навык support-edit, выбрать одно):`n support-edit -Path ""$rp"" -Set editable — редактировать и дальше получать обновления вендора (возможны конфликты слияния);`n support-edit -Path ""$rp"" -Set off-support — снять с поддержки: обновления по объекту больше не приходят."
|
|
||||||
}
|
|
||||||
[Console]::Error.WriteLine("$head`n$state`n$cfe`n$fix`n$offNote")
|
|
||||||
exit 1
|
|
||||||
} catch { return }
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- Detect format version ---
|
|
||||||
|
|
||||||
function Detect-FormatVersion([string]$dir) {
|
|
||||||
$d = $dir
|
|
||||||
while ($d) {
|
|
||||||
$cfgPath = Join-Path $d "Configuration.xml"
|
|
||||||
if (Test-Path $cfgPath) {
|
|
||||||
$content = [System.IO.File]::ReadAllText($cfgPath, [System.Text.Encoding]::UTF8)
|
|
||||||
$head = $content.Substring(0, [Math]::Min(2000, $content.Length))
|
|
||||||
if ($head -match '<MetaDataObject[^>]+version="(\d+\.\d+)"') { return $Matches[1] }
|
|
||||||
}
|
|
||||||
$parent = Split-Path $d -Parent
|
|
||||||
if ($parent -eq $d) { break }
|
|
||||||
$d = $parent
|
|
||||||
}
|
|
||||||
return "2.17"
|
|
||||||
}
|
|
||||||
|
|
||||||
$formatVersion = Detect-FormatVersion (Resolve-Path $SrcDir).Path
|
|
||||||
|
|
||||||
# --- Проверки ---
|
|
||||||
|
|
||||||
$objectDir = Join-Path $SrcDir $ObjectName
|
|
||||||
$extDir = Join-Path $objectDir "Ext"
|
|
||||||
|
|
||||||
if (-not (Test-Path $extDir)) {
|
|
||||||
Write-Error "Каталог объекта не найден: $extDir. Проверьте путь ObjectName (например Catalogs/МойСправочник)."
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
$helpXmlPath = Join-Path $extDir "Help.xml"
|
|
||||||
if (Test-Path $helpXmlPath) {
|
|
||||||
Write-Error "Справка уже существует: $helpXmlPath"
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
Assert-EditAllowed $objectDir 'editable'
|
|
||||||
|
|
||||||
# --- Кодировка ---
|
|
||||||
|
|
||||||
$encBom = New-Object System.Text.UTF8Encoding($true)
|
|
||||||
|
|
||||||
# --- 1. Help.xml ---
|
|
||||||
|
|
||||||
$helpXml = @"
|
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<Help xmlns="http://v8.1c.ru/8.3/xcf/extrnprops" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="$formatVersion">
|
|
||||||
<Page>$Lang</Page>
|
|
||||||
</Help>
|
|
||||||
"@
|
|
||||||
|
|
||||||
[System.IO.File]::WriteAllText($helpXmlPath, $helpXml, $encBom)
|
|
||||||
|
|
||||||
# --- 2. Help/<lang>.html ---
|
|
||||||
|
|
||||||
$helpDir = Join-Path $extDir "Help"
|
|
||||||
New-Item -ItemType Directory -Path $helpDir -Force | Out-Null
|
|
||||||
|
|
||||||
$helpHtmlPath = Join-Path $helpDir "$Lang.html"
|
|
||||||
|
|
||||||
$helpHtml = @"
|
|
||||||
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN">
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
|
|
||||||
<link rel="stylesheet" type="text/css" href="v8help://service_book/service_style"/>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<h1>$ObjectName</h1>
|
|
||||||
<p>Описание.</p>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
"@
|
|
||||||
|
|
||||||
[System.IO.File]::WriteAllText($helpHtmlPath, $helpHtml, $encBom)
|
|
||||||
|
|
||||||
# --- 3. Проверка IncludeHelpInContents в метаданных форм ---
|
|
||||||
|
|
||||||
$formsDir = Join-Path $objectDir "Forms"
|
|
||||||
if (Test-Path $formsDir) {
|
|
||||||
$formMetaFiles = Get-ChildItem -Path $formsDir -Filter "*.xml" -File
|
|
||||||
foreach ($formMeta in $formMetaFiles) {
|
|
||||||
$xmlDoc = New-Object System.Xml.XmlDocument
|
|
||||||
$xmlDoc.PreserveWhitespace = $true
|
|
||||||
$xmlDoc.Load($formMeta.FullName)
|
|
||||||
|
|
||||||
$nsMgr = New-Object System.Xml.XmlNamespaceManager($xmlDoc.NameTable)
|
|
||||||
$nsMgr.AddNamespace("md", "http://v8.1c.ru/8.3/MDClasses")
|
|
||||||
|
|
||||||
$includeHelp = $xmlDoc.SelectSingleNode("//md:IncludeHelpInContents", $nsMgr)
|
|
||||||
if (-not $includeHelp) {
|
|
||||||
# Добавить после <FormType>
|
|
||||||
$formType = $xmlDoc.SelectSingleNode("//md:FormType", $nsMgr)
|
|
||||||
if ($formType) {
|
|
||||||
$newElem = $xmlDoc.CreateElement("IncludeHelpInContents", "http://v8.1c.ru/8.3/MDClasses")
|
|
||||||
$newElem.InnerText = "false"
|
|
||||||
$parent = $formType.ParentNode
|
|
||||||
$nextSibling = $formType.NextSibling
|
|
||||||
# Вставить перенос + табуляцию + элемент
|
|
||||||
$ws = $xmlDoc.CreateWhitespace("`n`t`t`t")
|
|
||||||
if ($nextSibling) {
|
|
||||||
$parent.InsertBefore($ws, $nextSibling) | Out-Null
|
|
||||||
$parent.InsertBefore($newElem, $ws) | Out-Null
|
|
||||||
} else {
|
|
||||||
$parent.AppendChild($ws) | Out-Null
|
|
||||||
$parent.AppendChild($newElem) | Out-Null
|
|
||||||
}
|
|
||||||
|
|
||||||
$settings = New-Object System.Xml.XmlWriterSettings
|
|
||||||
$settings.Encoding = $encBom
|
|
||||||
$settings.Indent = $false
|
|
||||||
$stream = New-Object System.IO.FileStream($formMeta.FullName, [System.IO.FileMode]::Create)
|
|
||||||
$writer = [System.Xml.XmlWriter]::Create($stream, $settings)
|
|
||||||
$xmlDoc.Save($writer)
|
|
||||||
$writer.Close()
|
|
||||||
$stream.Close()
|
|
||||||
|
|
||||||
Write-Host " IncludeHelpInContents добавлен: $($formMeta.Name)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Host "[OK] Создана справка: $ObjectName"
|
|
||||||
Write-Host " Метаданные: $helpXmlPath"
|
|
||||||
Write-Host " Страница: $helpHtmlPath"
|
|
||||||
@@ -1,328 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# add-help v1.7 — Add built-in help to 1C object
|
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
|
|
||||||
from lxml import etree
|
|
||||||
|
|
||||||
NSMAP = {"md": "http://v8.1c.ru/8.3/MDClasses"}
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# Support guard (Ext/ParentConfigurations.bin) — see docs/1c-support-state-spec.md
|
|
||||||
# Blocks edits of vendor objects "на замке" / read-only configs. Trigger = bin
|
|
||||||
# present; reaction from .v8-project.json editingAllowedCheck (deny|warn|off,
|
|
||||||
# default deny). Never throws (except sys.exit on deny) — errors degrade to allow.
|
|
||||||
# ============================================================
|
|
||||||
|
|
||||||
def _sg_root_uuid(xml_path):
|
|
||||||
if not os.path.isfile(xml_path):
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
mx = etree.parse(xml_path).getroot()
|
|
||||||
for child in mx:
|
|
||||||
if isinstance(child.tag, str) and child.get("uuid"):
|
|
||||||
return child.get("uuid")
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _sg_find_v8project(start_dir):
|
|
||||||
d = start_dir
|
|
||||||
for _ in range(20):
|
|
||||||
if not d:
|
|
||||||
break
|
|
||||||
pj = os.path.join(d, ".v8-project.json")
|
|
||||||
if os.path.isfile(pj):
|
|
||||||
return pj
|
|
||||||
parent = os.path.dirname(d)
|
|
||||||
if parent == d:
|
|
||||||
break
|
|
||||||
d = parent
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _sg_get_edit_mode(cfg_dir):
|
|
||||||
try:
|
|
||||||
pj = _sg_find_v8project(os.getcwd()) or _sg_find_v8project(cfg_dir)
|
|
||||||
if not pj:
|
|
||||||
return "deny"
|
|
||||||
proj = json.loads(open(pj, encoding="utf-8-sig").read())
|
|
||||||
cfg_full = os.path.normcase(os.path.abspath(cfg_dir)).rstrip("\\/")
|
|
||||||
for db in proj.get("databases", []):
|
|
||||||
src = db.get("configSrc")
|
|
||||||
if src:
|
|
||||||
src_full = os.path.normcase(os.path.abspath(src)).rstrip("\\/")
|
|
||||||
if cfg_full == src_full or cfg_full.startswith(src_full + os.sep):
|
|
||||||
if db.get("editingAllowedCheck"):
|
|
||||||
return db["editingAllowedCheck"]
|
|
||||||
if proj.get("editingAllowedCheck"):
|
|
||||||
return proj["editingAllowedCheck"]
|
|
||||||
return "deny"
|
|
||||||
except Exception:
|
|
||||||
return "deny"
|
|
||||||
|
|
||||||
|
|
||||||
def assert_edit_allowed(target_path, require):
|
|
||||||
try:
|
|
||||||
rp = os.path.abspath(target_path)
|
|
||||||
elem_uuid = _sg_root_uuid(rp)
|
|
||||||
cfg_dir = None
|
|
||||||
bin_path = None
|
|
||||||
d = rp if os.path.isdir(rp) else os.path.dirname(rp)
|
|
||||||
for _ in range(12):
|
|
||||||
if not d:
|
|
||||||
break
|
|
||||||
if not elem_uuid:
|
|
||||||
elem_uuid = _sg_root_uuid(d + ".xml")
|
|
||||||
if not cfg_dir:
|
|
||||||
cand = os.path.join(d, "Ext", "ParentConfigurations.bin")
|
|
||||||
if os.path.exists(cand) or os.path.exists(os.path.join(d, "Configuration.xml")):
|
|
||||||
cfg_dir = d
|
|
||||||
bin_path = cand
|
|
||||||
if elem_uuid and cfg_dir:
|
|
||||||
break
|
|
||||||
parent = os.path.dirname(d)
|
|
||||||
if parent == d:
|
|
||||||
break
|
|
||||||
d = parent
|
|
||||||
if not elem_uuid and cfg_dir:
|
|
||||||
elem_uuid = _sg_root_uuid(os.path.join(cfg_dir, "Configuration.xml"))
|
|
||||||
if not bin_path or not os.path.exists(bin_path):
|
|
||||||
return
|
|
||||||
data = open(bin_path, "rb").read()
|
|
||||||
if len(data) <= 32:
|
|
||||||
return
|
|
||||||
if data[:3] == b"\xef\xbb\xbf":
|
|
||||||
data = data[3:]
|
|
||||||
text = data.decode("utf-8", "replace")
|
|
||||||
h = re.match(r"\{6,(\d+),(\d+),", text)
|
|
||||||
if not h:
|
|
||||||
return
|
|
||||||
g = int(h.group(1))
|
|
||||||
k = int(h.group(2))
|
|
||||||
if k == 0:
|
|
||||||
return
|
|
||||||
best = None
|
|
||||||
if elem_uuid:
|
|
||||||
for m in re.finditer(r"([0-2]),0," + re.escape(elem_uuid.lower()), text):
|
|
||||||
f1 = int(m.group(1))
|
|
||||||
if best is None or f1 < best:
|
|
||||||
best = f1
|
|
||||||
blocked = False
|
|
||||||
code = ""
|
|
||||||
reason = ""
|
|
||||||
if g == 1:
|
|
||||||
blocked = True
|
|
||||||
code = "capability-off"
|
|
||||||
reason = "возможность изменения конфигурации выключена (вся конфигурация read-only)"
|
|
||||||
elif require == "removed":
|
|
||||||
if best is not None and best != 2:
|
|
||||||
blocked = True
|
|
||||||
code = "not-removed"
|
|
||||||
reason = "объект не снят с поддержки — удаление сломает обновления"
|
|
||||||
else:
|
|
||||||
if best is not None and best == 0:
|
|
||||||
blocked = True
|
|
||||||
code = "locked"
|
|
||||||
reason = "объект на замке — редактирование сломает обновления"
|
|
||||||
if not blocked:
|
|
||||||
return
|
|
||||||
mode = _sg_get_edit_mode(cfg_dir)
|
|
||||||
if mode == "off":
|
|
||||||
return
|
|
||||||
if mode == "warn":
|
|
||||||
sys.stderr.write(f"[support-guard] ПРЕДУПРЕЖДЕНИЕ: {reason}. Цель: {rp}\n")
|
|
||||||
return
|
|
||||||
head = "[support-guard] Редактирование отклонено: это объект типовой конфигурации на поддержке поставщика, прямое редактирование молча сломает будущие обновления."
|
|
||||||
cfe = "Рекомендуемый путь: внести доработку в расширение (навыки cfe-borrow / cfe-patch-method) — состояние поддержки менять не нужно, обновления вендора сохраняются."
|
|
||||||
off_note = "Снять проверку для этой базы: editingAllowedCheck = warn|off в .v8-project.json."
|
|
||||||
if code == "capability-off":
|
|
||||||
state = f"Состояние: у всей конфигурации выключена возможность изменения (режим read-only «из коробки») — поэтому объект «{rp}» редактировать нельзя."
|
|
||||||
fix = (
|
|
||||||
"Либо снять защиту явно (навык support-edit, два шага):\n"
|
|
||||||
f' 1. support-edit -Path "{cfg_dir}" -Capability on — включить возможность изменения (объекты пока остаются на замке);\n'
|
|
||||||
f' 2. support-edit -Path "{rp}" -Set editable — открыть этот объект для редактирования.\n'
|
|
||||||
" Изменение применяется в базу полной загрузкой выгрузки и обходит механизм обновлений вендора."
|
|
||||||
)
|
|
||||||
elif code == "not-removed":
|
|
||||||
state = f"Состояние: объект «{rp}» на поддержке (не снят с поддержки) — его удаление разорвёт обновления вендора."
|
|
||||||
fix = (
|
|
||||||
"Либо сначала снять объект с поддержки, затем удалять:\n"
|
|
||||||
f' support-edit -Path "{rp}" -Set off-support — объект уходит из-под обновлений, после этого удаление безопасно.'
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
state = f"Состояние: объект «{rp}» на замке (возможность изменения конфигурации включена, но сам объект не редактируется)."
|
|
||||||
fix = (
|
|
||||||
"Либо разрешить редактирование этого объекта (навык support-edit, выбрать одно):\n"
|
|
||||||
f' support-edit -Path "{rp}" -Set editable — редактировать и дальше получать обновления вендора (возможны конфликты слияния);\n'
|
|
||||||
f' support-edit -Path "{rp}" -Set off-support — снять с поддержки: обновления по объекту больше не приходят.'
|
|
||||||
)
|
|
||||||
sys.stderr.write(head + "\n" + state + "\n" + cfe + "\n" + fix + "\n" + off_note + "\n")
|
|
||||||
sys.exit(1)
|
|
||||||
except SystemExit:
|
|
||||||
raise
|
|
||||||
except Exception:
|
|
||||||
return
|
|
||||||
|
|
||||||
|
|
||||||
def detect_format_version(d):
|
|
||||||
while d:
|
|
||||||
cfg_path = os.path.join(d, "Configuration.xml")
|
|
||||||
if os.path.isfile(cfg_path):
|
|
||||||
with open(cfg_path, "r", encoding="utf-8-sig") as f:
|
|
||||||
head = f.read(2000)
|
|
||||||
m = re.search(r'<MetaDataObject[^>]+version="(\d+\.\d+)"', head)
|
|
||||||
if m:
|
|
||||||
return m.group(1)
|
|
||||||
parent = os.path.dirname(d)
|
|
||||||
if parent == d:
|
|
||||||
break
|
|
||||||
d = parent
|
|
||||||
return "2.17"
|
|
||||||
|
|
||||||
|
|
||||||
def save_xml_with_bom(tree, path):
|
|
||||||
"""Save XML tree to file with UTF-8 BOM."""
|
|
||||||
xml_bytes = etree.tostring(tree, xml_declaration=True, encoding="UTF-8")
|
|
||||||
xml_bytes = xml_bytes.replace(b"<?xml version='1.0' encoding='UTF-8'?>", b'<?xml version="1.0" encoding="utf-8"?>')
|
|
||||||
if not xml_bytes.endswith(b"\n"):
|
|
||||||
xml_bytes += b"\n"
|
|
||||||
with open(path, "wb") as f:
|
|
||||||
f.write(b"\xef\xbb\xbf")
|
|
||||||
f.write(xml_bytes)
|
|
||||||
|
|
||||||
|
|
||||||
def write_text_with_bom(path, text):
|
|
||||||
"""Write text to file with UTF-8 BOM."""
|
|
||||||
with open(path, "w", encoding="utf-8-sig") as f:
|
|
||||||
f.write(text)
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
sys.stdout.reconfigure(encoding="utf-8")
|
|
||||||
sys.stderr.reconfigure(encoding="utf-8")
|
|
||||||
parser = argparse.ArgumentParser(description="Add built-in help to 1C object", allow_abbrev=False)
|
|
||||||
parser.add_argument("-ObjectName", required=True)
|
|
||||||
parser.add_argument("-Lang", default="ru")
|
|
||||||
parser.add_argument("-SrcDir", default="src")
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
object_name = args.ObjectName
|
|
||||||
lang = args.Lang
|
|
||||||
src_dir = args.SrcDir
|
|
||||||
|
|
||||||
format_version = detect_format_version(os.path.abspath(src_dir))
|
|
||||||
|
|
||||||
# --- Checks ---
|
|
||||||
|
|
||||||
object_dir = os.path.join(src_dir, object_name)
|
|
||||||
ext_dir = os.path.join(object_dir, "Ext")
|
|
||||||
|
|
||||||
if not os.path.isdir(ext_dir):
|
|
||||||
print(f"Каталог объекта не найден: {ext_dir}. Проверьте путь ObjectName (например Catalogs/МойСправочник).", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
help_xml_path = os.path.join(ext_dir, "Help.xml")
|
|
||||||
if os.path.exists(help_xml_path):
|
|
||||||
print(f"Справка уже существует: {help_xml_path}", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
assert_edit_allowed(object_dir, "editable")
|
|
||||||
|
|
||||||
# --- 1. Help.xml ---
|
|
||||||
|
|
||||||
help_xml = (
|
|
||||||
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
|
||||||
'<Help xmlns="http://v8.1c.ru/8.3/xcf/extrnprops"'
|
|
||||||
' xmlns:xs="http://www.w3.org/2001/XMLSchema"'
|
|
||||||
' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"'
|
|
||||||
f' version="{format_version}">\n'
|
|
||||||
f'\t<Page>{lang}</Page>\n'
|
|
||||||
'</Help>'
|
|
||||||
)
|
|
||||||
|
|
||||||
write_text_with_bom(help_xml_path, help_xml)
|
|
||||||
|
|
||||||
# --- 2. Help/<lang>.html ---
|
|
||||||
|
|
||||||
help_dir = os.path.join(ext_dir, "Help")
|
|
||||||
os.makedirs(help_dir, exist_ok=True)
|
|
||||||
|
|
||||||
help_html_path = os.path.join(help_dir, f"{lang}.html")
|
|
||||||
|
|
||||||
help_html = (
|
|
||||||
'<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN">\n'
|
|
||||||
'<html>\n'
|
|
||||||
'<head>\n'
|
|
||||||
' <meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>\n'
|
|
||||||
' <link rel="stylesheet" type="text/css" href="v8help://service_book/service_style"/>\n'
|
|
||||||
'</head>\n'
|
|
||||||
'<body>\n'
|
|
||||||
f' <h1>{object_name}</h1>\n'
|
|
||||||
' <p>Описание.</p>\n'
|
|
||||||
'</body>\n'
|
|
||||||
'</html>'
|
|
||||||
)
|
|
||||||
|
|
||||||
write_text_with_bom(help_html_path, help_html)
|
|
||||||
|
|
||||||
# --- 3. Check IncludeHelpInContents in form metadata ---
|
|
||||||
|
|
||||||
forms_dir = os.path.join(object_dir, "Forms")
|
|
||||||
if os.path.isdir(forms_dir):
|
|
||||||
for entry in os.listdir(forms_dir):
|
|
||||||
if not entry.endswith(".xml"):
|
|
||||||
continue
|
|
||||||
form_meta_full = os.path.join(forms_dir, entry)
|
|
||||||
if not os.path.isfile(form_meta_full):
|
|
||||||
continue
|
|
||||||
|
|
||||||
parser_xml = etree.XMLParser(remove_blank_text=False)
|
|
||||||
form_tree = etree.parse(form_meta_full, parser_xml)
|
|
||||||
form_root = form_tree.getroot()
|
|
||||||
|
|
||||||
include_help = form_root.find(".//md:IncludeHelpInContents", NSMAP)
|
|
||||||
if include_help is not None:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Add after <FormType>
|
|
||||||
form_type = form_root.find(".//md:FormType", NSMAP)
|
|
||||||
if form_type is None:
|
|
||||||
continue
|
|
||||||
|
|
||||||
parent = form_type.getparent()
|
|
||||||
ns = "http://v8.1c.ru/8.3/MDClasses"
|
|
||||||
new_elem = etree.SubElement(parent, f"{{{ns}}}IncludeHelpInContents")
|
|
||||||
new_elem.text = "false"
|
|
||||||
# Remove SubElement's auto-placement (it appends to end) and insert after FormType
|
|
||||||
parent.remove(new_elem)
|
|
||||||
|
|
||||||
# Find index of FormType in parent
|
|
||||||
form_type_idx = list(parent).index(form_type)
|
|
||||||
|
|
||||||
# Insert after FormType
|
|
||||||
parent.insert(form_type_idx + 1, new_elem)
|
|
||||||
|
|
||||||
# Whitespace handling: copy FormType's tail as new_elem's tail,
|
|
||||||
# and set FormType's tail to include newline + indent
|
|
||||||
new_elem.tail = form_type.tail
|
|
||||||
form_type.tail = "\n\t\t\t"
|
|
||||||
|
|
||||||
save_xml_with_bom(form_tree, form_meta_full)
|
|
||||||
|
|
||||||
print(f" IncludeHelpInContents добавлен: {entry}")
|
|
||||||
|
|
||||||
print(f"[OK] Создана справка: {object_name}")
|
|
||||||
print(f" Метаданные: {help_xml_path}")
|
|
||||||
print(f" Страница: {help_html_path}")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
---
|
|
||||||
name: meta-compile
|
|
||||||
description: Создать объект метаданных 1С. Используй когда нужно создать или добавить справочник, документ, регистр, перечисление, константу, общий модуль, обработку, отчёт и др.
|
|
||||||
argument-hint: <JsonPath> <OutputDir>
|
|
||||||
allowed-tools:
|
|
||||||
- Bash
|
|
||||||
- Read
|
|
||||||
- Write
|
|
||||||
- Glob
|
|
||||||
---
|
|
||||||
|
|
||||||
# /meta-compile — генерация объектов метаданных из JSON
|
|
||||||
|
|
||||||
Принимает JSON-определение объекта → генерирует XML + модули в структуре выгрузки конфигурации и
|
|
||||||
регистрирует объект в `Configuration.xml`.
|
|
||||||
|
|
||||||
## Порядок работы
|
|
||||||
|
|
||||||
1. Составь JSON по синтаксису ниже → запиши во временный файл.
|
|
||||||
2. Запусти скрипт.
|
|
||||||
3. Изменить созданный объект — `/meta-edit`; проверить — `/meta-validate`.
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
python ".augment/skills/meta-compile/scripts/meta-compile.py" -JsonPath "<json>" -OutputDir "<ConfigDir>"
|
|
||||||
```
|
|
||||||
|
|
||||||
| Параметр | Описание |
|
|
||||||
|----------|----------|
|
|
||||||
| `JsonPath` | Путь к JSON-файлу |
|
|
||||||
| `OutputDir` | Корень выгрузки конфигурации (где `Configuration.xml`, `Catalogs/`, `Documents/`, …) |
|
|
||||||
|
|
||||||
## Формат JSON
|
|
||||||
|
|
||||||
**Один объект** `{ ... }` или **массив** объектов `[{ ... }, { ... }]` (batch — несколько объектов за прогон).
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "type": "Catalog", "name": "Номенклатура", "...свойства типа...": "..." }
|
|
||||||
```
|
|
||||||
|
|
||||||
`type` и `name` — обязательные. Остальное — по типу (см. индекс ниже). `synonym` по умолчанию выводится из
|
|
||||||
`name` (CamelCase → слова через пробел); можно задать явно строкой или мультиязычно: `"synonym": { "ru": "…", "en": "…" }`.
|
|
||||||
|
|
||||||
## Реквизиты (shorthand)
|
|
||||||
|
|
||||||
Массивы `attributes`, `dimensions`, `resources` и колонки в `tabularSections` задаются строками:
|
|
||||||
|
|
||||||
```
|
|
||||||
"Имя" → String(10)
|
|
||||||
"Имя: Тип" → с типом
|
|
||||||
"Имя: Тип | req, index" → с флагами
|
|
||||||
```
|
|
||||||
|
|
||||||
**Типы:** `String(100)`, `String(10, fixed)` (фикс. длина), `Number(15,2)`, `Boolean`, `Date`, `DateTime`,
|
|
||||||
`Time`, ссылочные `CatalogRef.Xxx` / `DocumentRef.Xxx` / `EnumRef.Xxx` / `DefinedType.Xxx` и т.п.
|
|
||||||
Составной тип — через `+`: `"Значение: String + Number(15,2) + CatalogRef.Контрагенты"`.
|
|
||||||
|
|
||||||
**Флаги** (после `|`, через запятую):
|
|
||||||
|
|
||||||
| Флаг | Значение | Где |
|
|
||||||
|------|----------|-----|
|
|
||||||
| `req` | обязательное заполнение | attributes, dimensions, resources |
|
|
||||||
| `index` | индексировать | attributes, dimensions |
|
|
||||||
| `indexAdditional` | индекс с доп. упорядочиванием | attributes |
|
|
||||||
| `multiline` | многострочное поле | attributes |
|
|
||||||
| `nonneg` | неотрицательное (Number) | attributes, resources |
|
|
||||||
| `master` | ведущее измерение | dimensions (регистры) |
|
|
||||||
| `mainFilter` | основной отбор | dimensions (регистры) |
|
|
||||||
| `denyIncomplete` | запрет незаполненных | dimensions |
|
|
||||||
| `useInTotals` | использовать в итогах | dimensions (регистр накопления) |
|
|
||||||
|
|
||||||
Реквизиту нужны свойства сверх shorthand (значение заполнения, параметры выбора, формат, подсказка, …) —
|
|
||||||
задаётся **объектной формой**, см. `reference/attributes.md`.
|
|
||||||
|
|
||||||
## Табличные части
|
|
||||||
|
|
||||||
```json
|
|
||||||
"tabularSections": { "Товары": ["Номенклатура: CatalogRef.Номенклатура", "Количество: Number(15,3)"] }
|
|
||||||
```
|
|
||||||
|
|
||||||
Ключ — имя ТЧ, значение — массив колонок (shorthand) ЛИБО объект со свойствами ТЧ (см. `reference/attributes.md`).
|
|
||||||
|
|
||||||
## Индекс: свойства по типам
|
|
||||||
|
|
||||||
Для каждого типа — свой reference-файл со свойствами, дефолтами и допустимыми значениями:
|
|
||||||
|
|
||||||
| Тип(ы) | Файл |
|
|
||||||
|--------|------|
|
|
||||||
| Catalog (справочник) | `reference/catalog.md` |
|
|
||||||
| Document, DocumentJournal, Sequence, DocumentNumerator | `reference/document.md` |
|
|
||||||
| InformationRegister, AccumulationRegister, AccountingRegister, CalculationRegister | `reference/registers.md` |
|
|
||||||
| ChartOfAccounts, ChartOfCharacteristicTypes, ChartOfCalculationTypes | `reference/charts.md` |
|
|
||||||
| ExchangePlan | `reference/exchangeplan.md` |
|
|
||||||
| BusinessProcess, Task | `reference/process.md` |
|
|
||||||
| Report, DataProcessor | `reference/report-dataprocessor.md` |
|
|
||||||
| CommonModule, ScheduledJob, EventSubscription | `reference/code.md` |
|
|
||||||
| HTTPService, WebService | `reference/web.md` |
|
|
||||||
| Enum, Constant, DefinedType | `reference/simple.md` |
|
|
||||||
| FunctionalOption, FilterCriterion, SettingsStorage, CommonForm, CommonPicture, CommonTemplate, служебные | `reference/other-types.md` |
|
|
||||||
|
|
||||||
Кросс-типовые детали:
|
|
||||||
- **`reference/attributes.md`** — объектная форма реквизита и колонки ТЧ (значение заполнения, параметры
|
|
||||||
выбора, формат, подсказка, границы, …) + свойства самой ТЧ.
|
|
||||||
- **`reference/blocks.md`** — блоки объекта: представления, команды (+ характеристики/стандартные реквизиты).
|
|
||||||
|
|
||||||
Эта инструкция и reference-файлы — полная документация. Не ищи примеры XML в выгрузках конфигураций.
|
|
||||||
|
|
||||||
## Примеры
|
|
||||||
|
|
||||||
Справочник с реквизитами:
|
|
||||||
```json
|
|
||||||
{ "type": "Catalog", "name": "Организации", "descriptionLength": 100,
|
|
||||||
"attributes": ["ИНН: String(12)", "КПП: String(9)", "Директор: CatalogRef.ФизическиеЛица"] }
|
|
||||||
```
|
|
||||||
|
|
||||||
Документ с движениями и ТЧ:
|
|
||||||
```json
|
|
||||||
{ "type": "Document", "name": "ПриходнаяНакладная",
|
|
||||||
"registerRecords": ["AccumulationRegister.ОстаткиТоваров"],
|
|
||||||
"attributes": ["Организация: CatalogRef.Организации", "Контрагент: CatalogRef.Контрагенты"],
|
|
||||||
"tabularSections": { "Товары": ["Номенклатура: CatalogRef.Номенклатура", "Количество: Number(15,3)", "Цена: Number(15,2)"] } }
|
|
||||||
```
|
|
||||||
|
|
||||||
Регистр сведений:
|
|
||||||
```json
|
|
||||||
{ "type": "InformationRegister", "name": "КурсыВалют", "periodicity": "Day",
|
|
||||||
"dimensions": ["Валюта: CatalogRef.Валюты | master, mainFilter, denyIncomplete"],
|
|
||||||
"resources": ["Курс: Number(15,4)", "Кратность: Number(10,0)"] }
|
|
||||||
```
|
|
||||||
|
|
||||||
Batch:
|
|
||||||
```json
|
|
||||||
[ { "type": "Enum", "name": "Статусы", "values": ["Новый", "Закрыт"] },
|
|
||||||
{ "type": "Catalog", "name": "Валюты" },
|
|
||||||
{ "type": "Constant", "name": "ОсновнаяВалюта", "valueType": "CatalogRef.Валюты" } ]
|
|
||||||
```
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
# Объектная форма реквизита и табличной части
|
|
||||||
|
|
||||||
Когда реквизиту (в `attributes` / `dimensions` / `resources` / колонках ТЧ) нужны свойства сверх
|
|
||||||
shorthand — вместо строки задаётся объект:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "name": "Цена", "type": "Number(15,2)", "tooltip": "Цена за единицу", "fillValue": 0 }
|
|
||||||
```
|
|
||||||
|
|
||||||
`name` и `type` обязательны (тип можно задать и раздельно: `"type": "Number", "length": 15, "precision": 2`).
|
|
||||||
Остальные ключи — ниже, все со значением по умолчанию (не задавать, если устраивает дефолт).
|
|
||||||
|
|
||||||
## Свойства реквизита
|
|
||||||
|
|
||||||
| Ключ | Умолчание | Значения |
|
|
||||||
|------|-----------|----------|
|
|
||||||
| `synonym` | из имени | ML (строка или `{ru,en}`) |
|
|
||||||
| `tooltip` | пусто | ML |
|
|
||||||
| `comment` | пусто | строка |
|
|
||||||
| `fillChecking` | `DontCheck` | `DontCheck` / `ShowError` / `ShowWarning` (то же, что флаг `req`) |
|
|
||||||
| `fullTextSearch` | `Use` | `Use` / `DontUse` |
|
|
||||||
| `fillFromFillingValue` | `false` | bool |
|
|
||||||
| `fillValue` | по типу (см. ниже) | значение заполнения |
|
|
||||||
| `createOnInput` | `Auto` | `Auto` / `Use` / `DontUse` |
|
|
||||||
| `quickChoice` | `Auto` | `Auto` / `Use` / `DontUse` |
|
|
||||||
| `choiceHistoryOnInput` | `Auto` | `Auto` / `DontUse` |
|
|
||||||
| `dataHistory` | `Use` | `Use` / `DontUse` |
|
|
||||||
| `use` | `ForItem` | `ForItem` / `ForFolder` / `ForFolderAndItem` (реквизит иерархического справочника) |
|
|
||||||
| `passwordMode` | `false` | bool |
|
|
||||||
| `multiLine` | `false` | bool (то же, что флаг `multiline`) |
|
|
||||||
| `extendedEdit` | `false` | bool (расширенное редактирование — многострочный ввод) |
|
|
||||||
| `mask` | пусто | строка маски ввода |
|
|
||||||
| `format` / `editFormat` | пусто | форматная строка 1С (ML) |
|
|
||||||
| `markNegatives` | `false` | bool (выделять отрицательные, для Number) |
|
|
||||||
| `minValue` / `maxValue` | не задано | граница диапазона (см. ниже) |
|
|
||||||
| `choiceParameterLinks` | пусто | связи параметров выбора (см. ниже) |
|
|
||||||
| `choiceParameters` | пусто | параметры выбора (см. ниже) |
|
|
||||||
| `choiceForm` | пусто | ссылка на форму выбора `Тип.Объект.Form.ИмяФормы` |
|
|
||||||
| `choiceFoldersAndItems` | `Items` | `Items` / `Folders` / `FoldersAndItems` (что выбирать в иерарх. справочнике) |
|
|
||||||
|
|
||||||
Индексирование задаётся флагом `index` / `indexAdditional` в shorthand, либо в объекте — как и в строковой форме,
|
|
||||||
через `"type": "… | index"`.
|
|
||||||
|
|
||||||
### `fillValue` — значение заполнения
|
|
||||||
|
|
||||||
Пустое значение по типу компилятор подставляет сам — ключ **не задают**:
|
|
||||||
|
|
||||||
| Тип реквизита | Пустое значение |
|
|
||||||
|---------------|-----------------|
|
|
||||||
| String | пустая строка |
|
|
||||||
| Number | `0` |
|
|
||||||
| Boolean, Date, ссылочный, составной | не задано (nil) |
|
|
||||||
|
|
||||||
Ключ `fillValue` задают для **конкретного** значения — интерпретируется по типу реквизита:
|
|
||||||
|
|
||||||
- **Boolean** — `true` / `false`.
|
|
||||||
- **Number** — число (`21`, `1.5`).
|
|
||||||
- **String** — строка.
|
|
||||||
- **Date** — ISO-строка `"2020-01-01T00:00:00"`.
|
|
||||||
- **Ссылочный** — путь: `"Catalog.Валюты.EmptyRef"` (пустая ссылка), `"Enum.Периодичность.EnumValue.Месяц"`
|
|
||||||
(значение перечисления), `"Catalog.СтраныМира.Россия"` (предопределённый элемент).
|
|
||||||
- **`null`** — явно «значение не задано» (nil), когда нужно перекрыть непустой дефолт типа.
|
|
||||||
- **`{ "emptyRef": true }`** — пустая ссылка для реквизита типа `DefinedType.X` (когда тип из пути не выводится).
|
|
||||||
|
|
||||||
> Пустая ссылка (`EmptyRef`) и `null` — разное: платформа хранит их отдельно.
|
|
||||||
|
|
||||||
### `minValue` / `maxValue` — границы диапазона
|
|
||||||
|
|
||||||
Число → числовая граница; строка → строковая (напр. год `"2000"`). Без ключа — граница не задана.
|
|
||||||
|
|
||||||
### `choiceParameterLinks` — связи параметров выбора
|
|
||||||
|
|
||||||
Связывают параметр выбора этого реквизита с другим реквизитом объекта. Массив строк или объектов:
|
|
||||||
|
|
||||||
```json
|
|
||||||
"choiceParameterLinks": ["Отбор.Организация=Организация", "Отбор.Договор=Договор:DontChange"]
|
|
||||||
"choiceParameterLinks": [{ "name": "Отбор.Организация", "dataPath": "Организация", "valueChange": "Clear" }]
|
|
||||||
```
|
|
||||||
|
|
||||||
- `dataPath` — реквизит **того же объекта**: имя обычного реквизита (`"Организация"`) или стандартного
|
|
||||||
(`"Владелец"`, `"Ссылка"`).
|
|
||||||
- `valueChange` — `Clear` (по умолчанию) / `DontChange`.
|
|
||||||
|
|
||||||
### `choiceParameters` — параметры выбора
|
|
||||||
|
|
||||||
Фиксируют параметр выбора значением. Массив строк или объектов:
|
|
||||||
|
|
||||||
```json
|
|
||||||
"choiceParameters": ["Отбор.ЭтоГруппа=false"]
|
|
||||||
"choiceParameters": [{ "name": "Отбор.Владелец", "value": "Catalog.Организации.EmptyRef" }]
|
|
||||||
```
|
|
||||||
|
|
||||||
- `value` — bool / число / строка / ссылочный путь (несёт тип) ИЛИ массив (список фиксированных значений).
|
|
||||||
- Для набора голых имён-значений добавьте `type` (тип поля-фильтра), чтобы они стали ссылками:
|
|
||||||
`{ "name": "Отбор.Тип", "type": "EnumRef.ТипыВЕТИС", "value": ["EmptyRef", "ТТН"] }`.
|
|
||||||
|
|
||||||
### Редкие ключи
|
|
||||||
|
|
||||||
`linkByType` — связь по типу (тип реквизита-Характеристики берётся из другого реквизита):
|
|
||||||
`{ "dataPath": "Свойство", "linkItem": 0 }` или строка-путь. Применяется для реквизитов-характеристик.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Табличная часть — объектная форма
|
|
||||||
|
|
||||||
Значение в `tabularSections` — массив колонок ЛИБО объект со свойствами самой ТЧ:
|
|
||||||
|
|
||||||
```json
|
|
||||||
"tabularSections": {
|
|
||||||
"Товары": {
|
|
||||||
"synonym": { "ru": "Товары", "en": "Goods" },
|
|
||||||
"tooltip": "Строки заказа",
|
|
||||||
"fillChecking": "ShowError",
|
|
||||||
"attributes": ["Номенклатура: CatalogRef.Номенклатура", "Количество: Number(15,3)"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
| Ключ | Умолчание | Значения |
|
|
||||||
|------|-----------|----------|
|
|
||||||
| `synonym` | из имени | ML |
|
|
||||||
| `tooltip` | пусто | ML |
|
|
||||||
| `comment` | пусто | строка |
|
|
||||||
| `fillChecking` | `DontCheck` | `DontCheck` / `ShowError` / `ShowWarning` (обязательность заполнения ТЧ) |
|
|
||||||
| `use` | `ForItem` | `ForItem` / `ForFolder` / `ForFolderAndItem` (только Catalog / ChartOfCharacteristicTypes) |
|
|
||||||
| `attributes` | `[]` | колонки (shorthand или объектная форма реквизита) |
|
|
||||||
| `lineNumber` | — | кастомизация стандартного реквизита НомерСтроки (см. ниже) |
|
|
||||||
|
|
||||||
### `lineNumber` — стандартный реквизит НомерСтроки
|
|
||||||
|
|
||||||
У каждой ТЧ есть стандартный реквизит НомерСтроки. По умолчанию все его свойства типовые. Ключ `lineNumber`
|
|
||||||
на объектной форме ТЧ их переопределяет:
|
|
||||||
|
|
||||||
```json
|
|
||||||
"Строки": { "lineNumber": { "synonym": "Номер п/п", "fullTextSearch": "DontUse" }, "attributes": [...] }
|
|
||||||
```
|
|
||||||
|
|
||||||
Переопределяемые: `synonym`, `comment`, `fullTextSearch` (`Use`/`DontUse`), `tooltip`, `format`, `editFormat`,
|
|
||||||
`choiceHistoryOnInput` (`Auto`/`DontUse`).
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
# Блоки объекта
|
|
||||||
|
|
||||||
Кросс-типовые блоки уровня объекта (применимы к ссылочным типам — Catalog, Document, ChartOf*, ExchangePlan,
|
|
||||||
BusinessProcess, Task и др.).
|
|
||||||
|
|
||||||
## Представления
|
|
||||||
|
|
||||||
Тексты представления объекта в интерфейсе (ML — строка или `{ru,en}`, по умолчанию пусто):
|
|
||||||
|
|
||||||
| Ключ | Смысл |
|
|
||||||
|------|-------|
|
|
||||||
| `objectPresentation` | представление объекта |
|
|
||||||
| `extendedObjectPresentation` | расширенное представление объекта |
|
|
||||||
| `listPresentation` | представление списка |
|
|
||||||
| `extendedListPresentation` | расширенное представление списка |
|
|
||||||
| `explanation` | пояснение |
|
|
||||||
|
|
||||||
Набор доступных ключей зависит от типа (у списочных без формы объекта нет `objectPresentation` и т.п.).
|
|
||||||
|
|
||||||
```json
|
|
||||||
"listPresentation": "Организации", "objectPresentation": { "ru": "Организация", "en": "Company" }
|
|
||||||
```
|
|
||||||
|
|
||||||
## Команды
|
|
||||||
|
|
||||||
Команды объекта. Ключ — имя команды, значение — объект свойств (map `имя → объект` или массив `[{name, …}]`).
|
|
||||||
Для каждой команды создаётся заготовка модуля с обработчиком `ОбработкаКоманды`.
|
|
||||||
|
|
||||||
| Ключ | Умолчание | Значения |
|
|
||||||
|------|-----------|----------|
|
|
||||||
| `synonym` | из имени | ML |
|
|
||||||
| `tooltip` | пусто | ML |
|
|
||||||
| `comment` | пусто | строка |
|
|
||||||
| `group` | **обязательно** | группа размещения (см. ниже) |
|
|
||||||
| `commandParameterType` | пусто | тип параметра (напр. `CatalogRef.Номенклатура`) — **только для групп формы** |
|
|
||||||
| `parameterUseMode` | `Single` | `Single` / `Multiple` |
|
|
||||||
| `modifiesData` | `false` | bool |
|
|
||||||
| `representation` | `Auto` | вид отображения |
|
|
||||||
| `picture` | пусто | ссылка на картинку (`StdPicture.Print`, `CommonPicture.Загрузка`) |
|
|
||||||
| `shortcut` | пусто | сочетание клавиш |
|
|
||||||
|
|
||||||
```json
|
|
||||||
"commands": {
|
|
||||||
"ПечатьЭтикеток": { "synonym": "Печать этикеток", "group": "FormCommandBarImportant",
|
|
||||||
"commandParameterType": "CatalogRef.Номенклатура", "picture": "StdPicture.Print" }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Группа (`group`) обязательна** — каждая команда размещается в группе командного интерфейса:
|
|
||||||
|
|
||||||
- **Командный интерфейс раздела** (панель навигации / панель действий; `commandParameterType` **недоступен**):
|
|
||||||
`NavigationPanelImportant` / `NavigationPanelOrdinary` / `NavigationPanelSeeAlso`,
|
|
||||||
`ActionsPanelCreate` / `ActionsPanelReports` / `ActionsPanelTools`.
|
|
||||||
- **Командный интерфейс формы** (`commandParameterType` допустим): `FormCommandBarImportant` /
|
|
||||||
`FormCommandBarCreateBasedOn`, `FormNavigationPanelImportant` / `FormNavigationPanelGoTo` / `FormNavigationPanelSeeAlso`.
|
|
||||||
- **Кастомная группа:** `CommandGroup.<Имя>` (параметр допустим).
|
|
||||||
|
|
||||||
Группа раздела вместе с `commandParameterType` → ошибка.
|
|
||||||
|
|
||||||
## `inputByString` / `dataLockFields` / `basedOn`
|
|
||||||
|
|
||||||
Списки полей/объектов уровня объекта. Поля — по имени реквизита объекта (обычного или стандартного).
|
|
||||||
|
|
||||||
- **`inputByString`** — поля быстрого ввода по строке. По умолчанию выводятся из Кода/Наименования — ключ не нужен;
|
|
||||||
задать при другом наборе/порядке, либо `[]` для отключения.
|
|
||||||
```json
|
|
||||||
"inputByString": ["Код", "Наименование", "Контрагент"]
|
|
||||||
```
|
|
||||||
- **`dataLockFields`** — поля управляемой блокировки данных (по умолчанию пусто).
|
|
||||||
```json
|
|
||||||
"dataLockFields": ["Организация", "Контрагент"]
|
|
||||||
```
|
|
||||||
- **`basedOn`** — «ввод на основании»: список ссылок на объекты метаданных (по умолчанию пусто).
|
|
||||||
```json
|
|
||||||
"basedOn": ["Catalog.Контрагенты", "Document.ЗаказПоставщику"]
|
|
||||||
```
|
|
||||||
|
|
||||||
## `standardAttributes` — кастомизация стандартных реквизитов
|
|
||||||
|
|
||||||
Стандартные реквизиты объекта (Наименование, Код, Владелец, …) переопределяются блоком
|
|
||||||
`standardAttributes` — объект `{ ИмяРеквизита: { переопределения } }`. Имена — как в 1С: `Description`, `Code`,
|
|
||||||
`Owner`, `Parent`, `DeletionMark`, `Ref` и т.д. (для Document — `Date`, `Number`, `Posted`).
|
|
||||||
|
|
||||||
Переопределяемые поля — как у обычного реквизита (`synonym`, `tooltip`, `fillChecking`, `fillValue`,
|
|
||||||
`choiceParameters`, `comment`, `mask`, `choiceForm`; полный набор — `attributes.md`).
|
|
||||||
|
|
||||||
```json
|
|
||||||
"standardAttributes": {
|
|
||||||
"Description": { "synonym": "Наименование контрагента" },
|
|
||||||
"Code": { "fillChecking": "ShowError" }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## `characteristics` — «Дополнительные реквизиты и сведения»
|
|
||||||
|
|
||||||
Привязка плана видов характеристик. Массив; каждый элемент связывает **источник типов** (где определены
|
|
||||||
характеристики) и **источник значений** (где хранятся значения).
|
|
||||||
|
|
||||||
```json
|
|
||||||
"characteristics": [{
|
|
||||||
"types": { "from": "Catalog.НаборыДопРеквизитов.ДополнительныеРеквизиты",
|
|
||||||
"key": "Свойство", "filterField": "Ссылка", "filterValue": "Справочник_Организации" },
|
|
||||||
"values": { "from": "Catalog.Организации.TabularSection.ДополнительныеРеквизиты",
|
|
||||||
"object": "Ссылка", "type": "Свойство", "value": "Значение" }
|
|
||||||
}]
|
|
||||||
```
|
|
||||||
|
|
||||||
- `from` — таблица-источник; `key`/`filterField`/`object`/`type`/`value` — поля источника (по имени реквизита).
|
|
||||||
- `filterValue` — значение фильтра типов: имя предопределённого набора (строка) или путь к элементу.
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
# Catalog (Справочник)
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "type": "Catalog", "name": "Организации", "descriptionLength": 100,
|
|
||||||
"attributes": ["ИНН: String(12)", "КПП: String(9)"] }
|
|
||||||
```
|
|
||||||
|
|
||||||
## Свойства
|
|
||||||
|
|
||||||
| Ключ | Умолчание | Значения |
|
|
||||||
|------|-----------|----------|
|
|
||||||
| `comment` | пусто | строка |
|
|
||||||
| `hierarchical` | `false` | bool |
|
|
||||||
| `hierarchyType` | `HierarchyFoldersAndItems` | `HierarchyFoldersAndItems` / `HierarchyOfItems` |
|
|
||||||
| `limitLevelCount` | `false` | bool (ограничивать кол-во уровней) |
|
|
||||||
| `levelCount` | `2` | число уровней (при `limitLevelCount`) |
|
|
||||||
| `foldersOnTop` | `true` | bool (группы сверху) |
|
|
||||||
| `owners` | `[]` | массив ссылок-владельцев: `["CatalogRef.Контрагенты"]` |
|
|
||||||
| `subordinationUse` | `ToItems` | `ToItems` / `ToFolders` / `ToFoldersAndItems` (кому подчинён) |
|
|
||||||
| `codeLength` | `9` | длина кода (0 — без кода) |
|
|
||||||
| `codeType` | `String` | `String` / `Number` |
|
|
||||||
| `codeAllowedLength` | `Variable` | `Variable` / `Fixed` |
|
|
||||||
| `codeSeries` | `WholeCatalog` | `WholeCatalog` / `WithinSubordination` / `WithinOwnerSubordination` |
|
|
||||||
| `autonumbering` | `true` | bool (автонумерация) |
|
|
||||||
| `checkUnique` | `false` | bool (контроль уникальности кода) |
|
|
||||||
| `descriptionLength` | `25` | длина наименования |
|
|
||||||
| `defaultPresentation` | `AsDescription` | `AsDescription` / `AsCode` |
|
|
||||||
| `quickChoice` | `true` | bool (быстрый выбор) |
|
|
||||||
| `choiceMode` | `BothWays` | `BothWays` / `QuickChoice` / `FromForm` |
|
|
||||||
| `editType` | `InDialog` | `InDialog` / `InList` / `BothWays` |
|
|
||||||
| `createOnInput` | `Use` | `Auto` / `Use` / `DontUse` |
|
|
||||||
| `choiceHistoryOnInput` | `Auto` | `Auto` / `DontUse` |
|
|
||||||
| `fullTextSearch` | `Use` | `Use` / `DontUse` |
|
|
||||||
| `fullTextSearchOnInputByString` | `DontUse` | `Use` / `DontUse` |
|
|
||||||
| `searchStringModeOnInputByString` | `Begin` | `Begin` / `AnyPart` |
|
|
||||||
| `predefinedDataUpdate` | `Auto` | `Auto` / `DontAutoUpdate` / `AutoUpdate` |
|
|
||||||
| `dataLockControlMode` | `Managed` | `Automatic` / `Managed` |
|
|
||||||
| `useStandardCommands` | `true` | bool |
|
|
||||||
| `includeHelpInContents` | `false` | bool |
|
|
||||||
| `attributes` | `[]` | реквизиты (shorthand / объектная форма) |
|
|
||||||
| `tabularSections` | `{}` | табличные части |
|
|
||||||
|
|
||||||
**Формы.** Ссылка на форму — `Тип.Объект.Form.ИмяФормы` (напр. `Catalog.Организации.Form.ФормаЭлемента`).
|
|
||||||
Слоты основных форм: `defaultObjectForm`, `defaultFolderForm`, `defaultListForm`, `defaultChoiceForm`,
|
|
||||||
`defaultFolderChoiceForm`; вспомогательных — те же имена с префиксом `auxiliary` (`auxiliaryObjectForm`, …).
|
|
||||||
|
|
||||||
## `predefined` — предопределённые элементы
|
|
||||||
|
|
||||||
Массив предопределённых элементов → `Ext/Predefined.xml`. Элемент — строка (плоский случай) или объект (иерархия).
|
|
||||||
|
|
||||||
**Строка:** `"(Код) Имя [Наименование]"` — `Имя` обязательно; `(Код)` и `[Наименование]` опциональны.
|
|
||||||
Без `[...]` наименование выводится из имени; `[]` — пустое; `[текст]` — заданное.
|
|
||||||
|
|
||||||
```json
|
|
||||||
"predefined": [
|
|
||||||
"Основной",
|
|
||||||
"(1) ДокументОПриемке [Документ о приемке]",
|
|
||||||
{ "name": "Группа1", "isFolder": true, "description": "Прочие",
|
|
||||||
"childItems": ["Факс", "(7) Скайп"] }
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
**Объект:** `name` (обязательно), `code`, `description` (наименование), `isFolder` (признак группы),
|
|
||||||
`childItems` (вложенные, рекурсивно). Тип кода — по свойству `codeType`.
|
|
||||||
|
|
||||||
## Дополнительно
|
|
||||||
|
|
||||||
- Свойства реквизитов и табличных частей — `attributes.md`.
|
|
||||||
- Представления (`objectPresentation`, `listPresentation`, …), команды объекта, характеристики
|
|
||||||
(«ДопРеквизиты и сведения»), кастомизация стандартных реквизитов, `inputByString` / `dataLockFields` /
|
|
||||||
`basedOn` — `blocks.md`.
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
# Планы: ChartOfCharacteristicTypes, ChartOfAccounts, ChartOfCalculationTypes
|
|
||||||
|
|
||||||
Все три — ссылочные типы (наследуют слой Catalog: коды, `standardAttributes`, `characteristics`, `inputByString`,
|
|
||||||
формы, представления — см. `catalog.md` / `attributes.md` / `blocks.md`) с предопределёнными элементами и своими
|
|
||||||
специальными свойствами.
|
|
||||||
|
|
||||||
## ChartOfCharacteristicTypes (План видов характеристик)
|
|
||||||
|
|
||||||
Хранит определения характеристик (видов). Иерархический (папки+элементы).
|
|
||||||
|
|
||||||
| Ключ | Умолчание | Значения |
|
|
||||||
|------|-----------|----------|
|
|
||||||
| `valueType` | любой примитив | тип значения характеристики (составной — строка `"A + B"` или массив `valueTypes`) |
|
|
||||||
| `characteristicExtValues` | пусто | ссылка на справочник доп. значений |
|
|
||||||
| `hierarchical` | `false` | bool |
|
|
||||||
| `foldersOnTop` | `true` | bool |
|
|
||||||
| `codeLength` | `9` | длина кода |
|
|
||||||
| `descriptionLength` | `100` | длина наименования |
|
|
||||||
| `checkUnique` | `true` | bool |
|
|
||||||
| `autonumbering` | `true` | bool |
|
|
||||||
| `codeSeries` | `WholeCharacteristicKind` | серия кодов |
|
|
||||||
| `createOnInput` | `DontUse` | `Auto` / `Use` / `DontUse` |
|
|
||||||
| `dataLockControlMode` | `Managed` | `Automatic` / `Managed` |
|
|
||||||
| `predefined` | `[]` | предопределённые виды (несут тип значения — см. ниже) |
|
|
||||||
| `attributes` / `tabularSections` | `[]` / `{}` | реквизиты / табличные части |
|
|
||||||
|
|
||||||
**Предопределённые виды** несут **тип значения на элемент** — короткой строкой после `:`
|
|
||||||
(`"(Код) Имя [Наименование]: Тип"`, составной через `+`) или объектной формой с ключом `type`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
"predefined": [
|
|
||||||
"(000001) Цвет: CatalogRef.Цвета",
|
|
||||||
"(000002) Размер [Размер одежды]: String(50) + Number(3,0)",
|
|
||||||
{ "name": "Группа", "isFolder": true, "type": "" }
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
## ChartOfAccounts (План счетов)
|
|
||||||
|
|
||||||
| Ключ | Умолчание | Значения |
|
|
||||||
|------|-----------|----------|
|
|
||||||
| `extDimensionTypes` | пусто | ссылка на ПВХ видов субконто `ChartOfCharacteristicTypes.X` |
|
|
||||||
| `maxExtDimensionCount` | `0` (без ПВХ) / `3` (с ПВХ) | макс. число субконто |
|
|
||||||
| `codeMask` | пусто | маска кода счёта (напр. `"@@@.@@"`) |
|
|
||||||
| `codeLength` | `9` | длина кода |
|
|
||||||
| `descriptionLength` | `25` | длина наименования |
|
|
||||||
| `checkUnique` | `true` | bool |
|
|
||||||
| `codeSeries` | `WholeChartOfAccounts` | серия кодов |
|
|
||||||
| `defaultPresentation` | `AsCode` | `AsCode` / `AsDescription` |
|
|
||||||
| `autoOrderByCode` | `true` | bool |
|
|
||||||
| `orderLength` | `9` | длина строки упорядочивания |
|
|
||||||
| `createOnInput` | `DontUse` | `Auto` / `Use` / `DontUse` |
|
|
||||||
| `dataLockControlMode` | `Managed` | `Automatic` / `Managed` |
|
|
||||||
| `accountingFlags` | `[]` | признаки учёта (как реквизиты, тип по умолчанию Boolean; массив имён/реквизитов) |
|
|
||||||
| `extDimensionAccountingFlags` | `[]` | признаки учёта субконто (как реквизиты) |
|
|
||||||
| `predefined` | `[]` | предопределённые счета (см. ниже) |
|
|
||||||
|
|
||||||
**Предопределённый счёт** (объектная форма):
|
|
||||||
|
|
||||||
| Поле | Умолчание | Значения |
|
|
||||||
|------|-----------|----------|
|
|
||||||
| `name` | — | имя (обязательно) |
|
|
||||||
| `code` | пусто | код счёта |
|
|
||||||
| `description` | из имени | наименование |
|
|
||||||
| `accountType` | `ActivePassive` | `Active` / `Passive` / `ActivePassive` |
|
|
||||||
| `offBalance` | `false` | bool (забалансовый) |
|
|
||||||
| `order` | — | строка сортировки |
|
|
||||||
| `flags` | `[]` | включённые признаки учёта (только TRUE) |
|
|
||||||
| `subconto` | `[]` | виды субконто (см. ниже) |
|
|
||||||
| `childItems` | `[]` | подчинённые счета |
|
|
||||||
|
|
||||||
`subconto` — строка `"Вид | Признак1, Признак2"` (после `|` — включённые признаки учёта субконто; токен `Turnover` —
|
|
||||||
«только обороты») или объект `{ type, turnover, flags }`. `Вид` — имя предопределённого вида из ПВХ `extDimensionTypes`.
|
|
||||||
|
|
||||||
```json
|
|
||||||
"predefined": [
|
|
||||||
{ "name": "ОсновныеСредства", "code": "01", "accountType": "Active", "order": " 01",
|
|
||||||
"flags": ["Количественный"], "subconto": ["Номенклатура | Суммовой, Валютный"],
|
|
||||||
"childItems": [ { "name": "ОСВОрганизации", "code": "01.01", "accountType": "Active", "order": " 01.01" } ] }
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
## ChartOfCalculationTypes (План видов расчёта)
|
|
||||||
|
|
||||||
| Ключ | Умолчание | Значения |
|
|
||||||
|------|-----------|----------|
|
|
||||||
| `codeLength` | `5` | длина кода |
|
|
||||||
| `descriptionLength` | `100` | длина наименования |
|
|
||||||
| `codeAllowedLength` | `Variable` | `Variable` / `Fixed` |
|
|
||||||
| `dependenceOnCalculationTypes` | `DontUse` | `DontUse` / `OnPeriod` / `OnActionPeriod` |
|
|
||||||
| `baseCalculationTypes` | `[]` | базовые виды расчёта (список ссылок `ChartOfCalculationTypes.X`) |
|
|
||||||
| `actionPeriodUse` | `false` | bool (использовать период действия) |
|
|
||||||
| `createOnInput` | `DontUse` | `Auto` / `Use` / `DontUse` |
|
|
||||||
| `dataLockControlMode` | `Managed` | `Automatic` / `Managed` |
|
|
||||||
| `predefined` | `[]` | предопределённые виды расчёта (см. ниже) |
|
|
||||||
|
|
||||||
**Предопределённый вид расчёта** — плоский: строка `"(Код) Имя [Наименование]"` или объект
|
|
||||||
`{ name, code, description, actionPeriodIsBase }` (`actionPeriodIsBase` — bool, по умолчанию `false`).
|
|
||||||
|
|
||||||
```json
|
|
||||||
"predefined": [ "(00001) Оклад [Оклад по дням]", { "name": "Премия", "code": "00002", "actionPeriodIsBase": true } ]
|
|
||||||
```
|
|
||||||
|
|
||||||
> **ChartOfAccounts** ссылается на ПВХ через `extDimensionTypes`. Регистр бухгалтерии/расчёта требует
|
|
||||||
> соответствующий план (см. `registers.md`).
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
# CommonModule, ScheduledJob, EventSubscription (объекты, привязанные к коду)
|
|
||||||
|
|
||||||
## CommonModule (Общий модуль)
|
|
||||||
|
|
||||||
Флаги контекста выполнения (все bool, по умолчанию `false`). Создаёт пустой `Ext/Module.bsl`.
|
|
||||||
|
|
||||||
| Ключ | Умолчание | Значения |
|
|
||||||
|------|-----------|----------|
|
|
||||||
| `context` | — | шорткат флагов (см. ниже) |
|
|
||||||
| `global` | `false` | bool |
|
|
||||||
| `server` | `false` | bool |
|
|
||||||
| `serverCall` | `false` | bool (вызов сервера) |
|
|
||||||
| `clientManagedApplication` | `false` | bool (клиент управляемого приложения) |
|
|
||||||
| `clientOrdinaryApplication` | `false` | bool (клиент обычного приложения) |
|
|
||||||
| `externalConnection` | `false` | bool |
|
|
||||||
| `privileged` | `false` | bool |
|
|
||||||
| `returnValuesReuse` | `DontUse` | `DontUse` / `DuringRequest` / `DuringSession` |
|
|
||||||
|
|
||||||
Шорткат `context`: `"server"` → Server+ServerCall; `"client"` → ClientManagedApplication;
|
|
||||||
`"serverClient"` → Server+ClientManagedApplication.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "type": "CommonModule", "name": "ОбменДаннымиСервер", "context": "server", "returnValuesReuse": "DuringRequest" }
|
|
||||||
```
|
|
||||||
|
|
||||||
## ScheduledJob (Регламентное задание)
|
|
||||||
|
|
||||||
| Ключ | Умолчание | Значения |
|
|
||||||
|------|-----------|----------|
|
|
||||||
| `methodName` | пусто | метод-обработчик `"МодульСервер.Процедура"` (дополняется до `CommonModule.…`) |
|
|
||||||
| `description` | пусто | наименование задания |
|
|
||||||
| `key` | пусто | ключ |
|
|
||||||
| `use` | `false` | bool (использование) |
|
|
||||||
| `predefined` | `false` | bool (предопределённое) |
|
|
||||||
| `restartCountOnFailure` | `3` | число повторов при сбое |
|
|
||||||
| `restartIntervalOnFailure` | `10` | интервал повтора, сек |
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "type": "ScheduledJob", "name": "ОбменДанными", "methodName": "ОбменДаннымиСервер.Выполнить", "use": true }
|
|
||||||
```
|
|
||||||
|
|
||||||
## EventSubscription (Подписка на событие)
|
|
||||||
|
|
||||||
| Ключ | Умолчание | Значения |
|
|
||||||
|------|-----------|----------|
|
|
||||||
| `source` | `[]` | объекты-источники: `["CatalogObject.Контрагенты", "DocumentObject.Реализация"]` |
|
|
||||||
| `event` | `BeforeWrite` | `BeforeWrite` / `OnWrite` / `BeforeDelete` / `OnReadAtServer` / `FillCheckProcessing` … |
|
|
||||||
| `handler` | пусто | метод-обработчик `"МодульСервер.Процедура"` (дополняется до `CommonModule.…`) |
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "type": "EventSubscription", "name": "ПередЗаписьюКонтрагента",
|
|
||||||
"source": ["CatalogObject.Контрагенты"], "event": "BeforeWrite",
|
|
||||||
"handler": "ОбщегоНазначенияСервер.ПередЗаписьюКонтрагента" }
|
|
||||||
```
|
|
||||||
|
|
||||||
> Процедура-обработчик (`methodName` / `handler`) должна существовать в указанном общем модуле (экспортная).
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
# Document, DocumentJournal, Sequence, DocumentNumerator
|
|
||||||
|
|
||||||
## Document (Документ)
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "type": "Document", "name": "ПриходнаяНакладная",
|
|
||||||
"registerRecords": ["AccumulationRegister.ОстаткиТоваров"],
|
|
||||||
"attributes": ["Организация: CatalogRef.Организации"],
|
|
||||||
"tabularSections": { "Товары": ["Номенклатура: CatalogRef.Номенклатура", "Количество: Number(15,3)"] } }
|
|
||||||
```
|
|
||||||
|
|
||||||
| Ключ | Умолчание | Значения |
|
|
||||||
|------|-----------|----------|
|
|
||||||
| `comment` | пусто | строка |
|
|
||||||
| `numerator` | пусто | ссылка на нумератор `DocumentNumerator.X` |
|
|
||||||
| `numberType` | `String` | `String` / `Number` |
|
|
||||||
| `numberLength` | `11` | длина номера |
|
|
||||||
| `numberAllowedLength` | `Variable` | `Variable` / `Fixed` |
|
|
||||||
| `numberPeriodicity` | `Year` | `Nonperiodical` / `Day` / `Month` / `Quarter` / `Year` |
|
|
||||||
| `checkUnique` | `true` | bool |
|
|
||||||
| `autonumbering` | `true` | bool |
|
|
||||||
| `posting` | `Allow` | `Allow` / `Deny` (проведение) |
|
|
||||||
| `realTimePosting` | `Deny` | `Allow` / `Deny` (оперативное проведение) |
|
|
||||||
| `registerRecordsDeletion` | `AutoDelete` | `AutoDelete` / `AutoDeleteOnUnpost` / `AutoDeleteOff` |
|
|
||||||
| `registerRecordsWritingOnPost` | `WriteSelected` | `WriteModified` / `WriteSelected` / `WriteAll` |
|
|
||||||
| `sequenceFilling` | `AutoFill` | заполнение последовательностей |
|
|
||||||
| `postInPrivilegedMode` | `true` | bool |
|
|
||||||
| `unpostInPrivilegedMode` | `true` | bool |
|
|
||||||
| `createOnInput` | `Use` | `Auto` / `Use` / `DontUse` |
|
|
||||||
| `dataLockControlMode` | `Managed` | `Automatic` / `Managed` |
|
|
||||||
| `fullTextSearch` | `Use` | `Use` / `DontUse` |
|
|
||||||
| `dataHistory` | `DontUse` | `Use` / `DontUse` |
|
|
||||||
| `registerRecords` | `[]` | движения: список ссылок `["AccumulationRegister.ОстаткиТоваров", "InformationRegister.Цены"]` |
|
|
||||||
| `useStandardCommands` | `true` | bool |
|
|
||||||
| `includeHelpInContents` | `false` | bool |
|
|
||||||
| `attributes` / `tabularSections` | `[]` / `{}` | реквизиты / табличные части |
|
|
||||||
|
|
||||||
Формы: `defaultObjectForm`, `defaultListForm`, `defaultChoiceForm`, `auxiliary*` (см. `catalog.md`).
|
|
||||||
Реквизиты и ТЧ — `attributes.md`. Представления, команды, характеристики, `basedOn`, `standardAttributes`,
|
|
||||||
`inputByString`, `dataLockFields` — `blocks.md`.
|
|
||||||
|
|
||||||
## DocumentJournal (Журнал документов)
|
|
||||||
|
|
||||||
| Ключ | Умолчание | Значения |
|
|
||||||
|------|-----------|----------|
|
|
||||||
| `registeredDocuments` | `[]` | документы журнала: `["Document.Встреча", "Document.Звонок"]` |
|
|
||||||
| `columns` | `[]` | графы журнала (см. ниже) |
|
|
||||||
|
|
||||||
Графа — строка `"Имя"` или объект `{ name, synonym, indexing, references }`, где `indexing` — `Index`/`DontIndex`,
|
|
||||||
`references` — пути к реквизитам документов, отображаемым в графе.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "type": "DocumentJournal", "name": "Взаимодействия",
|
|
||||||
"registeredDocuments": ["Document.Встреча", "Document.Звонок"],
|
|
||||||
"columns": [{ "name": "Организация", "indexing": "Index",
|
|
||||||
"references": ["Document.Встреча.Attribute.Организация"] }] }
|
|
||||||
```
|
|
||||||
|
|
||||||
## Sequence (Последовательность документов)
|
|
||||||
|
|
||||||
| Ключ | Умолчание | Значения |
|
|
||||||
|------|-----------|----------|
|
|
||||||
| `moveBoundaryOnPosting` | `DontMove` | сдвиг границы при проведении |
|
|
||||||
| `documents` | `[]` | документы последовательности (список ссылок) |
|
|
||||||
| `registerRecords` | `[]` | движения (список ссылок) |
|
|
||||||
| `dataLockControlMode` | `Managed` | `Automatic` / `Managed` |
|
|
||||||
| `dimensions` | `[]` | измерения `{name, type, documentMap[], registerRecordsMap[]}` |
|
|
||||||
|
|
||||||
`documentMap` / `registerRecordsMap` — пути к реквизитам документов / движениям, соответствующим измерению.
|
|
||||||
|
|
||||||
## DocumentNumerator (Нумератор документов)
|
|
||||||
|
|
||||||
| Ключ | Умолчание | Значения |
|
|
||||||
|------|-----------|----------|
|
|
||||||
| `numberType` | `String` | `String` / `Number` |
|
|
||||||
| `numberLength` | `11` | длина номера |
|
|
||||||
| `numberAllowedLength` | `Variable` | `Variable` / `Fixed` |
|
|
||||||
| `numberPeriodicity` | `Year` | `Nonperiodical` / `Day` / … / `Year` |
|
|
||||||
| `checkUnique` | `true` | bool |
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
# ExchangePlan (План обмена)
|
|
||||||
|
|
||||||
Близок к справочнику (без иерархии/владельцев), плюс состав объектов обмена. Наследует слой Catalog:
|
|
||||||
`codeLength`, `codeAllowedLength`, `descriptionLength`, `defaultPresentation`, `editType`, `quickChoice`,
|
|
||||||
`choiceMode`, формы, `standardAttributes`, `characteristics`, `inputByString`, `basedOn`, представления —
|
|
||||||
см. `catalog.md` / `attributes.md` / `blocks.md`.
|
|
||||||
|
|
||||||
| Ключ | Умолчание | Значения |
|
|
||||||
|------|-----------|----------|
|
|
||||||
| `comment` | пусто | строка |
|
|
||||||
| `distributedInfoBase` | `false` | bool (распределённая ИБ — РИБ) |
|
|
||||||
| `includeConfigurationExtensions` | `false` | bool (включать расширения конфигурации) |
|
|
||||||
| `descriptionLength` | `150` | длина наименования |
|
|
||||||
| `createOnInput` | `DontUse` | `Auto` / `Use` / `DontUse` |
|
|
||||||
| `dataLockControlMode` | `Managed` | `Automatic` / `Managed` |
|
|
||||||
| `dataHistory` | `DontUse` | `Use` / `DontUse` |
|
|
||||||
| `useStandardCommands` | `true` | bool |
|
|
||||||
| `content` | `[]` | состав обмена (см. ниже) |
|
|
||||||
| `attributes` / `tabularSections` | `[]` / `{}` | реквизиты / табличные части |
|
|
||||||
|
|
||||||
## `content` — состав плана обмена
|
|
||||||
|
|
||||||
Список объектов-участников обмена; у каждого — признак авторегистрации изменений (по умолчанию выключена).
|
|
||||||
Элемент — ссылка на объект метаданных (строка) или объект с признаком:
|
|
||||||
|
|
||||||
```json
|
|
||||||
"content": [
|
|
||||||
"Catalog.Организации", // авторегистрация выключена
|
|
||||||
"InformationRegister.Курсы: autoRecord", // авторегистрация включена (токен)
|
|
||||||
{ "metadata": "Document.РеализацияТоваров", "autoRecord": true }
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
- Строка `"Тип.Имя"` — авторегистрация выключена; суффикс `: autoRecord` — включена.
|
|
||||||
- Объект: `metadata` (ссылка), `autoRecord` (bool или `Allow`/`Deny`).
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "type": "ExchangePlan", "name": "ОбменССайтом", "distributedInfoBase": false,
|
|
||||||
"content": ["Catalog.Номенклатура: autoRecord", "Catalog.Контрагенты: autoRecord"],
|
|
||||||
"attributes": ["АдресСервера: String(200)"] }
|
|
||||||
```
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
# Прочие типы
|
|
||||||
|
|
||||||
Редкие/служебные объекты. Каждый — минимальный набор свойств.
|
|
||||||
|
|
||||||
## FunctionalOption (Функциональная опция)
|
|
||||||
|
|
||||||
| Ключ | Умолчание | Значения |
|
|
||||||
|------|-----------|----------|
|
|
||||||
| `location` | пусто | где хранится значение: `Constant.X` / `InformationRegister.X.Resource.Y` / `<Тип>.X.Attribute.Y` |
|
|
||||||
| `content` | `[]` | реквизиты/измерения/ресурсы, зависящие от опции (полные пути к объектам) |
|
|
||||||
| `privilegedGetMode` | `true` | bool |
|
|
||||||
| `comment` | пусто | строка |
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "type": "FunctionalOption", "name": "ВестиУчетПоСкладам", "location": "Constant.ВестиУчетПоСкладам",
|
|
||||||
"content": ["Document.РеализацияТоваров.TabularSection.Товары.Attribute.Склад"] }
|
|
||||||
```
|
|
||||||
|
|
||||||
## FilterCriterion (Критерий отбора)
|
|
||||||
|
|
||||||
| Ключ | Умолчание | Значения |
|
|
||||||
|------|-----------|----------|
|
|
||||||
| `valueType` | — | тип значения отбора (составной через `+`) |
|
|
||||||
| `content` | `[]` | реквизиты, по которым идёт отбор (пути к объектам) |
|
|
||||||
| `useStandardCommands` | `true` | bool |
|
|
||||||
| `defaultForm` / `auxiliaryForm` | пусто | формы |
|
|
||||||
| `comment` | пусто | строка |
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "type": "FilterCriterion", "name": "ДокументыПоКонтрагенту", "valueType": "CatalogRef.Контрагенты",
|
|
||||||
"content": ["Document.Реализация.Attribute.Контрагент"] }
|
|
||||||
```
|
|
||||||
|
|
||||||
## SettingsStorage (Хранилище настроек)
|
|
||||||
|
|
||||||
| Ключ | Умолчание | Значения |
|
|
||||||
|------|-----------|----------|
|
|
||||||
| `defaultSaveForm` / `defaultLoadForm` | пусто | формы сохранения / загрузки |
|
|
||||||
| `auxiliarySaveForm` / `auxiliaryLoadForm` | пусто | вспомогательные формы |
|
|
||||||
| `comment` | пусто | строка |
|
|
||||||
|
|
||||||
## CommonForm (Общая форма)
|
|
||||||
|
|
||||||
Создаёт метаданные + заготовку формы. Содержимое формы наполняется `/form-compile` или `/form-edit`.
|
|
||||||
|
|
||||||
| Ключ | Умолчание | Значения |
|
|
||||||
|------|-----------|----------|
|
|
||||||
| `formType` | `Managed` | тип формы |
|
|
||||||
| `usePurposes` | `[PlatformApplication, MobilePlatformApplication]` | назначение (массив) |
|
|
||||||
| `useStandardCommands` | `false` | bool |
|
|
||||||
| `includeHelpInContents` | `false` | bool |
|
|
||||||
| `comment` | пусто | строка |
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "type": "CommonForm", "name": "НастройкиОбмена", "usePurposes": ["PlatformApplication"] }
|
|
||||||
```
|
|
||||||
|
|
||||||
## CommonPicture / CommonTemplate (Общие картинки и макеты)
|
|
||||||
|
|
||||||
Только метаданные + регистрация; содержимое (`Ext/Picture*`, `Ext/Template.*`) импортируется отдельно
|
|
||||||
(для табличного макета — `/mxl-compile`).
|
|
||||||
|
|
||||||
- **CommonPicture** — `availabilityForChoice` / `availabilityForAppearance` (bool, по умолчанию `false`).
|
|
||||||
- **CommonTemplate** — `templateType` (`SpreadsheetDocument` по умолчанию / `TextDocument` / `HTMLDocument` /
|
|
||||||
`BinaryData` / `AddIn` / `DataCompositionSchema` / `DataCompositionAppearanceTemplate` / `GraphicalSchema`).
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "type": "CommonTemplate", "name": "ПечатьЗаказа", "templateType": "SpreadsheetDocument" }
|
|
||||||
```
|
|
||||||
|
|
||||||
## Служебные типы
|
|
||||||
|
|
||||||
- **SessionParameter** (параметр сеанса) — `valueType` (тип значения, составной через `+`).
|
|
||||||
- **FunctionalOptionsParameter** (параметр функциональной опции) — `use` (массив измерений/реквизитов).
|
|
||||||
- **WSReference** (WS-ссылка) — `locationURL` (URL WSDL).
|
|
||||||
- **CommandGroup** (группа команд) — `category` (по умолч. `NavigationPanel`) — где размещается группа:
|
|
||||||
`NavigationPanel` / `ActionsPanel` (командный интерфейс раздела) или `FormCommandBar` / `FormNavigationPanel`
|
|
||||||
(командный интерфейс формы); `representation` (`Auto`), `tooltip` (ML), `picture`. Команды объекта ссылаются на
|
|
||||||
группу через `group: "CommandGroup.<Имя>"` (см. `blocks.md`).
|
|
||||||
- **CommonCommand** (общая команда) — `group`, `representation`, `tooltip`, `picture`, `shortcut`,
|
|
||||||
`commandParameterType`, `parameterUseMode` (`Single`/`Multiple`), `modifiesData`, `includeHelpInContents`.
|
|
||||||
Создаёт `Ext/CommandModule.bsl`.
|
|
||||||
- **CommonAttribute** (общий реквизит) — `valueType` (по умолчанию `String(0)`) + свойства реквизита
|
|
||||||
(`attributes.md`) + `content` (объекты, куда входит реквизит) + свойства разделения данных
|
|
||||||
(`dataSeparation`, `separatedDataUse`, `usersSeparation`, … — по умолчанию `DontUse`/`Independently`).
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "type": "CommonAttribute", "name": "Организация", "valueType": "CatalogRef.Организации",
|
|
||||||
"autoUse": "Use", "content": ["Document.РеализацияТоваров", "Document.ПоступлениеТоваров"] }
|
|
||||||
```
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
# BusinessProcess, Task (Бизнес-процессы и Задачи)
|
|
||||||
|
|
||||||
Ссылочные типы. Наследуют слой Catalog (нумерация, формы, `standardAttributes`, `characteristics`, `basedOn`,
|
|
||||||
представления — см. `catalog.md` / `attributes.md` / `blocks.md`). Бизнес-процесс всегда связан с задачей.
|
|
||||||
|
|
||||||
## BusinessProcess (Бизнес-процесс)
|
|
||||||
|
|
||||||
| Ключ | Умолчание | Значения |
|
|
||||||
|------|-----------|----------|
|
|
||||||
| `task` | пусто | ссылка на задачу `Task.X` (обязательна для рабочего БП) |
|
|
||||||
| `numberType` | `String` | `String` / `Number` |
|
|
||||||
| `numberLength` | `11` | длина номера |
|
|
||||||
| `checkUnique` | `true` | bool |
|
|
||||||
| `autonumbering` | `true` | bool |
|
|
||||||
| `createOnInput` | `DontUse` | `Auto` / `Use` / `DontUse` |
|
|
||||||
| `dataLockControlMode` | `Managed` | `Automatic` / `Managed` |
|
|
||||||
| `attributes` / `tabularSections` | `[]` / `{}` | реквизиты / табличные части |
|
|
||||||
|
|
||||||
Создаётся с картой маршрута (`Ext/Flowchart.xml`) и модулем объекта.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "type": "BusinessProcess", "name": "Согласование", "task": "Task.ЗадачаИсполнителя",
|
|
||||||
"attributes": ["Документ: DocumentRef.ЗаявкаНаРасход"] }
|
|
||||||
```
|
|
||||||
|
|
||||||
## Task (Задача)
|
|
||||||
|
|
||||||
| Ключ | Умолчание | Значения |
|
|
||||||
|------|-----------|----------|
|
|
||||||
| `numberType` | `String` | `String` / `Number` |
|
|
||||||
| `numberLength` | `14` | длина номера |
|
|
||||||
| `checkUnique` | `true` | bool |
|
|
||||||
| `autonumbering` | `true` | bool |
|
|
||||||
| `descriptionLength` | `150` | длина наименования |
|
|
||||||
| `addressing` | пусто | ссылка на регистр сведений адресации `InformationRegister.X` |
|
|
||||||
| `mainAddressingAttribute` | пусто | основной реквизит адресации (имя реквизита адресации) |
|
|
||||||
| `currentPerformer` | пусто | реквизит текущего исполнителя |
|
|
||||||
| `createOnInput` | `DontUse` | `Auto` / `Use` / `DontUse` |
|
|
||||||
| `dataLockControlMode` | `Managed` | `Automatic` / `Managed` |
|
|
||||||
| `addressingAttributes` | `[]` | реквизиты адресации (см. ниже) |
|
|
||||||
| `attributes` / `tabularSections` | `[]` / `{}` | реквизиты / табличные части |
|
|
||||||
|
|
||||||
**Реквизит адресации** — shorthand `"Имя: Тип"` или объект `{ name, type, addressingDimension }`
|
|
||||||
(`addressingDimension` — измерение регистра адресации).
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "type": "Task", "name": "ЗадачаИсполнителя",
|
|
||||||
"addressingAttributes": ["Исполнитель: CatalogRef.Пользователи", "Роль: CatalogRef.Роли"] }
|
|
||||||
```
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
# Регистры: Information, Accumulation, Accounting, Calculation
|
|
||||||
|
|
||||||
**Измерения и ресурсы** задаются как реквизиты (shorthand `"Имя: Тип | флаги"` или объектная форма, см.
|
|
||||||
`attributes.md`). Флаги измерений: `master`, `mainFilter`, `denyIncomplete`, `useInTotals` (регистр накопления).
|
|
||||||
|
|
||||||
```json
|
|
||||||
"dimensions": ["Организация: CatalogRef.Организации | master, mainFilter"],
|
|
||||||
"resources": ["Сумма: Number(15,2)"]
|
|
||||||
```
|
|
||||||
|
|
||||||
## InformationRegister (Регистр сведений)
|
|
||||||
|
|
||||||
| Ключ | Умолчание | Значения |
|
|
||||||
|------|-----------|----------|
|
|
||||||
| `writeMode` | `Independent` | `Independent` / `RecorderSubordinate` |
|
|
||||||
| `periodicity` | `Nonperiodical` | `Nonperiodical` / `Second` / `Day` / `Month` / `Quarter` / `Year` / `RecorderPosition` |
|
|
||||||
| `mainFilterOnPeriod` | `false` | bool (основной отбор по периоду) |
|
|
||||||
| `dataLockControlMode` | `Managed` | `Automatic` / `Managed` |
|
|
||||||
| `dimensions` / `resources` / `attributes` | `[]` | измерения / ресурсы / реквизиты |
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "type": "InformationRegister", "name": "КурсыВалют", "periodicity": "Day",
|
|
||||||
"dimensions": ["Валюта: CatalogRef.Валюты | master, mainFilter, denyIncomplete"],
|
|
||||||
"resources": ["Курс: Number(15,4)", "Кратность: Number(10,0)"] }
|
|
||||||
```
|
|
||||||
|
|
||||||
## AccumulationRegister (Регистр накопления)
|
|
||||||
|
|
||||||
| Ключ | Умолчание | Значения |
|
|
||||||
|------|-----------|----------|
|
|
||||||
| `registerType` | `Balance` | `Balance` (остатки) / `Turnovers` (обороты) |
|
|
||||||
| `enableTotalsSplitting` | `true` | bool (разделение итогов) |
|
|
||||||
| `dataLockControlMode` | `Managed` | `Automatic` / `Managed` |
|
|
||||||
| `dimensions` / `resources` / `attributes` | `[]` | измерения / ресурсы / реквизиты |
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "type": "AccumulationRegister", "name": "ОстаткиТоваров", "registerType": "Balance",
|
|
||||||
"dimensions": ["Номенклатура: CatalogRef.Номенклатура", "Склад: CatalogRef.Склады"],
|
|
||||||
"resources": ["Количество: Number(15,3)"] }
|
|
||||||
```
|
|
||||||
|
|
||||||
## AccountingRegister (Регистр бухгалтерии)
|
|
||||||
|
|
||||||
| Ключ | Умолчание | Значения |
|
|
||||||
|------|-----------|----------|
|
|
||||||
| `chartOfAccounts` | — | **обязательно**: ссылка на план счетов `ChartOfAccounts.X` |
|
|
||||||
| `correspondence` | `false` | bool (корреспонденция) |
|
|
||||||
| `periodAdjustmentLength` | `0` | длина периода корректировки |
|
|
||||||
| `dataLockControlMode` | `Managed` | `Automatic` / `Managed` |
|
|
||||||
| `dimensions` / `resources` / `attributes` | `[]` | измерения / ресурсы / реквизиты |
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "type": "AccountingRegister", "name": "Хозрасчетный",
|
|
||||||
"chartOfAccounts": "ChartOfAccounts.Хозрасчетный",
|
|
||||||
"dimensions": ["Организация: CatalogRef.Организации"], "resources": ["Сумма: Number(15,2)"] }
|
|
||||||
```
|
|
||||||
|
|
||||||
## CalculationRegister (Регистр расчёта)
|
|
||||||
|
|
||||||
| Ключ | Умолчание | Значения |
|
|
||||||
|------|-----------|----------|
|
|
||||||
| `chartOfCalculationTypes` | — | **обязательно**: ссылка на ПВР `ChartOfCalculationTypes.X` |
|
|
||||||
| `periodicity` | `Month` | периодичность |
|
|
||||||
| `actionPeriod` | `false` | bool (период действия) |
|
|
||||||
| `basePeriod` | `false` | bool (базовый период) |
|
|
||||||
| `schedule` | пусто | ссылка на регистр сведений графиков |
|
|
||||||
| `dataLockControlMode` | `Managed` | `Automatic` / `Managed` |
|
|
||||||
| `dimensions` / `resources` / `attributes` | `[]` | измерения / ресурсы / реквизиты |
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "type": "CalculationRegister", "name": "Начисления",
|
|
||||||
"chartOfCalculationTypes": "ChartOfCalculationTypes.Начисления", "periodicity": "Month",
|
|
||||||
"dimensions": ["Сотрудник: CatalogRef.Сотрудники"], "resources": ["Сумма: Number(15,2)"] }
|
|
||||||
```
|
|
||||||
|
|
||||||
> **AccountingRegister** требует план счетов, **CalculationRegister** — план видов расчёта (и оба — документ-регистратор).
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
# Report, DataProcessor (Отчёты и Обработки)
|
|
||||||
|
|
||||||
Почти идентичны по составу: реквизиты, табличные части, формы, макеты, команды. Модуль объекта — `Ext/ObjectModule.bsl`.
|
|
||||||
Реквизиты и ТЧ — `attributes.md`; команды — `blocks.md`.
|
|
||||||
|
|
||||||
Ссылки на формы/схемы/хранилища пишутся **как есть** (имя формы может быть буквально «Форма»).
|
|
||||||
|
|
||||||
## Report (Отчёт)
|
|
||||||
|
|
||||||
| Ключ | Умолчание | Значения |
|
|
||||||
|------|-----------|----------|
|
|
||||||
| `comment` | пусто | строка |
|
|
||||||
| `useStandardCommands` | `true` | bool (доступность через стандартный командный интерфейс) |
|
|
||||||
| `mainDataCompositionSchema` | пусто | основной макет СКД (`Report.X.Template.ОсновнаяСхемаКомпоновкиДанных`) |
|
|
||||||
| `defaultForm` / `auxiliaryForm` | пусто | основная / вспомогательная форма |
|
|
||||||
| `defaultSettingsForm` / `auxiliarySettingsForm` / `defaultVariantForm` | пусто | формы настроек / вариантов |
|
|
||||||
| `variantsStorage` / `settingsStorage` | пусто | хранилища вариантов / настроек (`SettingsStorage.X`) |
|
|
||||||
| `extendedPresentation` / `explanation` | пусто | представление / пояснение (ML) |
|
|
||||||
| `includeHelpInContents` | `false` | bool |
|
|
||||||
| `attributes` / `tabularSections` | `[]` / `{}` | реквизиты / табличные части |
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "type": "Report", "name": "АнализПродаж", "useStandardCommands": false,
|
|
||||||
"mainDataCompositionSchema": "Report.АнализПродаж.Template.ОсновнаяСхемаКомпоновкиДанных",
|
|
||||||
"attributes": ["Период: StandardPeriod"] }
|
|
||||||
```
|
|
||||||
|
|
||||||
## DataProcessor (Обработка)
|
|
||||||
|
|
||||||
| Ключ | Умолчание | Значения |
|
|
||||||
|------|-----------|----------|
|
|
||||||
| `comment` | пусто | строка |
|
|
||||||
| `useStandardCommands` | `true` | bool |
|
|
||||||
| `defaultForm` / `auxiliaryForm` | пусто | основная / вспомогательная форма |
|
|
||||||
| `extendedPresentation` / `explanation` | пусто | представление / пояснение (ML) |
|
|
||||||
| `includeHelpInContents` | `false` | bool |
|
|
||||||
| `attributes` / `tabularSections` | `[]` / `{}` | реквизиты / табличные части |
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "type": "DataProcessor", "name": "ЗагрузкаТаблиц", "useStandardCommands": false,
|
|
||||||
"attributes": [{ "name": "Таблица", "type": "ValueTree" }, { "name": "Произвольные", "type": "" }] }
|
|
||||||
```
|
|
||||||
|
|
||||||
> Реквизиты отчётов/обработок допускают платформенные типы-коллекции: `ValueTable`, `ValueTree`, `ValueList`,
|
|
||||||
> `StandardPeriod`, `SpreadsheetDocument` и др., а также `"type": ""` — реквизит без типа.
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
# Enum, Constant, DefinedType
|
|
||||||
|
|
||||||
## Enum (Перечисление)
|
|
||||||
|
|
||||||
| Ключ | Умолчание | Значения |
|
|
||||||
|------|-----------|----------|
|
|
||||||
| `values` | `[]` | значения перечисления (массив имён или объектов) |
|
|
||||||
|
|
||||||
Значение — строка `"ИмяЗначения"` или объект `{ name, synonym }`.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "type": "Enum", "name": "Статусы", "values": ["Новый", "ВРаботе", "Закрыт"] }
|
|
||||||
```
|
|
||||||
|
|
||||||
## Constant (Константа)
|
|
||||||
|
|
||||||
| Ключ | Умолчание | Значения |
|
|
||||||
|------|-----------|----------|
|
|
||||||
| `valueType` | `String` | тип значения (shorthand типа) |
|
|
||||||
| `dataLockControlMode` | `Managed` | `Automatic` / `Managed` |
|
|
||||||
|
|
||||||
`valueType` принимает shorthand: `"String(100)"`, `"Number(15,2)"`, `"Boolean"`, `"CatalogRef.Валюты"`,
|
|
||||||
составной через `+`.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "type": "Constant", "name": "ОсновнаяВалюта", "valueType": "CatalogRef.Валюты" }
|
|
||||||
```
|
|
||||||
|
|
||||||
## DefinedType (Определяемый тип)
|
|
||||||
|
|
||||||
| Ключ | Умолчание | Значения |
|
|
||||||
|------|-----------|----------|
|
|
||||||
| `valueTypes` | `[]` | состав типа (массив shorthand-типов) |
|
|
||||||
| `valueType` | — | то же одной строкой (`"A + B"`) или строкой одного типа |
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "type": "DefinedType", "name": "ДенежныеСредства",
|
|
||||||
"valueTypes": ["CatalogRef.БанковскиеСчета", "CatalogRef.Кассы"] }
|
|
||||||
{ "type": "DefinedType", "name": "ФлагАктивности", "valueType": "Boolean" }
|
|
||||||
```
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
# HTTPService, WebService (Веб-сервисы)
|
|
||||||
|
|
||||||
Модуль обоих — `Ext/Module.bsl`, в нём реализуются обработчики.
|
|
||||||
|
|
||||||
## HTTPService (HTTP-сервис)
|
|
||||||
|
|
||||||
| Ключ | Умолчание | Значения |
|
|
||||||
|------|-----------|----------|
|
|
||||||
| `rootURL` | `= name` (в нижнем регистре) | корневой URL |
|
|
||||||
| `reuseSessions` | `DontUse` | `DontUse` / `AutoUse` |
|
|
||||||
| `sessionMaxAge` | `20` | время жизни сессии, сек |
|
|
||||||
| `urlTemplates` | `{}` | шаблоны URL (см. ниже) |
|
|
||||||
|
|
||||||
`urlTemplates` — объект `{ "ИмяШаблона": def }`, где `def`:
|
|
||||||
- строка — URL-путь без методов: `"/health"`;
|
|
||||||
- объект: `template` (путь с параметрами `{id}`, по умолчанию `/имяшаблона`), `methods` — `{ "ИмяМетода": "HTTPMethod" }`.
|
|
||||||
|
|
||||||
HTTP-методы: `GET`, `POST`, `PUT`, `DELETE`, `PATCH`, `HEAD`, `OPTIONS`, `CONNECT`, `TRACE`, `MERGE`.
|
|
||||||
Обработчик метода в модуле именуется `{ИмяШаблона}{ИмяМетода}`.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "type": "HTTPService", "name": "API", "rootURL": "api",
|
|
||||||
"urlTemplates": {
|
|
||||||
"Users": { "template": "/v1/users/{id}", "methods": { "Get": "GET", "Create": "POST", "Delete": "DELETE" } },
|
|
||||||
"Health": "/health"
|
|
||||||
} }
|
|
||||||
```
|
|
||||||
|
|
||||||
## WebService (Веб-сервис, SOAP)
|
|
||||||
|
|
||||||
| Ключ | Умолчание | Значения |
|
|
||||||
|------|-----------|----------|
|
|
||||||
| `namespace` | пусто | URI пространства имён WSDL |
|
|
||||||
| `xdtoPackages` | пусто | XDTO-пакеты |
|
|
||||||
| `reuseSessions` | `DontUse` | `DontUse` / `AutoUse` |
|
|
||||||
| `sessionMaxAge` | `20` | время жизни сессии, сек |
|
|
||||||
| `operations` | `{}` | операции (см. ниже) |
|
|
||||||
|
|
||||||
`operations` — объект `{ "ИмяОперации": def }`, где `def`:
|
|
||||||
- строка — XDTO-тип возврата без параметров: `"xs:string"`;
|
|
||||||
- объект: `returnType` (по умолчанию `xs:string`), `nillable` (bool), `transactioned` (bool),
|
|
||||||
`handler` (имя процедуры, по умолчанию = имя операции), `parameters`.
|
|
||||||
|
|
||||||
`parameters` — объект `{ "ИмяПараметра": def }`, где `def`:
|
|
||||||
- строка — XDTO-тип (`direction` = `In`);
|
|
||||||
- объект: `type` (по умолчанию `xs:string`), `nillable` (bool, по умолчанию `true`), `direction` (`In` / `Out` / `InOut`).
|
|
||||||
|
|
||||||
XDTO-типы: `xs:string`, `xs:boolean`, `xs:int`, `xs:long`, `xs:decimal`, `xs:dateTime`, `xs:base64Binary`.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "type": "WebService", "name": "DataExchange", "namespace": "http://www.1c.ru/DataExchange",
|
|
||||||
"operations": {
|
|
||||||
"TestConnection": { "returnType": "xs:boolean", "handler": "ПроверкаПодключения",
|
|
||||||
"parameters": { "ErrorMessage": { "type": "xs:string", "direction": "Out" } } },
|
|
||||||
"GetVersion": "xs:string"
|
|
||||||
} }
|
|
||||||
```
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,52 +0,0 @@
|
|||||||
---
|
|
||||||
name: meta-decompile
|
|
||||||
description: Декомпиляция объекта метаданных 1С в JSON-заготовку формата meta-compile. Используй когда нужно получить черновик DSL-описания нового объекта по образцу другого. Не сохраняет UUID/модули/формы.
|
|
||||||
argument-hint: <ObjectPath> [-OutputPath <out.json>]
|
|
||||||
disable-model-invocation: true
|
|
||||||
allowed-tools:
|
|
||||||
- Bash
|
|
||||||
- Read
|
|
||||||
- Write
|
|
||||||
- Glob
|
|
||||||
---
|
|
||||||
|
|
||||||
# /meta-decompile — DSL-заготовка из XML объекта метаданных
|
|
||||||
|
|
||||||
Читает XML объекта метаданных (`Catalogs/Имя.xml` и т.п.) и эмитит компактный JSON в формате `/meta-compile`. Назначение — **взять существующий объект образцом и собрать по нему НОВЫЙ**: декомпилировать → поправить → скомпилировать под другим именем.
|
|
||||||
|
|
||||||
## ⚠️ Главное: это НЕ обратимая выгрузка
|
|
||||||
|
|
||||||
Компиляция черновика создаёт **новый объект с новой идентичностью**, а не копию исходного. В JSON **не** попадают: UUID (идентичность самого объекта и всех дочерних), тела модулей, формы, макеты, права. Захватываются только структура и свойства.
|
|
||||||
|
|
||||||
Отсюда правило: **никогда не компилируй черновик поверх объекта-источника и не выдавай его за «реимпорт»** — у пересобранного объекта другие UUID, поэтому все ссылки на исходный объект (из кода, других объектов, состава подсистем, предопределённых данных) сломаются, а код модулей и формы пропадут.
|
|
||||||
|
|
||||||
## Когда использовать
|
|
||||||
|
|
||||||
**Собрать новый объект по образцу существующего** — получить DSL-заготовку рабочего объекта, переименовать и адаптировать состав, скомпилировать в новый. Быстрее, чем писать DSL с нуля для богатого объекта.
|
|
||||||
|
|
||||||
## Когда **не** использовать
|
|
||||||
|
|
||||||
- **Точечная правка существующего объекта** (добавить реквизит, ТЧ, свойство) → `/meta-edit`. Цикл decompile→compile тут вреден: даёт объект с новой идентичностью и теряет модули/формы.
|
|
||||||
- **Сохранить / восстановить / перенести тот же объект** (бэкап, миграция между конфигурациями с сохранением ссылок) → штатная выгрузка 1С (`/db-dump-xml` ↔ `/db-load-xml`, CF), а не decompile.
|
|
||||||
- **Просто понять структуру** объекта (реквизиты, ТЧ, типы) без пересборки → `/meta-info` (дешевле, не плодит файл).
|
|
||||||
|
|
||||||
## Параметры
|
|
||||||
|
|
||||||
| Параметр | Описание |
|
|
||||||
|----------|----------|
|
|
||||||
| `ObjectPath` | Путь к XML объекта (`Catalogs/Имя.xml`), обязательный |
|
|
||||||
| `OutputPath` | Путь к выходному JSON. Если не задан — JSON в stdout |
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
python ".augment/skills/meta-decompile/scripts/meta-decompile.py" -ObjectPath "<Объект.xml>" -OutputPath "<out.json>"
|
|
||||||
```
|
|
||||||
|
|
||||||
Неподдерживаемый тип объекта или не-`MetaDataObject` root → ненулевой код выхода и сообщение в stderr.
|
|
||||||
|
|
||||||
## Workflow (сборка нового объекта по образцу)
|
|
||||||
|
|
||||||
1. `/meta-decompile <Образец.xml> -OutputPath draft.json` — получить заготовку.
|
|
||||||
2. В `draft.json` **сменить `name`** на имя нового объекта и адаптировать состав (реквизиты/ТЧ/свойства). Ссылки на *другие* объекты (владельцы, ввод на основании, типы) — по имени, сохраняются как есть.
|
|
||||||
3. `/meta-compile -JsonPath draft.json -OutputDir <ConfigDir>` — собрать (объект получит свежие UUID).
|
|
||||||
4. `/meta-validate` + `/meta-info` — проверить.
|
|
||||||
5. Модули, формы, макеты, права — добавить отдельно (в черновик они не попадают).
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,63 +0,0 @@
|
|||||||
# Свойства объекта и свойства-списки
|
|
||||||
|
|
||||||
Справочник операций для обычных свойств объекта и свойств-списков (Owners, RegisterRecords, BasedOn, InputByString).
|
|
||||||
|
|
||||||
## modify-property
|
|
||||||
|
|
||||||
Изменение свойств объекта по имени свойства 1С (PascalCase, как в конфигураторе). Формат: `Ключ=Значение`
|
|
||||||
(batch через `;;`):
|
|
||||||
```powershell
|
|
||||||
-Operation modify-property -Value "CodeLength=11 ;; DescriptionLength=150"
|
|
||||||
-Operation modify-property -Value "Hierarchical=true"
|
|
||||||
```
|
|
||||||
|
|
||||||
Свойство можно задать, даже если оно ещё не выставлено у объекта (например `FullTextSearch`, `DataHistory`).
|
|
||||||
Опечатка в имени свойства → ошибка (правка не теряется молча). Допустимы имена свойств соответствующего типа объекта.
|
|
||||||
|
|
||||||
## Свойства-списки
|
|
||||||
|
|
||||||
Свойства, значение которых — список ссылок. Управляются через inline `add-*` / `remove-*` / `set-*` и через JSON `modify.properties`.
|
|
||||||
|
|
||||||
| Свойство | Объекты | Inline-значение |
|
|
||||||
|----------|---------|-----------------|
|
|
||||||
| Owners | Catalog, ChartOfCharacteristicTypes | `Catalog.XXX` |
|
|
||||||
| RegisterRecords | Document | `AccumulationRegister.XXX` |
|
|
||||||
| BasedOn | Document, Catalog, BP, Task | `Document.XXX` |
|
|
||||||
| InputByString | Catalog, ChartOf*, Task | `StandardAttribute.Description` |
|
|
||||||
| DataLockFields | Catalog, Document, регистры и др. | `Организация` (короткое имя реквизита → полный путь) |
|
|
||||||
| RegisteredDocuments | DocumentJournal | `Document.XXX` |
|
|
||||||
|
|
||||||
### add-owner / add-registerRecord / add-basedOn / add-registeredDocument
|
|
||||||
|
|
||||||
Полное имя метаданных `MetaType.Name`:
|
|
||||||
```powershell
|
|
||||||
-Operation add-owner -Value "Catalog.Контрагенты ;; Catalog.Организации"
|
|
||||||
-Operation add-registerRecord -Value "AccumulationRegister.ОстаткиТоваров"
|
|
||||||
-Operation add-basedOn -Value "Document.ЗаказКлиента"
|
|
||||||
-Operation add-registeredDocument -Value "Document.РасходныйОрдер"
|
|
||||||
```
|
|
||||||
|
|
||||||
### add-inputByString / add-dataLockField
|
|
||||||
|
|
||||||
Пути полей (короткое имя реквизита разворачивается в полный путь автоматически):
|
|
||||||
```powershell
|
|
||||||
-Operation add-inputByString -Value "StandardAttribute.Description ;; StandardAttribute.Code"
|
|
||||||
-Operation add-dataLockField -Value "Организация ;; Контрагент"
|
|
||||||
```
|
|
||||||
|
|
||||||
### remove-owner / remove-registerRecord / remove-basedOn / remove-inputByString / remove-dataLockField / remove-registeredDocument
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
-Operation remove-owner -Value "Catalog.Контрагенты"
|
|
||||||
-Operation remove-inputByString -Value "Catalog.МойСпр.StandardAttribute.Code"
|
|
||||||
-Operation remove-dataLockField -Value "Организация"
|
|
||||||
```
|
|
||||||
|
|
||||||
### set-owners / set-registerRecords / set-basedOn / set-inputByString / set-dataLockFields / set-registeredDocuments
|
|
||||||
|
|
||||||
Заменяют **весь список** (в отличие от add/remove):
|
|
||||||
```powershell
|
|
||||||
-Operation set-owners -Value "Catalog.Организации ;; Catalog.Контрагенты"
|
|
||||||
-Operation set-registerRecords -Value "AccumulationRegister.Продажи ;; AccumulationRegister.ОстаткиТоваров"
|
|
||||||
-Operation set-inputByString -Value "StandardAttribute.Description ;; StandardAttribute.Code"
|
|
||||||
```
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,52 +0,0 @@
|
|||||||
---
|
|
||||||
name: skd-decompile
|
|
||||||
description: Декомпиляция схемы компоновки данных 1С (СКД) в JSON-черновик в формате skd-compile. Используй для scaffold нового отчёта по образцу или структурного рефакторинга. Не для точечных правок
|
|
||||||
argument-hint: <TemplatePath> [-OutputPath <out.json>]
|
|
||||||
disable-model-invocation: true
|
|
||||||
allowed-tools:
|
|
||||||
- Bash
|
|
||||||
- Read
|
|
||||||
- Write
|
|
||||||
- Glob
|
|
||||||
---
|
|
||||||
|
|
||||||
# /skd-decompile — JSON-черновик из Template.xml СКД
|
|
||||||
|
|
||||||
Читает Template.xml и эмитит JSON в формате `skd-compile`. **Результат — черновик**, а не обратимое представление: см. раздел «Что получаешь».
|
|
||||||
|
|
||||||
## Когда использовать
|
|
||||||
|
|
||||||
- **Scaffold нового отчёта по образцу** — взять существующий СКД, получить JSON, поправить и скомпилировать в новый.
|
|
||||||
- **Структурный рефакторинг** — переписать вариант, перерисовать шаблон, перебрать набор полей.
|
|
||||||
|
|
||||||
## Когда **не** использовать
|
|
||||||
|
|
||||||
- **Точечные правки готового отчёта** (добавить поле, фильтр, итог, переименовать) → `/skd-edit`. Цикл «декомпиляция → правка JSON → компиляция» переписывает шаблон целиком, может терять непокрытые конструкции и даёт большой diff в исходниках. `/skd-edit` правит адресно, без полной реконструкции.
|
|
||||||
|
|
||||||
## Параметры
|
|
||||||
|
|
||||||
| Параметр | Описание |
|
|
||||||
|----------|----------|
|
|
||||||
| `TemplatePath` | Путь к Template.xml (обязательный) |
|
|
||||||
| `OutputPath` | Путь к выходному JSON. Если не задан — JSON в stdout |
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
python ".augment/skills/skd-decompile/scripts/skd-decompile.py" -TemplatePath "<Template.xml>" -OutputPath "<out.json>"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Что получаешь
|
|
||||||
|
|
||||||
JSON-черновик в формате `/skd-compile` — **не полное обратимое представление СКД**. На вход компилятору такой JSON напрямую может не пойти: в нём встречаются sentinel-узлы (маркер `__unsupported__`).
|
|
||||||
|
|
||||||
- **Готовые узлы** — большая часть СКД (поля, параметры, шаблоны, варианты со structure/filter/order/conditionalAppearance и т.п.) ложится в JSON как обычные узлы DSL.
|
|
||||||
- **Sentinel-узлы** — места, где встретилась конструкция, которую декомпилятор не умеет выразить в DSL. JSON остаётся валидным, но компилятор откажется его собирать, пока sentinel не **заменён ручной реализацией** (явный raw `template`, прописанный appearance и т.п.) **или не удалён**, если в новом отчёте конструкция не нужна. Это намеренный барьер — чтобы непокрытое не уехало в финальный отчёт незамеченным.
|
|
||||||
- **`<basename>.warnings.md`** рядом с `OutputPath` — список всех sentinel-узлов с координатами в исходнике, по нему удобно обходить места под ручную доработку.
|
|
||||||
- **Критичные конструкции** (Picture cells, ХранилищеЗначения, вложенные схемы, не-СКД root) — скрипт падает с ненулевым кодом и сообщением в stderr; такой Template как образец не годится.
|
|
||||||
|
|
||||||
## Workflow
|
|
||||||
|
|
||||||
1. `/skd-decompile <Template.xml> -OutputPath draft.json` — получить черновик.
|
|
||||||
2. Открыть `draft.warnings.md`, посмотреть, что не покрылось.
|
|
||||||
3. Поправить JSON под задачу. Sentinel-узлы — заменить на ручную реализацию (через явный raw `template`, через ручное описание appearance и т.п.) либо удалить, если конструкция в новом отчёте не нужна.
|
|
||||||
4. `/skd-compile -DefinitionFile draft.json -OutputPath new-Template.xml` — собрать обратно.
|
|
||||||
5. `/skd-validate` + `/skd-info` — проверить.
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,41 +0,0 @@
|
|||||||
---
|
|
||||||
name: support-edit
|
|
||||||
description: Переключение состояния поддержки типовой конфигурации 1С. Используй когда нужно включить возможность редактирования конфигурации или конкретного объекта на поддержке («на замке»)
|
|
||||||
argument-hint: -Path <путь> -Set editable|off-support|locked | -Capability on|off
|
|
||||||
allowed-tools:
|
|
||||||
- Bash
|
|
||||||
- Read
|
|
||||||
- Write
|
|
||||||
- Glob
|
|
||||||
---
|
|
||||||
|
|
||||||
# /support-edit — переключение состояния поддержки 1С
|
|
||||||
|
|
||||||
Меняет правила поддержки типовой конфигурации: разрешает правку объекта/конфигурации «на замке», снимает с поддержки, включает/выключает возможность изменения. Это осознанное действие — обычно после отказа `support-guard` (готовую команду под конкретный случай печатает сам отказ).
|
|
||||||
|
|
||||||
Действует только на выгрузку; чтобы применить в информационной базе — загрузить выгрузку (полная загрузка).
|
|
||||||
|
|
||||||
## Команды
|
|
||||||
|
|
||||||
| Что нужно | Команда |
|
|
||||||
|-----------|---------|
|
|
||||||
| Разрешить правку объекта | `-Path <объект> -Set editable` |
|
|
||||||
| Снять объект с поддержки | `-Path <объект> -Set off-support` |
|
|
||||||
| Вернуть объект «на замок» | `-Path <объект> -Set locked` |
|
|
||||||
| Разрешить добавление новых объектов в конфигурацию | `-Path <каталог дампа> -Set editable` |
|
|
||||||
| Включить / выключить возможность изменения всей конфигурации | `-Path <каталог дампа> -Capability on` / `off` |
|
|
||||||
|
|
||||||
`-Path` — тот же путь, который отклонил `support-guard` (объект, форма, макет или каталог дампа).
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
python ".augment/skills/support-edit/scripts/support-edit.py" -Path "Catalogs/Контрагенты.xml" -Set editable
|
|
||||||
```
|
|
||||||
|
|
||||||
## editable или off-support?
|
|
||||||
|
|
||||||
- **editable** — правки разрешены, объект **продолжает получать обновления вендора** (при обновлении возможны конфликты слияния). Бери, когда хочешь дорабатывать и дальше получать обновления.
|
|
||||||
- **off-support** — объект **снят с поддержки**: правки свободны, обновления вендора по нему больше не приходят. Это не удаление объекта. Бери, когда объект уводишь из-под обновлений.
|
|
||||||
|
|
||||||
## Если возможность изменения выключена
|
|
||||||
|
|
||||||
Если вся конфигурация read-only (типовая «из коробки»), пообъектный `-Set` не сработает — навык подскажет сначала выполнить `-Capability on`. Это включает возможность изменения (все объекты при этом остаются на замке), после чего открываешь нужные точечно через `-Set editable`.
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
# support-edit v1.0 — Toggle 1C configuration support state (Ext/ParentConfigurations.bin)
|
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
|
||||||
param(
|
|
||||||
[Parameter(Mandatory=$true)][Alias('Path')][string]$TargetPath,
|
|
||||||
[ValidateSet("editable","off-support","locked")]
|
|
||||||
[string]$Set,
|
|
||||||
[ValidateSet("on","off")]
|
|
||||||
[string]$Capability
|
|
||||||
)
|
|
||||||
|
|
||||||
$ErrorActionPreference = 'Stop'
|
|
||||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
|
||||||
|
|
||||||
if ((-not $Set -and -not $Capability) -or ($Set -and $Capability)) {
|
|
||||||
[Console]::Error.WriteLine("Укажите ровно одно: -Set editable|off-support|locked ЛИБО -Capability on|off")
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- Resolve target uuid + config root + bin (walk-up, same as support-guard) ---
|
|
||||||
function Get-RootUuid([string]$xmlPath) {
|
|
||||||
if (-not (Test-Path $xmlPath)) { return $null }
|
|
||||||
try {
|
|
||||||
[xml]$mx = Get-Content -Path $xmlPath -Encoding UTF8
|
|
||||||
$el = $mx.DocumentElement.FirstChild
|
|
||||||
while ($el -and $el.NodeType -ne 'Element') { $el = $el.NextSibling }
|
|
||||||
if ($el) { $u = $el.GetAttribute("uuid"); if ($u) { return $u } }
|
|
||||||
} catch {}
|
|
||||||
return $null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (-not (Test-Path $TargetPath)) {
|
|
||||||
[Console]::Error.WriteLine("Путь не найден: $TargetPath")
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
$rp = (Resolve-Path $TargetPath).Path
|
|
||||||
$elemUuid = Get-RootUuid $rp
|
|
||||||
$cfgDir = $null; $binPath = $null
|
|
||||||
$d = if (Test-Path $rp -PathType Container) { $rp } else { [System.IO.Path]::GetDirectoryName($rp) }
|
|
||||||
for ($i = 0; $i -lt 12 -and $d; $i++) {
|
|
||||||
if (-not $elemUuid) { $elemUuid = Get-RootUuid "$d.xml" }
|
|
||||||
if (-not $cfgDir) {
|
|
||||||
$cand = Join-Path (Join-Path $d "Ext") "ParentConfigurations.bin"
|
|
||||||
if ((Test-Path $cand) -or (Test-Path (Join-Path $d "Configuration.xml"))) { $cfgDir = $d; $binPath = $cand }
|
|
||||||
}
|
|
||||||
if ($elemUuid -and $cfgDir) { break }
|
|
||||||
$parent = [System.IO.Path]::GetDirectoryName($d)
|
|
||||||
if ($parent -eq $d) { break }
|
|
||||||
$d = $parent
|
|
||||||
}
|
|
||||||
if (-not $elemUuid -and $cfgDir) { $elemUuid = Get-RootUuid (Join-Path $cfgDir "Configuration.xml") }
|
|
||||||
|
|
||||||
if (-not $cfgDir) {
|
|
||||||
[Console]::Error.WriteLine("Не найден корень конфигурации (Configuration.xml) над путём: $rp")
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
if (-not (Test-Path $binPath)) {
|
|
||||||
Write-Host "Конфигурация не на поддержке (Ext/ParentConfigurations.bin отсутствует) — переключать нечего."
|
|
||||||
exit 0
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- Read bin (UTF-8 text with BOM) ---
|
|
||||||
$bytes = [System.IO.File]::ReadAllBytes($binPath)
|
|
||||||
if ($bytes.Length -le 32) {
|
|
||||||
Write-Host "Поддержка снята полностью (пустой ParentConfigurations.bin) — переключать нечего."
|
|
||||||
exit 0
|
|
||||||
}
|
|
||||||
$start = 0
|
|
||||||
if ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) { $start = 3 }
|
|
||||||
$text = [System.Text.Encoding]::UTF8.GetString($bytes, $start, $bytes.Length - $start)
|
|
||||||
$hm = [regex]::Match($text, '^\{6,(\d+),(\d+),')
|
|
||||||
if (-not $hm.Success) {
|
|
||||||
[Console]::Error.WriteLine("Неизвестный формат ParentConfigurations.bin")
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
$G = [int]$hm.Groups[1].Value
|
|
||||||
$K = [int]$hm.Groups[2].Value
|
|
||||||
|
|
||||||
function Save-Bin([string]$txt) {
|
|
||||||
[System.IO.File]::WriteAllText($binPath, $txt, (New-Object System.Text.UTF8Encoding($true)))
|
|
||||||
}
|
|
||||||
|
|
||||||
# === Capability (global G) ===
|
|
||||||
if ($Capability) {
|
|
||||||
$target = if ($Capability -eq 'on') { '0' } else { '1' }
|
|
||||||
if ($G -eq [int]$target) {
|
|
||||||
$word = if ($Capability -eq 'on') { 'включена' } else { 'выключена' }
|
|
||||||
Write-Host "Возможность изменения конфигурации уже $word — изменений нет."
|
|
||||||
exit 0
|
|
||||||
}
|
|
||||||
# G + X (per block) + bulk f1
|
|
||||||
$text = [regex]::Replace($text, '^(\{6,)\d+(,)', "`${1}$target`$2")
|
|
||||||
$text = [regex]::Replace($text, '([0-9a-f-]{36}),\d+,([0-9a-f-]{36})', "`$1,$target,`$2")
|
|
||||||
$text = [regex]::Replace($text, '[0-2],0,([0-9a-f-]{36})', "$target,0,`$1")
|
|
||||||
Save-Bin $text
|
|
||||||
if ($Capability -eq 'on') {
|
|
||||||
Write-Host "Возможность изменения конфигурации ВКЛЮЧЕНА. Все объекты поставщика — на замке."
|
|
||||||
Write-Host "Включайте редактирование точечно: support-edit -Path <объект> -Set editable"
|
|
||||||
} else {
|
|
||||||
Write-Host "Возможность изменения конфигурации ВЫКЛЮЧЕНА. Вся конфигурация стала read-only; пообъектные правила сброшены."
|
|
||||||
}
|
|
||||||
exit 0
|
|
||||||
}
|
|
||||||
|
|
||||||
# === Per-object -Set ===
|
|
||||||
if ($G -eq 1) {
|
|
||||||
[Console]::Error.WriteLine("Возможность изменения конфигурации выключена — пообъектное переключение недоступно.`n Сначала: support-edit -Path $TargetPath -Capability on")
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
if (-not $elemUuid) {
|
|
||||||
[Console]::Error.WriteLine("Не удалось определить объект по пути: $rp")
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
$u = [regex]::Escape($elemUuid.ToLower())
|
|
||||||
$matches = [regex]::Matches($text, "([0-2]),0,$u")
|
|
||||||
if ($matches.Count -eq 0) {
|
|
||||||
Write-Host "Объект (uuid $elemUuid) не на поддержке (своё добавление или не найден в bin) — переключать нечего."
|
|
||||||
exit 0
|
|
||||||
}
|
|
||||||
$newF1 = switch ($Set) { 'editable' { '1' } 'off-support' { '2' } 'locked' { '0' } }
|
|
||||||
# Replacement string has no group refs — uuid is fixed, f1 is rewritten.
|
|
||||||
$text = [regex]::Replace($text, "([0-2]),0,$u", "$newF1,0,$($elemUuid.ToLower())")
|
|
||||||
Save-Bin $text
|
|
||||||
$state = switch ($Set) {
|
|
||||||
'editable' { "редактируется с сохранением поддержки (объект продолжит получать обновления вендора — возможны конфликты при обновлении)" }
|
|
||||||
'off-support' { "снят с поддержки (обновления вендора по этому объекту прекращаются)" }
|
|
||||||
'locked' { "на замке (правка запрещена)" }
|
|
||||||
}
|
|
||||||
Write-Host "Объект uuid $elemUuid → $state."
|
|
||||||
Write-Host "Записей в bin изменено: $($matches.Count). Цель: $rp"
|
|
||||||
exit 0
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# support-edit v1.0 — Toggle 1C configuration support state (Ext/ParentConfigurations.bin)
|
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
|
|
||||||
from lxml import etree
|
|
||||||
|
|
||||||
sys.stdout.reconfigure(encoding="utf-8")
|
|
||||||
sys.stderr.reconfigure(encoding="utf-8")
|
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(description="Toggle 1C support state", allow_abbrev=False)
|
|
||||||
parser.add_argument("-Path", "-TargetPath", dest="Path", required=True, help="Путь к объекту/форме/макету или каталогу дампа")
|
|
||||||
parser.add_argument("-Set", choices=["editable", "off-support", "locked"], default=None)
|
|
||||||
parser.add_argument("-Capability", choices=["on", "off"], default=None)
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
if (not args.Set and not args.Capability) or (args.Set and args.Capability):
|
|
||||||
sys.stderr.write("Укажите ровно одно: -Set editable|off-support|locked ЛИБО -Capability on|off\n")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
|
|
||||||
def root_uuid(xml_path):
|
|
||||||
if not os.path.isfile(xml_path):
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
mx = etree.parse(xml_path).getroot()
|
|
||||||
for child in mx:
|
|
||||||
if isinstance(child.tag, str) and child.get("uuid"):
|
|
||||||
return child.get("uuid")
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
target_path = args.Path
|
|
||||||
if not os.path.exists(target_path):
|
|
||||||
sys.stderr.write(f"Путь не найден: {target_path}\n")
|
|
||||||
sys.exit(1)
|
|
||||||
rp = os.path.abspath(target_path)
|
|
||||||
elem_uuid = root_uuid(rp)
|
|
||||||
cfg_dir = None
|
|
||||||
bin_path = None
|
|
||||||
d = rp if os.path.isdir(rp) else os.path.dirname(rp)
|
|
||||||
for _ in range(12):
|
|
||||||
if not d:
|
|
||||||
break
|
|
||||||
if not elem_uuid:
|
|
||||||
elem_uuid = root_uuid(d + ".xml")
|
|
||||||
if not cfg_dir:
|
|
||||||
cand = os.path.join(d, "Ext", "ParentConfigurations.bin")
|
|
||||||
if os.path.exists(cand) or os.path.exists(os.path.join(d, "Configuration.xml")):
|
|
||||||
cfg_dir = d
|
|
||||||
bin_path = cand
|
|
||||||
if elem_uuid and cfg_dir:
|
|
||||||
break
|
|
||||||
parent = os.path.dirname(d)
|
|
||||||
if parent == d:
|
|
||||||
break
|
|
||||||
d = parent
|
|
||||||
if not elem_uuid and cfg_dir:
|
|
||||||
elem_uuid = root_uuid(os.path.join(cfg_dir, "Configuration.xml"))
|
|
||||||
|
|
||||||
if not cfg_dir:
|
|
||||||
sys.stderr.write(f"Не найден корень конфигурации (Configuration.xml) над путём: {rp}\n")
|
|
||||||
sys.exit(1)
|
|
||||||
if not os.path.exists(bin_path):
|
|
||||||
print("Конфигурация не на поддержке (Ext/ParentConfigurations.bin отсутствует) — переключать нечего.")
|
|
||||||
sys.exit(0)
|
|
||||||
|
|
||||||
raw = open(bin_path, "rb").read()
|
|
||||||
if len(raw) <= 32:
|
|
||||||
print("Поддержка снята полностью (пустой ParentConfigurations.bin) — переключать нечего.")
|
|
||||||
sys.exit(0)
|
|
||||||
text = raw[3:].decode("utf-8") if raw[:3] == b"\xef\xbb\xbf" else raw.decode("utf-8")
|
|
||||||
hm = re.match(r"\{6,(\d+),(\d+),", text)
|
|
||||||
if not hm:
|
|
||||||
sys.stderr.write("Неизвестный формат ParentConfigurations.bin\n")
|
|
||||||
sys.exit(1)
|
|
||||||
g = int(hm.group(1))
|
|
||||||
k = int(hm.group(2))
|
|
||||||
|
|
||||||
|
|
||||||
def save_bin(txt):
|
|
||||||
open(bin_path, "wb").write(b"\xef\xbb\xbf" + txt.encode("utf-8"))
|
|
||||||
|
|
||||||
|
|
||||||
# === Capability (global G) ===
|
|
||||||
if args.Capability:
|
|
||||||
target = "0" if args.Capability == "on" else "1"
|
|
||||||
if g == int(target):
|
|
||||||
word = "включена" if args.Capability == "on" else "выключена"
|
|
||||||
print(f"Возможность изменения конфигурации уже {word} — изменений нет.")
|
|
||||||
sys.exit(0)
|
|
||||||
text = re.sub(r"^(\{6,)\d+(,)", r"\g<1>" + target + r"\g<2>", text)
|
|
||||||
text = re.sub(r"([0-9a-f-]{36}),\d+,([0-9a-f-]{36})", r"\1," + target + r",\2", text)
|
|
||||||
text = re.sub(r"[0-2],0,([0-9a-f-]{36})", target + r",0,\1", text)
|
|
||||||
save_bin(text)
|
|
||||||
if args.Capability == "on":
|
|
||||||
print("Возможность изменения конфигурации ВКЛЮЧЕНА. Все объекты поставщика — на замке.")
|
|
||||||
print("Включайте редактирование точечно: support-edit -Path <объект> -Set editable")
|
|
||||||
else:
|
|
||||||
print("Возможность изменения конфигурации ВЫКЛЮЧЕНА. Вся конфигурация стала read-only; пообъектные правила сброшены.")
|
|
||||||
sys.exit(0)
|
|
||||||
|
|
||||||
# === Per-object -Set ===
|
|
||||||
if g == 1:
|
|
||||||
sys.stderr.write(
|
|
||||||
"Возможность изменения конфигурации выключена — пообъектное переключение недоступно.\n"
|
|
||||||
f" Сначала: support-edit -Path {target_path} -Capability on\n"
|
|
||||||
)
|
|
||||||
sys.exit(1)
|
|
||||||
if not elem_uuid:
|
|
||||||
sys.stderr.write(f"Не удалось определить объект по пути: {rp}\n")
|
|
||||||
sys.exit(1)
|
|
||||||
u = re.escape(elem_uuid.lower())
|
|
||||||
n = len(re.findall(r"[0-2],0," + u, text))
|
|
||||||
if n == 0:
|
|
||||||
print(f"Объект (uuid {elem_uuid}) не на поддержке (своё добавление или не найден в bin) — переключать нечего.")
|
|
||||||
sys.exit(0)
|
|
||||||
new_f1 = {"editable": "1", "off-support": "2", "locked": "0"}[args.Set]
|
|
||||||
text = re.sub(r"[0-2],0," + u, new_f1 + ",0," + elem_uuid.lower(), text)
|
|
||||||
save_bin(text)
|
|
||||||
state = {
|
|
||||||
"editable": "редактируется с сохранением поддержки (объект продолжит получать обновления вендора — возможны конфликты при обновлении)",
|
|
||||||
"off-support": "снят с поддержки (обновления вендора по этому объекту прекращаются)",
|
|
||||||
"locked": "на замке (правка запрещена)",
|
|
||||||
}[args.Set]
|
|
||||||
print(f"Объект uuid {elem_uuid} → {state}.")
|
|
||||||
print(f"Записей в bin изменено: {n}. Цель: {rp}")
|
|
||||||
sys.exit(0)
|
|
||||||
@@ -1,424 +0,0 @@
|
|||||||
# Regression suite authoring
|
|
||||||
|
|
||||||
Use this when the user asks to cover a 1C solution with automated regression tests, build out a test suite, or run an existing suite and analyse failures. For ad-hoc single-script automation, stay with the `run`/`exec` modes from SKILL.md instead.
|
|
||||||
|
|
||||||
The runner is the same `run.mjs`. The mode is `test`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
node $RUN test <dir|file>... [flags]
|
|
||||||
```
|
|
||||||
|
|
||||||
Positional args are test paths (files and/or dirs, multiple allowed). URL is NOT positional — it comes from `webtest.config.mjs`; override with `--url=<url>`.
|
|
||||||
|
|
||||||
Tests live next to the project they cover (not inside the skill). Convention: `tests/` at the project root, with `_hooks.mjs` and `webtest.config.mjs` at the suite root. Tests are ES modules with `*.test.mjs` suffix.
|
|
||||||
|
|
||||||
## When to choose `test` over `exec`
|
|
||||||
|
|
||||||
| Goal | Mode |
|
|
||||||
|------|------|
|
|
||||||
| Explore a form, prototype a single step, debug one selector | `exec` (interactive session) |
|
|
||||||
| Reproduce a bug as a failing test before fixing it | `test` |
|
|
||||||
| Cover a feature so future changes are checked automatically | `test` |
|
|
||||||
| Run the project's regression on a new build | `test` |
|
|
||||||
| Generate a screencast walkthrough | `exec` with `startRecording` |
|
|
||||||
|
|
||||||
Don't write a `.test.mjs` for a one-shot user request. Don't drive a regression suite through chained `exec` calls.
|
|
||||||
|
|
||||||
## Before writing tests — recon
|
|
||||||
|
|
||||||
Two layers, in order.
|
|
||||||
|
|
||||||
**1. Static recon — metadata.** Never invent identifiers. For every metadata object the user mentions, run the matching info skill first: `/meta-info` (attributes/tabular sections), `/form-info` (form layout), `/skd-info` (DCS), `/mxl-info` (templates), `/role-info` (rights), `/subsystem-info` (composition / command interface). If the user names objects you can't find — stop and ask.
|
|
||||||
|
|
||||||
**2. Live recon — interactive walkthrough.** For any non-trivial scenario, walk the path live in `exec` mode before transcribing it. Metadata tells you what exists; the live walkthrough tells you what actually happens. Capture from `getFormState()`: exact button names (`'Провести и закрыть'`, not `'Сохранить'`), table section names for multi-grid forms, required fields, places where a real async wait is needed. Then transcribe the working sequence into `*.test.mjs`, wrapping logical chunks in `step('...', async () => { ... })`.
|
|
||||||
|
|
||||||
The mechanics of `exec` / `getFormState` / `fillFields` / `clickElement` are in [SKILL.md](SKILL.md) — read it before recon if you haven't already.
|
|
||||||
|
|
||||||
When live recon is overkill: trivial reads (`navigateSection` + `readTable` + assert non-empty), or scenarios you've already proven once in this session. When it's essential: confirmation dialogs, posting/cancellation flows, reports with custom filters, multi-grid forms, user-customised forms.
|
|
||||||
|
|
||||||
## Suite layout
|
|
||||||
|
|
||||||
**Each application gets its own subfolder under `tests/`.** A single repo may host several independent suites side by side — they must not share `_hooks.mjs` or `webtest.config.mjs`, because each suite restores a different DB, publishes to a different URL, and ships its own test data.
|
|
||||||
|
|
||||||
```
|
|
||||||
tests/
|
|
||||||
<app-name>/ # application regression — one per solution
|
|
||||||
_hooks.mjs
|
|
||||||
webtest.config.mjs
|
|
||||||
_allure/ # optional static Allure config
|
|
||||||
01-login/
|
|
||||||
02-counterparties/
|
|
||||||
...
|
|
||||||
<another-app>/ # second solution, fully isolated
|
|
||||||
```
|
|
||||||
|
|
||||||
Inside the application subfolder, organize by **feature**, not by metadata kind. Numeric prefixes on both folder and file enforce run order — discovery walks recursively and sorts files by full relative path; entries starting with `_` or `.` are skipped (so `_hooks.mjs`, `_allure/` won't be picked up as tests).
|
|
||||||
|
|
||||||
```
|
|
||||||
tests/<app-name>/
|
|
||||||
01-login/
|
|
||||||
01-open-base.test.mjs
|
|
||||||
02-section-navigation.test.mjs
|
|
||||||
02-counterparties/
|
|
||||||
01-create.test.mjs
|
|
||||||
02-edit-phone.test.mjs
|
|
||||||
03-goods-receipt/
|
|
||||||
01-fill.test.mjs
|
|
||||||
02-post.test.mjs
|
|
||||||
05-approval-process/
|
|
||||||
01-end-to-end.test.mjs # multi-user
|
|
||||||
```
|
|
||||||
|
|
||||||
Per-folder `_hooks.mjs` / `webtest.config.mjs` inside the application subfolder are NOT supported — only the application-root copies are loaded.
|
|
||||||
|
|
||||||
## Test file anatomy
|
|
||||||
|
|
||||||
```js
|
|
||||||
export const name = 'Создание контрагента'; // required
|
|
||||||
export const tags = ['catalog', 'create']; // optional, used for filtering + Allure
|
|
||||||
export const timeout = 60000; // optional, default 30000
|
|
||||||
// export const skip = 'pending fix #123'; // optional: true | string
|
|
||||||
// export const only = true; // debug-only — never commit
|
|
||||||
// export const context = 'manager'; // optional, single non-default context
|
|
||||||
// export const contexts = ['clerk', 'manager']; // optional, multi-user test
|
|
||||||
// export const severity = 'critical'; // optional, overrides config severity
|
|
||||||
|
|
||||||
export async function setup(ctx) {
|
|
||||||
// per-test prep — runs before default. Skip if not needed.
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function teardown(ctx) {
|
|
||||||
// per-test cleanup — runs after default, always (even on failure).
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function(ctx) {
|
|
||||||
const { navigateSection, openCommand, clickElement, fillFields,
|
|
||||||
readTable, closeForm, getFormState,
|
|
||||||
assert, step, log } = ctx;
|
|
||||||
|
|
||||||
await step('Открыть список контрагентов', async () => {
|
|
||||||
await navigateSection('Продажи');
|
|
||||||
await openCommand('Контрагенты');
|
|
||||||
});
|
|
||||||
|
|
||||||
await step('Создать нового контрагента', async () => {
|
|
||||||
await clickElement('Создать');
|
|
||||||
await fillFields({ 'Наименование': 'Тест ' + Date.now() });
|
|
||||||
await clickElement('Записать и закрыть');
|
|
||||||
});
|
|
||||||
|
|
||||||
await step('Убедиться, что элемент появился в списке', async () => {
|
|
||||||
const t = await readTable();
|
|
||||||
assert.tableHasRow(t, r => r['Наименование']?.startsWith('Тест '));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step names — in Russian, descriptive.** Step labels surface in the console output, in JSON/JUnit, and as Allure step nodes. Russian-speaking QA reads them. Use a full action phrase (`'Создать нового контрагента'`), not a tag (`'create'`) and not a transliteration. Same applies to `export const name` and `displayName` in `webtest.config.mjs`.
|
|
||||||
|
|
||||||
## `ctx` contract
|
|
||||||
|
|
||||||
The runner injects every `browser.mjs` export into `ctx` (all 1C action functions auto-detect platform errors — see SKILL.md), plus the test utilities below.
|
|
||||||
|
|
||||||
### Test utilities
|
|
||||||
|
|
||||||
```js
|
|
||||||
step(name, fn) // async wrapper. Records start/stop. Nested calls supported.
|
|
||||||
// On throw: marks the step failed, re-throws.
|
|
||||||
// On screenshot='every-step': captures after fn().
|
|
||||||
log(...args) // adds a line to ctx.testInfo's output (goes into JSON / Allure
|
|
||||||
// attachment). Use instead of console.log inside tests.
|
|
||||||
assert.* // see "Assertions" below
|
|
||||||
```
|
|
||||||
|
|
||||||
### `ctx.testInfo` (always set, read-only)
|
|
||||||
|
|
||||||
```js
|
|
||||||
{
|
|
||||||
name, // 'Навигация по разделам' (with params substituted)
|
|
||||||
file, // '01-navigation.test.mjs' (basename)
|
|
||||||
filePath, // relative path inside testDir
|
|
||||||
tags, // ['nav', 'smoke']
|
|
||||||
timeout, // ms
|
|
||||||
attempt, // 1..maxAttempts (1-based)
|
|
||||||
maxAttempts, // 1 + retry
|
|
||||||
param, // { ... } | undefined (only when export const params is set)
|
|
||||||
contexts: { // mirrors config.contexts; includes custom fields like displayName
|
|
||||||
clerk: { url, isolation, displayName, ... },
|
|
||||||
manager: { ... },
|
|
||||||
},
|
|
||||||
primaryContext, // 'clerk' — name of the context active at test entry
|
|
||||||
// (= t.context for single, t.contexts[0] for multi)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### `ctx.testResult` (only in `afterEach`)
|
|
||||||
|
|
||||||
```js
|
|
||||||
{
|
|
||||||
status, // 'passed' | 'failed'
|
|
||||||
duration, // ms
|
|
||||||
attempts, // attempts actually executed
|
|
||||||
error, // { message, step?, screenshot? } | null
|
|
||||||
steps, // array of step results (each: { name, start, stop, status, error?, steps[] })
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Context shape
|
|
||||||
|
|
||||||
- **Single-context (default or `export const context = 'manager'`):** all API on `ctx` top-level — `ctx.clickElement(...)`, `ctx.getFormState()`, etc.
|
|
||||||
- **Multi-context (`export const contexts = ['clerk', 'manager']`):** each name is its own scoped namespace — `ctx.clerk.clickElement(...)`, `ctx.manager.fillFields(...)`. `step`, `assert`, `log`, `testInfo` stay top-level. Scoped methods auto-switch the active page before each call.
|
|
||||||
|
|
||||||
## Assertions
|
|
||||||
|
|
||||||
All on `ctx.assert`. Throw `AssertionError` with `.message`, `.actual`, `.expected`. No dependencies.
|
|
||||||
|
|
||||||
```js
|
|
||||||
// generic
|
|
||||||
assert.ok(value, msg?) // truthy
|
|
||||||
assert.equal(actual, expected, msg?) // ===
|
|
||||||
assert.notEqual(actual, expected, msg?) // !==
|
|
||||||
assert.deepEqual(actual, expected, msg?) // JSON-compare
|
|
||||||
assert.includes(haystack, needle, msg?) // string.includes / array.includes
|
|
||||||
assert.match(string, regex, msg?) // regex.test(string)
|
|
||||||
await assert.throws(asyncFn, msg?) // passes if fn throws (use await)
|
|
||||||
|
|
||||||
// 1C-specific — operate on getFormState() / readTable() output
|
|
||||||
assert.formHasField(state, 'Контрагент', msg?) // state.fields[name] exists
|
|
||||||
assert.formTitle(state, expected, msg?) // state.title includes expected
|
|
||||||
assert.tableHasRow(table, predicate, msg?) // predicate: object (partial match) or fn(row) => bool
|
|
||||||
// object form: { 'Наименование': 'Тест' }
|
|
||||||
// fn form: r => r['Сумма'] > 100
|
|
||||||
assert.tableRowCount(table, expected, msg?) // table.rows.length === expected
|
|
||||||
assert.noErrors(state, msg?) // !state.errors
|
|
||||||
```
|
|
||||||
|
|
||||||
Beyond these, just use plain JS (`throw new Error(...)`) — there's no custom matcher extension API. The 1C-specific helpers are the ones worth preferring over hand-rolled equivalents because their error messages name the actual fields/rows present, which speeds up triage.
|
|
||||||
|
|
||||||
## webtest.config.mjs
|
|
||||||
|
|
||||||
```js
|
|
||||||
export default {
|
|
||||||
// Single-context shorthand:
|
|
||||||
url: 'http://localhost:9191/myapp/ru_RU',
|
|
||||||
|
|
||||||
// OR multi-context:
|
|
||||||
// contexts: {
|
|
||||||
// clerk: { url: 'http://localhost:9191/myapp-clerk/ru_RU', displayName: 'Кладовщик' },
|
|
||||||
// manager: { url: 'http://localhost:9191/myapp-manager/ru_RU', displayName: 'Менеджер' },
|
|
||||||
// },
|
|
||||||
// defaultContext: 'clerk',
|
|
||||||
|
|
||||||
timeout: 30000,
|
|
||||||
retries: 0,
|
|
||||||
screenshot: 'on-failure', // 'every-step' | 'off'
|
|
||||||
record: false,
|
|
||||||
|
|
||||||
// Severity → tags mapping for Allure. Each tag at most one bucket.
|
|
||||||
severity: {
|
|
||||||
critical: ['smoke', 'crud'],
|
|
||||||
minor: ['recording'],
|
|
||||||
},
|
|
||||||
defaultSeverity: 'normal',
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
CLI flags override config. Use latin context IDs + Russian `displayName` for ergonomics — `ctx.testInfo.contexts.clerk.displayName` is friendlier than mixed-case Cyrillic keys.
|
|
||||||
|
|
||||||
## _hooks.mjs
|
|
||||||
|
|
||||||
Two layers. Infra hooks run without a browser; testlevel hooks receive `ctx`.
|
|
||||||
|
|
||||||
```js
|
|
||||||
import { execSync } from 'child_process';
|
|
||||||
|
|
||||||
// Infra — runs once around the whole suite.
|
|
||||||
export async function prepare({ hookArgs, log, config }) {
|
|
||||||
// hookArgs: everything after `--` on the CLI, as a string[]. Parse yourself.
|
|
||||||
const force = hookArgs.includes('--rebuild-stand');
|
|
||||||
const dataArg = hookArgs.find(a => a.startsWith('--data='))?.slice('--data='.length);
|
|
||||||
log('preparing stand, force=', force, 'data=', dataArg);
|
|
||||||
// Idempotent hash-locks on inputs (config sources, EPF spec, DB dump) keep
|
|
||||||
// warm starts to a liveness probe.
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function cleanup({ log, config }) { /* optional */ }
|
|
||||||
|
|
||||||
// Testlevel — runs with browser ctx.
|
|
||||||
export async function beforeAll(ctx) { /* once after first context opens */ }
|
|
||||||
export async function afterAll(ctx) { /* once before final teardown */ }
|
|
||||||
export async function beforeEach(ctx) { /* ctx.testInfo is set */ }
|
|
||||||
export async function afterEach(ctx) { /* ctx.testInfo + ctx.testResult set */ }
|
|
||||||
|
|
||||||
// Per-context — runs whenever a context is created/closed.
|
|
||||||
export async function afterOpenContext(ctx, name, spec) { /* spec = config.contexts[name] */ }
|
|
||||||
export async function beforeCloseContext(ctx, name, spec) { }
|
|
||||||
```
|
|
||||||
|
|
||||||
Built-in state reset (`dismissPendingErrors` + close all forms) runs after `afterEach` automatically. Don't reimplement it in `afterEach`.
|
|
||||||
|
|
||||||
Pass hook args after `--`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
node $RUN test tests/<app-name>/ --bail -- --rebuild-stand --data=demo
|
|
||||||
└─runner─┘ └────── hookArgs ─────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
**Where to put data setup:**
|
|
||||||
- DB restore, publication, EPF build → `prepare()`. Make it idempotent (hash-locks).
|
|
||||||
- Test-specific seed data → per-test `setup`.
|
|
||||||
- Shared session-wide warmup → `beforeAll`.
|
|
||||||
|
|
||||||
## Ready-to-paste patterns
|
|
||||||
|
|
||||||
A minimal CRUD shape is in *Test file anatomy* above — use it as the rhythm for catalog/document tests, swapping in the right section/command/fields. The patterns below cover what's specific to the regression engine, not the browser API (those live in SKILL.md).
|
|
||||||
|
|
||||||
### DCS report
|
|
||||||
|
|
||||||
```js
|
|
||||||
await openCommand('Остатки товаров');
|
|
||||||
// Reset user settings — 1C persists them between sessions.
|
|
||||||
await clickElement('Ещё');
|
|
||||||
await clickElement('Установить стандартные настройки');
|
|
||||||
|
|
||||||
await selectValue('Номенклатура', 'Товар 02'); // auto-enables the filter checkbox
|
|
||||||
await clickElement('Сформировать');
|
|
||||||
await wait(3);
|
|
||||||
const r = await readSpreadsheet();
|
|
||||||
assert.deepEqual(r.headers, ['Номенклатура', 'Количество', 'Сумма']);
|
|
||||||
assert.ok(r.data.length >= 1);
|
|
||||||
assert.ok(r.totals?.['Сумма']);
|
|
||||||
```
|
|
||||||
|
|
||||||
### Multi-user process
|
|
||||||
|
|
||||||
```js
|
|
||||||
export const contexts = ['clerk', 'manager'];
|
|
||||||
|
|
||||||
export default async function({ clerk, manager, step, assert }) {
|
|
||||||
await step('Кладовщик создаёт накладную', async () => {
|
|
||||||
await clerk.navigateSection('Склад');
|
|
||||||
await clerk.openCommand('Приходные накладные');
|
|
||||||
await clerk.clickElement('Создать');
|
|
||||||
await clerk.fillFields({ 'Контрагент': 'ООО Север' });
|
|
||||||
await clerk.clickElement('Записать');
|
|
||||||
});
|
|
||||||
await step('Менеджер утверждает накладную', async () => {
|
|
||||||
await manager.navigateSection('Согласование');
|
|
||||||
await manager.openCommand('На утверждении');
|
|
||||||
await manager.clickElement('ООО Север', { dblclick: true });
|
|
||||||
await manager.clickElement('Утвердить');
|
|
||||||
});
|
|
||||||
await step('Кладовщик видит новый статус', async () => {
|
|
||||||
const s = await clerk.getFormState();
|
|
||||||
assert.equal(s.fields['Статус']?.value, 'Утверждён');
|
|
||||||
});
|
|
||||||
await step('Освободить сессию кладовщика', async () => {
|
|
||||||
await manager.closeContext('clerk'); // free a 1C license for the next test
|
|
||||||
});
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Close contexts you no longer need (`manager.closeContext('clerk')`) before the next multi-user test starts — frees a 1C web-client license and stops the previous role from holding state.
|
|
||||||
|
|
||||||
### Failing-test repro
|
|
||||||
|
|
||||||
```js
|
|
||||||
export const name = 'Bug #123: накладная без контрагента не должна проводиться';
|
|
||||||
export const tags = ['bug', 'validation'];
|
|
||||||
|
|
||||||
export default async function({ openCommand, clickElement, getFormState, assert, step }) {
|
|
||||||
await openCommand('Приходные накладные');
|
|
||||||
await clickElement('Создать');
|
|
||||||
await clickElement('Провести');
|
|
||||||
const s = await getFormState();
|
|
||||||
assert.ok(s.errorModal || s.fields['Контрагент']?.required,
|
|
||||||
'Должна быть ошибка валидации или поле помечено обязательным');
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Write it red first, hand it to the user, fix the underlying issue, re-run green.
|
|
||||||
|
|
||||||
### Parameterised test
|
|
||||||
|
|
||||||
```js
|
|
||||||
export const name = 'Заполнение поля {type}';
|
|
||||||
export const params = [
|
|
||||||
{ type: 'String', field: 'Наименование', value: 'Тест' },
|
|
||||||
{ type: 'Number', field: 'Цена', value: '100.50' },
|
|
||||||
{ type: 'Date', field: 'ДатаПоступления', value: '01.01.2024' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default async function({ fillFields, getFormState, assert }, { type, field, value }) {
|
|
||||||
await fillFields({ [field]: value });
|
|
||||||
const state = await getFormState();
|
|
||||||
assert.equal(state.fields[field]?.value, String(value));
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Each `params` entry becomes its own test in the report. `{key}` placeholders in `name` get substituted; without placeholders, a `[index]` suffix is added. `ctx.testInfo.param` carries the current row.
|
|
||||||
|
|
||||||
## Running
|
|
||||||
|
|
||||||
```bash
|
|
||||||
node $RUN test tests/<app-name>/ # full app suite
|
|
||||||
node $RUN test tests/<app-name>/03-goods-receipt/ # one feature folder
|
|
||||||
node $RUN test tests/<app-name>/02-counterparties/01-create.test.mjs # one file
|
|
||||||
node $RUN test tests/<app-name>/02-x.test.mjs tests/<app-name>/05-y.test.mjs # several files
|
|
||||||
node $RUN test tests/<app-name>/ --tags=smoke # by tag (intersection)
|
|
||||||
node $RUN test tests/<app-name>/ --grep='накладн' # by name regex
|
|
||||||
node $RUN test tests/<app-name>/ --bail --retry=1 # stop on first fail, allow 1 retry
|
|
||||||
node $RUN test tests/<app-name>/ --report=allure-results --format=allure --report-dir=allure-results
|
|
||||||
node $RUN test tests/<app-name>/ --report=- # machine JSON to stdout, progress to stderr
|
|
||||||
node $RUN test tests/<app-name>/ -- --rebuild-stand # after `--` → hookArgs
|
|
||||||
```
|
|
||||||
|
|
||||||
**Output contract.** `test` behaves like a test runner: by default the human report (with the summary as the last line) goes to **stdout** — read the tail of stdout + exit code. The machine report is opt-in via `--report`: `--report=path` writes it to a file (default JSON; XML for `--format=junit`), `--report=-` writes it to stdout while progress moves to stderr. Allure needs `--format=allure` + a directory (`-` is invalid for allure). For detailed triage use `--report=path` or `--report=-`. **In `--report=-` mode never use `2>&1`** — it merges stderr progress into the stdout JSON. (In the default mode there is no JSON in stdout, so `… | tail` is safe.)
|
|
||||||
|
|
||||||
### Allure static config — `_allure/`
|
|
||||||
|
|
||||||
The runner copies `<testDir>/_allure/` into the report directory before generating Allure output. Drop in `categories.json` (regex-based failure classification — useful for 1C-specific buckets: license pool exhaustion, platform exceptions, runner timeouts, assertion failures), `environment.properties` (optional, often emitted dynamically by `prepare()`), `executor.json` (CI metadata, skip locally). The underscore prefix keeps the directory out of test discovery.
|
|
||||||
|
|
||||||
## Severity guidance
|
|
||||||
|
|
||||||
When the user doesn't dictate, default to:
|
|
||||||
|
|
||||||
| Test kind | Severity |
|
|
||||||
|-----------|----------|
|
|
||||||
| Login + section navigation, basic CRUD on covered entities | `critical` (also tag `smoke`) |
|
|
||||||
| Documents posting, report generation, end-to-end processes | `critical` |
|
|
||||||
| Field-level edge cases, formatting, optional flows | `normal` |
|
|
||||||
| Cosmetic / recording / non-functional | `minor` |
|
|
||||||
| Reserved for show-stopper protections | `blocker` (use sparingly) |
|
|
||||||
|
|
||||||
Don't promote everything to `critical` — it loses signal in the Allure dashboard.
|
|
||||||
|
|
||||||
## Anti-patterns
|
|
||||||
|
|
||||||
- **Sleeps as a substitute for assertions.** `wait(5)` after `openCommand` is fine; `wait(30)` because something flakes is a bug — wait on `getFormState` instead.
|
|
||||||
- **Retry as a substitute for understanding.** "Not found" twice means the data isn't there or the label is wrong. Don't loop.
|
|
||||||
- **Position-based row identification** (`rows[0]`) when the DB has shared seed data. Filter by a unique marker (`Date.now()` suffix) instead.
|
|
||||||
- **Hand-writing reset code in `afterEach`.** The runner already closes forms and dismisses errors after the hook.
|
|
||||||
- **Cross-test state assumptions.** Each test must start from the desktop and seed its own data. Order-of-execution coupling is a regression-suite trap.
|
|
||||||
- **`tags: ['smoke']` on a 90-second test.** Smoke means fast.
|
|
||||||
- **Skipping recon** because "I know what this catalog looks like." The project's customisation almost certainly differs from stock.
|
|
||||||
|
|
||||||
(General browser-API anti-patterns — raw DOM, `clickElement('Закрыть')` instead of `closeForm()` — live in SKILL.md.)
|
|
||||||
|
|
||||||
## After a run — failure triage
|
|
||||||
|
|
||||||
1. Scan the JSON or Allure summary for `failed`.
|
|
||||||
2. For each failure, read `error.message` + `error.step` + screenshot.
|
|
||||||
3. If `error.onecError.stack` is present — it's a 1C exception, look at the platform trace.
|
|
||||||
4. Classify:
|
|
||||||
- **Test bug** — selector wrong, expectation wrong, race with no anchor → fix the test.
|
|
||||||
- **Application bug** — actual misbehaviour reproduced → report to the user with the failing step name and the platform stack.
|
|
||||||
- **Stand flake** — Apache timeout, login form not loading, license shortage → fix the hook idempotency or session-cleanup logic, not the test.
|
|
||||||
5. After fixes, re-run only the affected files before the full suite.
|
|
||||||
|
|
||||||
Report back to the user with the classification, not raw failure dumps.
|
|
||||||
|
|
||||||
## Reference
|
|
||||||
|
|
||||||
- Browser API: [SKILL.md](SKILL.md)
|
|
||||||
- Video and narration: [recording.md](recording.md)
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
// web-test browser v1.18 — engine facade: re-exports the public API from engine/*
|
|
||||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
|
||||||
/**
|
|
||||||
* Public API of the web-test engine. Pure re-export facade — no logic here.
|
|
||||||
* Implementation lives in `./engine/*`. External callers (run.mjs, exec scripts,
|
|
||||||
* tests) import from this file; engine internals import each other directly.
|
|
||||||
*/
|
|
||||||
|
|
||||||
// ── core ──────────────────────────────────────────────────────────────────
|
|
||||||
export {
|
|
||||||
isConnected, getPage, ensureConnected, setPreserveClipboard,
|
|
||||||
} from './engine/core/state.mjs';
|
|
||||||
export {
|
|
||||||
pasteText, saveClipboard, restoreClipboard,
|
|
||||||
} from './engine/core/clipboard.mjs';
|
|
||||||
export { getFormState } from './engine/forms/state.mjs';
|
|
||||||
export { fetchErrorStack } from './engine/core/errors.mjs';
|
|
||||||
export { clickElement } from './engine/core/click.mjs';
|
|
||||||
|
|
||||||
// ── session ───────────────────────────────────────────────────────────────
|
|
||||||
export {
|
|
||||||
connect, disconnect, attach, detach, getSession,
|
|
||||||
createContext, setActiveContext, listContexts, getActiveContext,
|
|
||||||
hasContext, closeContext,
|
|
||||||
} from './engine/core/session.mjs';
|
|
||||||
|
|
||||||
// ── navigation ────────────────────────────────────────────────────────────
|
|
||||||
export {
|
|
||||||
getPageState, getSections, navigateSection, getCommands,
|
|
||||||
openCommand, switchTab, openFile, navigateLink,
|
|
||||||
} from './engine/nav/navigation.mjs';
|
|
||||||
|
|
||||||
// ── forms ─────────────────────────────────────────────────────────────────
|
|
||||||
export { selectValue } from './engine/forms/select-value.mjs';
|
|
||||||
export { fillFields, fillField } from './engine/forms/fill.mjs';
|
|
||||||
export { closeForm } from './engine/forms/close.mjs';
|
|
||||||
|
|
||||||
// ── tables ────────────────────────────────────────────────────────────────
|
|
||||||
export { readTable, deleteTableRow } from './engine/table/grid.mjs';
|
|
||||||
export { readSpreadsheet } from './engine/spreadsheet/spreadsheet.mjs';
|
|
||||||
export { fillTableRow } from './engine/table/row-fill.mjs';
|
|
||||||
export { filterList, unfilterList } from './engine/table/filter.mjs';
|
|
||||||
|
|
||||||
// ── recording / overlays ──────────────────────────────────────────────────
|
|
||||||
export {
|
|
||||||
screenshot, wait, isRecording, startRecording, stopRecording,
|
|
||||||
} from './engine/recording/capture.mjs';
|
|
||||||
export {
|
|
||||||
showCaption, hideCaption, getCaptions,
|
|
||||||
showTitleSlide, hideTitleSlide,
|
|
||||||
showImage, hideImage,
|
|
||||||
} from './engine/recording/captions.mjs';
|
|
||||||
export {
|
|
||||||
highlight, unhighlight, setHighlight, isHighlightMode,
|
|
||||||
} from './engine/recording/highlight.mjs';
|
|
||||||
export { addNarration } from './engine/recording/narration.mjs';
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
// web-test cli/commands/exec v1.0 — send script to running server
|
|
||||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
|
||||||
import http from 'http';
|
|
||||||
import { readFileSync } from 'fs';
|
|
||||||
import { resolve } from 'path';
|
|
||||||
import { out, die, readStdin } from '../util.mjs';
|
|
||||||
import { loadSession } from '../session.mjs';
|
|
||||||
|
|
||||||
export async function cmdExec(fileOrDash, flags = {}) {
|
|
||||||
if (!fileOrDash) die('Usage: node src/run.mjs exec <file|-> [--no-record]');
|
|
||||||
|
|
||||||
const code = fileOrDash === '-'
|
|
||||||
? await readStdin()
|
|
||||||
: readFileSync(resolve(fileOrDash), 'utf-8');
|
|
||||||
|
|
||||||
const sess = loadSession();
|
|
||||||
const headers = {};
|
|
||||||
if (flags.noRecord) headers['x-no-record'] = '1';
|
|
||||||
const timeoutMs = flags.execTimeoutMs ?? 30 * 60 * 1000;
|
|
||||||
const result = await new Promise((resolveP, reject) => {
|
|
||||||
const req = http.request({
|
|
||||||
hostname: '127.0.0.1', port: sess.port, path: '/exec',
|
|
||||||
method: 'POST', timeout: timeoutMs, headers,
|
|
||||||
}, res => {
|
|
||||||
let data = '';
|
|
||||||
res.on('data', chunk => data += chunk);
|
|
||||||
res.on('end', () => { try { resolveP(JSON.parse(data)); } catch { reject(new Error(data)); } });
|
|
||||||
});
|
|
||||||
req.on('error', reject);
|
|
||||||
req.on('timeout', () => { req.destroy(new Error(`Exec timeout (${Math.round(timeoutMs / 60000)} min)`)); });
|
|
||||||
req.write(code);
|
|
||||||
req.end();
|
|
||||||
});
|
|
||||||
out(result);
|
|
||||||
if (!result.ok) process.exit(1);
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
// web-test cli/commands/run v1.0 — autonomous connect → exec → disconnect (no server)
|
|
||||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
|
||||||
import { readFileSync } from 'fs';
|
|
||||||
import { resolve } from 'path';
|
|
||||||
import * as browser from '../../browser.mjs';
|
|
||||||
import { out, die, readStdin } from '../util.mjs';
|
|
||||||
import { executeScript } from '../exec-context.mjs';
|
|
||||||
|
|
||||||
export async function cmdRun(url, fileOrDash) {
|
|
||||||
if (!url || !fileOrDash) die('Usage: node src/run.mjs run <url> <file|->');
|
|
||||||
|
|
||||||
const code = fileOrDash === '-'
|
|
||||||
? await readStdin()
|
|
||||||
: readFileSync(resolve(fileOrDash), 'utf-8');
|
|
||||||
|
|
||||||
await browser.connect(url);
|
|
||||||
const result = await executeScript(code);
|
|
||||||
await browser.disconnect();
|
|
||||||
|
|
||||||
out(result);
|
|
||||||
if (!result.ok) process.exit(1);
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
// web-test cli/commands/shot v1.0 — take screenshot via server
|
|
||||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
|
||||||
import { writeFileSync } from 'fs';
|
|
||||||
import { out, die } from '../util.mjs';
|
|
||||||
import { loadSession } from '../session.mjs';
|
|
||||||
|
|
||||||
export async function cmdShot(file) {
|
|
||||||
const sess = loadSession();
|
|
||||||
const resp = await fetch(`http://127.0.0.1:${sess.port}/shot`);
|
|
||||||
if (!resp.ok) {
|
|
||||||
const err = await resp.text();
|
|
||||||
die(`Screenshot failed: ${err}`);
|
|
||||||
}
|
|
||||||
const buf = Buffer.from(await resp.arrayBuffer());
|
|
||||||
const outFile = file || 'shot.png';
|
|
||||||
writeFileSync(outFile, buf);
|
|
||||||
out({ ok: true, file: outFile });
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
// web-test cli/commands/start v1.0
|
|
||||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
|
||||||
import http from 'http';
|
|
||||||
import { writeFileSync } from 'fs';
|
|
||||||
import * as browser from '../../browser.mjs';
|
|
||||||
import { out, die } from '../util.mjs';
|
|
||||||
import { SESSION_FILE, cleanup } from '../session.mjs';
|
|
||||||
import { handleRequest } from '../server.mjs';
|
|
||||||
|
|
||||||
export async function cmdStart(url) {
|
|
||||||
if (!url) die('Usage: node src/run.mjs start <url>');
|
|
||||||
|
|
||||||
const state = await browser.connect(url);
|
|
||||||
|
|
||||||
const httpServer = http.createServer(handleRequest);
|
|
||||||
httpServer.listen(0, '127.0.0.1', () => {
|
|
||||||
const port = httpServer.address().port;
|
|
||||||
const session = {
|
|
||||||
port,
|
|
||||||
url,
|
|
||||||
pid: process.pid,
|
|
||||||
startedAt: new Date().toISOString()
|
|
||||||
};
|
|
||||||
writeFileSync(SESSION_FILE, JSON.stringify(session, null, 2));
|
|
||||||
out({ ok: true, message: 'Browser ready', port, ...state });
|
|
||||||
});
|
|
||||||
|
|
||||||
process.on('SIGINT', async () => {
|
|
||||||
await browser.disconnect();
|
|
||||||
cleanup();
|
|
||||||
process.exit(0);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
// web-test cli/commands/status v1.0 — check session
|
|
||||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
|
||||||
import { existsSync, readFileSync } from 'fs';
|
|
||||||
import { out } from '../util.mjs';
|
|
||||||
import { SESSION_FILE } from '../session.mjs';
|
|
||||||
|
|
||||||
export function cmdStatus() {
|
|
||||||
if (!existsSync(SESSION_FILE)) {
|
|
||||||
out({ ok: false, message: 'No active session' });
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
const sess = JSON.parse(readFileSync(SESSION_FILE, 'utf-8'));
|
|
||||||
out({ ok: true, ...sess });
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
// web-test cli/commands/stop v1.0 — send stop to server
|
|
||||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
|
||||||
import { out } from '../util.mjs';
|
|
||||||
import { loadSession, cleanup } from '../session.mjs';
|
|
||||||
|
|
||||||
export async function cmdStop() {
|
|
||||||
const sess = loadSession();
|
|
||||||
try {
|
|
||||||
const resp = await fetch(`http://127.0.0.1:${sess.port}/stop`, { method: 'POST' });
|
|
||||||
const result = await resp.json();
|
|
||||||
out(result);
|
|
||||||
} catch {
|
|
||||||
// Server may have already exited before responding
|
|
||||||
out({ ok: true, message: 'Stopped' });
|
|
||||||
}
|
|
||||||
cleanup();
|
|
||||||
}
|
|
||||||
@@ -1,458 +0,0 @@
|
|||||||
// web-test cli/commands/test v1.3 — regression test runner
|
|
||||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
|
||||||
import { existsSync, writeFileSync, mkdirSync } from 'fs';
|
|
||||||
import { resolve, dirname, basename, relative } from 'path';
|
|
||||||
import * as browser from '../../browser.mjs';
|
|
||||||
import { out, die, elapsed, slugify, formatDuration, interpolate, printSteps } from '../util.mjs';
|
|
||||||
import { buildContext, buildScopedContext } from '../exec-context.mjs';
|
|
||||||
import { createAssertions } from '../test-runner/assertions.mjs';
|
|
||||||
import { buildSeverityIndex } from '../test-runner/severity.mjs';
|
|
||||||
import { writeAllure, buildJUnit, syncAllureExtras } from '../test-runner/reporters.mjs';
|
|
||||||
import { discoverTests, resetState } from '../test-runner/discover.mjs';
|
|
||||||
|
|
||||||
export async function cmdTest(rawArgs) {
|
|
||||||
// Split off everything after `--` — those args belong to user-defined hooks
|
|
||||||
// (see spec §6: "all arguments after `--` are forwarded verbatim to _hooks.mjs
|
|
||||||
// via the hookArgs field; the runner does not interpret them").
|
|
||||||
const sepIdx = rawArgs.indexOf('--');
|
|
||||||
const ownArgs = sepIdx >= 0 ? rawArgs.slice(0, sepIdx) : rawArgs;
|
|
||||||
const hookArgs = sepIdx >= 0 ? rawArgs.slice(sepIdx + 1) : [];
|
|
||||||
|
|
||||||
// Parse flags
|
|
||||||
const opts = { bail: false, retry: 0, timeout: 30000, report: null, format: 'json', screenshot: null, reportDir: null, record: false };
|
|
||||||
let tags = null, grep = null, urlFlag = null;
|
|
||||||
const positional = [];
|
|
||||||
for (const a of ownArgs) {
|
|
||||||
if (a.startsWith('--tags=')) tags = a.slice(7).split(',');
|
|
||||||
else if (a.startsWith('--grep=')) grep = new RegExp(a.slice(7), 'i');
|
|
||||||
else if (a.startsWith('--url=')) urlFlag = a.slice(6);
|
|
||||||
else if (a === '--bail') opts.bail = true;
|
|
||||||
else if (a.startsWith('--retry=')) opts.retry = parseInt(a.slice(8)) || 0;
|
|
||||||
else if (a.startsWith('--timeout=')) opts.timeout = parseInt(a.slice(10)) || 30000;
|
|
||||||
else if (a.startsWith('--report=')) opts.report = a.slice(9);
|
|
||||||
else if (a.startsWith('--format=')) opts.format = a.slice(9);
|
|
||||||
else if (a.startsWith('--screenshot=')) opts.screenshot = a.slice(13);
|
|
||||||
else if (a.startsWith('--report-dir=')) opts.reportDir = a.slice(13);
|
|
||||||
else if (a === '--record') opts.record = true;
|
|
||||||
else if (!a.startsWith('--')) positional.push(a);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Positional args are ALWAYS test paths (one or many). URL comes from --url= or config
|
|
||||||
// (see webtest.config.mjs). This matches pytest/jest/playwright; a positional that looks
|
|
||||||
// like a URL is a mistake → fail fast with a hint instead of feeding it to page.goto().
|
|
||||||
const isUrl = (s) => /^https?:\/\//i.test(s);
|
|
||||||
let url = urlFlag || null;
|
|
||||||
const testPaths = [...positional];
|
|
||||||
if (testPaths.length === 0) {
|
|
||||||
die('Usage: node run.mjs test <dir|file>... [--url=URL] [--tags=...] [--grep=...] [--bail] [--retry=N] [--timeout=ms] [--report=path]');
|
|
||||||
}
|
|
||||||
for (const p of testPaths) {
|
|
||||||
if (existsSync(resolve(p))) continue;
|
|
||||||
if (isUrl(p)) {
|
|
||||||
die(`"${p}" looks like a URL — use --url=<url>; positional args are test paths.`);
|
|
||||||
}
|
|
||||||
die(`Test path not found: "${p}". To run a subset use --grep= / --tags=, or pass an existing dir/file.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load config if exists. config (webtest.config.mjs) and hooks (_hooks.mjs) resolve from
|
|
||||||
// the FIRST path's directory — list paths from the same suite folder.
|
|
||||||
const firstPath = resolve(testPaths[0]);
|
|
||||||
const isFile = firstPath.endsWith('.test.mjs');
|
|
||||||
const testDir = isFile ? dirname(firstPath) : firstPath;
|
|
||||||
const configPath = resolve(testDir, 'webtest.config.mjs');
|
|
||||||
let config = {};
|
|
||||||
if (existsSync(configPath)) {
|
|
||||||
const mod = await import('file:///' + configPath.replace(/\\/g, '/'));
|
|
||||||
config = mod.default || {};
|
|
||||||
}
|
|
||||||
const severityIndex = buildSeverityIndex(config);
|
|
||||||
|
|
||||||
// Build context registry: name → url. Supports config.contexts or single config.url / CLI url.
|
|
||||||
const contextSpecs = {};
|
|
||||||
let defaultContextName = 'default';
|
|
||||||
const defaultIsolation = config.isolation || 'tab';
|
|
||||||
if (config.contexts && typeof config.contexts === 'object' && Object.keys(config.contexts).length) {
|
|
||||||
for (const [n, spec] of Object.entries(config.contexts)) {
|
|
||||||
contextSpecs[n] = { ...spec };
|
|
||||||
}
|
|
||||||
defaultContextName = config.defaultContext || Object.keys(config.contexts)[0];
|
|
||||||
if (url) contextSpecs[defaultContextName] = { ...contextSpecs[defaultContextName], url };
|
|
||||||
} else {
|
|
||||||
const fallbackUrl = url || config.url;
|
|
||||||
if (!fallbackUrl) die('No URL provided and no webtest.config.mjs found');
|
|
||||||
contextSpecs.default = { url: fallbackUrl };
|
|
||||||
}
|
|
||||||
if (!contextSpecs[defaultContextName]) {
|
|
||||||
die(`defaultContext "${defaultContextName}" not found in contexts: [${Object.keys(contextSpecs).join(', ')}]`);
|
|
||||||
}
|
|
||||||
if (!url) url = contextSpecs[defaultContextName].url;
|
|
||||||
|
|
||||||
// Apply config defaults (CLI flags override)
|
|
||||||
if (!tags && config.tags) tags = config.tags;
|
|
||||||
opts.timeout = ownArgs.some(a => a.startsWith('--timeout=')) ? opts.timeout : (config.timeout || opts.timeout);
|
|
||||||
opts.retry = ownArgs.some(a => a.startsWith('--retry=')) ? opts.retry : (config.retries || opts.retry);
|
|
||||||
if (config.preserveClipboard === false && !ownArgs.includes('--no-preserve-clipboard')) {
|
|
||||||
browser.setPreserveClipboard(false);
|
|
||||||
}
|
|
||||||
opts.record = opts.record || !!config.record;
|
|
||||||
opts.screenshot = opts.screenshot || config.screenshot || 'on-failure';
|
|
||||||
if (!['on-failure', 'every-step', 'off'].includes(opts.screenshot)) {
|
|
||||||
die(`Invalid --screenshot=${opts.screenshot} (expected on-failure|every-step|off)`);
|
|
||||||
}
|
|
||||||
if (!['json', 'allure', 'junit'].includes(opts.format)) {
|
|
||||||
die(`Invalid --format=${opts.format} (expected json|allure|junit)`);
|
|
||||||
}
|
|
||||||
if (opts.format === 'junit' && !opts.report) {
|
|
||||||
die('--format=junit requires --report=path.xml');
|
|
||||||
}
|
|
||||||
// `--report=-` means "machine report to stdout" (Unix `-` convention).
|
|
||||||
// Only meaningful for streamable formats (json/junit); allure is a directory.
|
|
||||||
const reportToStdout = opts.report === '-';
|
|
||||||
if (reportToStdout && opts.format === 'allure') {
|
|
||||||
die('--report=- (stdout) is not valid with --format=allure: allure emits a directory of files, not a single stream. Use --report-dir=<dir> instead.');
|
|
||||||
}
|
|
||||||
const reportDir = opts.reportDir
|
|
||||||
? resolve(opts.reportDir)
|
|
||||||
: (opts.report && !reportToStdout ? dirname(resolve(opts.report)) : testDir);
|
|
||||||
if (opts.screenshot !== 'off') {
|
|
||||||
try { mkdirSync(reportDir, { recursive: true }); } catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Discover test files
|
|
||||||
const testFiles = discoverTests(testPaths);
|
|
||||||
if (!testFiles.length) die(`No *.test.mjs files found in ${testPaths.join(', ')}`);
|
|
||||||
|
|
||||||
// Import and filter tests
|
|
||||||
const tests = [];
|
|
||||||
let hasOnly = false;
|
|
||||||
for (const file of testFiles) {
|
|
||||||
const mod = await import('file:///' + file.replace(/\\/g, '/'));
|
|
||||||
const base = {
|
|
||||||
file: relative(testDir, file).replace(/\\/g, '/'),
|
|
||||||
name: mod.name || basename(file, '.test.mjs'),
|
|
||||||
tags: mod.tags || [],
|
|
||||||
timeout: mod.timeout || opts.timeout,
|
|
||||||
skip: mod.skip || false,
|
|
||||||
only: mod.only || false,
|
|
||||||
setup: mod.setup,
|
|
||||||
teardown: mod.teardown,
|
|
||||||
fn: mod.default,
|
|
||||||
param: undefined,
|
|
||||||
context: mod.context || null,
|
|
||||||
contexts: Array.isArray(mod.contexts) ? mod.contexts : null,
|
|
||||||
severity: typeof mod.severity === 'string' ? mod.severity : null,
|
|
||||||
};
|
|
||||||
if (base.only) hasOnly = true;
|
|
||||||
if (Array.isArray(mod.params) && mod.params.length) {
|
|
||||||
for (let i = 0; i < mod.params.length; i++) {
|
|
||||||
const p = mod.params[i];
|
|
||||||
const name = base.name.includes('{') ? interpolate(base.name, p) : `${base.name}[${i}]`;
|
|
||||||
tests.push({ ...base, name, param: p });
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
tests.push(base);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter
|
|
||||||
const filtered = tests.filter(t => {
|
|
||||||
if (hasOnly && !t.only) return false;
|
|
||||||
if (tags && !tags.some(tag => t.tags.includes(tag))) return false;
|
|
||||||
if (grep && !grep.test(t.name)) return false;
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Load hooks
|
|
||||||
const hooksPath = resolve(testDir, '_hooks.mjs');
|
|
||||||
let hooks = {};
|
|
||||||
if (existsSync(hooksPath)) {
|
|
||||||
hooks = await import('file:///' + hooksPath.replace(/\\/g, '/'));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Human-readable report goes to stdout (test-runner convention: jest/pytest/playwright).
|
|
||||||
// In `--report -` mode the machine JSON/XML takes over stdout, so progress moves to stderr.
|
|
||||||
const W = reportToStdout ? process.stderr : process.stdout;
|
|
||||||
W.write(`\nweb-test -- ${url}\n`);
|
|
||||||
W.write(`Running ${filtered.length} tests from ${relative(process.cwd(), testDir).replace(/\\/g, '/') || '.'}/\n\n`);
|
|
||||||
|
|
||||||
const startedAt = new Date().toISOString();
|
|
||||||
const results = [];
|
|
||||||
let passCount = 0, failCount = 0, skipCount = 0;
|
|
||||||
|
|
||||||
const hookLog = (...a) => W.write(`[hooks] ${a.map(String).join(' ')}\n`);
|
|
||||||
const hookEnv = { hookArgs, log: hookLog, config };
|
|
||||||
if (hooks.prepare) await hooks.prepare(hookEnv);
|
|
||||||
|
|
||||||
// Lazy context creation
|
|
||||||
async function ensureContext(name) {
|
|
||||||
if (browser.hasContext(name)) return;
|
|
||||||
const spec = contextSpecs[name];
|
|
||||||
if (!spec) throw new Error(`Unknown context "${name}". Defined: [${Object.keys(contextSpecs).join(', ')}]`);
|
|
||||||
await browser.createContext(name, spec.url, { isolation: spec.isolation || defaultIsolation });
|
|
||||||
if (hooks.afterOpenContext && hookCtx) {
|
|
||||||
try { await hooks.afterOpenContext(hookCtx, name, spec); }
|
|
||||||
catch (e) { hookLog(`afterOpenContext("${name}") threw: ${e.message.split('\n')[0]}`); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let hookCtx = null;
|
|
||||||
|
|
||||||
function wrapCloseContextHook(target) {
|
|
||||||
const orig = target.closeContext;
|
|
||||||
if (typeof orig !== 'function') return;
|
|
||||||
target.closeContext = async (name) => {
|
|
||||||
if (hooks.beforeCloseContext) {
|
|
||||||
try { await hooks.beforeCloseContext(target, name, contextSpecs[name]); }
|
|
||||||
catch (e) { hookLog(`beforeCloseContext("${name}") threw: ${e.message.split('\n')[0]}`); }
|
|
||||||
}
|
|
||||||
return await orig(name);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Connect: create default context up front
|
|
||||||
await ensureContext(defaultContextName);
|
|
||||||
|
|
||||||
const ctx = buildContext({ noRecord: false });
|
|
||||||
ctx.assert = createAssertions();
|
|
||||||
ctx.log = (...a) => { /* per-test, overridden below */ };
|
|
||||||
wrapCloseContextHook(ctx);
|
|
||||||
hookCtx = ctx;
|
|
||||||
|
|
||||||
// Default context was created BEFORE hookCtx existed → fire afterOpenContext now.
|
|
||||||
if (hooks.afterOpenContext) {
|
|
||||||
try { await hooks.afterOpenContext(ctx, defaultContextName, contextSpecs[defaultContextName]); }
|
|
||||||
catch (e) { hookLog(`afterOpenContext("${defaultContextName}") threw: ${e.message.split('\n')[0]}`); }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hooks.beforeAll) await hooks.beforeAll(ctx);
|
|
||||||
|
|
||||||
let testIdx = 0;
|
|
||||||
for (const t of filtered) {
|
|
||||||
testIdx++;
|
|
||||||
const declaredContexts = t.contexts && t.contexts.length
|
|
||||||
? t.contexts
|
|
||||||
: [t.context || defaultContextName];
|
|
||||||
|
|
||||||
if (t.skip) {
|
|
||||||
const reason = typeof t.skip === 'string' ? t.skip : '';
|
|
||||||
W.write(` ○ ${t.name}${reason ? ` (skip: ${reason})` : ' (skip)'}\n`);
|
|
||||||
results.push({ name: t.name, file: t.file, tags: t.tags, contexts: declaredContexts, status: 'skipped', duration: 0, attempts: 0, steps: [], output: '', error: null, screenshot: null });
|
|
||||||
skipCount++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const testContextNames = declaredContexts;
|
|
||||||
try {
|
|
||||||
for (const cn of testContextNames) await ensureContext(cn);
|
|
||||||
await browser.setActiveContext(testContextNames[0]);
|
|
||||||
} catch (e) {
|
|
||||||
W.write(` ✗ ${t.name} (context setup failed: ${e.message})\n`);
|
|
||||||
results.push({ name: t.name, file: t.file, tags: t.tags, contexts: declaredContexts, status: 'failed', duration: 0, attempts: 0, steps: [], output: '', error: { message: e.message }, screenshot: null });
|
|
||||||
failCount++;
|
|
||||||
if (opts.bail) break;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let lastError = null;
|
|
||||||
let testResult = null;
|
|
||||||
const maxAttempts = 1 + opts.retry;
|
|
||||||
|
|
||||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
||||||
const output = [];
|
|
||||||
let steps = [];
|
|
||||||
let currentSteps = steps;
|
|
||||||
let stepIdx = 0;
|
|
||||||
const t0 = Date.now();
|
|
||||||
|
|
||||||
ctx.testInfo = {
|
|
||||||
name: t.name,
|
|
||||||
file: basename(t.file),
|
|
||||||
filePath: t.file,
|
|
||||||
tags: t.tags,
|
|
||||||
timeout: t.timeout,
|
|
||||||
attempt,
|
|
||||||
maxAttempts,
|
|
||||||
param: t.param,
|
|
||||||
contexts: Object.fromEntries(testContextNames.map(n => [n, contextSpecs[n]])),
|
|
||||||
primaryContext: testContextNames[0],
|
|
||||||
};
|
|
||||||
ctx.testResult = null;
|
|
||||||
|
|
||||||
let videoFile = null;
|
|
||||||
if (opts.record) {
|
|
||||||
videoFile = resolve(reportDir, `${testIdx}-${slugify(t.name)}.mp4`);
|
|
||||||
try { await browser.startRecording(videoFile, { force: true }); } catch { videoFile = null; }
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.log = (...a) => output.push(a.map(String).join(' '));
|
|
||||||
ctx.step = async (name, fn) => {
|
|
||||||
const s = { name, start: Date.now(), status: 'passed', steps: [] };
|
|
||||||
currentSteps.push(s);
|
|
||||||
const prev = currentSteps;
|
|
||||||
currentSteps = s.steps;
|
|
||||||
stepIdx++;
|
|
||||||
const myIdx = stepIdx;
|
|
||||||
try {
|
|
||||||
await fn();
|
|
||||||
} catch (e) {
|
|
||||||
s.status = 'failed';
|
|
||||||
s.error = e.message;
|
|
||||||
throw e;
|
|
||||||
} finally {
|
|
||||||
s.stop = Date.now();
|
|
||||||
currentSteps = prev;
|
|
||||||
if (opts.screenshot === 'every-step' && s.status === 'passed') {
|
|
||||||
try {
|
|
||||||
const slug = slugify(name);
|
|
||||||
const file = resolve(reportDir, `${testIdx}-${myIdx}-${slug}.png`);
|
|
||||||
const png = await browser.screenshot();
|
|
||||||
writeFileSync(file, png);
|
|
||||||
s.screenshot = file;
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const scopedKeys = [];
|
|
||||||
if (t.contexts && t.contexts.length) {
|
|
||||||
for (const cn of t.contexts) {
|
|
||||||
ctx[cn] = buildScopedContext(cn);
|
|
||||||
wrapCloseContextHook(ctx[cn]);
|
|
||||||
scopedKeys.push(cn);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (hooks.beforeEach) await hooks.beforeEach(ctx);
|
|
||||||
if (t.setup) await t.setup(ctx);
|
|
||||||
|
|
||||||
let timeoutTimer;
|
|
||||||
try {
|
|
||||||
await Promise.race([
|
|
||||||
t.fn(ctx, t.param),
|
|
||||||
new Promise((_, reject) => { timeoutTimer = setTimeout(() => reject(new Error(`Timeout (${t.timeout}ms)`)), t.timeout); }),
|
|
||||||
]);
|
|
||||||
} finally {
|
|
||||||
// Clear the guard timer — otherwise it stays armed in the event loop and,
|
|
||||||
// since the success path never calls process.exit(), node can't exit until
|
|
||||||
// it fires (up to `timeout` ms after the last test finished).
|
|
||||||
clearTimeout(timeoutTimer);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (t.teardown) try { await t.teardown(ctx); } catch {}
|
|
||||||
ctx.testResult = { status: 'passed', duration: elapsed(t0), attempts: attempt, error: null, steps };
|
|
||||||
if (hooks.afterEach) try { await hooks.afterEach(ctx); } catch {}
|
|
||||||
for (const cn of testContextNames) {
|
|
||||||
try { await browser.setActiveContext(cn); await resetState(ctx); } catch {}
|
|
||||||
}
|
|
||||||
for (const k of scopedKeys) delete ctx[k];
|
|
||||||
|
|
||||||
if (videoFile) {
|
|
||||||
try { await browser.stopRecording(); } catch {}
|
|
||||||
}
|
|
||||||
const dur = elapsed(t0);
|
|
||||||
testResult = { name: t.name, file: t.file, tags: t.tags, contexts: testContextNames, severity: t.severity, status: 'passed', duration: dur, attempts: attempt, start: t0, stop: Date.now(), steps, output: output.join('\n'), error: null, screenshot: null, video: videoFile };
|
|
||||||
lastError = null;
|
|
||||||
break;
|
|
||||||
|
|
||||||
} catch (e) {
|
|
||||||
// Screenshot on failure FIRST — before teardown/afterEach/resetState reset the UI.
|
|
||||||
let shotFile = e.onecError?.screenshot;
|
|
||||||
if (!shotFile && opts.screenshot !== 'off') {
|
|
||||||
try {
|
|
||||||
const png = await browser.screenshot();
|
|
||||||
shotFile = resolve(reportDir, `error-${testIdx}-${slugify(t.file.replace(/\.test\.mjs$/, ''))}.png`);
|
|
||||||
writeFileSync(shotFile, png);
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (t.teardown) try { await t.teardown(ctx); } catch {}
|
|
||||||
const errInfo = { message: e.message, step: e.onecError?.step, screenshot: shotFile, onecError: e.onecError };
|
|
||||||
ctx.testResult = { status: 'failed', duration: elapsed(t0), attempts: attempt, error: errInfo, steps };
|
|
||||||
if (hooks.afterEach) try { await hooks.afterEach(ctx); } catch {}
|
|
||||||
for (const cn of testContextNames) {
|
|
||||||
try { await browser.setActiveContext(cn); await resetState(ctx); } catch {}
|
|
||||||
}
|
|
||||||
for (const k of scopedKeys) delete ctx[k];
|
|
||||||
|
|
||||||
if (videoFile) {
|
|
||||||
try { await browser.stopRecording(); } catch {}
|
|
||||||
}
|
|
||||||
lastError = errInfo;
|
|
||||||
const dur = elapsed(t0);
|
|
||||||
testResult = { name: t.name, file: t.file, tags: t.tags, contexts: testContextNames, severity: t.severity, status: 'failed', duration: dur, attempts: attempt, start: t0, stop: Date.now(), steps, output: output.join('\n'), error: errInfo, screenshot: shotFile, video: videoFile };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
results.push(testResult);
|
|
||||||
|
|
||||||
if (testResult.status === 'passed') {
|
|
||||||
passCount++;
|
|
||||||
W.write(` ✓ ${t.name} (${testResult.duration}s)\n`);
|
|
||||||
} else {
|
|
||||||
failCount++;
|
|
||||||
W.write(` ✗ ${t.name} (${testResult.duration}s)\n`);
|
|
||||||
printSteps(W, testResult.steps, ' ');
|
|
||||||
if (lastError?.message) W.write(` ${lastError.message}\n`);
|
|
||||||
if (lastError?.screenshot) W.write(` screenshot: ${lastError.screenshot}\n`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (opts.bail && testResult.status === 'failed') break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hooks.afterAll) try { await hooks.afterAll(ctx); } catch {}
|
|
||||||
|
|
||||||
} finally {
|
|
||||||
// Per-context teardown
|
|
||||||
try {
|
|
||||||
const remaining = browser.listContexts();
|
|
||||||
if (remaining.length > 0) {
|
|
||||||
const survivor = remaining[0];
|
|
||||||
try { await browser.setActiveContext(survivor); } catch {}
|
|
||||||
for (let i = remaining.length - 1; i >= 1; i--) {
|
|
||||||
const name = remaining[i];
|
|
||||||
if (hooks.beforeCloseContext && hookCtx) {
|
|
||||||
try { await hooks.beforeCloseContext(hookCtx, name, contextSpecs[name]); }
|
|
||||||
catch (e) { hookLog(`beforeCloseContext("${name}") threw: ${e.message.split('\n')[0]}`); }
|
|
||||||
}
|
|
||||||
try { await browser.closeContext(name); }
|
|
||||||
catch (e) { hookLog(`closeContext("${name}") failed: ${e.message.split('\n')[0]}`); }
|
|
||||||
}
|
|
||||||
if (hooks.beforeCloseContext && hookCtx) {
|
|
||||||
try { await hooks.beforeCloseContext(hookCtx, survivor, contextSpecs[survivor]); }
|
|
||||||
catch (e) { hookLog(`beforeCloseContext("${survivor}") threw: ${e.message.split('\n')[0]}`); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
hookLog(`final teardown loop failed: ${e.message.split('\n')[0]}`);
|
|
||||||
}
|
|
||||||
try { await browser.disconnect(); } catch {}
|
|
||||||
if (hooks.cleanup) try { await hooks.cleanup(hookEnv); } catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
const finishedAt = new Date().toISOString();
|
|
||||||
const totalDuration = results.reduce((s, r) => s + r.duration, 0);
|
|
||||||
|
|
||||||
W.write(`\n${passCount} passed, ${failCount} failed, ${skipCount} skipped (${formatDuration(totalDuration)})\n\n`);
|
|
||||||
|
|
||||||
const report = {
|
|
||||||
runner: 'web-test', url, startedAt, finishedAt,
|
|
||||||
duration: totalDuration,
|
|
||||||
summary: { total: results.length, passed: passCount, failed: failCount, skipped: skipCount },
|
|
||||||
tests: results,
|
|
||||||
};
|
|
||||||
if (opts.format === 'allure') {
|
|
||||||
writeAllure(results, reportDir, severityIndex);
|
|
||||||
syncAllureExtras(testDir, reportDir);
|
|
||||||
} else if (opts.format === 'junit') {
|
|
||||||
if (reportToStdout) process.stdout.write(buildJUnit(report, testDir) + '\n');
|
|
||||||
else writeFileSync(resolve(opts.report), buildJUnit(report, testDir));
|
|
||||||
} else if (reportToStdout) {
|
|
||||||
out(report);
|
|
||||||
} else if (opts.report) {
|
|
||||||
writeFileSync(resolve(opts.report), JSON.stringify(report, null, 2));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (failCount > 0) process.exit(1);
|
|
||||||
}
|
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
// web-test cli/exec-context v1.0 — buildContext + executeScript для run/exec/test
|
|
||||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
|
||||||
import { readFileSync, writeFileSync } from 'fs';
|
|
||||||
import { resolve, dirname } from 'path';
|
|
||||||
import { fileURLToPath } from 'url';
|
|
||||||
import * as browser from '../browser.mjs';
|
|
||||||
import { elapsed } from './util.mjs';
|
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
||||||
const ERROR_SHOT_PATH = resolve(__dirname, '..', '..', 'error-shot.png');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build a per-context wrapper: same shape as buildContext output, but every call
|
|
||||||
* is prefixed with `setActiveContext(name)` so the test can interleave actions
|
|
||||||
* across contexts (`ctx.a.click(...); ctx.b.click(...)`).
|
|
||||||
*/
|
|
||||||
export function buildScopedContext(name) {
|
|
||||||
const inner = buildContext({ noRecord: false });
|
|
||||||
const scoped = {};
|
|
||||||
for (const [k, v] of Object.entries(inner)) {
|
|
||||||
if (typeof v === 'function') {
|
|
||||||
scoped[k] = async (...args) => {
|
|
||||||
await browser.setActiveContext(name);
|
|
||||||
return v(...args);
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
scoped[k] = v;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return scoped;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildContext({ noRecord = false } = {}) {
|
|
||||||
const ctx = {};
|
|
||||||
for (const [k, v] of Object.entries(browser)) {
|
|
||||||
if (k !== 'default') ctx[k] = v;
|
|
||||||
}
|
|
||||||
ctx.writeFileSync = writeFileSync;
|
|
||||||
ctx.readFileSync = readFileSync;
|
|
||||||
|
|
||||||
// --no-record: stub recording/narration functions to return safe defaults
|
|
||||||
if (noRecord) {
|
|
||||||
const noop = async () => {};
|
|
||||||
ctx.startRecording = noop;
|
|
||||||
ctx.stopRecording = async () => ({ file: null, duration: 0, size: 0 });
|
|
||||||
ctx.addNarration = async () => ({ file: null, duration: 0, size: 0, captions: 0 });
|
|
||||||
for (const fn of ['showCaption', 'hideCaption']) {
|
|
||||||
ctx[fn] = noop;
|
|
||||||
}
|
|
||||||
ctx.isRecording = () => false;
|
|
||||||
ctx.getCaptions = () => [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wrap action functions to auto-detect 1C errors (modal, balloon)
|
|
||||||
// and stop execution immediately with diagnostic info
|
|
||||||
const ACTION_FNS = [
|
|
||||||
'clickElement', 'fillFields', 'fillField', 'selectValue', 'fillTableRow',
|
|
||||||
'deleteTableRow', 'openCommand', 'navigateSection', 'navigateLink', 'openFile',
|
|
||||||
'closeForm', 'filterList', 'unfilterList'
|
|
||||||
];
|
|
||||||
for (const name of ACTION_FNS) {
|
|
||||||
if (typeof ctx[name] !== 'function') continue;
|
|
||||||
const orig = ctx[name];
|
|
||||||
ctx[name] = async (...args) => {
|
|
||||||
const result = await orig(...args);
|
|
||||||
const errors = result?.errors;
|
|
||||||
if (errors?.modal || errors?.balloon) {
|
|
||||||
// Screenshot while the error modal is still visible (before fetchErrorStack closes it)
|
|
||||||
let errorShot;
|
|
||||||
try {
|
|
||||||
const png = await ctx.screenshot();
|
|
||||||
errorShot = ERROR_SHOT_PATH;
|
|
||||||
writeFileSync(errorShot, png);
|
|
||||||
} catch {}
|
|
||||||
// Try to fetch call stack for modal errors before throwing
|
|
||||||
let stack = null;
|
|
||||||
if (errors?.modal && typeof ctx.fetchErrorStack === 'function') {
|
|
||||||
try {
|
|
||||||
stack = await ctx.fetchErrorStack(errors.modal.formNum, errors.modal.hasReport);
|
|
||||||
} catch { /* don't fail if stack fetch fails */ }
|
|
||||||
}
|
|
||||||
const msg = errors.modal?.message || errors.balloon?.message || 'Unknown 1C error';
|
|
||||||
const err = new Error(msg);
|
|
||||||
err.onecError = { step: name, args, errors, formState: result, stack, screenshot: errorShot };
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return ctx;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function executeScript(code, { noRecord } = {}) {
|
|
||||||
const output = [];
|
|
||||||
const origLog = console.log;
|
|
||||||
const origErr = console.error;
|
|
||||||
console.log = (...a) => output.push(a.map(String).join(' '));
|
|
||||||
console.error = (...a) => output.push('[ERR] ' + a.map(String).join(' '));
|
|
||||||
|
|
||||||
const t0 = Date.now();
|
|
||||||
try {
|
|
||||||
const ctx = buildContext({ noRecord });
|
|
||||||
|
|
||||||
// Normalize Windows backslash paths to prevent JS parse errors
|
|
||||||
// (e.g. C:\Users\... → \u triggers "Invalid Unicode escape sequence")
|
|
||||||
code = code.replace(/[A-Za-z]:\\[^\s'"`;\n)}\]]+/g, m => m.replace(/\\/g, '/'));
|
|
||||||
|
|
||||||
const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor;
|
|
||||||
const fn = new AsyncFunction(...Object.keys(ctx), code);
|
|
||||||
await fn(...Object.values(ctx));
|
|
||||||
|
|
||||||
console.log = origLog;
|
|
||||||
console.error = origErr;
|
|
||||||
return { ok: true, output: output.join('\n'), elapsed: elapsed(t0) };
|
|
||||||
} catch (e) {
|
|
||||||
console.log = origLog;
|
|
||||||
console.error = origErr;
|
|
||||||
|
|
||||||
// Auto-stop recording if active (prevents "Already recording" on next exec)
|
|
||||||
if (browser.isRecording()) {
|
|
||||||
try { await browser.stopRecording(); } catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Error screenshot (skip if already taken before fetchErrorStack closed the modal)
|
|
||||||
let shotFile = e.onecError?.screenshot;
|
|
||||||
if (!shotFile) {
|
|
||||||
try {
|
|
||||||
const png = await browser.screenshot();
|
|
||||||
shotFile = ERROR_SHOT_PATH;
|
|
||||||
writeFileSync(shotFile, png);
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = { ok: false, error: e.message, output: output.join('\n'), screenshot: shotFile, elapsed: elapsed(t0) };
|
|
||||||
|
|
||||||
// Enrich with 1C error context if available
|
|
||||||
if (e.onecError) {
|
|
||||||
result.step = e.onecError.step;
|
|
||||||
result.stepArgs = e.onecError.args;
|
|
||||||
result.onecErrors = e.onecError.errors;
|
|
||||||
result.formState = e.onecError.formState;
|
|
||||||
if (e.onecError.stack) result.stack = e.onecError.stack;
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
// web-test cli/server v1.0 — HTTP server для exec/shot/stop/status в процессе start
|
|
||||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
|
||||||
import * as browser from '../browser.mjs';
|
|
||||||
import { json, readBody } from './util.mjs';
|
|
||||||
import { cleanup } from './session.mjs';
|
|
||||||
import { executeScript } from './exec-context.mjs';
|
|
||||||
|
|
||||||
export async function handleRequest(req, res) {
|
|
||||||
try {
|
|
||||||
if (req.method === 'POST' && req.url === '/exec') {
|
|
||||||
const code = await readBody(req);
|
|
||||||
const noRecord = req.headers['x-no-record'] === '1';
|
|
||||||
const result = await executeScript(code, { noRecord });
|
|
||||||
json(res, result);
|
|
||||||
|
|
||||||
} else if (req.method === 'GET' && req.url === '/shot') {
|
|
||||||
const png = await browser.screenshot();
|
|
||||||
res.writeHead(200, { 'Content-Type': 'image/png' });
|
|
||||||
res.end(png);
|
|
||||||
|
|
||||||
} else if (req.method === 'POST' && req.url === '/stop') {
|
|
||||||
json(res, { ok: true, message: 'Stopping' });
|
|
||||||
await browser.disconnect();
|
|
||||||
cleanup();
|
|
||||||
process.exit(0);
|
|
||||||
|
|
||||||
} else if (req.method === 'GET' && req.url === '/status') {
|
|
||||||
json(res, { ok: true, connected: browser.isConnected() });
|
|
||||||
|
|
||||||
} else {
|
|
||||||
res.writeHead(404);
|
|
||||||
res.end('Not found');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
json(res, { ok: false, error: e.message }, 500);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
// web-test cli/session v1.0 — session-file helpers for HTTP-server mode
|
|
||||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
|
||||||
import { existsSync, readFileSync, unlinkSync } from 'fs';
|
|
||||||
import { resolve, dirname } from 'path';
|
|
||||||
import { fileURLToPath } from 'url';
|
|
||||||
import { die } from './util.mjs';
|
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
||||||
export const SESSION_FILE = resolve(__dirname, '..', '..', '.browser-session.json');
|
|
||||||
|
|
||||||
export function loadSession() {
|
|
||||||
if (!existsSync(SESSION_FILE)) {
|
|
||||||
die('No active session. Run: node src/run.mjs start <url>');
|
|
||||||
}
|
|
||||||
return JSON.parse(readFileSync(SESSION_FILE, 'utf-8'));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function cleanup() {
|
|
||||||
try { unlinkSync(SESSION_FILE); } catch {}
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
// web-test cli/test-runner/assertions v1.0 — ctx.assert API
|
|
||||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
|
||||||
|
|
||||||
export function createAssertions() {
|
|
||||||
class AssertionError extends Error {
|
|
||||||
constructor(msg, actual, expected) {
|
|
||||||
super(msg);
|
|
||||||
this.name = 'AssertionError';
|
|
||||||
this.actual = actual;
|
|
||||||
this.expected = expected;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
ok(value, msg) {
|
|
||||||
if (!value) throw new AssertionError(msg || `Expected truthy, got ${JSON.stringify(value)}`, value, true);
|
|
||||||
},
|
|
||||||
equal(actual, expected, msg) {
|
|
||||||
if (actual !== expected) throw new AssertionError(msg || `Expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`, actual, expected);
|
|
||||||
},
|
|
||||||
notEqual(actual, expected, msg) {
|
|
||||||
if (actual === expected) throw new AssertionError(msg || `Expected not ${JSON.stringify(expected)}`, actual, expected);
|
|
||||||
},
|
|
||||||
deepEqual(actual, expected, msg) {
|
|
||||||
const a = JSON.stringify(actual), b = JSON.stringify(expected);
|
|
||||||
if (a !== b) throw new AssertionError(msg || `Deep equal failed:\n actual: ${a}\n expected: ${b}`, actual, expected);
|
|
||||||
},
|
|
||||||
includes(haystack, needle, msg) {
|
|
||||||
const h = Array.isArray(haystack) ? haystack : String(haystack);
|
|
||||||
if (!h.includes(needle)) throw new AssertionError(msg || `Expected ${JSON.stringify(h)} to include ${JSON.stringify(needle)}`, haystack, needle);
|
|
||||||
},
|
|
||||||
match(string, regex, msg) {
|
|
||||||
if (!regex.test(string)) throw new AssertionError(msg || `Expected ${JSON.stringify(string)} to match ${regex}`, string, regex);
|
|
||||||
},
|
|
||||||
async throws(fn, msg) {
|
|
||||||
try { await fn(); } catch { return; }
|
|
||||||
throw new AssertionError(msg || 'Expected function to throw');
|
|
||||||
},
|
|
||||||
// 1C-specific
|
|
||||||
formHasField(state, fieldName, msg) {
|
|
||||||
if (!state?.fields?.[fieldName]) throw new AssertionError(msg || `Field "${fieldName}" not found in form. Available: ${Object.keys(state?.fields || {}).join(', ')}`, null, fieldName);
|
|
||||||
},
|
|
||||||
formTitle(state, expected, msg) {
|
|
||||||
if (!state?.title?.includes(expected)) throw new AssertionError(msg || `Form title "${state?.title}" does not contain "${expected}"`, state?.title, expected);
|
|
||||||
},
|
|
||||||
tableHasRow(table, predicate, msg) {
|
|
||||||
const rows = table?.rows || [];
|
|
||||||
let found;
|
|
||||||
if (typeof predicate === 'function') {
|
|
||||||
found = rows.some(predicate);
|
|
||||||
} else {
|
|
||||||
found = rows.some(r => Object.entries(predicate).every(([k, v]) => r[k] === v));
|
|
||||||
}
|
|
||||||
if (!found) throw new AssertionError(msg || `No row matching predicate in table (${rows.length} rows)`, null, predicate);
|
|
||||||
},
|
|
||||||
tableRowCount(table, expected, msg) {
|
|
||||||
const actual = table?.rows?.length ?? 0;
|
|
||||||
if (actual !== expected) throw new AssertionError(msg || `Expected ${expected} rows, got ${actual}`, actual, expected);
|
|
||||||
},
|
|
||||||
noErrors(state, msg) {
|
|
||||||
if (state?.errors) throw new AssertionError(msg || `Form has errors: ${JSON.stringify(state.errors)}`, state.errors, null);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
// web-test cli/test-runner/discover v1.1 — test file discovery + state reset between tests
|
|
||||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
|
||||||
import { existsSync, readdirSync } from 'fs';
|
|
||||||
import { resolve } from 'path';
|
|
||||||
|
|
||||||
// Accepts a single path or an array of paths (files and/or dirs). Each .test.mjs file is
|
|
||||||
// taken directly; each directory is walked recursively (skipping _ / . prefixes). Results
|
|
||||||
// are deduped and sorted — sorting preserves the numeric-prefix order the suite relies on
|
|
||||||
// (00-, 01-, …) even when paths are listed out of order.
|
|
||||||
export function discoverTests(testPaths) {
|
|
||||||
const paths = Array.isArray(testPaths) ? testPaths : [testPaths];
|
|
||||||
const files = [];
|
|
||||||
function walk(dir) {
|
|
||||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
||||||
if (entry.name.startsWith('_') || entry.name.startsWith('.')) continue;
|
|
||||||
const full = resolve(dir, entry.name);
|
|
||||||
if (entry.isDirectory()) walk(full);
|
|
||||||
else if (entry.name.endsWith('.test.mjs')) files.push(full);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const p of paths) {
|
|
||||||
const full = resolve(p);
|
|
||||||
if (full.endsWith('.test.mjs')) {
|
|
||||||
if (existsSync(full)) files.push(full);
|
|
||||||
} else if (existsSync(full)) {
|
|
||||||
walk(full);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return [...new Set(files)].sort();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function resetState(ctx) {
|
|
||||||
try { if (typeof ctx.dismissPendingErrors === 'function') await ctx.dismissPendingErrors(); } catch {}
|
|
||||||
for (let i = 0; i < 10; i++) {
|
|
||||||
try {
|
|
||||||
const state = await ctx.getFormState();
|
|
||||||
// form === null means no form open (desktop). form === 0 is a real background form
|
|
||||||
// 1C exposes in some states — must still close it to fully reset.
|
|
||||||
if (state.form == null) break;
|
|
||||||
await ctx.closeForm({ save: false });
|
|
||||||
} catch { break; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
// web-test cli/test-runner/reporters v1.0 — Allure/JUnit writers + extras sync
|
|
||||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
|
||||||
import { writeFileSync, existsSync, readdirSync, copyFileSync, statSync } from 'fs';
|
|
||||||
import { resolve, dirname, basename, relative } from 'path';
|
|
||||||
import { randomUUID } from 'crypto';
|
|
||||||
import { xmlEscape } from '../util.mjs';
|
|
||||||
import { resolveSeverity } from './severity.mjs';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Copy any files from `<testDir>/_allure/` into `reportDir`. Convention for
|
|
||||||
* Allure customization that doesn't fit inside per-test JSON:
|
|
||||||
* - `categories.json` — failure classification (regex → bucket)
|
|
||||||
* - `environment.properties` — values shown in the Environment widget
|
|
||||||
* - `executor.json` — CI/CD metadata
|
|
||||||
* Underscored folder mirrors `_hooks.mjs` convention (infra, not a test).
|
|
||||||
* Silent if folder absent.
|
|
||||||
*/
|
|
||||||
export function syncAllureExtras(testDir, reportDir) {
|
|
||||||
const extrasDir = resolve(testDir, '_allure');
|
|
||||||
if (!existsSync(extrasDir)) return;
|
|
||||||
try {
|
|
||||||
if (!statSync(extrasDir).isDirectory()) return;
|
|
||||||
} catch { return; }
|
|
||||||
for (const entry of readdirSync(extrasDir, { withFileTypes: true })) {
|
|
||||||
if (!entry.isFile()) continue;
|
|
||||||
try { copyFileSync(resolve(extrasDir, entry.name), resolve(reportDir, entry.name)); }
|
|
||||||
catch { /* best-effort */ }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function writeAllure(results, reportDir, severityIndex) {
|
|
||||||
for (const tr of results) {
|
|
||||||
if (tr.status === 'skipped') continue; // Allure ignores skipped without start/stop
|
|
||||||
const uuid = randomUUID();
|
|
||||||
const suite = dirname(tr.file);
|
|
||||||
const suiteLabel = (suite && suite !== '.') ? suite : 'root';
|
|
||||||
const severity = resolveSeverity(tr, severityIndex);
|
|
||||||
const out = {
|
|
||||||
uuid,
|
|
||||||
name: tr.name,
|
|
||||||
fullName: tr.file,
|
|
||||||
status: tr.status,
|
|
||||||
stage: 'finished',
|
|
||||||
start: tr.start,
|
|
||||||
stop: tr.stop,
|
|
||||||
labels: [
|
|
||||||
...(tr.tags || []).map(t => ({ name: 'tag', value: t })),
|
|
||||||
{ name: 'suite', value: suiteLabel },
|
|
||||||
{ name: 'severity', value: severity },
|
|
||||||
],
|
|
||||||
steps: (tr.steps || []).map(allureStep),
|
|
||||||
attachments: [
|
|
||||||
...(tr.screenshot ? [{ name: 'Screenshot on failure', source: basename(tr.screenshot), type: 'image/png' }] : []),
|
|
||||||
...(tr.video ? [{ name: 'Video', source: basename(tr.video), type: 'video/mp4' }] : []),
|
|
||||||
],
|
|
||||||
};
|
|
||||||
if (tr.status === 'failed' && tr.error) {
|
|
||||||
const traceParts = [];
|
|
||||||
if (tr.output) traceParts.push(tr.output);
|
|
||||||
const onecStack = tr.error.onecError?.stack?.raw;
|
|
||||||
if (onecStack) {
|
|
||||||
if (traceParts.length) traceParts.push('\n--- 1C stack ---\n');
|
|
||||||
traceParts.push(onecStack);
|
|
||||||
}
|
|
||||||
out.statusDetails = { message: tr.error.message || '', trace: traceParts.join('') };
|
|
||||||
}
|
|
||||||
writeFileSync(resolve(reportDir, `${uuid}-result.json`), JSON.stringify(out, null, 2));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function allureStep(s) {
|
|
||||||
const out = {
|
|
||||||
name: s.name,
|
|
||||||
status: s.status,
|
|
||||||
stage: 'finished',
|
|
||||||
start: s.start,
|
|
||||||
stop: s.stop,
|
|
||||||
steps: (s.steps || []).map(allureStep),
|
|
||||||
};
|
|
||||||
if (s.screenshot) {
|
|
||||||
out.attachments = [{ name: 'Screenshot', source: basename(s.screenshot), type: 'image/png' }];
|
|
||||||
}
|
|
||||||
if (s.status === 'failed' && s.error) {
|
|
||||||
out.statusDetails = { message: s.error, trace: s.error };
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildJUnit(report, testDir) {
|
|
||||||
const { summary, duration, tests } = report;
|
|
||||||
const suiteName = relative(process.cwd(), testDir).replace(/\\/g, '/') || '.';
|
|
||||||
const lines = ['<?xml version="1.0" encoding="UTF-8"?>'];
|
|
||||||
lines.push(`<testsuites name="web-test" tests="${summary.total}" failures="${summary.failed}" skipped="${summary.skipped}" time="${duration.toFixed(3)}">`);
|
|
||||||
lines.push(` <testsuite name="${xmlEscape(suiteName)}" tests="${summary.total}" failures="${summary.failed}" skipped="${summary.skipped}" time="${duration.toFixed(3)}">`);
|
|
||||||
for (const t of tests) {
|
|
||||||
const attrs = `name="${xmlEscape(t.name)}" classname="${xmlEscape(t.file)}" time="${(t.duration || 0).toFixed(3)}"`;
|
|
||||||
if (t.status === 'passed') {
|
|
||||||
lines.push(` <testcase ${attrs}/>`);
|
|
||||||
} else if (t.status === 'skipped') {
|
|
||||||
lines.push(` <testcase ${attrs}><skipped/></testcase>`);
|
|
||||||
} else {
|
|
||||||
lines.push(` <testcase ${attrs}>`);
|
|
||||||
const msg = t.error?.message || '';
|
|
||||||
const trace = t.output || '';
|
|
||||||
lines.push(` <failure message="${xmlEscape(msg)}">${xmlEscape(trace)}</failure>`);
|
|
||||||
if (t.screenshot) lines.push(` <system-out>screenshot: ${xmlEscape(t.screenshot)}</system-out>`);
|
|
||||||
lines.push(` </testcase>`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
lines.push(` </testsuite>`);
|
|
||||||
lines.push(`</testsuites>`);
|
|
||||||
return lines.join('\n');
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
// web-test cli/test-runner/severity v1.0 — Allure severity policy resolver
|
|
||||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
|
||||||
import { die } from '../util.mjs';
|
|
||||||
|
|
||||||
export const SEVERITY_RANK = { blocker: 5, critical: 4, normal: 3, minor: 2, trivial: 1 };
|
|
||||||
export const SEVERITY_LEVELS = Object.keys(SEVERITY_RANK);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate config.severity (inverted map: severity → [tags]) at config load time.
|
|
||||||
* Returns:
|
|
||||||
* - tagToSeverity: Map<tag, severity> (precomputed lookup for the resolver)
|
|
||||||
* - defaultSeverity: string (validated, defaults to 'normal')
|
|
||||||
* Throws (via die) on invalid keys, invalid default, or duplicate tag across buckets.
|
|
||||||
*/
|
|
||||||
export function buildSeverityIndex(config) {
|
|
||||||
const tagToSeverity = new Map();
|
|
||||||
const sev = config.severity || {};
|
|
||||||
if (typeof sev !== 'object' || Array.isArray(sev)) {
|
|
||||||
die(`config.severity must be an object, got ${typeof sev}`);
|
|
||||||
}
|
|
||||||
for (const [level, tags] of Object.entries(sev)) {
|
|
||||||
if (!SEVERITY_LEVELS.includes(level)) {
|
|
||||||
die(`config.severity: unknown level "${level}". Allowed: ${SEVERITY_LEVELS.join('|')}`);
|
|
||||||
}
|
|
||||||
if (!Array.isArray(tags)) {
|
|
||||||
die(`config.severity.${level} must be an array of tag names, got ${typeof tags}`);
|
|
||||||
}
|
|
||||||
for (const tag of tags) {
|
|
||||||
if (tagToSeverity.has(tag)) {
|
|
||||||
die(`config.severity: tag "${tag}" listed under both "${tagToSeverity.get(tag)}" and "${level}" — pick one`);
|
|
||||||
}
|
|
||||||
tagToSeverity.set(tag, level);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const def = config.defaultSeverity || 'normal';
|
|
||||||
if (!SEVERITY_LEVELS.includes(def)) {
|
|
||||||
die(`config.defaultSeverity: "${def}" is not a valid level. Allowed: ${SEVERITY_LEVELS.join('|')}`);
|
|
||||||
}
|
|
||||||
return { tagToSeverity, defaultSeverity: def };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve a test's severity. Precedence:
|
|
||||||
* 1. explicit `export const severity` from the test module
|
|
||||||
* 2. max-rank severity found among tags (either standard severity name, or mapped via config)
|
|
||||||
* 3. defaultSeverity from config (or 'normal' if not set)
|
|
||||||
* Returns one of SEVERITY_LEVELS.
|
|
||||||
*/
|
|
||||||
export function resolveSeverity(t, severityIndex) {
|
|
||||||
if (t.severity) {
|
|
||||||
if (!SEVERITY_LEVELS.includes(t.severity)) {
|
|
||||||
return severityIndex.defaultSeverity;
|
|
||||||
}
|
|
||||||
return t.severity;
|
|
||||||
}
|
|
||||||
let best = null;
|
|
||||||
for (const tag of t.tags || []) {
|
|
||||||
let candidate = null;
|
|
||||||
if (SEVERITY_LEVELS.includes(tag)) candidate = tag;
|
|
||||||
else if (severityIndex.tagToSeverity.has(tag)) candidate = severityIndex.tagToSeverity.get(tag);
|
|
||||||
if (candidate && (best === null || SEVERITY_RANK[candidate] > SEVERITY_RANK[best])) {
|
|
||||||
best = candidate;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return best || severityIndex.defaultSeverity;
|
|
||||||
}
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
// web-test cli/util v1.2 — generic helpers for CLI commands
|
|
||||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
|
||||||
|
|
||||||
export function out(obj) {
|
|
||||||
process.stdout.write(JSON.stringify(obj, null, 2) + '\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function die(msg) {
|
|
||||||
process.stderr.write(msg + '\n');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function json(res, obj, status = 200) {
|
|
||||||
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
||||||
res.end(JSON.stringify(obj, null, 2));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function readBody(req) {
|
|
||||||
const chunks = [];
|
|
||||||
for await (const chunk of req) chunks.push(chunk);
|
|
||||||
return Buffer.concat(chunks).toString('utf-8');
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function readStdin() {
|
|
||||||
const chunks = [];
|
|
||||||
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
||||||
return Buffer.concat(chunks).toString('utf-8');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function elapsed(t0) {
|
|
||||||
return Math.round((Date.now() - t0) / 100) / 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function elapsed2(start, stop) {
|
|
||||||
return Math.round(((stop || Date.now()) - start) / 100) / 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function slugify(s) {
|
|
||||||
return String(s).trim()
|
|
||||||
.replace(/[\s/\\:*?"<>|]+/g, '-')
|
|
||||||
.replace(/-+/g, '-')
|
|
||||||
.replace(/^-|-$/g, '')
|
|
||||||
.slice(0, 60) || 'step';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatDuration(seconds) {
|
|
||||||
if (seconds < 60) return `${Math.round(seconds * 10) / 10}s`;
|
|
||||||
const m = Math.floor(seconds / 60);
|
|
||||||
const s = Math.round((seconds - m * 60) * 10) / 10;
|
|
||||||
return `${m}m ${s}s`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function xmlEscape(s) {
|
|
||||||
return String(s == null ? '' : s)
|
|
||||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"').replace(/'/g, ''');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function interpolate(template, params) {
|
|
||||||
return String(template).replace(/\{(\w+)\}/g, (_, key) =>
|
|
||||||
params[key] !== undefined ? String(params[key]) : `{${key}}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function printSteps(W, steps, indent) {
|
|
||||||
for (let i = 0; i < steps.length; i++) {
|
|
||||||
const s = steps[i];
|
|
||||||
const last = i === steps.length - 1;
|
|
||||||
const prefix = last ? '└' : '├';
|
|
||||||
const mark = s.status === 'failed' ? '✗ ' : '';
|
|
||||||
W.write(`${indent}${prefix} ${mark}${s.name} (${elapsed2(s.start, s.stop)}s)\n`);
|
|
||||||
if (s.error && s.status === 'failed') {
|
|
||||||
W.write(`${indent} ${s.error}\n`);
|
|
||||||
}
|
|
||||||
if (s.steps.length) printSteps(W, s.steps, indent + ' ');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function usage() {
|
|
||||||
die(`Usage: node run.mjs <command> [args]
|
|
||||||
|
|
||||||
Commands:
|
|
||||||
start <url> Launch browser and connect to 1C web client
|
|
||||||
run <url> <file|-> Autonomous: connect, execute script, disconnect
|
|
||||||
exec <file|-> [options] Execute script (file path or - for stdin)
|
|
||||||
shot [file] Take screenshot (default: shot.png)
|
|
||||||
stop Logout and close browser
|
|
||||||
status Check session status
|
|
||||||
test <dir|file>... Run regression tests (*.test.mjs); accepts multiple paths
|
|
||||||
|
|
||||||
Options for exec:
|
|
||||||
--no-record Skip video recording (record() becomes no-op)
|
|
||||||
|
|
||||||
Global options (any command):
|
|
||||||
--no-preserve-clipboard Don't save/restore OS clipboard around action calls.
|
|
||||||
Default: on (env: WEB_TEST_PRESERVE_CLIPBOARD=0 to disable globally).
|
|
||||||
|
|
||||||
Options for test:
|
|
||||||
--url=URL Override the base URL (default: from webtest.config.mjs)
|
|
||||||
--tags=smoke,crud Filter tests by tags
|
|
||||||
--grep=pattern Filter tests by name (regex)
|
|
||||||
--bail Stop on first failure
|
|
||||||
--retry=N Retry failed tests N times
|
|
||||||
--timeout=ms Per-test timeout (default: 30000)
|
|
||||||
--report=path Write machine report (JSON/JUnit) to file
|
|
||||||
--report=- Write machine report to stdout (progress moves to stderr)
|
|
||||||
--report-dir=path Directory for screenshots and other artifacts
|
|
||||||
--screenshot=mode on-failure (default) | every-step | off
|
|
||||||
--format=fmt json (default) | allure | junit
|
|
||||||
--record Record video for each test (mp4 in report-dir)
|
|
||||||
-- <hook-args...> Everything after \`--\` is forwarded to _hooks.mjs
|
|
||||||
prepare/cleanup as hookArgs (runner does not parse it).
|
|
||||||
Example: ... tests/web-test/ -- --rebuild-stand`);
|
|
||||||
}
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
// web-test dom v1.16 — facade re-exporting injectable DOM scripts from dom/
|
|
||||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
|
||||||
/**
|
|
||||||
* Facade: re-exports DOM selector & semantic mapping script generators.
|
|
||||||
* Внутренности живут в dom/*. Публичный набор имён неизменен.
|
|
||||||
*
|
|
||||||
* All functions return JavaScript strings for page.evaluate().
|
|
||||||
* They produce clean semantic structures — no DOM IDs or CSS classes leak out.
|
|
||||||
* Only non-default property values are included to minimize response size.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export {
|
|
||||||
detectFormScript,
|
|
||||||
readFormScript,
|
|
||||||
findClickTargetScript,
|
|
||||||
findFieldButtonScript,
|
|
||||||
resolveFieldsScript,
|
|
||||||
detectNewFormScript,
|
|
||||||
findSearchInputScript,
|
|
||||||
findNamedButtonScript,
|
|
||||||
findCompareTypeRadioScript,
|
|
||||||
isFormVisibleScript,
|
|
||||||
findPatternInputIdScript,
|
|
||||||
isTypeDialogScript,
|
|
||||||
isNotInListCloudVisibleScript,
|
|
||||||
clickShowAllInNotInListCloudScript,
|
|
||||||
findChildFormByButtonScript,
|
|
||||||
readTypeDialogVisibleRowsScript,
|
|
||||||
} from './dom/forms.mjs';
|
|
||||||
|
|
||||||
export {
|
|
||||||
findFirstGridCellCoordsScript,
|
|
||||||
findColumnFirstCellCoordsScript,
|
|
||||||
readFieldSelectorInfoScript,
|
|
||||||
pickFieldInSelectorDropdownScript,
|
|
||||||
readFilterDialogInfoScript,
|
|
||||||
findFilterBadgeCloseScript,
|
|
||||||
findFirstFilterBadgeCloseScript,
|
|
||||||
} from './dom/filter.mjs';
|
|
||||||
|
|
||||||
export {
|
|
||||||
isInputFocusedScript,
|
|
||||||
isInputFocusedInGridScript,
|
|
||||||
findOpenPopupScript,
|
|
||||||
} from './dom/edit-state.mjs';
|
|
||||||
|
|
||||||
export {
|
|
||||||
readEddScript,
|
|
||||||
isEddVisibleScript,
|
|
||||||
clickEddItemViaDispatchScript,
|
|
||||||
clickShowAllInEddScript,
|
|
||||||
} from './dom/edd.mjs';
|
|
||||||
|
|
||||||
export { getFormStateScript } from './dom/form-state.mjs';
|
|
||||||
|
|
||||||
export {
|
|
||||||
resolveGridScript,
|
|
||||||
readTableScript,
|
|
||||||
countGridRowsScript,
|
|
||||||
isTreeGridScript,
|
|
||||||
findGridHeadCenterCoordsScript,
|
|
||||||
getSelectedOrLastRowIndexScript,
|
|
||||||
findGridCellScript,
|
|
||||||
findFocusCellScript,
|
|
||||||
snapshotGridScript,
|
|
||||||
resolveCellTargetScript,
|
|
||||||
} from './dom/grid.mjs';
|
|
||||||
|
|
||||||
export {
|
|
||||||
sortFieldKeysByColindexScript,
|
|
||||||
findCellCoordsByFieldsScript,
|
|
||||||
findNextCellCoordsByKeyScript,
|
|
||||||
findCheckboxAtPointScript,
|
|
||||||
findRowCommitClickCoordsScript,
|
|
||||||
getGridEditCheckScript,
|
|
||||||
readActiveGridCellScript,
|
|
||||||
getElementCenterCoordsByIdScript,
|
|
||||||
} from './dom/grid-edit.mjs';
|
|
||||||
|
|
||||||
export {
|
|
||||||
readSectionsScript,
|
|
||||||
readTabsScript,
|
|
||||||
switchTabScript,
|
|
||||||
readCommandsScript,
|
|
||||||
navigateSectionScript,
|
|
||||||
openCommandScript,
|
|
||||||
} from './dom/nav.mjs';
|
|
||||||
|
|
||||||
export {
|
|
||||||
readSubmenuScript,
|
|
||||||
clickPopupItemScript,
|
|
||||||
readCloudDDScript,
|
|
||||||
} from './dom/submenu.mjs';
|
|
||||||
|
|
||||||
export { checkErrorsScript } from './dom/errors.mjs';
|
|
||||||
@@ -1,486 +0,0 @@
|
|||||||
// web-test dom shared v1.2 — embedded JS function constants
|
|
||||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
|
||||||
/**
|
|
||||||
* Shared function strings embedded into page.evaluate() generators.
|
|
||||||
* Не экспортируются наружу через dom.mjs facade — внутренняя кухня.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** Find visible #modalSurface. 1C may leave multiple #modalSurface in DOM (duplicate id),
|
|
||||||
* e.g. when a second form (drill-down) creates its own alongside a stale one from the first
|
|
||||||
* form. getElementById returns the FIRST in document order, which may be hidden. Scan all. */
|
|
||||||
export const HAS_VISIBLE_MODAL_FN = `function hasVisibleModal() {
|
|
||||||
const all = document.querySelectorAll('#modalSurface');
|
|
||||||
for (const el of all) { if (el.offsetWidth > 0) return true; }
|
|
||||||
return false;
|
|
||||||
}`;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Click point INSIDE a grid row's first visible text cell — NOT the row-line centre.
|
|
||||||
*
|
|
||||||
* A wide multi-column row's centre `x = line.x + line.width/2` lands far beyond the
|
|
||||||
* form's horizontal viewport (the `.gridLine` spans ALL columns, frozen + scrollable),
|
|
||||||
* so `mouse.click` at that X falls on an overlay outside the visible grid and the row
|
|
||||||
* is never hit — the click silently does nothing. Seen on narrow modal selection forms
|
|
||||||
* with many columns (множественный выбор) and the `not_selectable` bug on selection forms.
|
|
||||||
*
|
|
||||||
* Picks the first visible non-checkbox cell that HAS text (so center-clicking never
|
|
||||||
* toggles a checkbox/picture mark), skips the first column on tree grids (it holds the
|
|
||||||
* expand toggle), and clamps X near the left edge (`min(width/2, 60)`) so a wide first
|
|
||||||
* column still lands in the viewport.
|
|
||||||
*
|
|
||||||
* @param line a `.gridLine` element
|
|
||||||
* @param body the grid's `.gridBody` (for tree detection); may be null
|
|
||||||
* @returns `{ x, y }` rounded, or `null` when the row has no usable cell.
|
|
||||||
*/
|
|
||||||
export const ROW_CLICK_POINT_FN = `function rowClickPoint(line, body) {
|
|
||||||
const isTree = !!(body && body.querySelector('.gridBoxTree'));
|
|
||||||
let cells = [...line.children]
|
|
||||||
.filter(b => b.offsetWidth > 0)
|
|
||||||
.map(b => ({ r: b.getBoundingClientRect(), checkbox: !!b.querySelector('.checkbox'), hasText: !!b.querySelector('.gridBoxText') }));
|
|
||||||
if (isTree && cells.length > 1) cells = cells.slice(1);
|
|
||||||
const pick = cells.find(c => !c.checkbox && c.hasText) || cells.find(c => !c.checkbox) || cells[0];
|
|
||||||
if (!pick) return null;
|
|
||||||
return { x: Math.round(pick.r.x + Math.min(pick.r.width / 2, 60)), y: Math.round(pick.r.y + pick.r.height / 2) };
|
|
||||||
}`;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Single source of truth for column derivation on HEADERLESS grids (no `.gridHead`).
|
|
||||||
* 1C still puts `colindex` on body cells, so anchoring works without a header.
|
|
||||||
* Returns ordered descriptors consumed identically by readers (readTable, getFormState)
|
|
||||||
* and resolvers (findCellCoords, findGridCell, scanGridRows) so a synthesized name like
|
|
||||||
* "Колонка1" always maps to the same physical cell on both read and write.
|
|
||||||
*
|
|
||||||
* Descriptor: { name, kind:'data'|'checkbox'|'picture', colindex, subTarget:'checkbox'|'title'|'text'|null }
|
|
||||||
* - colindex — anchor: find the cell via line.children box with matching getAttribute('colindex').
|
|
||||||
* - subTarget — node inside that box: 'checkbox' → .checkbox, 'title' → .gridBoxTitle,
|
|
||||||
* 'text' → .gridBoxText, null → box itself.
|
|
||||||
*
|
|
||||||
* A COMBINED mark-box (one box holding BOTH .checkbox AND non-empty .gridBoxTitle, e.g. the
|
|
||||||
* value-list checkbox mark-lists) is split into TWO logical columns sharing one colindex:
|
|
||||||
* "(checkbox)" (subTarget:checkbox) + "КолонкаN" (subTarget:title). Data columns are numbered
|
|
||||||
* КолонкаN among themselves (checkbox/picture don't consume a number); duplicate
|
|
||||||
* "(checkbox)"/"(picture)" get a " 2", " 3" suffix.
|
|
||||||
*/
|
|
||||||
export const HEADERLESS_GRID_FN = `function synthHeaderlessColumns(grid) {
|
|
||||||
function picInfo(cell) {
|
|
||||||
if (!cell) return null;
|
|
||||||
if (cell.querySelector('.gridListH, .gridListV, [tree="true"], .gridBoxTree')) return null;
|
|
||||||
const dib = cell.querySelector('.gridBoxImg .dIB');
|
|
||||||
if (!dib) return null;
|
|
||||||
const bg = dib.style.backgroundImage || '';
|
|
||||||
if (!bg.includes('pictureCollection/picture/')) return null;
|
|
||||||
const m = bg.match(/[?&]gx=(\\d+)/);
|
|
||||||
return { gx: m ? m[1] : '0' };
|
|
||||||
}
|
|
||||||
const body = grid.querySelector('.gridBody');
|
|
||||||
if (!body) return [];
|
|
||||||
const line = body.querySelector('.gridLine');
|
|
||||||
if (!line) return [];
|
|
||||||
const cols = [];
|
|
||||||
let dataN = 0;
|
|
||||||
const uniq = (base) => {
|
|
||||||
if (!cols.some(c => c.name === base)) return base;
|
|
||||||
let n = 2; while (cols.some(c => c.name === base + ' ' + n)) n++;
|
|
||||||
return base + ' ' + n;
|
|
||||||
};
|
|
||||||
[...line.children].forEach(box => {
|
|
||||||
if (box.offsetWidth === 0) return;
|
|
||||||
const ci = box.getAttribute('colindex');
|
|
||||||
if (ci == null) return;
|
|
||||||
const chk = box.querySelector('.checkbox');
|
|
||||||
const titleEl = box.querySelector('.gridBoxTitle');
|
|
||||||
const textEl = box.querySelector('.gridBoxText');
|
|
||||||
const titleTxt = ((titleEl ? titleEl.innerText : '') || '').trim();
|
|
||||||
if (chk && titleTxt) {
|
|
||||||
cols.push({ name: uniq('(checkbox)'), kind: 'checkbox', colindex: ci, subTarget: 'checkbox' });
|
|
||||||
cols.push({ name: 'Колонка' + (++dataN), kind: 'data', colindex: ci, subTarget: 'title' });
|
|
||||||
} else if (chk) {
|
|
||||||
cols.push({ name: uniq('(checkbox)'), kind: 'checkbox', colindex: ci, subTarget: 'checkbox' });
|
|
||||||
} else if (picInfo(box)) {
|
|
||||||
cols.push({ name: uniq('(picture)'), kind: 'picture', colindex: ci, subTarget: null });
|
|
||||||
} else {
|
|
||||||
cols.push({ name: 'Колонка' + (++dataN), kind: 'data', colindex: ci, subTarget: textEl ? 'text' : (titleEl ? 'title' : null) });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return cols;
|
|
||||||
}`;
|
|
||||||
|
|
||||||
/** Detect active form number. Picks form with most visible elements, skipping form0.
|
|
||||||
* When modalSurface is visible — prefer the highest-numbered form (modal dialog). */
|
|
||||||
export const DETECT_FORM_FN = HAS_VISIBLE_MODAL_FN + `
|
|
||||||
function detectForm() {
|
|
||||||
const counts = {};
|
|
||||||
document.querySelectorAll('input.editInput[id], textarea[id], a.press[id]').forEach(el => {
|
|
||||||
if (el.offsetWidth === 0) return;
|
|
||||||
const m = el.id.match(/^form(\\d+)_/);
|
|
||||||
if (m) counts[m[1]] = (counts[m[1]] || 0) + 1;
|
|
||||||
});
|
|
||||||
const nums = Object.keys(counts).map(Number);
|
|
||||||
if (!nums.length) return null;
|
|
||||||
const candidates = nums.filter(n => n > 0);
|
|
||||||
if (!candidates.length) return nums[0];
|
|
||||||
// When modal surface is visible, prefer the highest-numbered form (modal dialog)
|
|
||||||
if (hasVisibleModal()) {
|
|
||||||
const maxForm = Math.max(...candidates);
|
|
||||||
if (counts[maxForm] >= 1) return maxForm;
|
|
||||||
}
|
|
||||||
return candidates.reduce((best, n) => counts[n] > counts[best] ? n : best);
|
|
||||||
}`;
|
|
||||||
|
|
||||||
/** Detect all open forms + modal state. Returns { activeForm, allForms, formCount, modal }.
|
|
||||||
* Works even when the open-windows tab bar is hidden. */
|
|
||||||
export const DETECT_FORMS_FN = HAS_VISIBLE_MODAL_FN + `
|
|
||||||
function detectForms() {
|
|
||||||
const counts = {};
|
|
||||||
document.querySelectorAll('input.editInput[id], textarea[id], a.press[id]').forEach(el => {
|
|
||||||
if (el.offsetWidth === 0) return;
|
|
||||||
const m = el.id.match(/^form(\\d+)_/);
|
|
||||||
if (m) counts[m[1]] = (counts[m[1]] || 0) + 1;
|
|
||||||
});
|
|
||||||
const nums = Object.keys(counts).map(Number);
|
|
||||||
return { allForms: nums.sort((a, b) => a - b), formCount: nums.length, modal: hasVisibleModal() };
|
|
||||||
}`;
|
|
||||||
|
|
||||||
/** Read form state given prefix p. Returns { fields, buttons, tabs, texts, hyperlinks, table, iframes }. */
|
|
||||||
export const READ_FORM_FN = HEADERLESS_GRID_FN + `
|
|
||||||
function readForm(p) {
|
|
||||||
const result = {};
|
|
||||||
const fields = [];
|
|
||||||
const buttons = [];
|
|
||||||
const formTabs = [];
|
|
||||||
const texts = [];
|
|
||||||
const hyperlinks = [];
|
|
||||||
// Normalize non-breaking spaces to regular spaces
|
|
||||||
const nbsp = s => (s || '').replace(/\\u00a0/g, ' ');
|
|
||||||
|
|
||||||
// Fields (inputs)
|
|
||||||
document.querySelectorAll('input.editInput[id^="' + p + '"]').forEach(el => {
|
|
||||||
if (el.offsetWidth === 0) return;
|
|
||||||
const name = el.id.replace(p, '').replace(/_i\\d+$/, '');
|
|
||||||
const titleEl = document.getElementById(p + name + '#title_text')
|
|
||||||
|| document.getElementById(p + name + '#title_div');
|
|
||||||
const label = nbsp((titleEl?.innerText?.trim() || '').replace(/\\n/g, ' '));
|
|
||||||
const actions = [];
|
|
||||||
if (document.getElementById(p + name + '_DLB')?.offsetWidth > 0) actions.push('select');
|
|
||||||
if (document.getElementById(p + name + '_OB')?.offsetWidth > 0) actions.push('open');
|
|
||||||
if (document.getElementById(p + name + '_CLR')?.offsetWidth > 0) actions.push('clear');
|
|
||||||
if (document.getElementById(p + name + '_CB')?.offsetWidth > 0) actions.push('pick');
|
|
||||||
const field = { name, value: el.value || '' };
|
|
||||||
// Multi-value reference fields keep their value in .chipsItem chips, not in input.value
|
|
||||||
if (!field.value) {
|
|
||||||
const labelEl = document.getElementById(p + name);
|
|
||||||
if (labelEl) {
|
|
||||||
const chipTexts = [...labelEl.querySelectorAll('.chipsItem .chipsTitle')]
|
|
||||||
.map(c => nbsp(c.innerText?.trim() || ''))
|
|
||||||
.filter(Boolean);
|
|
||||||
if (chipTexts.length) field.value = chipTexts.join(', ');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (label && label !== name) field.label = label;
|
|
||||||
if (el.readOnly) field.readonly = true;
|
|
||||||
if (el.disabled) field.disabled = true;
|
|
||||||
if (el.type && el.type !== 'text') field.type = el.type;
|
|
||||||
if (document.activeElement === el) field.focused = true;
|
|
||||||
if (actions.length) field.actions = actions;
|
|
||||||
if (el.closest('.inputsBox')?.classList.contains('markIncomplete')) field.required = true;
|
|
||||||
fields.push(field);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Textareas
|
|
||||||
document.querySelectorAll('textarea[id^="' + p + '"]').forEach(el => {
|
|
||||||
if (el.offsetWidth === 0) return;
|
|
||||||
const name = el.id.replace(p, '').replace(/_i\\d+$/, '');
|
|
||||||
const titleEl = document.getElementById(p + name + '#title_text')
|
|
||||||
|| document.getElementById(p + name + '#title_div');
|
|
||||||
const label = nbsp((titleEl?.innerText?.trim() || '').replace(/\\n/g, ' '));
|
|
||||||
const field = { name, value: el.value || '', type: 'textarea' };
|
|
||||||
if (label && label !== name) field.label = label;
|
|
||||||
if (el.readOnly) field.readonly = true;
|
|
||||||
if (el.disabled) field.disabled = true;
|
|
||||||
if (document.activeElement === el) field.focused = true;
|
|
||||||
if (el.closest('.inputsBox')?.classList.contains('markIncomplete')) field.required = true;
|
|
||||||
fields.push(field);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Checkboxes
|
|
||||||
document.querySelectorAll('[id^="' + p + '"].checkbox').forEach(el => {
|
|
||||||
if (el.offsetWidth === 0) return;
|
|
||||||
const name = el.id.replace(p, '');
|
|
||||||
const titleEl = document.getElementById(p + name + '#title_text');
|
|
||||||
const label = nbsp(titleEl?.innerText?.trim() || '');
|
|
||||||
const field = {
|
|
||||||
name,
|
|
||||||
value: el.classList.contains('checked') || el.classList.contains('checkboxOn') || el.classList.contains('select'),
|
|
||||||
type: 'checkbox'
|
|
||||||
};
|
|
||||||
if (label && label !== name) field.label = label;
|
|
||||||
fields.push(field);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Radio buttons — base element is option 0, others are #N#radio (N >= 1)
|
|
||||||
const radioGroups = {};
|
|
||||||
document.querySelectorAll('[id^="' + p + '"].radio').forEach(el => {
|
|
||||||
if (el.offsetWidth === 0) return;
|
|
||||||
const id = el.id.replace(p, '');
|
|
||||||
const m = id.match(/^(.+?)#(\\d+)#radio$/);
|
|
||||||
if (m) {
|
|
||||||
// Options 1, 2, ... have explicit #N#radio suffix
|
|
||||||
const [, groupName, idx] = m;
|
|
||||||
if (!radioGroups[groupName]) radioGroups[groupName] = [];
|
|
||||||
const labelEl = document.getElementById(p + groupName + '#' + idx + '#radio_text');
|
|
||||||
const label = nbsp(labelEl?.innerText?.trim() || 'option' + idx);
|
|
||||||
radioGroups[groupName].push({ index: parseInt(idx), label, selected: el.classList.contains('select') });
|
|
||||||
} else if (!id.includes('#')) {
|
|
||||||
// Base element = option 0 (no #0#radio suffix)
|
|
||||||
if (!radioGroups[id]) radioGroups[id] = [];
|
|
||||||
const labelEl = document.getElementById(p + id + '#0#radio_text');
|
|
||||||
const label = nbsp(labelEl?.innerText?.trim() || 'option0');
|
|
||||||
radioGroups[id].unshift({ index: 0, label, selected: el.classList.contains('select') });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
for (const [name, options] of Object.entries(radioGroups)) {
|
|
||||||
const titleEl = document.getElementById(p + name + '#title_text');
|
|
||||||
const label = titleEl?.innerText?.trim() || '';
|
|
||||||
const selected = options.find(o => o.selected);
|
|
||||||
const field = {
|
|
||||||
name,
|
|
||||||
value: selected?.label || '',
|
|
||||||
type: 'radio',
|
|
||||||
options: options.map(o => o.label)
|
|
||||||
};
|
|
||||||
if (label && label !== name) field.label = label;
|
|
||||||
fields.push(field);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Buttons (a.press)
|
|
||||||
document.querySelectorAll('a.press[id^="' + p + '"]').forEach(el => {
|
|
||||||
if (el.offsetWidth === 0) return;
|
|
||||||
const idName = el.id.replace(p, '');
|
|
||||||
if (/_(?:DLB|CLR|OB|CB)$/.test(idName)) return;
|
|
||||||
const span = el.querySelector('.submenuText') || el.querySelector('span');
|
|
||||||
const text = nbsp(span?.textContent?.trim() || el.innerText?.trim() || '');
|
|
||||||
if (!text && !el.classList.contains('pressCommand')) return;
|
|
||||||
const btn = { name: text || idName };
|
|
||||||
if (el.classList.contains('pressDefault')) btn.default = true;
|
|
||||||
if (el.classList.contains('pressDisabled')) btn.disabled = true;
|
|
||||||
// Icon-only buttons: expose tooltip from DOM title attribute (1C puts title on parent .framePress)
|
|
||||||
if (!text) {
|
|
||||||
const tip = nbsp(el.title || el.parentElement?.title || '');
|
|
||||||
if (tip) btn.tooltip = tip;
|
|
||||||
}
|
|
||||||
buttons.push(btn);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Frame buttons
|
|
||||||
document.querySelectorAll('[id^="' + p + '"].frameButton, [id^="' + p + '"] .frameButton').forEach(el => {
|
|
||||||
if (el.offsetWidth === 0) return;
|
|
||||||
const text = nbsp(el.innerText?.trim() || '');
|
|
||||||
const idName = el.id?.replace(p, '') || '';
|
|
||||||
if (!text && !idName) return;
|
|
||||||
buttons.push({ name: text || idName, frame: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Tumbler items
|
|
||||||
document.querySelectorAll('[id^="' + p + '"].tumblerItem').forEach(el => {
|
|
||||||
if (el.offsetWidth === 0) return;
|
|
||||||
const text = el.innerText?.trim();
|
|
||||||
const idName = el.id?.replace(p, '') || '';
|
|
||||||
buttons.push({ name: text || idName, tumbler: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Tabs — scoped to form by checking ancestor IDs
|
|
||||||
document.querySelectorAll('[data-content]').forEach(el => {
|
|
||||||
if (el.offsetWidth === 0) return;
|
|
||||||
let node = el.parentElement;
|
|
||||||
let inForm = false;
|
|
||||||
while (node) {
|
|
||||||
if (node.id && node.id.startsWith(p)) { inForm = true; break; }
|
|
||||||
node = node.parentElement;
|
|
||||||
}
|
|
||||||
if (!inForm) return;
|
|
||||||
const tab = { name: el.dataset.content };
|
|
||||||
if (el.classList.contains('select')) tab.active = true;
|
|
||||||
formTabs.push(tab);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Static texts and hyperlinks
|
|
||||||
document.querySelectorAll('[id^="' + p + '"].staticText').forEach(el => {
|
|
||||||
if (el.offsetWidth === 0) return;
|
|
||||||
const name = el.id.replace(p, '');
|
|
||||||
if (name.endsWith('_div') || name.includes('#title')) return;
|
|
||||||
const text = el.innerText?.trim();
|
|
||||||
if (!text) return;
|
|
||||||
if (el.classList.contains('staticTextHyper')) {
|
|
||||||
hyperlinks.push({ name: text });
|
|
||||||
} else {
|
|
||||||
const titleEl = document.getElementById(p + name + '#title_text');
|
|
||||||
const label = titleEl?.innerText?.trim() || '';
|
|
||||||
const entry = { name, value: text };
|
|
||||||
if (label) entry.label = label;
|
|
||||||
texts.push(entry);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Tables/grids — collect ALL visible grids
|
|
||||||
const allGrids = [...document.querySelectorAll('[id^="' + p + '"].grid, [id^="' + p + '"] .grid')]
|
|
||||||
.filter(g => g.offsetWidth > 0 && g.offsetHeight > 0);
|
|
||||||
if (allGrids.length > 0) {
|
|
||||||
const tables = allGrids.map(grid => {
|
|
||||||
const name = grid.id ? grid.id.replace(p, '') : '';
|
|
||||||
const head = grid.querySelector('.gridHead');
|
|
||||||
const body = grid.querySelector('.gridBody');
|
|
||||||
const columns = [];
|
|
||||||
if (head) {
|
|
||||||
const headLine = head.querySelector('.gridLine') || head;
|
|
||||||
[...headLine.children].forEach(box => {
|
|
||||||
if (box.offsetWidth === 0) return;
|
|
||||||
const textEl = box.querySelector('.gridBoxText');
|
|
||||||
const text = (textEl || box).innerText?.trim().replace(/\\n/g, ' ') || '';
|
|
||||||
if (text) {
|
|
||||||
const r = box.getBoundingClientRect();
|
|
||||||
columns.push({ text, x: r.x, right: r.x + r.width, y: r.y, h: r.height });
|
|
||||||
} else {
|
|
||||||
// Unnamed column — check if data cells contain checkboxes
|
|
||||||
const firstLine = body?.querySelector('.gridLine');
|
|
||||||
if (firstLine) {
|
|
||||||
const visibleHeaders = [...headLine.children].filter(c => c.offsetWidth > 0);
|
|
||||||
const idx = visibleHeaders.indexOf(box);
|
|
||||||
const cells = [...firstLine.children].filter(c => c.offsetWidth > 0);
|
|
||||||
if (cells[idx]?.querySelector('.checkbox')) {
|
|
||||||
columns.push({ text: '(checkbox)', x: 0, right: 0, y: 0, h: 0 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// Expand single merged headers with multiple data sub-rows (e.g. "Субконто Дт" → 1/2/3)
|
|
||||||
const firstLine = body?.querySelector('.gridLine');
|
|
||||||
if (firstLine && columns.length > 0) {
|
|
||||||
const xGrp = new Map();
|
|
||||||
columns.forEach(c => {
|
|
||||||
const k = Math.round(c.x) + ':' + Math.round(c.right);
|
|
||||||
if (!xGrp.has(k)) xGrp.set(k, []);
|
|
||||||
xGrp.get(k).push(c);
|
|
||||||
});
|
|
||||||
for (const [k, hdrs] of xGrp) {
|
|
||||||
if (hdrs.length !== 1) continue;
|
|
||||||
let cnt = 0;
|
|
||||||
[...firstLine.children].forEach(box => {
|
|
||||||
if (box.offsetWidth === 0) return;
|
|
||||||
const r = box.getBoundingClientRect();
|
|
||||||
const cx = r.x + r.width / 2;
|
|
||||||
if (cx >= hdrs[0].x && cx < hdrs[0].right) cnt++;
|
|
||||||
});
|
|
||||||
if (cnt > 1) {
|
|
||||||
const base = hdrs[0];
|
|
||||||
const baseIdx = columns.indexOf(base);
|
|
||||||
columns.splice(baseIdx, 1);
|
|
||||||
for (let si = 0; si < cnt; si++) {
|
|
||||||
columns.splice(baseIdx + si, 0, { text: base.text + ' ' + (si + 1), x: base.x, right: base.right, y: 0, h: 0 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (body) {
|
|
||||||
// Headerless grid — synthesize columns by colindex (single source).
|
|
||||||
synthHeaderlessColumns(grid).forEach(c => columns.push({ text: c.name, x: 0, right: 0, y: 0, h: 0 }));
|
|
||||||
}
|
|
||||||
const colNames = columns.map(c => c.text);
|
|
||||||
const rowCount = body ? body.querySelectorAll('.gridLine').length : 0;
|
|
||||||
// Visual label from group title (e.g. "Входящие:" for grid "Входящие")
|
|
||||||
const titleEl = document.getElementById(p + name + '#title_div')
|
|
||||||
|| document.getElementById(p + 'Группа' + name + '#title_div');
|
|
||||||
const label = titleEl ? (titleEl.innerText?.trim().replace(/:\\s*$/, '').replace(/\\u00a0/g, ' ') || null) : null;
|
|
||||||
return { name, columns: colNames, rowCount, ...(label ? { label } : {}) };
|
|
||||||
});
|
|
||||||
result.tables = tables;
|
|
||||||
// Backward compat: table = first grid summary
|
|
||||||
const first = tables[0];
|
|
||||||
result.table = { present: true, columns: first.columns, rowCount: first.rowCount };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Active filters (train badges above grid: *СостояниеПросмотра)
|
|
||||||
const filters = [];
|
|
||||||
document.querySelectorAll('[id^="' + p + '"].trainItem').forEach(el => {
|
|
||||||
if (el.offsetWidth === 0) return;
|
|
||||||
const titleEl = el.querySelector('.trainName');
|
|
||||||
const valueEl = el.querySelector('.trainTitle');
|
|
||||||
if (!titleEl && !valueEl) return;
|
|
||||||
const field = (titleEl?.innerText?.trim() || '').replace(/\\n/g, ' ').replace(/\\s*:$/, '').trim();
|
|
||||||
const value = valueEl?.innerText?.trim()?.replace(/\\n/g, ' ') || '';
|
|
||||||
if (field || value) filters.push({ field, value });
|
|
||||||
});
|
|
||||||
// Also check search field value
|
|
||||||
const searchInput = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')]
|
|
||||||
.find(el => el.offsetWidth > 0 && /Строк[аи]Поиска|SearchString/i.test(el.id));
|
|
||||||
if (searchInput?.value) {
|
|
||||||
filters.push({ type: 'search', value: searchInput.value });
|
|
||||||
}
|
|
||||||
if (filters.length) result.filters = filters;
|
|
||||||
|
|
||||||
// Navigation panel (FormNavigationPanel) — lives in parent page{N} container
|
|
||||||
const navigation = [];
|
|
||||||
const formEl = document.querySelector('[id^="' + p + '"]');
|
|
||||||
if (formEl) {
|
|
||||||
let pageEl = formEl.parentElement;
|
|
||||||
while (pageEl && !(pageEl.id && /^page\\d+$/.test(pageEl.id))) pageEl = pageEl.parentElement;
|
|
||||||
if (pageEl) {
|
|
||||||
pageEl.querySelectorAll('.navigationItem').forEach(el => {
|
|
||||||
if (el.offsetWidth === 0) return;
|
|
||||||
const nameEl = el.querySelector('.navigationItemName');
|
|
||||||
const text = (nameEl?.innerText?.trim() || '').replace(/\\u00a0/g, ' ');
|
|
||||||
if (!text) return;
|
|
||||||
const nav = { name: text };
|
|
||||||
if (el.classList.contains('select')) nav.active = true;
|
|
||||||
navigation.push(nav);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Iframes
|
|
||||||
let iframeCount = 0;
|
|
||||||
document.querySelectorAll('[id^="' + p + '"] iframe, iframe[id^="' + p + '"]').forEach(el => {
|
|
||||||
if (el.offsetWidth > 0 && el.offsetHeight > 0) iframeCount++;
|
|
||||||
});
|
|
||||||
if (iframeCount) result.iframes = iframeCount;
|
|
||||||
|
|
||||||
if (fields.length) result.fields = fields;
|
|
||||||
if (buttons.length) result.buttons = buttons;
|
|
||||||
if (formTabs.length) result.tabs = formTabs;
|
|
||||||
if (navigation.length) result.navigation = navigation;
|
|
||||||
if (texts.length) result.texts = texts;
|
|
||||||
if (hyperlinks.length) result.hyperlinks = hyperlinks;
|
|
||||||
|
|
||||||
// Group DCS report settings into readable format
|
|
||||||
if (result.fields) {
|
|
||||||
const dcsRe = /^(.+Элемент(\\d+))(Использование|Значение|ВидСравнения)$/;
|
|
||||||
const dcsGroups = {};
|
|
||||||
const dcsNames = new Set();
|
|
||||||
for (const f of result.fields) {
|
|
||||||
const m = f.name.match(dcsRe);
|
|
||||||
if (!m) continue;
|
|
||||||
if (!dcsGroups[m[1]]) dcsGroups[m[1]] = { _n: parseInt(m[2]) };
|
|
||||||
dcsGroups[m[1]][m[3]] = f;
|
|
||||||
dcsNames.add(f.name);
|
|
||||||
}
|
|
||||||
const dcsEntries = Object.entries(dcsGroups).sort((a, b) => a[1]._n - b[1]._n);
|
|
||||||
if (dcsEntries.length) {
|
|
||||||
result.reportSettings = dcsEntries.map(([, g]) => {
|
|
||||||
const cb = g['Использование'];
|
|
||||||
const val = g['Значение'];
|
|
||||||
if (!cb && !val) return null;
|
|
||||||
// No checkbox present (class="staticText" instead of .checkbox) — setting is always enabled
|
|
||||||
const label = (val?.label || cb?.label || val?.name || cb?.name || '').replace(/:$/, '').trim();
|
|
||||||
const s = { name: label, enabled: cb ? !!cb.value : true };
|
|
||||||
if (val) {
|
|
||||||
s.value = val.value || '';
|
|
||||||
if (val.actions && val.actions.length) s.actions = val.actions;
|
|
||||||
}
|
|
||||||
return s;
|
|
||||||
}).filter(Boolean);
|
|
||||||
result.fields = result.fields.filter(f => !dcsNames.has(f.name));
|
|
||||||
if (!result.fields.length) delete result.fields;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}`;
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
// web-test dom/edd v1.0 — DOM scripts for the #editDropDown autocomplete popup
|
|
||||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read the `#editDropDown` autocomplete popup.
|
|
||||||
*
|
|
||||||
* Returns `{ visible: false }` when EDD is absent/hidden, or
|
|
||||||
* `{ visible: true, items: [{ name, x, y }] }` with center coords suitable
|
|
||||||
* for `page.mouse.click(x, y)`.
|
|
||||||
*
|
|
||||||
* Note: `page.mouse.click` is often intercepted by `div.surface` overlays
|
|
||||||
* from DLB — prefer `clickEddItemViaDispatchScript` for those cases.
|
|
||||||
*/
|
|
||||||
export function readEddScript() {
|
|
||||||
return `(() => {
|
|
||||||
const edd = document.getElementById('editDropDown');
|
|
||||||
if (!edd || edd.offsetWidth === 0) return { visible: false };
|
|
||||||
const eddTexts = [...edd.querySelectorAll('.eddText')].filter(el => el.offsetWidth > 0);
|
|
||||||
return {
|
|
||||||
visible: true,
|
|
||||||
items: eddTexts.map(el => {
|
|
||||||
const r = el.getBoundingClientRect();
|
|
||||||
return { name: el.innerText?.trim() || '', x: r.x + r.width / 2, y: r.y + r.height / 2 };
|
|
||||||
})
|
|
||||||
};
|
|
||||||
})()`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Is the EDD popup currently visible? Returns boolean.
|
|
||||||
* Lighter than `readEddScript` when only presence matters.
|
|
||||||
*/
|
|
||||||
export function isEddVisibleScript() {
|
|
||||||
return `(() => {
|
|
||||||
const edd = document.getElementById('editDropDown');
|
|
||||||
return !!(edd && edd.offsetWidth > 0);
|
|
||||||
})()`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Click an EDD item by name via `dispatchEvent` — bypasses `div.surface`
|
|
||||||
* overlays from DLB that intercept `page.mouse.click`.
|
|
||||||
*
|
|
||||||
* Matching is fuzzy: exact (with optional `(suffix)` strip) → includes,
|
|
||||||
* normalizes ё/е and NBSP.
|
|
||||||
*
|
|
||||||
* Returns the clicked item's innerText (trimmed), or `null` when no match.
|
|
||||||
*/
|
|
||||||
export function clickEddItemViaDispatchScript(itemName) {
|
|
||||||
return `(() => {
|
|
||||||
const edd = document.getElementById('editDropDown');
|
|
||||||
if (!edd || edd.offsetWidth === 0) return null;
|
|
||||||
const ny = s => s.replace(/ё/gi, 'е').replace(/\\u00a0/g, ' ');
|
|
||||||
const target = ny(${JSON.stringify(itemName.toLowerCase())});
|
|
||||||
const items = [...edd.querySelectorAll('.eddText')].filter(el => el.offsetWidth > 0);
|
|
||||||
function clickEl(el) {
|
|
||||||
const r = el.getBoundingClientRect();
|
|
||||||
const opts = { bubbles: true, cancelable: true, clientX: r.x + r.width/2, clientY: r.y + r.height/2 };
|
|
||||||
el.dispatchEvent(new MouseEvent('mousedown', opts));
|
|
||||||
el.dispatchEvent(new MouseEvent('mouseup', opts));
|
|
||||||
el.dispatchEvent(new MouseEvent('click', opts));
|
|
||||||
return el.innerText.trim();
|
|
||||||
}
|
|
||||||
// Pass 1: exact match (prefer over partial)
|
|
||||||
for (const el of items) {
|
|
||||||
const t = ny((el.innerText?.trim() || '').toLowerCase());
|
|
||||||
if (t === target) return clickEl(el);
|
|
||||||
const stripped = t.replace(/\\s*\\([^)]*\\)\\s*$/, '');
|
|
||||||
if (stripped === target) return clickEl(el);
|
|
||||||
}
|
|
||||||
// Pass 2: partial match
|
|
||||||
for (const el of items) {
|
|
||||||
const t = ny((el.innerText?.trim() || '').toLowerCase());
|
|
||||||
if (t.includes(target) || target.includes(t.replace(/\\s*\\([^)]*\\)\\s*$/, ''))) return clickEl(el);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
})()`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Click the "Показать все" / "Show all" link in the EDD footer via
|
|
||||||
* `dispatchEvent`. Tries `.eddBottom .hyperlink` first, then falls back
|
|
||||||
* to scanning for span/div/a with the literal text.
|
|
||||||
*
|
|
||||||
* Returns boolean — whether the link was found and clicked.
|
|
||||||
*/
|
|
||||||
export function clickShowAllInEddScript() {
|
|
||||||
return `(() => {
|
|
||||||
const edd = document.getElementById('editDropDown');
|
|
||||||
if (!edd || edd.offsetWidth === 0) return false;
|
|
||||||
let el = edd.querySelector('.eddBottom .hyperlink');
|
|
||||||
if (!el || el.offsetWidth === 0) {
|
|
||||||
const candidates = [...edd.querySelectorAll('span, div, a')]
|
|
||||||
.filter(e => e.offsetWidth > 0 && e.children.length === 0);
|
|
||||||
el = candidates.find(e => {
|
|
||||||
const t = (e.innerText?.trim() || '').toLowerCase();
|
|
||||||
return t === 'показать все' || t === 'show all';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (!el) return false;
|
|
||||||
const r = el.getBoundingClientRect();
|
|
||||||
const opts = { bubbles: true, cancelable: true, clientX: r.x + r.width/2, clientY: r.y + r.height/2 };
|
|
||||||
el.dispatchEvent(new MouseEvent('mousedown', opts));
|
|
||||||
el.dispatchEvent(new MouseEvent('mouseup', opts));
|
|
||||||
el.dispatchEvent(new MouseEvent('click', opts));
|
|
||||||
return true;
|
|
||||||
})()`;
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
// web-test dom/edit-state v1.1 — focus and popup detection inside the 1C web client
|
|
||||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Is the currently focused element an INPUT (optionally TEXTAREA too)?
|
|
||||||
* Returns boolean.
|
|
||||||
*
|
|
||||||
* @param {object} [opts]
|
|
||||||
* @param {boolean} [opts.allowTextarea=false] — also return true for TEXTAREA.
|
|
||||||
*/
|
|
||||||
export function isInputFocusedScript({ allowTextarea = false } = {}) {
|
|
||||||
const cond = allowTextarea
|
|
||||||
? `f.tagName === 'INPUT' || f.tagName === 'TEXTAREA'`
|
|
||||||
: `f.tagName === 'INPUT'`;
|
|
||||||
return `(() => {
|
|
||||||
const f = document.activeElement;
|
|
||||||
return !!(f && (${cond}));
|
|
||||||
})()`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Is the currently focused INPUT/TEXTAREA inside a `.grid` ancestor?
|
|
||||||
* Used to verify grid edit-mode (active cell editor).
|
|
||||||
*
|
|
||||||
* @param {string} [gridSelector] — when given, only `true` if the focused input
|
|
||||||
* is inside that specific grid. Without it — any `.grid` ancestor counts.
|
|
||||||
*
|
|
||||||
* Returns boolean.
|
|
||||||
*/
|
|
||||||
export function isInputFocusedInGridScript(gridSelector) {
|
|
||||||
const sel = gridSelector ? JSON.stringify(gridSelector) : 'null';
|
|
||||||
return `(() => {
|
|
||||||
const f = document.activeElement;
|
|
||||||
if (!f || (f.tagName !== 'INPUT' && f.tagName !== 'TEXTAREA')) return false;
|
|
||||||
const sel = ${sel};
|
|
||||||
if (sel) {
|
|
||||||
const grid = document.querySelector(sel);
|
|
||||||
return !!(grid && grid.contains(f));
|
|
||||||
}
|
|
||||||
let n = f;
|
|
||||||
while (n) {
|
|
||||||
if (n.classList?.contains('grid')) return true;
|
|
||||||
n = n.parentElement;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
})()`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Is a calculator (`.calculate`) or calendar (`.frameCalendar`) popup visible?
|
|
||||||
* Returns `'calculator' | 'calendar' | null`.
|
|
||||||
*
|
|
||||||
* For the "popup gone" check, callers use: `!await findOpenPopup()`.
|
|
||||||
*/
|
|
||||||
export function findOpenPopupScript() {
|
|
||||||
return `(() => {
|
|
||||||
const calc = document.querySelector('.calculate');
|
|
||||||
if (calc && calc.offsetWidth > 0) return 'calculator';
|
|
||||||
const cal = document.querySelector('.frameCalendar');
|
|
||||||
if (cal && cal.offsetWidth > 0) return 'calendar';
|
|
||||||
return null;
|
|
||||||
})()`;
|
|
||||||
}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
// web-test dom/errors-stack v1.0 — DOM scripts for fetching error stack via OpenReport link.
|
|
||||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
|
||||||
//
|
|
||||||
// Path-1 flow for platform exceptions: click "Сформировать отчет об ошибке" link,
|
|
||||||
// open detailed error dialog, read textarea, close cleanup dialogs.
|
|
||||||
|
|
||||||
/** Find OpenReport link coordinates on the error modal for given formNum. */
|
|
||||||
export function getOpenReportCoordsScript(formNum) {
|
|
||||||
return `(() => {
|
|
||||||
const el = document.getElementById('form${formNum}_OpenReport#text');
|
|
||||||
if (!el || el.offsetWidth <= 2) return null;
|
|
||||||
const rect = el.getBoundingClientRect();
|
|
||||||
return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 };
|
|
||||||
})()`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Check whether the "подробный текст ошибки" link is visible (signals report dialog ready). */
|
|
||||||
export function isErrorDetailLinkVisibleScript() {
|
|
||||||
return `(() => {
|
|
||||||
const links = document.querySelectorAll('a, [class*="hyper"], span');
|
|
||||||
for (const el of links) {
|
|
||||||
if (el.offsetWidth > 0 && el.textContent.includes('подробный текст ошибки')) return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
})()`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Read the largest visible non-empty textarea — contains the detailed error stack. */
|
|
||||||
export function readLargestVisibleTextareaScript() {
|
|
||||||
return `(() => {
|
|
||||||
let best = null;
|
|
||||||
document.querySelectorAll('textarea').forEach(ta => {
|
|
||||||
if (ta.offsetWidth > 0 && ta.value.length > 0) {
|
|
||||||
if (!best || ta.value.length > best.value.length) best = ta;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return best?.value || null;
|
|
||||||
})()`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Click the OK button in the topmost cloud window (closes "Подробный текст ошибки"). */
|
|
||||||
export function clickTopCloudOkButtonScript() {
|
|
||||||
return `(() => {
|
|
||||||
const psWins = [...document.querySelectorAll('[id^="ps"][id$="win"]')]
|
|
||||||
.filter(w => w.offsetWidth > 0)
|
|
||||||
.sort((a, b) => parseInt(b.style?.zIndex || '0') - parseInt(a.style?.zIndex || '0'));
|
|
||||||
for (const w of psWins) {
|
|
||||||
const ok = w.querySelector('button.webBtn, .pressDefault');
|
|
||||||
if (ok && ok.textContent.trim() === 'OK') { ok.click(); return true; }
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
})()`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Click the × CloseButton in the topmost visible cloud window (closes "Отчет об ошибке"). */
|
|
||||||
export function clickReportCloseButtonScript() {
|
|
||||||
return `(() => {
|
|
||||||
const psWins = [...document.querySelectorAll('[id^="ps"][id$="win"]')]
|
|
||||||
.filter(w => w.offsetWidth > 0);
|
|
||||||
for (const w of psWins) {
|
|
||||||
const closeBtn = w.querySelector('[id$="_cmd_CloseButton"]');
|
|
||||||
if (closeBtn && closeBtn.offsetWidth > 0) { closeBtn.click(); break; }
|
|
||||||
}
|
|
||||||
})()`;
|
|
||||||
}
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
// web-test dom/errors v1.0 — error/diagnostic detection (balloon, messages, modal, stateWindow)
|
|
||||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check for validation errors / diagnostics after an action.
|
|
||||||
* Detects three patterns:
|
|
||||||
* 1. Inline balloon tooltip (div.balloon with .balloonMessage)
|
|
||||||
* 2. Messages panel (div.messages with msg0, msg1... grid rows)
|
|
||||||
* 3. Modal error dialog (high-numbered form with pressDefault + static texts)
|
|
||||||
* Returns { balloon, messages[], modal } or null if no errors.
|
|
||||||
*/
|
|
||||||
export function checkErrorsScript() {
|
|
||||||
return `(() => {
|
|
||||||
const result = {};
|
|
||||||
|
|
||||||
// 1. Inline balloon tooltip
|
|
||||||
const balloon = document.querySelector('.balloon');
|
|
||||||
if (balloon && balloon.offsetWidth > 0) {
|
|
||||||
const msg = balloon.querySelector('.balloonMessage');
|
|
||||||
const title = balloon.querySelector('.balloonTitle');
|
|
||||||
if (msg) {
|
|
||||||
result.balloon = {
|
|
||||||
title: title?.innerText?.trim() || 'Ошибка',
|
|
||||||
message: msg.innerText?.trim() || ''
|
|
||||||
};
|
|
||||||
// Count navigation arrows to indicate total errors
|
|
||||||
const fwd = balloon.querySelector('.balloonJumpFwd');
|
|
||||||
const back = balloon.querySelector('.balloonJumpBack');
|
|
||||||
const fwdDisabled = fwd?.classList.contains('disabled');
|
|
||||||
const backDisabled = back?.classList.contains('disabled');
|
|
||||||
if (fwd && !fwdDisabled) result.balloon.hasNext = true;
|
|
||||||
if (back && !backDisabled) result.balloon.hasPrev = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Messages panel (div.messages — pick visible one, multiple may exist across tabs)
|
|
||||||
const msgPanels = [...document.querySelectorAll('.messages')].filter(el => el.offsetWidth > 0);
|
|
||||||
for (const msgPanel of msgPanels) {
|
|
||||||
const msgs = [];
|
|
||||||
msgPanel.querySelectorAll('[id^="msg"]').forEach(line => {
|
|
||||||
if (line.offsetWidth === 0) return;
|
|
||||||
const textEl = line.querySelector('.gridBoxText');
|
|
||||||
const text = (textEl || line).innerText?.trim();
|
|
||||||
if (text) msgs.push(text);
|
|
||||||
});
|
|
||||||
if (msgs.length > 0) { result.messages = msgs; break; }
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3+4. Modal dialogs: confirmation (multiple buttons) or error (single pressDefault)
|
|
||||||
// Uses form container ancestry to group buttons — pressButton elements often lack form-prefixed IDs
|
|
||||||
// Note: 1C shows some modals WITHOUT #modalSurface (e.g. "Не удалось записать" uses ps*win floating window)
|
|
||||||
// so we always scan for small forms with button patterns, regardless of modalSurface state
|
|
||||||
const formButtons = {};
|
|
||||||
[...document.querySelectorAll('a.press.pressButton')].forEach(btn => {
|
|
||||||
if (btn.offsetWidth === 0) return;
|
|
||||||
const container = btn.closest('[id$="_container"]');
|
|
||||||
const m = container?.id?.match(/^form(\\d+)_/);
|
|
||||||
if (!m) return;
|
|
||||||
const fn = m[1];
|
|
||||||
if (!formButtons[fn]) formButtons[fn] = [];
|
|
||||||
formButtons[fn].push(btn);
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const [fn, buttons] of Object.entries(formButtons)) {
|
|
||||||
const p = 'form' + fn + '_';
|
|
||||||
const elCount = document.querySelectorAll('[id^="' + p + '"]').length;
|
|
||||||
if (elCount > 100) continue; // Skip large content forms
|
|
||||||
if (buttons.length > 1) {
|
|
||||||
// Confirmation dialog (multiple buttons: Да/Нет, OK/Отмена, etc.)
|
|
||||||
// Must have a Message element — real 1C confirmations always have form{N}_Message.
|
|
||||||
// Without it, this is just a regular form with multiple buttons (e.g. EPF form).
|
|
||||||
const msgEl = document.getElementById(p + 'Message');
|
|
||||||
if (!msgEl || msgEl.offsetWidth === 0) continue;
|
|
||||||
const message = msgEl.innerText?.trim() || '';
|
|
||||||
const btnNames = buttons.map(el => {
|
|
||||||
const b = { name: el.innerText?.trim() || '' };
|
|
||||||
if (el.classList.contains('pressDefault')) b.default = true;
|
|
||||||
return b;
|
|
||||||
}).filter(b => b.name);
|
|
||||||
result.confirmation = { message, buttons: btnNames.map(b => b.name), formNum: parseInt(fn) };
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Single-button modal: error dialog with pressDefault + staticText
|
|
||||||
// Skip forms with input fields — those are data entry forms (e.g. register record),
|
|
||||||
// not error dialogs. Real error modals only have staticText + buttons.
|
|
||||||
if (!result.confirmation) {
|
|
||||||
for (const [fn, buttons] of Object.entries(formButtons)) {
|
|
||||||
const p = 'form' + fn + '_';
|
|
||||||
const elCount = document.querySelectorAll('[id^="' + p + '"]').length;
|
|
||||||
if (elCount > 100) continue;
|
|
||||||
if (buttons.length !== 1 || !buttons[0].classList.contains('pressDefault')) continue;
|
|
||||||
const hasInputs = document.querySelectorAll('input.editInput[id^="' + p + '"], textarea[id^="' + p + '"]').length > 0;
|
|
||||||
if (hasInputs) continue;
|
|
||||||
const texts = [...document.querySelectorAll('[id^="' + p + '"].staticText')]
|
|
||||||
.filter(el => el.offsetWidth > 0)
|
|
||||||
.map(el => el.innerText?.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
if (texts.length > 0) {
|
|
||||||
result.modal = { message: texts.join(' '), formNum: parseInt(fn), button: buttons[0].innerText?.trim() || '' };
|
|
||||||
// Check if OpenReport link is available (platform exceptions have visible link text)
|
|
||||||
const reportLink = document.getElementById(p + 'OpenReport#text');
|
|
||||||
if (reportLink && reportLink.offsetWidth > 2 && reportLink.textContent.trim()) {
|
|
||||||
result.modal.hasReport = true;
|
|
||||||
}
|
|
||||||
// Grab AdditionalInfo/ServerText if filled (may contain extra error details)
|
|
||||||
const addInfo = document.getElementById(p + 'AdditionalInfo');
|
|
||||||
if (addInfo && addInfo.textContent && addInfo.textContent.trim()) result.modal.additionalInfo = addInfo.textContent.trim();
|
|
||||||
const srvText = document.getElementById(p + 'ServerText');
|
|
||||||
if (srvText && srvText.textContent && srvText.textContent.trim()) result.modal.serverText = srvText.textContent.trim();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. SpreadsheetDocument state window (info bar inside moxelContainer)
|
|
||||||
// Shows messages like "Не установлено значение параметра X" or "Отчет не сформирован"
|
|
||||||
const stateWins = [...document.querySelectorAll('.stateWindowSupportSurface')].filter(el => el.offsetWidth > 0);
|
|
||||||
if (stateWins.length) {
|
|
||||||
const texts = stateWins.map(el => el.innerText?.trim()).filter(Boolean);
|
|
||||||
if (texts.length) result.stateText = texts;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (result.balloon || result.messages || result.modal || result.confirmation || result.stateText) ? result : null;
|
|
||||||
})()`;
|
|
||||||
}
|
|
||||||
@@ -1,187 +0,0 @@
|
|||||||
// web-test dom/filter v1.0 — DOM scripts for filterList / unfilterList
|
|
||||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find the first grid cell on the form and return its center coords.
|
|
||||||
* Used as a fallback target for Alt+F when there's no search input.
|
|
||||||
*
|
|
||||||
* Returns `{ x, y } | null`.
|
|
||||||
*/
|
|
||||||
export function findFirstGridCellCoordsScript(formNum) {
|
|
||||||
return `(() => {
|
|
||||||
const p = 'form${formNum}_';
|
|
||||||
const grid = [...document.querySelectorAll('[id^="' + p + '"].grid, [id^="' + p + '"] .grid')]
|
|
||||||
.find(g => g.offsetWidth > 0);
|
|
||||||
if (!grid) return null;
|
|
||||||
const rows = [...grid.querySelectorAll('.gridBody .gridLine')];
|
|
||||||
if (!rows.length) return null;
|
|
||||||
const cells = [...rows[0].querySelectorAll('.gridBox')];
|
|
||||||
if (!cells.length) return null;
|
|
||||||
const r = cells[0].getBoundingClientRect();
|
|
||||||
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
|
|
||||||
})()`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find the grid cell of the first row in the column whose header text matches `field`
|
|
||||||
* (fuzzy: exact → startsWith → includes; normalizes ё/е and NBSP).
|
|
||||||
*
|
|
||||||
* If the column isn't in the grid, returns coords of the first cell + `needDlb: true`
|
|
||||||
* so the caller can use DLB to switch FieldSelector after opening the dialog.
|
|
||||||
*
|
|
||||||
* Returns:
|
|
||||||
* - `{ x, y, needDlb? } ` — coords to click (advanced search target)
|
|
||||||
* - `{ error }` — `'no_grid' | 'no_rows' | 'no_cells' | 'cell_not_found'`
|
|
||||||
*/
|
|
||||||
export function findColumnFirstCellCoordsScript(formNum, field) {
|
|
||||||
return `(() => {
|
|
||||||
const p = 'form${formNum}_';
|
|
||||||
const grid = [...document.querySelectorAll('[id^="' + p + '"].grid, [id^="' + p + '"] .grid')]
|
|
||||||
.find(g => g.offsetWidth > 0);
|
|
||||||
if (!grid) return { error: 'no_grid' };
|
|
||||||
const targetField = ${JSON.stringify(field)};
|
|
||||||
const headers = [...grid.querySelectorAll('.gridHead .gridBox')];
|
|
||||||
let colIndex = -1;
|
|
||||||
let startsWithIdx = -1;
|
|
||||||
let includesIdx = -1;
|
|
||||||
for (let i = 0; i < headers.length; i++) {
|
|
||||||
const t = headers[i].innerText?.trim().replace(/\\u00a0/g, ' ');
|
|
||||||
if (!t) continue;
|
|
||||||
const ny = s => s.replace(/ё/gi, 'е').replace(/\\u00a0/g, ' ');
|
|
||||||
const tl = ny(t.toLowerCase()), fl = ny(targetField.toLowerCase());
|
|
||||||
if (tl === fl) { colIndex = i; break; }
|
|
||||||
if (startsWithIdx < 0 && tl.startsWith(fl)) { startsWithIdx = i; }
|
|
||||||
else if (includesIdx < 0 && tl.includes(fl)) { includesIdx = i; }
|
|
||||||
}
|
|
||||||
if (colIndex < 0) colIndex = startsWithIdx >= 0 ? startsWithIdx : includesIdx;
|
|
||||||
const rows = [...grid.querySelectorAll('.gridBody .gridLine')];
|
|
||||||
if (!rows.length) return { error: 'no_rows' };
|
|
||||||
if (colIndex < 0) {
|
|
||||||
const cells = [...rows[0].querySelectorAll('.gridBox')];
|
|
||||||
if (!cells.length) return { error: 'no_cells' };
|
|
||||||
const r = cells[0].getBoundingClientRect();
|
|
||||||
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), needDlb: true };
|
|
||||||
}
|
|
||||||
const cells = [...rows[0].querySelectorAll('.gridBox')];
|
|
||||||
if (colIndex >= cells.length) return { error: 'cell_not_found' };
|
|
||||||
const r = cells[colIndex].getBoundingClientRect();
|
|
||||||
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
|
|
||||||
})()`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read FieldSelector input + its DLB button coords on the advanced search dialog.
|
|
||||||
* Returns `{ current, dlbX, dlbY }` (zero coords if DLB not visible).
|
|
||||||
*/
|
|
||||||
export function readFieldSelectorInfoScript(dialogForm) {
|
|
||||||
return `(() => {
|
|
||||||
const p = 'form' + ${JSON.stringify(String(dialogForm))} + '_';
|
|
||||||
const fsInput = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')]
|
|
||||||
.find(el => el.offsetWidth > 0 && /FieldSelector/i.test(el.id));
|
|
||||||
const dlb = document.getElementById(p + 'FieldSelector_DLB');
|
|
||||||
return {
|
|
||||||
current: fsInput?.value?.trim() || '',
|
|
||||||
dlbX: dlb && dlb.offsetWidth > 0 ? Math.round(dlb.getBoundingClientRect().x + dlb.getBoundingClientRect().width / 2) : 0,
|
|
||||||
dlbY: dlb && dlb.offsetWidth > 0 ? Math.round(dlb.getBoundingClientRect().y + dlb.getBoundingClientRect().height / 2) : 0
|
|
||||||
};
|
|
||||||
})()`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pick a field name in the FieldSelector EDD dropdown (fuzzy: exact → includes,
|
|
||||||
* normalizes ё/е and NBSP).
|
|
||||||
*
|
|
||||||
* Returns:
|
|
||||||
* - `{ x, y, name }` — coords + matched name to click
|
|
||||||
* - `{ error, available? }` — `'no_dropdown'` or `'field_not_found'` with list of available names
|
|
||||||
*/
|
|
||||||
export function pickFieldInSelectorDropdownScript(field) {
|
|
||||||
return `(() => {
|
|
||||||
const edd = document.getElementById('editDropDown');
|
|
||||||
if (!edd || edd.offsetWidth === 0) return { error: 'no_dropdown' };
|
|
||||||
const ny = s => s.replace(/ё/gi, 'е').replace(/\\u00a0/g, ' ');
|
|
||||||
const target = ny(${JSON.stringify(field.toLowerCase())});
|
|
||||||
const items = [...edd.querySelectorAll('div')].filter(el =>
|
|
||||||
el.offsetWidth > 0 && el.innerText?.trim() && !el.innerText.includes('\\n'));
|
|
||||||
const match = items.find(el => ny(el.innerText.trim().toLowerCase()) === target)
|
|
||||||
|| items.find(el => ny(el.innerText.trim().toLowerCase()).includes(target));
|
|
||||||
if (!match) return { error: 'field_not_found', available: items.map(el => el.innerText.trim()) };
|
|
||||||
const r = match.getBoundingClientRect();
|
|
||||||
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), name: match.innerText.trim() };
|
|
||||||
})()`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read advanced search dialog state — FieldSelector value, Pattern input id+value,
|
|
||||||
* and field type flags (isDate via iCalendB button, isRef via iDLB button on Pattern).
|
|
||||||
*
|
|
||||||
* Returns `{ fieldSelector, patternValue, patternId, isDate, isRef }`.
|
|
||||||
*/
|
|
||||||
export function readFilterDialogInfoScript(dialogForm) {
|
|
||||||
return `(() => {
|
|
||||||
const p = 'form' + ${JSON.stringify(String(dialogForm))} + '_';
|
|
||||||
const fsInput = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')]
|
|
||||||
.find(el => el.offsetWidth > 0 && /FieldSelector/i.test(el.id));
|
|
||||||
const ptInput = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')]
|
|
||||||
.find(el => el.offsetWidth > 0 && /Pattern/i.test(el.id));
|
|
||||||
const ptLabel = ptInput?.closest('label');
|
|
||||||
const btns = ptLabel ? [...ptLabel.querySelectorAll('span.btn')].map(b => b.className) : [];
|
|
||||||
const isDate = btns.some(c => c.includes('iCalendB'));
|
|
||||||
const isRef = !isDate && btns.some(c => c.includes('iDLB'));
|
|
||||||
return {
|
|
||||||
fieldSelector: fsInput?.value?.trim() || '',
|
|
||||||
patternValue: ptInput?.value?.trim() || '',
|
|
||||||
patternId: ptInput?.id || '',
|
|
||||||
isDate,
|
|
||||||
isRef
|
|
||||||
};
|
|
||||||
})()`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find the × close button on the filter badge whose title matches `field`
|
|
||||||
* (exact → includes; normalizes ё/е and NBSP).
|
|
||||||
*
|
|
||||||
* Returns:
|
|
||||||
* - `{ x, y, field }` — coords + actual field title from the badge
|
|
||||||
* - `{ error, available }` — `'not_found'` with list of available badge titles
|
|
||||||
*/
|
|
||||||
export function findFilterBadgeCloseScript(formNum, field) {
|
|
||||||
return `(() => {
|
|
||||||
const p = 'form${formNum}_';
|
|
||||||
const norm = s => s?.trim().replace(/\\u00a0/g, ' ').replace(/:$/, '').replace(/\\n/g, ' ') || '';
|
|
||||||
const ny = s => s.replace(/ё/gi, 'е').replace(/\\u00a0/g, ' ');
|
|
||||||
const target = ny(${JSON.stringify(field.toLowerCase())});
|
|
||||||
const items = [...document.querySelectorAll('[id^="' + p + '"].trainItem')].filter(el => el.offsetWidth > 0);
|
|
||||||
for (const item of items) {
|
|
||||||
const titleEl = item.querySelector('.trainName');
|
|
||||||
const title = ny(norm(titleEl?.innerText).toLowerCase());
|
|
||||||
if (title === target || title.includes(target)) {
|
|
||||||
const close = item.querySelector('.trainClose');
|
|
||||||
if (close) {
|
|
||||||
const r = close.getBoundingClientRect();
|
|
||||||
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), field: norm(titleEl?.innerText) };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const available = items.map(item => norm(item.querySelector('.trainName')?.innerText));
|
|
||||||
return { error: 'not_found', available };
|
|
||||||
})()`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find the × close button on the FIRST visible filter badge (for clear-all loop).
|
|
||||||
* Returns `{ x, y } | null`.
|
|
||||||
*/
|
|
||||||
export function findFirstFilterBadgeCloseScript(formNum) {
|
|
||||||
return `(() => {
|
|
||||||
const p = 'form${formNum}_';
|
|
||||||
const item = [...document.querySelectorAll('[id^="' + p + '"].trainItem')]
|
|
||||||
.find(el => el.offsetWidth > 0);
|
|
||||||
if (!item) return null;
|
|
||||||
const close = item.querySelector('.trainClose');
|
|
||||||
if (!close) return null;
|
|
||||||
const r = close.getBoundingClientRect();
|
|
||||||
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
|
|
||||||
})()`;
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
// web-test dom/form-state v1.0 — combined detectForm + readForm + open tabs
|
|
||||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
|
||||||
import { DETECT_FORM_FN, DETECT_FORMS_FN, READ_FORM_FN } from './_shared.mjs';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Combined: detect form + read form + read open tabs.
|
|
||||||
* Single evaluate call instead of 3. Used by browser.getFormState().
|
|
||||||
*/
|
|
||||||
export function getFormStateScript() {
|
|
||||||
return `(() => {
|
|
||||||
${DETECT_FORM_FN}
|
|
||||||
${DETECT_FORMS_FN}
|
|
||||||
${READ_FORM_FN}
|
|
||||||
const formNum = detectForm();
|
|
||||||
const meta = detectForms();
|
|
||||||
if (formNum === null) return { form: null, formCount: 0, message: 'No form detected' };
|
|
||||||
const p = 'form' + formNum + '_';
|
|
||||||
const formData = readForm(p);
|
|
||||||
// Open tabs bar (present only when tab panel is enabled in 1C settings)
|
|
||||||
const openTabs = [];
|
|
||||||
document.querySelectorAll('[id^="openedCell_cmd_"]').forEach(el => {
|
|
||||||
const text = el.innerText?.trim();
|
|
||||||
if (!text) return;
|
|
||||||
const entry = { name: text };
|
|
||||||
if (el.classList.contains('select')) entry.active = true;
|
|
||||||
openTabs.push(entry);
|
|
||||||
});
|
|
||||||
const activeTab = openTabs.find(t => t.active)?.name || null;
|
|
||||||
const result = { form: formNum, activeTab, openForms: meta.allForms, formCount: meta.formCount, ...formData };
|
|
||||||
if (meta.modal) result.modal = true;
|
|
||||||
if (openTabs.length) result.openTabs = openTabs;
|
|
||||||
return result;
|
|
||||||
})()`;
|
|
||||||
}
|
|
||||||
@@ -1,653 +0,0 @@
|
|||||||
// web-test dom/forms v1.7 — form detection, content read, click-target/field-button resolution
|
|
||||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
|
||||||
import { DETECT_FORM_FN, READ_FORM_FN, ROW_CLICK_POINT_FN } from './_shared.mjs';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Detect the active form number.
|
|
||||||
* Picks the form with the most visible elements (excluding form0 = home page).
|
|
||||||
*/
|
|
||||||
export function detectFormScript() {
|
|
||||||
return `(() => {
|
|
||||||
${DETECT_FORM_FN}
|
|
||||||
return detectForm();
|
|
||||||
})()`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read full form state for a given form number.
|
|
||||||
* Uses shared READ_FORM_FN.
|
|
||||||
*/
|
|
||||||
export function readFormScript(formNum) {
|
|
||||||
const p = `form${formNum}_`;
|
|
||||||
return `(() => {
|
|
||||||
${READ_FORM_FN}
|
|
||||||
return readForm(${JSON.stringify(p)});
|
|
||||||
})()`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find a clickable element on the current form (button, hyperlink, tab, frame button).
|
|
||||||
* Returns { id, kind, name } for Playwright page.click(), or { error, available }.
|
|
||||||
* Supports synonym matching: visible text AND internal name from DOM ID.
|
|
||||||
* Fuzzy order: exact name -> exact label -> includes name -> includes label.
|
|
||||||
*/
|
|
||||||
export function findClickTargetScript(formNum, text, { tableName, gridSelector } = {}) {
|
|
||||||
const p = `form${formNum}_`;
|
|
||||||
return `(() => {
|
|
||||||
${ROW_CLICK_POINT_FN}
|
|
||||||
const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е');
|
|
||||||
const target = ${JSON.stringify(text.toLowerCase().replace(/ё/g, 'е'))};
|
|
||||||
const p = ${JSON.stringify(p)};
|
|
||||||
const tableName = ${JSON.stringify(tableName || '')};
|
|
||||||
const gridSelector = ${JSON.stringify(gridSelector || '')};
|
|
||||||
const items = [];
|
|
||||||
|
|
||||||
// Buttons (a.press)
|
|
||||||
[...document.querySelectorAll('a.press[id^="' + p + '"]')].filter(el => el.offsetWidth > 0).forEach(el => {
|
|
||||||
const idName = el.id.replace(p, '');
|
|
||||||
if (/_(?:DLB|CLR|OB|CB)$/.test(idName)) return;
|
|
||||||
const span = el.querySelector('.submenuText') || el.querySelector('span');
|
|
||||||
const text = norm(span?.textContent) || norm(el.innerText);
|
|
||||||
if (!text && !el.classList.contains('pressCommand')) return;
|
|
||||||
const isSubmenu = /^(?:Подменю|allActions)/i.test(idName);
|
|
||||||
const item = { id: el.id, name: text || idName, label: idName, kind: isSubmenu ? 'submenu' : 'button' };
|
|
||||||
// Icon-only buttons: use tooltip for fuzzy match (1C puts title on parent .framePress)
|
|
||||||
if (!text) { const tip = norm(el.title || el.parentElement?.title || ''); if (tip) item.tooltip = tip; }
|
|
||||||
items.push(item);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Hyperlinks (staticTextHyper)
|
|
||||||
[...document.querySelectorAll('[id^="' + p + '"].staticTextHyper')].filter(el => el.offsetWidth > 0).forEach(el => {
|
|
||||||
const idName = el.id.replace(p, '');
|
|
||||||
const text = norm(el.innerText);
|
|
||||||
items.push({ id: el.id, name: text, label: idName, kind: 'hyperlink' });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Frame buttons
|
|
||||||
[...document.querySelectorAll('[id^="' + p + '"] .frameButton, [id^="' + p + '"].frameButton')].filter(el => el.offsetWidth > 0).forEach(el => {
|
|
||||||
const text = norm(el.innerText);
|
|
||||||
const idName = el.id.replace(p, '');
|
|
||||||
if (!text && !idName) return;
|
|
||||||
items.push({ id: el.id, name: text || idName, label: text ? '' : idName, kind: 'frameButton' });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Tumbler items (toggle switch segments)
|
|
||||||
[...document.querySelectorAll('[id^="' + p + '"].tumblerItem')].filter(el => el.offsetWidth > 0).forEach(el => {
|
|
||||||
const idName = el.id.replace(p, '');
|
|
||||||
const text = norm(el.innerText);
|
|
||||||
items.push({ id: el.id, name: text || idName, label: idName, kind: 'tumbler' });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Checkboxes (div.checkbox) — match by label or internal name
|
|
||||||
[...document.querySelectorAll('[id^="' + p + '"].checkbox')].filter(el => el.offsetWidth > 0).forEach(el => {
|
|
||||||
const idName = el.id.replace(p, '');
|
|
||||||
const titleEl = document.getElementById(p + idName + '#title_text');
|
|
||||||
const label = norm(titleEl?.innerText || '').replace(/:/g, '').trim();
|
|
||||||
items.push({ id: el.id, name: label || idName, label: idName, kind: 'checkbox' });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Tabs (scoped to form)
|
|
||||||
[...document.querySelectorAll('[data-content]')].filter(el => {
|
|
||||||
if (el.offsetWidth === 0) return false;
|
|
||||||
let node = el.parentElement;
|
|
||||||
while (node) {
|
|
||||||
if (node.id && node.id.startsWith(p)) return true;
|
|
||||||
node = node.parentElement;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}).forEach(el => {
|
|
||||||
const r = el.getBoundingClientRect();
|
|
||||||
items.push({ id: el.id, name: el.dataset.content, label: '', kind: 'tab',
|
|
||||||
x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Navigation panel items (FormNavigationPanel) — in parent page{N}
|
|
||||||
const formEl = document.querySelector('[id^="' + p + '"]');
|
|
||||||
if (formEl) {
|
|
||||||
let pageEl = formEl.parentElement;
|
|
||||||
while (pageEl && !(pageEl.id && /^page\\d+$/.test(pageEl.id))) pageEl = pageEl.parentElement;
|
|
||||||
if (pageEl) {
|
|
||||||
pageEl.querySelectorAll('.navigationItem').forEach(el => {
|
|
||||||
if (el.offsetWidth === 0) return;
|
|
||||||
const nameEl = el.querySelector('.navigationItemName');
|
|
||||||
const text = norm(nameEl?.innerText || '');
|
|
||||||
if (!text) return;
|
|
||||||
items.push({ id: el.id, name: text, label: '', kind: 'navigation' });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// When table is specified, scope button search to grid's parent container
|
|
||||||
if (gridSelector) {
|
|
||||||
const gridEl = document.querySelector(gridSelector);
|
|
||||||
if (gridEl) {
|
|
||||||
// Find parent container that has id with formPrefix and contains the grid
|
|
||||||
let container = gridEl.parentElement;
|
|
||||||
while (container && container !== document.body) {
|
|
||||||
if (container.id && container.id.startsWith(p)) break;
|
|
||||||
container = container.parentElement;
|
|
||||||
}
|
|
||||||
// Filter items to those inside the container
|
|
||||||
const containerItems = container && container !== document.body
|
|
||||||
? items.filter(i => { const el = document.getElementById(i.id); return el && container.contains(el); })
|
|
||||||
: [];
|
|
||||||
// Try fuzzy match within container first
|
|
||||||
let cf = containerItems.find(i => i.name.toLowerCase() === target);
|
|
||||||
if (!cf) cf = containerItems.find(i => i.label && i.label.toLowerCase() === target);
|
|
||||||
if (!cf && target.length >= 4) cf = containerItems.find(i => i.name.toLowerCase().includes(target));
|
|
||||||
if (!cf && target.length >= 4) cf = containerItems.find(i => i.label && i.label.toLowerCase().includes(target));
|
|
||||||
if (cf) { const res = { id: cf.id, kind: cf.kind, name: cf.name }; if (cf.x != null) { res.x = cf.x; res.y = cf.y; } return res; }
|
|
||||||
// Fallback: filter by gridName id-prefix (e.g. ИсходящиеКоманднаяПанель_Добавить)
|
|
||||||
const gridName = gridEl.id ? gridEl.id.replace(p, '') : '';
|
|
||||||
if (gridName) {
|
|
||||||
const prefixItems = items.filter(i => i.label && i.label.includes(gridName));
|
|
||||||
let pf = prefixItems.find(i => i.name.toLowerCase() === target);
|
|
||||||
if (!pf && target.length >= 4) pf = prefixItems.find(i => i.label && i.label.toLowerCase().includes(target));
|
|
||||||
if (!pf && target.length >= 4) pf = prefixItems.find(i => i.name.toLowerCase().includes(target));
|
|
||||||
if (pf) { const res = { id: pf.id, kind: pf.kind, name: pf.name }; if (pf.x != null) { res.x = pf.x; res.y = pf.y; } return res; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Fall through to unscoped search
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fuzzy match: exact name -> exact label -> exact tooltip -> startsWith name -> startsWith label -> includes name -> includes label -> includes tooltip
|
|
||||||
// Skip includes() for short strings (< 4 chars) to avoid false positives
|
|
||||||
// e.g. "Да" matching "КомандаУстановитьВсе"
|
|
||||||
let found = items.find(i => i.name.toLowerCase() === target);
|
|
||||||
if (!found) found = items.find(i => i.label && i.label.toLowerCase() === target);
|
|
||||||
if (!found) found = items.find(i => i.tooltip && i.tooltip.toLowerCase() === target);
|
|
||||||
if (!found) found = items.find(i => i.name.toLowerCase().startsWith(target));
|
|
||||||
if (!found) found = items.find(i => i.label && i.label.toLowerCase().startsWith(target));
|
|
||||||
if (!found && target.length >= 4) found = items.find(i => i.name.toLowerCase().includes(target));
|
|
||||||
if (!found && target.length >= 4) found = items.find(i => i.label && i.label.toLowerCase().includes(target));
|
|
||||||
if (!found && target.length >= 4) found = items.find(i => i.tooltip && i.tooltip.toLowerCase().includes(target));
|
|
||||||
|
|
||||||
if (found) {
|
|
||||||
const res = { id: found.id, kind: found.kind, name: found.name };
|
|
||||||
if (found.x != null) { res.x = found.x; res.y = found.y; }
|
|
||||||
return res;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Grid rows — fallback: search in table rows (for hierarchical/tree navigation)
|
|
||||||
// Search ALL visible grids (or specific grid when table parameter is set)
|
|
||||||
let grids;
|
|
||||||
if (gridSelector) {
|
|
||||||
const g = document.querySelector(gridSelector);
|
|
||||||
grids = g ? [g] : [];
|
|
||||||
} else {
|
|
||||||
grids = [...document.querySelectorAll('[id^="' + p + '"].grid')].filter(g => g.offsetWidth > 0);
|
|
||||||
}
|
|
||||||
for (const grid of grids) {
|
|
||||||
const body = grid.querySelector('.gridBody');
|
|
||||||
if (!body) continue;
|
|
||||||
const lines = [...body.querySelectorAll('.gridLine')];
|
|
||||||
for (const line of lines) {
|
|
||||||
const textBoxes = [...line.querySelectorAll('.gridBoxText')].filter(b => b.offsetWidth > 0);
|
|
||||||
const rowTexts = textBoxes.map(b => norm(b.innerText) || '').filter(Boolean);
|
|
||||||
const firstCell = rowTexts[0]?.toLowerCase() || '';
|
|
||||||
const rowText = rowTexts.join(' ').toLowerCase();
|
|
||||||
if (firstCell === target || rowText === target || (target.length >= 4 && (firstCell.includes(target) || rowText.includes(target)))) {
|
|
||||||
const imgBox = line.querySelector('.gridBoxImg');
|
|
||||||
const isGroup = imgBox?.querySelector('.gridListH') !== null;
|
|
||||||
const isParent = imgBox?.querySelector('.gridListV') !== null;
|
|
||||||
const isTreeNode = line.querySelector('.gridBoxTree') !== null;
|
|
||||||
const hasChildren = line.querySelector('[tree="true"]') !== null;
|
|
||||||
let kind;
|
|
||||||
if (isGroup) kind = 'gridGroup';
|
|
||||||
else if (isParent) kind = 'gridParent';
|
|
||||||
else if (isTreeNode && hasChildren) kind = 'gridTreeNode';
|
|
||||||
else kind = 'gridRow';
|
|
||||||
// Click point: first visible text cell of the row, NOT the row-line centre.
|
|
||||||
// A wide multi-column row's centre lands beyond the form's viewport (e.g. on
|
|
||||||
// narrow modal selection forms) so mouse.click misses the row. See ROW_CLICK_POINT_FN.
|
|
||||||
const pt = rowClickPoint(line, body);
|
|
||||||
const r = line.getBoundingClientRect();
|
|
||||||
return { id: '', kind, name: rowTexts[0] || '', gridId: grid.id,
|
|
||||||
x: pt ? pt.x : Math.round(r.x + r.width / 2),
|
|
||||||
y: pt ? pt.y : Math.round(r.y + r.height / 2) };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Form input fields — LAST resort: focus a field by name/label without changing its value.
|
|
||||||
// Only when no table scope is given ("если нет уточнения таблицы"): grid cells are handled elsewhere.
|
|
||||||
// Reached only after every clickable target (button/link/tab/nav/grid row) failed to match,
|
|
||||||
// so collisions between a field name and a real control are unlikely.
|
|
||||||
const fields = [];
|
|
||||||
if (!tableName) {
|
|
||||||
[...document.querySelectorAll('input.editInput[id^="' + p + '"], textarea[id^="' + p + '"]')].forEach(el => {
|
|
||||||
if (el.offsetWidth === 0) return;
|
|
||||||
// Skip inputs inside a grid — those are table cells, not form fields.
|
|
||||||
let n = el.parentElement; let inGrid = false;
|
|
||||||
while (n) { if (n.classList && n.classList.contains('grid')) { inGrid = true; break; } n = n.parentElement; }
|
|
||||||
if (inGrid) return;
|
|
||||||
const idName = el.id.replace(p, '').replace(/_i\\d+$/, '');
|
|
||||||
const titleEl = document.getElementById(p + idName + '#title_text') || document.getElementById(p + idName + '#title_div');
|
|
||||||
const label = norm(titleEl?.innerText || '').replace(/:/g, '').trim();
|
|
||||||
fields.push({ id: el.id, name: idName, label });
|
|
||||||
});
|
|
||||||
let ff = fields.find(f => f.label && f.label.toLowerCase() === target);
|
|
||||||
if (!ff) ff = fields.find(f => f.name.toLowerCase() === target);
|
|
||||||
if (!ff) ff = fields.find(f => f.label && f.label.toLowerCase().startsWith(target));
|
|
||||||
if (!ff) ff = fields.find(f => f.name.toLowerCase().startsWith(target));
|
|
||||||
if (!ff && target.length >= 4) ff = fields.find(f => f.label && f.label.toLowerCase().includes(target));
|
|
||||||
if (!ff && target.length >= 4) ff = fields.find(f => f.name.toLowerCase().includes(target));
|
|
||||||
if (ff) return { id: ff.id, kind: 'field', name: ff.label || ff.name };
|
|
||||||
}
|
|
||||||
|
|
||||||
const available = items.map(i => i.tooltip ? i.name + ' [' + i.tooltip + ']' : i.name).filter(Boolean);
|
|
||||||
for (const f of fields) { const nm = f.label || f.name; if (nm && !available.includes(nm)) available.push(nm); }
|
|
||||||
return { error: 'not_found', available };
|
|
||||||
})()`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find a field's action button (DLB, OB, CLR, CB) by fuzzy field name.
|
|
||||||
* Returns { fieldName, buttonId, buttonType } or { error, available }.
|
|
||||||
*/
|
|
||||||
export function findFieldButtonScript(formNum, fieldName, buttonSuffix = 'DLB') {
|
|
||||||
const p = `form${formNum}_`;
|
|
||||||
return `(() => {
|
|
||||||
const p = ${JSON.stringify(p)};
|
|
||||||
const target = ${JSON.stringify(fieldName.toLowerCase().replace(/ё/g, 'е'))};
|
|
||||||
const suffix = ${JSON.stringify(buttonSuffix)};
|
|
||||||
const allFields = [];
|
|
||||||
document.querySelectorAll('input.editInput[id^="' + p + '"], textarea[id^="' + p + '"]').forEach(el => {
|
|
||||||
if (el.offsetWidth === 0) return;
|
|
||||||
const name = el.id.replace(p, '').replace(/_i\\d+$/, '');
|
|
||||||
const titleEl = document.getElementById(p + name + '#title_text')
|
|
||||||
|| document.getElementById(p + name + '#title_div');
|
|
||||||
const label = (titleEl?.innerText?.trim() || '').replace(/\\n/g, ' ').replace(/:$/, '');
|
|
||||||
allFields.push({ name, label });
|
|
||||||
});
|
|
||||||
// Also collect checkboxes for DCS pair matching
|
|
||||||
const allCheckboxes = [];
|
|
||||||
document.querySelectorAll('[id^="' + p + '"].checkbox').forEach(el => {
|
|
||||||
if (el.offsetWidth === 0) return;
|
|
||||||
const name = el.id.replace(p, '');
|
|
||||||
const titleEl = document.getElementById(p + name + '#title_text');
|
|
||||||
const label = (titleEl?.innerText?.trim() || '').replace(/\\n/g, ' ').replace(/:$/, '');
|
|
||||||
allCheckboxes.push({ inputId: el.id, name, label });
|
|
||||||
});
|
|
||||||
// Build DCS pairs: checkbox label → paired value field
|
|
||||||
const dcsPairs = {};
|
|
||||||
for (const f of [...allFields, ...allCheckboxes]) {
|
|
||||||
const m = f.name.match(/^(.+Элемент\\d+)(Использование|Значение)$/);
|
|
||||||
if (!m) continue;
|
|
||||||
if (!dcsPairs[m[1]]) dcsPairs[m[1]] = {};
|
|
||||||
dcsPairs[m[1]][m[2]] = f;
|
|
||||||
}
|
|
||||||
let found = allFields.find(f => f.name.toLowerCase() === target);
|
|
||||||
if (!found) found = allFields.find(f => f.label && f.label.toLowerCase() === target);
|
|
||||||
if (!found) found = allFields.find(f => f.name.toLowerCase().includes(target));
|
|
||||||
if (!found) found = allFields.find(f => f.label && f.label.toLowerCase().includes(target));
|
|
||||||
// DCS pair: match checkbox or value label → resolve to paired value field
|
|
||||||
let dcsCheckbox = null;
|
|
||||||
if (!found) {
|
|
||||||
for (const pair of Object.values(dcsPairs)) {
|
|
||||||
const cb = pair['Использование'];
|
|
||||||
const val = pair['Значение'];
|
|
||||||
if (!cb || !val) continue;
|
|
||||||
const pairLabel = ((val.label || cb.label || '').replace(/:$/, '')).toLowerCase();
|
|
||||||
if (pairLabel && (pairLabel === target || pairLabel.includes(target) || target.includes(pairLabel))) {
|
|
||||||
found = val;
|
|
||||||
dcsCheckbox = cb;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!found) {
|
|
||||||
return { error: 'field_not_found', available: allFields.map(f => f.label ? f.name + ' (' + f.label + ')' : f.name) };
|
|
||||||
}
|
|
||||||
const btnId = p + found.name + '_' + suffix;
|
|
||||||
const btn = document.getElementById(btnId);
|
|
||||||
if (!btn || btn.offsetWidth === 0) {
|
|
||||||
return { error: 'button_not_found', fieldName: found.name, message: suffix + ' button not visible for field ' + found.name };
|
|
||||||
}
|
|
||||||
const result = { fieldName: found.name, buttonId: btnId, buttonType: suffix };
|
|
||||||
if (dcsCheckbox) result.dcsCheckbox = { inputId: dcsCheckbox.inputId };
|
|
||||||
return result;
|
|
||||||
})()`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve field names to element IDs for Playwright page.fill().
|
|
||||||
* Returns [{ field, inputId, name, label }] or [{ field, error, available }].
|
|
||||||
* Supports synonym matching: internal name AND visible label.
|
|
||||||
* Fuzzy order: exact name -> exact label -> includes name -> includes label.
|
|
||||||
*/
|
|
||||||
export function resolveFieldsScript(formNum, fields) {
|
|
||||||
const p = `form${formNum}_`;
|
|
||||||
return `(() => {
|
|
||||||
const p = ${JSON.stringify(p)};
|
|
||||||
const fieldNames = ${JSON.stringify(Object.keys(fields))};
|
|
||||||
const results = [];
|
|
||||||
|
|
||||||
// Build field map with name + label for synonym matching
|
|
||||||
const allFields = [];
|
|
||||||
document.querySelectorAll('input.editInput[id^="' + p + '"], textarea[id^="' + p + '"]').forEach(el => {
|
|
||||||
if (el.offsetWidth === 0) return;
|
|
||||||
const name = el.id.replace(p, '').replace(/_i\\d+$/, '');
|
|
||||||
const titleEl = document.getElementById(p + name + '#title_text')
|
|
||||||
|| document.getElementById(p + name + '#title_div');
|
|
||||||
const label = (titleEl?.innerText?.trim() || '').replace(/\\n/g, ' ').replace(/:$/, '');
|
|
||||||
const last = { inputId: el.id, name, label };
|
|
||||||
if (document.getElementById(p + name + '_DLB')?.offsetWidth > 0) last.hasSelect = true;
|
|
||||||
const cbEl = document.getElementById(p + name + '_CB');
|
|
||||||
if (cbEl?.offsetWidth > 0) {
|
|
||||||
last.hasPick = true;
|
|
||||||
if (cbEl.classList.contains('iCalendB')) last.isDate = true;
|
|
||||||
else if (cbEl.classList.contains('iCalcB')) last.isCalc = true;
|
|
||||||
}
|
|
||||||
allFields.push(last);
|
|
||||||
});
|
|
||||||
// Checkboxes
|
|
||||||
document.querySelectorAll('[id^="' + p + '"].checkbox').forEach(el => {
|
|
||||||
if (el.offsetWidth === 0) return;
|
|
||||||
const name = el.id.replace(p, '');
|
|
||||||
const titleEl = document.getElementById(p + name + '#title_text');
|
|
||||||
const label = (titleEl?.innerText?.trim() || '').replace(/\\n/g, ' ').replace(/:$/, '');
|
|
||||||
const checked = el.classList.contains('checked') || el.classList.contains('checkboxOn') || el.classList.contains('select');
|
|
||||||
allFields.push({ inputId: el.id, name, label, isCheckbox: true, checked });
|
|
||||||
});
|
|
||||||
// Radio button groups — base element = option 0, others are #N#radio
|
|
||||||
const radioSeen = new Set();
|
|
||||||
document.querySelectorAll('[id^="' + p + '"].radio').forEach(el => {
|
|
||||||
if (el.offsetWidth === 0) return;
|
|
||||||
const id = el.id.replace(p, '');
|
|
||||||
// Skip if already processed or if it's a sub-element (#N#radio)
|
|
||||||
const m = id.match(/^(.+?)#(\\d+)#radio$/);
|
|
||||||
const groupName = m ? m[1] : (!id.includes('#') ? id : null);
|
|
||||||
if (!groupName || radioSeen.has(groupName)) return;
|
|
||||||
radioSeen.add(groupName);
|
|
||||||
const titleEl = document.getElementById(p + groupName + '#title_text');
|
|
||||||
const label = (titleEl?.innerText?.trim() || '').replace(/\\n/g, ' ').replace(/:$/, '');
|
|
||||||
// Collect options: option 0 is the base element, options 1+ have #N#radio
|
|
||||||
const options = [];
|
|
||||||
// Option 0: base element
|
|
||||||
const base = document.getElementById(p + groupName);
|
|
||||||
if (base && base.classList.contains('radio') && base.offsetWidth > 0) {
|
|
||||||
const textEl = document.getElementById(p + groupName + '#0#radio_text');
|
|
||||||
options.push({ index: 0, label: textEl?.innerText?.trim() || '', selected: base.classList.contains('select') });
|
|
||||||
}
|
|
||||||
// Options 1+
|
|
||||||
for (let i = 1; i < 20; i++) {
|
|
||||||
const opt = document.getElementById(p + groupName + '#' + i + '#radio');
|
|
||||||
if (!opt || opt.offsetWidth === 0) break;
|
|
||||||
const textEl = document.getElementById(p + groupName + '#' + i + '#radio_text');
|
|
||||||
options.push({ index: i, label: textEl?.innerText?.trim() || '', selected: opt.classList.contains('select') });
|
|
||||||
}
|
|
||||||
allFields.push({ inputId: p + groupName, name: groupName, label, isRadio: true, options });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Build DCS pairs: checkbox label → paired value field
|
|
||||||
const dcsPairs = {};
|
|
||||||
for (const f of allFields) {
|
|
||||||
const m = f.name.match(/^(.+Элемент\\d+)(Использование|Значение)$/);
|
|
||||||
if (!m) continue;
|
|
||||||
if (!dcsPairs[m[1]]) dcsPairs[m[1]] = {};
|
|
||||||
dcsPairs[m[1]][m[2]] = f;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const fieldName of fieldNames) {
|
|
||||||
const target = fieldName.toLowerCase().replace(/\\n/g, ' ').replace(/:$/, '');
|
|
||||||
// Fuzzy: exact name -> exact label -> includes name -> includes label
|
|
||||||
let found = allFields.find(f => f.name.toLowerCase() === target);
|
|
||||||
if (!found) found = allFields.find(f => f.label && f.label.toLowerCase() === target);
|
|
||||||
if (!found) found = allFields.find(f => f.name.toLowerCase().includes(target));
|
|
||||||
if (!found) found = allFields.find(f => f.label && f.label.toLowerCase().includes(target));
|
|
||||||
// DCS pair: match checkbox or value label → resolve to paired value field
|
|
||||||
if (!found) {
|
|
||||||
for (const pair of Object.values(dcsPairs)) {
|
|
||||||
const cb = pair['Использование'];
|
|
||||||
const val = pair['Значение'];
|
|
||||||
if (!cb || !val) continue;
|
|
||||||
const pairLabel = ((val.label || cb.label || '').replace(/:$/, '')).toLowerCase();
|
|
||||||
if (pairLabel && (pairLabel === target || pairLabel.includes(target) || target.includes(pairLabel))) {
|
|
||||||
found = val;
|
|
||||||
found._dcsCheckbox = cb;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (found) {
|
|
||||||
const entry = { field: fieldName, inputId: found.inputId, name: found.name, label: found.label };
|
|
||||||
if (found.isCheckbox) { entry.isCheckbox = true; entry.checked = found.checked; }
|
|
||||||
if (found.isRadio) { entry.isRadio = true; entry.options = found.options; }
|
|
||||||
if (found.hasSelect) entry.hasSelect = true;
|
|
||||||
if (found.hasPick) entry.hasPick = true;
|
|
||||||
if (found.isDate) entry.isDate = true;
|
|
||||||
if (found.isCalc) entry.isCalc = true;
|
|
||||||
if (found._dcsCheckbox) {
|
|
||||||
entry.dcsCheckbox = { inputId: found._dcsCheckbox.inputId, checked: found._dcsCheckbox.checked };
|
|
||||||
delete found._dcsCheckbox;
|
|
||||||
}
|
|
||||||
results.push(entry);
|
|
||||||
} else {
|
|
||||||
const available = allFields.map(f => f.label ? f.name + ' (' + f.label + ')' : f.name);
|
|
||||||
results.push({ field: fieldName, error: 'not_found', available });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return results;
|
|
||||||
})()`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Detect a new form opened above `prevFormNum`. Two modes:
|
|
||||||
* default (broad) — counts any visible `[id]` element; finds dialogs whose
|
|
||||||
* `a.press` buttons have empty IDs. Used by selectValue / fillTableRow.
|
|
||||||
* `{ strict: true }` — only counts visible interactive elements
|
|
||||||
* (`input.editInput[id], a.press[id]`); used by fillReferenceField.
|
|
||||||
*
|
|
||||||
* Returns the highest new form number or `null`.
|
|
||||||
*/
|
|
||||||
export function detectNewFormScript(prevFormNum, { strict = false } = {}) {
|
|
||||||
const selector = strict ? 'input.editInput[id], a.press[id]' : '[id]';
|
|
||||||
const visibleCheck = strict
|
|
||||||
? 'el.offsetWidth === 0'
|
|
||||||
: 'el.offsetWidth === 0 && el.offsetHeight === 0';
|
|
||||||
return `(() => {
|
|
||||||
const forms = {};
|
|
||||||
document.querySelectorAll(${JSON.stringify(selector)}).forEach(el => {
|
|
||||||
if (${visibleCheck}) return;
|
|
||||||
const m = el.id.match(/^form(\\d+)_/);
|
|
||||||
if (m) forms[m[1]] = true;
|
|
||||||
});
|
|
||||||
const nums = Object.keys(forms).map(Number).filter(n => n > ${prevFormNum});
|
|
||||||
return nums.length > 0 ? Math.max(...nums) : null;
|
|
||||||
})()`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find the search input on a list form (matches `SearchString` / `ПоискаСтроки` id).
|
|
||||||
* Returns `{ id, value } | null`.
|
|
||||||
*/
|
|
||||||
export function findSearchInputScript(formNum) {
|
|
||||||
return `(() => {
|
|
||||||
const p = 'form${formNum}_';
|
|
||||||
const el = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')]
|
|
||||||
.find(el => el.offsetWidth > 0 && /Строк[аи]Поиска|SearchString/i.test(el.id));
|
|
||||||
return el ? { id: el.id, value: el.value || '' } : null;
|
|
||||||
})()`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find a visible `a.press` button by its exact innerText (after trim).
|
|
||||||
* Returns `{ x, y } | null` for `page.mouse.click(x, y)`.
|
|
||||||
*
|
|
||||||
* Used for modal dialog buttons (Найти, OK) where page.click may be blocked.
|
|
||||||
*/
|
|
||||||
export function findNamedButtonScript(buttonText) {
|
|
||||||
return `(() => {
|
|
||||||
const btns = [...document.querySelectorAll('a.press')].filter(el => el.offsetWidth > 0);
|
|
||||||
const btn = btns.find(el => el.innerText?.trim() === ${JSON.stringify(buttonText)});
|
|
||||||
if (!btn) return null;
|
|
||||||
const r = btn.getBoundingClientRect();
|
|
||||||
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
|
|
||||||
})()`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find a CompareType radio button by index (1 = "contains", 2 = "exact", etc.)
|
|
||||||
* on a search/filter dialog.
|
|
||||||
*
|
|
||||||
* Returns:
|
|
||||||
* - `{ already: true }` — the group is disabled OR the radio is already selected
|
|
||||||
* - `{ x, y } | null` — coords to click, or null if radio not present
|
|
||||||
*/
|
|
||||||
export function findCompareTypeRadioScript(dialogForm, radioIndex) {
|
|
||||||
return `(() => {
|
|
||||||
const p = 'form' + ${JSON.stringify(String(dialogForm))} + '_';
|
|
||||||
const group = document.getElementById(p + 'CompareType');
|
|
||||||
if (group && group.classList.contains('disabled')) return { already: true };
|
|
||||||
const el = document.getElementById(p + 'CompareType#' + ${JSON.stringify(String(radioIndex))} + '#radio');
|
|
||||||
if (!el || el.offsetWidth === 0) return null;
|
|
||||||
if (el.classList.contains('select')) return { already: true };
|
|
||||||
const r = el.getBoundingClientRect();
|
|
||||||
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
|
|
||||||
})()`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Is any element of `form{dialogForm}_` currently visible?
|
|
||||||
* Used to poll dialog dismissal after Escape.
|
|
||||||
*/
|
|
||||||
export function isFormVisibleScript(dialogForm) {
|
|
||||||
return `(() => {
|
|
||||||
const p = 'form${dialogForm}_';
|
|
||||||
return [...document.querySelectorAll('[id^="' + p + '"]')].some(el => el.offsetWidth > 0);
|
|
||||||
})()`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find the Pattern input id on a search/filter dialog. Returns `id | null`.
|
|
||||||
*/
|
|
||||||
export function findPatternInputIdScript(dialogForm) {
|
|
||||||
return `(() => {
|
|
||||||
const p = 'form${dialogForm}_';
|
|
||||||
const el = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')]
|
|
||||||
.find(el => el.offsetWidth > 0 && /Pattern/i.test(el.id));
|
|
||||||
return el ? el.id : null;
|
|
||||||
})()`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Is the given form a type selection dialog ("Выбор типа данных")?
|
|
||||||
*
|
|
||||||
* Detection signals (any one is sufficient):
|
|
||||||
* - `form{N}_OK` element exists (selection forms use "Выбрать", not "OK")
|
|
||||||
* - `form{N}_ValueList` grid exists (specific to type/value list dialogs)
|
|
||||||
* - window title contains "Выбор типа" on a visible `.toplineBoxTitle`
|
|
||||||
*
|
|
||||||
* Returns boolean.
|
|
||||||
*/
|
|
||||||
export function isTypeDialogScript(formNum) {
|
|
||||||
return `(() => {
|
|
||||||
const p = 'form' + ${formNum} + '_';
|
|
||||||
const hasOK = !!document.getElementById(p + 'OK');
|
|
||||||
const hasValueList = !!document.getElementById(p + 'ValueList');
|
|
||||||
const hasTitle = [...document.querySelectorAll('.toplineBoxTitle')]
|
|
||||||
.some(el => el.offsetWidth > 0 && /выбор типа/i.test(el.getAttribute('title') || ''));
|
|
||||||
return hasOK || hasValueList || hasTitle;
|
|
||||||
})()`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Click the "Показать все" / "Show all" link inside the "нет в списке"
|
|
||||||
* cloud popup via `dispatchEvent`. Returns boolean — whether clicked.
|
|
||||||
*/
|
|
||||||
export function clickShowAllInNotInListCloudScript() {
|
|
||||||
return `(() => {
|
|
||||||
for (const el of document.querySelectorAll('div')) {
|
|
||||||
if (el.offsetWidth === 0 || el.offsetHeight === 0) continue;
|
|
||||||
const s = getComputedStyle(el);
|
|
||||||
if (s.position !== 'absolute' && s.position !== 'fixed') continue;
|
|
||||||
if ((parseInt(s.zIndex) || 0) < 100) continue;
|
|
||||||
if (!(el.innerText || '').includes('нет в списке')) continue;
|
|
||||||
const links = [...el.querySelectorAll('a, span, div')]
|
|
||||||
.filter(e => e.offsetWidth > 0 && e.children.length === 0);
|
|
||||||
const showAll = links.find(e => {
|
|
||||||
const t = (e.innerText?.trim() || '').toLowerCase();
|
|
||||||
return t === 'показать все' || t === 'show all';
|
|
||||||
});
|
|
||||||
if (showAll) {
|
|
||||||
const r = showAll.getBoundingClientRect();
|
|
||||||
const opts = { bubbles:true, cancelable:true,
|
|
||||||
clientX: r.x + r.width/2, clientY: r.y + r.height/2 };
|
|
||||||
showAll.dispatchEvent(new MouseEvent('mousedown', opts));
|
|
||||||
showAll.dispatchEvent(new MouseEvent('mouseup', opts));
|
|
||||||
showAll.dispatchEvent(new MouseEvent('click', opts));
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
})()`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Is the "нет в списке" cloud popup visible? 1C shows it as a positioned div
|
|
||||||
* (absolute/fixed, high z-index) whose text contains "нет в списке".
|
|
||||||
* Returns boolean.
|
|
||||||
*/
|
|
||||||
export function isNotInListCloudVisibleScript() {
|
|
||||||
return `(() => {
|
|
||||||
const divs = document.querySelectorAll('div');
|
|
||||||
for (const el of divs) {
|
|
||||||
if (el.offsetWidth === 0 || el.offsetHeight === 0) continue;
|
|
||||||
const style = getComputedStyle(el);
|
|
||||||
if (style.position !== 'absolute' && style.position !== 'fixed') continue;
|
|
||||||
const z = parseInt(style.zIndex) || 0;
|
|
||||||
if (z < 100) continue;
|
|
||||||
if ((el.innerText || '').includes('нет в списке')) return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
})()`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find a child form opened above `prevFormNum` whose `form{N}_{buttonName}` button is visible.
|
|
||||||
* Used by type-dialog Ctrl+F flow to locate the "Найти" sub-dialog form number.
|
|
||||||
* Returns the form number or `null`.
|
|
||||||
*/
|
|
||||||
export function findChildFormByButtonScript(prevFormNum, buttonName, range = 20) {
|
|
||||||
return `(() => {
|
|
||||||
for (let n = ${prevFormNum} + 1; n < ${prevFormNum} + ${range}; n++) {
|
|
||||||
const btn = document.getElementById('form' + n + '_' + ${JSON.stringify(buttonName)});
|
|
||||||
if (btn && btn.offsetWidth > 0) return n;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
})()`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read visible rows of a type-dialog ValueList grid and return rows that fuzzy-match `typeNorm`.
|
|
||||||
*
|
|
||||||
* `typeNorm` should already be lowercased, NBSP-normalized, ё→е normalized (use `normYo`).
|
|
||||||
*
|
|
||||||
* Returns `{ visible: string[], matches: Array<{ text, x, y }> }`.
|
|
||||||
*/
|
|
||||||
export function readTypeDialogVisibleRowsScript(formNum, typeNorm) {
|
|
||||||
return `(() => {
|
|
||||||
const grid = document.getElementById('form${formNum}_ValueList');
|
|
||||||
if (!grid) return { visible: [], matches: [] };
|
|
||||||
const body = grid.querySelector('.gridBody');
|
|
||||||
if (!body) return { visible: [], matches: [] };
|
|
||||||
const lines = body.querySelectorAll('.gridLine');
|
|
||||||
const norm = s => (s || '').replace(/\\u00a0/g, ' ').trim();
|
|
||||||
const typeNorm = ${JSON.stringify(typeNorm)};
|
|
||||||
const visible = [];
|
|
||||||
const matches = [];
|
|
||||||
for (const line of lines) {
|
|
||||||
const text = norm(line.innerText);
|
|
||||||
if (!text) continue;
|
|
||||||
visible.push(text);
|
|
||||||
if (text.toLowerCase().replace(/ё/gi, 'е').includes(typeNorm)) {
|
|
||||||
const r = line.getBoundingClientRect();
|
|
||||||
matches.push({ text, x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { visible, matches };
|
|
||||||
})()`;
|
|
||||||
}
|
|
||||||
@@ -1,334 +0,0 @@
|
|||||||
// web-test dom/grid-edit v1.2 — DOM scripts for row-fill (grid edit-time operations)
|
|
||||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
|
||||||
//
|
|
||||||
import { HEADERLESS_GRID_FN } from './_shared.mjs';
|
|
||||||
//
|
|
||||||
// All helpers below accept an optional `gridSelector`. When passed, they target
|
|
||||||
// that exact grid; when null/undefined they pick the LAST visible `.grid` on
|
|
||||||
// the page (this matches the implicit "current grid" used by row-fill).
|
|
||||||
|
|
||||||
/** Inline JS fragment that resolves the target grid into `const grid`. */
|
|
||||||
function gridResolver(gridSelector) {
|
|
||||||
return gridSelector
|
|
||||||
? `document.querySelector(${JSON.stringify(gridSelector)})`
|
|
||||||
: `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read the grid's column header texts paired with their `colindex` attribute,
|
|
||||||
* fuzzy-match `fieldKeys` (lowercased) against them, and return the keys in
|
|
||||||
* left-to-right colindex order.
|
|
||||||
*
|
|
||||||
* Keys that don't match a column get colindex `999` (pushed to the end);
|
|
||||||
* caller is expected to preserve their original relative order.
|
|
||||||
*
|
|
||||||
* Returns `string[] | null` (null when no grid or no head).
|
|
||||||
*/
|
|
||||||
export function sortFieldKeysByColindexScript(gridSelector, fieldKeys) {
|
|
||||||
return `(() => {
|
|
||||||
${HEADERLESS_GRID_FN}
|
|
||||||
const grid = ${gridResolver(gridSelector)};
|
|
||||||
if (!grid) return null;
|
|
||||||
const head = grid.querySelector('.gridHead');
|
|
||||||
const cols = [];
|
|
||||||
if (head) {
|
|
||||||
const headLine = head.querySelector('.gridLine') || head;
|
|
||||||
[...headLine.children].forEach(box => {
|
|
||||||
if (box.offsetWidth === 0) return;
|
|
||||||
const t = ((box.querySelector('.gridBoxText') || box).innerText?.trim() || '').toLowerCase();
|
|
||||||
const ci = parseInt(box.getAttribute('colindex') || '-1');
|
|
||||||
if (t) cols.push({ text: t, colindex: ci });
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Headerless: synthesized columns (КолонкаN/(checkbox)) ordered by colindex
|
|
||||||
synthHeaderlessColumns(grid).forEach(c => cols.push({ text: c.name.toLowerCase(), colindex: parseInt(c.colindex) }));
|
|
||||||
}
|
|
||||||
const keys = ${JSON.stringify(fieldKeys)};
|
|
||||||
const mapped = keys.map(k => {
|
|
||||||
const exact = cols.find(c => c.text === k);
|
|
||||||
if (exact) return { key: k, colindex: exact.colindex };
|
|
||||||
const inc = cols.find(c => c.text.includes(k) || k.includes(c.text));
|
|
||||||
return { key: k, colindex: inc ? inc.colindex : 999 };
|
|
||||||
});
|
|
||||||
mapped.sort((a, b) => a.colindex - b.colindex);
|
|
||||||
return mapped.map(m => m.key);
|
|
||||||
})()`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve cell coords for row `row` by matching the first column whose header
|
|
||||||
* fuzzy-matches any of `fieldKeys` (lowercased). Falls back to the second
|
|
||||||
* visible (non-`.gridBoxComp`) box when no header matches.
|
|
||||||
*
|
|
||||||
* Returns one of:
|
|
||||||
* - `{ x, y, currentText }` — coords + cell text
|
|
||||||
* - `{ error: 'no_grid' | 'no_grid_body' | 'no_cell' }`
|
|
||||||
* - `{ error: 'row_out_of_range', total }`
|
|
||||||
*/
|
|
||||||
export function findCellCoordsByFieldsScript(gridSelector, row, fieldKeys) {
|
|
||||||
return `(() => {
|
|
||||||
${HEADERLESS_GRID_FN}
|
|
||||||
const grid = ${gridResolver(gridSelector)};
|
|
||||||
if (!grid) return { error: 'no_grid' };
|
|
||||||
const head = grid.querySelector('.gridHead');
|
|
||||||
const body = grid.querySelector('.gridBody');
|
|
||||||
if (!body) return { error: 'no_grid_body' };
|
|
||||||
|
|
||||||
// Read columns to find target colindex (+ subTarget for headerless split mark-boxes)
|
|
||||||
const cols = [];
|
|
||||||
if (head) {
|
|
||||||
const headLine = head.querySelector('.gridLine') || head;
|
|
||||||
[...headLine.children].forEach(box => {
|
|
||||||
if (box.offsetWidth === 0) return;
|
|
||||||
const t = box.querySelector('.gridBoxText');
|
|
||||||
const ci = box.getAttribute('colindex');
|
|
||||||
cols.push({ colindex: ci, text: ((t || box).innerText?.trim() || '').toLowerCase(), subTarget: null });
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
synthHeaderlessColumns(grid).forEach(c => cols.push({ colindex: c.colindex, text: c.name.toLowerCase(), subTarget: c.subTarget }));
|
|
||||||
}
|
|
||||||
|
|
||||||
const keys = ${JSON.stringify(fieldKeys)};
|
|
||||||
let targetColindex = null, targetSub = null;
|
|
||||||
for (const key of keys) {
|
|
||||||
const exact = cols.find(c => c.text === key);
|
|
||||||
if (exact) { targetColindex = exact.colindex; targetSub = exact.subTarget; break; }
|
|
||||||
const inc = cols.find(c => c.text.includes(key) || key.includes(c.text));
|
|
||||||
if (inc) { targetColindex = inc.colindex; targetSub = inc.subTarget; break; }
|
|
||||||
}
|
|
||||||
|
|
||||||
const rows = [...body.querySelectorAll('.gridLine')];
|
|
||||||
if (${row} >= rows.length) return { error: 'row_out_of_range', total: rows.length };
|
|
||||||
const line = rows[${row}];
|
|
||||||
|
|
||||||
// Find body cell by colindex (reliable across merged headers)
|
|
||||||
let box = null;
|
|
||||||
if (targetColindex != null) {
|
|
||||||
box = [...line.children].find(b => b.offsetWidth > 0 && b.getAttribute('colindex') === targetColindex);
|
|
||||||
}
|
|
||||||
// Fallback: second visible box (skip checkbox/N column)
|
|
||||||
if (!box) {
|
|
||||||
const boxes = [...line.children].filter(b => b.offsetWidth > 0 && !b.classList.contains('gridBoxComp'));
|
|
||||||
box = boxes.length > 1 ? boxes[1] : boxes[0];
|
|
||||||
}
|
|
||||||
if (!box) return { error: 'no_cell' };
|
|
||||||
box.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
|
||||||
// subTarget-aware cell node: 'checkbox' → box itself (row-fill then uses findCheckboxAtPoint),
|
|
||||||
// 'title' → .gridBoxTitle, else (headed default / 'text') → .gridBoxText || box.
|
|
||||||
let cell;
|
|
||||||
if (targetSub === 'checkbox') cell = box;
|
|
||||||
else if (targetSub === 'title') cell = box.querySelector('.gridBoxTitle') || box;
|
|
||||||
else cell = box.querySelector('.gridBoxText') || box;
|
|
||||||
const r = cell.getBoundingClientRect();
|
|
||||||
const currentText = (cell.innerText?.trim() || '').replace(/\\u00a0/g, ' ');
|
|
||||||
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), currentText };
|
|
||||||
})()`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Like `findCellCoordsByFieldsScript` but for a SINGLE key, with extra
|
|
||||||
* "no-space/no-dash" fuzzy fallback (e.g. "Группа Контрагентов" header matches
|
|
||||||
* key "ГруппаКонтрагентов").
|
|
||||||
*
|
|
||||||
* Returns `{ x, y, currentText } | null`.
|
|
||||||
*/
|
|
||||||
export function findNextCellCoordsByKeyScript(gridSelector, row, key) {
|
|
||||||
return `(() => {
|
|
||||||
${HEADERLESS_GRID_FN}
|
|
||||||
const grid = ${gridResolver(gridSelector)};
|
|
||||||
if (!grid) return null;
|
|
||||||
const head = grid.querySelector('.gridHead');
|
|
||||||
const body = grid.querySelector('.gridBody');
|
|
||||||
if (!body) return null;
|
|
||||||
const cols = [];
|
|
||||||
if (head) {
|
|
||||||
const headLine = head.querySelector('.gridLine') || head;
|
|
||||||
[...headLine.children].forEach(box => {
|
|
||||||
if (box.offsetWidth === 0) return;
|
|
||||||
const t = box.querySelector('.gridBoxText');
|
|
||||||
const ci = box.getAttribute('colindex');
|
|
||||||
cols.push({ colindex: ci, text: ((t || box).innerText?.trim() || '').toLowerCase(), subTarget: null });
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
synthHeaderlessColumns(grid).forEach(c => cols.push({ colindex: c.colindex, text: c.name.toLowerCase(), subTarget: c.subTarget }));
|
|
||||||
}
|
|
||||||
const kl = ${JSON.stringify(key.toLowerCase())};
|
|
||||||
const klNoSpace = kl.replace(/[\\s\\-]+/g, '');
|
|
||||||
let targetColindex = null, targetSub = null;
|
|
||||||
const exact = cols.find(c => c.text === kl);
|
|
||||||
if (exact) { targetColindex = exact.colindex; targetSub = exact.subTarget; }
|
|
||||||
else {
|
|
||||||
const inc = cols.find(c => c.text.includes(kl) || kl.includes(c.text)
|
|
||||||
|| c.text.includes(klNoSpace) || klNoSpace.includes(c.text));
|
|
||||||
if (inc) { targetColindex = inc.colindex; targetSub = inc.subTarget; }
|
|
||||||
}
|
|
||||||
if (targetColindex == null) return null;
|
|
||||||
const rows = [...body.querySelectorAll('.gridLine')];
|
|
||||||
if (${row} >= rows.length) return null;
|
|
||||||
const line = rows[${row}];
|
|
||||||
const box = [...line.children].find(b => b.offsetWidth > 0 && b.getAttribute('colindex') === targetColindex);
|
|
||||||
if (!box) return null;
|
|
||||||
box.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
|
||||||
let cell;
|
|
||||||
if (targetSub === 'checkbox') cell = box;
|
|
||||||
else if (targetSub === 'title') cell = box.querySelector('.gridBoxTitle') || box;
|
|
||||||
else cell = box.querySelector('.gridBoxText') || box;
|
|
||||||
const r = cell.getBoundingClientRect();
|
|
||||||
const currentText = (cell.innerText?.trim() || '').replace(/\\u00a0/g, ' ');
|
|
||||||
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), currentText };
|
|
||||||
})()`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Inspect the element at point `(x, y)`. If it's inside a `.gridBox` containing
|
|
||||||
* a `.checkbox`, return `{ checked, x, y }` (coords of the checkbox center for
|
|
||||||
* direct click).
|
|
||||||
*
|
|
||||||
* Returns `null` when there's no cell, or the cell isn't a checkbox cell.
|
|
||||||
*/
|
|
||||||
export function findCheckboxAtPointScript(x, y) {
|
|
||||||
return `(() => {
|
|
||||||
const el = document.elementFromPoint(${x}, ${y});
|
|
||||||
const cell = el?.closest('.gridBox');
|
|
||||||
if (!cell) return null;
|
|
||||||
const chk = cell.querySelector('.checkbox');
|
|
||||||
if (!chk) return null;
|
|
||||||
const r = chk.getBoundingClientRect();
|
|
||||||
return { checked: chk.classList.contains('select'), x: Math.round(r.x + r.width/2), y: Math.round(r.y + r.height/2) };
|
|
||||||
})()`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find center coords of the first VISIBLE non-`.gridBoxComp` cell on a row
|
|
||||||
* OTHER than `row` (used to commit an edit by clicking off the edited row —
|
|
||||||
* Escape would cancel in tree grids).
|
|
||||||
*
|
|
||||||
* For `row === 0`, targets row 1; otherwise targets row 0.
|
|
||||||
*
|
|
||||||
* Returns `{ x, y } | null` (null when there's no other row).
|
|
||||||
*/
|
|
||||||
export function findRowCommitClickCoordsScript(gridSelector, row) {
|
|
||||||
return `(() => {
|
|
||||||
const grid = ${gridResolver(gridSelector)};
|
|
||||||
if (!grid) return null;
|
|
||||||
const body = grid.querySelector('.gridBody');
|
|
||||||
if (!body) return null;
|
|
||||||
const rows = [...body.querySelectorAll('.gridLine')];
|
|
||||||
const otherIdx = ${row} === 0 ? 1 : 0;
|
|
||||||
const other = rows[otherIdx];
|
|
||||||
if (!other) return null;
|
|
||||||
const visBoxes = [...other.children].filter(b => b.offsetWidth > 0 && !b.classList.contains('gridBoxComp'));
|
|
||||||
const box = visBoxes.length > 1 ? visBoxes[1] : visBoxes[0];
|
|
||||||
if (!box) return null;
|
|
||||||
const r = box.getBoundingClientRect();
|
|
||||||
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
|
|
||||||
})()`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Diagnostic: are we in grid edit mode (active INPUT inside `.grid` or
|
|
||||||
* `.gridContent`)? Returns an OBJECT (not a boolean) suitable for diagnostics:
|
|
||||||
* - `{ inEdit: true }` — good
|
|
||||||
* - `{ inEdit: false, tag: 'DIV' }` — active element wasn't INPUT
|
|
||||||
* - `{ inEdit: false, hint: 'input not inside grid' }` — input but no grid ancestor
|
|
||||||
*/
|
|
||||||
export function getGridEditCheckScript() {
|
|
||||||
return `(() => {
|
|
||||||
const f = document.activeElement;
|
|
||||||
if (!f || f.tagName !== 'INPUT') return { inEdit: false, tag: f?.tagName };
|
|
||||||
let node = f;
|
|
||||||
while (node) {
|
|
||||||
if (node.classList?.contains('grid') || node.classList?.contains('gridContent')) return { inEdit: true };
|
|
||||||
node = node.parentElement;
|
|
||||||
}
|
|
||||||
return { inEdit: false, hint: 'input not inside grid' };
|
|
||||||
})()`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read the currently focused element if it's an editable grid cell (INPUT or
|
|
||||||
* TEXTAREA inside `.grid` / `.gridContent`). Resolves the header text by
|
|
||||||
* matching x-overlap of the input's bounding rect against header boxes.
|
|
||||||
*
|
|
||||||
* Returns one of:
|
|
||||||
* - `{ tag: 'INPUT', id, fullName, headerText }` — editable cell
|
|
||||||
* - `{ tag: 'DIV' | 'BODY' | ... }` — focused but not an editable cell
|
|
||||||
* - `{ tag: 'none' }` — nothing focused
|
|
||||||
*
|
|
||||||
* `fullName` strips both `form{N}_` prefix and `_i{M}` suffix.
|
|
||||||
*/
|
|
||||||
export function readActiveGridCellScript() {
|
|
||||||
return `(() => {
|
|
||||||
${HEADERLESS_GRID_FN}
|
|
||||||
const f = document.activeElement;
|
|
||||||
if (!f) return { tag: 'none' };
|
|
||||||
if (f.tagName === 'INPUT' || f.tagName === 'TEXTAREA') {
|
|
||||||
const inGrid = (() => { let n = f; while (n) { if (n.classList?.contains('grid') || n.classList?.contains('gridContent')) return true; n = n.parentElement; } return false; })();
|
|
||||||
if (inGrid) {
|
|
||||||
let headerText = '';
|
|
||||||
let grid = f; while (grid && !grid.classList?.contains('grid')) grid = grid.parentElement;
|
|
||||||
if (grid) {
|
|
||||||
const fr = f.getBoundingClientRect();
|
|
||||||
const head = grid.querySelector('.gridHead');
|
|
||||||
const hl = head?.querySelector('.gridLine') || head;
|
|
||||||
if (hl) for (const h of hl.children) {
|
|
||||||
if (h.offsetWidth === 0) continue;
|
|
||||||
const hr = h.getBoundingClientRect();
|
|
||||||
if (fr.x >= hr.x && fr.x < hr.x + hr.width) {
|
|
||||||
const t = h.querySelector('.gridBoxText');
|
|
||||||
headerText = (t || h).innerText?.trim() || '';
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!head) {
|
|
||||||
// Headerless: the editing INPUT is rendered in an overlay (.inputs) OUTSIDE
|
|
||||||
// the .gridBox, so walking ancestors for colindex fails. Resolve colindex by
|
|
||||||
// matching the input's x against the body cells (same idea as the headed branch).
|
|
||||||
const bl = grid.querySelector('.gridBody .gridLine');
|
|
||||||
let ci = null;
|
|
||||||
if (bl) for (const b of bl.children) {
|
|
||||||
if (b.offsetWidth === 0) continue;
|
|
||||||
const br = b.getBoundingClientRect();
|
|
||||||
if (fr.x >= br.x && fr.x < br.x + br.width) { ci = b.getAttribute('colindex'); break; }
|
|
||||||
}
|
|
||||||
if (ci != null) {
|
|
||||||
const sc = synthHeaderlessColumns(grid).find(c => c.kind === 'data' && c.colindex === ci);
|
|
||||||
if (sc) headerText = sc.name;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Classify the cell's choice button (if any): ref (_DLB), calc/date (_CB iCalcB/iCalendB),
|
|
||||||
// or bare 'choice' (_CB iCB — value picked from a programmatic list, e.g. НачалоВыбора).
|
|
||||||
let buttonKind = null;
|
|
||||||
const base = f.id.replace(/_i\\d+$/, '');
|
|
||||||
const dlb = document.getElementById(base + '_DLB');
|
|
||||||
const cb = document.getElementById(base + '_CB');
|
|
||||||
if (dlb && dlb.offsetWidth > 0) buttonKind = 'ref';
|
|
||||||
else if (cb && cb.offsetWidth > 0) {
|
|
||||||
if (cb.classList.contains('iCalcB')) buttonKind = 'calc';
|
|
||||||
else if (cb.classList.contains('iCalendB')) buttonKind = 'date';
|
|
||||||
else buttonKind = 'choice';
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
tag: 'INPUT', id: f.id,
|
|
||||||
fullName: f.id.replace(/^form\\d+_/, '').replace(/_i\\d+$/, ''),
|
|
||||||
headerText, buttonKind
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { tag: f.tagName || 'none' };
|
|
||||||
})()`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Return center coords of the element with the given id.
|
|
||||||
* Returns `{ x, y } | null`.
|
|
||||||
*/
|
|
||||||
export function getElementCenterCoordsByIdScript(elementId) {
|
|
||||||
return `(() => {
|
|
||||||
const el = document.getElementById(${JSON.stringify(elementId)});
|
|
||||||
if (!el) return null;
|
|
||||||
const r = el.getBoundingClientRect();
|
|
||||||
return { x: r.x + r.width / 2, y: r.y + r.height / 2 };
|
|
||||||
})()`;
|
|
||||||
}
|
|
||||||
@@ -1,966 +0,0 @@
|
|||||||
// web-test dom/grid v1.12 — grid resolution + table reading + edit-time helpers
|
|
||||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
|
||||||
import { ROW_CLICK_POINT_FN, HEADERLESS_GRID_FN } from './_shared.mjs';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve a specific grid by semantic name (table parameter).
|
|
||||||
* Cascade: exact gridName match → gridName contains → column contains.
|
|
||||||
* Returns { gridSelector, gridId, gridName, gridIndex, columns } or { error, available }.
|
|
||||||
*/
|
|
||||||
export function resolveGridScript(formNum, tableName) {
|
|
||||||
const p = `form${formNum}_`;
|
|
||||||
return `(() => {
|
|
||||||
const p = ${JSON.stringify(p)};
|
|
||||||
const target = ${JSON.stringify(tableName.toLowerCase().replace(/ё/g, 'е'))};
|
|
||||||
const norm = s => (s || '').replace(/ё/gi, 'е');
|
|
||||||
const allGrids = [...document.querySelectorAll('[id^="' + p + '"].grid, [id^="' + p + '"] .grid')]
|
|
||||||
.filter(g => g.offsetWidth > 0 && g.offsetHeight > 0);
|
|
||||||
if (!allGrids.length) return { error: 'no_grids', message: 'No grids found on form' };
|
|
||||||
const infos = allGrids.map((g, idx) => {
|
|
||||||
const gridId = g.id || '';
|
|
||||||
const gridName = gridId.replace(p, '');
|
|
||||||
const head = g.querySelector('.gridHead');
|
|
||||||
const columns = [];
|
|
||||||
if (head) {
|
|
||||||
const headLine = head.querySelector('.gridLine') || head;
|
|
||||||
[...headLine.children].forEach(box => {
|
|
||||||
if (box.offsetWidth === 0) return;
|
|
||||||
const textEl = box.querySelector('.gridBoxText');
|
|
||||||
const text = (textEl || box).innerText?.trim().replace(/\\n/g, ' ') || '';
|
|
||||||
if (text) columns.push(text);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// Visual label from group title element
|
|
||||||
const titleEl = document.getElementById(p + gridName + '#title_div')
|
|
||||||
|| document.getElementById(p + 'Группа' + gridName + '#title_div');
|
|
||||||
const label = titleEl ? (titleEl.innerText?.trim().replace(/:\s*$/, '').replace(/ /g, ' ') || '') : '';
|
|
||||||
return { idx, gridId, gridName, label, columns, el: g };
|
|
||||||
});
|
|
||||||
// 1. Exact gridName match (case-insensitive)
|
|
||||||
let found = infos.find(i => norm(i.gridName).toLowerCase() === target);
|
|
||||||
// 2. Exact label match
|
|
||||||
if (!found) found = infos.find(i => i.label && norm(i.label).toLowerCase() === target);
|
|
||||||
// 3. gridName contains target
|
|
||||||
if (!found) found = infos.find(i => norm(i.gridName).toLowerCase().includes(target));
|
|
||||||
// 4. Label contains target
|
|
||||||
if (!found) found = infos.find(i => i.label && norm(i.label).toLowerCase().includes(target));
|
|
||||||
// 5. Any column contains target
|
|
||||||
if (!found) found = infos.find(i => i.columns.some(c => norm(c).toLowerCase().includes(target)));
|
|
||||||
if (found) {
|
|
||||||
return {
|
|
||||||
gridSelector: found.gridId ? '#' + CSS.escape(found.gridId) : null,
|
|
||||||
gridId: found.gridId,
|
|
||||||
gridName: found.gridName,
|
|
||||||
gridIndex: found.idx,
|
|
||||||
columns: found.columns
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
error: 'not_found',
|
|
||||||
message: 'Table "' + ${JSON.stringify(tableName)} + '" not found',
|
|
||||||
available: infos.map(i => ({ name: i.gridName, ...(i.label ? { label: i.label } : {}), columns: i.columns }))
|
|
||||||
};
|
|
||||||
})()`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read table/grid data with pagination.
|
|
||||||
* Parses grid.innerText — \n separates rows, \t separates cells.
|
|
||||||
* First row = column headers.
|
|
||||||
* Returns { name, columns[], rows[{col:val}], total, offset, shown }.
|
|
||||||
*/
|
|
||||||
export function readTableScript(formNum, { maxRows = 20, offset = 0, gridSelector } = {}) {
|
|
||||||
const p = `form${formNum}_`;
|
|
||||||
return `(() => {
|
|
||||||
const p = ${JSON.stringify(p)};
|
|
||||||
const grid = ${gridSelector
|
|
||||||
? `document.querySelector(${JSON.stringify(gridSelector)})`
|
|
||||||
: `[...document.querySelectorAll('[id^="' + p + '"].grid, [id^="' + p + '"] .grid')]
|
|
||||||
.find(g => g.offsetWidth > 0 && g.offsetHeight > 0)`};
|
|
||||||
if (!grid) return { error: 'no_table', message: 'No table found on form ${formNum}' };
|
|
||||||
const name = grid.id ? grid.id.replace(p, '') : '';
|
|
||||||
|
|
||||||
// Detect a "picture value" cell: a sprite from a picture collection
|
|
||||||
// (.gridBoxImg .dIB with background-image .../pictureCollection/picture/<id>?...&gx=<N>).
|
|
||||||
// Excludes decorative tree/group markers (gridListH/gridListV/[tree]/gridBoxTree).
|
|
||||||
// Returns { gx } — the sprite frame index that encodes the cell state, or null.
|
|
||||||
function picInfo(cell) {
|
|
||||||
if (!cell) return null;
|
|
||||||
if (cell.querySelector('.gridListH, .gridListV, [tree="true"], .gridBoxTree')) return null;
|
|
||||||
const dib = cell.querySelector('.gridBoxImg .dIB');
|
|
||||||
if (!dib) return null;
|
|
||||||
const bg = dib.style.backgroundImage || '';
|
|
||||||
if (!bg.includes('pictureCollection/picture/')) return null;
|
|
||||||
const m = bg.match(/[?&]gx=(\\d+)/);
|
|
||||||
return { gx: m ? m[1] : '0' };
|
|
||||||
}
|
|
||||||
${HEADERLESS_GRID_FN}
|
|
||||||
|
|
||||||
// DOM-based parsing: gridHead → columns, gridBody → gridLine rows → gridBox cells
|
|
||||||
const head = grid.querySelector('.gridHead');
|
|
||||||
const body = grid.querySelector('.gridBody');
|
|
||||||
if (!body) {
|
|
||||||
// Fallback: innerText-based (for non-standard grids without a body)
|
|
||||||
const gText = grid.innerText?.trim() || '';
|
|
||||||
const lines = gText.split('\\n').filter(Boolean);
|
|
||||||
return { name, columns: [], rows: [], total: lines.length, offset: 0, shown: 0,
|
|
||||||
hint: 'Grid has no gridHead/gridBody structure' };
|
|
||||||
}
|
|
||||||
|
|
||||||
// HEADERLESS grid (body but no .gridHead) — synthesize columns by colindex.
|
|
||||||
// Single source: synthHeaderlessColumns. Headed path below is left untouched.
|
|
||||||
if (!head) {
|
|
||||||
const synth = synthHeaderlessColumns(grid);
|
|
||||||
const colNames = synth.map(c => c.name);
|
|
||||||
const allLines = body.querySelectorAll('.gridLine');
|
|
||||||
const total = allLines.length;
|
|
||||||
const rows = [];
|
|
||||||
const end = Math.min(${offset} + ${maxRows}, total);
|
|
||||||
for (let i = ${offset}; i < end; i++) {
|
|
||||||
const line = allLines[i];
|
|
||||||
if (!line) break;
|
|
||||||
const row = {};
|
|
||||||
const boxes = [...line.children].filter(b => b.offsetWidth > 0);
|
|
||||||
synth.forEach(c => {
|
|
||||||
const box = boxes.find(b => b.getAttribute('colindex') === c.colindex);
|
|
||||||
let val = '';
|
|
||||||
if (box) {
|
|
||||||
if (c.subTarget === 'checkbox') {
|
|
||||||
const chk = box.querySelector('.checkbox');
|
|
||||||
val = chk && chk.classList.contains('select') ? 'true' : 'false';
|
|
||||||
} else if (c.subTarget === 'title') {
|
|
||||||
val = (box.querySelector('.gridBoxTitle')?.innerText || '').trim().replace(/\\n/g, ' ');
|
|
||||||
} else if (c.subTarget === 'text') {
|
|
||||||
val = (box.querySelector('.gridBoxText')?.innerText || '').trim().replace(/\\n/g, ' ');
|
|
||||||
} else {
|
|
||||||
const pic = picInfo(box);
|
|
||||||
val = pic ? 'pic:' + pic.gx : ((box.innerText || '').trim().replace(/\\n/g, ' '));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
row[c.name] = val;
|
|
||||||
});
|
|
||||||
// Row meta — mirrors the headed path (group/parent/tree/level/selected)
|
|
||||||
const imgBox = line.querySelector('.gridBoxImg');
|
|
||||||
if (imgBox) {
|
|
||||||
if (imgBox.querySelector('.gridListH')) row._kind = 'group';
|
|
||||||
else if (imgBox.querySelector('.gridListV')) row._kind = 'parent';
|
|
||||||
}
|
|
||||||
const treeBox = line.querySelector('.gridBoxTree');
|
|
||||||
if (treeBox) {
|
|
||||||
const treeIcon = imgBox?.querySelector('[tree="true"]');
|
|
||||||
if (treeIcon) { const bg = treeIcon.style.backgroundImage || ''; row._tree = bg.includes('gx=0') ? 'expanded' : 'collapsed'; }
|
|
||||||
row._level = imgBox ? imgBox.querySelectorAll('.dIB').length - 1 : 0;
|
|
||||||
}
|
|
||||||
if (line.classList.contains('selRow') || line.classList.contains('select')) row._selected = true;
|
|
||||||
rows.push(row);
|
|
||||||
}
|
|
||||||
// hasMore — mirrors the headed path
|
|
||||||
let hasMore;
|
|
||||||
const turnsBox = document.getElementById('vertButtonScroll_' + grid.id);
|
|
||||||
if (turnsBox && turnsBox.offsetHeight > 0) {
|
|
||||||
const upBtns = turnsBox.querySelectorAll('[data-home], [data-up]');
|
|
||||||
const dnBtns = turnsBox.querySelectorAll('[data-down], [data-end]');
|
|
||||||
hasMore = { above: [...upBtns].some(b => !b.classList.contains('disabled')),
|
|
||||||
below: [...dnBtns].some(b => !b.classList.contains('disabled')) };
|
|
||||||
} else {
|
|
||||||
const vs = document.getElementById('vertScroll_' + grid.id);
|
|
||||||
if (vs && vs.classList.contains('scrollV') && vs.offsetWidth > 0) {
|
|
||||||
const back = vs.querySelector('[data-track-back]')?.offsetHeight ?? 0;
|
|
||||||
const next = vs.querySelector('[data-track-next]')?.offsetHeight ?? 0;
|
|
||||||
hasMore = { above: back > 0, below: next > 0 };
|
|
||||||
} else {
|
|
||||||
hasMore = { below: body.scrollHeight > body.clientHeight };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const isTree = !!body.querySelector('.gridBoxTree');
|
|
||||||
const hasGroups = rows.some(r => r._kind === 'group');
|
|
||||||
const result = { name, columns: colNames, rows, total, offset: ${offset}, shown: rows.length, hasMore };
|
|
||||||
if (isTree) result.viewMode = 'tree';
|
|
||||||
if (hasGroups) result.hierarchical = true;
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract column headers with X-coordinates for alignment
|
|
||||||
const columns = [];
|
|
||||||
const headLine = head.querySelector('.gridLine') || head;
|
|
||||||
[...headLine.children].forEach(box => {
|
|
||||||
if (box.offsetWidth === 0) return;
|
|
||||||
const textEl = box.querySelector('.gridBoxText');
|
|
||||||
const text = (textEl || box).innerText?.trim().replace(/\\n/g, ' ') || '';
|
|
||||||
if (!text) {
|
|
||||||
// Unnamed column — check if data cells contain checkboxes or pictures.
|
|
||||||
// Picture columns have no header text (only an icon + a title tooltip); 1С
|
|
||||||
// doesn't expose the technical column name in the DOM, so we name them by
|
|
||||||
// the header's title attribute, falling back to '(picture)'.
|
|
||||||
const firstLine = body?.querySelector('.gridLine');
|
|
||||||
const visibleHeaders = [...headLine.children].filter(c => c.offsetWidth > 0);
|
|
||||||
const idx = visibleHeaders.indexOf(box);
|
|
||||||
const cells = firstLine ? [...firstLine.children].filter(c => c.offsetWidth > 0) : [];
|
|
||||||
const r = box.getBoundingClientRect();
|
|
||||||
if (cells[idx]?.querySelector('.checkbox')) {
|
|
||||||
columns.push({ text: '(checkbox)', x: r.x, w: r.width, right: r.x + r.width, y: r.y, h: r.height });
|
|
||||||
} else if (picInfo(box) || picInfo(cells[idx])) {
|
|
||||||
let title = (box.getAttribute('title') || '').trim() || '(picture)';
|
|
||||||
// Disambiguate duplicate picture-column names with a numeric suffix.
|
|
||||||
if (columns.some(c => c.text === title)) {
|
|
||||||
let n = 2;
|
|
||||||
while (columns.some(c => c.text === title + ' ' + n)) n++;
|
|
||||||
title = title + ' ' + n;
|
|
||||||
}
|
|
||||||
columns.push({ text: title, x: r.x, w: r.width, right: r.x + r.width, y: r.y, h: r.height });
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const r = box.getBoundingClientRect();
|
|
||||||
columns.push({ text, x: r.x, w: r.width, right: r.x + r.width, y: r.y, h: r.height });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Multi-row grid support: detect stacked/merged headers.
|
|
||||||
// Group headers by X-range. For each group, count data sub-rows from first line.
|
|
||||||
// - Stacked headers (2+ headers at same X) with multiple data rows → match by Y-order
|
|
||||||
// - Single merged header with multiple data rows → expand to numbered columns (e.g. "Субконто Дт 1")
|
|
||||||
const xGroups = new Map();
|
|
||||||
columns.forEach(c => {
|
|
||||||
const key = Math.round(c.x) + ':' + Math.round(c.right);
|
|
||||||
if (!xGroups.has(key)) xGroups.set(key, []);
|
|
||||||
xGroups.get(key).push(c);
|
|
||||||
});
|
|
||||||
for (const [, hdrs] of xGroups) hdrs.sort((a, b) => a.y - b.y);
|
|
||||||
|
|
||||||
const firstDataLine = body?.querySelector('.gridLine');
|
|
||||||
const subRowMap = new Map();
|
|
||||||
if (firstDataLine) {
|
|
||||||
[...firstDataLine.children].forEach(box => {
|
|
||||||
if (box.offsetWidth === 0) return;
|
|
||||||
const r = box.getBoundingClientRect();
|
|
||||||
const cx = r.x + r.width / 2;
|
|
||||||
for (const [key, hdrs] of xGroups) {
|
|
||||||
const h0 = hdrs[0];
|
|
||||||
if (cx >= h0.x && cx < h0.right) {
|
|
||||||
if (!subRowMap.has(key)) subRowMap.set(key, []);
|
|
||||||
subRowMap.get(key).push({ y: r.y });
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
for (const [, subs] of subRowMap) subs.sort((a, b) => a.y - b.y);
|
|
||||||
}
|
|
||||||
|
|
||||||
const multiRowGroups = new Map();
|
|
||||||
for (const [key, hdrs] of xGroups) {
|
|
||||||
const subs = subRowMap.get(key);
|
|
||||||
if (!subs || subs.length <= 1) continue;
|
|
||||||
if (hdrs.length >= 2) {
|
|
||||||
multiRowGroups.set(key, hdrs);
|
|
||||||
} else if (hdrs.length === 1 && subs.length > 1) {
|
|
||||||
const base = hdrs[0];
|
|
||||||
const baseIdx = columns.indexOf(base);
|
|
||||||
columns.splice(baseIdx, 1);
|
|
||||||
const expanded = [];
|
|
||||||
for (let si = 0; si < subs.length; si++) {
|
|
||||||
const numbered = {
|
|
||||||
text: base.text + ' ' + (si + 1),
|
|
||||||
x: base.x, w: base.w, right: base.right,
|
|
||||||
y: base.y + si, h: base.h / subs.length, _subIdx: si
|
|
||||||
};
|
|
||||||
columns.splice(baseIdx + si, 0, numbered);
|
|
||||||
expanded.push(numbered);
|
|
||||||
}
|
|
||||||
multiRowGroups.set(key, expanded);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function matchColumn(cellX, cellW, cellY) {
|
|
||||||
const cx = cellX + cellW / 2;
|
|
||||||
for (const [key, hdrs] of multiRowGroups) {
|
|
||||||
const h0 = hdrs[0];
|
|
||||||
if (cx >= h0.x && cx < h0.right) {
|
|
||||||
const subs = subRowMap.get(key);
|
|
||||||
if (subs) {
|
|
||||||
const subIdx = subs.findIndex(s => Math.abs(s.y - cellY) < 5);
|
|
||||||
if (subIdx >= 0 && subIdx < hdrs.length) return hdrs[subIdx];
|
|
||||||
}
|
|
||||||
let best = hdrs[0], bestDist = Infinity;
|
|
||||||
for (const h of hdrs) {
|
|
||||||
const dist = Math.abs(cellY - h.y);
|
|
||||||
if (dist < bestDist) { bestDist = dist; best = h; }
|
|
||||||
}
|
|
||||||
return best;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return columns.find(c => cx >= c.x && cx < c.right);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract data rows from gridBody
|
|
||||||
const allLines = body.querySelectorAll('.gridLine');
|
|
||||||
const total = allLines.length;
|
|
||||||
const rows = [];
|
|
||||||
const end = Math.min(${offset} + ${maxRows}, total);
|
|
||||||
for (let i = ${offset}; i < end; i++) {
|
|
||||||
const line = allLines[i];
|
|
||||||
if (!line) break;
|
|
||||||
const row = {};
|
|
||||||
columns.forEach(c => { row[c.text] = ''; });
|
|
||||||
[...line.children].forEach(box => {
|
|
||||||
if (box.offsetWidth === 0) return;
|
|
||||||
const textEl = box.querySelector('.gridBoxText');
|
|
||||||
const chk = box.querySelector('.checkbox');
|
|
||||||
let val;
|
|
||||||
if (chk) {
|
|
||||||
val = chk.classList.contains('select') ? 'true' : 'false';
|
|
||||||
} else {
|
|
||||||
val = (textEl || box).innerText?.trim().replace(/\\n/g, ' ') || '';
|
|
||||||
if (!val) {
|
|
||||||
// Empty text → maybe a picture cell. 'pic:<gx>' encodes the sprite frame
|
|
||||||
// (state). Absent picture stays '' (truthy check distinguishes presence).
|
|
||||||
const pic = picInfo(box);
|
|
||||||
if (pic) val = 'pic:' + pic.gx;
|
|
||||||
else return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Match cell to column by X+Y overlap (multi-row aware)
|
|
||||||
const r = box.getBoundingClientRect();
|
|
||||||
const col = matchColumn(r.x, r.width, r.y);
|
|
||||||
if (col) {
|
|
||||||
row[col.text] = row[col.text] ? row[col.text] + ' / ' + val : val;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// Detect row kind: group (gridListH), parent/up (gridListV), or element
|
|
||||||
const imgBox = line.querySelector('.gridBoxImg');
|
|
||||||
if (imgBox) {
|
|
||||||
if (imgBox.querySelector('.gridListH')) row._kind = 'group';
|
|
||||||
else if (imgBox.querySelector('.gridListV')) row._kind = 'parent';
|
|
||||||
}
|
|
||||||
// Tree mode: detect expand/collapse state and indent level
|
|
||||||
const treeBox = line.querySelector('.gridBoxTree');
|
|
||||||
if (treeBox) {
|
|
||||||
const treeIcon = imgBox?.querySelector('[tree="true"]');
|
|
||||||
if (treeIcon) {
|
|
||||||
const bg = treeIcon.style.backgroundImage || '';
|
|
||||||
row._tree = bg.includes('gx=0') ? 'expanded' : 'collapsed';
|
|
||||||
}
|
|
||||||
row._level = imgBox ? imgBox.querySelectorAll('.dIB').length - 1 : 0;
|
|
||||||
}
|
|
||||||
// Selection state: selRow = selected row in grid
|
|
||||||
if (line.classList.contains('selRow') || line.classList.contains('select')) row._selected = true;
|
|
||||||
rows.push(row);
|
|
||||||
}
|
|
||||||
const isTree = !!body.querySelector('.gridBoxTree');
|
|
||||||
const hasGroups = rows.some(r => r._kind === 'group');
|
|
||||||
// Virtualization-aware hasMore signal. Three sources in priority order:
|
|
||||||
// 1. Dynamic-list turn buttons (#vertButtonScroll_<gridId>, sibling of grid).
|
|
||||||
// Buttons carry data-home/data-up (above) and data-down/data-end (below);
|
|
||||||
// class "disabled" on a direction means nothing to show there.
|
|
||||||
// 2. Tabular-section scrollbar (#vertScroll_<gridId>, class scrollV) —
|
|
||||||
// track-back/track-next pixel heights tell us above/below precisely.
|
|
||||||
// 3. Fallback: scrollHeight>clientHeight for "below"; "above" unknown.
|
|
||||||
let hasMore;
|
|
||||||
const turnsBox = document.getElementById('vertButtonScroll_' + grid.id);
|
|
||||||
if (turnsBox && turnsBox.offsetHeight > 0) {
|
|
||||||
const upBtns = turnsBox.querySelectorAll('[data-home], [data-up]');
|
|
||||||
const dnBtns = turnsBox.querySelectorAll('[data-down], [data-end]');
|
|
||||||
hasMore = {
|
|
||||||
above: [...upBtns].some(b => !b.classList.contains('disabled')),
|
|
||||||
below: [...dnBtns].some(b => !b.classList.contains('disabled')),
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
const vsId = 'vertScroll_' + grid.id;
|
|
||||||
const vs = document.getElementById(vsId);
|
|
||||||
if (vs && vs.classList.contains('scrollV') && vs.offsetWidth > 0) {
|
|
||||||
const back = vs.querySelector('[data-track-back]')?.offsetHeight ?? 0;
|
|
||||||
const next = vs.querySelector('[data-track-next]')?.offsetHeight ?? 0;
|
|
||||||
hasMore = { above: back > 0, below: next > 0 };
|
|
||||||
} else {
|
|
||||||
hasMore = { below: body.scrollHeight > body.clientHeight };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const result = { name, columns: columns.map(c => c.text), rows, total, offset: ${offset}, shown: rows.length, hasMore };
|
|
||||||
if (isTree) result.viewMode = 'tree';
|
|
||||||
if (hasGroups) result.hierarchical = true;
|
|
||||||
return result;
|
|
||||||
})()`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Edit-time grid helpers (for fillTableRow / row-fill) ────────────────────
|
|
||||||
//
|
|
||||||
// All helpers below accept an optional `gridSelector`. When passed, they target
|
|
||||||
// that exact grid; when null/undefined they pick the LAST visible `.grid` on
|
|
||||||
// the page (this matches the implicit "current grid" used by row-fill).
|
|
||||||
|
|
||||||
/** Inline JS fragment that resolves the target grid into `const grid`. */
|
|
||||||
function gridResolver(gridSelector) {
|
|
||||||
return gridSelector
|
|
||||||
? `document.querySelector(${JSON.stringify(gridSelector)})`
|
|
||||||
: `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find center coords of a target row for click-select (used by deleteTableRow).
|
|
||||||
* Picks the second visible gridBox container in the row (skips row-number/checkbox col).
|
|
||||||
*
|
|
||||||
* Returns `{ x, y, total } | { error: 'no_grid'|'no_grid_body'|'row_out_of_range'|'no_cell', total? }`.
|
|
||||||
*/
|
|
||||||
export function findDeleteRowCoordsScript(gridSelector, row) {
|
|
||||||
return `(() => {
|
|
||||||
const grid = ${gridResolver(gridSelector)};
|
|
||||||
if (!grid) return { error: 'no_grid' };
|
|
||||||
const body = grid.querySelector('.gridBody');
|
|
||||||
if (!body) return { error: 'no_grid_body' };
|
|
||||||
const rows = [...body.querySelectorAll('.gridLine')];
|
|
||||||
if (${row} >= rows.length) return { error: 'row_out_of_range', total: rows.length };
|
|
||||||
const line = rows[${row}];
|
|
||||||
// Use visible gridBox containers (not gridBoxText) to avoid clicking checkboxes
|
|
||||||
const boxes = [...line.children].filter(b => b.offsetWidth > 0 && !b.classList.contains('gridBoxComp'));
|
|
||||||
// Skip first column (row number / checkbox) — pick second visible box
|
|
||||||
const box = boxes.length > 1 ? boxes[1] : boxes[0];
|
|
||||||
if (!box) return { error: 'no_cell' };
|
|
||||||
const cell = box.querySelector('.gridBoxText') || box;
|
|
||||||
const r = cell.getBoundingClientRect();
|
|
||||||
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), total: rows.length };
|
|
||||||
})()`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Count `.gridLine` rows in the body of the target grid.
|
|
||||||
* Returns the row count, or `0` when grid/body absent.
|
|
||||||
*/
|
|
||||||
export function countGridRowsScript(gridSelector) {
|
|
||||||
return `(() => {
|
|
||||||
const grid = ${gridResolver(gridSelector)};
|
|
||||||
const body = grid?.querySelector('.gridBody');
|
|
||||||
return body ? body.querySelectorAll('.gridLine').length : 0;
|
|
||||||
})()`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Is the target grid a tree grid? (presence of `.gridBoxTree`)
|
|
||||||
* Returns boolean.
|
|
||||||
*/
|
|
||||||
export function isTreeGridScript(gridSelector) {
|
|
||||||
return `(() => {
|
|
||||||
const grid = ${gridResolver(gridSelector)};
|
|
||||||
return grid ? !!grid.querySelector('.gridBoxTree') : false;
|
|
||||||
})()`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Return center coords of the grid's `.gridHead` element.
|
|
||||||
* Used as a click target to commit a pending cell edit (clicking the header
|
|
||||||
* defocuses the input without selecting another row).
|
|
||||||
*
|
|
||||||
* Returns `{ x, y } | null`.
|
|
||||||
*/
|
|
||||||
export function findGridHeadCenterCoordsScript(gridSelector) {
|
|
||||||
return `(() => {
|
|
||||||
const grid = ${gridResolver(gridSelector)};
|
|
||||||
if (!grid) return null;
|
|
||||||
const head = grid.querySelector('.gridHead');
|
|
||||||
if (!head) {
|
|
||||||
// Headerless editable grid: no header to click for commit-defocus. Click the
|
|
||||||
// thin strip at the grid's very top edge (above the first row) so the active
|
|
||||||
// edit commits without landing on a .gridLine (which would re-enter edit).
|
|
||||||
const gr = grid.getBoundingClientRect();
|
|
||||||
return { x: Math.round(gr.x + gr.width / 2), y: Math.round(gr.y + 1) };
|
|
||||||
}
|
|
||||||
const r = head.getBoundingClientRect();
|
|
||||||
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
|
|
||||||
})()`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Return the index of the currently selected row in the target grid, or
|
|
||||||
* fall back to the last row when nothing is selected.
|
|
||||||
*
|
|
||||||
* Returns row index, or `-1` when no rows.
|
|
||||||
*/
|
|
||||||
export function getSelectedOrLastRowIndexScript(gridSelector) {
|
|
||||||
return `(() => {
|
|
||||||
const grid = ${gridResolver(gridSelector)};
|
|
||||||
if (!grid) return -1;
|
|
||||||
const body = grid.querySelector('.gridBody');
|
|
||||||
if (!body) return -1;
|
|
||||||
const lines = [...body.querySelectorAll('.gridLine')];
|
|
||||||
const sel = lines.findIndex(l => l.classList.contains('selected'));
|
|
||||||
return sel >= 0 ? sel : lines.length - 1;
|
|
||||||
})()`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Scan a selection-form grid for the row matching `search` and return a click
|
|
||||||
* point INSIDE that row's first visible text cell — NOT the row-line centre.
|
|
||||||
* (A wide multi-column row's centre `x = r.x + r.width/2` lands beyond the form's
|
|
||||||
* horizontal viewport, on an overlay, so `mouse.click` misses the row → Enter
|
|
||||||
* doesn't select → form stays open. That was the `not_selectable` bug.)
|
|
||||||
*
|
|
||||||
* `search` is either:
|
|
||||||
* - a string — matched per-cell (case/ё/NBSP-insensitive), preferring
|
|
||||||
* exact-cell → startsWith → includes (so "Кабель" wins over "Кабель ВВГ");
|
|
||||||
* - an object `{ column: value, ... }` — each key fuzzy-resolved to a header
|
|
||||||
* column, a row matches when EVERY column's cell includes its value (AND),
|
|
||||||
* preferring rows where every column's cell equals its value exactly.
|
|
||||||
* Empty `search` → first row (fallback).
|
|
||||||
*
|
|
||||||
* Returns:
|
|
||||||
* `{ rowCount, x, y, isGroup, matchKind, visibleSample }` when found,
|
|
||||||
* `{ rowCount, visibleSample, error? }` when rows present but unmatched,
|
|
||||||
* `{ rowCount: 0 }` for an empty grid, or `null` when no grid.
|
|
||||||
* `visibleSample` = first-cell text of visible rows, for actionable error messages.
|
|
||||||
*/
|
|
||||||
export function scanGridRowsScript(formNum, search) {
|
|
||||||
return `(() => {
|
|
||||||
${ROW_CLICK_POINT_FN}
|
|
||||||
${HEADERLESS_GRID_FN}
|
|
||||||
const p = 'form${formNum}_';
|
|
||||||
const grid = document.querySelector('[id^="' + p + '"].grid, [id^="' + p + '"] .grid');
|
|
||||||
if (!grid) return null;
|
|
||||||
const body = grid.querySelector('.gridBody');
|
|
||||||
if (!body) return null;
|
|
||||||
const lines = [...body.querySelectorAll('.gridLine')];
|
|
||||||
if (!lines.length) return { rowCount: 0 };
|
|
||||||
|
|
||||||
const search = ${JSON.stringify(search ?? '')};
|
|
||||||
const isObj = search && typeof search === 'object';
|
|
||||||
const norm = s => (s || '').replace(/\\u00a0/g, ' ').replace(/\\s+/g, ' ').trim().toLowerCase().replace(/ё/gi, 'е');
|
|
||||||
const disp = s => (s || '').replace(/\\u00a0/g, ' ').replace(/\\s+/g, ' ').trim();
|
|
||||||
const cellText = b => (b.querySelector('.gridBoxText') ? b.querySelector('.gridBoxText').innerText : b.innerText) || '';
|
|
||||||
const visCells = line => [...line.children].filter(b => b.offsetWidth > 0);
|
|
||||||
const visibleSample = lines.slice(0, 10)
|
|
||||||
.map(l => disp(l.querySelector('.gridBoxText') ? l.querySelector('.gridBoxText').innerText : ''))
|
|
||||||
.filter(Boolean);
|
|
||||||
|
|
||||||
let sel = null, matchKind = null;
|
|
||||||
|
|
||||||
if (!search || (isObj && !Object.keys(search).length)) {
|
|
||||||
sel = lines[0]; matchKind = 'first';
|
|
||||||
} else if (isObj) {
|
|
||||||
// Resolve each key to a header column (fuzzy, normalised) — mirror resolveCol.
|
|
||||||
const headLine = grid.querySelector('.gridHead .gridLine') || grid.querySelector('.gridHead');
|
|
||||||
let headers;
|
|
||||||
if (headLine) {
|
|
||||||
headers = [...headLine.children]
|
|
||||||
.filter(c => c.offsetWidth > 0)
|
|
||||||
.map(c => {
|
|
||||||
const t = (c.querySelector('.gridBoxText') || c).innerText || '';
|
|
||||||
const title = c.getAttribute('title') || '';
|
|
||||||
const r = c.getBoundingClientRect();
|
|
||||||
return { name: disp(t) || disp(title), text: t, title, x: r.x, right: r.x + r.width };
|
|
||||||
})
|
|
||||||
.filter(h => h.name);
|
|
||||||
} else {
|
|
||||||
// Headerless: synthesized columns anchored by colindex.
|
|
||||||
headers = synthHeaderlessColumns(grid).map(c => ({ name: c.name, text: c.name, title: '', x: 0, right: 0, colindex: c.colindex }));
|
|
||||||
}
|
|
||||||
const resolveCol = name => {
|
|
||||||
const n = norm(name);
|
|
||||||
const cand = h => [h.text, h.title].filter(Boolean);
|
|
||||||
return headers.find(h => cand(h).some(t => norm(t) === n))
|
|
||||||
|| headers.find(h => cand(h).some(t => norm(t).includes(n)));
|
|
||||||
};
|
|
||||||
const cellAtCol = (line, col) => {
|
|
||||||
if (col.colindex != null) return visCells(line).find(b => b.getAttribute('colindex') === col.colindex);
|
|
||||||
return visCells(line).find(b => {
|
|
||||||
const r = b.getBoundingClientRect();
|
|
||||||
const cx = r.x + r.width / 2;
|
|
||||||
return cx >= col.x && cx < col.right;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
const keys = Object.keys(search);
|
|
||||||
const cols = {};
|
|
||||||
for (const k of keys) {
|
|
||||||
const c = resolveCol(k);
|
|
||||||
if (!c) return { rowCount: lines.length, error: 'filter_column_not_found', column: k, visibleSample };
|
|
||||||
cols[k] = c;
|
|
||||||
}
|
|
||||||
let bestRank = 0;
|
|
||||||
for (const line of lines) {
|
|
||||||
let allIncludes = true, allExact = true;
|
|
||||||
for (const k of keys) {
|
|
||||||
const v = norm(search[k]);
|
|
||||||
if (!v) continue;
|
|
||||||
const cell = cellAtCol(line, cols[k]);
|
|
||||||
const t = norm(cell ? cellText(cell) : '');
|
|
||||||
if (!t.includes(v)) { allIncludes = false; break; }
|
|
||||||
if (t !== v) allExact = false;
|
|
||||||
}
|
|
||||||
if (!allIncludes) continue;
|
|
||||||
const rank = allExact ? 2 : 1;
|
|
||||||
if (rank > bestRank) { bestRank = rank; sel = line; matchKind = allExact ? 'object-exact' : 'object'; if (rank === 2) break; }
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// String: per-cell, prefer exact-cell → startsWith → includes.
|
|
||||||
const v = norm(search);
|
|
||||||
let bestRank = 0;
|
|
||||||
for (const line of lines) {
|
|
||||||
let rowRank = 0;
|
|
||||||
for (const b of visCells(line)) {
|
|
||||||
const t = norm(cellText(b));
|
|
||||||
if (!t) continue;
|
|
||||||
let r = 0;
|
|
||||||
if (t === v) r = 3; else if (t.startsWith(v)) r = 2; else if (t.includes(v)) r = 1;
|
|
||||||
if (r > rowRank) rowRank = r;
|
|
||||||
}
|
|
||||||
if (rowRank > bestRank) { bestRank = rowRank; sel = line; matchKind = rowRank === 3 ? 'exact' : rowRank === 2 ? 'startsWith' : 'includes'; if (rowRank === 3) break; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!sel) return { rowCount: lines.length, visibleSample };
|
|
||||||
|
|
||||||
// Click point: first visible text cell of the row (skip checkboxes; on tree grids
|
|
||||||
// skip the expand-toggle column; clamp X near the left). Shared with the
|
|
||||||
// clickElement row-select path — see ROW_CLICK_POINT_FN.
|
|
||||||
const pt = rowClickPoint(sel, body);
|
|
||||||
if (!pt) return { rowCount: lines.length, visibleSample };
|
|
||||||
|
|
||||||
const imgBox = sel.querySelector('.gridBoxImg');
|
|
||||||
const isGroup = imgBox ? !!imgBox.querySelector('.gridListH') : false;
|
|
||||||
return {
|
|
||||||
rowCount: lines.length,
|
|
||||||
x: pt.x,
|
|
||||||
y: pt.y,
|
|
||||||
isGroup, matchKind, visibleSample
|
|
||||||
};
|
|
||||||
})()`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Cell-click DOM scripts (for clickElement({row, column}) on grids) ───────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve a target cell in a grid by (row, column).
|
|
||||||
* - `column` matched: exact (case+ё-insensitive) → endsWith ' / X' → includes.
|
|
||||||
* - `row`: number = index in current DOM window; object = {col: value, ...} filter
|
|
||||||
* (matches first non-group/parent row where every column condition passes).
|
|
||||||
*
|
|
||||||
* Returns `{ x, y, cellX, cellRight, gridX, gridRight, columnText, rowIdx, cellText, visible } | { error, ... }`.
|
|
||||||
*
|
|
||||||
* Visibility (`visible`) is true when the cell is fully within the grid's horizontal viewport.
|
|
||||||
* Callers should horizontally scroll first if `visible === false`.
|
|
||||||
*/
|
|
||||||
export function findGridCellScript(formNum, gridSelector, { row, column }) {
|
|
||||||
const p = `form${formNum}_`;
|
|
||||||
return `(() => {
|
|
||||||
const norm = s => (s || '').replace(/\\u00a0/g, ' ').replace(/ё/gi, 'е').trim();
|
|
||||||
const lo = s => norm(s).toLowerCase();
|
|
||||||
|
|
||||||
const p = ${JSON.stringify(p)};
|
|
||||||
const grid = ${gridSelector
|
|
||||||
? `document.querySelector(${JSON.stringify(gridSelector)})`
|
|
||||||
: `[...document.querySelectorAll('[id^="' + p + '"].grid, [id^="' + p + '"] .grid')]
|
|
||||||
.find(g => g.offsetWidth > 0 && g.offsetHeight > 0)`};
|
|
||||||
if (!grid) return { error: 'no_grid' };
|
|
||||||
const head = grid.querySelector('.gridHead');
|
|
||||||
const body = grid.querySelector('.gridBody');
|
|
||||||
if (!body) return { error: 'no_grid_structure' };
|
|
||||||
${HEADERLESS_GRID_FN}
|
|
||||||
const isHeadless = !head;
|
|
||||||
|
|
||||||
// Header X-ranges (mirror of readTableScript logic, simplified). We also
|
|
||||||
// remember whether each header is frozen (gridBoxFix) — frozen and scrollable
|
|
||||||
// columns can share X coordinates after horizontal scroll, so cell matching
|
|
||||||
// must respect the frozen/scrollable partition.
|
|
||||||
let headers;
|
|
||||||
if (head) {
|
|
||||||
const headLine = head.querySelector('.gridLine') || head;
|
|
||||||
headers = [...headLine.children]
|
|
||||||
.filter(c => c.offsetWidth > 0)
|
|
||||||
.map(c => {
|
|
||||||
const textEl = c.querySelector('.gridBoxText');
|
|
||||||
const text = (textEl || c).innerText?.trim().replace(/\\n/g, ' ') || '';
|
|
||||||
// Picture/icon columns have no header text — fall back to the title tooltip
|
|
||||||
// (mirrors readTable naming) so they can still be targeted for clicking.
|
|
||||||
const title = (c.getAttribute('title') || '').trim();
|
|
||||||
const r = c.getBoundingClientRect();
|
|
||||||
return { text, title, name: text || title, x: r.x, right: r.x + r.width, fixed: c.classList.contains('gridBoxFix') };
|
|
||||||
})
|
|
||||||
.filter(h => h.name);
|
|
||||||
} else {
|
|
||||||
// Headerless: synthesized columns anchored by colindex (cellAtColX matches by colindex).
|
|
||||||
headers = synthHeaderlessColumns(grid).map(c => ({ text: c.name, title: '', name: c.name, x: 0, right: 0, fixed: false, colindex: c.colindex, subTarget: c.subTarget }));
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolveCol = (name) => {
|
|
||||||
const suffix = ' / ' + name;
|
|
||||||
const cand = h => [h.text, h.title].filter(Boolean);
|
|
||||||
return headers.find(h => cand(h).some(t => lo(t) === lo(name)))
|
|
||||||
|| headers.find(h => cand(h).some(t => t.endsWith(suffix)))
|
|
||||||
|| headers.find(h => cand(h).some(t => lo(t).includes(lo(name))));
|
|
||||||
};
|
|
||||||
|
|
||||||
const targetCol = ${JSON.stringify(column)};
|
|
||||||
const col = resolveCol(targetCol);
|
|
||||||
if (!col) return { error: 'column_not_found', column: targetCol, available: headers.map(h => h.name) };
|
|
||||||
|
|
||||||
const lines = [...body.querySelectorAll('.gridLine')];
|
|
||||||
if (lines.length === 0) return { error: 'empty_grid' };
|
|
||||||
|
|
||||||
// Match cell to column by X overlap, but only among cells with the same
|
|
||||||
// fixed/scrollable kind as the header. After horizontal scroll a scrollable
|
|
||||||
// cell may have the same x as a frozen one — without this guard cellAtColX
|
|
||||||
// would silently return the frozen cell for a scrollable header.
|
|
||||||
const cellAtColX = (line, c) => {
|
|
||||||
// Headerless columns carry colindex → match the body cell directly (robust,
|
|
||||||
// and returns the same box for both logical columns of a combined mark-box).
|
|
||||||
if (c.colindex != null) {
|
|
||||||
return [...line.children].find(b => b.offsetWidth > 0 && b.getAttribute('colindex') === c.colindex);
|
|
||||||
}
|
|
||||||
return [...line.children]
|
|
||||||
.filter(b => b.offsetWidth > 0 && b.classList.contains('gridBoxFix') === c.fixed)
|
|
||||||
.find(b => {
|
|
||||||
const r = b.getBoundingClientRect();
|
|
||||||
const cx = r.x + r.width / 2;
|
|
||||||
return cx >= c.x && cx < c.right;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
const cellText = (b) => norm(b?.querySelector('.gridBoxText')?.innerText || b?.innerText || '');
|
|
||||||
|
|
||||||
const target = ${JSON.stringify(row)};
|
|
||||||
let line, rowIdx;
|
|
||||||
if (typeof target === 'number') {
|
|
||||||
if (target < 0 || target >= lines.length) {
|
|
||||||
return { error: 'row_out_of_range', row: target, loaded: lines.length };
|
|
||||||
}
|
|
||||||
line = lines[target];
|
|
||||||
rowIdx = target;
|
|
||||||
} else if (target && typeof target === 'object') {
|
|
||||||
const entries = Object.entries(target);
|
|
||||||
const colsByKey = {};
|
|
||||||
for (const [k] of entries) {
|
|
||||||
const c = resolveCol(k);
|
|
||||||
if (!c) return { error: 'filter_column_not_found', column: k, available: headers.map(h => h.name) };
|
|
||||||
colsByKey[k] = c;
|
|
||||||
}
|
|
||||||
const matches = (ln) => {
|
|
||||||
for (const [k, v] of entries) {
|
|
||||||
const c = colsByKey[k];
|
|
||||||
const cell = cellAtColX(ln, c);
|
|
||||||
const txt = cellText(cell);
|
|
||||||
const wanted = lo(v);
|
|
||||||
if (!txt) return false;
|
|
||||||
const t = txt.toLowerCase();
|
|
||||||
if (!(t === wanted || t.includes(wanted))) return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
rowIdx = lines.findIndex(matches);
|
|
||||||
if (rowIdx < 0) return { error: 'row_not_found', filter: target };
|
|
||||||
line = lines[rowIdx];
|
|
||||||
} else {
|
|
||||||
return { error: 'invalid_row_type' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const cell = cellAtColX(line, col);
|
|
||||||
if (!cell) return { error: 'cell_not_in_dom', column: col.name, rowIdx };
|
|
||||||
// Headerless: click coords target the subTarget node (checkbox image / title),
|
|
||||||
// no frozen/scroll partition in these narrow grids → trivially visible.
|
|
||||||
if (isHeadless) {
|
|
||||||
let node = cell;
|
|
||||||
if (col.subTarget === 'checkbox') node = cell.querySelector('.checkbox') || cell;
|
|
||||||
else if (col.subTarget === 'title') node = cell.querySelector('.gridBoxTitle') || cell;
|
|
||||||
else if (col.subTarget === 'text') node = cell.querySelector('.gridBoxText') || cell;
|
|
||||||
const rr = node.getBoundingClientRect();
|
|
||||||
const gb = grid.getBoundingClientRect();
|
|
||||||
return { x: Math.round(rr.x + rr.width / 2), y: Math.round(rr.y + rr.height / 2),
|
|
||||||
cellX: Math.round(rr.x), cellRight: Math.round(rr.x + rr.width),
|
|
||||||
gridX: Math.round(gb.x), gridRight: Math.round(gb.x + gb.width), scrollableLeft: Math.round(gb.x),
|
|
||||||
columnText: col.name, rowIdx, isFixed: false, cellText: cellText(cell), visible: true };
|
|
||||||
}
|
|
||||||
const r = cell.getBoundingClientRect();
|
|
||||||
const gridBox = grid.getBoundingClientRect();
|
|
||||||
// Frozen columns (.gridBoxFix) stay pinned at the left edge of the grid even
|
|
||||||
// when the rest scrolls horizontally. For non-frozen cells, "visible" means
|
|
||||||
// inside the SCROLLABLE viewport (right of any frozen columns). Frozen cells
|
|
||||||
// are always visible by definition.
|
|
||||||
const isFixed = cell.classList.contains('gridBoxFix');
|
|
||||||
let scrollableLeft = gridBox.x;
|
|
||||||
if (!isFixed) {
|
|
||||||
[...line.children].forEach(b => {
|
|
||||||
if (b.offsetWidth > 0 && b.classList.contains('gridBoxFix')) {
|
|
||||||
const br = b.getBoundingClientRect();
|
|
||||||
if (br.x + br.width > scrollableLeft) scrollableLeft = br.x + br.width;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// "Visible enough to click" — the cell's CENTER is inside the scrollable area
|
|
||||||
// and the cell's right edge is inside the grid. Strict left-edge check would
|
|
||||||
// reject cells that 1С rendered touching the frozen-column boundary (off by 1px).
|
|
||||||
const center = r.x + r.width / 2;
|
|
||||||
const visible = center >= scrollableLeft && center <= (gridBox.x + gridBox.width) && (r.x + r.width) <= (gridBox.x + gridBox.width);
|
|
||||||
return {
|
|
||||||
x: Math.round(r.x + r.width / 2),
|
|
||||||
y: Math.round(r.y + r.height / 2),
|
|
||||||
cellX: Math.round(r.x), cellRight: Math.round(r.x + r.width),
|
|
||||||
gridX: Math.round(gridBox.x), gridRight: Math.round(gridBox.x + gridBox.width),
|
|
||||||
scrollableLeft: Math.round(scrollableLeft),
|
|
||||||
columnText: col.name, rowIdx, isFixed,
|
|
||||||
cellText: cellText(cell),
|
|
||||||
visible
|
|
||||||
};
|
|
||||||
})()`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pick coordinates for a focus-click on a safe cell within the grid.
|
|
||||||
*
|
|
||||||
* Used both for vertical reveal-loop focus and for horizontal-scroll edge focus.
|
|
||||||
* The caller passes a profile that selects which row, which cells to exclude,
|
|
||||||
* and (for horizontal scroll) which edge of the row to take.
|
|
||||||
*
|
|
||||||
* @param {string} gridSelector
|
|
||||||
* @param {object} opts
|
|
||||||
* @param {number} [opts.rowIdx] - Pick from this row; falls back to first non-group/parent data row.
|
|
||||||
* @param {'ArrowRight'|'ArrowLeft'} [opts.direction]
|
|
||||||
* - When set, restricts to non-frozen FULLY visible cells and picks the edge
|
|
||||||
* cell in that direction (rightmost for ArrowRight, leftmost for ArrowLeft).
|
|
||||||
* - When omitted, picks a generic safe cell (skips first column to avoid tree-toggles).
|
|
||||||
*
|
|
||||||
* Always prefers non-checkbox cells (center-click on a checkbox would toggle it).
|
|
||||||
*
|
|
||||||
* Returns `{ x, y } | null`.
|
|
||||||
*/
|
|
||||||
export function findFocusCellScript(gridSelector, { rowIdx, direction } = {}) {
|
|
||||||
return `(() => {
|
|
||||||
const grid = ${gridResolver(gridSelector)};
|
|
||||||
if (!grid) return null;
|
|
||||||
const body = grid.querySelector('.gridBody');
|
|
||||||
if (!body) return null;
|
|
||||||
const lines = [...body.querySelectorAll('.gridLine')];
|
|
||||||
if (!lines.length) return null;
|
|
||||||
|
|
||||||
const rowIdx = ${rowIdx == null ? 'null' : JSON.stringify(rowIdx)};
|
|
||||||
const direction = ${direction ? JSON.stringify(direction) : 'null'};
|
|
||||||
|
|
||||||
const line = (rowIdx != null && lines[rowIdx])
|
|
||||||
|| lines.find(ln => {
|
|
||||||
const imgBox = ln.querySelector('.gridBoxImg');
|
|
||||||
return !imgBox?.querySelector('.gridListH, .gridListV');
|
|
||||||
})
|
|
||||||
|| lines[0];
|
|
||||||
if (!line) return null;
|
|
||||||
|
|
||||||
let candidates;
|
|
||||||
if (direction) {
|
|
||||||
// Horizontal-scroll mode: edge cell in the scrollable area, exclude frozen.
|
|
||||||
const gridBox = grid.getBoundingClientRect();
|
|
||||||
let scrollableLeft = gridBox.x;
|
|
||||||
[...line.children].forEach(b => {
|
|
||||||
if (b.offsetWidth > 0 && b.classList.contains('gridBoxFix')) {
|
|
||||||
const br = b.getBoundingClientRect();
|
|
||||||
if (br.x + br.width > scrollableLeft) scrollableLeft = br.x + br.width;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const visible = [...line.children]
|
|
||||||
.filter(b => b.offsetWidth > 0 && !b.classList.contains('gridBoxFix'))
|
|
||||||
.map(b => ({ b, r: b.getBoundingClientRect(), checkbox: !!b.querySelector('.checkbox') }))
|
|
||||||
.filter(({ r }) => r.x >= scrollableLeft && (r.x + r.width) <= (gridBox.x + gridBox.width));
|
|
||||||
if (!visible.length) return null;
|
|
||||||
visible.sort((a, b) => a.r.x - b.r.x);
|
|
||||||
candidates = direction === 'ArrowRight' ? [...visible].reverse() : visible;
|
|
||||||
} else {
|
|
||||||
// Generic focus mode (used by reveal-loop): pick the FIRST visible cell —
|
|
||||||
// typically a Reference column (Номенклатура in документах) which doesn't
|
|
||||||
// auto-enter edit mode on click. Number/Date/String cells auto-edit and
|
|
||||||
// break subsequent PageDown navigation.
|
|
||||||
// For tree grids (presence of .gridBoxTree), skip first column to avoid
|
|
||||||
// toggling expand/collapse of the row.
|
|
||||||
const isTree = !!body.querySelector('.gridBoxTree');
|
|
||||||
const cells = [...line.children]
|
|
||||||
.filter(b => b.offsetWidth > 0)
|
|
||||||
.map(b => ({ b, r: b.getBoundingClientRect(), checkbox: !!b.querySelector('.checkbox') }));
|
|
||||||
if (!cells.length) return null;
|
|
||||||
candidates = isTree && cells.length > 1 ? cells.slice(1) : cells;
|
|
||||||
}
|
|
||||||
const pick = candidates.find(v => !v.checkbox) || candidates[0];
|
|
||||||
if (!pick) return null;
|
|
||||||
return { x: Math.round(pick.r.x + pick.r.width / 2), y: Math.round(pick.r.y + pick.r.height / 2) };
|
|
||||||
})()`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Snapshot grid state for reveal-loop end detection.
|
|
||||||
* Returns `{ firstText, lastText, lineCount, selIdx, hasBelow }`.
|
|
||||||
*
|
|
||||||
* `firstText`/`lastText` use the first cell's `.gridBoxText` content.
|
|
||||||
* `hasBelow` is derived from scrollbar widget tracks when visible, else from scrollHeight>clientHeight.
|
|
||||||
*/
|
|
||||||
export function snapshotGridScript(gridSelector) {
|
|
||||||
return `(() => {
|
|
||||||
const grid = ${gridResolver(gridSelector)};
|
|
||||||
if (!grid) return null;
|
|
||||||
const body = grid.querySelector('.gridBody');
|
|
||||||
if (!body) return null;
|
|
||||||
const lines = body.querySelectorAll('.gridLine');
|
|
||||||
// Full-row signature: join EVERY cell's text, not just the first column.
|
|
||||||
// A low-cardinality first column (e.g. all "Товар 0X") would otherwise make
|
|
||||||
// two different windows look identical and abort the reveal-loop early.
|
|
||||||
const txt = ln => ln ? [...ln.querySelectorAll('.gridBoxText')].map(b => (b.innerText || '').trim()).join('|') : '';
|
|
||||||
const selIdx = [...lines].findIndex(l => l.classList.contains('selRow') || l.classList.contains('select'));
|
|
||||||
// hasBelow priority: (1) dynamic-list turn buttons, (2) tabular scrollbar tracks, (3) scrollHeight.
|
|
||||||
let hasBelow;
|
|
||||||
const turnsBox = document.getElementById('vertButtonScroll_' + grid.id);
|
|
||||||
if (turnsBox && turnsBox.offsetHeight > 0) {
|
|
||||||
const dnBtns = turnsBox.querySelectorAll('[data-down], [data-end]');
|
|
||||||
hasBelow = [...dnBtns].some(b => !b.classList.contains('disabled'));
|
|
||||||
} else {
|
|
||||||
const vs = document.getElementById('vertScroll_' + grid.id);
|
|
||||||
if (vs && vs.classList.contains('scrollV') && vs.offsetWidth > 0) {
|
|
||||||
hasBelow = (vs.querySelector('[data-track-next]')?.offsetHeight ?? 0) > 0;
|
|
||||||
} else {
|
|
||||||
hasBelow = body.scrollHeight > body.clientHeight;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
firstText: txt(lines[0]),
|
|
||||||
lastText: txt(lines[lines.length - 1]),
|
|
||||||
lineCount: lines.length,
|
|
||||||
selIdx,
|
|
||||||
hasBelow
|
|
||||||
};
|
|
||||||
})()`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve the click target kind for `clickElement({row, column})`.
|
|
||||||
*
|
|
||||||
* Routing:
|
|
||||||
* - `tableName` specified: try to match a visible grid by name (exact → contains).
|
|
||||||
* If matched → grid. Else if form has a spreadsheet iframe → spreadsheet. Else error.
|
|
||||||
* - `tableName` omitted: spreadsheet iframe present → spreadsheet (backward-compat).
|
|
||||||
* Else first visible grid. Else error.
|
|
||||||
*
|
|
||||||
* Returns `{ kind: 'spreadsheet' } | { kind: 'grid', gridSelector, gridName } | { error, ... }`.
|
|
||||||
*/
|
|
||||||
export function resolveCellTargetScript(formNum, tableName) {
|
|
||||||
const p = `form${formNum}_`;
|
|
||||||
return `(() => {
|
|
||||||
const p = ${JSON.stringify(p)};
|
|
||||||
const tableName = ${JSON.stringify(tableName || '')};
|
|
||||||
// Spreadsheet = iframe under form prefix with non-trivial width.
|
|
||||||
const hasSpreadsheet = [...document.querySelectorAll('iframe')].some(f => {
|
|
||||||
if (f.offsetWidth < 100) return false;
|
|
||||||
let el = f.parentElement;
|
|
||||||
for (let d = 0; el && d < 30; d++, el = el.parentElement) {
|
|
||||||
if (el.id && el.id.startsWith(p)) return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
const grids = [...document.querySelectorAll('[id^="' + p + '"].grid, [id^="' + p + '"] .grid')]
|
|
||||||
.filter(g => g.offsetWidth > 0 && g.offsetHeight > 0);
|
|
||||||
const norm = s => (s || '').replace(/ё/gi, 'е').toLowerCase();
|
|
||||||
|
|
||||||
if (tableName) {
|
|
||||||
const target = norm(tableName);
|
|
||||||
const matched = grids.find(g => norm(g.id.replace(p, '')) === target)
|
|
||||||
|| grids.find(g => norm(g.id.replace(p, '')).includes(target));
|
|
||||||
if (matched) {
|
|
||||||
return { kind: 'grid', gridSelector: '#' + CSS.escape(matched.id), gridName: matched.id.replace(p, '') };
|
|
||||||
}
|
|
||||||
if (hasSpreadsheet) return { kind: 'spreadsheet' };
|
|
||||||
return { error: 'table_not_found', table: tableName, availableGrids: grids.map(g => g.id.replace(p, '')) };
|
|
||||||
}
|
|
||||||
if (hasSpreadsheet) return { kind: 'spreadsheet' };
|
|
||||||
if (grids.length > 0) {
|
|
||||||
const g = grids[0];
|
|
||||||
return { kind: 'grid', gridSelector: '#' + CSS.escape(g.id), gridName: g.id.replace(p, '') };
|
|
||||||
}
|
|
||||||
return { error: 'no_spreadsheet_or_grid' };
|
|
||||||
})()`;
|
|
||||||
}
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
// web-test dom/nav v1.0 — sections panel, tabs bar, function panel commands
|
|
||||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
|
||||||
|
|
||||||
/** Read sections panel (left sidebar). */
|
|
||||||
export function readSectionsScript() {
|
|
||||||
return `(() => {
|
|
||||||
const sections = [];
|
|
||||||
document.querySelectorAll('[id^="themesCell_theme_"]').forEach(el => {
|
|
||||||
const entry = { name: el.innerText?.trim() || '' };
|
|
||||||
if (el.classList.contains('select')) entry.active = true;
|
|
||||||
sections.push(entry);
|
|
||||||
});
|
|
||||||
return sections;
|
|
||||||
})()`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Read open tabs bar. */
|
|
||||||
export function readTabsScript() {
|
|
||||||
return `(() => {
|
|
||||||
const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е');
|
|
||||||
const tabs = [];
|
|
||||||
document.querySelectorAll('[id^="openedCell_cmd_"]').forEach(el => {
|
|
||||||
const text = norm(el.innerText);
|
|
||||||
if (!text) return;
|
|
||||||
const entry = { name: text };
|
|
||||||
if (el.classList.contains('select')) entry.active = true;
|
|
||||||
tabs.push(entry);
|
|
||||||
});
|
|
||||||
return tabs;
|
|
||||||
})()`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Switch to a tab by name (fuzzy match). Returns matched name or { error, available }. */
|
|
||||||
export function switchTabScript(name) {
|
|
||||||
return `(() => {
|
|
||||||
const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е');
|
|
||||||
const target = ${JSON.stringify(name.toLowerCase().replace(/ё/g, 'е'))};
|
|
||||||
const tabs = [...document.querySelectorAll('[id^="openedCell_cmd_"]')].filter(el => el.offsetWidth > 0 && norm(el.innerText));
|
|
||||||
let best = tabs.find(el => norm(el.innerText).toLowerCase() === target);
|
|
||||||
if (!best) best = tabs.find(el => norm(el.innerText).toLowerCase().includes(target));
|
|
||||||
if (best) { best.click(); return norm(best.innerText); }
|
|
||||||
return { error: 'not_found', available: tabs.map(el => norm(el.innerText)) };
|
|
||||||
})()`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Read commands in the function panel (current section). */
|
|
||||||
export function readCommandsScript() {
|
|
||||||
return `(() => {
|
|
||||||
const groups = [];
|
|
||||||
const container = document.querySelector('#funcPanel_container table tr');
|
|
||||||
if (!container) return groups;
|
|
||||||
for (const td of container.children) {
|
|
||||||
const commands = [];
|
|
||||||
td.querySelectorAll('[id^="cmd_"][id$="_txt"]').forEach(el => {
|
|
||||||
if (el.offsetWidth === 0) return;
|
|
||||||
commands.push(el.innerText?.trim() || '');
|
|
||||||
});
|
|
||||||
if (commands.length > 0) groups.push(commands);
|
|
||||||
}
|
|
||||||
return groups;
|
|
||||||
})()`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Navigate to a section by name (fuzzy match).
|
|
||||||
* Returns the matched section name, or { error, available }.
|
|
||||||
*/
|
|
||||||
export function navigateSectionScript(name) {
|
|
||||||
return `(() => {
|
|
||||||
const norm = s => (s?.trim().replace(/\\u00a0/g, ' ').replace(/[\\r\\n]+/g, ' ').replace(/ +/g, ' ') || '').replace(/ё/gi, 'е');
|
|
||||||
const target = ${JSON.stringify(name.toLowerCase().replace(/ё/g, 'е').replace(/[\r\n]+/g, ' ').replace(/ +/g, ' '))};
|
|
||||||
const els = [...document.querySelectorAll('[id^="themesCell_theme_"]')];
|
|
||||||
let bestEl = els.find(el => norm(el.innerText).toLowerCase() === target);
|
|
||||||
if (!bestEl) bestEl = els.find(el => norm(el.innerText).toLowerCase().includes(target));
|
|
||||||
if (bestEl) { bestEl.click(); return norm(bestEl.innerText); }
|
|
||||||
return { error: 'not_found', available: els.map(el => norm(el.innerText)).filter(Boolean) };
|
|
||||||
})()`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Open a command from function panel by name (fuzzy match).
|
|
||||||
*/
|
|
||||||
export function openCommandScript(name) {
|
|
||||||
return `(() => {
|
|
||||||
const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е');
|
|
||||||
const target = ${JSON.stringify(name.toLowerCase().replace(/ё/g, 'е'))};
|
|
||||||
const els = [...document.querySelectorAll('[id^="cmd_"][id$="_txt"]')].filter(el => el.offsetWidth > 0);
|
|
||||||
let bestEl = els.find(el => norm(el.innerText).toLowerCase() === target);
|
|
||||||
if (!bestEl) bestEl = els.find(el => norm(el.innerText).toLowerCase().includes(target));
|
|
||||||
if (bestEl) { bestEl.click(); return norm(bestEl.innerText); }
|
|
||||||
return { error: 'not_found', available: els.map(el => norm(el.innerText)).filter(Boolean) };
|
|
||||||
})()`;
|
|
||||||
}
|
|
||||||
@@ -1,178 +0,0 @@
|
|||||||
// web-test dom/submenu v1.1 — popup/submenu reading and clicking
|
|
||||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read open popup/submenu items.
|
|
||||||
* Looks for absolutely positioned visible popup containers with a.press items inside.
|
|
||||||
* Returns [{ id, name }] or { error }.
|
|
||||||
*/
|
|
||||||
export function readSubmenuScript() {
|
|
||||||
return `(() => {
|
|
||||||
const items = [];
|
|
||||||
const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е');
|
|
||||||
|
|
||||||
// 1. DLB dropdown (#editDropDown with .eddText items)
|
|
||||||
const edd = document.getElementById('editDropDown');
|
|
||||||
if (edd && edd.offsetWidth > 0 && edd.offsetHeight > 0) {
|
|
||||||
edd.querySelectorAll('.eddText').forEach(el => {
|
|
||||||
if (el.offsetWidth === 0) return;
|
|
||||||
const text = norm(el.innerText);
|
|
||||||
if (!text) return;
|
|
||||||
const r = el.getBoundingClientRect();
|
|
||||||
items.push({ id: '', name: text, kind: 'dropdown',
|
|
||||||
x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) });
|
|
||||||
});
|
|
||||||
// Detect "Показать все" link in EDD footer
|
|
||||||
// Structure: div.eddBottom > div > span.hyperlink "Показать все"
|
|
||||||
let showAllEl = edd.querySelector('.eddBottom .hyperlink');
|
|
||||||
if (!showAllEl || showAllEl.offsetWidth === 0) {
|
|
||||||
// Fallback: scan all visible elements for text match
|
|
||||||
const candidates = [...edd.querySelectorAll('a.press, a, span, div')]
|
|
||||||
.filter(el => el.offsetWidth > 0 && el.children.length === 0);
|
|
||||||
showAllEl = candidates.find(el => {
|
|
||||||
const t = norm(el.innerText).toLowerCase();
|
|
||||||
return t === 'показать все' || t === 'show all';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (showAllEl) {
|
|
||||||
const r = showAllEl.getBoundingClientRect();
|
|
||||||
items.push({ id: showAllEl.id || '', name: norm(showAllEl.innerText), kind: 'showAll',
|
|
||||||
x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) });
|
|
||||||
}
|
|
||||||
if (items.length > 0) return items;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Cloud submenu (allActions / command panel menus — div.cloud with .submenuText items)
|
|
||||||
// Read ALL visible high-z clouds (main menu + nested submenus)
|
|
||||||
const clouds = [...document.querySelectorAll('.cloud')].filter(c => c.offsetWidth > 0 && c.offsetHeight > 0);
|
|
||||||
const seen = new Set();
|
|
||||||
clouds.forEach(c => {
|
|
||||||
const z = parseInt(getComputedStyle(c).zIndex) || 0;
|
|
||||||
if (z <= 1000) return;
|
|
||||||
c.querySelectorAll('.submenuText').forEach(el => {
|
|
||||||
if (el.offsetWidth === 0) return;
|
|
||||||
const text = norm(el.innerText);
|
|
||||||
if (!text || seen.has(text)) return;
|
|
||||||
seen.add(text);
|
|
||||||
const block = el.closest('.submenuBlock');
|
|
||||||
if (block && block.classList.contains('submenuBlockDisabled')) return;
|
|
||||||
const hasSub = block && /_sub$/.test(block.id);
|
|
||||||
const r = el.getBoundingClientRect();
|
|
||||||
items.push({ id: block?.id || '', name: text, kind: hasSub ? 'submenuArrow' : 'submenu',
|
|
||||||
x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
if (items.length > 0) return items;
|
|
||||||
|
|
||||||
// 3. Submenu popups — find the topmost positioned container with non-form a.press items
|
|
||||||
const popups = [...document.querySelectorAll('div')].filter(c => {
|
|
||||||
const style = getComputedStyle(c);
|
|
||||||
return (style.position === 'absolute' || style.position === 'fixed')
|
|
||||||
&& c.offsetWidth > 0 && c.offsetHeight > 0;
|
|
||||||
}).sort((a, b) => {
|
|
||||||
const za = parseInt(getComputedStyle(a).zIndex) || 0;
|
|
||||||
const zb = parseInt(getComputedStyle(b).zIndex) || 0;
|
|
||||||
return zb - za;
|
|
||||||
});
|
|
||||||
for (const container of popups) {
|
|
||||||
// Only direct a.press children or those not nested in another positioned div
|
|
||||||
const menuItems = [...container.querySelectorAll('a.press')].filter(el => {
|
|
||||||
if (el.offsetWidth === 0) return false;
|
|
||||||
if (el.id && /^form\\d+_/.test(el.id)) return false;
|
|
||||||
// Skip if this a.press is inside a deeper positioned container
|
|
||||||
let parent = el.parentElement;
|
|
||||||
while (parent && parent !== container) {
|
|
||||||
const ps = getComputedStyle(parent).position;
|
|
||||||
if (ps === 'absolute' || ps === 'fixed') return false;
|
|
||||||
parent = parent.parentElement;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
if (menuItems.length < 2) continue; // Not a real menu
|
|
||||||
const seen = new Set();
|
|
||||||
menuItems.forEach(el => {
|
|
||||||
const text = norm(el.innerText);
|
|
||||||
if (!text) return;
|
|
||||||
if (seen.has(text)) return;
|
|
||||||
seen.add(text);
|
|
||||||
const r = el.getBoundingClientRect();
|
|
||||||
items.push({ id: el.id || '', name: text, kind: 'submenu',
|
|
||||||
x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) });
|
|
||||||
});
|
|
||||||
if (items.length > 0) break; // Found the popup menu
|
|
||||||
}
|
|
||||||
|
|
||||||
if (items.length === 0) return { error: 'no_popup', message: 'No open popup/submenu found' };
|
|
||||||
return items;
|
|
||||||
})()`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Click a popup/dropdown item by text match (evaluate-based for items without IDs).
|
|
||||||
* Returns true if clicked, false if not found.
|
|
||||||
*/
|
|
||||||
export function clickPopupItemScript(text) {
|
|
||||||
return `(() => {
|
|
||||||
const target = ${JSON.stringify(text.toLowerCase().replace(/ё/g, 'е'))};
|
|
||||||
// 1. DLB dropdown (#editDropDown .eddText items)
|
|
||||||
const edd = document.getElementById('editDropDown');
|
|
||||||
if (edd && edd.offsetWidth > 0) {
|
|
||||||
for (const el of edd.querySelectorAll('.eddText')) {
|
|
||||||
if (el.offsetWidth === 0) continue;
|
|
||||||
const t = el.innerText?.trim() || '';
|
|
||||||
if (t.toLowerCase() === target || t.toLowerCase().includes(target)) {
|
|
||||||
el.click();
|
|
||||||
return t;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Submenu popups (a.press in absolutely positioned containers)
|
|
||||||
const containers = [...document.querySelectorAll('div')].filter(c => {
|
|
||||||
const style = getComputedStyle(c);
|
|
||||||
return (style.position === 'absolute' || style.position === 'fixed')
|
|
||||||
&& c.offsetWidth > 0 && c.offsetHeight > 0;
|
|
||||||
});
|
|
||||||
for (const container of containers) {
|
|
||||||
const items = [...container.querySelectorAll('a.press')]
|
|
||||||
.filter(el => el.offsetWidth > 0);
|
|
||||||
for (const el of items) {
|
|
||||||
const t = el.innerText?.trim() || '';
|
|
||||||
if (t.toLowerCase() === target || t.toLowerCase().includes(target)) {
|
|
||||||
el.click();
|
|
||||||
return t;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
})()`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read a platform "cloud dropdown" checkbox list (`.cloudDD`) — the inline
|
|
||||||
* quick-choice multi-select dropdown (e.g. a catalog with QuickChoice opened via F4).
|
|
||||||
* NOT handled by readSubmenuScript (which reads `.eddText`/`.cloud`/popup `a.press`).
|
|
||||||
*
|
|
||||||
* Returns `[{ text, checked, x, y }]` — `checked` from `.checkbox.select`, `x/y` =
|
|
||||||
* the checkbox center (click there to toggle). Toggle via page.mouse.click (a
|
|
||||||
* `.surface` backdrop swallows selector clicks); confirm by clicking OUTSIDE the panel.
|
|
||||||
* Returns `{ error: 'no_clouddd' }` when no visible `.cloudDD` panel.
|
|
||||||
*/
|
|
||||||
export function readCloudDDScript() {
|
|
||||||
return `(() => {
|
|
||||||
const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/\\s+/g, ' ').replace(/ё/gi, 'е');
|
|
||||||
const panel = [...document.querySelectorAll('.cloudDD')].find(p => p.offsetWidth > 0 && p.offsetHeight > 0);
|
|
||||||
if (!panel) return { error: 'no_clouddd' };
|
|
||||||
const items = [];
|
|
||||||
panel.querySelectorAll('.checkbox').forEach(chk => {
|
|
||||||
if (chk.offsetWidth === 0) return;
|
|
||||||
// The text label sits beside the checkbox — climb until a container has text.
|
|
||||||
let row = chk;
|
|
||||||
for (let k = 0; k < 4 && row.parentElement; k++) { row = row.parentElement; if ((row.innerText || '').trim()) break; }
|
|
||||||
const r = chk.getBoundingClientRect();
|
|
||||||
items.push({ text: norm(row.innerText || ''), checked: chk.classList.contains('select'),
|
|
||||||
x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) });
|
|
||||||
});
|
|
||||||
return items;
|
|
||||||
})()`;
|
|
||||||
}
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
// web-test core/click v1.22 — clickElement dispatcher: routes to spreadsheet / popup / grid-row / form-element / field-focus handlers by target kind.
|
|
||||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
|
||||||
|
|
||||||
import { page, ensureConnected, highlightMode } from './state.mjs';
|
|
||||||
import {
|
|
||||||
detectFormScript, findClickTargetScript, resolveGridScript,
|
|
||||||
readSubmenuScript, resolveCellTargetScript,
|
|
||||||
} from '../../dom.mjs';
|
|
||||||
import { dismissPendingErrors, checkForErrors } from './errors.mjs';
|
|
||||||
import { waitForStable } from './wait.mjs';
|
|
||||||
import { highlight, unhighlight } from '../recording/highlight.mjs';
|
|
||||||
import { modifierClick, returnFormState } from './helpers.mjs';
|
|
||||||
import {
|
|
||||||
clickGridGroupTarget, clickGridTreeNodeTarget, clickGridRowTarget,
|
|
||||||
} from '../table/click-row.mjs';
|
|
||||||
import { clickGridCell } from '../table/click-cell.mjs';
|
|
||||||
import {
|
|
||||||
clickConfirmationButton, tryClickPopupItem,
|
|
||||||
} from '../forms/click-popup.mjs';
|
|
||||||
import { clickFormTarget, focusFormField } from '../forms/click-form.mjs';
|
|
||||||
import {
|
|
||||||
clickSpreadsheetCell, findSpreadsheetCellByText,
|
|
||||||
} from '../spreadsheet/spreadsheet.mjs';
|
|
||||||
|
|
||||||
/** Click a button/hyperlink/tab on the current form. Use {dblclick: true} to double-click (open items from lists).
|
|
||||||
* First argument can also be an object { row, column } to click a cell in a SpreadsheetDocument (отчёт) or a form grid (таблица/табчасть). */
|
|
||||||
export async function clickElement(text, { dblclick, table, toggle, expand, modifier, scroll, timeout } = {}) {
|
|
||||||
ensureConnected();
|
|
||||||
|
|
||||||
// Dispatch to cell handler when first arg is { row, column }.
|
|
||||||
// Routing (see resolveCellTargetScript):
|
|
||||||
// - `table` named: matches grid → grid cell; falls back to spreadsheet if it's the spreadsheet's name.
|
|
||||||
// - no `table`: form has spreadsheet → spreadsheet cell (backward-compat);
|
|
||||||
// else first visible grid → grid cell.
|
|
||||||
if (typeof text === 'object' && text !== null && text.column != null) {
|
|
||||||
await dismissPendingErrors();
|
|
||||||
const formNum = await page.evaluate(detectFormScript());
|
|
||||||
if (formNum === null) throw new Error('clickElement: no form found');
|
|
||||||
const route = await page.evaluate(resolveCellTargetScript(formNum, table));
|
|
||||||
if (route.error === 'table_not_found') {
|
|
||||||
throw new Error(`clickElement: table "${table}" not found. Available grids: ${(route.availableGrids || []).join(', ') || 'none'}`);
|
|
||||||
}
|
|
||||||
if (route.error) {
|
|
||||||
throw new Error(`clickElement: no spreadsheet or grid on form to click cell in.`);
|
|
||||||
}
|
|
||||||
if (route.kind === 'spreadsheet') {
|
|
||||||
return clickSpreadsheetCell(text, { dblclick, modifier });
|
|
||||||
}
|
|
||||||
// route.kind === 'grid'
|
|
||||||
return clickGridCell(text, {
|
|
||||||
formNum,
|
|
||||||
gridSelector: route.gridSelector,
|
|
||||||
gridName: route.gridName,
|
|
||||||
modifier, dblclick, scroll,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await dismissPendingErrors();
|
|
||||||
if (highlightMode) {
|
|
||||||
try { await highlight(text, { table }); await page.waitForTimeout(500); await unhighlight(); } catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 1. Intercept open confirmation dialog (Да/Нет/Отмена) — match button by text.
|
|
||||||
const pending = await checkForErrors();
|
|
||||||
if (pending?.confirmation) {
|
|
||||||
return await clickConfirmationButton(text);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Intercept open popup (from previous submenu/split-button click).
|
|
||||||
// Returns null if popup is open but `text` doesn't match — fall through.
|
|
||||||
const popupItems = await page.evaluate(readSubmenuScript());
|
|
||||||
if (Array.isArray(popupItems) && popupItems.length > 0) {
|
|
||||||
const popupResult = await tryClickPopupItem(text, popupItems);
|
|
||||||
if (popupResult) return popupResult;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Find a target on the current form.
|
|
||||||
let formNum = await page.evaluate(detectFormScript());
|
|
||||||
if (formNum === null) throw new Error(`clickElement: no form found`);
|
|
||||||
|
|
||||||
let gridSelector;
|
|
||||||
if (table) {
|
|
||||||
const resolved = await page.evaluate(resolveGridScript(formNum, table));
|
|
||||||
if (resolved.error) throw new Error(`clickElement: table "${table}" not found. Available: ${resolved.available?.map(a => a.name).join(', ') || 'none'}`);
|
|
||||||
gridSelector = resolved.gridSelector;
|
|
||||||
}
|
|
||||||
|
|
||||||
let target = await page.evaluate(findClickTargetScript(formNum, text, { tableName: table, gridSelector }));
|
|
||||||
|
|
||||||
// Retry: if not found, a modal form may still be loading (e.g. after F4).
|
|
||||||
if (target?.error) {
|
|
||||||
for (let retry = 0; retry < 4; retry++) {
|
|
||||||
await page.waitForTimeout(500);
|
|
||||||
const newForm = await page.evaluate(detectFormScript());
|
|
||||||
if (newForm !== null && newForm !== formNum) {
|
|
||||||
formNum = newForm;
|
|
||||||
target = await page.evaluate(findClickTargetScript(formNum, text, { tableName: table, gridSelector }));
|
|
||||||
if (!target?.error) break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Spreadsheet fallback: search iframes for text match before giving up.
|
|
||||||
if (target?.error) {
|
|
||||||
const ssCell = await findSpreadsheetCellByText(formNum, text);
|
|
||||||
if (ssCell) {
|
|
||||||
const cx = ssCell.box.x + ssCell.box.width / 2;
|
|
||||||
const cy = ssCell.box.y + ssCell.box.height / 2;
|
|
||||||
await modifierClick(cx, cy, modifier, { dbl: !!dblclick });
|
|
||||||
await waitForStable();
|
|
||||||
return returnFormState({
|
|
||||||
clicked: { kind: 'spreadsheetCell', name: ssCell.text, ...(dblclick ? { dblclick: true } : {}) },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
throw new Error(`clickElement: "${text}" not found. Available: ${target.available?.join(', ') || 'none'}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Dispatch to the right handler by target kind.
|
|
||||||
const ctx = { formNum, modifier, dblclick, toggle, expand, timeout, table, gridSelector };
|
|
||||||
if (target.kind === 'gridGroup' || target.kind === 'gridParent') return await clickGridGroupTarget(target, ctx);
|
|
||||||
if (target.kind === 'gridTreeNode') return await clickGridTreeNodeTarget(target, ctx);
|
|
||||||
if (target.kind === 'gridRow') return await clickGridRowTarget(target, ctx);
|
|
||||||
if (target.kind === 'field') return await focusFormField(target, ctx);
|
|
||||||
return await clickFormTarget(target, ctx);
|
|
||||||
} finally {
|
|
||||||
if (highlightMode) try { await unhighlight(); } catch {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
// web-test engine/core/clipboard v1.17 — OS-clipboard preservation around trusted paste.
|
|
||||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
|
||||||
//
|
|
||||||
// pasteText() — the only path 1C respects for autocomplete and Cyrillic input.
|
|
||||||
// saveClipboard/restoreClipboard preserve full clipboard contents (all MIME
|
|
||||||
// types) around the writeText+Ctrl+V pair so a user's concurrent Ctrl+C isn't
|
|
||||||
// clobbered. Blobs are stashed on `window` to avoid CDP serialization.
|
|
||||||
|
|
||||||
import {
|
|
||||||
page, preserveClipboard, clipboardWarnLogged, setClipboardWarnLogged,
|
|
||||||
} from './state.mjs';
|
|
||||||
|
|
||||||
export async function saveClipboard() {
|
|
||||||
if (!page) return;
|
|
||||||
try {
|
|
||||||
await page.evaluate(async () => {
|
|
||||||
try {
|
|
||||||
const items = await navigator.clipboard.read();
|
|
||||||
const saved = [];
|
|
||||||
for (const item of items) {
|
|
||||||
const types = {};
|
|
||||||
for (const t of item.types) types[t] = await item.getType(t);
|
|
||||||
saved.push(types);
|
|
||||||
}
|
|
||||||
window.__webTestSavedClipboard = saved;
|
|
||||||
delete window.__webTestClipboardError;
|
|
||||||
} catch (e) {
|
|
||||||
window.__webTestSavedClipboard = null;
|
|
||||||
window.__webTestClipboardError = e?.name || String(e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
// page.evaluate itself failed (closed page, navigation in flight) — skip.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function restoreClipboard() {
|
|
||||||
if (!page) return;
|
|
||||||
let err = null;
|
|
||||||
try {
|
|
||||||
err = await page.evaluate(async () => {
|
|
||||||
const saved = window.__webTestSavedClipboard;
|
|
||||||
const captured = window.__webTestClipboardError || null;
|
|
||||||
delete window.__webTestSavedClipboard;
|
|
||||||
delete window.__webTestClipboardError;
|
|
||||||
try {
|
|
||||||
if (!saved || saved.length === 0) {
|
|
||||||
// Save failed (e.g. CF_HDROP from Explorer not readable via Clipboard API)
|
|
||||||
// or buffer was empty. Either way, the test's writeText already destroyed
|
|
||||||
// any prior native formats in the OS clipboard, so explicitly clear here
|
|
||||||
// to avoid leaking the test value into the user's clipboard.
|
|
||||||
await navigator.clipboard.writeText('');
|
|
||||||
return captured;
|
|
||||||
}
|
|
||||||
const items = saved.map(types => new ClipboardItem(types));
|
|
||||||
await navigator.clipboard.write(items);
|
|
||||||
return null;
|
|
||||||
} catch (e) {
|
|
||||||
return e?.name || String(e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (err && !clipboardWarnLogged) {
|
|
||||||
setClipboardWarnLogged(true);
|
|
||||||
console.error(`[web-test] clipboard preserve skipped: ${err} (logged once per session)`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Paste `text` via OS clipboard (the only trusted-paste path that 1C respects
|
|
||||||
* for autocomplete and Cyrillic). Wraps the writeText+confirm-key pair in a
|
|
||||||
* narrow save/restore so a user's clipboard survives the test run — the window
|
|
||||||
* between save and restore is microseconds.
|
|
||||||
*
|
|
||||||
* - `confirm` — key (string) or sequence (array) to press after writeText.
|
|
||||||
* Defaults to 'Control+V'. Use ['Control+a', 'Control+v'] for select-all-then-paste,
|
|
||||||
* or 'Shift+F11' for the goto-link dialog.
|
|
||||||
* - `postDelay` — ms to wait between confirm-press and restore, for dialogs
|
|
||||||
* that read clipboard asynchronously (e.g. Shift+F11). Default 0.
|
|
||||||
*/
|
|
||||||
export async function pasteText(text, { confirm = 'Control+V', postDelay = 0 } = {}) {
|
|
||||||
if (!page) return;
|
|
||||||
if (preserveClipboard) await saveClipboard();
|
|
||||||
try {
|
|
||||||
await page.evaluate(`navigator.clipboard.writeText(${JSON.stringify(String(text))})`);
|
|
||||||
if (Array.isArray(confirm)) {
|
|
||||||
for (const key of confirm) await page.keyboard.press(key);
|
|
||||||
} else if (confirm) {
|
|
||||||
await page.keyboard.press(confirm);
|
|
||||||
}
|
|
||||||
if (postDelay) await page.waitForTimeout(postDelay);
|
|
||||||
} finally {
|
|
||||||
if (preserveClipboard) await restoreClipboard();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,310 +0,0 @@
|
|||||||
// web-test core/errors v1.18 — Error/modal/platform-dialog handling: dismiss, detect, fetch stack from 1C UI.
|
|
||||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
|
||||||
|
|
||||||
import { page } from './state.mjs';
|
|
||||||
import { checkErrorsScript } from '../../dom.mjs';
|
|
||||||
import {
|
|
||||||
getOpenReportCoordsScript, isErrorDetailLinkVisibleScript,
|
|
||||||
readLargestVisibleTextareaScript, clickTopCloudOkButtonScript,
|
|
||||||
clickReportCloseButtonScript,
|
|
||||||
} from '../../dom/errors-stack.mjs';
|
|
||||||
import { waitForStable } from './wait.mjs';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Close startup modals and guide tabs.
|
|
||||||
* Strategy: Escape → click default buttons → close extra tabs → repeat.
|
|
||||||
*/
|
|
||||||
export async function closeModals() {
|
|
||||||
for (let attempt = 0; attempt < 5; attempt++) {
|
|
||||||
// 1. Press Escape to dismiss any popup/modal
|
|
||||||
await page.keyboard.press('Escape');
|
|
||||||
await page.waitForTimeout(1000);
|
|
||||||
|
|
||||||
// 2. Try clicking default "Закрыть"/"OK" buttons
|
|
||||||
const clicked = await page.evaluate(`(() => {
|
|
||||||
const btns = [...document.querySelectorAll('a.press.pressDefault')].filter(el => el.offsetWidth > 0);
|
|
||||||
for (const btn of btns) {
|
|
||||||
const text = (btn.innerText?.trim() || '').toLowerCase();
|
|
||||||
if (['закрыть', 'ok', 'ок', 'нет', 'отмена'].includes(text)) {
|
|
||||||
btn.click();
|
|
||||||
return text;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
})()`);
|
|
||||||
if (clicked) { await page.waitForTimeout(1000); continue; }
|
|
||||||
|
|
||||||
// 3. Close extra tabs (Путеводитель etc.) via openedClose button
|
|
||||||
const tabClosed = await page.evaluate(`(() => {
|
|
||||||
const btn = document.querySelector('.openedClose');
|
|
||||||
if (btn && btn.offsetWidth > 0) { btn.click(); return true; }
|
|
||||||
return false;
|
|
||||||
})()`);
|
|
||||||
if (tabClosed) { await page.waitForTimeout(1000); continue; }
|
|
||||||
|
|
||||||
// Nothing to close — done
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check for validation errors / diagnostics after an action.
|
|
||||||
* Detects: inline balloon tooltip, messages panel, modal error dialog.
|
|
||||||
* Returns { balloon, messages[], modal } or null.
|
|
||||||
*/
|
|
||||||
export async function checkForErrors() {
|
|
||||||
return await page.evaluate(checkErrorsScript());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Dismiss pending error modal if present (single OK button dialog).
|
|
||||||
* Called at the start of action functions so that a leftover error modal
|
|
||||||
* from a previous operation doesn't block the next action.
|
|
||||||
* Does NOT dismiss confirmations (Да/Нет — require user decision).
|
|
||||||
* Returns the dismissed error object or null.
|
|
||||||
*/
|
|
||||||
export async function dismissPendingErrors() {
|
|
||||||
// Close leftover platform dialogs first (About, Support Info, Error Report)
|
|
||||||
// These block all interaction via modalSurface and are invisible to 1C form detection
|
|
||||||
try {
|
|
||||||
const pd = await detectPlatformDialogs();
|
|
||||||
if (pd.length) await closePlatformDialogs();
|
|
||||||
} catch { /* OK */ }
|
|
||||||
const err = await checkForErrors();
|
|
||||||
if (!err?.modal) return null;
|
|
||||||
try {
|
|
||||||
// Target pressDefault within the modal's form container specifically
|
|
||||||
const formNum = err.modal.formNum;
|
|
||||||
const sel = formNum != null
|
|
||||||
? `#form${formNum}_container a.press.pressDefault`
|
|
||||||
: 'a.press.pressDefault';
|
|
||||||
const btn = await page.$(sel);
|
|
||||||
if (btn) { await btn.click({ force: true }); await page.waitForTimeout(500); }
|
|
||||||
} catch { /* OK */ }
|
|
||||||
await waitForStable();
|
|
||||||
return err;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Detect open platform-level dialogs (About, Support Info, Error Report).
|
|
||||||
* Returns array of { type, title? } for each detected dialog, or empty array.
|
|
||||||
*/
|
|
||||||
export async function detectPlatformDialogs() {
|
|
||||||
return await page.evaluate(() => {
|
|
||||||
const result = [];
|
|
||||||
// "О программе" dialog
|
|
||||||
const about = document.getElementById('aboutContainer');
|
|
||||||
if (about && about.offsetWidth > 0) result.push({ type: 'about', title: 'О программе' });
|
|
||||||
// "Информация для технической поддержки" (inside a ps*win with errJournalInput)
|
|
||||||
const errJ = document.getElementById('errJournalInput');
|
|
||||||
if (errJ && errJ.offsetWidth > 0) result.push({ type: 'supportInfo', title: 'Информация для технической поддержки' });
|
|
||||||
// "Отчет об ошибке" / "Подробный текст ошибки" — ps*win cloud windows without aboutContainer
|
|
||||||
if (!result.length) {
|
|
||||||
document.querySelectorAll('[id^="ps"][id$="win"]').forEach(w => {
|
|
||||||
if (w.offsetWidth === 0 || w.offsetHeight === 0) return;
|
|
||||||
// Skip the main app window (ps*win that contains the 1C forms)
|
|
||||||
if (w.querySelector('[id^="form"][id$="_container"]')) return;
|
|
||||||
// Check title text
|
|
||||||
const titleEl = w.querySelector('[id$="headerTopLine_cmd_Title"]');
|
|
||||||
const title = titleEl?.textContent?.trim() || '';
|
|
||||||
if (title) result.push({ type: 'platformWindow', title });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Close any platform-level dialogs that may be left open (about, support info, error report).
|
|
||||||
* These are NOT 1C forms — they are platform UI overlays invisible to getFormState().
|
|
||||||
* Each close is wrapped in try/catch to avoid cascading failures.
|
|
||||||
*/
|
|
||||||
export async function closePlatformDialogs() {
|
|
||||||
await page.evaluate(() => {
|
|
||||||
// "Подробный текст ошибки" OK button (inside error report detail view)
|
|
||||||
// It's a cloud window with its own OK button — look for visible pressDefault in small ps*win
|
|
||||||
const psWins = document.querySelectorAll('[id^="ps"][id$="win"]');
|
|
||||||
for (const w of psWins) {
|
|
||||||
if (w.offsetWidth === 0) continue;
|
|
||||||
// Check if this is a small dialog (error detail, about, support info)
|
|
||||||
const closeBtn = w.querySelector('[id$="_cmd_CloseButton"]');
|
|
||||||
if (closeBtn && closeBtn.offsetWidth > 0) {
|
|
||||||
try { closeBtn.click(); } catch {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// "Информация для технической поддержки" — extOkBtn
|
|
||||||
const extOk = document.getElementById('extOkBtn');
|
|
||||||
if (extOk && extOk.offsetWidth > 0) try { extOk.click(); } catch {}
|
|
||||||
// "О программе" — aboutOkButton
|
|
||||||
const aboutOk = document.getElementById('aboutOkButton');
|
|
||||||
if (aboutOk && aboutOk.offsetWidth > 0) try { aboutOk.click(); } catch {}
|
|
||||||
});
|
|
||||||
await page.waitForTimeout(300);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse raw error stack text into structured entries.
|
|
||||||
* Input: raw text from errJournalInput (first block) or "Подробный текст ошибки" textarea.
|
|
||||||
* Returns { raw, timestamp?, entries: [{location, code}] }
|
|
||||||
*/
|
|
||||||
function parseErrorStack(raw) {
|
|
||||||
if (!raw) return null;
|
|
||||||
const result = { raw, entries: [] };
|
|
||||||
// Extract timestamp if present (format: DD.MM.YYYY HH:MM:SS)
|
|
||||||
const tsMatch = raw.match(/^(\d{2}\.\d{2}\.\d{4}\s+\d{1,2}:\d{2}:\d{2})/m);
|
|
||||||
if (tsMatch) result.timestamp = tsMatch[1];
|
|
||||||
// Extract {Module.Path(lineNum)}: code entries
|
|
||||||
const entryRe = /\{([^}]+)\}:\s*(.+)/g;
|
|
||||||
let m;
|
|
||||||
while ((m = entryRe.exec(raw)) !== null) {
|
|
||||||
result.entries.push({ location: m[1].trim(), code: m[2].trim() });
|
|
||||||
}
|
|
||||||
return result.entries.length > 0 ? result : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch error call stack from the 1C platform UI.
|
|
||||||
* Uses two strategies:
|
|
||||||
* Path 1 (hasReport=true): Click OpenReport link → "подробный текст ошибки" → read textarea
|
|
||||||
* Path 2 (fallback): Hamburger → "О программе" → "Информация для техподдержки" → errJournalInput
|
|
||||||
*
|
|
||||||
* Always closes the error modal and any platform dialogs it opened.
|
|
||||||
* Returns parsed stack object or null on failure.
|
|
||||||
*
|
|
||||||
* @param {number} formNum - form number of the error modal (e.g. 6 for form6_)
|
|
||||||
* @param {boolean} hasReport - true if OpenReport link is available
|
|
||||||
*/
|
|
||||||
export async function fetchErrorStack(formNum, hasReport) {
|
|
||||||
try {
|
|
||||||
// Platform exception modals are initially unstable — they redraw within ~1s.
|
|
||||||
// The initial state may lack the OpenReport link. Re-check after a short delay.
|
|
||||||
if (!hasReport) {
|
|
||||||
await page.waitForTimeout(1500);
|
|
||||||
hasReport = await page.evaluate((fn) => {
|
|
||||||
const el = document.getElementById('form' + fn + '_OpenReport#text');
|
|
||||||
return !!(el && el.offsetWidth > 2 && el.textContent.trim());
|
|
||||||
}, formNum);
|
|
||||||
}
|
|
||||||
if (hasReport) return await fetchStackViaReport(formNum);
|
|
||||||
return await fetchStackViaHamburger(formNum);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
} finally {
|
|
||||||
// Ensure all platform dialogs are closed
|
|
||||||
try { await closePlatformDialogs(); } catch {}
|
|
||||||
// Ensure the error modal itself is closed
|
|
||||||
try {
|
|
||||||
const sel = formNum != null
|
|
||||||
? `#form${formNum}_container a.press.pressDefault`
|
|
||||||
: 'a.press.pressDefault';
|
|
||||||
const btn = await page.$(sel);
|
|
||||||
if (btn) await btn.click({ force: true });
|
|
||||||
await page.waitForTimeout(300);
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Path 1: Fetch stack via OpenReport link (for platform exceptions).
|
|
||||||
* The error modal must still be open with a visible "Сформировать отчет об ошибке" link.
|
|
||||||
*/
|
|
||||||
async function fetchStackViaReport(formNum) {
|
|
||||||
// 1. Get coordinates of the OpenReport link and click via mouse (modalSurface blocks JS clicks)
|
|
||||||
const coords = await page.evaluate(getOpenReportCoordsScript(formNum));
|
|
||||||
if (!coords) return null;
|
|
||||||
|
|
||||||
await page.mouse.click(coords.x, coords.y);
|
|
||||||
|
|
||||||
// 2. Wait for "Отчет об ошибке" dialog — look for "подробный текст ошибки" link
|
|
||||||
let found = false;
|
|
||||||
for (let i = 0; i < 20; i++) {
|
|
||||||
await page.waitForTimeout(500);
|
|
||||||
found = await page.evaluate(isErrorDetailLinkVisibleScript());
|
|
||||||
if (found) break;
|
|
||||||
}
|
|
||||||
if (!found) return null;
|
|
||||||
|
|
||||||
// 3. Click "подробный текст ошибки"
|
|
||||||
await page.getByText('подробный текст ошибки').click();
|
|
||||||
await page.waitForTimeout(2000);
|
|
||||||
|
|
||||||
// 4. Read the textarea with detailed error text (find the largest visible textarea)
|
|
||||||
const raw = await page.evaluate(readLargestVisibleTextareaScript());
|
|
||||||
|
|
||||||
// 5. Close "Подробный текст ошибки" dialog (click its OK button)
|
|
||||||
try {
|
|
||||||
await page.evaluate(clickTopCloudOkButtonScript());
|
|
||||||
await page.waitForTimeout(300);
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
// 6. Close "Отчет об ошибке" dialog (click its × close button)
|
|
||||||
try {
|
|
||||||
await page.evaluate(clickReportCloseButtonScript());
|
|
||||||
await page.waitForTimeout(300);
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
return parseErrorStack(raw);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Path 2: Fetch stack via hamburger menu → "О программе" → "Информация для техподдержки".
|
|
||||||
* Works for all error types including simple ВызватьИсключение.
|
|
||||||
* The error modal is closed first to allow access to the hamburger menu.
|
|
||||||
*/
|
|
||||||
async function fetchStackViaHamburger(formNum) {
|
|
||||||
// 1. Close the error modal first
|
|
||||||
try {
|
|
||||||
const sel = formNum != null
|
|
||||||
? `#form${formNum}_container a.press.pressDefault`
|
|
||||||
: 'a.press.pressDefault';
|
|
||||||
const btn = await page.$(sel);
|
|
||||||
if (btn) await btn.click({ force: true });
|
|
||||||
await page.waitForTimeout(500);
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
// 2. Click hamburger menu
|
|
||||||
await page.click('#captionbarMore', { timeout: 5000 });
|
|
||||||
await page.waitForTimeout(1000);
|
|
||||||
|
|
||||||
// 3. Click "О программе..."
|
|
||||||
await page.getByText('О программе...', { exact: true }).click({ timeout: 5000 });
|
|
||||||
await page.waitForTimeout(2000);
|
|
||||||
|
|
||||||
// 4. Click "Информация для технической поддержки"
|
|
||||||
await page.click('#aboutHyperLink', { timeout: 5000 });
|
|
||||||
|
|
||||||
// 5. Wait for errJournalInput to appear and be filled
|
|
||||||
let raw = null;
|
|
||||||
for (let i = 0; i < 20; i++) {
|
|
||||||
await page.waitForTimeout(500);
|
|
||||||
raw = await page.evaluate(() => {
|
|
||||||
const el = document.getElementById('errJournalInput');
|
|
||||||
return (el && el.offsetWidth > 0 && el.value.length > 50) ? el.value : null;
|
|
||||||
});
|
|
||||||
if (raw) break;
|
|
||||||
}
|
|
||||||
if (!raw) return null;
|
|
||||||
|
|
||||||
// 6. Parse first error block (most recent — before first separator)
|
|
||||||
const separator = / - - - - /;
|
|
||||||
const errSection = raw.indexOf('\n\n') !== -1 ? raw.substring(raw.indexOf('\n\n')) : raw;
|
|
||||||
// Find the "Ошибки:" section
|
|
||||||
const errIdx = raw.indexOf('Ошибки:');
|
|
||||||
let errorText = errIdx !== -1 ? raw.substring(errIdx + 'Ошибки:'.length).trim() : raw;
|
|
||||||
// Take first block (before first separator line)
|
|
||||||
const lines = errorText.split('\n');
|
|
||||||
const firstBlockLines = [];
|
|
||||||
let inBlock = false;
|
|
||||||
for (const line of lines) {
|
|
||||||
if (separator.test(line)) {
|
|
||||||
if (inBlock) break; // end of first block
|
|
||||||
inBlock = true;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (inBlock) firstBlockLines.push(line);
|
|
||||||
}
|
|
||||||
const firstBlock = firstBlockLines.join('\n').trim();
|
|
||||||
|
|
||||||
// 7. Close support info and about dialogs (done in finally via closePlatformDialogs)
|
|
||||||
return parseErrorStack(firstBlock || errorText);
|
|
||||||
}
|
|
||||||
@@ -1,178 +0,0 @@
|
|||||||
// web-test core/helpers v1.21 — private, cross-cutting helpers used by the
|
|
||||||
// public action functions (clickElement/fillFields/selectValue/etc).
|
|
||||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
|
||||||
|
|
||||||
import { page } from './state.mjs';
|
|
||||||
import { dismissPendingErrors, checkForErrors } from './errors.mjs';
|
|
||||||
import { getFormState } from '../forms/state.mjs';
|
|
||||||
import {
|
|
||||||
detectNewFormScript,
|
|
||||||
isInputFocusedScript,
|
|
||||||
isInputFocusedInGridScript,
|
|
||||||
findOpenPopupScript,
|
|
||||||
readEddScript,
|
|
||||||
isEddVisibleScript,
|
|
||||||
clickEddItemViaDispatchScript,
|
|
||||||
clickShowAllInEddScript,
|
|
||||||
} from '../../dom.mjs';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* page.click with the standard "intercepts pointer events" retry ladder:
|
|
||||||
* normal → force → Escape (+ optional dismissPendingErrors) → normal.
|
|
||||||
* Mirrors the three hand-written copies in fillReferenceField, clickElement
|
|
||||||
* and the DLB branch of selectValue.
|
|
||||||
*
|
|
||||||
* @param {string} selector
|
|
||||||
* @param {object} [opts]
|
|
||||||
* @param {number} [opts.timeout] — passed through to page.click
|
|
||||||
* @param {boolean} [opts.dismissErrors=false] — call dismissPendingErrors()
|
|
||||||
* before pressing Escape on the second retry (used in fillReferenceField).
|
|
||||||
*/
|
|
||||||
export async function safeClick(selector, { timeout, dismissErrors = false } = {}) {
|
|
||||||
const baseOpts = timeout != null ? { timeout } : {};
|
|
||||||
try {
|
|
||||||
await page.click(selector, baseOpts);
|
|
||||||
} catch (e) {
|
|
||||||
if (!e.message.includes('intercepts pointer events')) throw e;
|
|
||||||
try {
|
|
||||||
await page.click(selector, { ...baseOpts, force: true });
|
|
||||||
} catch (e2) {
|
|
||||||
if (!e2.message.includes('intercepts pointer events')) throw e2;
|
|
||||||
if (dismissErrors) await dismissPendingErrors();
|
|
||||||
await page.keyboard.press('Escape');
|
|
||||||
await page.waitForTimeout(500);
|
|
||||||
await page.click(selector, baseOpts);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find a form field's input element id by name. Tries `form{N}_{name}` first,
|
|
||||||
* then `form{N}_{name}_i0` (reference fields use the _i0 suffix). Returns the
|
|
||||||
* element id or null. Used in selectValue's clear/composite-type/F4 fallback
|
|
||||||
* branches.
|
|
||||||
*
|
|
||||||
* @param {number} formNum
|
|
||||||
* @param {string} fieldName
|
|
||||||
* @returns {Promise<string|null>}
|
|
||||||
*/
|
|
||||||
export async function findFieldInputId(formNum, fieldName) {
|
|
||||||
return await page.evaluate(`(() => {
|
|
||||||
const p = 'form${formNum}_';
|
|
||||||
const name = ${JSON.stringify(fieldName)};
|
|
||||||
const el = document.querySelector('[id="' + p + name + '"], [id="' + p + name + '_i0"]');
|
|
||||||
return el ? el.id : null;
|
|
||||||
})()`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Detect a new form opened above the given `prevFormNum`. Two modes:
|
|
||||||
* `{ strict: true }` — only counts visible interactive elements
|
|
||||||
* (`input.editInput[id], a.press[id]`); used by fillReferenceField.
|
|
||||||
* default (broad) — any element with `id^=form{N}_` that's visible
|
|
||||||
* in either dimension; also finds type-dialogs whose a.press buttons
|
|
||||||
* have empty IDs. Used by selectValue / fillTableRow.
|
|
||||||
*
|
|
||||||
* @param {number} prevFormNum
|
|
||||||
* @param {object} [opts]
|
|
||||||
* @param {boolean} [opts.strict=false]
|
|
||||||
* @returns {Promise<number|null>} new form number or null
|
|
||||||
*/
|
|
||||||
export async function detectNewForm(prevFormNum, { strict = false } = {}) {
|
|
||||||
return page.evaluate(detectNewFormScript(prevFormNum, { strict }));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Thin wrapper: is the currently focused element an INPUT (or TEXTAREA)?
|
|
||||||
*
|
|
||||||
* @param {object} [opts]
|
|
||||||
* @param {boolean} [opts.allowTextarea=false]
|
|
||||||
*/
|
|
||||||
export async function isInputFocused({ allowTextarea = false } = {}) {
|
|
||||||
return page.evaluate(isInputFocusedScript({ allowTextarea }));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Thin wrapper: is the currently focused INPUT/TEXTAREA inside a `.grid`?
|
|
||||||
* Used to verify grid edit-mode. Pass `{ gridSelector }` to scope the check
|
|
||||||
* to a specific grid (when a form has multiple grids).
|
|
||||||
*/
|
|
||||||
export async function isInputFocusedInGrid({ gridSelector } = {}) {
|
|
||||||
return page.evaluate(isInputFocusedInGridScript(gridSelector));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Thin wrapper: is calculator (`.calculate`) or calendar (`.frameCalendar`)
|
|
||||||
* popup visible? Returns `'calculator' | 'calendar' | null`.
|
|
||||||
*/
|
|
||||||
export async function findOpenPopup() {
|
|
||||||
return page.evaluate(findOpenPopupScript());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read the `#editDropDown` autocomplete popup. Returns whether it's visible
|
|
||||||
* and, when visible, an array of `.eddText` items with display name and
|
|
||||||
* center coordinates (suitable for page.mouse.click).
|
|
||||||
*
|
|
||||||
* @returns {Promise<{visible: boolean, items?: Array<{name:string, x:number, y:number}>}>}
|
|
||||||
*/
|
|
||||||
export async function readEdd() {
|
|
||||||
return page.evaluate(readEddScript());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Thin wrapper: is the EDD popup currently visible?
|
|
||||||
* Lighter than `readEdd` when only presence matters.
|
|
||||||
*/
|
|
||||||
export async function isEddVisible() {
|
|
||||||
return page.evaluate(isEddVisibleScript());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Click an EDD item by name via dispatchEvent (bypasses div.surface overlays).
|
|
||||||
* Returns the clicked item's innerText, or `null` if no match.
|
|
||||||
*/
|
|
||||||
export async function clickEddItemViaDispatch(itemName) {
|
|
||||||
return page.evaluate(clickEddItemViaDispatchScript(itemName));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Click the "Показать все" / "Show all" link in the EDD footer.
|
|
||||||
* Returns boolean.
|
|
||||||
*/
|
|
||||||
export async function clickShowAllInEdd() {
|
|
||||||
return page.evaluate(clickShowAllInEddScript());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Standard "tail" of action functions: fetch current form state, attach
|
|
||||||
* caller-specified extras (e.g. `{ clicked: {...} }`) and the result of
|
|
||||||
* `checkForErrors()` if any. Returns the flat state object.
|
|
||||||
*
|
|
||||||
* Unifies ~15 hand-written copies in clickElement, selectValue, closeForm,
|
|
||||||
* navigation functions, etc. Also closes R1/R2/R3 from the refactor plan —
|
|
||||||
* any caller using this helper gets `state.errors` for free.
|
|
||||||
*
|
|
||||||
* @param {object} [extras] — merged into the state object via Object.assign.
|
|
||||||
* @returns {Promise<object>} form state (flat) with optional `errors`.
|
|
||||||
*/
|
|
||||||
export async function returnFormState(extras = {}) {
|
|
||||||
const state = await getFormState();
|
|
||||||
Object.assign(state, extras);
|
|
||||||
const err = await checkForErrors();
|
|
||||||
if (err) state.errors = err;
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mouse click at (x, y) with an optional modifier key held down for the duration.
|
|
||||||
* Supports `'ctrl'` / `'shift'` (used by clickElement for multi-select).
|
|
||||||
* Pass `{ dbl: true }` for double-click.
|
|
||||||
*/
|
|
||||||
export async function modifierClick(x, y, modifier, { dbl = false } = {}) {
|
|
||||||
const modKey = modifier === 'ctrl' ? 'Control' : modifier === 'shift' ? 'Shift' : null;
|
|
||||||
if (modKey) await page.keyboard.down(modKey);
|
|
||||||
if (dbl) await page.mouse.dblclick(x, y);
|
|
||||||
else await page.mouse.click(x, y);
|
|
||||||
if (modKey) await page.keyboard.up(modKey);
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
// web-test core/scroll-horiz v1.0 — horizontal scroll loop helper for grids and spreadsheets.
|
|
||||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
|
||||||
//
|
|
||||||
// 1С scrolls horizontally by shifting absolute X coordinates of cells (not via
|
|
||||||
// scrollLeft). The only reliable way to drive this from outside is to press
|
|
||||||
// ArrowRight / ArrowLeft on a focused cell. Both SpreadsheetDocument and form
|
|
||||||
// grids share this mechanic, so the loop body is identical: press an arrow,
|
|
||||||
// wait, check visibility, bail when the cell stops moving (lost focus / hit edge).
|
|
||||||
//
|
|
||||||
// Callers handle their own focus setup (clicking a visible cell to put keyboard
|
|
||||||
// focus on the grid/spreadsheet), direction selection, and visibility queries.
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Press {direction} key in a loop until the target cell is fully visible or
|
|
||||||
* progress stalls.
|
|
||||||
*
|
|
||||||
* @param {object} opts
|
|
||||||
* @param {import('playwright').Page} opts.page
|
|
||||||
* @param {'ArrowRight'|'ArrowLeft'} opts.direction
|
|
||||||
* @param {() => Promise<boolean>} opts.isFullyVisible — true when target inside viewport
|
|
||||||
* @param {() => Promise<number|null>} opts.getCenterX — current target center X (page coords); null if cell vanished
|
|
||||||
* @param {number} [opts.maxPresses=100]
|
|
||||||
* @param {number} [opts.staleMax=5] — bail when center hasn't moved this many presses in a row
|
|
||||||
* @param {number} [opts.delayMs=50] — wait after each key press
|
|
||||||
* @param {number} [opts.finalDelayMs=200] — wait after the loop completes
|
|
||||||
*/
|
|
||||||
export async function scrollHorizontallyByKey({
|
|
||||||
page, direction,
|
|
||||||
isFullyVisible, getCenterX,
|
|
||||||
maxPresses = 100, staleMax = 5,
|
|
||||||
delayMs = 50, finalDelayMs = 200,
|
|
||||||
}) {
|
|
||||||
let prevCx = await getCenterX();
|
|
||||||
if (prevCx == null) return;
|
|
||||||
let stale = 0;
|
|
||||||
for (let i = 0; i < maxPresses; i++) {
|
|
||||||
await page.keyboard.press(direction);
|
|
||||||
await page.waitForTimeout(delayMs);
|
|
||||||
if (await isFullyVisible()) break;
|
|
||||||
const cx = await getCenterX();
|
|
||||||
if (cx == null) break;
|
|
||||||
if (Math.abs(cx - prevCx) >= 1) stale = 0;
|
|
||||||
else { stale++; if (stale >= staleMax) break; }
|
|
||||||
prevCx = cx;
|
|
||||||
}
|
|
||||||
await page.waitForTimeout(finalDelayMs);
|
|
||||||
}
|
|
||||||
@@ -1,404 +0,0 @@
|
|||||||
// web-test core/session v1.17 — Browser session lifecycle: connect/disconnect/attach/detach, multi-context registry.
|
|
||||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
|
||||||
|
|
||||||
import { chromium } from 'playwright';
|
|
||||||
import { statSync, mkdirSync, readdirSync, rmSync } from 'fs';
|
|
||||||
import { join as pathJoin } from 'path';
|
|
||||||
import { tmpdir } from 'os';
|
|
||||||
import {
|
|
||||||
browser, page, sessionPrefix, seanceId, recorder, highlightMode,
|
|
||||||
contexts, activeContextName, activeMode, persistentUserDataDir,
|
|
||||||
setBrowser, setPage, setSessionPrefix, setSeanceId, setHighlightMode,
|
|
||||||
setActiveContextName, setActiveMode, setPersistentUserDataDir,
|
|
||||||
isConnected, LOAD_TIMEOUT, INIT_TIMEOUT, EXT_ID,
|
|
||||||
} from './state.mjs';
|
|
||||||
import { closeModals } from './errors.mjs';
|
|
||||||
import { stopRecording } from '../recording/capture.mjs';
|
|
||||||
import { getPageState } from '../nav/navigation.mjs';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find the 1C browser extension in Chrome/Edge user profiles.
|
|
||||||
* Returns the path to the latest version, or null if not found.
|
|
||||||
* Can be overridden via extensionPath in .v8-project.json.
|
|
||||||
*/
|
|
||||||
function findExtension(overridePath) {
|
|
||||||
if (overridePath) {
|
|
||||||
try { if (statSync(overridePath).isDirectory()) return overridePath; } catch {}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const localAppData = process.env.LOCALAPPDATA;
|
|
||||||
if (!localAppData) return null;
|
|
||||||
const browsers = [
|
|
||||||
pathJoin(localAppData, 'Google', 'Chrome', 'User Data'),
|
|
||||||
pathJoin(localAppData, 'Microsoft', 'Edge', 'User Data'),
|
|
||||||
];
|
|
||||||
for (const userData of browsers) {
|
|
||||||
try { if (!statSync(userData).isDirectory()) continue; } catch { continue; }
|
|
||||||
let profiles;
|
|
||||||
try { profiles = readdirSync(userData).filter(d => d === 'Default' || d.startsWith('Profile ')); } catch { continue; }
|
|
||||||
for (const profile of profiles) {
|
|
||||||
const extDir = pathJoin(userData, profile, 'Extensions', EXT_ID);
|
|
||||||
try { if (!statSync(extDir).isDirectory()) continue; } catch { continue; }
|
|
||||||
let versions;
|
|
||||||
try { versions = readdirSync(extDir).filter(d => /^\d/.test(d)).sort(); } catch { continue; }
|
|
||||||
if (versions.length > 0) {
|
|
||||||
const best = pathJoin(extDir, versions[versions.length - 1]);
|
|
||||||
try { if (statSync(pathJoin(best, 'manifest.json')).isFile()) return best; } catch {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* isConnected moved to core/state.mjs */
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Open browser and navigate to 1C web client URL.
|
|
||||||
* Waits for initialization (themesCell_theme_0 selector) and attempts to close startup modals.
|
|
||||||
*/
|
|
||||||
export async function connect(url, { extensionPath } = {}) {
|
|
||||||
if (isConnected()) {
|
|
||||||
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: LOAD_TIMEOUT });
|
|
||||||
} else {
|
|
||||||
const extPath = findExtension(extensionPath);
|
|
||||||
if (extPath) {
|
|
||||||
// Launch with 1C browser extension via persistent context
|
|
||||||
setPersistentUserDataDir(pathJoin(tmpdir(), 'pw-1c-ext-' + Date.now()));
|
|
||||||
mkdirSync(persistentUserDataDir, { recursive: true });
|
|
||||||
const context = await chromium.launchPersistentContext(persistentUserDataDir, {
|
|
||||||
headless: false,
|
|
||||||
args: [
|
|
||||||
'--start-maximized',
|
|
||||||
'--disable-extensions-except=' + extPath,
|
|
||||||
'--load-extension=' + extPath,
|
|
||||||
],
|
|
||||||
viewport: null,
|
|
||||||
permissions: ['clipboard-read', 'clipboard-write'],
|
|
||||||
});
|
|
||||||
setBrowser(context); // persistent context IS the browser
|
|
||||||
setPage(context.pages()[0] || await context.newPage());
|
|
||||||
} else {
|
|
||||||
// Fallback: launch without extension
|
|
||||||
setBrowser(await chromium.launch({ headless: false, args: ['--start-maximized'] }));
|
|
||||||
const context = await browser.newContext({
|
|
||||||
viewport: null,
|
|
||||||
permissions: ['clipboard-read', 'clipboard-write'],
|
|
||||||
});
|
|
||||||
setPage(await context.newPage());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-accept native browser dialogs (confirm/alert from 1C scripts like vis.js)
|
|
||||||
page.on('dialog', dialog => dialog.accept().catch(() => {}));
|
|
||||||
|
|
||||||
// Capture seanceId from network requests for graceful logout
|
|
||||||
setSessionPrefix(null);
|
|
||||||
setSeanceId(null);
|
|
||||||
page.on('request', req => {
|
|
||||||
if (seanceId) return;
|
|
||||||
const m = req.url().match(/^(https?:\/\/[^/]+\/[^/]+\/[^/]+)\/e1cib\/.+[?&]seanceId=([^&]+)/);
|
|
||||||
if (m) { setSessionPrefix(m[1]); setSeanceId(m[2]); }
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: LOAD_TIMEOUT });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait for 1C to initialize — detect by section panel appearance
|
|
||||||
try {
|
|
||||||
await page.waitForSelector('#themesCell_theme_0', { timeout: INIT_TIMEOUT });
|
|
||||||
} catch {
|
|
||||||
// Fallback: wait fixed time if selector doesn't appear (e.g. login page)
|
|
||||||
await page.waitForTimeout(5000);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to close startup modals (Путеводитель etc.)
|
|
||||||
await closeModals();
|
|
||||||
|
|
||||||
return await getPageState();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Best-effort POST /e1cib/logout on a slot to release the 1C session license.
|
|
||||||
* Silent — if page is closed or session info missing, just returns.
|
|
||||||
* @param {object} slot { page, sessionPrefix, seanceId } from contexts Map
|
|
||||||
* @param {number} [waitMs=500] pause after logout fetch (gives 1C time to process)
|
|
||||||
*/
|
|
||||||
async function logoutSlot(slot, waitMs = 500) {
|
|
||||||
if (!slot?.page || slot.page.isClosed() || !slot.seanceId || !slot.sessionPrefix) return;
|
|
||||||
try {
|
|
||||||
const logoutUrl = `${slot.sessionPrefix}/e1cib/logout?seanceId=${slot.seanceId}`;
|
|
||||||
await slot.page.evaluate(async (url) => {
|
|
||||||
await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{"root":{}}' });
|
|
||||||
}, logoutUrl);
|
|
||||||
await slot.page.waitForTimeout(waitMs);
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gracefully terminate the 1C session and close the browser.
|
|
||||||
* Sends POST /e1cib/logout to release the license before closing.
|
|
||||||
*/
|
|
||||||
export async function disconnect() {
|
|
||||||
// Multi-context path: stop recording + logout each slot before closing browser
|
|
||||||
if (contexts.size > 0) {
|
|
||||||
saveActiveSlot();
|
|
||||||
// Recorder is global — one stop covers all contexts
|
|
||||||
if (recorder) {
|
|
||||||
try { await stopRecording(); } catch {}
|
|
||||||
}
|
|
||||||
for (const [, slot] of contexts.entries()) {
|
|
||||||
await logoutSlot(slot);
|
|
||||||
}
|
|
||||||
contexts.clear();
|
|
||||||
setActiveContextName(null);
|
|
||||||
setActiveMode(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Single-session path (connect): auto-stop recording if active
|
|
||||||
if (recorder) {
|
|
||||||
try { await stopRecording(); } catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (browser) {
|
|
||||||
// Graceful logout — release the 1C license (single-session connect path)
|
|
||||||
await logoutSlot({ page, sessionPrefix, seanceId }, 1000);
|
|
||||||
await browser.close().catch(() => {});
|
|
||||||
setBrowser(null);
|
|
||||||
setPage(null);
|
|
||||||
setSessionPrefix(null);
|
|
||||||
setSeanceId(null);
|
|
||||||
// Clean up persistent user data dir
|
|
||||||
if (persistentUserDataDir) {
|
|
||||||
try { rmSync(persistentUserDataDir, { recursive: true, force: true }); } catch {}
|
|
||||||
setPersistentUserDataDir(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Attach to a running browser server via CDP WebSocket.
|
|
||||||
* Sets module state so all functions (getFormState, clickElement, etc.) work.
|
|
||||||
*/
|
|
||||||
export async function attach(wsEndpoint, session = {}) {
|
|
||||||
if (isConnected()) return;
|
|
||||||
setBrowser(await chromium.connect(wsEndpoint));
|
|
||||||
const ctx = browser.contexts()[0];
|
|
||||||
setPage(ctx?.pages()[0]);
|
|
||||||
if (!page) throw new Error('No page found in browser');
|
|
||||||
setSessionPrefix(session.sessionPrefix || null);
|
|
||||||
setSeanceId(session.seanceId || null);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Detach from browser without closing it.
|
|
||||||
* Returns session state for persistence.
|
|
||||||
*/
|
|
||||||
export function detach() {
|
|
||||||
const session = { sessionPrefix, seanceId };
|
|
||||||
setBrowser(null);
|
|
||||||
setPage(null);
|
|
||||||
setSessionPrefix(null);
|
|
||||||
setSeanceId(null);
|
|
||||||
return session;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Get current session state (for saving between reconnections). */
|
|
||||||
export function getSession() {
|
|
||||||
return { sessionPrefix, seanceId };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// Multi-context support (used by run.mjs cmdTest only)
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Save current module-level state into the active slot before switching.
|
|
||||||
* No-op if no active slot.
|
|
||||||
*/
|
|
||||||
function saveActiveSlot() {
|
|
||||||
if (!activeContextName) return;
|
|
||||||
const slot = contexts.get(activeContextName);
|
|
||||||
if (!slot) return;
|
|
||||||
slot.page = page;
|
|
||||||
slot.sessionPrefix = sessionPrefix;
|
|
||||||
slot.seanceId = seanceId;
|
|
||||||
slot.highlightMode = highlightMode;
|
|
||||||
// Note: `recorder`, `lastCaptions`, `lastRecordingDuration` are intentionally NOT
|
|
||||||
// mirrored per-slot. A multi-context recording produces one continuous output file —
|
|
||||||
// the recorder follows the active page via recorder._attachPage(), not per-slot state.
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Load a slot's state into module-level vars and mark it active. */
|
|
||||||
function activateSlot(name) {
|
|
||||||
const slot = contexts.get(name);
|
|
||||||
if (!slot) throw new Error(`Context "${name}" not found. Create it via createContext() first.`);
|
|
||||||
setPage(slot.page);
|
|
||||||
setSessionPrefix(slot.sessionPrefix);
|
|
||||||
setSeanceId(slot.seanceId);
|
|
||||||
setHighlightMode(slot.highlightMode || false);
|
|
||||||
setActiveContextName(name);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Attach 1C session listeners to a page, writing into the given slot. */
|
|
||||||
function attachSessionListeners(pg, slot, name) {
|
|
||||||
pg.on('dialog', dialog => dialog.accept().catch(() => {}));
|
|
||||||
pg.on('request', req => {
|
|
||||||
if (slot.seanceId) return;
|
|
||||||
const m = req.url().match(/^(https?:\/\/[^/]+\/[^/]+\/[^/]+)\/e1cib\/.+[?&]seanceId=([^&]+)/);
|
|
||||||
if (m) {
|
|
||||||
slot.sessionPrefix = m[1];
|
|
||||||
slot.seanceId = m[2];
|
|
||||||
if (activeContextName === name) {
|
|
||||||
setSessionPrefix(m[1]);
|
|
||||||
setSeanceId(m[2]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create (or navigate) a named browser context.
|
|
||||||
* First call launches Chromium via chromium.launch() (NOT launchPersistentContext) so that
|
|
||||||
* subsequent calls can create additional isolated BrowserContexts in the same process.
|
|
||||||
* Trade-off: 1C browser extension is loaded via --load-extension (process-level) rather than
|
|
||||||
* persistent profile.
|
|
||||||
*
|
|
||||||
* Use this from run.mjs cmdTest only — exec/run/start use connect() and stay on the
|
|
||||||
* legacy persistent-context path.
|
|
||||||
*/
|
|
||||||
export async function createContext(name, url, { extensionPath, isolation = 'tab' } = {}) {
|
|
||||||
if (contexts.has(name)) {
|
|
||||||
await setActiveContext(name);
|
|
||||||
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: LOAD_TIMEOUT });
|
|
||||||
try { await page.waitForSelector('#themesCell_theme_0', { timeout: INIT_TIMEOUT }); }
|
|
||||||
catch { await page.waitForTimeout(5000); }
|
|
||||||
await closeModals();
|
|
||||||
return await getPageState();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!['tab', 'window'].includes(isolation)) {
|
|
||||||
throw new Error(`createContext: invalid isolation "${isolation}", expected 'tab' or 'window'`);
|
|
||||||
}
|
|
||||||
if (activeMode && activeMode !== isolation) {
|
|
||||||
throw new Error(`createContext: cannot mix isolation modes — first context used "${activeMode}", "${name}" requested "${isolation}". Use the same mode for all contexts in one run.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// First context: launch browser. Subsequent: reuse existing.
|
|
||||||
let isFirstContext = !browser;
|
|
||||||
if (isFirstContext) {
|
|
||||||
const extPath = findExtension(extensionPath);
|
|
||||||
const launchArgs = ['--start-maximized'];
|
|
||||||
if (extPath) {
|
|
||||||
launchArgs.push('--disable-extensions-except=' + extPath, '--load-extension=' + extPath);
|
|
||||||
}
|
|
||||||
if (isolation === 'tab') {
|
|
||||||
// Persistent context: extension loads reliably, one window with tabs per context
|
|
||||||
setPersistentUserDataDir(pathJoin(tmpdir(), 'pw-1c-test-' + Date.now()));
|
|
||||||
mkdirSync(persistentUserDataDir, { recursive: true });
|
|
||||||
setBrowser(await chromium.launchPersistentContext(persistentUserDataDir, {
|
|
||||||
headless: false,
|
|
||||||
args: launchArgs,
|
|
||||||
viewport: null,
|
|
||||||
permissions: ['clipboard-read', 'clipboard-write'],
|
|
||||||
}));
|
|
||||||
} else {
|
|
||||||
// Window mode: separate BrowserContext per slot, full cookie isolation
|
|
||||||
setBrowser(await chromium.launch({ headless: false, args: launchArgs }));
|
|
||||||
}
|
|
||||||
setActiveMode(isolation);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save current active before switching
|
|
||||||
saveActiveSlot();
|
|
||||||
|
|
||||||
// Create slot — page differs by mode
|
|
||||||
let newCtx, newPage;
|
|
||||||
if (activeMode === 'tab') {
|
|
||||||
// Reuse the persistent context for all slots; each slot gets its own page (tab)
|
|
||||||
newCtx = browser;
|
|
||||||
if (isFirstContext) {
|
|
||||||
newPage = browser.pages()[0] || await browser.newPage();
|
|
||||||
} else {
|
|
||||||
newPage = await browser.newPage();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Window mode: each slot owns its BrowserContext + page
|
|
||||||
newCtx = await browser.newContext({
|
|
||||||
viewport: null,
|
|
||||||
permissions: ['clipboard-read', 'clipboard-write'],
|
|
||||||
});
|
|
||||||
newPage = await newCtx.newPage();
|
|
||||||
}
|
|
||||||
|
|
||||||
const slot = {
|
|
||||||
context: newCtx,
|
|
||||||
page: newPage,
|
|
||||||
sessionPrefix: null,
|
|
||||||
seanceId: null,
|
|
||||||
highlightMode: false,
|
|
||||||
};
|
|
||||||
contexts.set(name, slot);
|
|
||||||
|
|
||||||
attachSessionListeners(newPage, slot, name);
|
|
||||||
activateSlot(name);
|
|
||||||
|
|
||||||
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: LOAD_TIMEOUT });
|
|
||||||
try { await page.waitForSelector('#themesCell_theme_0', { timeout: INIT_TIMEOUT }); }
|
|
||||||
catch { await page.waitForTimeout(5000); }
|
|
||||||
await closeModals();
|
|
||||||
|
|
||||||
return await getPageState();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Switch the active context. Subsequent browser API calls operate on this context's page. */
|
|
||||||
export async function setActiveContext(name) {
|
|
||||||
if (activeContextName === name) return;
|
|
||||||
if (!contexts.has(name)) throw new Error(`Context "${name}" not found. Available: [${[...contexts.keys()].join(', ')}]`);
|
|
||||||
// If a recording is active, flush the outgoing page's last frame so the gap is filled
|
|
||||||
// up to the moment of the switch (avoids a "jump" in video time).
|
|
||||||
if (recorder && recorder._flushFrames) recorder._flushFrames();
|
|
||||||
saveActiveSlot();
|
|
||||||
activateSlot(name);
|
|
||||||
// If the recording is still alive (it lives across slots — we keep the same ffmpeg/output),
|
|
||||||
// re-attach its screencast to the newly active page.
|
|
||||||
if (recorder && recorder._attachPage) {
|
|
||||||
await recorder._attachPage(page);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function listContexts() {
|
|
||||||
return [...contexts.keys()];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getActiveContext() {
|
|
||||||
return activeContextName;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function hasContext(name) {
|
|
||||||
return contexts.has(name);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Close a named context: logout, close its page (tab mode) or BrowserContext
|
|
||||||
* (window mode), remove from registry. Cannot close the currently active
|
|
||||||
* context — caller must setActiveContext to another first. This keeps the
|
|
||||||
* recorder/page invariants simple: recorder is always attached to the
|
|
||||||
* active slot, which closeContext never touches.
|
|
||||||
*
|
|
||||||
* @throws if name is not registered or equals the active context.
|
|
||||||
*/
|
|
||||||
export async function closeContext(name) {
|
|
||||||
if (!contexts.has(name)) {
|
|
||||||
throw new Error(`Context "${name}" not found. Available: [${[...contexts.keys()].join(', ')}]`);
|
|
||||||
}
|
|
||||||
if (name === activeContextName) {
|
|
||||||
throw new Error(`closeContext: cannot close the active context "${name}". setActiveContext to another context first.`);
|
|
||||||
}
|
|
||||||
const slot = contexts.get(name);
|
|
||||||
await logoutSlot(slot);
|
|
||||||
if (activeMode === 'tab') {
|
|
||||||
try { await slot.page.close(); } catch {}
|
|
||||||
} else {
|
|
||||||
try { await slot.context.close(); } catch {}
|
|
||||||
}
|
|
||||||
contexts.delete(name);
|
|
||||||
}
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
// web-test core/state v1.17 — module-level state for the web-test engine.
|
|
||||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
|
||||||
//
|
|
||||||
// Holds the single browser/page/recorder slot plus the multi-context registry,
|
|
||||||
// constants, and small state-only utilities (ensureConnected, getPage,
|
|
||||||
// resolveProjectPath, normYo). Mutable values are exported as `let` bindings
|
|
||||||
// for live read access from consumer modules; writes go through setters so
|
|
||||||
// imported bindings stay read-only at the import site.
|
|
||||||
|
|
||||||
import { dirname, resolve as pathResolve } from 'path';
|
|
||||||
import { fileURLToPath } from 'url';
|
|
||||||
|
|
||||||
// Project root: 6 levels up from .claude/skills/web-test/scripts/engine/core/state.mjs
|
|
||||||
const __fn_state = fileURLToPath(import.meta.url);
|
|
||||||
export const projectRoot = pathResolve(dirname(__fn_state), '..', '..', '..', '..', '..', '..');
|
|
||||||
|
|
||||||
/** Resolve a user-provided path relative to the project root (not cwd). */
|
|
||||||
export const resolveProjectPath = (p) => pathResolve(projectRoot, p);
|
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────────────────────────────
|
|
||||||
// Mutable single-session state. Importers read via the live binding; writes
|
|
||||||
// must go through the corresponding setter (ESM imports are read-only).
|
|
||||||
// ──────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export let browser = null;
|
|
||||||
export let page = null;
|
|
||||||
export let sessionPrefix = null; // e.g. "http://localhost:8081/bpdemo/ru_RU"
|
|
||||||
export let seanceId = null;
|
|
||||||
export let recorder = null; // { cdp, ffmpeg, startTime, outputPath, ffmpegError, captions }
|
|
||||||
export let lastCaptions = []; // captions from the last completed recording (for addNarration)
|
|
||||||
export let lastRecordingDuration = null; // wall-clock duration of the last recording (seconds)
|
|
||||||
export let highlightMode = false;
|
|
||||||
export let persistentUserDataDir = null; // temp dir for launchPersistentContext, cleaned on disconnect
|
|
||||||
|
|
||||||
// Clipboard preservation: save full clipboard contents (all MIME types) right
|
|
||||||
// before each writeText+Ctrl+V pair, restore right after. Toggled via
|
|
||||||
// setPreserveClipboard() from run.mjs.
|
|
||||||
export let preserveClipboard = true;
|
|
||||||
export let clipboardWarnLogged = false;
|
|
||||||
|
|
||||||
export const setBrowser = (v) => { browser = v; };
|
|
||||||
export const setPage = (v) => { page = v; };
|
|
||||||
export const setSessionPrefix = (v) => { sessionPrefix = v; };
|
|
||||||
export const setSeanceId = (v) => { seanceId = v; };
|
|
||||||
export const setRecorder = (v) => { recorder = v; };
|
|
||||||
export const setLastCaptions = (v) => { lastCaptions = v; };
|
|
||||||
export const setLastRecordingDuration = (v) => { lastRecordingDuration = v; };
|
|
||||||
export const setHighlightMode = (v) => { highlightMode = !!v; };
|
|
||||||
export const setPersistentUserDataDir = (v) => { persistentUserDataDir = v; };
|
|
||||||
export const setPreserveClipboard = (v) => { preserveClipboard = !!v; };
|
|
||||||
export const setClipboardWarnLogged = (v) => { clipboardWarnLogged = !!v; };
|
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────────────────────────────
|
|
||||||
// Multi-context registry: name → { context, page, sessionPrefix, seanceId,
|
|
||||||
// recorder, lastCaptions, lastRecordingDuration, highlightMode }.
|
|
||||||
// Populated by createContext(); module-level vars above mirror the active
|
|
||||||
// slot. connect() does NOT use this Map — it preserves legacy single-session
|
|
||||||
// behavior for exec/run/start.
|
|
||||||
// ──────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export const contexts = new Map();
|
|
||||||
export let activeContextName = null;
|
|
||||||
// Isolation mode for the current cmdTest session — set by the first
|
|
||||||
// createContext call. 'tab': all contexts share one persistent context
|
|
||||||
// (one window, multiple tabs, extension loads reliably). 'window': each
|
|
||||||
// context gets its own BrowserContext (separate window per context, full
|
|
||||||
// cookie isolation, extension may not load).
|
|
||||||
export let activeMode = null;
|
|
||||||
|
|
||||||
export const setActiveContextName = (v) => { activeContextName = v; };
|
|
||||||
export const setActiveMode = (v) => { activeMode = v; };
|
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────────────────────────────
|
|
||||||
// Constants.
|
|
||||||
// ──────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export const LOAD_TIMEOUT = 60000;
|
|
||||||
export const INIT_TIMEOUT = 60000;
|
|
||||||
export const ACTION_WAIT = 2000; // fallback minimum wait
|
|
||||||
export const MAX_WAIT = 10000; // max wait for stability
|
|
||||||
export const POLL_INTERVAL = 200; // polling interval
|
|
||||||
export const STABLE_CYCLES = 3; // consecutive stable cycles needed
|
|
||||||
|
|
||||||
// 1C browser extension ID (stable across versions, defined by key in manifest.json)
|
|
||||||
export const EXT_ID = 'pbhelknnhilelbnhfpcjlcabhmfangik';
|
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────────────────────────────
|
|
||||||
// Utilities that only depend on state.
|
|
||||||
// ──────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** Normalize ё→е and →space for fuzzy matching. */
|
|
||||||
export const normYo = (s) => s.replace(/ё/gi, 'е').replace(/ /g, ' ');
|
|
||||||
|
|
||||||
/** Check if browser is connected and page is usable. */
|
|
||||||
export function isConnected() {
|
|
||||||
if (!browser || !page || page.isClosed()) return false;
|
|
||||||
// launchPersistentContext returns BrowserContext (no isConnected), launch returns Browser
|
|
||||||
if (typeof browser.isConnected === 'function') return browser.isConnected();
|
|
||||||
// For persistent context, check via context's browser()
|
|
||||||
return browser.browser()?.isConnected() ?? false;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ensureConnected() {
|
|
||||||
if (!isConnected()) {
|
|
||||||
throw new Error('Browser not connected. Call web_connect first.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Get the raw Playwright page object (for advanced scripting in skill mode). */
|
|
||||||
export function getPage() {
|
|
||||||
ensureConnected();
|
|
||||||
return page;
|
|
||||||
}
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
// web-test core/wait v1.17 — Smart wait helpers: DOM stability polling, JS-expression polling, CDP network monitor.
|
|
||||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
|
||||||
|
|
||||||
import { page, MAX_WAIT, POLL_INTERVAL, STABLE_CYCLES } from './state.mjs';
|
|
||||||
import { detectFormScript } from '../../dom.mjs';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Smart wait: poll until DOM is stable and no loading indicators are visible.
|
|
||||||
* Checks: form number change, loading indicators, DOM stability.
|
|
||||||
* @param {number|null} previousFormNum — form number before the action (null = don't check)
|
|
||||||
*/
|
|
||||||
export async function waitForStable(previousFormNum = null) {
|
|
||||||
let stableCount = 0;
|
|
||||||
let lastSnapshot = '';
|
|
||||||
const start = Date.now();
|
|
||||||
|
|
||||||
while (Date.now() - start < MAX_WAIT) {
|
|
||||||
await page.waitForTimeout(POLL_INTERVAL);
|
|
||||||
|
|
||||||
// Check for loading indicators
|
|
||||||
const status = await page.evaluate(`(() => {
|
|
||||||
const loading = document.querySelector('.loadingImage, .waitCurtain, .progressBar');
|
|
||||||
const isLoading = loading && loading.offsetWidth > 0;
|
|
||||||
const formCount = document.querySelectorAll('input.editInput[id], a.press[id]').length;
|
|
||||||
return { isLoading, formCount };
|
|
||||||
})()`);
|
|
||||||
|
|
||||||
if (status.isLoading) {
|
|
||||||
stableCount = 0;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check DOM stability by comparing element count snapshot
|
|
||||||
const snapshot = String(status.formCount);
|
|
||||||
if (snapshot === lastSnapshot) {
|
|
||||||
stableCount++;
|
|
||||||
} else {
|
|
||||||
stableCount = 0;
|
|
||||||
lastSnapshot = snapshot;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If form was expected to change, ensure it did
|
|
||||||
if (previousFormNum !== null && stableCount === 1) {
|
|
||||||
const currentForm = await page.evaluate(detectFormScript());
|
|
||||||
if (currentForm !== previousFormNum) {
|
|
||||||
// Form changed — still wait for stability
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (stableCount >= STABLE_CYCLES) return;
|
|
||||||
}
|
|
||||||
// Fallback: max wait reached
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start monitoring network activity via CDP.
|
|
||||||
* Must be called BEFORE the click so it captures all server requests.
|
|
||||||
* Returns a monitor object with waitDone() and cleanup() methods.
|
|
||||||
*/
|
|
||||||
export async function startNetworkMonitor() {
|
|
||||||
const client = await page.context().newCDPSession(page);
|
|
||||||
await client.send('Network.enable');
|
|
||||||
|
|
||||||
let pending = 0;
|
|
||||||
let total = 0;
|
|
||||||
let lastZeroTime = null;
|
|
||||||
const DEBOUNCE = 300;
|
|
||||||
|
|
||||||
client.on('Network.requestWillBeSent', () => {
|
|
||||||
pending++;
|
|
||||||
total++;
|
|
||||||
lastZeroTime = null;
|
|
||||||
});
|
|
||||||
client.on('Network.loadingFinished', () => {
|
|
||||||
if (--pending === 0) lastZeroTime = Date.now();
|
|
||||||
});
|
|
||||||
client.on('Network.loadingFailed', () => {
|
|
||||||
if (--pending === 0) lastZeroTime = Date.now();
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
/** Wait until all network requests complete (300ms debounce) or UI element appears. */
|
|
||||||
async waitDone(timeout = 10000) {
|
|
||||||
const start = Date.now();
|
|
||||||
while (Date.now() - start < timeout) {
|
|
||||||
await page.waitForTimeout(50);
|
|
||||||
|
|
||||||
// Check for UI elements (modal, balloon, confirm)
|
|
||||||
const ui = await page.evaluate(`(() => {
|
|
||||||
const modal = document.querySelector('#modalSurface:not([style*="display: none"])');
|
|
||||||
const balloon = document.querySelector('.balloon');
|
|
||||||
const confirm = document.querySelector('.confirm');
|
|
||||||
return !!(modal || balloon || confirm);
|
|
||||||
})()`);
|
|
||||||
if (ui) return;
|
|
||||||
|
|
||||||
// CDP debounce: pending===0 held for DEBOUNCE ms
|
|
||||||
if (total > 0 && pending === 0 && lastZeroTime !== null) {
|
|
||||||
if (Date.now() - lastZeroTime >= DEBOUNCE) return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
/** Detach CDP session. Always call this when done. */
|
|
||||||
async cleanup() {
|
|
||||||
await client.send('Network.disable').catch(() => {});
|
|
||||||
await client.detach().catch(() => {});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Poll until a JS expression returns truthy, or timeout (ms) expires.
|
|
||||||
* Resolves early — typically within 100-300ms instead of fixed delays.
|
|
||||||
*/
|
|
||||||
export async function waitForCondition(evalScript, timeout = 2000) {
|
|
||||||
const start = Date.now();
|
|
||||||
while (Date.now() - start < timeout) {
|
|
||||||
const result = await page.evaluate(evalScript);
|
|
||||||
if (result) return result;
|
|
||||||
await page.waitForTimeout(100);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user