Spaces:
Running
Running
File size: 204,616 Bytes
ec038f4 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 1113 1114 1115 1116 1117 1118 1119 1120 1121 1122 1123 1124 1125 1126 1127 1128 1129 1130 1131 1132 1133 1134 1135 1136 1137 1138 1139 1140 1141 1142 1143 1144 1145 1146 1147 1148 1149 1150 1151 1152 1153 1154 1155 1156 1157 1158 1159 1160 1161 1162 1163 1164 1165 1166 1167 1168 1169 1170 1171 1172 1173 1174 1175 1176 1177 1178 1179 1180 1181 1182 1183 1184 1185 1186 1187 1188 1189 1190 1191 1192 1193 1194 1195 1196 1197 1198 1199 1200 1201 1202 1203 1204 1205 1206 1207 1208 1209 1210 1211 1212 1213 1214 1215 1216 1217 1218 1219 1220 1221 1222 1223 1224 1225 1226 1227 1228 1229 1230 1231 1232 1233 1234 1235 1236 1237 1238 1239 1240 1241 1242 1243 1244 1245 1246 1247 1248 1249 1250 1251 1252 1253 1254 1255 1256 1257 1258 1259 1260 1261 1262 1263 1264 1265 1266 1267 1268 1269 1270 1271 1272 1273 1274 1275 1276 1277 1278 1279 1280 1281 1282 1283 1284 1285 1286 1287 1288 1289 1290 1291 1292 1293 1294 1295 1296 1297 1298 1299 1300 1301 1302 1303 1304 1305 1306 1307 1308 1309 1310 1311 1312 1313 1314 1315 1316 1317 1318 1319 1320 1321 1322 1323 1324 1325 1326 1327 1328 1329 1330 1331 1332 1333 1334 1335 1336 1337 1338 1339 1340 1341 1342 1343 1344 1345 1346 1347 1348 1349 1350 1351 1352 1353 1354 1355 1356 1357 1358 1359 1360 1361 1362 1363 1364 1365 1366 1367 1368 1369 1370 1371 1372 1373 1374 1375 1376 1377 1378 1379 1380 1381 1382 1383 1384 1385 1386 1387 1388 1389 1390 1391 1392 1393 1394 1395 1396 1397 1398 1399 1400 1401 1402 1403 1404 1405 1406 1407 1408 1409 1410 1411 1412 1413 1414 1415 1416 1417 1418 1419 1420 1421 1422 1423 1424 1425 1426 1427 1428 1429 1430 1431 1432 1433 1434 1435 1436 1437 1438 1439 1440 1441 1442 1443 1444 1445 1446 1447 1448 1449 1450 1451 1452 1453 1454 1455 1456 1457 1458 1459 1460 1461 1462 1463 1464 1465 1466 1467 1468 1469 1470 1471 1472 1473 1474 1475 1476 1477 1478 1479 1480 1481 1482 1483 1484 1485 1486 1487 1488 1489 1490 1491 1492 1493 1494 1495 1496 1497 1498 1499 1500 1501 1502 1503 1504 1505 1506 1507 1508 1509 1510 1511 1512 1513 1514 1515 1516 1517 1518 1519 1520 1521 1522 1523 1524 1525 1526 1527 1528 1529 1530 1531 1532 1533 1534 1535 1536 1537 1538 1539 1540 1541 1542 1543 1544 1545 1546 1547 1548 1549 1550 1551 1552 1553 1554 1555 1556 1557 1558 1559 1560 1561 1562 1563 1564 1565 1566 1567 1568 1569 1570 1571 1572 1573 1574 1575 1576 1577 1578 1579 1580 1581 1582 1583 1584 1585 1586 1587 1588 1589 1590 1591 1592 1593 1594 1595 1596 1597 1598 1599 1600 1601 1602 1603 1604 1605 1606 1607 1608 1609 1610 1611 1612 1613 1614 1615 1616 1617 1618 1619 1620 1621 1622 1623 1624 1625 1626 1627 1628 1629 1630 1631 1632 1633 1634 1635 1636 1637 1638 1639 1640 1641 1642 1643 1644 1645 1646 1647 1648 1649 1650 1651 1652 1653 1654 1655 1656 1657 1658 1659 1660 1661 1662 1663 1664 1665 1666 1667 1668 1669 1670 1671 1672 1673 1674 1675 1676 1677 1678 1679 1680 1681 1682 1683 1684 1685 1686 1687 1688 1689 1690 1691 1692 1693 1694 1695 1696 1697 1698 1699 1700 1701 1702 1703 1704 1705 1706 1707 1708 1709 1710 1711 1712 1713 1714 1715 1716 1717 1718 1719 1720 1721 1722 1723 1724 1725 1726 1727 1728 1729 1730 1731 1732 1733 1734 1735 1736 1737 1738 1739 1740 1741 1742 1743 1744 1745 1746 1747 1748 1749 1750 1751 1752 1753 1754 1755 1756 1757 1758 1759 1760 1761 1762 1763 1764 1765 1766 1767 1768 1769 1770 1771 1772 1773 1774 1775 1776 1777 1778 1779 1780 1781 1782 1783 1784 1785 1786 1787 1788 1789 1790 1791 1792 1793 1794 1795 1796 1797 1798 1799 1800 1801 1802 1803 1804 1805 1806 1807 1808 1809 1810 1811 1812 1813 1814 1815 1816 1817 1818 1819 1820 1821 1822 1823 1824 1825 1826 1827 1828 1829 1830 1831 1832 1833 1834 1835 1836 1837 1838 1839 1840 1841 1842 1843 1844 1845 1846 1847 1848 1849 1850 1851 1852 1853 1854 1855 1856 1857 1858 1859 1860 1861 1862 1863 1864 1865 1866 1867 1868 1869 1870 1871 1872 1873 1874 1875 1876 1877 1878 1879 1880 1881 1882 1883 1884 1885 1886 1887 1888 1889 1890 1891 1892 1893 1894 1895 1896 1897 1898 1899 1900 1901 1902 1903 1904 1905 1906 1907 1908 1909 1910 1911 1912 1913 1914 1915 1916 1917 1918 1919 1920 1921 1922 1923 1924 1925 1926 1927 1928 1929 1930 1931 1932 1933 1934 1935 1936 1937 1938 1939 1940 1941 1942 1943 1944 1945 1946 1947 1948 1949 1950 1951 1952 1953 1954 1955 1956 1957 1958 1959 1960 1961 1962 1963 1964 1965 1966 1967 1968 1969 1970 1971 1972 1973 1974 1975 1976 1977 1978 1979 1980 1981 1982 1983 1984 1985 1986 1987 1988 1989 1990 1991 1992 1993 1994 1995 1996 1997 1998 1999 2000 2001 2002 2003 2004 2005 2006 2007 2008 2009 2010 2011 2012 2013 2014 2015 2016 2017 2018 2019 2020 2021 2022 2023 2024 2025 2026 2027 2028 2029 2030 2031 2032 2033 2034 2035 2036 2037 2038 2039 2040 2041 2042 2043 2044 2045 2046 2047 2048 2049 2050 2051 2052 2053 2054 2055 2056 2057 2058 2059 2060 2061 2062 2063 2064 2065 2066 2067 2068 2069 2070 2071 2072 2073 2074 2075 2076 2077 2078 2079 2080 2081 2082 2083 2084 2085 2086 2087 2088 2089 2090 2091 2092 2093 2094 2095 2096 2097 2098 2099 2100 2101 2102 2103 2104 2105 2106 2107 2108 2109 2110 2111 2112 2113 2114 2115 2116 2117 2118 2119 2120 2121 2122 2123 2124 2125 2126 2127 2128 2129 2130 2131 2132 2133 2134 2135 2136 2137 2138 2139 2140 2141 2142 2143 2144 2145 2146 2147 2148 2149 2150 2151 2152 2153 2154 2155 2156 2157 2158 2159 2160 2161 2162 2163 2164 2165 2166 2167 2168 2169 2170 2171 2172 2173 2174 2175 2176 2177 2178 2179 2180 2181 2182 2183 2184 2185 2186 2187 2188 2189 2190 2191 2192 2193 2194 2195 2196 2197 2198 2199 2200 2201 2202 2203 2204 2205 2206 2207 2208 2209 2210 2211 2212 2213 2214 2215 2216 2217 2218 2219 2220 2221 2222 2223 2224 2225 2226 2227 2228 2229 2230 2231 2232 2233 2234 2235 2236 2237 2238 2239 2240 2241 2242 2243 2244 2245 2246 2247 2248 2249 2250 2251 2252 2253 2254 2255 2256 2257 2258 2259 2260 2261 2262 2263 2264 2265 2266 2267 2268 2269 2270 2271 2272 2273 2274 2275 2276 2277 2278 2279 2280 2281 2282 2283 2284 2285 2286 2287 2288 2289 2290 2291 2292 2293 2294 2295 2296 2297 2298 2299 2300 2301 2302 2303 2304 2305 2306 2307 2308 2309 2310 2311 2312 2313 2314 2315 2316 2317 2318 2319 2320 2321 2322 2323 2324 2325 2326 2327 2328 2329 2330 2331 2332 2333 2334 2335 2336 2337 2338 2339 2340 2341 2342 2343 2344 2345 2346 2347 2348 2349 2350 2351 2352 2353 2354 2355 2356 2357 2358 2359 2360 2361 2362 2363 2364 2365 2366 2367 2368 2369 2370 2371 2372 2373 2374 2375 2376 2377 2378 2379 2380 2381 2382 2383 2384 2385 2386 2387 2388 2389 2390 2391 2392 2393 2394 2395 2396 2397 2398 2399 2400 2401 2402 2403 2404 2405 2406 2407 2408 2409 2410 2411 2412 2413 2414 2415 2416 2417 2418 2419 2420 2421 2422 2423 2424 2425 2426 2427 2428 2429 2430 2431 2432 2433 2434 2435 2436 2437 2438 2439 2440 2441 2442 2443 2444 2445 2446 2447 2448 2449 2450 2451 2452 2453 2454 2455 2456 2457 2458 2459 2460 2461 2462 2463 2464 2465 2466 2467 2468 2469 2470 2471 2472 2473 2474 2475 2476 2477 2478 2479 2480 2481 2482 2483 2484 2485 2486 2487 2488 2489 2490 2491 2492 2493 2494 2495 2496 2497 2498 2499 2500 2501 2502 2503 2504 2505 2506 2507 2508 2509 2510 2511 2512 2513 2514 2515 2516 2517 2518 2519 2520 2521 2522 2523 2524 2525 2526 2527 2528 2529 2530 2531 2532 2533 2534 2535 2536 2537 2538 2539 2540 2541 2542 2543 2544 2545 2546 2547 2548 2549 2550 2551 2552 2553 2554 2555 2556 2557 2558 2559 2560 2561 2562 2563 2564 2565 2566 2567 2568 2569 2570 2571 2572 2573 2574 2575 2576 2577 2578 2579 2580 2581 2582 2583 2584 2585 2586 2587 2588 2589 2590 2591 2592 2593 2594 2595 2596 2597 2598 2599 2600 2601 2602 2603 2604 2605 2606 2607 2608 2609 2610 2611 2612 2613 2614 2615 2616 2617 2618 2619 2620 2621 2622 2623 2624 2625 2626 2627 2628 2629 2630 2631 2632 2633 2634 2635 2636 2637 2638 2639 2640 2641 2642 2643 2644 2645 2646 2647 2648 2649 2650 2651 2652 2653 2654 2655 2656 2657 2658 2659 2660 2661 2662 2663 2664 2665 2666 2667 2668 2669 2670 2671 2672 2673 2674 2675 2676 2677 2678 2679 2680 2681 2682 2683 2684 2685 2686 2687 2688 2689 2690 2691 2692 2693 2694 2695 2696 2697 2698 2699 2700 2701 2702 2703 2704 2705 2706 2707 2708 2709 2710 2711 2712 2713 2714 2715 2716 2717 2718 2719 2720 2721 2722 2723 2724 2725 2726 2727 2728 2729 2730 2731 2732 2733 2734 2735 2736 2737 2738 2739 2740 2741 2742 2743 2744 2745 2746 2747 2748 2749 2750 2751 2752 2753 2754 2755 2756 2757 2758 2759 2760 2761 2762 2763 2764 2765 2766 2767 2768 2769 2770 2771 2772 2773 2774 2775 2776 2777 2778 2779 2780 2781 2782 2783 2784 2785 2786 2787 2788 2789 2790 2791 2792 2793 2794 2795 2796 2797 2798 2799 2800 2801 2802 2803 2804 2805 2806 2807 2808 2809 2810 2811 2812 2813 2814 2815 2816 2817 2818 2819 2820 2821 2822 2823 2824 2825 2826 2827 2828 2829 2830 2831 2832 2833 2834 2835 2836 2837 2838 2839 2840 2841 2842 2843 2844 2845 2846 2847 2848 2849 2850 2851 2852 2853 2854 2855 2856 2857 2858 2859 2860 2861 2862 2863 2864 2865 2866 2867 2868 2869 2870 2871 2872 2873 2874 2875 2876 2877 2878 2879 2880 2881 2882 2883 2884 2885 2886 2887 2888 2889 2890 2891 2892 2893 2894 2895 2896 2897 2898 2899 2900 2901 2902 2903 2904 2905 2906 2907 2908 2909 2910 2911 2912 2913 2914 2915 2916 2917 2918 2919 2920 2921 2922 2923 2924 2925 2926 2927 2928 2929 2930 2931 2932 2933 2934 2935 2936 2937 2938 2939 2940 2941 2942 2943 2944 2945 2946 2947 2948 2949 2950 2951 2952 2953 2954 2955 2956 2957 2958 2959 2960 2961 2962 2963 2964 2965 2966 2967 2968 2969 2970 2971 2972 2973 2974 2975 2976 2977 2978 2979 2980 2981 2982 2983 2984 2985 2986 2987 2988 2989 2990 2991 2992 2993 2994 2995 2996 2997 2998 2999 3000 3001 3002 3003 3004 3005 3006 3007 3008 3009 3010 3011 3012 3013 3014 3015 3016 3017 3018 3019 3020 3021 3022 3023 3024 3025 3026 3027 3028 3029 3030 3031 3032 3033 3034 3035 3036 3037 3038 3039 3040 3041 3042 3043 3044 3045 3046 3047 3048 3049 3050 3051 3052 3053 3054 3055 3056 3057 3058 3059 3060 3061 3062 3063 3064 3065 3066 3067 3068 3069 3070 3071 3072 3073 3074 3075 3076 3077 3078 3079 3080 3081 3082 3083 3084 3085 3086 3087 3088 3089 3090 3091 3092 3093 3094 3095 3096 3097 3098 3099 3100 3101 3102 3103 3104 3105 3106 3107 3108 3109 3110 3111 3112 3113 3114 3115 3116 3117 3118 3119 3120 3121 3122 3123 3124 3125 3126 3127 3128 3129 3130 3131 3132 3133 3134 3135 3136 3137 3138 3139 3140 3141 3142 3143 3144 3145 3146 3147 3148 3149 3150 3151 3152 3153 3154 3155 3156 3157 3158 3159 3160 3161 3162 3163 3164 3165 3166 3167 3168 3169 3170 3171 3172 3173 3174 3175 3176 3177 3178 3179 3180 3181 3182 3183 3184 3185 3186 3187 3188 3189 3190 3191 3192 3193 3194 3195 3196 3197 3198 3199 3200 3201 3202 3203 3204 3205 3206 3207 3208 3209 3210 3211 3212 3213 3214 3215 3216 3217 3218 3219 3220 3221 3222 3223 3224 3225 3226 3227 3228 3229 3230 3231 3232 3233 3234 3235 3236 3237 3238 3239 3240 3241 3242 3243 3244 3245 3246 3247 3248 3249 3250 3251 3252 3253 3254 3255 3256 3257 3258 3259 3260 3261 3262 3263 3264 3265 3266 3267 3268 3269 3270 3271 3272 3273 3274 3275 3276 3277 3278 3279 3280 3281 3282 3283 3284 3285 3286 3287 3288 3289 3290 3291 3292 3293 3294 3295 3296 3297 3298 3299 3300 3301 3302 3303 3304 3305 3306 3307 3308 3309 3310 3311 3312 3313 3314 3315 3316 3317 3318 3319 3320 3321 3322 3323 3324 3325 3326 3327 3328 3329 3330 3331 3332 3333 3334 3335 3336 3337 3338 3339 3340 3341 3342 3343 3344 3345 3346 3347 3348 3349 3350 3351 3352 3353 3354 3355 3356 3357 3358 3359 3360 3361 3362 3363 3364 3365 3366 3367 3368 3369 3370 3371 3372 3373 3374 3375 3376 3377 3378 3379 3380 3381 3382 3383 3384 3385 3386 3387 3388 3389 3390 3391 3392 3393 3394 3395 3396 3397 3398 3399 3400 3401 3402 3403 3404 3405 3406 3407 3408 3409 3410 3411 3412 3413 3414 3415 3416 3417 3418 3419 3420 3421 3422 3423 3424 3425 3426 3427 3428 3429 3430 3431 3432 3433 3434 3435 3436 3437 3438 3439 3440 3441 3442 3443 3444 3445 3446 3447 3448 3449 3450 3451 3452 3453 3454 3455 3456 3457 3458 3459 3460 3461 3462 3463 3464 3465 3466 3467 3468 3469 3470 3471 3472 3473 3474 3475 3476 3477 3478 3479 3480 3481 3482 3483 3484 3485 3486 3487 3488 3489 3490 3491 3492 3493 3494 3495 3496 3497 3498 3499 3500 3501 3502 3503 3504 3505 3506 3507 3508 3509 3510 3511 3512 3513 3514 3515 3516 3517 3518 3519 3520 3521 3522 3523 3524 3525 3526 3527 3528 3529 3530 3531 3532 3533 3534 3535 3536 3537 3538 3539 3540 3541 3542 3543 3544 3545 3546 3547 3548 3549 3550 3551 3552 3553 3554 3555 3556 3557 3558 3559 3560 3561 3562 3563 3564 3565 3566 3567 3568 3569 3570 3571 3572 3573 3574 3575 3576 3577 3578 3579 3580 3581 3582 3583 3584 3585 3586 3587 3588 3589 3590 3591 3592 3593 3594 3595 3596 3597 3598 3599 3600 3601 3602 3603 3604 3605 3606 3607 3608 3609 3610 3611 3612 3613 3614 3615 3616 3617 3618 3619 3620 3621 3622 3623 3624 3625 3626 3627 3628 3629 3630 3631 3632 3633 3634 3635 3636 3637 3638 3639 3640 3641 3642 3643 3644 3645 3646 3647 3648 3649 3650 3651 3652 3653 3654 3655 3656 3657 3658 3659 3660 3661 3662 3663 3664 3665 3666 3667 3668 3669 3670 3671 3672 3673 3674 3675 3676 3677 3678 3679 3680 3681 3682 3683 3684 3685 3686 3687 3688 3689 3690 3691 3692 3693 3694 3695 3696 3697 3698 3699 3700 3701 3702 3703 3704 3705 3706 3707 3708 3709 3710 3711 3712 3713 3714 3715 3716 3717 3718 3719 3720 3721 3722 3723 3724 3725 3726 3727 3728 3729 3730 3731 3732 3733 3734 3735 3736 3737 3738 3739 3740 3741 3742 3743 3744 3745 3746 3747 3748 3749 3750 3751 3752 3753 3754 3755 3756 3757 3758 3759 3760 3761 3762 3763 3764 3765 3766 3767 3768 3769 3770 3771 3772 3773 3774 3775 3776 3777 3778 3779 3780 3781 3782 3783 3784 3785 3786 3787 3788 3789 3790 3791 3792 3793 3794 3795 3796 3797 3798 3799 3800 3801 3802 3803 3804 3805 3806 3807 3808 3809 3810 3811 3812 3813 3814 3815 3816 3817 3818 3819 3820 3821 3822 3823 3824 3825 3826 3827 3828 3829 3830 3831 3832 3833 3834 3835 3836 3837 3838 3839 3840 3841 3842 3843 3844 3845 3846 3847 3848 3849 3850 3851 3852 3853 3854 3855 3856 3857 3858 3859 3860 3861 3862 3863 3864 3865 3866 3867 3868 3869 3870 3871 3872 3873 3874 3875 3876 3877 3878 3879 3880 3881 3882 3883 3884 3885 3886 3887 3888 3889 3890 3891 3892 3893 3894 3895 3896 3897 3898 3899 3900 3901 3902 3903 3904 3905 3906 3907 3908 3909 3910 3911 3912 3913 3914 3915 3916 3917 3918 3919 3920 3921 3922 3923 3924 3925 3926 3927 3928 3929 3930 3931 3932 3933 3934 3935 3936 3937 3938 3939 3940 3941 3942 3943 3944 3945 3946 3947 3948 3949 3950 3951 3952 3953 3954 3955 3956 3957 3958 3959 3960 3961 3962 3963 3964 3965 3966 3967 3968 3969 3970 3971 3972 3973 3974 3975 3976 3977 3978 3979 3980 3981 3982 3983 3984 3985 3986 3987 3988 3989 3990 3991 3992 3993 3994 3995 3996 3997 3998 3999 4000 4001 4002 4003 4004 4005 4006 4007 4008 4009 4010 4011 4012 4013 4014 4015 4016 4017 4018 4019 4020 4021 4022 4023 4024 4025 4026 4027 4028 4029 4030 4031 4032 4033 4034 4035 4036 4037 4038 4039 4040 4041 4042 4043 4044 4045 4046 4047 4048 4049 4050 4051 4052 4053 4054 4055 4056 4057 4058 4059 4060 4061 4062 4063 4064 4065 4066 4067 4068 4069 4070 4071 4072 4073 4074 4075 4076 4077 4078 4079 4080 4081 4082 4083 | """
QA Scanner GUI Methods
These methods can be integrated into TranslatorGUI or used standalone
"""
import os
import sys
import re
import json
from PySide6.QtWidgets import (QApplication, QDialog, QWidget, QLabel, QPushButton,
QVBoxLayout, QHBoxLayout, QGridLayout, QFrame,
QCheckBox, QSpinBox, QSlider, QTextEdit, QScrollArea,
QRadioButton, QButtonGroup, QGroupBox, QComboBox,
QFileDialog, QMessageBox, QSizePolicy)
from PySide6.QtCore import Qt, QTimer, Signal, QThread, QObject, QUrl
from PySide6.QtGui import QFont, QPixmap, QIcon, QDesktopServices
import threading
import traceback
# WindowManager and UIHelper removed - not needed in PySide6
# Qt handles window management and UI utilities automatically
scan_html_folder = None # Will be lazy-loaded from translator_gui
def _normalize_target_language(display_text):
"""Normalize a human-facing target language label to a canonical value.
The QA pipeline expects simple lowercase identifiers like "english",
"korean", or "chinese". This helper maps common dropdown labels to
those canonical forms so detection logic stays stable even if the
UI wording changes (e.g. "Chinese (Simplified)").
"""
if not display_text:
return "english"
s = display_text.strip().lower()
mapping = {
# Core languages
"english": "english",
"en": "english",
"spanish": "spanish",
"es": "spanish",
"french": "french",
"fr": "french",
"german": "german",
"de": "german",
"portuguese": "portuguese",
"pt": "portuguese",
"italian": "italian",
"it": "italian",
"russian": "russian",
"ru": "russian",
"japanese": "japanese",
"ja": "japanese",
"korean": "korean",
"ko": "korean",
# Chinese variants (keep distinct)
"chinese": "chinese",
"chinese (simplified)": "chinese (simplified)",
"chinese (traditional)": "chinese (traditional)",
"zh": "chinese",
"zh-cn": "chinese (simplified)",
"zh-tw": "chinese (traditional)",
# RTL / other scripts
"arabic": "arabic",
"ar": "arabic",
"hebrew": "hebrew",
"he": "hebrew",
"thai": "thai",
"th": "thai",
}
if s in mapping:
return mapping[s]
# Fallback: use the first word (e.g. "english (us)" → "english")
first = s.split()[0]
return mapping.get(first, first)
def _normalize_source_language(display_text):
"""
Normalize source language without collapsing Chinese variants.
Returns lowercase labels that align with word_count_multipliers keys.
"""
if not display_text:
return 'auto'
s = display_text.strip().lower()
if s == 'auto':
return 'auto'
# Keep distinct variants for Chinese
if 'chinese' in s:
if 'traditional' in s:
return 'chinese (traditional)'
if 'simplified' in s:
return 'chinese (simplified)'
return 'chinese'
return s
def check_epub_folder_match(epub_name, folder_name, custom_suffixes=''):
"""
Check if EPUB name and folder name likely refer to the same content
Uses strict matching to avoid false positives with similar numbered titles
"""
# Normalize names for comparison
epub_norm = normalize_name_for_comparison(epub_name)
folder_norm = normalize_name_for_comparison(folder_name)
# Direct match
if epub_norm == folder_norm:
return True
# Check if folder has common output suffixes that should be ignored
output_suffixes = ['_output', '_translated', '_trans', '_en', '_english', '_done', '_complete', '_final']
if custom_suffixes:
custom_list = [s.strip() for s in custom_suffixes.split(',') if s.strip()]
output_suffixes.extend(custom_list)
for suffix in output_suffixes:
if folder_norm.endswith(suffix):
folder_base = folder_norm[:-len(suffix)]
if folder_base == epub_norm:
return True
if epub_norm.endswith(suffix):
epub_base = epub_norm[:-len(suffix)]
if epub_base == folder_norm:
return True
# Check for exact match with version numbers removed
version_pattern = r'[\s_-]v\d+$'
epub_no_version = re.sub(version_pattern, '', epub_norm)
folder_no_version = re.sub(version_pattern, '', folder_norm)
if epub_no_version == folder_no_version and (epub_no_version != epub_norm or folder_no_version != folder_norm):
return True
# STRICT NUMBER CHECK - all numbers must match exactly
epub_numbers = re.findall(r'\d+', epub_name)
folder_numbers = re.findall(r'\d+', folder_name)
if epub_numbers != folder_numbers:
return False
# If we get here, numbers match, so check if the text parts are similar enough
epub_text_only = re.sub(r'\d+', '', epub_norm).strip()
folder_text_only = re.sub(r'\d+', '', folder_norm).strip()
if epub_numbers and folder_numbers:
return epub_text_only == folder_text_only
return False
def normalize_name_for_comparison(name):
"""Normalize a filename for comparison - preserving number positions"""
name = name.lower()
name = re.sub(r'\.(epub|txt|html?)$', '', name)
name = re.sub(r'[-_\s]+', ' ', name)
name = re.sub(r'\[(?![^\]]*\d)[^\]]*\]', '', name)
name = re.sub(r'\((?![^)]*\d)[^)]*\)', '', name)
name = re.sub(r'[^\w\s\-]', ' ', name)
name = ' '.join(name.split())
return name.strip()
class QAScannerMixin:
"""Mixin class containing QA Scanner methods for TranslatorGUI"""
def _create_styled_checkbox(self, text):
"""Create a checkbox with all checkmarks disabled"""
from PySide6.QtWidgets import QCheckBox
checkbox = QCheckBox(text)
checkbox.setStyleSheet("""
QCheckBox {
color: white;
}
QCheckBox::indicator {
background-image: none;
image: none;
content: none;
text: none;
}
QCheckBox::indicator:checked {
background-image: none;
image: none;
content: none;
text: none;
}
""")
return checkbox
def _create_styled_radio_button(self, text):
"""Create a radio button with consistent styling"""
from PySide6.QtWidgets import QRadioButton
radio = QRadioButton(text)
radio.setStyleSheet("""
QRadioButton {
color: white;
font-family: Arial;
font-size: 10pt;
}
QRadioButton::indicator {
width: 13px;
height: 13px;
border: 2px solid #0d6efd;
border-radius: 7px;
background-color: transparent;
}
QRadioButton::indicator:checked {
background-color: #0d6efd;
border: 2px solid #0d6efd;
}
QRadioButton::indicator:hover {
border: 2px solid #0b5ed7;
}
QRadioButton::indicator:checked:hover {
background-color: #0b5ed7;
border: 2px solid #0b5ed7;
}
""")
return radio
def open_latest_qa_report(self):
"""Open the most recently found QA report (validation_results.html)."""
try:
override_dir = os.environ.get('OUTPUT_DIRECTORY') or self.config.get('output_directory')
newest = None
newest_mtime = -1
search_roots = []
if override_dir and os.path.isdir(override_dir):
search_roots.append(os.path.normpath(override_dir))
else:
search_roots.append(os.getcwd())
for root_dir in search_roots:
for root, _, files in os.walk(root_dir):
for fname in files:
if fname.lower() == "validation_results.html":
candidate = os.path.join(root, fname)
try:
mtime = os.path.getmtime(candidate)
except Exception:
mtime = 0
if mtime > newest_mtime:
newest_mtime = mtime
newest = candidate
# Fallback to cached path only if nothing found in current search
if not newest and getattr(self, 'last_qa_report_path', None) and os.path.exists(self.last_qa_report_path):
newest = self.last_qa_report_path
if not newest or not os.path.exists(newest):
QMessageBox.information(self, "QA Report", "QA report does not exist. Run a QA scan first.")
return
self.last_qa_report_path = newest
QDesktopServices.openUrl(QUrl.fromLocalFile(os.path.abspath(newest)))
if hasattr(self, 'append_log'):
self.append_log(f"📄 Opened QA report: {os.path.basename(newest)}")
except Exception as e:
try:
if hasattr(self, 'append_log'):
self.append_log(f"❌ Failed to open QA report: {e}")
except Exception:
pass
QMessageBox.warning(self, "QA Report", f"Failed to open QA report:\n{e}")
def run_qa_scan(self, mode_override=None, non_interactive=False, preselected_files=None):
"""Run QA scan with mode selection and settings"""
# Removed loading screen - initialize directly for smoother experience
try:
# Start a brief auto-scroll delay so first log lines are readable
try:
import time as _time
if hasattr(self, '_start_autoscroll_delay'):
self._start_autoscroll_delay(100)
elif hasattr(self, '_autoscroll_delay_until'):
self._autoscroll_delay_until = _time.time() + 0.6
except Exception:
pass
if not self._lazy_load_modules():
self.append_log("❌ Failed to load QA scanner modules")
return
# Check for scan_html_folder in the global scope from translator_gui
import sys
translator_module = sys.modules.get('translator_gui')
if translator_module is None or not hasattr(translator_module, 'scan_html_folder') or translator_module.scan_html_folder is None:
self.append_log("❌ QA scanner module is not available")
QMessageBox.critical(None, "Module Error", "QA scanner module is not available.")
return
if hasattr(self, 'qa_thread') and self.qa_thread and self.qa_thread.is_alive():
self.stop_requested = True
self.append_log("⛔ QA scan stop requested.")
return
self.append_log("✅ QA scanner initialized successfully")
except Exception as e:
self.append_log(f"❌ Error initializing QA scanner: {e}")
return
# Load QA scanner settings from config
qa_settings = self.config.get('qa_scanner_settings', {
'foreign_char_threshold': 10,
'excluded_characters': '',
'target_language': 'english',
'source_language': 'auto',
'check_encoding_issues': False,
'check_repetition': True,
'check_translation_artifacts': False,
'check_ai_artifacts': False,
'check_punctuation_mismatch': False,
'punctuation_loss_threshold': 49,
'flag_excess_punctuation': False,
'excess_punctuation_threshold': 49,
'check_glossary_leakage': True,
'check_missing_images': True,
'min_file_length': 0,
'report_format': 'detailed',
'auto_save_report': True,
'check_missing_html_tag': True,
'check_missing_header_tags': True,
'check_invalid_nesting': False,
'check_word_count_ratio': True,
'check_multiple_headers': True,
'warn_name_mismatch': True,
'quick_scan_sample_size': 1000,
'cache_enabled': True,
'cache_auto_size': False,
'cache_show_stats': False,
'cache_normalize_text': 10000,
'cache_similarity_ratio': 20000,
'cache_content_hashes': 5000,
'cache_semantic_fingerprint': 2000,
'cache_structural_signature': 2000,
'cache_translation_artifacts': 1000,
'word_count_multipliers': {
# Character-based multipliers (source chars → target chars, no spaces)
# CJK languages expand significantly when translated to alphabetic languages
'english': 1.0,
'spanish': 1.10,
'french': 1.10,
'german': 1.05,
'italian': 1.05,
'portuguese': 1.10,
'russian': 1.15,
'arabic': 1.15,
'hindi': 1.10,
'turkish': 1.05,
'chinese': 2.50,
'chinese (simplified)': 2.50,
'chinese (traditional)': 2.50,
'japanese': 2.20,
'korean': 2.30,
'hebrew': 1.05,
'thai': 1.10
}
})
# Ensure multipliers include all defaults
wordcount_defaults = qa_settings.get('word_count_multipliers', {})
if not wordcount_defaults or not isinstance(wordcount_defaults, dict):
wordcount_defaults = {}
for _k, _v in {
# Character-based multipliers (source chars → target chars, no spaces)
'english': 1.0, 'spanish': 1.10, 'french': 1.10, 'german': 1.05, 'italian': 1.05,
'portuguese': 1.10, 'russian': 1.15, 'arabic': 1.15, 'hindi': 1.10, 'turkish': 1.05,
'chinese': 2.50, 'chinese (simplified)': 2.50, 'chinese (traditional)': 2.50,
'japanese': 2.20, 'korean': 2.30, 'hebrew': 1.05, 'thai': 1.10,
'other': 1.0
}.items():
wordcount_defaults.setdefault(_k, _v)
qa_settings['word_count_multipliers'] = wordcount_defaults
# Keep QA target language aligned with the main target language.
# This ensures the scanner respects the same language the user
# selected for translation.
try:
main_lang = self.config.get('output_language') or os.getenv('OUTPUT_LANGUAGE', '')
if main_lang:
qa_settings['target_language'] = _normalize_target_language(main_lang)
except Exception:
pass
# Debug: Print current settings
print(f"[DEBUG] QA Settings: {qa_settings}")
print(f"[DEBUG] Target language: {qa_settings.get('target_language', 'NOT SET')}")
print(f"[DEBUG] Word count check enabled: {qa_settings.get('check_word_count_ratio', False)}")
# Optionally skip mode dialog if a mode override was provided (e.g., scanning phase)
selected_mode_value = mode_override if mode_override else None
if selected_mode_value is None:
# Show mode selection dialog with settings - calculate proportional sizing (halved)
screen = QApplication.primaryScreen().geometry()
screen_width = screen.width()
screen_height = screen.height()
dialog_width = int(screen_width * 0.51) # 50% of screen width
dialog_height = int(screen_height * 0.43) # 45% of screen height
mode_dialog = QDialog(self)
# Apply global stylesheet for consistent appearance IMMEDIATELY to prevent white flash
mode_dialog.setStyleSheet("""
QDialog {
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
stop:0 #1a1a2e, stop:1 #16213e);
}
QPushButton {
border: 1px solid #4a5568;
border-radius: 4px;
padding: 8px 16px;
background-color: #2d3748;
color: white;
font-weight: bold;
}
QPushButton:hover {
background-color: #4a5568;
border-color: #718096;
}
QPushButton:pressed {
background-color: #1a202c;
}
""")
mode_dialog.setWindowTitle("Select QA Scanner Mode")
mode_dialog.resize(dialog_width, dialog_height)
# Non-modal but stays on top
mode_dialog.setModal(False)
mode_dialog.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint)
# Set window icon
try:
ico_path = os.path.join(self.base_dir, 'Halgakos.ico')
if os.path.isfile(ico_path):
mode_dialog.setWindowIcon(QIcon(ico_path))
except Exception:
pass
if selected_mode_value is None:
# Set minimum size to prevent dialog from being too small (using ratios)
# 35% width, 35% height for better content fit
min_width = int(screen_width * 0.45)
min_height = int(screen_height * 0.35)
mode_dialog.setMinimumSize(min_width, min_height)
# Variables
# selected_mode_value already set above
# Main container with constrained expansion
main_layout = QVBoxLayout(mode_dialog)
main_layout.setContentsMargins(10, 10, 10, 10)
# Content widget with padding
content_widget = QWidget()
content_layout = QVBoxLayout(content_widget)
content_layout.setContentsMargins(15, 10, 15, 10)
main_layout.addWidget(content_widget)
# Pre-create Quick Scan sample size spinbox so click handlers can capture it
quick_sample_spinbox = QSpinBox()
quick_sample_spinbox.setMinimum(-1) # -1 = use full text (no downsampling)
quick_sample_spinbox.setMaximum(20000)
quick_sample_spinbox.setSingleStep(500)
qs_initial = qa_settings.get('quick_scan_sample_size', 1000)
try:
qs_initial = int(qs_initial)
except Exception:
qs_initial = 1000
quick_sample_spinbox.setValue(qs_initial)
quick_sample_spinbox.setMinimumWidth(110)
quick_sample_spinbox.wheelEvent = lambda event: event.ignore()
# Auto-save whenever the value changes or editing finishes
def _save_quick_sample(val):
try:
val = int(val)
qa_settings['quick_scan_sample_size'] = val
if hasattr(self, 'config'):
self.config.setdefault('qa_scanner_settings', {})
self.config['qa_scanner_settings']['quick_scan_sample_size'] = val
if hasattr(self, 'save_config'):
self.save_config(show_message=False)
except Exception:
pass
try:
quick_sample_spinbox.valueChanged.disconnect()
except Exception:
pass
quick_sample_spinbox.valueChanged.connect(_save_quick_sample)
try:
quick_sample_spinbox.editingFinished.disconnect()
except Exception:
pass
quick_sample_spinbox.editingFinished.connect(lambda: _save_quick_sample(quick_sample_spinbox.value()))
# Persist current value immediately to ensure config key exists
_save_quick_sample(qs_initial)
# Title with subtitle
title_label = QLabel("Select Detection Mode")
title_label.setFont(QFont("Arial", 20, QFont.Bold))
title_label.setStyleSheet("color: #f0f0f0;")
title_label.setAlignment(Qt.AlignCenter)
content_layout.addWidget(title_label)
subtitle_label = QLabel("Choose how sensitive the duplicate detection should be")
subtitle_label.setFont(QFont("Arial", 11))
subtitle_label.setStyleSheet("color: #d0d0d0;")
subtitle_label.setAlignment(Qt.AlignCenter)
content_layout.addWidget(subtitle_label)
content_layout.addSpacing(8)
# Mode cards container
modes_widget = QWidget()
modes_layout = QGridLayout(modes_widget)
modes_layout.setSpacing(8)
content_layout.addWidget(modes_widget)
mode_data = [
{
"value": "ai-hunter",
"emoji": "🤖",
"title": "AI HUNTER",
"subtitle": "30% threshold",
"features": [
"✓ Catches AI retranslations",
"✓ Different translation styles",
"⚠ MANY false positives",
"✓ Same chapter, different words",
"✓ Detects paraphrasing",
"✓ Ultimate duplicate finder"
],
"bg_color": "#2a1a3e", # Dark purple
"hover_color": "#6a4c93", # Medium purple
"border_color": "#8b5cf6",
"accent_color": "#a78bfa",
"recommendation": "⚡ Best for finding ALL similar content"
},
{
"value": "aggressive",
"emoji": "🔥",
"title": "AGGRESSIVE",
"subtitle": "75% threshold",
"features": [
"✓ Catches most duplicates",
"✓ Good for similar chapters",
"⚠ Some false positives",
"✓ Finds edited duplicates",
"✓ Moderate detection",
"✓ Balanced approach"
],
"bg_color": "#3a1f1f", # Dark red
"hover_color": "#8b3a3a", # Medium red
"border_color": "#dc2626",
"accent_color": "#ef4444",
"recommendation": None
},
{
"value": "quick-scan",
"emoji": "⚡",
"title": "QUICK SCAN",
"subtitle": "85% threshold, Speed optimized",
"features": [
"✓ 3-5x faster scanning",
"✓ Checks consecutive chapters only",
"✓ Simplified analysis",
"✓ Skips AI Hunter",
"✓ Good for large libraries",
"✓ Minimal resource usage"
],
"bg_color": "#1f2937", # Dark gray
"hover_color": "#374151", # Medium gray
"border_color": "#059669",
"accent_color": "#10b981",
"recommendation": "✅ Recommended for average use"
},
{
"value": "custom",
"emoji": "⚙️",
"title": "CUSTOM",
"subtitle": "Configurable",
"features": [
"✓ Fully customizable",
"✓ Set your own thresholds",
"✓ Advanced controls",
"✓ Fine-tune detection",
"✓ Expert mode",
"✓ Maximum flexibility"
],
"bg_color": "#1e3a5f", # Dark blue
"hover_color": "#2c5aa0", # Medium blue
"border_color": "#3b82f6",
"accent_color": "#60a5fa",
"recommendation": None
}
]
# Restore original single-row layout (four cards across)
if selected_mode_value is None:
# Scale down the card contents on small screens while keeping the same 4-card row.
try:
ui_scale = min(1.0, max(0.75, min(screen_width / 1600.0, screen_height / 900.0)))
except Exception:
ui_scale = 1.0
emoji_px = max(28, int(38 * ui_scale))
title_pt = max(12, int(16 * ui_scale))
subtitle_pt = max(8, int(10 * ui_scale))
feature_pt = max(7, int(9 * ui_scale))
icon_logical = max(40, int(56 * ui_scale))
icon_h = max(44, int(60 * ui_scale))
# Make each column share space evenly
for col in range(len(mode_data)):
modes_layout.setColumnStretch(col, 1)
for idx, mi in enumerate(mode_data):
# Main card frame with initial background and border
card = QFrame()
card.setFrameShape(QFrame.StyledPanel)
card.setStyleSheet(f"""
QFrame {{
background-color: {mi["bg_color"]};
border: 2px solid {mi["border_color"]};
border-radius: 5px;
}}
QFrame:hover {{
background-color: {mi["hover_color"]};
}}
""")
card.setCursor(Qt.PointingHandCursor)
modes_layout.addWidget(card, 0, idx)
# Content layout
card_layout = QVBoxLayout(card)
m = max(6, int(10 * ui_scale))
mb = max(3, int(5 * ui_scale))
card_layout.setContentsMargins(m, m, m, mb)
# Icon/Emoji container with fixed height for alignment
icon_container = QWidget()
icon_container.setFixedHeight(icon_h)
icon_container.setStyleSheet("background-color: transparent;")
icon_container_layout = QVBoxLayout(icon_container)
icon_container_layout.setContentsMargins(0, 0, 0, 0)
icon_container_layout.setAlignment(Qt.AlignCenter)
# Icon/Emoji - use Halgakos.ico for AI Hunter, emoji for others (HiDPI, multi-path, sharp)
if mi["value"] == "ai-hunter":
icon_label = None
try:
import sys
candidates = [
os.path.join(getattr(self, "base_dir", os.getcwd()), "Halgakos.ico"),
os.path.join(os.path.dirname(os.path.abspath(__file__)), "Halgakos.ico"),
os.path.join(os.getcwd(), "Halgakos.ico"),
os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "Halgakos.ico"),
os.path.join(getattr(sys, "_MEIPASS", os.getcwd()), "Halgakos.ico"),
]
ico_path = next((p for p in candidates if os.path.isfile(p)), None)
if ico_path:
icon = QIcon(ico_path)
icon_label = QLabel()
icon_label.setStyleSheet("background-color: transparent; border: none;")
# Use device pixel ratio to avoid blur
try:
dpr = self.devicePixelRatioF()
except Exception:
dpr = 1.0
target_logical = icon_logical # requested logical size of label
dev_px = int(target_logical * max(1.0, dpr))
# Prefer largest available size to reduce scaling blur
avail = icon.availableSizes()
if avail:
best = max(avail, key=lambda s: s.width() * s.height())
pm = icon.pixmap(best * int(max(1.0, dpr)))
else:
pm = icon.pixmap(QSize(dev_px, dev_px))
if pm.isNull():
pm = QPixmap(ico_path)
if not pm.isNull():
try:
pm.setDevicePixelRatio(dpr)
except Exception:
pass
# Fit into logical target size
fitted = pm.scaled(
int(target_logical * dpr),
int(target_logical * dpr),
Qt.KeepAspectRatio,
Qt.SmoothTransformation,
)
try:
fitted.setDevicePixelRatio(dpr)
except Exception:
pass
icon_label.setPixmap(fitted)
icon_label.setFixedSize(target_logical, target_logical)
icon_label.setAlignment(Qt.AlignCenter)
icon_container_layout.addWidget(icon_label)
else:
icon_label = None
except Exception:
icon_label = None
if icon_label is None:
emoji_label = QLabel(mi["emoji"])
emoji_label.setFont(QFont("Arial", emoji_px))
emoji_label.setAlignment(Qt.AlignCenter)
emoji_label.setStyleSheet("background-color: transparent; color: white; border: none;")
icon_container_layout.addWidget(emoji_label)
else:
# Use emoji for other cards
emoji_label = QLabel(mi["emoji"])
emoji_label.setFont(QFont("Arial", emoji_px))
emoji_label.setAlignment(Qt.AlignCenter)
emoji_label.setStyleSheet("background-color: transparent; color: white; border: none;")
icon_container_layout.addWidget(emoji_label)
card_layout.addWidget(icon_container)
# Title
title_label = QLabel(mi["title"])
title_label.setFont(QFont("Arial", title_pt, QFont.Bold))
title_label.setWordWrap(True)
title_label.setAlignment(Qt.AlignCenter)
title_label.setStyleSheet(f"background-color: transparent; color: white; border: none;")
card_layout.addWidget(title_label)
# Subtitle
subtitle_label = QLabel(mi["subtitle"])
subtitle_label.setFont(QFont("Arial", subtitle_pt))
subtitle_label.setWordWrap(True)
subtitle_label.setAlignment(Qt.AlignCenter)
subtitle_label.setStyleSheet(f"background-color: transparent; color: {mi['accent_color']}; border: none;")
card_layout.addWidget(subtitle_label)
card_layout.addSpacing(6)
# Features
for feature in mi["features"]:
feature_label = QLabel(feature)
feature_label.setFont(QFont("Arial", feature_pt))
feature_label.setWordWrap(True)
feature_label.setStyleSheet(f"background-color: transparent; color: #e0e0e0; border: none;")
card_layout.addWidget(feature_label)
# Recommendation badge if present
if mi["recommendation"]:
card_layout.addSpacing(6)
rec_label = QLabel(mi["recommendation"])
rec_label.setFont(QFont("Arial", feature_pt, QFont.Bold))
rec_label.setWordWrap(True)
rec_label.setStyleSheet(f"""
background-color: {mi['accent_color']};
color: white;
padding: 3px 6px;
border-radius: 3px;
""")
rec_label.setAlignment(Qt.AlignCenter)
card_layout.addWidget(rec_label)
card_layout.addStretch()
# Click handler
def make_click_handler(mode_value):
def handler():
nonlocal selected_mode_value
# Persist quick scan sample size before closing
qa_settings['quick_scan_sample_size'] = quick_sample_spinbox.value()
try:
if hasattr(self, 'config'):
if 'qa_scanner_settings' not in self.config:
self.config['qa_scanner_settings'] = {}
self.config['qa_scanner_settings'].update(qa_settings)
# Save quietly to persist between runs
if hasattr(self, 'save_config'):
self.save_config(show_message=False)
except Exception:
pass
selected_mode_value = mode_value
mode_dialog.accept()
return handler
# Make card clickable with mouse press event
card.mousePressEvent = lambda event, handler=make_click_handler(mi["value"]): handler()
if selected_mode_value is None:
# Quick Scan sample size control
qs_row = QWidget()
qs_layout = QHBoxLayout(qs_row)
qs_layout.setContentsMargins(4, 8, 4, 4)
qs_label = QLabel("Quick Scan duplicate check sample size (characters):")
qs_label.setFont(QFont("Arial", 10))
qs_label.setStyleSheet("color: #f0f0f0;")
qs_layout.addWidget(qs_label)
qs_layout.addWidget(quick_sample_spinbox)
hint = QLabel("Used only for duplicate detection; -1 = all text, 0 = disable check")
hint.setStyleSheet("color: #9ca3af;")
hint.setFont(QFont("Arial", 9))
qs_layout.addWidget(hint)
qs_layout.addStretch()
content_layout.addWidget(qs_row)
# Add separator line before buttons
separator = QFrame()
separator.setFrameShape(QFrame.HLine)
separator.setStyleSheet("background-color: #cccccc;")
separator.setFixedHeight(1)
content_layout.addWidget(separator)
content_layout.addSpacing(10)
# Add settings/button layout
button_layout = QHBoxLayout()
button_layout.addStretch()
# Open QA report button (left of auto-search toggle)
open_report_btn = QPushButton("📁 Open QA Report")
open_report_btn.setMinimumWidth(130)
open_report_btn.setStyleSheet("""
QPushButton {
background-color: #17a2b8;
color: white;
border: 1px solid #17a2b8;
padding: 8px 10px;
border-radius: 4px;
font-weight: bold;
}
QPushButton:hover {
background-color: #138496;
border-color: #117a8b;
}
""")
open_report_btn.clicked.connect(lambda: self.open_latest_qa_report())
button_layout.addWidget(open_report_btn)
button_layout.addSpacing(10)
def show_qa_settings():
"""Show QA Scanner settings dialog"""
self.show_qa_scanner_settings(mode_dialog, qa_settings)
# Auto-search checkbox
if not hasattr(self, 'qa_auto_search_output_checkbox'):
self.qa_auto_search_output_checkbox = self._create_styled_checkbox("Auto-search output")
# Define the save handler
def save_auto_search_state(checked):
self.config['qa_auto_search_output'] = checked
self.save_config(show_message=False)
self.qa_auto_search_save_handler = save_auto_search_state
# Always update checkbox state from current config
# Block signals temporarily to prevent triggering save during programmatic update
self.qa_auto_search_output_checkbox.blockSignals(True)
self.qa_auto_search_output_checkbox.setChecked(self.config.get('qa_auto_search_output', True))
self.qa_auto_search_output_checkbox.blockSignals(False)
# Connect or reconnect the signal handler
try:
self.qa_auto_search_output_checkbox.toggled.disconnect()
except:
pass # No handler was connected
self.qa_auto_search_output_checkbox.toggled.connect(self.qa_auto_search_save_handler)
button_layout.addWidget(self.qa_auto_search_output_checkbox)
button_layout.addSpacing(10)
settings_btn = QPushButton("⚙️ Scanner Settings")
settings_btn.setMinimumWidth(140)
settings_btn.setStyleSheet("""
QPushButton {
background-color: #0d6efd;
color: white;
border: 1px solid #0d6efd;
padding: 8px 10px;
border-radius: 4px;
}
QPushButton:hover {
background-color: #0b5ed7;
}
""")
settings_btn.clicked.connect(show_qa_settings)
button_layout.addWidget(settings_btn)
button_layout.addSpacing(10)
cancel_btn = QPushButton("Cancel")
cancel_btn.setMinimumWidth(100)
cancel_btn.setStyleSheet("""
QPushButton {
background-color: #dc3545;
color: white;
border: 1px solid #dc3545;
padding: 8px 10px;
border-radius: 4px;
}
QPushButton:hover {
background-color: #bb2d3b;
}
""")
cancel_btn.clicked.connect(mode_dialog.reject)
button_layout.addWidget(cancel_btn)
button_layout.addStretch()
content_layout.addLayout(button_layout)
# Handle window close (X button)
def on_close():
nonlocal selected_mode_value
selected_mode_value = None
mode_dialog.rejected.connect(on_close)
# Show dialog non-modally and wait for result using local event loop
# This allows interaction with the main window while waiting
mode_dialog.show()
from PySide6.QtCore import QEventLoop
loop = QEventLoop()
mode_dialog.finished.connect(loop.quit)
loop.exec()
result = mode_dialog.result()
# Check if user canceled or selected a mode
if result == QDialog.Rejected or selected_mode_value is None:
self.append_log("⚠️ QA scan canceled.")
return
# End of optional mode dialog
# Show custom settings dialog if custom mode is selected
# BUT skip the dialog if non_interactive=True (e.g., post-translation scan)
if selected_mode_value == "custom" and not non_interactive:
# Create custom settings dialog
custom_dialog = QDialog(self)
# Apply dark stylesheet IMMEDIATELY to prevent white flash
custom_dialog.setStyleSheet("""
QDialog {
background-color: #2d2d2d;
color: white;
}
QGroupBox {
color: white;
border: 1px solid #555;
margin: 10px;
padding-top: 10px;
}
QGroupBox::title {
color: white;
left: 10px;
padding: 0 5px;
}
QLabel {
color: white;
}
QPushButton {
background-color: #404040;
color: white;
border: 1px solid #555;
padding: 5px;
}
QPushButton:hover {
background-color: #505050;
}
""")
custom_dialog.setWindowTitle("Custom Mode Settings")
custom_dialog.setModal(True)
# Use screen ratios: 20% width, 50% height for better content fit
screen = QApplication.primaryScreen().geometry()
custom_width = int(screen.width() * 0.51)
custom_height = int(screen.height() * 0.60)
custom_dialog.resize(custom_width, custom_height)
# Set window icon
try:
ico_path = os.path.join(self.base_dir, 'Halgakos.ico')
if os.path.isfile(ico_path):
custom_dialog.setWindowIcon(QIcon(ico_path))
except Exception:
pass
# Main layout
dialog_layout = QVBoxLayout(custom_dialog)
# Scroll area
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
# Scrollable content widget
scroll_widget = QWidget()
scroll_layout = QVBoxLayout(scroll_widget)
scroll.setWidget(scroll_widget)
dialog_layout.addWidget(scroll)
# Variables for custom settings (using native Python values instead of tk vars)
# Load saved settings from config if they exist
saved_custom_settings = self.config.get('qa_scanner_settings', {}).get('custom_mode_settings', {})
# Default values
custom_settings = {
'similarity': 85,
'semantic': 80,
'structural': 90,
'word_overlap': 75,
'minhash_threshold': 80,
'consecutive_chapters': 2,
'check_all_pairs': False,
'sample_size': 3000,
'min_text_length': 500,
'min_duplicate_word_count': 500
}
# Override with saved settings if they exist
if saved_custom_settings:
# Load threshold values (they're stored as decimals, need to convert to percentages)
saved_thresholds = saved_custom_settings.get('thresholds', {})
if saved_thresholds:
custom_settings['similarity'] = int(saved_thresholds.get('similarity', 0.85) * 100)
custom_settings['semantic'] = int(saved_thresholds.get('semantic', 0.80) * 100)
custom_settings['structural'] = int(saved_thresholds.get('structural', 0.90) * 100)
custom_settings['word_overlap'] = int(saved_thresholds.get('word_overlap', 0.75) * 100)
custom_settings['minhash_threshold'] = int(saved_thresholds.get('minhash_threshold', 0.80) * 100)
# Load other settings
custom_settings['consecutive_chapters'] = saved_custom_settings.get('consecutive_chapters', 2)
custom_settings['check_all_pairs'] = saved_custom_settings.get('check_all_pairs', False)
custom_settings['sample_size'] = saved_custom_settings.get('sample_size', 3000)
custom_settings['min_text_length'] = saved_custom_settings.get('min_text_length', 500)
self.append_log("📥 Loaded saved custom mode settings from config")
# Store widget references
custom_widgets = {}
# Title with icons on both sides
title_container = QWidget()
title_layout = QHBoxLayout(title_container)
title_layout.setContentsMargins(0, 0, 0, 0)
# Left icon
left_icon_label = QLabel()
try:
ico_path = os.path.join(self.base_dir, 'Halgakos.ico')
if os.path.isfile(ico_path):
icon = QIcon(ico_path)
pixmap = icon.pixmap(48, 48)
if not pixmap.isNull():
left_icon_label.setPixmap(pixmap)
left_icon_label.setAlignment(Qt.AlignCenter)
left_icon_label.setStyleSheet("background-color: transparent; border: none;")
except Exception:
pass
# Title text
title_label = QLabel("Configure Custom Detection Settings")
title_label.setFont(QFont('Arial', 20, QFont.Bold))
title_label.setAlignment(Qt.AlignCenter)
title_label.setStyleSheet("background-color: transparent; border: none;")
# Right icon
right_icon_label = QLabel()
try:
ico_path = os.path.join(self.base_dir, 'Halgakos.ico')
if os.path.isfile(ico_path):
icon = QIcon(ico_path)
pixmap = icon.pixmap(48, 48)
if not pixmap.isNull():
right_icon_label.setPixmap(pixmap)
right_icon_label.setAlignment(Qt.AlignCenter)
right_icon_label.setStyleSheet("background-color: transparent; border: none;")
except Exception:
pass
# Add to layout with proper spacing
title_layout.addStretch()
title_layout.addWidget(left_icon_label)
title_layout.addSpacing(15)
title_layout.addWidget(title_label)
title_layout.addSpacing(15)
title_layout.addWidget(right_icon_label)
title_layout.addStretch()
scroll_layout.addWidget(title_container)
scroll_layout.addSpacing(20)
# Detection Thresholds Section
threshold_group = QGroupBox("Detection Thresholds (%)")
threshold_group.setFont(QFont('Arial', 12, QFont.Bold))
threshold_layout = QVBoxLayout(threshold_group)
threshold_layout.setContentsMargins(25, 25, 25, 25)
scroll_layout.addWidget(threshold_group)
threshold_descriptions = {
'similarity': ('Text Similarity', 'Character-by-character comparison'),
'semantic': ('Semantic Analysis', 'Meaning and context matching'),
'structural': ('Structural Patterns', 'Document structure similarity'),
'word_overlap': ('Word Overlap', 'Common words between texts'),
'minhash_threshold': ('MinHash Similarity', 'Fast approximate matching')
}
# Create percentage labels dictionary to store references
percentage_labels = {}
for setting_key, (label_text, description) in threshold_descriptions.items():
# Container for each threshold
row_widget = QWidget()
row_layout = QHBoxLayout(row_widget)
row_layout.setContentsMargins(0, 8, 0, 8)
# Left side - labels
label_widget = QWidget()
label_layout = QVBoxLayout(label_widget)
label_layout.setContentsMargins(0, 0, 0, 0)
main_label = QLabel(f"{label_text} - {description}:")
main_label.setFont(QFont('Arial', 11))
label_layout.addWidget(main_label)
label_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
row_layout.addWidget(label_widget)
# Right side - slider and percentage
slider_widget = QWidget()
slider_layout = QHBoxLayout(slider_widget)
slider_layout.setContentsMargins(20, 0, 0, 0)
# Create slider
slider = QSlider(Qt.Horizontal)
slider.setMinimum(10)
slider.setMaximum(100)
slider.setValue(custom_settings[setting_key])
slider.setMinimumWidth(300)
# Disable mousewheel scrolling on slider
slider.wheelEvent = lambda event: event.ignore()
slider_layout.addWidget(slider)
# Percentage label (shows current value)
percentage_label = QLabel(f"{custom_settings[setting_key]}%")
percentage_label.setFont(QFont('Arial', 12, QFont.Bold))
percentage_label.setMinimumWidth(50)
percentage_label.setAlignment(Qt.AlignRight)
slider_layout.addWidget(percentage_label)
percentage_labels[setting_key] = percentage_label
row_layout.addWidget(slider_widget)
threshold_layout.addWidget(row_widget)
# Store slider widget reference
custom_widgets[setting_key] = slider
# Update percentage label when slider moves
def create_update_function(key, label, settings_dict):
def update_percentage(value):
settings_dict[key] = value
label.setText(f"{value}%")
return update_percentage
# Connect slider to update function
update_func = create_update_function(setting_key, percentage_label, custom_settings)
slider.valueChanged.connect(update_func)
scroll_layout.addSpacing(15)
# Processing Options Section
options_group = QGroupBox("Processing Options")
options_group.setFont(QFont('Arial', 12, QFont.Bold))
options_layout = QVBoxLayout(options_group)
options_layout.setContentsMargins(20, 20, 20, 20)
scroll_layout.addWidget(options_group)
# Consecutive chapters option with spinbox
consec_widget = QWidget()
consec_layout = QHBoxLayout(consec_widget)
consec_layout.setContentsMargins(0, 5, 0, 5)
consec_label = QLabel("Consecutive chapters to check:")
consec_label.setFont(QFont('Arial', 11))
consec_layout.addWidget(consec_label)
consec_spinbox = QSpinBox()
consec_spinbox.setMinimum(1)
consec_spinbox.setMaximum(10)
consec_spinbox.setValue(custom_settings['consecutive_chapters'])
consec_spinbox.setMinimumWidth(100)
# Disable mousewheel scrolling
consec_spinbox.wheelEvent = lambda event: event.ignore()
consec_layout.addWidget(consec_spinbox)
consec_layout.addStretch()
options_layout.addWidget(consec_widget)
custom_widgets['consecutive_chapters'] = consec_spinbox
# Sample size option
sample_widget = QWidget()
sample_layout = QHBoxLayout(sample_widget)
sample_layout.setContentsMargins(0, 5, 0, 5)
sample_label = QLabel("Sample size for comparison (characters):")
sample_label.setFont(QFont('Arial', 11))
sample_layout.addWidget(sample_label)
# Sample size spinbox with larger range
# -1 = use all characters (no downsampling)
# 0 = disable duplicate detection
sample_spinbox = QSpinBox()
sample_spinbox.setMinimum(-1)
# QSpinBox requires a maximum; set it extremely high to be effectively "no maximum"
sample_spinbox.setMaximum(2000000000)
sample_spinbox.setSingleStep(500)
sample_spinbox.setValue(custom_settings['sample_size'])
sample_spinbox.setMinimumWidth(100)
sample_spinbox.setToolTip("-1 = use all characters, 0 = disable duplicate detection")
# Disable mousewheel scrolling
sample_spinbox.wheelEvent = lambda event: event.ignore()
sample_layout.addWidget(sample_spinbox)
sample_layout.addStretch()
options_layout.addWidget(sample_widget)
custom_widgets['sample_size'] = sample_spinbox
# Minimum text length option
min_length_widget = QWidget()
min_length_layout = QHBoxLayout(min_length_widget)
min_length_layout.setContentsMargins(0, 5, 0, 5)
min_length_label = QLabel("Minimum text length to process (characters):")
min_length_label.setFont(QFont('Arial', 11))
min_length_layout.addWidget(min_length_label)
# Minimum length spinbox
min_length_spinbox = QSpinBox()
min_length_spinbox.setMinimum(100)
min_length_spinbox.setMaximum(5000)
min_length_spinbox.setSingleStep(100)
min_length_spinbox.setValue(custom_settings['min_text_length'])
min_length_spinbox.setMinimumWidth(100)
# Disable mousewheel scrolling
min_length_spinbox.wheelEvent = lambda event: event.ignore()
min_length_layout.addWidget(min_length_spinbox)
min_length_layout.addStretch()
options_layout.addWidget(min_length_widget)
custom_widgets['min_text_length'] = min_length_spinbox
# Check all file pairs option
check_all_checkbox = self._create_styled_checkbox("Check all file pairs (slower but more thorough)")
check_all_checkbox.setChecked(custom_settings['check_all_pairs'])
options_layout.addWidget(check_all_checkbox)
custom_widgets['check_all_pairs'] = check_all_checkbox
scroll_layout.addStretch()
# Create fixed bottom button section (outside scroll area)
button_widget = QWidget()
button_layout = QHBoxLayout(button_widget)
button_layout.setContentsMargins(20, 15, 20, 15)
# Flag to track if settings were saved
settings_saved = False
def save_custom_settings():
"""Save custom settings and close dialog for scan"""
nonlocal settings_saved
qa_settings['custom_mode_settings'] = {
'thresholds': {
'similarity': custom_widgets['similarity'].value() / 100,
'semantic': custom_widgets['semantic'].value() / 100,
'structural': custom_widgets['structural'].value() / 100,
'word_overlap': custom_widgets['word_overlap'].value() / 100,
'minhash_threshold': custom_widgets['minhash_threshold'].value() / 100
},
'consecutive_chapters': custom_widgets['consecutive_chapters'].value(),
'check_all_pairs': custom_widgets['check_all_pairs'].isChecked(),
'sample_size': custom_widgets['sample_size'].value(),
'min_text_length': custom_widgets['min_text_length'].value()
}
settings_saved = True
self.append_log("✅ Custom detection settings saved")
custom_dialog.accept()
def save_settings_to_config():
"""Save settings to config.json without closing dialog"""
try:
# Update qa_settings with current values
current_custom_settings = {
'thresholds': {
'similarity': custom_widgets['similarity'].value() / 100,
'semantic': custom_widgets['semantic'].value() / 100,
'structural': custom_widgets['structural'].value() / 100,
'word_overlap': custom_widgets['word_overlap'].value() / 100,
'minhash_threshold': custom_widgets['minhash_threshold'].value() / 100
},
'consecutive_chapters': custom_widgets['consecutive_chapters'].value(),
'check_all_pairs': custom_widgets['check_all_pairs'].isChecked(),
'sample_size': custom_widgets['sample_size'].value(),
'min_text_length': custom_widgets['min_text_length'].value()
}
# Ensure qa_scanner_settings exists in config
if 'qa_scanner_settings' not in self.config:
self.config['qa_scanner_settings'] = {}
# Update config with current custom settings - FORCE UPDATE
self.config['qa_scanner_settings']['custom_mode_settings'] = current_custom_settings
# Also update qa_settings dict for this session
qa_settings['custom_mode_settings'] = current_custom_settings
# Write config directly to ensure persistence
import json
from api_key_encryption import encrypt_config
google_creds_path = self.config.get('google_cloud_credentials')
encrypted_config = encrypt_config(self.config)
if google_creds_path:
encrypted_config['google_cloud_credentials'] = google_creds_path
# Get config file path
config_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'config.json')
# Write to file
with open(config_file, 'w', encoding='utf-8') as f:
json.dump(encrypted_config, f, ensure_ascii=False, indent=2)
# Show success message
self.append_log("✅ Custom settings saved to config.json")
self.append_log(f"💾 Saved thresholds: similarity={current_custom_settings['thresholds']['similarity']:.0%}, semantic={current_custom_settings['thresholds']['semantic']:.0%}, structural={current_custom_settings['thresholds']['structural']:.0%}")
# Animate the save button
original_text = save_config_btn.text()
original_style = save_config_btn.styleSheet()
save_config_btn.setText("💾 Saved!")
save_config_btn.setStyleSheet("""
QPushButton {
background-color: #28a745;
color: white;
border: 1px solid #28a745;
padding: 6px 12px;
font-weight: bold;
}
""")
# Reset button after delay
def reset_button():
save_config_btn.setText(original_text)
save_config_btn.setStyleSheet(original_style)
QTimer.singleShot(1500, reset_button)
except Exception as e:
self.append_log(f"❌ Error saving settings: {e}")
import traceback
traceback.print_exc()
def reset_to_defaults():
"""Reset all values to default settings"""
reply = QMessageBox.question(custom_dialog, "Reset to Defaults",
"Reset all values to default settings?",
QMessageBox.Yes | QMessageBox.No)
if reply == QMessageBox.Yes:
custom_widgets['similarity'].setValue(85)
custom_widgets['semantic'].setValue(80)
custom_widgets['structural'].setValue(90)
custom_widgets['word_overlap'].setValue(75)
custom_widgets['minhash_threshold'].setValue(80)
custom_widgets['consecutive_chapters'].setValue(2)
custom_widgets['check_all_pairs'].setChecked(False)
custom_widgets['sample_size'].setValue(3000)
custom_widgets['min_text_length'].setValue(500)
self.append_log("ℹ️ Settings reset to defaults")
# Flag to prevent recursive cancel calls
cancel_in_progress = False
def cancel_settings():
"""Cancel without saving"""
nonlocal settings_saved, cancel_in_progress
# Prevent recursive calls
if cancel_in_progress:
return
cancel_in_progress = True
try:
# Disconnect signal before rejecting to prevent loop
try:
custom_dialog.rejected.disconnect(cancel_settings)
except:
pass
custom_dialog.reject()
finally:
cancel_in_progress = False
# Create buttons for bottom section
cancel_btn = QPushButton("Cancel")
cancel_btn.setMinimumWidth(140)
cancel_btn.setStyleSheet("background-color: #6c757d; color: white; padding: 6px 12px; font-weight: bold;")
cancel_btn.clicked.connect(cancel_settings)
button_layout.addWidget(cancel_btn)
reset_btn = QPushButton("Reset to Default")
reset_btn.setMinimumWidth(140)
reset_btn.setStyleSheet("background-color: #ffc107; color: black; padding: 6px 12px; font-weight: bold;")
reset_btn.clicked.connect(reset_to_defaults)
button_layout.addWidget(reset_btn)
# Save Settings button (saves to config.json)
save_config_btn = QPushButton("💾 Save Settings")
save_config_btn.setMinimumWidth(140)
save_config_btn.setStyleSheet("""
QPushButton {
background-color: #007bff;
color: white;
border: 1px solid #007bff;
padding: 6px 12px;
font-weight: bold;
}
QPushButton:hover {
background-color: #0056b3;
}
""")
save_config_btn.clicked.connect(save_settings_to_config)
button_layout.addWidget(save_config_btn)
start_btn = QPushButton("Start Scan")
start_btn.setMinimumWidth(140)
start_btn.setStyleSheet("background-color: #28a745; color: white; padding: 6px 12px; font-weight: bold;")
start_btn.clicked.connect(save_custom_settings)
button_layout.addWidget(start_btn)
# Add button widget to main layout (not scroll layout)
dialog_layout.addWidget(button_widget)
# Handle window close properly - treat as cancel
# Store the connection so we can disconnect it later if needed
rejected_connection = custom_dialog.rejected.connect(cancel_settings)
# Show dialog and wait for result
result = custom_dialog.exec()
# If user cancelled at this dialog, cancel the whole scan
if not settings_saved:
self.append_log("⚠️ QA scan canceled - no custom settings were saved.")
return
# Check if word count cross-reference is enabled but no source file is selected
check_word_count = qa_settings.get('check_word_count_ratio', False)
epub_files_to_scan = []
primary_epub_path = None
# Determine if text file mode is enabled
text_file_mode = self.config.get('qa_text_file_mode', False)
if hasattr(self, 'qa_text_file_mode_checkbox'):
try:
text_file_mode = bool(self.qa_text_file_mode_checkbox.isChecked())
except Exception:
pass
# ALWAYS populate epub_files_to_scan for auto-search, regardless of word count checking
# First check if current selection actually contains source files (EPUB, TXT, PDF, or MD)
current_epub_files = []
if hasattr(self, 'selected_files') and self.selected_files:
# Check for EPUB, TXT, PDF, and MD files
current_epub_files = [f for f in self.selected_files if f.lower().endswith(('.epub', '.txt', '.pdf', '.md'))]
epub_count = len([f for f in current_epub_files if f.lower().endswith('.epub')])
txt_count = len([f for f in current_epub_files if f.lower().endswith('.txt')])
pdf_count = len([f for f in current_epub_files if f.lower().endswith('.pdf')])
md_count = len([f for f in current_epub_files if f.lower().endswith('.md')])
print(f"[DEBUG] Current selection contains {epub_count} EPUB files, {txt_count} TXT files, {pdf_count} PDF files, and {md_count} MD files")
if current_epub_files:
# Use source files from current selection
epub_files_to_scan = current_epub_files
print(f"[DEBUG] Using {len(epub_files_to_scan)} source files from current selection")
else:
# No source files in current selection - check if we have stored path
primary_epub_path = self.get_current_epub_path()
print(f"[DEBUG] get_current_epub_path returned: {primary_epub_path}")
if primary_epub_path:
epub_files_to_scan = [primary_epub_path]
print(f"[DEBUG] Using stored source file for auto-search")
# Now handle word count specific logic if enabled
if check_word_count:
print("[DEBUG] Word count check is enabled, validating EPUB availability...")
# Check if we have source files for word count analysis
if not epub_files_to_scan:
# No source files available for word count analysis
file_type = "text" if text_file_mode else "EPUB"
msg = QMessageBox(self)
msg.setIcon(QMessageBox.Warning)
msg.setWindowTitle(f"No Source {file_type.upper()} Selected")
msg.setText(f"Word count cross-reference is enabled but no source {file_type} file is selected.")
msg.setInformativeText("Would you like to:\n"
"• YES - Continue scan without word count analysis\n"
f"• NO - Select a {file_type} file now\n"
"• CANCEL - Cancel the scan")
msg.setStandardButtons(QMessageBox.Yes | QMessageBox.No | QMessageBox.Cancel)
result = msg.exec()
if result == QMessageBox.Cancel:
self.append_log("⚠️ QA scan canceled.")
return
elif result == QMessageBox.No: # No - Select source file now
if text_file_mode:
epub_path, _ = QFileDialog.getOpenFileName(
self,
"Select Source Text File",
"",
"Text files (*.txt);;All files (*.*)"
)
else:
epub_path, _ = QFileDialog.getOpenFileName(
self,
"Select Source EPUB File",
"",
"EPUB files (*.epub);;All files (*.*)"
)
if not epub_path:
retry = QMessageBox.question(
self,
"No File Selected",
f"No {file_type} file was selected.\n\n" +
"Do you want to continue the scan without word count analysis?",
QMessageBox.Yes | QMessageBox.No
)
if retry == QMessageBox.No:
self.append_log("⚠️ QA scan canceled.")
return
else:
qa_settings = qa_settings.copy()
qa_settings['check_word_count_ratio'] = False
self.append_log("ℹ️ Proceeding without word count analysis.")
epub_files_to_scan = []
else:
self.selected_epub_path = epub_path
self.config['last_epub_path'] = epub_path
self.save_config(show_message=False)
self.append_log(f"✅ Selected {file_type}: {os.path.basename(epub_path)}")
epub_files_to_scan = [epub_path]
else: # Yes - Continue without word count
qa_settings = qa_settings.copy()
qa_settings['check_word_count_ratio'] = False
self.append_log("ℹ️ Proceeding without word count analysis.")
epub_files_to_scan = []
# Try to auto-detect output folders based on EPUB files
folders_to_scan = []
# Get auto-search preference from checkbox if it exists, otherwise from config
auto_search_enabled = self.config.get('qa_auto_search_output', True)
if hasattr(self, 'qa_auto_search_output_checkbox'):
try:
auto_search_enabled = bool(self.qa_auto_search_output_checkbox.isChecked())
except Exception:
pass
# Debug output for scanning phase removed
if auto_search_enabled and epub_files_to_scan:
# Process each EPUB file to find its corresponding output folder
self.append_log(f"🔍 DEBUG: Auto-search running with {len(epub_files_to_scan)} EPUB files")
for epub_path in epub_files_to_scan:
self.append_log(f"🔍 DEBUG: Processing EPUB: {epub_path}")
try:
epub_base = os.path.splitext(os.path.basename(epub_path))[0]
current_dir = os.getcwd()
script_dir = os.path.dirname(os.path.abspath(__file__))
self.append_log(f"🔍 DEBUG: EPUB base name: '{epub_base}'")
self.append_log(f"🔍 DEBUG: Current dir: {current_dir}")
self.append_log(f"🔍 DEBUG: Script dir: {script_dir}")
# Check the most common locations in order of priority
candidates = [
os.path.join(current_dir, epub_base), # current working directory
os.path.join(script_dir, epub_base), # src directory (where output typically goes)
os.path.join(current_dir, 'src', epub_base), # src subdirectory from current dir
]
# Add output directory override if configured
override_dir = os.environ.get('OUTPUT_DIRECTORY') or self.config.get('output_directory')
if override_dir:
candidates.insert(0, os.path.join(override_dir, epub_base))
self.append_log(f"🔍 DEBUG: Checking override dir: {override_dir}")
folder_found = None
for i, candidate in enumerate(candidates):
exists = os.path.isdir(candidate)
self.append_log(f" [{epub_base}] Checking candidate {i+1}: {candidate} - {'EXISTS' if exists else 'NOT FOUND'}")
if exists:
# Verify the folder actually contains appropriate files (HTML/XHTML or TXT)
try:
files = os.listdir(candidate)
# Determine if text file mode is enabled
text_file_mode = self.config.get('qa_text_file_mode', False)
if hasattr(self, 'qa_text_file_mode_checkbox'):
try:
text_file_mode = bool(self.qa_text_file_mode_checkbox.isChecked())
except Exception:
pass
# Auto-detect text file mode if source file is .txt or .pdf
if epub_path and epub_path.lower().endswith(('.txt', '.pdf')):
text_file_mode = True
if text_file_mode:
# For text mode, check for both .txt AND .html files (PDFs generate .html)
target_files = [f for f in files if f.lower().endswith(('.txt', '.html', '.xhtml', '.htm'))]
file_type = "TXT/HTML"
else:
target_files = [f for f in files if f.lower().endswith(('.html', '.xhtml', '.htm'))]
file_type = "HTML/XHTML"
if target_files:
folder_found = candidate
self.append_log(f"📁 Auto-selected output folder for {epub_base}: {folder_found}")
self.append_log(f" Found {len(target_files)} {file_type} files to scan")
break
else:
self.append_log(f" [{epub_base}] Folder exists but contains no {file_type} files: {candidate}")
except Exception as e:
self.append_log(f" [{epub_base}] Error checking files in {candidate}: {e}")
if folder_found:
folders_to_scan.append(folder_found)
self.append_log(f"🔍 DEBUG: Added to folders_to_scan: {folder_found}")
else:
self.append_log(f" ⚠️ No output folder found for {epub_base}")
except Exception as e:
self.append_log(f" ❌ Error processing {epub_base}: {e}")
self.append_log(f"🔍 DEBUG: Final folders_to_scan: {folders_to_scan}")
# Fallback behavior - if no folders found through auto-detection
if not folders_to_scan:
if auto_search_enabled:
# Auto-search failed, offer manual selection as fallback
self.append_log("⚠️ Auto-search enabled but no matching output folder found")
self.append_log("📁 Falling back to manual folder selection...")
selected_folder = QFileDialog.getExistingDirectory(
self,
"Auto-search failed - Select Output Folder to Scan"
)
if not selected_folder:
self.append_log("⚠️ QA scan canceled - no folder selected.")
return
# Verify the selected folder contains scannable files
try:
files = os.listdir(selected_folder)
# Respect text file mode when validating manual selection
text_file_mode = self.config.get('qa_text_file_mode', False)
if hasattr(self, 'qa_text_file_mode_checkbox'):
try:
text_file_mode = bool(self.qa_text_file_mode_checkbox.isChecked())
except Exception:
pass
if text_file_mode:
# For text mode, check for both .txt AND .html files (PDFs generate .html)
target_files = [f for f in files if f.lower().endswith(('.txt', '.html', '.xhtml', '.htm'))]
file_type = "TXT/HTML"
else:
target_files = [f for f in files if f.lower().endswith(('.html', '.xhtml', '.htm'))]
file_type = "HTML/XHTML"
if target_files:
folders_to_scan.append(selected_folder)
self.append_log(f"✓ Manual selection: {os.path.basename(selected_folder)} ({len(target_files)} {file_type} files)")
else:
self.append_log(f"❌ Selected folder contains no {file_type} files: {selected_folder}")
return
except Exception as e:
self.append_log(f"❌ Error checking selected folder: {e}")
return
if non_interactive:
# Add debug info for scanning phase
if epub_files_to_scan:
self.append_log(f"⚠️ Scanning phase: No matching output folders found for {len(epub_files_to_scan)} EPUB file(s)")
for epub_path in epub_files_to_scan:
epub_base = os.path.splitext(os.path.basename(epub_path))[0]
current_dir = os.getcwd()
expected_folder = os.path.join(current_dir, epub_base)
self.append_log(f" [{epub_base}] Expected: {expected_folder}")
self.append_log(f" [{epub_base}] Exists: {os.path.isdir(expected_folder)}")
# List actual folders in current directory for debugging
try:
current_dir = os.getcwd()
actual_folders = [d for d in os.listdir(current_dir) if os.path.isdir(os.path.join(current_dir, d)) and not d.startswith('.')]
if actual_folders:
self.append_log(f" Available folders: {', '.join(actual_folders[:10])}{'...' if len(actual_folders) > 10 else ''}")
except Exception:
pass
else:
self.append_log("⚠️ Scanning phase: No EPUB files available for folder detection")
self.append_log("⚠️ Skipping scan")
return
# Clean single folder selection - no messageboxes, no harassment
self.append_log("📁 Select folder to scan...")
folders_to_scan = []
# Simply select one folder - clean and simple
# Adjust caption to reflect current file mode
text_file_mode = self.config.get('qa_text_file_mode', False)
if hasattr(self, 'qa_text_file_mode_checkbox'):
try:
text_file_mode = bool(self.qa_text_file_mode_checkbox.isChecked())
except Exception:
pass
caption = "Select Folder with TXT Files" if text_file_mode else "Select Folder with HTML Files"
selected_folder = QFileDialog.getExistingDirectory(
self,
caption
)
if not selected_folder:
self.append_log("⚠️ QA scan canceled - no folder selected.")
return
folders_to_scan.append(selected_folder)
self.append_log(f" ✓ Selected folder: {os.path.basename(selected_folder)}")
self.append_log(f"📁 Single folder scan mode - scanning: {os.path.basename(folders_to_scan[0])}")
mode = selected_mode_value
# Initialize epub_path for use in run_scan() function
# This ensures epub_path is always defined even when manually selecting folders
epub_path = None
if epub_files_to_scan:
epub_path = epub_files_to_scan[0] # Use first EPUB if multiple
self.append_log(f"📚 Using EPUB from scan list: {os.path.basename(epub_path)}")
elif hasattr(self, 'selected_epub_path') and self.selected_epub_path:
epub_path = self.selected_epub_path
self.append_log(f"📚 Using stored EPUB: {os.path.basename(epub_path)}")
elif primary_epub_path:
epub_path = primary_epub_path
self.append_log(f"📚 Using primary EPUB: {os.path.basename(epub_path)}")
else:
self.append_log("ℹ️ No EPUB file configured (word count analysis will be disabled if needed)")
# Initialize global selected_files that applies to single-folder scans
global_selected_files = None
if len(folders_to_scan) == 1 and preselected_files:
global_selected_files = list(preselected_files)
elif len(folders_to_scan) == 1 and (not non_interactive) and (not auto_search_enabled):
# Scan all files in the folder - no messageboxes asking about specific files
# User can set up file preselection if they need specific files
pass
# Log bulk scan start
if len(folders_to_scan) == 1:
self.append_log(f"🔍 Starting QA scan in {mode.upper()} mode for folder: {folders_to_scan[0]}")
else:
self.append_log(f"🔍 Starting bulk QA scan in {mode.upper()} mode for {len(folders_to_scan)} folders")
self.stop_requested = False
# Extract cache configuration from qa_settings
cache_config = {
'enabled': qa_settings.get('cache_enabled', True),
'auto_size': qa_settings.get('cache_auto_size', False),
'show_stats': qa_settings.get('cache_show_stats', False),
'sizes': {}
}
# Get individual cache sizes
for cache_name in ['normalize_text', 'similarity_ratio', 'content_hashes',
'semantic_fingerprint', 'structural_signature', 'translation_artifacts']:
size = qa_settings.get(f'cache_{cache_name}', None)
if size is not None:
# Convert -1 to None for unlimited
cache_config['sizes'][cache_name] = None if size == -1 else size
# Create custom settings that includes cache config
custom_settings = {
'qa_settings': qa_settings,
'cache_config': cache_config,
'log_cache_stats': qa_settings.get('cache_show_stats', False)
}
def run_scan():
try:
# Extract cache configuration from qa_settings
cache_config = {
'enabled': qa_settings.get('cache_enabled', True),
'auto_size': qa_settings.get('cache_auto_size', False),
'show_stats': qa_settings.get('cache_show_stats', False),
'sizes': {}
}
# Get individual cache sizes
for cache_name in ['normalize_text', 'similarity_ratio', 'content_hashes',
'semantic_fingerprint', 'structural_signature', 'translation_artifacts']:
size = qa_settings.get(f'cache_{cache_name}', None)
if size is not None:
# Convert -1 to None for unlimited
cache_config['sizes'][cache_name] = None if size == -1 else size
# Configure the cache BEFORE calling scan_html_folder
from scan_html_folder import configure_qa_cache
configure_qa_cache(cache_config)
# Loop through all selected folders for bulk scanning
successful_scans = 0
failed_scans = 0
for i, current_folder in enumerate(folders_to_scan):
if self.stop_requested:
self.append_log(f"⚠️ Bulk scan stopped by user at folder {i+1}/{len(folders_to_scan)}")
break
folder_name = os.path.basename(current_folder)
if len(folders_to_scan) > 1:
self.append_log(f"\n📁 [{i+1}/{len(folders_to_scan)}] Scanning folder: {folder_name}")
# Determine the correct EPUB path for this specific folder
current_epub_path = epub_path
current_qa_settings = qa_settings.copy()
# For bulk scanning, try to find a matching EPUB for each folder
if len(folders_to_scan) > 1 and current_qa_settings.get('check_word_count_ratio', False):
# Try to find EPUB file matching this specific folder
folder_basename = os.path.basename(current_folder.rstrip('/\\'))
self.append_log(f" 🔍 Searching for EPUB matching folder: {folder_basename}")
# Look for EPUB in various locations
folder_parent = os.path.dirname(current_folder)
# Simple exact matching first, with minimal suffix handling
base_name = folder_basename
# Only handle the most common output suffixes
common_suffixes = ['_output', '_translated', '_en']
for suffix in common_suffixes:
if base_name.endswith(suffix):
base_name = base_name[:-len(suffix)]
break
# Simple EPUB search - focus on exact matching
search_names = [folder_basename] # Start with exact folder name
if base_name != folder_basename: # Add base name only if different
search_names.append(base_name)
potential_epub_paths = [
# Most common locations in order of priority
os.path.join(folder_parent, f"{folder_basename}.epub"), # Same directory as output folder
os.path.join(folder_parent, f"{base_name}.epub"), # Same directory with base name
os.path.join(current_folder, f"{folder_basename}.epub"), # Inside the output folder
os.path.join(current_folder, f"{base_name}.epub"), # Inside with base name
]
# Find the first existing EPUB
folder_epub_path = None
for potential_path in potential_epub_paths:
if os.path.isfile(potential_path):
folder_epub_path = potential_path
if len(folders_to_scan) > 1:
self.append_log(f" Found matching EPUB: {os.path.basename(potential_path)}")
break
if folder_epub_path:
current_epub_path = folder_epub_path
if len(folders_to_scan) > 1: # Only log for bulk scans
self.append_log(f" 📖 Using EPUB: {os.path.basename(current_epub_path)}")
else:
# NO FALLBACK TO GLOBAL EPUB FOR BULK SCANS - This prevents wrong EPUB usage!
if len(folders_to_scan) > 1:
self.append_log(f" ⚠️ No matching EPUB found for folder '{folder_name}' - disabling word count analysis")
expected_names = ', '.join([f"{name}.epub" for name in search_names])
self.append_log(f" Expected EPUB names: {expected_names}")
current_epub_path = None
elif current_epub_path: # Single folder scan can use global EPUB
self.append_log(f" 📖 Using global EPUB: {os.path.basename(current_epub_path)} (no folder-specific EPUB found)")
else:
current_epub_path = None
# Disable word count analysis when no matching EPUB is found
if not current_epub_path:
current_qa_settings = current_qa_settings.copy()
current_qa_settings['check_word_count_ratio'] = False
# Check for EPUB/folder name mismatch
if current_epub_path and current_qa_settings.get('check_word_count_ratio', False) and current_qa_settings.get('warn_name_mismatch', True):
epub_name = os.path.splitext(os.path.basename(current_epub_path))[0]
folder_name_for_check = os.path.basename(current_folder.rstrip('/\\'))
if not check_epub_folder_match(epub_name, folder_name_for_check, current_qa_settings.get('custom_output_suffixes', '')):
if len(folders_to_scan) == 1:
# Interactive dialog for single folder scans
msg = QMessageBox(self)
msg.setIcon(QMessageBox.Warning)
msg.setWindowTitle("EPUB/Folder Name Mismatch")
msg.setText(f"The source EPUB and output folder names don't match:\n\n"
f"📖 EPUB: {epub_name}\n"
f"📁 Folder: {folder_name_for_check}\n\n"
"This might mean you're comparing the wrong files.")
msg.setInformativeText("Would you like to:\n"
"• YES - Continue anyway (I'm sure these match)\n"
"• NO - Select a different EPUB file\n"
"• CANCEL - Cancel the scan")
msg.setStandardButtons(QMessageBox.Yes | QMessageBox.No | QMessageBox.Cancel)
result = msg.exec()
if result == QMessageBox.Cancel:
self.append_log("⚠️ QA scan canceled due to EPUB/folder mismatch.")
return
elif result == QMessageBox.No: # No - select different EPUB
# Determine if text file mode is enabled
text_file_mode = self.config.get('qa_text_file_mode', False)
if hasattr(self, 'qa_text_file_mode_checkbox'):
try:
text_file_mode = bool(self.qa_text_file_mode_checkbox.isChecked())
except Exception:
pass
if text_file_mode:
new_epub_path, _ = QFileDialog.getOpenFileName(
self,
"Select Different Source Text/PDF File",
"",
"Source files (*.txt *.pdf);;Text files (*.txt);;PDF files (*.pdf);;All files (*.*)"
)
else:
new_epub_path, _ = QFileDialog.getOpenFileName(
self,
"Select Different Source EPUB File",
"",
"EPUB files (*.epub);;All files (*.*)"
)
if new_epub_path:
current_epub_path = new_epub_path
self.selected_epub_path = new_epub_path
self.config['last_epub_path'] = new_epub_path
self.save_config(show_message=False)
self.append_log(f"✅ Updated EPUB: {os.path.basename(new_epub_path)}")
else:
proceed = QMessageBox.question(
self,
"No File Selected",
"No EPUB file was selected.\n\n" +
"Continue scan without word count analysis?",
QMessageBox.Yes | QMessageBox.No
)
if proceed == QMessageBox.No:
self.append_log("⚠️ QA scan canceled.")
return
else:
current_qa_settings = current_qa_settings.copy()
current_qa_settings['check_word_count_ratio'] = False
current_epub_path = None
self.append_log("ℹ️ Proceeding without word count analysis.")
# If YES, just continue with warning
else:
# For bulk scans, just warn and continue
self.append_log(f" ⚠️ Warning: EPUB/folder name mismatch - {epub_name} vs {folder_name_for_check}")
try:
# Determine selected_files for this folder
current_selected_files = None
if global_selected_files and len(folders_to_scan) == 1:
current_selected_files = global_selected_files
# Auto-detect PDF source file if not already set
# Check if the folder name matches a .pdf file in the parent directory
if not current_epub_path or not os.path.exists(current_epub_path):
folder_basename = os.path.basename(current_folder)
parent_dir = os.path.dirname(current_folder)
# Try to find a matching PDF file
potential_pdf = os.path.join(parent_dir, folder_basename + ".pdf")
if os.path.exists(potential_pdf):
current_epub_path = potential_pdf
self.append_log(f" 📄 Auto-detected PDF source: {os.path.basename(potential_pdf)}")
else:
# Also try without folder suffix if it has one
potential_pdf_alt = os.path.join(parent_dir, folder_basename.replace("_output", "") + ".pdf")
if os.path.exists(potential_pdf_alt):
current_epub_path = potential_pdf_alt
self.append_log(f" 📄 Auto-detected PDF source: {os.path.basename(potential_pdf_alt)}")
# Pass the QA settings to scan_html_folder
# Don't pass text_file_mode explicitly - let scan_html_folder auto-detect from epub_path
# Get scan_html_folder from translator_gui's global scope
import translator_gui
scan_func = translator_gui.scan_html_folder
scan_func(
current_folder,
log=self.append_log,
stop_flag=lambda: self.stop_requested,
mode=mode,
qa_settings=current_qa_settings,
epub_path=current_epub_path,
selected_files=current_selected_files,
text_file_mode=None # Let it auto-detect from epub_path extension
)
successful_scans += 1
# Record last generated report path for quick access
report_path = os.path.join(current_folder, "validation_results.html")
if os.path.exists(report_path):
self.last_qa_report_path = report_path
if len(folders_to_scan) > 1:
self.append_log(f"✅ Folder '{folder_name}' scan completed successfully")
except Exception as folder_error:
failed_scans += 1
self.append_log(f"❌ Folder '{folder_name}' scan failed: {folder_error}")
if len(folders_to_scan) == 1:
# Re-raise for single folder scans
raise
# Final summary for bulk scans
if len(folders_to_scan) > 1:
self.append_log(f"\n📋 Bulk scan summary: {successful_scans} successful, {failed_scans} failed")
# If show_stats is enabled, log cache statistics
if qa_settings.get('cache_show_stats', False):
from scan_html_folder import get_cache_info
cache_stats = get_cache_info()
self.append_log("\n📊 Cache Performance Statistics:")
for name, info in cache_stats.items():
if info: # Check if info exists
hit_rate = info.hits / (info.hits + info.misses) if (info.hits + info.misses) > 0 else 0
self.append_log(f" {name}: {info.hits} hits, {info.misses} misses ({hit_rate:.1%} hit rate)")
if len(folders_to_scan) == 1:
self.append_log("✅ QA scan completed successfully.")
else:
self.append_log("✅ Bulk QA scan completed.")
except Exception as e:
self.append_log(f"❌ QA scan error: {e}")
self.append_log(f"Traceback: {traceback.format_exc()}")
finally:
# Clear thread/future refs so buttons re-enable
self.qa_thread = None
if hasattr(self, 'qa_future'):
try:
self.qa_future = None
except Exception:
pass
# Emit signal to update button (thread-safe)
self.thread_complete_signal.emit()
# Run via shared executor
self._ensure_executor()
if self.executor:
self.qa_future = self.executor.submit(run_scan)
# Ensure UI is refreshed when QA work completes (button update handled by thread_complete_signal in finally block)
def _qa_done_callback(f):
try:
self.qa_future = None
except Exception:
pass
try:
self.qa_future.add_done_callback(_qa_done_callback)
except Exception:
pass
else:
self.qa_thread = threading.Thread(target=run_scan, daemon=True)
self.qa_thread.start()
# Update button IMMEDIATELY after starting thread (synchronous)
self.update_run_button()
def show_qa_scanner_settings(self, parent_dialog, qa_settings):
"""Show QA Scanner settings dialog"""
# Create settings dialog
dialog = QDialog(parent_dialog)
try:
self._qa_settings_dialog = dialog
dialog.finished.connect(lambda *_: setattr(self, "_qa_settings_dialog", None))
except Exception:
pass
# Apply basic dark stylesheet IMMEDIATELY to prevent white flash
# Set up icon path
icon_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'Halgakos.ico')
dialog.setStyleSheet("""
QDialog {
background-color: #2d2d2d;
color: white;
}
QGroupBox {
color: white;
border: 1px solid #555;
margin: 10px;
padding-top: 10px;
}
QGroupBox::title {
color: white;
left: 10px;
padding: 0 5px;
}
QLabel {
color: white;
}
QPushButton {
background-color: #404040;
color: white;
border: 1px solid #555;
padding: 5px;
}
QPushButton:hover {
background-color: #505050;
}
QComboBox {
background-color: #404040;
color: white;
border: 1px solid #555;
padding: 5px;
padding-right: 25px;
}
QComboBox:hover {
background-color: #505050;
border: 1px solid #777;
}
QComboBox::drop-down {
subcontrol-origin: padding;
subcontrol-position: top right;
width: 30px;
border-left: 1px solid #555;
}
QComboBox::down-arrow {
image: url(""" + icon_path.replace('\\', '/') + """);
width: 16px;
height: 16px;
}
QComboBox:on {
border: 1px solid #888;
}
QComboBox QAbstractItemView {
background-color: #404040;
color: white;
border: 1px solid #555;
selection-background-color: #505050;
}
""")
dialog.setWindowTitle("QA Scanner Settings")
dialog.setModal(True)
# Use screen ratios: 40% width, 85% height (decreased from 100%)
screen = QApplication.primaryScreen().geometry()
settings_width = int(screen.width() * 0.52)
settings_height = int(screen.height() * 0.85)
dialog.resize(settings_width, settings_height)
# Set window icon and prepare icon path for comboboxes
try:
base_dir = sys._MEIPASS if getattr(sys, 'frozen', False) else os.path.dirname(os.path.abspath(__file__))
icon_path = os.path.join(base_dir, 'Halgakos.ico')
if not os.path.exists(icon_path):
icon_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'Halgakos.ico')
if not os.path.exists(icon_path):
icon_path = os.path.join(os.getcwd(), 'Halgakos.ico')
if os.path.exists(icon_path):
dialog.setWindowIcon(QIcon(icon_path))
except Exception:
icon_path = os.path.join(os.getcwd(), 'Halgakos.ico')
# Main layout
main_layout = QVBoxLayout(dialog)
# Scroll area
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
# Scrollable content widget
scroll_widget = QWidget()
scroll_widget.setObjectName('scroll_widget')
scroll_layout = QVBoxLayout(scroll_widget)
scroll_layout.setContentsMargins(30, 20, 30, 20)
scroll.setWidget(scroll_widget)
main_layout.addWidget(scroll)
# Helper function to disable mousewheel on spinboxes and comboboxes
def disable_wheel_event(widget):
widget.wheelEvent = lambda event: event.ignore()
# Word count multiplier defaults (factory) - character-based ratios
base_multiplier_defaults = {
'english': 1.0, 'spanish': 1.10, 'french': 1.10, 'german': 1.05, 'italian': 1.05,
'portuguese': 1.10, 'russian': 1.15, 'arabic': 1.15, 'hindi': 1.10, 'turkish': 1.05,
'chinese': 2.50, 'chinese (simplified)': 2.50, 'chinese (traditional)': 2.50,
'japanese': 2.20, 'korean': 2.30, 'hebrew': 1.05, 'thai': 1.10,
'other': 1.0
}
# Merge current settings over factory defaults for initial display
wordcount_defaults = dict(base_multiplier_defaults)
user_mults = qa_settings.get('word_count_multipliers', {})
if isinstance(user_mults, dict):
wordcount_defaults.update(user_mults)
# Immutable factory defaults for reset
default_wordcount_defaults = dict(base_multiplier_defaults)
# Title
title_label = QLabel("QA Scanner Settings")
title_label.setFont(QFont('Arial', 24, QFont.Bold))
title_label.setAlignment(Qt.AlignCenter)
scroll_layout.addWidget(title_label)
scroll_layout.addSpacing(20)
# Foreign Character Settings Section
foreign_group = QGroupBox("Foreign Character Detection")
foreign_group.setFont(QFont('Arial', 12, QFont.Bold))
foreign_layout = QVBoxLayout(foreign_group)
foreign_layout.setContentsMargins(20, 15, 20, 15)
scroll_layout.addWidget(foreign_group)
# Source Language setting (for multiplier selection)
source_lang_widget = QWidget()
source_lang_layout = QHBoxLayout(source_lang_widget)
source_lang_layout.setContentsMargins(0, 0, 0, 6)
source_lang_label = QLabel("Source language (for word-count multiplier):")
source_lang_label.setFont(QFont('Arial', 10))
source_lang_layout.addWidget(source_lang_label)
source_language_options = [
'Auto', 'Chinese (Simplified)', 'Chinese (Traditional)',
'Japanese', 'Korean', 'English', 'Spanish', 'French', 'German',
'Italian', 'Portuguese', 'Russian', 'Arabic', 'Hindi', 'Turkish',
'Hebrew', 'Thai', 'Other'
]
source_lang_combo = QComboBox()
source_lang_combo.setEditable(True)
source_lang_combo.addItems(source_language_options)
source_lang_combo.setCurrentText(qa_settings.get('source_language', 'Auto').title())
source_lang_combo.setMinimumWidth(240)
disable_wheel_event(source_lang_combo)
source_lang_layout.addWidget(source_lang_combo)
source_lang_hint = QLabel("(Auto = detect via script/CJK heuristics)")
source_lang_hint.setFont(QFont('Arial', 9))
source_lang_hint.setStyleSheet("color: gray;")
source_lang_layout.addWidget(source_lang_hint)
source_lang_layout.addStretch()
foreign_layout.addWidget(source_lang_widget)
# Target Language setting
target_lang_widget = QWidget()
target_lang_layout = QHBoxLayout(target_lang_widget)
target_lang_layout.setContentsMargins(0, 0, 0, 10)
target_lang_label = QLabel("Target language:")
target_lang_label.setFont(QFont('Arial', 10))
target_lang_layout.addWidget(target_lang_label)
# Capitalize the stored value for display in combobox
stored_language = qa_settings.get('target_language', 'english')
display_language = stored_language.capitalize()
target_language_options = [
'English', 'Spanish', 'French', 'German', 'Italian', 'Portuguese',
'Russian', 'Arabic', 'Hindi', 'Chinese (Simplified)',
'Chinese (Traditional)', 'Japanese', 'Korean', 'Turkish',
'Hebrew', 'Thai'
]
target_language_combo = QComboBox()
target_language_combo.setEditable(True)
target_language_combo.addItems(target_language_options)
target_language_combo.setMinimumWidth(360)
target_language_combo.setMinimumContentsLength(24) # ensure popup and line edit stay wide
target_language_combo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
# Prefer the main GUI's target language for display if available
initial_display = self.config.get('output_language') or display_language
target_language_combo.setCurrentText(initial_display)
target_language_combo.setMinimumWidth(150)
disable_wheel_event(target_language_combo)
target_lang_layout.addWidget(target_language_combo)
target_lang_hint = QLabel("(characters from other scripts will be flagged)")
target_lang_hint.setFont(QFont('Arial', 9))
target_lang_hint.setStyleSheet("color: gray;")
target_lang_layout.addWidget(target_lang_hint)
target_lang_layout.addStretch()
foreign_layout.addWidget(target_lang_widget)
# Threshold setting
threshold_widget = QWidget()
threshold_layout = QHBoxLayout(threshold_widget)
threshold_layout.setContentsMargins(0, 10, 0, 10)
threshold_label = QLabel("Minimum foreign characters to flag:")
threshold_label.setFont(QFont('Arial', 10))
threshold_layout.addWidget(threshold_label)
threshold_spinbox = QSpinBox()
threshold_spinbox.setMinimum(0)
threshold_spinbox.setMaximum(1000)
threshold_spinbox.setValue(qa_settings.get('foreign_char_threshold', 10))
threshold_spinbox.setMinimumWidth(100)
disable_wheel_event(threshold_spinbox)
threshold_layout.addWidget(threshold_spinbox)
threshold_hint = QLabel("(0 = always flag, higher = more tolerant)")
threshold_hint.setFont(QFont('Arial', 9))
threshold_hint.setStyleSheet("color: gray;")
threshold_layout.addWidget(threshold_hint)
threshold_layout.addStretch()
foreign_layout.addWidget(threshold_widget)
# Excluded characters
excluded_label = QLabel("Additional characters to exclude from detection:")
excluded_label.setFont(QFont('Arial', 10))
foreign_layout.addWidget(excluded_label)
# Text edit for excluded characters
excluded_text = QTextEdit()
excluded_text.setMaximumHeight(150)
excluded_text.setFont(QFont('Consolas', 10))
excluded_text.setPlainText(qa_settings.get('excluded_characters', ''))
foreign_layout.addWidget(excluded_text)
excluded_hint = QLabel("Enter characters separated by spaces (e.g., ™ © ® • …)")
excluded_hint.setFont(QFont('Arial', 9))
excluded_hint.setStyleSheet("color: gray;")
foreign_layout.addWidget(excluded_hint)
scroll_layout.addSpacing(20)
# Detection Options Section
detection_group = QGroupBox("Detection Options")
detection_group.setFont(QFont('Arial', 12, QFont.Bold))
detection_layout = QVBoxLayout(detection_group)
detection_layout.setContentsMargins(20, 15, 20, 15)
scroll_layout.addWidget(detection_group)
# Checkboxes for detection options
check_encoding_checkbox = self._create_styled_checkbox("Check for encoding issues (�, □, ◇)")
check_encoding_checkbox.setChecked(qa_settings.get('check_encoding_issues', False))
detection_layout.addWidget(check_encoding_checkbox)
check_repetition_checkbox = self._create_styled_checkbox("Check for excessive repetition")
check_repetition_checkbox.setChecked(qa_settings.get('check_repetition', True))
detection_layout.addWidget(check_repetition_checkbox)
check_artifacts_checkbox = self._create_styled_checkbox("Check for translation artifacts (MTL notes, watermarks)")
check_artifacts_checkbox.setChecked(qa_settings.get('check_translation_artifacts', False))
detection_layout.addWidget(check_artifacts_checkbox)
# Separate toggle for AI artifacts
check_ai_artifacts_checkbox = self._create_styled_checkbox("Check for AI artifacts (\"Sure, here’s…\", thinking tags, JSON)")
check_ai_artifacts_checkbox.setChecked(qa_settings.get('check_ai_artifacts', False))
check_ai_artifacts_checkbox.setContentsMargins(20, 0, 0, 0)
detection_layout.addWidget(check_ai_artifacts_checkbox)
check_punctuation_checkbox = self._create_styled_checkbox("Check ?! punctuation mismatches (compares with source file)")
check_punctuation_checkbox.setChecked(qa_settings.get('check_punctuation_mismatch', False))
detection_layout.addWidget(check_punctuation_checkbox)
# Punctuation loss threshold setting (indented under the checkbox)
punct_threshold_widget = QWidget()
punct_threshold_layout = QHBoxLayout(punct_threshold_widget)
punct_threshold_layout.setContentsMargins(20, 0, 0, 10)
punct_threshold_label = QLabel("Flag if lost >")
punct_threshold_label.setFont(QFont('Arial', 10))
punct_threshold_layout.addWidget(punct_threshold_label)
punct_threshold_spinbox = QSpinBox()
punct_threshold_spinbox.setMinimum(0)
punct_threshold_spinbox.setMaximum(100)
punct_threshold_spinbox.setValue(qa_settings.get('punctuation_loss_threshold', 49))
punct_threshold_spinbox.setSuffix("%")
punct_threshold_spinbox.setMinimumWidth(80)
disable_wheel_event(punct_threshold_spinbox)
punct_threshold_layout.addWidget(punct_threshold_spinbox)
punct_threshold_hint = QLabel("(0 = flag all, 49 = flag if half lost, 100 = only flag if all lost)")
punct_threshold_hint.setFont(QFont('Arial', 9))
punct_threshold_hint.setStyleSheet("color: gray;")
punct_threshold_layout.addWidget(punct_threshold_hint)
punct_threshold_layout.addStretch()
detection_layout.addWidget(punct_threshold_widget)
# Enable/disable punctuation threshold controls based on checkbox
def toggle_punctuation_threshold(checked):
punct_threshold_label.setEnabled(checked)
punct_threshold_spinbox.setEnabled(checked)
punct_threshold_hint.setEnabled(checked)
if checked:
punct_threshold_label.setStyleSheet("color: white;")
punct_threshold_spinbox.setStyleSheet("color: white;")
punct_threshold_hint.setStyleSheet("color: gray;")
else:
punct_threshold_label.setStyleSheet("color: #606060;")
punct_threshold_spinbox.setStyleSheet("color: #909090;")
punct_threshold_hint.setStyleSheet("color: #404040;")
check_punctuation_checkbox.toggled.connect(toggle_punctuation_threshold)
toggle_punctuation_threshold(check_punctuation_checkbox.isChecked()) # Set initial state
# Excess punctuation checkbox (indented under the punctuation checker)
excess_punct_widget = QWidget()
excess_punct_layout = QHBoxLayout(excess_punct_widget)
excess_punct_layout.setContentsMargins(20, 0, 0, 0)
excess_punct_checkbox = self._create_styled_checkbox("Flag excess punctuation (more ? or ! than source)")
excess_punct_checkbox.setChecked(qa_settings.get('flag_excess_punctuation', False))
excess_punct_layout.addWidget(excess_punct_checkbox)
excess_punct_layout.addStretch()
detection_layout.addWidget(excess_punct_widget)
# Excess punctuation threshold setting (indented under the excess checkbox)
excess_threshold_widget = QWidget()
excess_threshold_layout = QHBoxLayout(excess_threshold_widget)
excess_threshold_layout.setContentsMargins(40, 0, 0, 10)
excess_threshold_label = QLabel("Flag if excess >")
excess_threshold_label.setFont(QFont('Arial', 10))
excess_threshold_layout.addWidget(excess_threshold_label)
excess_threshold_spinbox = QSpinBox()
excess_threshold_spinbox.setMinimum(0)
excess_threshold_spinbox.setMaximum(500)
excess_threshold_spinbox.setValue(qa_settings.get('excess_punctuation_threshold', 49))
excess_threshold_spinbox.setSuffix("%")
excess_threshold_spinbox.setMinimumWidth(80)
disable_wheel_event(excess_threshold_spinbox)
excess_threshold_layout.addWidget(excess_threshold_spinbox)
excess_threshold_hint = QLabel("(0 = flag all excess, 49 = flag if half excess, 100 = only flag if doubled)")
excess_threshold_hint.setFont(QFont('Arial', 9))
excess_threshold_hint.setStyleSheet("color: gray;")
excess_threshold_layout.addWidget(excess_threshold_hint)
excess_threshold_layout.addStretch()
detection_layout.addWidget(excess_threshold_widget)
# Enable/disable excess punctuation controls based on main and excess checkboxes
def toggle_excess_punct(main_checked):
excess_enabled = main_checked
excess_punct_checkbox.setEnabled(excess_enabled)
# Threshold only enabled if both main and excess checkboxes are checked
threshold_enabled = main_checked and excess_punct_checkbox.isChecked()
excess_threshold_label.setEnabled(threshold_enabled)
excess_threshold_spinbox.setEnabled(threshold_enabled)
excess_threshold_hint.setEnabled(threshold_enabled)
if excess_enabled:
excess_punct_checkbox.setStyleSheet("color: white;")
else:
excess_punct_checkbox.setStyleSheet("color: #606060;")
if threshold_enabled:
excess_threshold_label.setStyleSheet("color: white;")
excess_threshold_spinbox.setStyleSheet("color: white;")
excess_threshold_hint.setStyleSheet("color: gray;")
else:
excess_threshold_label.setStyleSheet("color: #606060;")
excess_threshold_spinbox.setStyleSheet("color: #909090;")
excess_threshold_hint.setStyleSheet("color: #404040;")
def toggle_excess_threshold(excess_checked):
# Re-evaluate based on current state
toggle_excess_punct(check_punctuation_checkbox.isChecked())
check_punctuation_checkbox.toggled.connect(toggle_excess_punct)
excess_punct_checkbox.toggled.connect(toggle_excess_threshold)
toggle_excess_punct(check_punctuation_checkbox.isChecked()) # Set initial state
check_glossary_checkbox = self._create_styled_checkbox("Check for glossary leakage (raw glossary entries in translation)")
check_glossary_checkbox.setChecked(qa_settings.get('check_glossary_leakage', True))
detection_layout.addWidget(check_glossary_checkbox)
scroll_layout.addSpacing(20)
# File Processing Section
file_group = QGroupBox("File Processing")
file_group.setFont(QFont('Arial', 12, QFont.Bold))
file_layout = QVBoxLayout(file_group)
file_layout.setContentsMargins(20, 15, 20, 15)
scroll_layout.addWidget(file_group)
# Minimum file length
min_length_widget = QWidget()
min_length_layout = QHBoxLayout(min_length_widget)
min_length_layout.setContentsMargins(0, 0, 0, 10)
min_length_label = QLabel("Minimum file length (characters):")
min_length_label.setFont(QFont('Arial', 10))
min_length_layout.addWidget(min_length_label)
min_length_spinbox = QSpinBox()
min_length_spinbox.setMinimum(0)
min_length_spinbox.setMaximum(10000)
min_length_spinbox.setValue(qa_settings.get('min_file_length', 0))
min_length_spinbox.setMinimumWidth(100)
disable_wheel_event(min_length_spinbox)
min_length_layout.addWidget(min_length_spinbox)
min_length_layout.addStretch()
file_layout.addWidget(min_length_widget)
# Minimum duplicate word count
min_dup_words_widget = QWidget()
min_dup_words_layout = QHBoxLayout(min_dup_words_widget)
min_dup_words_layout.setContentsMargins(0, 10, 0, 10)
min_dup_words_label = QLabel("Skip small files as duplicates if <N words:")
min_dup_words_label.setFont(QFont('Arial', 10))
min_dup_words_layout.addWidget(min_dup_words_label)
min_dup_words_spinbox = QSpinBox()
min_dup_words_spinbox.setMinimum(0)
min_dup_words_spinbox.setMaximum(999999)
min_dup_words_spinbox.setSingleStep(50)
min_dup_words_spinbox.setValue(qa_settings.get('min_duplicate_word_count', 500))
min_dup_words_spinbox.setMinimumWidth(100)
disable_wheel_event(min_dup_words_spinbox)
min_dup_words_layout.addWidget(min_dup_words_spinbox)
min_dup_hint = QLabel("(prevents section/notice files from being flagged)")
min_dup_hint.setFont(QFont('Arial', 9))
min_dup_hint.setStyleSheet("color: gray;")
min_dup_words_layout.addWidget(min_dup_hint)
min_dup_words_layout.addStretch()
file_layout.addWidget(min_dup_words_widget)
# Minimum text length for spacing/linebreaks check
min_spacing_text_widget = QWidget()
min_spacing_text_layout = QHBoxLayout(min_spacing_text_widget)
min_spacing_text_layout.setContentsMargins(0, 10, 0, 10)
min_spacing_text_label = QLabel("Minimum text length for spacing check (characters):")
min_spacing_text_label.setFont(QFont('Arial', 10))
min_spacing_text_layout.addWidget(min_spacing_text_label)
min_spacing_text_spinbox = QSpinBox()
min_spacing_text_spinbox.setMinimum(0)
min_spacing_text_spinbox.setMaximum(999999)
min_spacing_text_spinbox.setSingleStep(10)
min_spacing_text_spinbox.setValue(qa_settings.get('min_text_length_for_spacing', 100))
min_spacing_text_spinbox.setMinimumWidth(100)
disable_wheel_event(min_spacing_text_spinbox)
min_spacing_text_layout.addWidget(min_spacing_text_spinbox)
min_spacing_hint = QLabel("(skips files with very little content like cover pages)")
min_spacing_hint.setFont(QFont('Arial', 9))
min_spacing_hint.setStyleSheet("color: gray;")
min_spacing_text_layout.addWidget(min_spacing_hint)
min_spacing_text_layout.addStretch()
file_layout.addWidget(min_spacing_text_widget)
scroll_layout.addSpacing(15)
# Word Count Cross-Reference Section
wordcount_group = QGroupBox("Word Count Analysis")
wordcount_group.setFont(QFont('Arial', 12, QFont.Bold))
wordcount_layout = QVBoxLayout(wordcount_group)
wordcount_layout.setContentsMargins(20, 15, 20, 15)
scroll_layout.addWidget(wordcount_group)
check_word_count_checkbox = self._create_styled_checkbox("Cross-reference word counts with original source file")
check_word_count_checkbox.setChecked(qa_settings.get('check_word_count_ratio', True))
wordcount_layout.addWidget(check_word_count_checkbox)
wordcount_desc = QLabel("Compares word counts between original and translated files to detect missing content.\n" +
"For EPUB: accounts for typical expansion ratios when translating from CJK to English.\n" +
"For Text: compares each section file against the total source .txt word count.")
wordcount_desc.setFont(QFont('Arial', 9))
wordcount_desc.setStyleSheet("color: gray;")
wordcount_desc.setWordWrap(True)
wordcount_desc.setMaximumWidth(700)
wordcount_layout.addWidget(wordcount_desc)
# Counting mode options
counting_mode_widget = QWidget()
counting_mode_layout = QHBoxLayout(counting_mode_widget)
counting_mode_layout.setContentsMargins(0, 10, 0, 5)
counting_mode_label = QLabel("Counting mode:")
counting_mode_label.setFont(QFont('Arial', 10))
counting_mode_layout.addWidget(counting_mode_label)
# Get current mode from saved settings (default: exact)
saved_counting_mode = qa_settings.get('counting_mode', 'exact')
counting_mode_combo = QComboBox()
counting_mode_combo.addItem("Character count (sampled) - Fastest", "sampled")
counting_mode_combo.addItem("Character count (exact) - Default", "exact")
counting_mode_combo.addItem("Word count (legacy)", "word")
counting_mode_combo.setMinimumWidth(250)
counting_mode_combo.wheelEvent = lambda event: event.ignore()
# Set current selection based on saved settings
if saved_counting_mode == 'word':
counting_mode_combo.setCurrentIndex(2) # Word count
elif saved_counting_mode == 'sampled':
counting_mode_combo.setCurrentIndex(0) # Sampled
else:
counting_mode_combo.setCurrentIndex(1) # Exact (default)
counting_mode_layout.addWidget(counting_mode_combo)
counting_mode_layout.addStretch()
wordcount_layout.addWidget(counting_mode_widget)
# Word count multiplier sliders (2-column grid)
multipliers_label = QLabel("Expected translation length multiplier (translated words ÷ source words)")
multipliers_label.setFont(QFont('Arial', 10, QFont.Bold))
wordcount_layout.addWidget(multipliers_label)
multiplier_hint = QLabel("Adjust per-language expansion. 100% = same length as source; 150% = 1.5x longer.")
multiplier_hint.setFont(QFont('Arial', 9))
multiplier_hint.setStyleSheet("color: gray;")
multiplier_hint.setWordWrap(True)
multiplier_hint.setMaximumWidth(700)
wordcount_layout.addWidget(multiplier_hint)
# Auto toggle for using default multipliers
auto_multipliers_widget = QWidget()
auto_multipliers_layout = QHBoxLayout(auto_multipliers_widget)
auto_multipliers_layout.setContentsMargins(0, 10, 0, 10)
auto_multipliers_checkbox = self._create_styled_checkbox("Auto: Use recommended default multipliers")
auto_multipliers_checkbox.setChecked(qa_settings.get('use_auto_multipliers', True)) # Enabled by default
auto_multipliers_layout.addWidget(auto_multipliers_checkbox)
auto_multipliers_hint = QLabel("(disable to customize per-language ratios)")
auto_multipliers_hint.setFont(QFont('Arial', 9))
auto_multipliers_hint.setStyleSheet("color: gray;")
auto_multipliers_layout.addWidget(auto_multipliers_hint)
auto_multipliers_layout.addStretch()
wordcount_layout.addWidget(auto_multipliers_widget)
multiplier_grid_widget = QWidget()
multiplier_grid = QGridLayout(multiplier_grid_widget)
multiplier_grid.setContentsMargins(0, 6, 0, 6)
multiplier_grid.setHorizontalSpacing(16)
multiplier_grid.setVerticalSpacing(8)
wordcount_layout.addWidget(multiplier_grid_widget)
# Keep slider refs for saving and enabling/disabling
word_multiplier_sliders = {}
word_multiplier_labels = []
# Ordered language list for stable UI
multiplier_order = [
'english', 'spanish', 'french', 'german', 'italian', 'portuguese',
'russian', 'arabic', 'hindi', 'turkish',
'chinese', 'chinese (simplified)', 'chinese (traditional)',
'japanese', 'korean', 'hebrew', 'thai', 'other'
]
# Build sliders in 2 columns
for idx, lang_key in enumerate(multiplier_order):
row = idx // 2
col = idx % 2
row_widget = QWidget()
row_layout = QHBoxLayout(row_widget)
row_layout.setContentsMargins(0, 0, 0, 0)
display_name = lang_key.capitalize() if '(' not in lang_key else lang_key.title()
lang_label = QLabel(display_name + ":")
lang_label.setFont(QFont('Arial', 9))
row_layout.addWidget(lang_label)
# Slider
slider = QSlider(Qt.Horizontal)
slider.setMinimum(10) # 0.10x
slider.setMaximum(1000) # 10.0x
slider.setSingleStep(5)
slider.setTickInterval(50)
slider.setMinimumWidth(140)
slider.wheelEvent = lambda event: event.ignore()
current_mult = wordcount_defaults.get(lang_key, 1.0)
slider.setValue(int(current_mult * 100))
row_layout.addWidget(slider)
# Editable spinbox (same range, %)
spin = QSpinBox()
spin.setMinimum(10)
spin.setMaximum(1000)
spin.setSingleStep(1)
spin.setValue(int(current_mult * 100))
spin.setSuffix("%")
spin.setMinimumWidth(70)
spin.wheelEvent = lambda event: event.ignore()
row_layout.addWidget(spin)
# Keep in sync both ways
slider.valueChanged.connect(spin.setValue)
spin.valueChanged.connect(slider.setValue)
word_multiplier_sliders[lang_key] = slider
word_multiplier_sliders[f"{lang_key}__spin"] = spin
word_multiplier_labels.append(lang_label)
multiplier_grid.addWidget(row_widget, row, col)
# Function to toggle multiplier controls based on auto checkbox
def toggle_multiplier_controls(auto_enabled):
for lang_key in multiplier_order:
slider = word_multiplier_sliders.get(lang_key)
spin = word_multiplier_sliders.get(f"{lang_key}__spin")
if slider:
slider.setEnabled(not auto_enabled)
if spin:
spin.setEnabled(not auto_enabled)
# Apply enable/disable styling to spinbox
if auto_enabled:
spin.setStyleSheet("background-color: #303030; color: #808080;")
else:
spin.setStyleSheet("background-color: #404040; color: white;") # Enabled styling
# Apply enable/disable styling to labels
for label in word_multiplier_labels:
if auto_enabled:
label.setStyleSheet("color: #808080;") # Gray out when disabled
else:
label.setStyleSheet("color: white;") # White when enabled
# Connect auto checkbox to toggle function
auto_multipliers_checkbox.toggled.connect(toggle_multiplier_controls)
# Set initial state
toggle_multiplier_controls(auto_multipliers_checkbox.isChecked())
wordcount_layout.addSpacing(6)
# Show current EPUB status and allow selection
epub_widget = QWidget()
epub_layout = QHBoxLayout(epub_widget)
epub_layout.setContentsMargins(0, 10, 0, 5)
# Get source files (EPUB, TXT, PDF, or MD) from actual current selection
current_epub_files = []
if hasattr(self, 'selected_files') and self.selected_files:
current_epub_files = [
f for f in self.selected_files
if f.lower().endswith(('.epub', '.txt', '.pdf', '.md'))
]
if len(current_epub_files) > 1:
# Multiple source files in current selection
primary_file = os.path.basename(current_epub_files[0])
status_text = f"📖 {len(current_epub_files)} source files selected (Primary: {primary_file})"
status_color = 'green'
elif len(current_epub_files) == 1:
# Single source file in current selection
file_name = os.path.basename(current_epub_files[0])
lower_name = current_epub_files[0].lower()
if lower_name.endswith('.txt'):
file_type = "TXT"
elif lower_name.endswith('.pdf'):
file_type = "PDF"
elif lower_name.endswith('.md'):
file_type = "MD"
else:
file_type = "EPUB"
status_text = f"📖 Current {file_type}: {file_name}"
status_color = 'green'
else:
# No source files in current selection
status_text = "📖 No EPUB/TXT/PDF/MD in current selection"
status_color = 'orange'
status_label = QLabel(status_text)
status_label.setFont(QFont('Arial', 10))
status_label.setStyleSheet(f"color: {status_color};")
epub_layout.addWidget(status_label)
def select_epub_for_qa():
# Allow selecting EPUB, TXT, PDF, or MD files as source
epub_path, _ = QFileDialog.getOpenFileName(
dialog,
"Select Source File",
"",
"Source files (*.epub *.txt *.pdf *.md);;EPUB files (*.epub);;Text files (*.txt);;PDF files (*.pdf);;Markdown files (*.md);;All files (*.*)"
)
if epub_path:
self.selected_epub_path = epub_path
self.config['last_epub_path'] = epub_path
self.save_config(show_message=False)
# Clear multiple EPUB tracking when manually selecting a single file
if hasattr(self, 'selected_epub_files'):
self.selected_epub_files = [epub_path]
lower_name = epub_path.lower()
if lower_name.endswith('.txt'):
file_type = "TXT"
elif lower_name.endswith('.pdf'):
file_type = "PDF"
elif lower_name.endswith('.md'):
file_type = "MD"
else:
file_type = "EPUB"
status_label.setText(f"📖 Current {file_type}: {os.path.basename(epub_path)}")
status_label.setStyleSheet("color: green;")
self.append_log(f"✅ Selected {file_type} for QA: {os.path.basename(epub_path)}")
select_epub_btn = QPushButton("Select Source File")
select_epub_btn.setFont(QFont('Arial', 9))
select_epub_btn.clicked.connect(select_epub_for_qa)
epub_layout.addWidget(select_epub_btn)
epub_layout.addStretch()
wordcount_layout.addWidget(epub_widget)
# Add option to disable mismatch warning
warn_mismatch_checkbox = self._create_styled_checkbox("Warn when EPUB and folder names don't match")
warn_mismatch_checkbox.setChecked(qa_settings.get('warn_name_mismatch', True))
wordcount_layout.addWidget(warn_mismatch_checkbox)
wordcount_layout.addSpacing(10)
# Missing images check (requires source file like word count does)
check_missing_images_checkbox = self._create_styled_checkbox("Check for missing image tags (images lost during translation)")
check_missing_images_checkbox.setChecked(qa_settings.get('check_missing_images', True))
wordcount_layout.addWidget(check_missing_images_checkbox)
images_desc = QLabel("Compares image tags between original and translated HTML files.\n" +
"Detects when <img> tags are lost during translation process.")
images_desc.setFont(QFont('Arial', 9))
images_desc.setStyleSheet("color: gray;")
images_desc.setWordWrap(True)
images_desc.setMaximumWidth(700)
wordcount_layout.addWidget(images_desc)
scroll_layout.addSpacing(20)
# Additional Checks Section
additional_group = QGroupBox("Additional Checks")
additional_group.setFont(QFont('Arial', 12, QFont.Bold))
additional_layout = QVBoxLayout(additional_group)
additional_layout.setContentsMargins(20, 15, 20, 15)
scroll_layout.addWidget(additional_group)
# Multiple headers check
check_multiple_headers_checkbox = self._create_styled_checkbox("Detect files with 2 or more headers (h1-h6 tags)")
check_multiple_headers_checkbox.setChecked(qa_settings.get('check_multiple_headers', True))
additional_layout.addWidget(check_multiple_headers_checkbox)
headers_desc = QLabel("Identifies files that may have been incorrectly split or merged.\n" +
"Useful for detecting chapters that contain multiple sections.")
headers_desc.setFont(QFont('Arial', 9))
headers_desc.setStyleSheet("color: gray;")
headers_desc.setWordWrap(True)
headers_desc.setMaximumWidth(700)
additional_layout.addWidget(headers_desc)
additional_layout.addSpacing(10)
# Missing HTML tag check
check_missing_html_tag_checkbox = self._create_styled_checkbox("Check HTML structure and tag consistency")
check_missing_html_tag_checkbox.setChecked(qa_settings.get('check_missing_html_tag', True))
additional_layout.addWidget(check_missing_html_tag_checkbox)
# Body tag check (separate, disabled by default)
body_tag_widget = QWidget()
body_tag_layout = QHBoxLayout(body_tag_widget)
body_tag_layout.setContentsMargins(0, 0, 0, 5)
check_body_tag_checkbox = self._create_styled_checkbox("Check for <body> tag consistency")
check_body_tag_checkbox.setChecked(qa_settings.get('check_body_tag', False))
body_tag_layout.addWidget(check_body_tag_checkbox)
body_tag_hint = QLabel("(Disabled by default - body tags not required in EPUBs)")
body_tag_hint.setFont(QFont('Arial', 9))
body_tag_hint.setStyleSheet("color: gray;")
body_tag_layout.addWidget(body_tag_hint)
body_tag_layout.addStretch()
additional_layout.addWidget(body_tag_widget)
# Missing header tags check
check_missing_header_tags_checkbox = self._create_styled_checkbox("Flag HTML files with no heading tags (h1-h6)")
check_missing_header_tags_checkbox.setChecked(qa_settings.get('check_missing_header_tags', True))
additional_layout.addWidget(check_missing_header_tags_checkbox)
# Invalid nesting check (separate toggle)
check_invalid_nesting_checkbox = self._create_styled_checkbox("Check for invalid tag nesting")
check_invalid_nesting_checkbox.setChecked(qa_settings.get('check_invalid_nesting', False))
additional_layout.addWidget(check_invalid_nesting_checkbox)
additional_layout.addSpacing(15)
# NEW: Paragraph Structure Check
# Separator line
separator_line = QFrame()
separator_line.setFrameShape(QFrame.HLine)
separator_line.setFrameShadow(QFrame.Sunken)
additional_layout.addWidget(separator_line)
additional_layout.addSpacing(10)
# Checkbox for paragraph structure check
check_paragraph_structure_checkbox = self._create_styled_checkbox("Check for insufficient paragraph tags")
check_paragraph_structure_checkbox.setChecked(qa_settings.get('check_paragraph_structure', True))
additional_layout.addWidget(check_paragraph_structure_checkbox)
# Threshold setting frame
threshold_widget = QWidget()
threshold_layout = QHBoxLayout(threshold_widget)
threshold_layout.setContentsMargins(20, 10, 0, 5)
threshold_label = QLabel("Minimum text in <p> tags:")
threshold_label.setFont(QFont('Arial', 10))
threshold_layout.addWidget(threshold_label)
# Get current threshold value (default 30%)
current_threshold = int(qa_settings.get('paragraph_threshold', 0.3) * 100)
# Spinbox for threshold
paragraph_threshold_spinbox = QSpinBox()
paragraph_threshold_spinbox.setMinimum(0)
paragraph_threshold_spinbox.setMaximum(100)
paragraph_threshold_spinbox.setValue(current_threshold)
paragraph_threshold_spinbox.setMinimumWidth(80)
disable_wheel_event(paragraph_threshold_spinbox)
threshold_layout.addWidget(paragraph_threshold_spinbox)
percent_label = QLabel("%")
percent_label.setFont(QFont('Arial', 10))
threshold_layout.addWidget(percent_label)
# Threshold value label
threshold_value_label = QLabel(f"(currently {current_threshold}%)")
threshold_value_label.setFont(QFont('Arial', 9))
threshold_value_label.setStyleSheet("color: gray;")
threshold_layout.addWidget(threshold_value_label)
threshold_layout.addStretch()
additional_layout.addWidget(threshold_widget)
# Update label when spinbox changes
def update_threshold_label(value):
threshold_value_label.setText(f"(currently {value}%)")
paragraph_threshold_spinbox.valueChanged.connect(update_threshold_label)
# Description
para_desc = QLabel("Detects HTML files where text content is not properly wrapped in paragraph tags.\n" +
"Files with less than the specified percentage of text in <p> tags will be flagged.\n" +
"Also checks for large blocks of unwrapped text directly in the body element.")
para_desc.setFont(QFont('Arial', 9))
para_desc.setStyleSheet("color: gray;")
para_desc.setWordWrap(True)
para_desc.setMaximumWidth(700)
para_desc.setContentsMargins(20, 5, 0, 0)
additional_layout.addWidget(para_desc)
# Enable/disable threshold setting based on checkbox
def toggle_paragraph_threshold(checked):
paragraph_threshold_spinbox.setEnabled(checked)
threshold_label.setEnabled(checked)
percent_label.setEnabled(checked)
threshold_value_label.setEnabled(checked)
check_paragraph_structure_checkbox.toggled.connect(toggle_paragraph_threshold)
toggle_paragraph_threshold(check_paragraph_structure_checkbox.isChecked()) # Set initial state
scroll_layout.addSpacing(20)
# Report Settings Section
report_group = QGroupBox("Report Settings")
report_group.setFont(QFont('Arial', 12, QFont.Bold))
report_layout = QVBoxLayout(report_group)
report_layout.setContentsMargins(20, 15, 20, 15)
scroll_layout.addWidget(report_group)
# Report format
format_widget = QWidget()
format_layout = QHBoxLayout(format_widget)
format_layout.setContentsMargins(0, 0, 0, 10)
format_label = QLabel("Report format:")
format_label.setFont(QFont('Arial', 10))
format_layout.addWidget(format_label)
current_format_value = qa_settings.get('report_format', 'detailed')
format_options = [
("Summary only", "summary"),
("Detailed (recommended)", "detailed"),
("Verbose (all data)", "verbose")
]
# Create radio buttons for format options
format_radio_buttons = []
for idx, (text, value) in enumerate(format_options):
rb = self._create_styled_radio_button(text)
if value == current_format_value:
rb.setChecked(True)
format_layout.addWidget(rb)
format_radio_buttons.append((rb, value))
format_layout.addStretch()
report_layout.addWidget(format_widget)
# Auto-save report
auto_save_checkbox = self._create_styled_checkbox("Automatically save report after scan")
auto_save_checkbox.setChecked(qa_settings.get('auto_save_report', True))
report_layout.addWidget(auto_save_checkbox)
# Add word count ratio threshold settings
# Min/Max normalized ratio thresholds
ratio_thresholds_widget = QWidget()
ratio_thresholds_layout = QHBoxLayout(ratio_thresholds_widget)
ratio_thresholds_layout.setContentsMargins(0, 10, 0, 5)
ratio_min_label = QLabel("Min Ratio (normalized):")
ratio_min_label.setFont(QFont('Arial', 10))
ratio_thresholds_layout.addWidget(ratio_min_label)
# Min ratio spinbox
ratio_min_spin = QComboBox()
ratio_min_spin.setEditable(True)
ratio_min_spin.addItem("Auto")
# Add reasonable range options
for val in [0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0]:
ratio_min_spin.addItem(str(val))
saved_min = qa_settings.get('word_count_min_ratio', 'Auto')
ratio_min_spin.setCurrentText(str(saved_min))
ratio_min_spin.setMinimumWidth(100) # Increased from 80
disable_wheel_event(ratio_min_spin)
ratio_thresholds_layout.addWidget(ratio_min_spin)
ratio_thresholds_layout.addSpacing(20)
ratio_max_label = QLabel("Max Ratio (normalized):")
ratio_max_label.setFont(QFont('Arial', 10))
ratio_thresholds_layout.addWidget(ratio_max_label)
# Max ratio spinbox
ratio_max_spin = QComboBox()
ratio_max_spin.setEditable(True)
ratio_max_spin.addItem("Auto")
# Add reasonable range options
for val in [1.2, 1.5, 1.8, 2.0, 2.2, 2.5, 3.0, 4.0, 5.0]:
ratio_max_spin.addItem(str(val))
saved_max = qa_settings.get('word_count_max_ratio', 'Auto')
ratio_max_spin.setCurrentText(str(saved_max))
ratio_max_spin.setMinimumWidth(100) # Increased from 80
disable_wheel_event(ratio_max_spin)
ratio_thresholds_layout.addWidget(ratio_max_spin)
ratio_thresholds_layout.addStretch()
wordcount_layout.addWidget(ratio_thresholds_widget)
ratio_hint = QLabel("(Auto CJK: Min 0.6, Max 2.0 | Auto Non-CJK: Min 0.7, Max 1.5)\nValues are normalized by the language multiplier above.")
ratio_hint.setFont(QFont('Arial', 9))
ratio_hint.setStyleSheet("color: gray;")
wordcount_layout.addWidget(ratio_hint)
scroll_layout.addSpacing(15)
# HTML Structure Analysis Section
cache_group = QGroupBox("Performance Cache Settings")
cache_group.setFont(QFont('Arial', 12, QFont.Bold))
cache_layout = QVBoxLayout(cache_group)
cache_layout.setContentsMargins(20, 15, 20, 15)
scroll_layout.addWidget(cache_group)
# Enable cache checkbox
cache_enabled_checkbox = self._create_styled_checkbox("Enable performance cache (speeds up duplicate detection)")
cache_enabled_checkbox.setChecked(qa_settings.get('cache_enabled', True))
cache_layout.addWidget(cache_enabled_checkbox)
cache_layout.addSpacing(10)
# Cache size settings
cache_desc_label = QLabel("Cache sizes (0 = disabled, -1 = unlimited):")
cache_desc_label.setFont(QFont('Arial', 10))
cache_layout.addWidget(cache_desc_label)
cache_layout.addSpacing(5)
# Cache size variables - store spinboxes and buttons
cache_spinboxes = {}
cache_buttons = {}
cache_defaults = {
'normalize_text': 10000,
'similarity_ratio': 20000,
'content_hashes': 5000,
'semantic_fingerprint': 2000,
'structural_signature': 2000,
'translation_artifacts': 1000
}
# Create input fields for each cache type
for cache_name, default_value in cache_defaults.items():
row_widget = QWidget()
row_layout = QHBoxLayout(row_widget)
row_layout.setContentsMargins(0, 2, 0, 2)
# Label
label_text = cache_name.replace('_', ' ').title() + ":"
cache_label = QLabel(label_text)
cache_label.setFont(QFont('Arial', 9))
cache_label.setMinimumWidth(200)
row_layout.addWidget(cache_label)
# Get current value
current_value = qa_settings.get(f'cache_{cache_name}', default_value)
# Spinbox
spinbox = QSpinBox()
spinbox.setMinimum(-1)
spinbox.setMaximum(50000)
spinbox.setValue(current_value)
spinbox.setMinimumWidth(100)
disable_wheel_event(spinbox)
row_layout.addWidget(spinbox)
cache_spinboxes[cache_name] = spinbox
# Quick preset buttons
def make_preset_handler(sb, val):
return lambda: sb.setValue(val)
off_btn = QPushButton("Off")
off_btn.setFont(QFont('Arial', 8))
off_btn.setMinimumWidth(40)
off_btn.clicked.connect(make_preset_handler(spinbox, 0))
row_layout.addWidget(off_btn)
small_btn = QPushButton("Small")
small_btn.setFont(QFont('Arial', 8))
small_btn.setMinimumWidth(50)
small_btn.clicked.connect(make_preset_handler(spinbox, 1000))
row_layout.addWidget(small_btn)
medium_btn = QPushButton("Medium")
medium_btn.setFont(QFont('Arial', 8))
medium_btn.setMinimumWidth(60)
medium_btn.clicked.connect(make_preset_handler(spinbox, default_value))
row_layout.addWidget(medium_btn)
large_btn = QPushButton("Large")
large_btn.setFont(QFont('Arial', 8))
large_btn.setMinimumWidth(50)
large_btn.clicked.connect(make_preset_handler(spinbox, default_value * 2))
row_layout.addWidget(large_btn)
max_btn = QPushButton("Max")
max_btn.setFont(QFont('Arial', 8))
max_btn.setMinimumWidth(40)
max_btn.clicked.connect(make_preset_handler(spinbox, -1))
row_layout.addWidget(max_btn)
# Store buttons for enabling/disabling
cache_buttons[cache_name] = [cache_label, off_btn, small_btn, medium_btn, large_btn, max_btn]
row_layout.addStretch()
cache_layout.addWidget(row_widget)
# Enable/disable cache size controls based on checkbox
def toggle_cache_controls(checked):
for cache_name in cache_defaults.keys():
spinbox = cache_spinboxes[cache_name]
spinbox.setEnabled(checked)
for widget in cache_buttons[cache_name]:
widget.setEnabled(checked)
cache_enabled_checkbox.toggled.connect(toggle_cache_controls)
toggle_cache_controls(cache_enabled_checkbox.isChecked()) # Set initial state
cache_layout.addSpacing(10)
# Auto-size cache option
auto_size_widget = QWidget()
auto_size_layout = QHBoxLayout(auto_size_widget)
auto_size_layout.setContentsMargins(0, 0, 0, 5)
auto_size_checkbox = self._create_styled_checkbox("Auto-size caches based on available RAM")
auto_size_checkbox.setChecked(qa_settings.get('cache_auto_size', False))
auto_size_layout.addWidget(auto_size_checkbox)
auto_size_hint = QLabel("(overrides manual settings)")
auto_size_hint.setFont(QFont('Arial', 9))
auto_size_hint.setStyleSheet("color: gray;")
auto_size_layout.addWidget(auto_size_hint)
auto_size_layout.addStretch()
cache_layout.addWidget(auto_size_widget)
cache_layout.addSpacing(10)
# Cache statistics display
show_stats_checkbox = self._create_styled_checkbox("Show cache hit/miss statistics after scan")
show_stats_checkbox.setChecked(qa_settings.get('cache_show_stats', False))
cache_layout.addWidget(show_stats_checkbox)
cache_layout.addSpacing(10)
# Info about cache
cache_info = QLabel("Larger cache sizes use more memory but improve performance for:\n" +
"• Large datasets (100+ files)\n" +
"• AI Hunter mode (all file pairs compared)\n" +
"• Repeated scans of the same folder")
cache_info.setFont(QFont('Arial', 9))
cache_info.setStyleSheet("color: gray;")
cache_info.setWordWrap(True)
cache_info.setMaximumWidth(700)
cache_info.setContentsMargins(20, 0, 0, 0)
cache_layout.addWidget(cache_info)
scroll_layout.addSpacing(20)
# AI Hunter Performance Section
ai_hunter_group = QGroupBox("AI Hunter Performance Settings")
ai_hunter_group.setFont(QFont('Arial', 12, QFont.Bold))
ai_hunter_layout = QVBoxLayout(ai_hunter_group)
ai_hunter_layout.setContentsMargins(20, 15, 20, 15)
scroll_layout.addWidget(ai_hunter_group)
# Description
ai_hunter_desc = QLabel("AI Hunter mode performs exhaustive duplicate detection by comparing every file pair.\n" +
"Parallel processing can significantly speed up this process on multi-core systems.")
ai_hunter_desc.setFont(QFont('Arial', 9))
ai_hunter_desc.setStyleSheet("color: gray;")
ai_hunter_desc.setWordWrap(True)
ai_hunter_desc.setMaximumWidth(700)
ai_hunter_layout.addWidget(ai_hunter_desc)
ai_hunter_layout.addSpacing(10)
# Parallel workers setting
workers_widget = QWidget()
workers_layout = QHBoxLayout(workers_widget)
workers_layout.setContentsMargins(0, 0, 0, 10)
workers_label = QLabel("Maximum parallel workers:")
workers_label.setFont(QFont('Arial', 10))
workers_layout.addWidget(workers_label)
# Get current value from AI Hunter config
ai_hunter_config = self.config.get('ai_hunter_config', {})
current_max_workers = ai_hunter_config.get('ai_hunter_max_workers', 1)
ai_hunter_workers_spinbox = QSpinBox()
ai_hunter_workers_spinbox.setMinimum(0)
ai_hunter_workers_spinbox.setMaximum(64)
ai_hunter_workers_spinbox.setValue(current_max_workers)
ai_hunter_workers_spinbox.setMinimumWidth(100)
disable_wheel_event(ai_hunter_workers_spinbox)
workers_layout.addWidget(ai_hunter_workers_spinbox)
# CPU count display
import multiprocessing
cpu_count = multiprocessing.cpu_count()
cpu_hint = QLabel(f"(0 = use all {cpu_count} cores)")
cpu_hint.setFont(QFont('Arial', 9))
cpu_hint.setStyleSheet("color: gray;")
workers_layout.addWidget(cpu_hint)
workers_layout.addStretch()
ai_hunter_layout.addWidget(workers_widget)
# Quick preset buttons
preset_widget = QWidget()
preset_layout = QHBoxLayout(preset_widget)
preset_layout.setContentsMargins(0, 0, 0, 0)
preset_label = QLabel("Quick presets:")
preset_label.setFont(QFont('Arial', 9))
preset_layout.addWidget(preset_label)
preset_layout.addSpacing(10)
all_cores_btn = QPushButton(f"All cores ({cpu_count})")
all_cores_btn.setFont(QFont('Arial', 9))
all_cores_btn.clicked.connect(lambda: ai_hunter_workers_spinbox.setValue(0))
preset_layout.addWidget(all_cores_btn)
half_cores_btn = QPushButton("Half cores")
half_cores_btn.setFont(QFont('Arial', 9))
half_cores_btn.clicked.connect(lambda: ai_hunter_workers_spinbox.setValue(max(1, cpu_count // 2)))
preset_layout.addWidget(half_cores_btn)
four_cores_btn = QPushButton("4 cores")
four_cores_btn.setFont(QFont('Arial', 9))
four_cores_btn.clicked.connect(lambda: ai_hunter_workers_spinbox.setValue(4))
preset_layout.addWidget(four_cores_btn)
eight_cores_btn = QPushButton("8 cores")
eight_cores_btn.setFont(QFont('Arial', 9))
eight_cores_btn.clicked.connect(lambda: ai_hunter_workers_spinbox.setValue(8))
preset_layout.addWidget(eight_cores_btn)
single_thread_btn = QPushButton("Single thread")
single_thread_btn.setFont(QFont('Arial', 9))
single_thread_btn.clicked.connect(lambda: ai_hunter_workers_spinbox.setValue(1))
preset_layout.addWidget(single_thread_btn)
preset_layout.addStretch()
ai_hunter_layout.addWidget(preset_widget)
# Performance tips
tips_text = "Performance Tips:\n" + \
f"• Your system has {cpu_count} CPU cores available\n" + \
"• Using all cores provides maximum speed but may slow other applications\n" + \
"• 4-8 cores usually provides good balance of speed and system responsiveness\n" + \
"• Single thread (1) disables parallel processing for debugging"
tips_label = QLabel(tips_text)
tips_label.setFont(QFont('Arial', 9))
tips_label.setStyleSheet("color: gray;")
tips_label.setWordWrap(True)
tips_label.setMaximumWidth(700)
tips_label.setContentsMargins(20, 10, 0, 0)
ai_hunter_layout.addWidget(tips_label)
def save_settings():
"""Save QA scanner settings with comprehensive debugging"""
try:
# Check if debug mode is enabled
debug_mode = self.config.get('show_debug_buttons', False)
if debug_mode:
self.append_log("🔍 [DEBUG] Starting QA Scanner settings save process...")
# Helper to get the selected radio button value
def get_selected_radio_value(radio_button_list):
for rb, value in radio_button_list:
if rb.isChecked():
return value
return None
# Core QA Settings with debugging
core_settings_to_save = {
'foreign_char_threshold': (threshold_spinbox, lambda x: x.value()),
'excluded_characters': (excluded_text, lambda x: x.toPlainText().strip()),
'source_language': (source_lang_combo, lambda x: _normalize_source_language(x.currentText())),
'target_language': (target_language_combo, lambda x: _normalize_target_language(x.currentText())),
'check_encoding_issues': (check_encoding_checkbox, lambda x: x.isChecked()),
'check_repetition': (check_repetition_checkbox, lambda x: x.isChecked()),
'check_translation_artifacts': (check_artifacts_checkbox, lambda x: x.isChecked()),
'check_ai_artifacts': (check_ai_artifacts_checkbox, lambda x: x.isChecked()),
'check_punctuation_mismatch': (check_punctuation_checkbox, lambda x: x.isChecked()),
'punctuation_loss_threshold': (punct_threshold_spinbox, lambda x: x.value()),
'flag_excess_punctuation': (excess_punct_checkbox, lambda x: x.isChecked()),
'excess_punctuation_threshold': (excess_threshold_spinbox, lambda x: x.value()),
'check_glossary_leakage': (check_glossary_checkbox, lambda x: x.isChecked()),
'check_missing_images': (check_missing_images_checkbox, lambda x: x.isChecked()),
'min_file_length': (min_length_spinbox, lambda x: x.value()),
'min_duplicate_word_count': (min_dup_words_spinbox, lambda x: x.value()),
'min_text_length_for_spacing': (min_spacing_text_spinbox, lambda x: x.value()),
'report_format': (format_radio_buttons, get_selected_radio_value),
'auto_save_report': (auto_save_checkbox, lambda x: x.isChecked()),
'check_word_count_ratio': (check_word_count_checkbox, lambda x: x.isChecked()),
'counting_mode': (counting_mode_combo, lambda x: x.currentData()),
'check_multiple_headers': (check_multiple_headers_checkbox, lambda x: x.isChecked()),
'warn_name_mismatch': (warn_mismatch_checkbox, lambda x: x.isChecked()),
'check_missing_html_tag': (check_missing_html_tag_checkbox, lambda x: x.isChecked()),
'check_body_tag': (check_body_tag_checkbox, lambda x: x.isChecked()),
'check_missing_header_tags': (check_missing_header_tags_checkbox, lambda x: x.isChecked()),
'check_paragraph_structure': (check_paragraph_structure_checkbox, lambda x: x.isChecked()),
'check_invalid_nesting': (check_invalid_nesting_checkbox, lambda x: x.isChecked()),
'word_count_min_ratio': (ratio_min_spin, lambda x: x.currentText()),
'word_count_max_ratio': (ratio_max_spin, lambda x: x.currentText()),
}
failed_core_settings = []
for setting_name, (var_obj, converter) in core_settings_to_save.items():
try:
old_value = qa_settings.get(setting_name, '<NOT SET>')
new_value = converter(var_obj)
qa_settings[setting_name] = new_value
if debug_mode:
if old_value != new_value:
self.append_log(f"🔍 [DEBUG] QA {setting_name}: '{old_value}' → '{new_value}'")
else:
self.append_log(f"🔍 [DEBUG] QA {setting_name}: unchanged ('{new_value}')")
except Exception as e:
failed_core_settings.append(f"{setting_name} ({str(e)})")
if debug_mode:
self.append_log(f"❌ [DEBUG] Failed to save QA {setting_name}: {e}")
if failed_core_settings and debug_mode:
self.append_log(f"⚠️ [DEBUG] Failed QA core settings: {', '.join(failed_core_settings)}")
# Cache settings with debugging
if debug_mode:
self.append_log("🔍 [DEBUG] Saving QA cache settings...")
cache_settings_to_save = {
'cache_enabled': (cache_enabled_checkbox, lambda x: x.isChecked()),
'cache_auto_size': (auto_size_checkbox, lambda x: x.isChecked()),
'cache_show_stats': (show_stats_checkbox, lambda x: x.isChecked()),
}
failed_cache_settings = []
for setting_name, (var_obj, converter) in cache_settings_to_save.items():
try:
old_value = qa_settings.get(setting_name, '<NOT SET>')
new_value = converter(var_obj)
qa_settings[setting_name] = new_value
if debug_mode:
if old_value != new_value:
self.append_log(f"🔍 [DEBUG] QA {setting_name}: '{old_value}' → '{new_value}'")
else:
self.append_log(f"🔍 [DEBUG] QA {setting_name}: unchanged ('{new_value}')")
except Exception as e:
failed_cache_settings.append(f"{setting_name} ({str(e)})")
if debug_mode:
self.append_log(f"❌ [DEBUG] Failed to save QA {setting_name}: {e}")
# Save individual cache sizes with debugging
saved_cache_vars = []
failed_cache_vars = []
for cache_name, cache_spinbox in cache_spinboxes.items():
try:
cache_key = f'cache_{cache_name}'
old_value = qa_settings.get(cache_key, '<NOT SET>')
new_value = cache_spinbox.value()
qa_settings[cache_key] = new_value
saved_cache_vars.append(cache_name)
if debug_mode and old_value != new_value:
self.append_log(f"🔍 [DEBUG] QA {cache_key}: '{old_value}' → '{new_value}'")
except Exception as e:
failed_cache_vars.append(f"{cache_name} ({str(e)})")
if debug_mode:
self.append_log(f"❌ [DEBUG] Failed to save QA cache_{cache_name}: {e}")
if debug_mode:
if saved_cache_vars:
self.append_log(f"🔍 [DEBUG] Saved {len(saved_cache_vars)} cache settings: {', '.join(saved_cache_vars)}")
if failed_cache_vars:
self.append_log(f"⚠️ [DEBUG] Failed cache settings: {', '.join(failed_cache_vars)}")
# Save word count multipliers
try:
# Save auto toggle state
use_auto = auto_multipliers_checkbox.isChecked()
qa_settings['use_auto_multipliers'] = use_auto
# If auto is enabled, use default values; otherwise use slider values
if use_auto:
wc_mults = dict(default_wordcount_defaults)
if debug_mode:
self.append_log("🔍 [DEBUG] Using default word count multipliers (auto mode)")
else:
wc_mults = {}
for lang_key, widget in word_multiplier_sliders.items():
if lang_key.endswith('__spin'):
base_key = lang_key[:-6]
wc_mults[base_key] = widget.value() / 100.0
elif f"{lang_key}__spin" not in word_multiplier_sliders:
wc_mults[lang_key] = widget.value() / 100.0
if debug_mode:
self.append_log("🔍 [DEBUG] Using custom word count multipliers (manual mode)")
qa_settings['word_count_multipliers'] = wc_mults
except Exception as e:
if debug_mode:
self.append_log(f"❌ [DEBUG] Failed to save word count multipliers: {e}")
# AI Hunter config with debugging
if debug_mode:
self.append_log("🔍 [DEBUG] Saving AI Hunter config...")
try:
if 'ai_hunter_config' not in self.config:
self.config['ai_hunter_config'] = {}
if debug_mode:
self.append_log("🔍 [DEBUG] Created new ai_hunter_config section")
old_workers = self.config['ai_hunter_config'].get('ai_hunter_max_workers', '<NOT SET>')
new_workers = ai_hunter_workers_spinbox.value()
self.config['ai_hunter_config']['ai_hunter_max_workers'] = new_workers
if debug_mode:
if old_workers != new_workers:
self.append_log(f"🔍 [DEBUG] AI Hunter max_workers: '{old_workers}' → '{new_workers}'")
else:
self.append_log(f"🔍 [DEBUG] AI Hunter max_workers: unchanged ('{new_workers}')")
except Exception as e:
if debug_mode:
self.append_log(f"❌ [DEBUG] Failed to save AI Hunter config: {e}")
# Validate and save paragraph threshold with debugging
if debug_mode:
self.append_log("🔍 [DEBUG] Validating paragraph threshold...")
try:
threshold_value = paragraph_threshold_spinbox.value()
old_threshold = qa_settings.get('paragraph_threshold', '<NOT SET>')
if 0 <= threshold_value <= 100:
new_threshold = threshold_value / 100.0 # Convert to decimal
qa_settings['paragraph_threshold'] = new_threshold
if debug_mode:
if old_threshold != new_threshold:
self.append_log(f"🔍 [DEBUG] QA paragraph_threshold: '{old_threshold}' → '{new_threshold}' ({threshold_value}%)")
else:
self.append_log(f"🔍 [DEBUG] QA paragraph_threshold: unchanged ('{new_threshold}' / {threshold_value}%)")
else:
raise ValueError("Threshold must be between 0 and 100")
except (ValueError, Exception) as e:
# Default to 30% if invalid
qa_settings['paragraph_threshold'] = 0.3
if debug_mode:
self.append_log(f"❌ [DEBUG] Invalid paragraph threshold ({e}), using default 30%")
self.append_log("⚠️ Invalid paragraph threshold, using default 30%")
# Save to main config with debugging
if debug_mode:
self.append_log("🔍 [DEBUG] Saving QA settings to main config...")
try:
old_qa_config = self.config.get('qa_scanner_settings', {})
self.config['qa_scanner_settings'] = qa_settings
if debug_mode:
# Count changed settings
changed_settings = []
for key, new_value in qa_settings.items():
if old_qa_config.get(key) != new_value:
changed_settings.append(key)
if changed_settings:
self.append_log(f"🔍 [DEBUG] Changed {len(changed_settings)} QA settings: {', '.join(changed_settings[:5])}{'...' if len(changed_settings) > 5 else ''}")
else:
self.append_log("🔍 [DEBUG] No QA settings changed")
except Exception as e:
if debug_mode:
self.append_log(f"❌ [DEBUG] Failed to update main config: {e}")
# Sync target language with the main translation UI so all
# dropdowns stay in sync.
try:
display_lang = target_language_combo.currentText().strip()
if display_lang and hasattr(self, 'update_target_language'):
self.update_target_language(display_lang)
except Exception as e:
if debug_mode:
self.append_log(f"⚠️ [DEBUG] Failed to sync target language with main UI: {e}")
# Environment variables setup for QA Scanner
if debug_mode:
self.append_log("🔍 [DEBUG] Setting QA Scanner environment variables...")
qa_env_vars_set = []
try:
# QA Scanner environment variables
qa_env_mappings = [
('QA_FOREIGN_CHAR_THRESHOLD', str(qa_settings.get('foreign_char_threshold', 10))),
('QA_TARGET_LANGUAGE', qa_settings.get('target_language', 'english')),
('QA_CHECK_ENCODING', '1' if qa_settings.get('check_encoding_issues', False) else '0'),
('QA_CHECK_REPETITION', '1' if qa_settings.get('check_repetition', True) else '0'),
('QA_CHECK_ARTIFACTS', '1' if qa_settings.get('check_translation_artifacts', False) else '0'),
('QA_CHECK_AI_ARTIFACTS', '1' if qa_settings.get('check_ai_artifacts', False) else '0'),
('QA_CHECK_GLOSSARY_LEAKAGE', '1' if qa_settings.get('check_glossary_leakage', True) else '0'),
('QA_CHECK_MISSING_IMAGES', '1' if qa_settings.get('check_missing_images', True) else '0'),
('QA_MIN_FILE_LENGTH', str(qa_settings.get('min_file_length', 0))),
('QA_REPORT_FORMAT', qa_settings.get('report_format', 'detailed')),
('QA_AUTO_SAVE_REPORT', '1' if qa_settings.get('auto_save_report', True) else '0'),
('QA_CACHE_ENABLED', '1' if qa_settings.get('cache_enabled', True) else '0'),
('QA_PARAGRAPH_THRESHOLD', str(qa_settings.get('paragraph_threshold', 0.3))),
('AI_HUNTER_MAX_WORKERS', str(self.config.get('ai_hunter_config', {}).get('ai_hunter_max_workers', 1))),
# Counting mode: set env vars based on selection
('QA_USE_WORD_COUNT', '1' if qa_settings.get('counting_mode') == 'word' else '0'),
('QA_EXACT_CHAR_COUNT', '1' if qa_settings.get('counting_mode') == 'exact' else '0'),
]
for env_key, env_value in qa_env_mappings:
try:
old_value = os.environ.get(env_key, '<NOT SET>')
os.environ[env_key] = str(env_value)
new_value = os.environ[env_key]
qa_env_vars_set.append(env_key)
if debug_mode:
if old_value != new_value:
self.append_log(f"🔍 [DEBUG] ENV {env_key}: '{old_value}' → '{new_value}'")
else:
self.append_log(f"🔍 [DEBUG] ENV {env_key}: unchanged ('{new_value}')")
except Exception as e:
if debug_mode:
self.append_log(f"❌ [DEBUG] Failed to set {env_key}: {e}")
if debug_mode:
self.append_log(f"🔍 [DEBUG] Successfully set {len(qa_env_vars_set)} QA environment variables")
except Exception as e:
if debug_mode:
self.append_log(f"❌ [DEBUG] QA environment variable setup failed: {e}")
import traceback
self.append_log(f"❌ [DEBUG] Traceback: {traceback.format_exc()}")
# Call save_config with show_message=False to avoid the error
if debug_mode:
self.append_log("🔍 [DEBUG] Calling main save_config method...")
try:
self.save_config(show_message=False)
if debug_mode:
self.append_log("🔍 [DEBUG] Main save_config completed successfully")
except Exception as e:
if debug_mode:
self.append_log(f"❌ [DEBUG] Main save_config failed: {e}")
raise
# Final QA environment variable verification
if debug_mode:
self.append_log("🔍 [DEBUG] Final QA environment variable check:")
critical_qa_vars = ['QA_FOREIGN_CHAR_THRESHOLD', 'QA_TARGET_LANGUAGE', 'QA_REPORT_FORMAT', 'AI_HUNTER_MAX_WORKERS']
for var in critical_qa_vars:
value = os.environ.get(var, '<NOT SET>')
if value == '<NOT SET>' or not value:
self.append_log(f"❌ [DEBUG] CRITICAL QA: {var} is not set or empty!")
else:
self.append_log(f"✅ [DEBUG] QA {var}: {value}")
self.append_log("✅ QA Scanner settings saved successfully")
dialog._cleanup_scrolling() # Clean up scrolling bindings
dialog.accept()
except Exception as e:
# Get debug_mode again in case of early exception
debug_mode = self.config.get('show_debug_buttons', False)
if debug_mode:
self.append_log(f"❌ [DEBUG] QA save_settings full exception: {str(e)}")
import traceback
self.append_log(f"❌ [DEBUG] QA save_settings traceback: {traceback.format_exc()}")
self.append_log(f"❌ Error saving QA settings: {str(e)}")
QMessageBox.critical(dialog, "Error", f"Failed to save settings: {str(e)}")
def reset_defaults():
"""Reset to default settings"""
result = QMessageBox.question(
dialog,
"Reset to Defaults",
"Are you sure you want to reset all settings to defaults?\n\n(Your excluded characters list will be preserved)",
QMessageBox.Yes | QMessageBox.No
)
if result == QMessageBox.Yes:
# Save current excluded characters before reset
saved_excluded_chars = excluded_text.toPlainText()
# Foreign character / language defaults
threshold_spinbox.setValue(10)
source_lang_combo.setCurrentText('Auto')
target_language_combo.setCurrentText('English')
# Detection defaults
check_encoding_checkbox.setChecked(False)
check_repetition_checkbox.setChecked(True)
check_artifacts_checkbox.setChecked(False)
check_ai_artifacts_checkbox.setChecked(False)
check_punctuation_checkbox.setChecked(False)
punct_threshold_spinbox.setValue(49)
excess_punct_checkbox.setChecked(False)
excess_threshold_spinbox.setValue(49)
# Word count analysis defaults
check_word_count_checkbox.setChecked(True)
try:
idx = counting_mode_combo.findData('exact')
counting_mode_combo.setCurrentIndex(idx if idx >= 0 else 1)
except Exception:
pass
ratio_min_spin.setCurrentText('Auto')
ratio_max_spin.setCurrentText('Auto')
# Reset auto multipliers checkbox to default (enabled)
auto_multipliers_checkbox.setChecked(True)
# Reset word count multipliers to defaults
for lang_key, widget in word_multiplier_sliders.items():
if lang_key.endswith('__spin'):
base_key = lang_key[:-6]
default_val = default_wordcount_defaults.get(base_key, 1.0)
widget.setValue(int(default_val * 100))
elif f"{lang_key}__spin" not in word_multiplier_sliders:
default_val = default_wordcount_defaults.get(lang_key, 1.0)
widget.setValue(int(default_val * 100))
check_glossary_checkbox.setChecked(True)
check_missing_images_checkbox.setChecked(True)
min_length_spinbox.setValue(0)
# Set 'detailed' radio button as checked
for rb, value in format_radio_buttons:
rb.setChecked(value == 'detailed')
auto_save_checkbox.setChecked(True)
check_multiple_headers_checkbox.setChecked(True)
warn_mismatch_checkbox.setChecked(True)
check_missing_html_tag_checkbox.setChecked(True)
check_missing_header_tags_checkbox.setChecked(True)
check_paragraph_structure_checkbox.setChecked(True)
check_invalid_nesting_checkbox.setChecked(False)
paragraph_threshold_spinbox.setValue(30) # 30% default
# Reset cache settings
cache_enabled_checkbox.setChecked(True)
auto_size_checkbox.setChecked(False)
show_stats_checkbox.setChecked(False)
# Reset cache sizes to defaults
for cache_name, default_value in cache_defaults.items():
cache_spinboxes[cache_name].setValue(default_value)
ai_hunter_workers_spinbox.setValue(1)
# Restore excluded characters (per confirmation text)
excluded_text.setPlainText(saved_excluded_chars)
scroll_layout.addStretch()
# Create fixed bottom button section (outside scroll area)
button_widget = QWidget()
button_layout = QHBoxLayout(button_widget)
button_layout.setContentsMargins(20, 15, 20, 15)
save_btn = QPushButton("Save Settings")
save_btn.setMinimumWidth(120)
save_btn.setStyleSheet("background-color: #28a745; color: white; padding: 8px; font-weight: bold;")
save_btn.clicked.connect(save_settings)
button_layout.addWidget(save_btn)
cancel_btn = QPushButton("Cancel")
cancel_btn.setMinimumWidth(120)
cancel_btn.setStyleSheet("background-color: #6c757d; color: white; padding: 8px;")
cancel_btn.clicked.connect(lambda: [dialog._cleanup_scrolling(), dialog.reject()])
button_layout.addWidget(cancel_btn)
reset_btn = QPushButton("Reset to Default")
reset_btn.setMinimumWidth(120)
reset_btn.setStyleSheet("background-color: #ffc107; color: black; padding: 8px;")
reset_btn.clicked.connect(reset_defaults)
button_layout.addWidget(reset_btn)
# Add button widget to main layout (not scroll layout)
main_layout.addWidget(button_widget)
# Show the dialog (PySide6 handles sizing automatically)
# Note: The dialog size is already set in the constructor (800x600)
# Add a dummy _cleanup_scrolling method for compatibility
dialog._cleanup_scrolling = lambda: None
# Handle window close - just cleanup, don't call reject() to avoid recursion
def handle_close():
dialog._cleanup_scrolling()
dialog.rejected.connect(handle_close)
# Show the dialog with fade animation and return result
try:
from dialog_animations import exec_dialog_with_fade
return exec_dialog_with_fade(dialog, duration=250)
except Exception:
return dialog.exec()
def show_custom_detection_dialog(parent=None):
"""
Standalone function to show the custom detection settings dialog.
Returns a dictionary with the settings if user confirms, None if cancelled.
This function can be called from anywhere, including scan_html_folder.py
"""
from PySide6.QtWidgets import (QApplication, QDialog, QWidget, QLabel, QPushButton,
QVBoxLayout, QHBoxLayout, QScrollArea, QGroupBox,
QCheckBox, QSpinBox, QSlider, QMessageBox, QSizePolicy)
from PySide6.QtCore import Qt
from PySide6.QtGui import QFont, QIcon
import os
# Create dialog
custom_dialog = QDialog(parent)
custom_dialog.setWindowTitle("Custom Mode Settings")
custom_dialog.setModal(True)
# Set dialog size
screen = QApplication.primaryScreen().geometry()
custom_width = int(screen.width() * 0.51)
custom_height = int(screen.height() * 0.60)
custom_dialog.resize(custom_width, custom_height)
# Set window icon
try:
# Try to find the icon in common locations
possible_paths = [
os.path.join(os.path.dirname(os.path.abspath(__file__)), 'Halgakos.ico'),
os.path.join(os.getcwd(), 'Halgakos.ico'),
]
for ico_path in possible_paths:
if os.path.isfile(ico_path):
custom_dialog.setWindowIcon(QIcon(ico_path))
break
except Exception:
pass
# Main layout
dialog_layout = QVBoxLayout(custom_dialog)
# Scroll area
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
# Scrollable content widget
scroll_widget = QWidget()
scroll_layout = QVBoxLayout(scroll_widget)
scroll.setWidget(scroll_widget)
dialog_layout.addWidget(scroll)
# Default settings
default_settings = {
'text_similarity': 85,
'semantic_analysis': 80,
'structural_patterns': 90,
'word_overlap': 75,
'minhash_similarity': 80,
'consecutive_chapters': 2,
'check_all_pairs': False,
'sample_size': 3000,
'min_text_length': 500,
'min_duplicate_word_count': 500
}
# Store widget references
custom_widgets = {}
# Title
title_label = QLabel("Configure Custom Detection Settings")
title_label.setFont(QFont('Arial', 20, QFont.Bold))
title_label.setAlignment(Qt.AlignCenter)
scroll_layout.addWidget(title_label)
scroll_layout.addSpacing(20)
# Detection Thresholds Section
threshold_group = QGroupBox("Detection Thresholds (%)")
threshold_group.setFont(QFont('Arial', 12, QFont.Bold))
threshold_layout = QVBoxLayout(threshold_group)
threshold_layout.setContentsMargins(25, 25, 25, 25)
scroll_layout.addWidget(threshold_group)
threshold_descriptions = {
'text_similarity': ('Text Similarity', 'Character-by-character comparison'),
'semantic_analysis': ('Semantic Analysis', 'Meaning and context matching'),
'structural_patterns': ('Structural Patterns', 'Document structure similarity'),
'word_overlap': ('Word Overlap', 'Common words between texts'),
'minhash_similarity': ('MinHash Similarity', 'Fast approximate matching')
}
# Create percentage labels dictionary
percentage_labels = {}
for setting_key, (label_text, description) in threshold_descriptions.items():
# Container for each threshold
row_widget = QWidget()
row_layout = QHBoxLayout(row_widget)
row_layout.setContentsMargins(0, 8, 0, 8)
# Left side - labels
label_widget = QWidget()
label_layout = QVBoxLayout(label_widget)
label_layout.setContentsMargins(0, 0, 0, 0)
main_label = QLabel(f"{label_text} - {description}:")
main_label.setFont(QFont('Arial', 11))
label_layout.addWidget(main_label)
label_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
row_layout.addWidget(label_widget)
# Right side - slider and percentage
slider_widget = QWidget()
slider_layout = QHBoxLayout(slider_widget)
slider_layout.setContentsMargins(20, 0, 0, 0)
# Create slider
slider = QSlider(Qt.Horizontal)
slider.setMinimum(10)
slider.setMaximum(100)
slider.setValue(custom_settings[setting_key])
slider.setMinimumWidth(300)
slider.wheelEvent = lambda event: event.ignore()
slider_layout.addWidget(slider)
# Percentage label
percentage_label = QLabel(f"{custom_settings[setting_key]}%")
percentage_label.setFont(QFont('Arial', 12, QFont.Bold))
percentage_label.setMinimumWidth(50)
percentage_label.setAlignment(Qt.AlignRight)
slider_layout.addWidget(percentage_label)
percentage_labels[setting_key] = percentage_label
row_layout.addWidget(slider_widget)
threshold_layout.addWidget(row_widget)
# Store slider widget reference
custom_widgets[setting_key] = slider
# Update percentage label when slider moves
def create_update_function(key, label, settings_dict):
def update_percentage(value):
settings_dict[key] = value
label.setText(f"{value}%")
return update_percentage
update_func = create_update_function(setting_key, percentage_label, custom_settings)
slider.valueChanged.connect(update_func)
scroll_layout.addSpacing(15)
# Processing Options Section
options_group = QGroupBox("Processing Options")
options_group.setFont(QFont('Arial', 12, QFont.Bold))
options_layout = QVBoxLayout(options_group)
options_layout.setContentsMargins(20, 20, 20, 20)
scroll_layout.addWidget(options_group)
# Consecutive chapters option
consec_widget = QWidget()
consec_layout = QHBoxLayout(consec_widget)
consec_layout.setContentsMargins(0, 5, 0, 5)
consec_label = QLabel("Consecutive chapters to check:")
consec_label.setFont(QFont('Arial', 11))
consec_layout.addWidget(consec_label)
consec_spinbox = QSpinBox()
consec_spinbox.setMinimum(1)
consec_spinbox.setMaximum(10)
consec_spinbox.setValue(custom_settings['consecutive_chapters'])
consec_spinbox.setMinimumWidth(100)
consec_spinbox.wheelEvent = lambda event: event.ignore()
consec_layout.addWidget(consec_spinbox)
consec_layout.addStretch()
options_layout.addWidget(consec_widget)
custom_widgets['consecutive_chapters'] = consec_spinbox
# Sample size option
sample_widget = QWidget()
sample_layout = QHBoxLayout(sample_widget)
sample_layout.setContentsMargins(0, 5, 0, 5)
sample_label = QLabel("Sample size for comparison (characters):")
sample_label.setFont(QFont('Arial', 11))
sample_layout.addWidget(sample_label)
sample_spinbox = QSpinBox()
sample_spinbox.setMinimum(-1)
# QSpinBox requires a maximum; set it extremely high to be effectively "no maximum"
sample_spinbox.setMaximum(2000000000)
sample_spinbox.setSingleStep(500)
sample_spinbox.setValue(custom_settings['sample_size'])
sample_spinbox.setMinimumWidth(100)
sample_spinbox.setToolTip("-1 = use all characters, 0 = disable duplicate detection")
sample_spinbox.wheelEvent = lambda event: event.ignore()
sample_layout.addWidget(sample_spinbox)
sample_layout.addStretch()
options_layout.addWidget(sample_widget)
custom_widgets['sample_size'] = sample_spinbox
# Minimum text length option
min_length_widget = QWidget()
min_length_layout = QHBoxLayout(min_length_widget)
min_length_layout.setContentsMargins(0, 5, 0, 5)
min_length_label = QLabel("Minimum text length to process (characters):")
min_length_label.setFont(QFont('Arial', 11))
min_length_layout.addWidget(min_length_label)
min_length_spinbox = QSpinBox()
min_length_spinbox.setMinimum(100)
min_length_spinbox.setMaximum(5000)
min_length_spinbox.setSingleStep(100)
min_length_spinbox.setValue(custom_settings['min_text_length'])
min_length_spinbox.setMinimumWidth(100)
min_length_spinbox.wheelEvent = lambda event: event.ignore()
min_length_layout.addWidget(min_length_spinbox)
min_length_layout.addStretch()
options_layout.addWidget(min_length_widget)
custom_widgets['min_text_length'] = min_length_spinbox
# Minimum word count for duplicate detection
min_dup_words_widget = QWidget()
min_dup_words_layout = QHBoxLayout(min_dup_words_widget)
min_dup_words_layout.setContentsMargins(0, 5, 0, 5)
min_dup_words_label = QLabel("Minimum words to flag as duplicate (skip small files like sections/notices):")
min_dup_words_label.setFont(QFont('Arial', 11))
min_dup_words_layout.addWidget(min_dup_words_label)
min_dup_words_spinbox = QSpinBox()
min_dup_words_spinbox.setMinimum(100)
min_dup_words_spinbox.setMaximum(2000)
min_dup_words_spinbox.setSingleStep(50)
min_dup_words_spinbox.setValue(custom_settings.get('min_duplicate_word_count', 500))
min_dup_words_spinbox.setMinimumWidth(100)
min_dup_words_spinbox.wheelEvent = lambda event: event.ignore()
min_dup_words_layout.addWidget(min_dup_words_spinbox)
min_dup_words_layout.addStretch()
options_layout.addWidget(min_dup_words_widget)
custom_widgets['min_duplicate_word_count'] = min_dup_words_spinbox
# Check all file pairs option
check_all_checkbox = QCheckBox("Check all file pairs (slower but more thorough)")
check_all_checkbox.setChecked(custom_settings['check_all_pairs'])
options_layout.addWidget(check_all_checkbox)
custom_widgets['check_all_pairs'] = check_all_checkbox
scroll_layout.addSpacing(30)
# Button layout
button_widget = QWidget()
button_layout = QHBoxLayout(button_widget)
button_layout.addStretch()
scroll_layout.addWidget(button_widget)
# Flag to track if settings were confirmed
settings_confirmed = False
result_settings = None
def confirm_settings():
"""Confirm settings and close dialog"""
nonlocal settings_confirmed, result_settings
result_settings = {
'text_similarity': custom_widgets['text_similarity'].value(),
'semantic_analysis': custom_widgets['semantic_analysis'].value(),
'structural_patterns': custom_widgets['structural_patterns'].value(),
'word_overlap': custom_widgets['word_overlap'].value(),
'minhash_similarity': custom_widgets['minhash_similarity'].value(),
'consecutive_chapters': custom_widgets['consecutive_chapters'].value(),
'check_all_pairs': custom_widgets['check_all_pairs'].isChecked(),
'sample_size': custom_widgets['sample_size'].value(),
'min_text_length': custom_widgets['min_text_length'].value(),
'min_duplicate_word_count': custom_widgets['min_duplicate_word_count'].value()
}
settings_confirmed = True
custom_dialog.accept()
def reset_to_defaults():
"""Reset all values to defaults"""
reply = QMessageBox.question(custom_dialog, "Reset to Defaults",
"Reset all values to default settings?",
QMessageBox.Yes | QMessageBox.No)
if reply == QMessageBox.Yes:
custom_widgets['text_similarity'].setValue(85)
custom_widgets['semantic_analysis'].setValue(80)
custom_widgets['structural_patterns'].setValue(90)
custom_widgets['word_overlap'].setValue(75)
custom_widgets['minhash_similarity'].setValue(80)
custom_widgets['consecutive_chapters'].setValue(2)
custom_widgets['check_all_pairs'].setChecked(False)
custom_widgets['sample_size'].setValue(3000)
custom_widgets['min_text_length'].setValue(500)
custom_widgets['min_duplicate_word_count'].setValue(500)
# Create buttons
cancel_btn = QPushButton("Cancel")
cancel_btn.setMinimumWidth(120)
cancel_btn.clicked.connect(custom_dialog.reject)
button_layout.addWidget(cancel_btn)
reset_btn = QPushButton("Reset Defaults")
reset_btn.setMinimumWidth(120)
reset_btn.clicked.connect(reset_to_defaults)
button_layout.addWidget(reset_btn)
start_btn = QPushButton("Start Scan")
start_btn.setMinimumWidth(120)
start_btn.setStyleSheet("""
QPushButton {
background-color: #28a745;
color: white;
border: 1px solid #28a745;
padding: 6px 12px;
border-radius: 4px;
}
QPushButton:hover {
background-color: #218838;
}
""")
start_btn.clicked.connect(confirm_settings)
button_layout.addWidget(start_btn)
button_layout.addStretch()
# Show dialog with fade animation and return result
try:
from dialog_animations import exec_dialog_with_fade
exec_dialog_with_fade(custom_dialog, duration=250)
except Exception:
custom_dialog.exec()
# Return settings if confirmed, None otherwise
return result_settings if settings_confirmed else None
|