From a7113384e8d53ce6261eebf80cb42cf2c1a7d491 Mon Sep 17 00:00:00 2001 From: Geomitron <22552797+Geomitron@users.noreply.github.com> Date: Sun, 22 Dec 2024 18:35:43 -0600 Subject: [PATCH] Add "Tools" tab with chart issue scanner --- package.json | 1 + pnpm-lock.yaml | 231 +++++++++++++++++ src-angular/app/app-routing.module.ts | 2 + .../components/toolbar/toolbar.component.html | 1 + .../app/components/tools/tools.component.html | 62 +++++ .../app/components/tools/tools.component.ts | 77 ++++++ .../app/core/services/settings.service.ts | 14 + src-electron/IpcHandler.ts | 2 + src-electron/ipc/SettingsHandler.ipc.ts | 1 + src-electron/ipc/issue-scan/ExcelBuilder.ts | 240 ++++++++++++++++++ .../ipc/issue-scan/IssueScanHandler.ipc.ts | 183 +++++++++++++ src-electron/preload.ts | 2 + src-shared/Settings.ts | 30 ++- src-shared/UtilFunctions.ts | 31 +++ src-shared/interfaces/ipc.interface.ts | 2 + 15 files changed, 866 insertions(+), 13 deletions(-) create mode 100644 src-angular/app/components/tools/tools.component.html create mode 100644 src-angular/app/components/tools/tools.component.ts create mode 100644 src-electron/ipc/issue-scan/ExcelBuilder.ts create mode 100644 src-electron/ipc/issue-scan/IssueScanHandler.ipc.ts diff --git a/package.json b/package.json index d1f6f96..b43c47b 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "electron-updater": "6.2.1", "electron-window-state": "5.0.3", "eventemitter3": "5.0.1", + "exceljs": "^4.4.0", "fs-extra": "11.2.0", "lodash": "4.17.21", "parse-sng": "4.0.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bea048d..1cdfeb2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -59,6 +59,9 @@ importers: eventemitter3: specifier: 5.0.1 version: 5.0.1 + exceljs: + specifier: ^4.4.0 + version: 4.4.0 fs-extra: specifier: 11.2.0 version: 11.2.0 @@ -810,6 +813,12 @@ packages: resolution: {integrity: sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + '@fast-csv/format@4.3.5': + resolution: {integrity: sha512-8iRn6QF3I8Ak78lNAa+Gdl5MJJBM5vRHivFtMRUWINdevNo00K7OXxS2PshawLKTejVwieIlPmK5YlLu6w4u8A==} + + '@fast-csv/parse@4.3.6': + resolution: {integrity: sha512-uRsLYksqpbDmWaSmzvJcuApSEe38+6NQZBUsuAyMZKqHxH0g1wcJgsKUvN3WC8tewaqFjBMMGrkHmC+T7k8LvA==} + '@humanwhocodes/config-array@0.11.14': resolution: {integrity: sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==} engines: {node: '>=10.10.0'} @@ -1238,6 +1247,9 @@ packages: '@types/ms@0.7.34': resolution: {integrity: sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==} + '@types/node@14.18.63': + resolution: {integrity: sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==} + '@types/node@18.16.0': resolution: {integrity: sha512-BsAaKhB+7X+H4GnSjGhJG9Qi8Tw+inU9nJDwmD5CgOmBLEI6ArdhikpLX7DjbjDRDTbqZzU2LSQNZg8WGPiSZQ==} @@ -1576,6 +1588,10 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + big-integer@1.6.52: + resolution: {integrity: sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==} + engines: {node: '>=0.6'} + binary-extensions@2.2.0: resolution: {integrity: sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==} engines: {node: '>=8'} @@ -1584,12 +1600,18 @@ packages: resolution: {integrity: sha512-5ATpz/uPDgq5GgEDxTB4ouXCde7q2lqAQlSdBRQVl/AJnxmQmhIfyxJx+0MGu//D5rHQifkfGbWWlaysG0o9NA==} engines: {node: '>=12'} + binary@0.3.0: + resolution: {integrity: sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg==} + bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} bluebird-lst@1.0.9: resolution: {integrity: sha512-7B1Rtx82hjnSD4PGLAjVWeYH3tHAcVUmChh85a3lltKQm6FresXh9ErQo6oAv6CqxttczC3/kEg8SY5NluPuUw==} + bluebird@3.4.7: + resolution: {integrity: sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==} + bluebird@3.7.2: resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==} @@ -1630,9 +1652,17 @@ packages: buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + buffer-indexof-polyfill@1.0.2: + resolution: {integrity: sha512-I7wzHwA3t1/lwXQh+A5PbNvJxgfo5r3xulgpYDB5zckTu/Z9oUK9biouBKQUjEqzaz3HnAT6TYoovmE+GqSf7A==} + engines: {node: '>=0.10'} + buffer@5.7.1: resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + buffers@0.1.1: + resolution: {integrity: sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==} + engines: {node: '>=0.2.0'} + builder-util-runtime@9.2.4: resolution: {integrity: sha512-upp+biKpN/XZMLim7aguUyW8s0FUpDvOtK6sbanMFDAMBzpHDqdhgVYm6zc9HJ6nWo7u2Lxk60i2M6Jd3aiNrA==} engines: {node: '>=12.0.0'} @@ -1670,6 +1700,9 @@ packages: caniuse-lite@1.0.30001640: resolution: {integrity: sha512-lA4VMpW0PSUrFnkmVuEKBUovSWKhj7puyCg8StBChgu298N1AtuF1sKWEvfDuimSEDbhlb/KqPKC3fs1HbuQUA==} + chainsaw@0.1.0: + resolution: {integrity: sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ==} + chalk@1.1.3: resolution: {integrity: sha512-U3lRVLMSlsCfjqYPbLyVv11M9CPW4I728d6TCKMAOJueEeB9/8o+eSsMnxPJD+Q+K909sdESg7C+tIkoH6on1A==} engines: {node: '>=0.10.0'} @@ -1984,6 +2017,9 @@ packages: resolution: {tarball: https://codeload.github.com/corbanbrook/dsp.js/tar.gz/219600bb0346ee9a00686c9875c81123e2d8780e} version: 1.0.0 + duplexer2@0.1.4: + resolution: {integrity: sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==} + duplexer@0.1.2: resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==} @@ -2225,6 +2261,10 @@ packages: eventemitter3@5.0.1: resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} + exceljs@4.4.0: + resolution: {integrity: sha512-XctvKaEMaj1Ii9oDOqbW/6e1gXknSY4g/aLCDicOXqBE4M0nRWkUu0PTp++UPNzoFY12BNHMfs/VadKIS6llvg==} + engines: {node: '>=8.3.0'} + exifreader@4.23.3: resolution: {integrity: sha512-/Ii4jiNp/5BXdKOiWXZYrWmZFn/ANu3bMVGO7GFQufao5M52/fK2OsAPMH34PL4S79z1eZBzAoaYyBXit0zzVA==} @@ -2244,6 +2284,10 @@ packages: resolution: {integrity: sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==} engines: {'0': node >=0.6.0} + fast-csv@4.3.6: + resolution: {integrity: sha512-2RNSpuwwsJGP0frGsOmTb9oUF+VkFSM4SyLTDgwf2ciHWTarN0lQTC+F2f/t5J9QjW+c65VFIAAu85GsvMIusw==} + engines: {node: '>=10.0.0'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -2364,6 +2408,11 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + fstream@1.0.12: + resolution: {integrity: sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg==} + engines: {node: '>=0.6'} + deprecated: This package is no longer supported. + function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} @@ -2546,6 +2595,9 @@ packages: resolution: {integrity: sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==} engines: {node: '>= 4'} + immediate@3.0.6: + resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} + immutable@4.3.4: resolution: {integrity: sha512-fsXeu4J4i6WNWSikpI88v/PcVflZz+6kMhUfIwc5SY+poQRPnaf5V7qds6SUyUN3cVxEzuCab7QIoLOQ+DQ1wA==} @@ -2791,6 +2843,9 @@ packages: resolution: {integrity: sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==} engines: {'0': node >= 0.2.0} + jszip@3.10.1: + resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -2805,6 +2860,9 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + lie@3.3.0: + resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} + lilconfig@2.1.0: resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==} engines: {node: '>=10'} @@ -2820,6 +2878,9 @@ packages: resolution: {integrity: sha512-wM1+Z03eypVAVUCE7QdSqpVIvelbOakn1M0bPDoA4SGWPx3sNDVUiMo3L6To6WWGClB7VyXnhQ4Sn7gxiJbE6A==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + listenercount@1.0.1: + resolution: {integrity: sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ==} + lmdb@3.0.8: resolution: {integrity: sha512-9rp8JT4jPhCRJUL7vRARa2N06OLSYzLwQsEkhC6Qu5XbcLyM/XBLMzDlgS/K7l7c5CdURLdDk9uE+hPFIogHTQ==} hasBin: true @@ -2843,18 +2904,36 @@ packages: lodash.flatten@4.4.0: resolution: {integrity: sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==} + lodash.groupby@4.6.0: + resolution: {integrity: sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==} + + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + lodash.isequal@4.5.0: resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==} + lodash.isfunction@3.0.9: + resolution: {integrity: sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw==} + + lodash.isnil@4.0.0: + resolution: {integrity: sha512-up2Mzq3545mwVnMhTDMdfoG1OurpA/s5t88JmQX809eH3C8491iu2sfKhTfhQtKY78oPNhiaHJUpT/dUDAAtng==} + lodash.isplainobject@4.0.6: resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + lodash.isundefined@3.0.1: + resolution: {integrity: sha512-MXB1is3s899/cD8jheYYE2V9qTHwKvt+npCwpD+1Sxm3Q3cECXCiYHjeHWXNwr6Q0SOBPrYUDxendrO6goVTEA==} + lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} lodash.union@4.6.0: resolution: {integrity: sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==} + lodash.uniq@4.5.0: + resolution: {integrity: sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==} + lodash@4.17.21: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} @@ -3223,6 +3302,9 @@ packages: engines: {node: ^16.14.0 || >=18.0.0} hasBin: true + pako@1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -3489,6 +3571,11 @@ packages: rfc4648@1.5.3: resolution: {integrity: sha512-MjOWxM065+WswwnmNONOT+bD1nXzY9Km6u3kzvnx8F8/HXGZdz3T6e6vZJ8Q/RIMUSp/nxqjH3GwvJDy8ijeQQ==} + rimraf@2.7.1: + resolution: {integrity: sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==} + deprecated: Rimraf versions prior to v4 are no longer supported + hasBin: true + rimraf@3.0.2: resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} deprecated: Rimraf versions prior to v4 are no longer supported @@ -3540,6 +3627,10 @@ packages: sax@1.3.0: resolution: {integrity: sha512-0s+oAmw9zLl1V1cS9BtZN7JAd0cW5e0QH4W3LWEK6a4LaLEA2OTpGYWDY+6XasBLtz6wkm3u1xRw95mRuJ59WA==} + saxes@5.0.1: + resolution: {integrity: sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw==} + engines: {node: '>=10'} + scan-chart@4.1.4: resolution: {integrity: sha512-LzFMfPxLgT7X5TCYK5Xkags3XVu3V7JAoDg7Te0A5GkSivwKA4fnwMRpdtRLiThlnspIAO2L/+lrysz1pIagmg==} @@ -3571,6 +3662,9 @@ packages: resolution: {integrity: sha512-tMNCiqYVkXIZgc2Hnoy2IvC/f8ezc5koaRFkCjrpWzGpCd3qbZXPzVy9MAZzK1ch/X0jvSkojys3oqJN0qCmdA==} engines: {node: '>= 0.4'} + setimmediate@1.0.5: + resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -3828,6 +3922,9 @@ packages: resolution: {integrity: sha512-WBx8Uy5TLtOSRtIq+M03/sKDrXCLHxwDcquSP2c43Le03/9serjQBIztjRz6FkJez9D/hleyAXTBGLwwZUw9lA==} hasBin: true + traverse@0.3.9: + resolution: {integrity: sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ==} + tree-kill@1.2.2: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true @@ -3927,6 +4024,9 @@ packages: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} engines: {node: '>= 10.0.0'} + unzipper@0.10.14: + resolution: {integrity: sha512-ti4wZj+0bQTiX2KmKWuwj7lhV+2n//uXEotUmGuQqrbVZSEGFMbI68+c6JCQ8aAmUWYvtHEz2A8K6wXvueR/6g==} + update-browserslist-db@1.1.0: resolution: {integrity: sha512-EdRAaAyk2cUE1wOf2DkEhzxqOQvFOoRJFNS6NeyJ01Gp2beMRpBAINjM2iDXE3KCuKhwnvHIQCJm6ThL2Z+HzQ==} hasBin: true @@ -3942,6 +4042,10 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + uuid@8.3.2: + resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + hasBin: true + validate-npm-package-license@3.0.4: resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} @@ -4040,6 +4144,9 @@ packages: resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==} engines: {node: '>=8.0'} + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -4735,6 +4842,25 @@ snapshots: '@eslint/js@8.57.0': {} + '@fast-csv/format@4.3.5': + dependencies: + '@types/node': 14.18.63 + lodash.escaperegexp: 4.1.2 + lodash.isboolean: 3.0.3 + lodash.isequal: 4.5.0 + lodash.isfunction: 3.0.9 + lodash.isnil: 4.0.0 + + '@fast-csv/parse@4.3.6': + dependencies: + '@types/node': 14.18.63 + lodash.escaperegexp: 4.1.2 + lodash.groupby: 4.6.0 + lodash.isfunction: 3.0.9 + lodash.isnil: 4.0.0 + lodash.isundefined: 3.0.1 + lodash.uniq: 4.5.0 + '@humanwhocodes/config-array@0.11.14': dependencies: '@humanwhocodes/object-schema': 2.0.3 @@ -5131,6 +5257,8 @@ snapshots: '@types/ms@0.7.34': {} + '@types/node@14.18.63': {} + '@types/node@18.16.0': {} '@types/node@20.14.10': @@ -5553,10 +5681,17 @@ snapshots: base64-js@1.5.1: {} + big-integer@1.6.52: {} + binary-extensions@2.2.0: {} binary-parser@2.2.1: {} + binary@0.3.0: + dependencies: + buffers: 0.1.1 + chainsaw: 0.1.0 + bl@4.1.0: dependencies: buffer: 5.7.1 @@ -5567,6 +5702,8 @@ snapshots: dependencies: bluebird: 3.7.2 + bluebird@3.4.7: {} + bluebird@3.7.2: {} boolbase@1.0.0: {} @@ -5604,11 +5741,15 @@ snapshots: buffer-from@1.1.2: {} + buffer-indexof-polyfill@1.0.2: {} + buffer@5.7.1: dependencies: base64-js: 1.5.1 ieee754: 1.2.1 + buffers@0.1.1: {} + builder-util-runtime@9.2.4: dependencies: debug: 4.3.5(supports-color@5.5.0) @@ -5682,6 +5823,10 @@ snapshots: caniuse-lite@1.0.30001640: {} + chainsaw@0.1.0: + dependencies: + traverse: 0.3.9 + chalk@1.1.3: dependencies: ansi-styles: 2.2.1 @@ -6008,6 +6153,10 @@ snapshots: dsp.js@https://codeload.github.com/corbanbrook/dsp.js/tar.gz/219600bb0346ee9a00686c9875c81123e2d8780e: {} + duplexer2@0.1.4: + dependencies: + readable-stream: 2.3.8 + duplexer@0.1.2: {} eastasianwidth@0.2.0: {} @@ -6403,6 +6552,18 @@ snapshots: eventemitter3@5.0.1: {} + exceljs@4.4.0: + dependencies: + archiver: 5.3.2 + dayjs: 1.11.11 + fast-csv: 4.3.6 + jszip: 3.10.1 + readable-stream: 3.6.2 + saxes: 5.0.1 + tmp: 0.2.1 + unzipper: 0.10.14 + uuid: 8.3.2 + exifreader@4.23.3: optionalDependencies: '@xmldom/xmldom': 0.8.10 @@ -6428,6 +6589,11 @@ snapshots: extsprintf@1.4.1: optional: true + fast-csv@4.3.6: + dependencies: + '@fast-csv/format': 4.3.5 + '@fast-csv/parse': 4.3.6 + fast-deep-equal@3.1.3: {} fast-diff@1.3.0: {} @@ -6553,6 +6719,13 @@ snapshots: fsevents@2.3.3: optional: true + fstream@1.0.12: + dependencies: + graceful-fs: 4.2.11 + inherits: 2.0.4 + mkdirp: 0.5.6 + rimraf: 2.7.1 + function-bind@1.1.2: {} function.prototype.name@1.1.6: @@ -6773,6 +6946,8 @@ snapshots: ignore@5.3.1: {} + immediate@3.0.6: {} + immutable@4.3.4: {} import-fresh@3.3.0: @@ -6995,6 +7170,13 @@ snapshots: jsonparse@1.3.1: {} + jszip@3.10.1: + dependencies: + lie: 3.3.0 + pako: 1.0.11 + readable-stream: 2.3.8 + setimmediate: 1.0.5 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -7010,6 +7192,10 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + lie@3.3.0: + dependencies: + immediate: 3.0.6 + lilconfig@2.1.0: {} lilconfig@3.0.0: {} @@ -7018,6 +7204,8 @@ snapshots: lines-and-columns@2.0.4: {} + listenercount@1.0.1: {} + lmdb@3.0.8: dependencies: msgpackr: 1.10.2 @@ -7047,14 +7235,26 @@ snapshots: lodash.flatten@4.4.0: {} + lodash.groupby@4.6.0: {} + + lodash.isboolean@3.0.3: {} + lodash.isequal@4.5.0: {} + lodash.isfunction@3.0.9: {} + + lodash.isnil@4.0.0: {} + lodash.isplainobject@4.0.6: {} + lodash.isundefined@3.0.1: {} + lodash.merge@4.6.2: {} lodash.union@4.6.0: {} + lodash.uniq@4.5.0: {} + lodash@4.17.21: {} log-symbols@4.1.0: @@ -7526,6 +7726,8 @@ snapshots: - bluebird - supports-color + pako@1.0.11: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -7771,6 +7973,10 @@ snapshots: rfc4648@1.5.3: {} + rimraf@2.7.1: + dependencies: + glob: 7.2.3 + rimraf@3.0.2: dependencies: glob: 7.2.3 @@ -7848,6 +8054,10 @@ snapshots: sax@1.3.0: {} + saxes@5.0.1: + dependencies: + xmlchars: 2.2.0 + scan-chart@4.1.4: dependencies: '@noble/hashes': 1.4.0 @@ -7892,6 +8102,8 @@ snapshots: functions-have-names: 1.2.3 has-property-descriptors: 1.0.2 + setimmediate@1.0.5: {} + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -8193,6 +8405,8 @@ snapshots: dependencies: nopt: 1.0.10 + traverse@0.3.9: {} + tree-kill@1.2.2: {} truncate-utf8-bytes@1.0.2: @@ -8295,6 +8509,19 @@ snapshots: universalify@2.0.1: {} + unzipper@0.10.14: + dependencies: + big-integer: 1.6.52 + binary: 0.3.0 + bluebird: 3.4.7 + buffer-indexof-polyfill: 1.0.2 + duplexer2: 0.1.4 + fstream: 1.0.12 + graceful-fs: 4.2.11 + listenercount: 1.0.1 + readable-stream: 2.3.8 + setimmediate: 1.0.5 + update-browserslist-db@1.1.0(browserslist@4.23.2): dependencies: browserslist: 4.23.2 @@ -8309,6 +8536,8 @@ snapshots: util-deprecate@1.0.2: {} + uuid@8.3.2: {} + validate-npm-package-license@3.0.4: dependencies: spdx-correct: 3.2.0 @@ -8411,6 +8640,8 @@ snapshots: xmlbuilder@15.1.1: {} + xmlchars@2.2.0: {} + y18n@5.0.8: {} yallist@3.1.1: {} diff --git a/src-angular/app/app-routing.module.ts b/src-angular/app/app-routing.module.ts index 7a9f467..c33b5cf 100644 --- a/src-angular/app/app-routing.module.ts +++ b/src-angular/app/app-routing.module.ts @@ -3,11 +3,13 @@ import { RouteReuseStrategy, RouterModule, Routes } from '@angular/router' import { BrowseComponent } from './components/browse/browse.component' import { SettingsComponent } from './components/settings/settings.component' +import { ToolsComponent } from './components/tools/tools.component' import { TabPersistStrategy } from './core/tab-persist.strategy' const routes: Routes = [ { path: 'browse', component: BrowseComponent, data: { shouldReuse: true } }, { path: 'library', redirectTo: '/browse' }, + { path: 'tools', component: ToolsComponent, data: { shouldReuse: true } }, { path: 'settings', component: SettingsComponent, data: { shouldReuse: true } }, { path: 'about', redirectTo: '/browse' }, { path: '**', redirectTo: '/browse' }, diff --git a/src-angular/app/components/toolbar/toolbar.component.html b/src-angular/app/components/toolbar/toolbar.component.html index dd20c41..b70ab3f 100644 --- a/src-angular/app/components/toolbar/toolbar.component.html +++ b/src-angular/app/components/toolbar/toolbar.component.html @@ -1,6 +1,7 @@ + diff --git a/src-angular/app/components/tools/tools.component.ts b/src-angular/app/components/tools/tools.component.ts new file mode 100644 index 0000000..04d0728 --- /dev/null +++ b/src-angular/app/components/tools/tools.component.ts @@ -0,0 +1,77 @@ +import { Component, ElementRef, NgZone, ViewChild } from '@angular/core' + +import { SettingsService } from 'src-angular/app/core/services/settings.service' + +@Component({ + selector: 'app-tools', + templateUrl: './tools.component.html', +}) +export class ToolsComponent { + @ViewChild('themeDropdown', { static: true }) themeDropdown: ElementRef + @ViewChild('scanErrorModal') scanErrorModal: ElementRef + + public scanning = false + public buttonText = 'Scan for issues' + public scanErrorText = '' + + constructor( + zone: NgZone, + public settingsService: SettingsService, + ) { + window.electron.on.updateIssueScan(({ status, message }) => zone.run(() => { + if (status === 'progress') { + this.buttonText = message + } else if (status === 'error') { + this.scanning = false + this.scanErrorText = message + this.scanErrorModal.nativeElement.showModal() + } else if (status === 'done') { + this.scanning = false + this.buttonText = 'Complete! (click to scan again)' + } + })) + } + + openIssueScanDirectory() { + if (this.settingsService.issueScanDirectory) { + window.electron.emit.showFolder(this.settingsService.issueScanDirectory) + } + } + + async getIssueScanDirectory() { + const result = await window.electron.invoke.showOpenDialog({ + title: 'Choose issue scan folder', + defaultPath: this.settingsService.issueScanDirectory || '', + properties: ['openDirectory'], + }) + + if (result.canceled === false) { + this.settingsService.issueScanDirectory = result.filePaths[0] + } + } + + openSpreadsheetOutputDirectory() { + if (this.settingsService.spreadsheetOutputDirectory) { + window.electron.emit.showFolder(this.settingsService.spreadsheetOutputDirectory) + } + } + + async getSpreadsheetOutputDirectory() { + const result = await window.electron.invoke.showOpenDialog({ + title: 'Choose spreadsheet output folder', + defaultPath: this.settingsService.spreadsheetOutputDirectory || '', + properties: ['openDirectory'], + }) + + if (result.canceled === false) { + this.settingsService.spreadsheetOutputDirectory = result.filePaths[0] + } + } + + async scanIssues() { + if (this.settingsService.issueScanDirectory && this.settingsService.spreadsheetOutputDirectory) { + this.scanning = true + window.electron.emit.scanIssues() + } + } +} diff --git a/src-angular/app/core/services/settings.service.ts b/src-angular/app/core/services/settings.service.ts index 505d5a6..94ca081 100644 --- a/src-angular/app/core/services/settings.service.ts +++ b/src-angular/app/core/services/settings.service.ts @@ -61,6 +61,20 @@ export class SettingsService { this.settings.libraryPath = value this.saveSettings() } + get issueScanDirectory() { + return this.settings.issueScanPath + } + set issueScanDirectory(value: string | undefined) { + this.settings.issueScanPath = value + this.saveSettings() + } + get spreadsheetOutputDirectory() { + return this.settings.spreadsheetOutputPath + } + set spreadsheetOutputDirectory(value: string | undefined) { + this.settings.spreadsheetOutputPath = value + this.saveSettings() + } get chartFolderName() { return this.settings.chartFolderName } diff --git a/src-electron/IpcHandler.ts b/src-electron/IpcHandler.ts index e9a9731..6222482 100644 --- a/src-electron/IpcHandler.ts +++ b/src-electron/IpcHandler.ts @@ -1,5 +1,6 @@ import { IpcInvokeHandlers, IpcToMainEmitHandlers } from '../src-shared/interfaces/ipc.interface.js' import { download } from './ipc/DownloadHandler.ipc.js' +import { scanIssues } from './ipc/issue-scan/IssueScanHandler.ipc.js' import { getSettings, setSettings } from './ipc/SettingsHandler.ipc.js' import { downloadUpdate, getCurrentVersion, getUpdateAvailable, quitAndInstall, retryUpdate } from './ipc/UpdateHandler.ipc.js' import { getPlatform, getThemeColors, isMaximized, maximize, minimize, openUrl, quit, restore, showFile, showFolder, showOpenDialog, toggleDevTools } from './ipc/UtilHandlers.ipc.js' @@ -31,5 +32,6 @@ export function getIpcToMainEmitHandlers(): IpcToMainEmitHandlers { quit, showFile, showFolder, + scanIssues, } } diff --git a/src-electron/ipc/SettingsHandler.ipc.ts b/src-electron/ipc/SettingsHandler.ipc.ts index e114d33..68ae27b 100644 --- a/src-electron/ipc/SettingsHandler.ipc.ts +++ b/src-electron/ipc/SettingsHandler.ipc.ts @@ -7,6 +7,7 @@ import { dataPath, settingsPath, tempPath, themesPath } from '../../src-shared/P import { defaultSettings, Settings } from '../../src-shared/Settings.js' import { mainWindow } from '../main.js' +console.log(settingsPath) export let settings = readSettings() function readSettings() { diff --git a/src-electron/ipc/issue-scan/ExcelBuilder.ts b/src-electron/ipc/issue-scan/ExcelBuilder.ts new file mode 100644 index 0000000..25239ac --- /dev/null +++ b/src-electron/ipc/issue-scan/ExcelBuilder.ts @@ -0,0 +1,240 @@ +import exceljs, { Borders } from 'exceljs' +import _ from 'lodash' +import { FolderIssueType, ScannedChart } from 'scan-chart' + +export function getChartIssues(charts: { chart: ScannedChart; path: string }[]) { + const chartIssues: { + path: string + artist: string + name: string + charter: string + errorName: string + errorDescription: string + fixMandatory: boolean + }[] = [] + + for (const chart of charts) { + const addIssue = ( + errorName: string, + errorDescription: string, + fixMandatory: boolean, + ) => { + + chartIssues.push({ + path: chart.path, + artist: removeStyleTags(chart.chart.artist ?? ''), + name: removeStyleTags(chart.chart.name ?? ''), + charter: removeStyleTags(chart.chart.charter ?? ''), + errorName, + errorDescription, + fixMandatory, + }) + } + + if (chart.chart.folderIssues.length > 0) { + for (const folderIssue of chart.chart.folderIssues) { + if (folderIssue.folderIssue === 'albumArtSize') { + continue + } // Ignored; .sng conversion fixes this + addIssue( + folderIssue.folderIssue, + folderIssue.description, + ( + [ + 'noMetadata', + 'invalidMetadata', + 'noAudio', + 'badAudio', + 'noChart', + 'invalidChart', + 'badChart', + ] satisfies FolderIssueType[] as FolderIssueType[] + ).includes(folderIssue.folderIssue), + ) + } + } + + for (const metadataIssue of chart.chart.metadataIssues) { + addIssue( + metadataIssue.metadataIssue, + metadataIssue.description, + ['"name"', '"artist"', '"charter"'].some(property => metadataIssue.description.includes(property)), + ) + } + + if (chart.chart.notesData) { + for (const issue of chart.chart.notesData.chartIssues) { + addIssue( + issue.noteIssue, + `${issue.instrument ? `[${issue.instrument}]` : ''}${issue.difficulty ? `[${issue.difficulty}]` : ''} ${issue.description}`, + issue.noteIssue === 'noNotes', + ) + } + } + } + + return chartIssues +} + +export async function getIssuesXLSX( + chartIssues: Awaited>, +) { + const chartIssueHeaders = [ + { text: 'Artist', width: 160 / 7 }, + { text: 'Name', width: 400 / 7 }, + { text: 'Charter', width: 160 / 7 }, + { text: 'Issue Name', width: 160 / 7 }, + { + text: 'Issue Description (a more detailed description of issue types can be ' + + 'found at https://drive.google.com/open?id=1UK7GsP4ZHJkOg8uREFRMY72svySaDlf0QRTGlk-ruYQ)', + width: 650 / 7, + }, + { text: 'Fix Mandatory?', width: 120 / 7 }, + { text: 'Path', width: 600 / 7 }, + ] + const chartIssueRows: (string | { text: string; hyperlink: string })[][] = [] + for (const issue of chartIssues) { + chartIssueRows.push([ + issue.artist, + issue.name, + issue.charter, + issue.errorName, + issue.errorDescription, + issue.fixMandatory ? 'yes' : 'no', + issue.path, + ]) + } + + const gridlineBorderStyle = { + top: { style: 'thin', color: { argb: 'FFD0D0D0' } }, + left: { style: 'thin', color: { argb: 'FFD0D0D0' } }, + bottom: { style: 'thin', color: { argb: 'FFD0D0D0' } }, + right: { style: 'thin', color: { argb: 'FFD0D0D0' } }, + } satisfies Partial + const workbook = new exceljs.Workbook() + workbook.creator = 'Chorus' + workbook.created = new Date() + workbook.modified = new Date() + + const chartIssuesWorksheet = workbook.addWorksheet('Chart Issues', { + views: [{ state: 'frozen', ySplit: 1 }], // Sticky header row + }) + chartIssuesWorksheet.autoFilter = { + from: { row: 1, column: 1 }, + to: { row: chartIssueRows.length + 1, column: chartIssueHeaders.length }, + } + chartIssueHeaders.forEach((header, index) => { + const cell = chartIssuesWorksheet.getCell(1, index + 1) + cell.value = header.text + cell.fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FFD3D3D3' }, + } + cell.font = { bold: true } + const column = chartIssuesWorksheet.getColumn(index + 1) + column.width = header.width + column.border = gridlineBorderStyle + }) + chartIssuesWorksheet.addRows(chartIssueRows) + chartIssuesWorksheet.addConditionalFormatting({ + ref: `A2:${columnNumberToLetter(chartIssueHeaders.length)}${chartIssueRows.length + 1 + }`, + rules: [ + { + type: 'expression', + priority: 99999, + formulae: ['MOD(ROW(),2)=0'], + style: { + fill: { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FFF7F7F7' }, + }, + }, + }, + ], + }) + + return await workbook.xlsx.writeBuffer({ useStyles: true }) +} + +export function columnNumberToLetter(column: number) { + let temp, + letter = '' + while (column > 0) { + temp = (column - 1) % 26 + letter = String.fromCharCode(temp + 65) + letter + column = (column - temp - 1) / 26 + } + return letter +} + +/** + * @returns an string representation of `ms` that looks like HH:MM:SS.mm + */ +export function msToExactTime(ms: number) { + const seconds = _.round((ms / 1000) % 60, 2) + const minutes = Math.floor((ms / 1000 / 60) % 60) + const hours = Math.floor((ms / 1000 / 60 / 60) % 24) + return `${hours ? `${hours}:` : ''}${_.padStart( + minutes + '', + 2, + '0', + )}:${_.padStart(seconds.toFixed(2), 5, '0')}` +} + +const allowedTags = [ + 'align', + 'allcaps', + 'alpha', + 'b', + 'br', + 'color', + 'cspace', + 'font', + 'font-weight', + 'gradient', + 'i', + 'indent', + 'line-height', + 'line-indent', + 'link', + 'lowercase', + 'margin', + 'mark', + 'mspace', + 'nobr', + 'noparse', + 'page', + 'pos', + 'rotate', + 's', + 'size', + 'smallcaps', + 'space', + 'sprite', + 'strikethrough', + 'style', + 'sub', + 'sup', + 'u', + 'uppercase', + 'voffset', + 'width', +] +const tagPattern = allowedTags.map(tag => `\\b${tag}\\b`).join('|') +/** + * @returns `text` with all style tags removed. (e.g. "Aren Eternal & Geo" -> "Aren Eternal & Geo") + */ +export function removeStyleTags(text: string) { + let oldText = text + let newText = text + do { + oldText = newText + newText = newText + .replace(new RegExp(`<\\s*\\/?\\s*(?:#|${tagPattern})[^>]*>`, 'gi'), '') + .trim() + } while (newText !== oldText) + return newText +} diff --git a/src-electron/ipc/issue-scan/IssueScanHandler.ipc.ts b/src-electron/ipc/issue-scan/IssueScanHandler.ipc.ts new file mode 100644 index 0000000..3c3c650 --- /dev/null +++ b/src-electron/ipc/issue-scan/IssueScanHandler.ipc.ts @@ -0,0 +1,183 @@ +import Bottleneck from 'bottleneck' +import dayjs from 'dayjs' +import { shell } from 'electron' +import { createReadStream } from 'fs' +import pkg from 'fs-extra' +import _ from 'lodash' +import { SngHeader, SngStream } from 'parse-sng' +import { scanChartFolder, ScannedChart } from 'scan-chart' +import { Readable } from 'stream' +import { inspect } from 'util' + +import { appearsToBeChartFolder, getExtension, hasAlbumName, hasChartExtension, hasIniExtension, hasSngExtension } from '../../../src-shared/UtilFunctions.js' +import { hasVideoExtension } from '../../ElectronUtilFunctions.js' +import { emitIpcEvent } from '../../main.js' +import { getSettings } from '../SettingsHandler.ipc.js' +import { getChartIssues, getIssuesXLSX } from './ExcelBuilder.js' + +const { readdir, readFile, writeFile } = pkg +export async function scanIssues() { + const settings = await getSettings() + if (!settings.issueScanPath || !settings.spreadsheetOutputPath) { + emitIpcEvent('updateIssueScan', { + status: 'error', + message: 'Scan path or output path were not properly defined.', + }) + return + } + + try { + const chartFolders = await getChartFolders(settings.issueScanPath) + + const limiter = new Bottleneck({ maxConcurrent: 20 }) // Ensures memory use stays bounded + + const charts: { chart: ScannedChart; path: string }[] = [] + for (const chartFolder of chartFolders) { + limiter.schedule(async () => { + const isSng = chartFolder.files.length === 1 && hasSngExtension(chartFolder.files[0]) + const files = isSng ? await getFilesFromSng([chartFolder.path, chartFolder.files[0]].join('/')) : await getFilesFromFolder(chartFolder) + + const result: { chart: ScannedChart; path: string } = { + chart: scanChartFolder(files), + path: chartFolder.path, + } + charts.push(result) + emitIpcEvent('updateIssueScan', { status: 'progress', message: `${charts.length}/${chartFolders.length} scanned...` }) + }) + } + + await new Promise((resolve, reject) => { + limiter.on('error', err => { + reject(err) + limiter.stop() + }) + + limiter.on('idle', async () => { + const issues = getChartIssues(charts) + const xlsx = await getIssuesXLSX(issues) + const outputPath = [settings.spreadsheetOutputPath, `chart_issues_${dayjs().format('YYYY.MM.DD_HH.mm.ss')}.xlsx`].join('/') + await writeFile(outputPath, new Uint8Array(xlsx)) + await new Promise(resolve => setTimeout(resolve, 500)) // Delay for OS file processing + await shell.openPath(outputPath) + emitIpcEvent('updateIssueScan', { + status: 'done', + message: `${issues.length} issues found in ${charts.length} charts. Spreadsheet saved to ${outputPath}`, + }) + resolve() + }) + }) + } catch (err) { + emitIpcEvent('updateIssueScan', { status: 'error', message: inspect(err) }) + } +} + +/** + * @returns valid chart folders in `path` and all its subdirectories. + */ +async function getChartFolders(path: string) { + const chartFolders: { path: string; files: string[] }[] = [] + + const entries = await readdir(path, { withFileTypes: true }) + + const subfolders = _.chain(entries) + .filter(entry => entry.isDirectory() && entry.name !== '__MACOSX') // Apple should follow the principle of least astonishment (smh) + .map(folder => getChartFolders([path, folder.name].join('/'))) + .value() + + chartFolders.push(..._.flatMap(await Promise.all(subfolders))) + + const sngFiles = entries.filter(entry => !entry.isDirectory() && hasSngExtension(entry.name)) + chartFolders.push(...sngFiles.map(sf => ({ path, files: [sf.name] }))) + + if ( + subfolders.length === 0 && // Charts won't contain other charts + appearsToBeChartFolder(entries.map(entry => getExtension(entry.name))) + ) { + chartFolders.push({ + path, + files: entries.filter(entry => !entry.isDirectory()).map(entry => entry.name), + }) + emitIpcEvent('updateIssueScan', { status: 'progress', message: `${chartFolders} charts found...` }) + } + + return chartFolders +} + +async function getFilesFromSng(sngPath: string) { + const sngStream = new SngStream(Readable.toWeb(createReadStream(sngPath)) as ReadableStream, { generateSongIni: true }) + + let header: SngHeader + sngStream.on('header', h => header = h) + const isFileTruncated = (fileName: string) => { + const MAX_FILE_MIB = 2048 + const MAX_FILES_MIB = 5000 + const sortedFiles = _.sortBy(header.fileMeta, f => f.contentsLen) + let usedSizeMib = 0 + for (const sortedFile of sortedFiles) { + usedSizeMib += Number(sortedFile.contentsLen / BigInt(1024) / BigInt(1024)) + if (sortedFile.filename === fileName) { + return usedSizeMib > MAX_FILES_MIB || sortedFile.contentsLen / BigInt(1024) / BigInt(1024) >= MAX_FILE_MIB + } + } + } + + + const files: { fileName: string; data: Uint8Array }[] = [] + + await new Promise((resolve, reject) => { + sngStream.on('file', async (fileName, fileStream, nextFile) => { + const matchingFileMeta = header.fileMeta.find(f => f.filename === fileName) + if (hasVideoExtension(fileName) || isFileTruncated(fileName) || !matchingFileMeta) { + const reader = fileStream.getReader() + // eslint-disable-next-line no-constant-condition + while (true) { + const result = await reader.read() + if (result.done) { + break + } + } + } else { + const data = new Uint8Array(Number(matchingFileMeta.contentsLen)) + let offset = 0 + const reader = fileStream.getReader() + // eslint-disable-next-line no-constant-condition + while (true) { + const result = await reader.read() + if (result.done) { + break + } + data.set(result.value, offset) + offset += result.value.length + } + + files.push({ fileName, data }) + } + + if (nextFile) { + nextFile() + } else { + resolve() + } + }) + + sngStream.on('error', error => reject(error)) + + sngStream.start() + }) + + return files +} + +async function getFilesFromFolder(chartFolder: { path: string; files: string[] }): Promise<{ fileName: string; data: Uint8Array }[]> { + const files: { fileName: string; data: Uint8Array }[] = [] + + for (const fileName of chartFolder.files) { + if (hasChartExtension(fileName) || hasIniExtension(fileName) || hasAlbumName(fileName)) { + files.push({ fileName, data: await readFile(chartFolder.path + '/' + fileName) }) + } else { + files.push({ fileName, data: new Uint8Array() }) + } + } + + return files +} diff --git a/src-electron/preload.ts b/src-electron/preload.ts index 94ca357..ee96da8 100644 --- a/src-electron/preload.ts +++ b/src-electron/preload.ts @@ -40,6 +40,7 @@ const electronApi: ContextBridgeApi = { quit: getEmitter('quit'), showFolder: getEmitter('showFolder'), showFile: getEmitter('showFile'), + scanIssues: getEmitter('scanIssues'), }, on: { errorLog: getListenerAdder('errorLog'), @@ -51,6 +52,7 @@ const electronApi: ContextBridgeApi = { queueUpdated: getListenerAdder('queueUpdated'), maximized: getListenerAdder('maximized'), minimized: getListenerAdder('minimized'), + updateIssueScan: getListenerAdder('updateIssueScan'), }, } diff --git a/src-shared/Settings.ts b/src-shared/Settings.ts index 7c33b4a..e3dd174 100644 --- a/src-shared/Settings.ts +++ b/src-shared/Settings.ts @@ -23,19 +23,21 @@ export const themes = [ * Represents Bridge's user settings. */ export interface Settings { - downloadVideos: boolean // If background videos should be downloaded - theme: typeof themes[number] // The name of the currently enabled UI theme - customTheme: ThemeColors | null // The colors of a custom theme - customThemePath: string | null // The last folder that contained the `customTheme`'s file - libraryPath: string | undefined // The path to the user's library - chartFolderName: string // The relative path and name of the chart that is saved in `libraryPath` - isSng: boolean // If the chart should be downloaded as a .sng file or as a chart folder - isCompactTable: boolean // If the search result table should have reduced padding - visibleColumns: string[] // The search result columns to include - zoomFactor: number // How much the display should be zoomed - instrument: Instrument | null // The instrument selected by default, or `null` for "Any Instrument" - difficulty: Difficulty | null // The difficulty selected by default, or `null` for "Any Difficulty" - volume: number // The volume of the chart preview (0-100) + downloadVideos: boolean // If background videos should be downloaded + theme: typeof themes[number] // The name of the currently enabled UI theme + customTheme: ThemeColors | null // The colors of a custom theme + customThemePath: string | null // The last folder that contained the `customTheme`'s file + libraryPath: string | undefined // The path to the user's library + issueScanPath: string | undefined // The path to use when scanning for issues + spreadsheetOutputPath: string | undefined // The path to use when saving generated issues + chartFolderName: string // The relative path and name of the chart that is saved in `libraryPath` + isSng: boolean // If the chart should be downloaded as a .sng file or as a chart folder + isCompactTable: boolean // If the search result table should have reduced padding + visibleColumns: string[] // The search result columns to include + zoomFactor: number // How much the display should be zoomed + instrument: Instrument | null // The instrument selected by default, or `null` for "Any Instrument" + difficulty: Difficulty | null // The difficulty selected by default, or `null` for "Any Difficulty" + volume: number // The volume of the chart preview (0-100) } /** @@ -47,6 +49,8 @@ export const defaultSettings: Settings = { customTheme: null, customThemePath: null, libraryPath: undefined, + issueScanPath: undefined, + spreadsheetOutputPath: undefined, chartFolderName: '{artist} - {name} ({charter})', isSng: false, isCompactTable: false, diff --git a/src-shared/UtilFunctions.ts b/src-shared/UtilFunctions.ts index bb23d63..6f2958e 100644 --- a/src-shared/UtilFunctions.ts +++ b/src-shared/UtilFunctions.ts @@ -207,6 +207,27 @@ export function hasChartExtension(fileName: string) { return ['chart', 'mid'].includes(getExtension(fileName).toLowerCase()) } +/** + * @returns `true` if `fileName` is a valid album fileName. + */ +export function hasAlbumName(fileName: string) { + return ['album.jpg', 'album.jpeg', 'album.png'].includes(fileName) +} + +/** + * @returns `true` if `name` has a valid sng file extension. + */ +export function hasSngExtension(name: string) { + return 'sng' === getExtension(name).toLowerCase() +} + +/** + * @returns `true` if `fileName` has a valid ini file extension. + */ +export function hasIniExtension(fileName: string) { + return 'ini' === getExtension(fileName).toLowerCase() +} + /** * @returns `true` if `fileName` is a valid chart fileName. */ @@ -246,6 +267,16 @@ export function hasAudioName(fileName: string) { ) } +/** + * @returns true if the list of filename `extensions` appears to be intended as a chart folder. + */ +export function appearsToBeChartFolder(extensions: string[]) { + const ext = extensions.map(extension => extension.toLowerCase()) + const containsNotes = ext.includes('chart') || ext.includes('mid') + const containsAudio = ext.includes('ogg') || ext.includes('mp3') || ext.includes('wav') || ext.includes('opus') + return containsNotes || containsAudio +} + export function resolveChartFolderName( chartFolderName: string, chart: { name: string; artist: string; album: string; genre: string; year: string; charter: string }, diff --git a/src-shared/interfaces/ipc.interface.ts b/src-shared/interfaces/ipc.interface.ts index 1c6102d..7996797 100644 --- a/src-shared/interfaces/ipc.interface.ts +++ b/src-shared/interfaces/ipc.interface.ts @@ -74,6 +74,7 @@ export interface IpcToMainEmitEvents { quit: void showFolder: string showFile: string + scanIssues: void } export type IpcToMainEmitHandlers = { @@ -93,6 +94,7 @@ export interface IpcFromMainEmitEvents { queueUpdated: number[] maximized: void minimized: void + updateIssueScan: { status: 'progress' | 'error' | 'done'; message: string } } export type IpcFromMainEmitHandlers = {