From a08f7678415e228ea0c9d5f0e5c35238b9c52d66 Mon Sep 17 00:00:00 2001 From: inubimambo Date: Sat, 12 Jul 2025 13:53:07 +0800 Subject: [PATCH] Add HTML & Markdown preview in dashboard and revise pages --- package-lock.json | 575 ++++++++++++++++++++++++++++ package.json | 3 + public/css/style.css | 229 +++++++++++- server.js | 871 ++++++++++++++++++++++++++++++++++--------- views/chat.ejs | 6 - views/dashboard.ejs | 91 ++++- views/revise.ejs | 85 ++++- 7 files changed, 1653 insertions(+), 207 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1b02546..0fdc099 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "body-parser": "^1.20.2", "connect-flash": "^0.1.1", "cors": "^2.8.5", + "dompurify": "^3.2.6", "dotenv": "^16.4.5", "ejs": "^3.1.10", "exceljs": "^4.4.0", @@ -21,7 +22,9 @@ "express-session": "^1.18.0", "form-data": "^4.0.3", "fs-extra": "^11.2.0", + "jsdom": "^26.1.0", "mammoth": "^1.9.1", + "marked": "^16.0.0", "multer": "^2.0.0", "pdf-parse": "^1.1.1", "uuid": "^10.0.0" @@ -30,6 +33,129 @@ "nodemon": "^3.0.1" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.0.2.tgz", + "integrity": "sha512-JqWH1vsgdGcw2RR6VliXXdA0/59LttzlU8UlRT/iUUsEeWfYq8I+K0yhihEUTTHLRm1EXvpsCx3083EU15ecsA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.0.10.tgz", + "integrity": "sha512-TiJ5Ajr6WRd1r8HSiwJvZBiJOqtH86aHpUjq5aEKWHiII2Qfjqd/HCWKPOW8EP4vcspXbHnXrwIDlu5savQipg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.0.2", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@fast-csv/format": { "version": "4.3.5", "resolved": "https://registry.npmjs.org/@fast-csv/format/-/format-4.3.5.tgz", @@ -85,6 +211,13 @@ "integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==", "license": "MIT" }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@xmldom/xmldom": { "version": "0.8.10", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.10.tgz", @@ -794,6 +927,66 @@ "node": ">= 10" } }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/data-urls/node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/data-urls/node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/dayjs": { "version": "1.11.13", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", @@ -809,6 +1002,12 @@ "ms": "2.0.0" } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "license": "MIT" + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -858,6 +1057,15 @@ "integrity": "sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==", "license": "BSD-2-Clause" }, + "node_modules/dompurify": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.6.tgz", + "integrity": "sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/dotenv": { "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", @@ -977,6 +1185,18 @@ "once": "^1.4.0" } }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -1541,6 +1761,18 @@ "node": ">= 0.4" } }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/http-errors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", @@ -1557,6 +1789,51 @@ "node": ">= 0.8" } }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/http-proxy-agent/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/http-proxy-agent/node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/http-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/https-proxy-agent": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", @@ -1719,6 +1996,12 @@ "node": ">=0.12.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "license": "MIT" + }, "node_modules/isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", @@ -1743,6 +2026,136 @@ "node": ">=10" } }, + "node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "license": "MIT", + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/jsdom/node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/jsdom/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/jsdom/node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/jsdom/node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/jsdom/node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/jsdom/node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/jsonfile": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", @@ -1944,6 +2357,12 @@ "underscore": "^1.13.1" } }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, "node_modules/make-dir": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", @@ -1992,6 +2411,18 @@ "node": ">=12.0.0" } }, + "node_modules/marked": { + "version": "16.0.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-16.0.0.tgz", + "integrity": "sha512-MUKMXDjsD/eptB7GPzxo4xcnLS6oo7/RHimUMHEDRhUooPwmN9BEpMl7AEOJv3bmso169wHI2wUF9VQgL7zfmA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -2307,6 +2738,12 @@ "set-blocking": "^2.0.0" } }, + "node_modules/nwsapi": { + "version": "2.2.20", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.20.tgz", + "integrity": "sha512-/ieB+mDe4MrrKMT8z+mQL8klXydZWGR5Dowt4RAGKbJ3kIGEx3X4ljUo+6V73IXtUPWgfOlU5B9MlGxFO5T+cA==", + "license": "MIT" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -2370,6 +2807,18 @@ "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", "license": "(MIT AND Zlib)" }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -2467,6 +2916,15 @@ "dev": true, "license": "MIT" }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/qs": { "version": "6.13.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", @@ -2588,6 +3046,12 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "license": "MIT" + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -2871,6 +3335,12 @@ "node": ">=8" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "license": "MIT" + }, "node_modules/tar": { "version": "6.2.1", "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", @@ -2916,6 +3386,24 @@ "node": ">=10" } }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "license": "MIT" + }, "node_modules/tmp": { "version": "0.2.3", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.3.tgz", @@ -2957,6 +3445,18 @@ "nodetouch": "bin/nodetouch.js" } }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", @@ -3119,12 +3619,57 @@ "node": ">= 0.8" } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", "license": "BSD-2-Clause" }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", @@ -3150,6 +3695,36 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, "node_modules/xmlbuilder": { "version": "10.1.1", "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-10.1.1.tgz", diff --git a/package.json b/package.json index 00b96f4..e79ba1c 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "body-parser": "^1.20.2", "connect-flash": "^0.1.1", "cors": "^2.8.5", + "dompurify": "^3.2.6", "dotenv": "^16.4.5", "ejs": "^3.1.10", "exceljs": "^4.4.0", @@ -20,7 +21,9 @@ "express-session": "^1.18.0", "form-data": "^4.0.3", "fs-extra": "^11.2.0", + "jsdom": "^26.1.0", "mammoth": "^1.9.1", + "marked": "^16.0.0", "multer": "^2.0.0", "pdf-parse": "^1.1.1", "uuid": "^10.0.0" diff --git a/public/css/style.css b/public/css/style.css index bf733b4..4edc7e3 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -740,20 +740,231 @@ body { border-color: rgba(255,255,255,0.3); } -/* Responsive adjustments for mobile */ +/* Rendered Markdown Styles */ +.rendered-markdown { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + line-height: 1.6; + color: #333; + max-width: 100%; + word-wrap: break-word; +} + +.rendered-markdown h1, +.rendered-markdown h2, +.rendered-markdown h3, +.rendered-markdown h4, +.rendered-markdown h5, +.rendered-markdown h6 { + margin-top: 1.5rem; + margin-bottom: 0.75rem; + font-weight: 600; + line-height: 1.25; +} + +.rendered-markdown h1 { + font-size: 1.75rem; + border-bottom: 2px solid #e1e4e8; + padding-bottom: 0.5rem; +} + +.rendered-markdown h2 { + font-size: 1.5rem; + border-bottom: 1px solid #e1e4e8; + padding-bottom: 0.3rem; +} + +.rendered-markdown h3 { + font-size: 1.25rem; +} + +.rendered-markdown h4 { + font-size: 1.1rem; +} + +.rendered-markdown h5, +.rendered-markdown h6 { + font-size: 1rem; +} + +.rendered-markdown p { + margin-bottom: 1rem; +} + +.rendered-markdown strong, +.rendered-markdown b { + font-weight: 600; +} + +.rendered-markdown em, +.rendered-markdown i { + font-style: italic; +} + +.rendered-markdown ul, +.rendered-markdown ol { + margin-bottom: 1rem; + padding-left: 2rem; +} + +.rendered-markdown li { + margin-bottom: 0.25rem; +} + +.rendered-markdown li > p { + margin-bottom: 0.5rem; +} + +.rendered-markdown ul { + list-style-type: disc; +} + +.rendered-markdown ol { + list-style-type: decimal; +} + +.rendered-markdown blockquote { + margin: 1rem 0; + padding: 0.5rem 1rem; + border-left: 4px solid #dfe2e5; + background-color: #f6f8fa; + color: #6a737d; +} + +.rendered-markdown blockquote p:last-child { + margin-bottom: 0; +} + +.rendered-markdown pre { + background-color: #f6f8fa; + border-radius: 6px; + font-size: 0.875rem; + line-height: 1.45; + overflow: auto; + padding: 1rem; + margin-bottom: 1rem; + font-family: 'Courier New', Consolas, monospace; +} + +.rendered-markdown code { + background-color: rgba(27, 31, 35, 0.05); + border-radius: 3px; + font-size: 0.875rem; + margin: 0; + padding: 0.2em 0.4em; + font-family: 'Courier New', Consolas, monospace; +} + +.rendered-markdown pre code { + background-color: transparent; + border-radius: 0; + font-size: inherit; + margin: 0; + padding: 0; + word-break: normal; + white-space: pre; + word-wrap: normal; +} + +.rendered-markdown table { + border-collapse: collapse; + margin-bottom: 1rem; + width: 100%; +} + +.rendered-markdown table th, +.rendered-markdown table td { + border: 1px solid #dfe2e5; + padding: 6px 13px; + text-align: left; +} + +.rendered-markdown table th { + background-color: #f6f8fa; + font-weight: 600; +} + +.rendered-markdown table tr:nth-child(2n) { + background-color: #f6f8fa; +} + +.rendered-markdown a { + color: #0366d6; + text-decoration: none; +} + +.rendered-markdown a:hover { + text-decoration: underline; +} + +.rendered-markdown hr { + border: none; + border-top: 1px solid #e1e4e8; + height: 1px; + margin: 1.5rem 0; +} + +.rendered-markdown img { + max-width: 100%; + height: auto; + border-radius: 6px; + margin: 0.5rem 0; +} + +/* Display mode toggle styling */ +#display-mode-toggle .btn-outline-secondary { + border-color: #6c757d; + color: #6c757d; + padding: 0.25rem 0.5rem; + font-size: 0.875rem; +} + +#display-mode-toggle .btn-check:checked + .btn-outline-secondary { + background-color: #6c757d; + border-color: #6c757d; + color: white; +} + +#display-mode-toggle .btn-outline-secondary:hover { + background-color: #5c636a; + border-color: #565e64; + color: white; +} + +/* Alert for format detection */ +.alert-sm { + padding: 0.375rem 0.75rem; + font-size: 0.875rem; +} + +/* Responsive adjustments */ @media (max-width: 768px) { - .message-text .code-block { + .rendered-markdown { + font-size: 0.9rem; + } + + .rendered-markdown h1 { + font-size: 1.5rem; + } + + .rendered-markdown h2 { + font-size: 1.3rem; + } + + .rendered-markdown h3 { + font-size: 1.1rem; + } + + .rendered-markdown pre { + padding: 0.75rem; font-size: 0.8rem; - padding: 0.5rem; } - .message-text .formatted-list { - padding-left: 1.2rem; + .rendered-markdown table { + font-size: 0.875rem; } - .message-text h3, - .message-text h4, - .message-text h5 { - font-size: 0.95rem; + .rendered-markdown ul, + .rendered-markdown ol { + padding-left: 1.5rem; } } diff --git a/server.js b/server.js index c0c006e..8cc2f07 100644 --- a/server.js +++ b/server.js @@ -17,6 +17,15 @@ const mammoth = require('mammoth'); // For .docx files const pdfParse = require('pdf-parse'); // For PDF files const ExcelJS = require('exceljs'); // For Excel files +// Markdown and HTML processing +const { marked } = require('marked'); +const createDOMPurify = require('dompurify'); +const { JSDOM } = require('jsdom'); + +// Initialize DOMPurify +const window = new JSDOM('').window; +const DOMPurify = createDOMPurify(window); + // Helper function to extract text from various document formats async function extractTextFromDocument(filePath, fileExtension) { try { @@ -705,6 +714,82 @@ app.get('/logout', (req, res) => { }); }); +// Dashboard route +app.get('/dashboard', requireAuth, async (req, res) => { + try { + // Load user files from both session and persistent storage + let allFiles = []; + let revisedFiles = []; + + // Load persistent files + try { + const persistentFiles = await loadUserFiles(req.session.userId); + const persistentRevisedFiles = await loadRevisedFiles(req.session.userId); + + allFiles = persistentFiles; + revisedFiles = persistentRevisedFiles; + + // Merge with session data (session data is more current) + const sessionFiles = req.session.uploadedFiles || []; + const sessionRevisedFiles = req.session.revisedFiles || []; + + // Update session with persistent data if session is empty + if (sessionFiles.length === 0 && persistentFiles.length > 0) { + req.session.uploadedFiles = persistentFiles; + } + + if (sessionRevisedFiles.length === 0 && persistentRevisedFiles.length > 0) { + req.session.revisedFiles = persistentRevisedFiles; + } + + // Use session data as the source of truth for display + allFiles = req.session.uploadedFiles || persistentFiles; + revisedFiles = req.session.revisedFiles || persistentRevisedFiles; + + } catch (error) { + console.error('Error loading user files for dashboard:', error); + // Fall back to session data + allFiles = req.session.uploadedFiles || []; + revisedFiles = req.session.revisedFiles || []; + } + + res.render('dashboard', { + title: 'Dashboard - EduCat', + files: allFiles, + revisedFiles: revisedFiles + }); + } catch (error) { + console.error('Dashboard error:', error); + req.flash('error', 'Error loading dashboard'); + res.redirect('/'); + } +}); + +// Chat route +app.get('/chat', requireAuth, async (req, res) => { + try { + // Load user chat history + let chatHistory = []; + try { + chatHistory = await loadChatHistory(req.session.userId); + } catch (error) { + console.error('Error loading chat history:', error); + chatHistory = []; + } + + res.render('chat', { + title: 'AI Chat - EduCat', + chatHistory: chatHistory + }); + } catch (error) { + console.error('Chat route error:', error); + res.render('chat', { + title: 'AI Chat - EduCat', + chatHistory: [] + }); + } +}); + app.get('/upload', requireAuth, (req, res) => { res.render('upload', { title: 'Upload Your Notes - EduCat' @@ -995,6 +1080,82 @@ app.post('/api/revise', requireAuth, async (req, res) => { } }); +// Chat API endpoint +app.post('/api/chat', requireAuth, async (req, res) => { + try { + const { message, history = [] } = req.body; + + if (!message || message.trim().length === 0) { + return res.json({ + success: false, + error: 'Message is required' + }); + } + + // Load existing chat history from storage + let existingHistory = []; + try { + existingHistory = await loadChatHistory(req.session.userId); + } catch (error) { + console.log('No existing chat history found, starting fresh'); + } + + // Prepare history for API call (last 10 conversations) + const recentHistory = existingHistory.slice(-10).map(conv => [ + { role: 'human', content: conv.human }, + { role: 'ai', content: conv.ai } + ]).flat(); + + // Call Flowise API for chat + const response = await axios.post(`${FLOWISE_API_URL}/${FLOWISE_CHATFLOW_ID}`, { + question: message.trim(), + history: recentHistory + }); + + const botResponse = response.data.text || response.data.answer || 'Sorry, I could not process your request.'; + + // Save the conversation to history + const conversation = { + human: message.trim(), + ai: botResponse, + timestamp: new Date().toISOString() + }; + + existingHistory.push(conversation); + await saveChatHistory(req.session.userId, existingHistory); + + res.json({ + success: true, + response: botResponse + }); + } catch (error) { + console.error('Chat error:', error); + res.json({ + success: false, + error: 'Failed to get response from AI. Please try again.', + details: error.message + }); + } +}); + +// Delete chat history endpoint +app.delete('/api/chat/history', requireAuth, async (req, res) => { + try { + await clearChatHistory(req.session.userId); + res.json({ + success: true, + message: 'Chat history cleared successfully' + }); + } catch (error) { + console.error('Error clearing chat history:', error); + res.json({ + success: false, + error: 'Failed to clear chat history', + details: error.message + }); + } +}); + // Save revised notes endpoint app.post('/api/save-revised', requireAuth, async (req, res) => { try { @@ -1177,6 +1338,56 @@ app.delete('/api/revised-files/:fileId', requireAuth, async (req, res) => { } }); +// Get revised file content with rendering options +app.get('/api/revised-files/:fileId/content', requireAuth, async (req, res) => { + try { + const fileId = req.params.fileId; + const { displayMode = 'markdown' } = req.query; + const revisedFiles = req.session.revisedFiles || []; + const file = revisedFiles.find(f => f.id === fileId); + + if (!file) { + // Try to load from persistent storage + const persistentRevisedFiles = await loadRevisedFiles(req.session.userId); + const persistentFile = persistentRevisedFiles.find(f => f.id === fileId); + + if (!persistentFile) { + return res.status(404).json({ + success: false, + error: 'File not found' + }); + } + + // Read file content + const content = await fs.readFile(persistentFile.path, 'utf8'); + + return res.json({ + success: true, + file: persistentFile, + content: content, + displayMode: displayMode + }); + } + + // Read file content + const content = await fs.readFile(file.path, 'utf8'); + + res.json({ + success: true, + file: file, + content: content, + displayMode: displayMode + }); + } catch (error) { + console.error('Error getting revised file content:', error); + res.status(500).json({ + success: false, + error: 'Failed to get file content', + details: error.message + }); + } +}); + // Get revised file info endpoint app.get('/api/revised-files/:fileId/info', requireAuth, async (req, res) => { try { @@ -1216,200 +1427,67 @@ app.get('/api/revised-files/:fileId/info', requireAuth, async (req, res) => { } }); -// ChatGPT integration routes -app.get('/chat', requireAuth, (req, res) => { - // Initialize chat history if it doesn't exist - if (!req.session.chatHistory) { - req.session.chatHistory = []; - } - - // Initialize chat session ID if it doesn't exist - if (!req.session.chatSessionId) { - req.session.chatSessionId = `educat-${req.session.userId}-${Date.now()}`; - - } - - res.render('chat', { - title: 'Chat with EduCat AI', - chatHistory: req.session.chatHistory - }); -}); - -app.post('/api/chat', requireAuth, async (req, res) => { +// Render revised notes content endpoint +app.post('/api/render-revised-content', requireAuth, async (req, res) => { try { - const { message } = req.body; + const { content, displayMode = 'markdown', autoDetect = true } = req.body; - // Initialize chat history in session if it doesn't exist - if (!req.session.chatHistory) { - req.session.chatHistory = []; + if (!content) { + return res.json({ + success: false, + error: 'No content provided' + }); } - - // Initialize or get persistent chat session ID for this user - if (!req.session.chatSessionId) { - req.session.chatSessionId = `${req.session.userId}-${Date.now()}`; + let renderedContent = ''; + let detectedFormat = 'text'; + let isMarkdownContent = false; + + // Auto-detect if content is markdown (if autoDetect is enabled) + if (autoDetect) { + isMarkdownContent = isLikelyMarkdown(content); + detectedFormat = isMarkdownContent ? 'markdown' : 'text'; } - - - - // Prepare the request payload for Flowise with sessionId and chatId - const flowisePayload = { - question: message, - sessionId: req.session.chatSessionId - }; - - // Add chatId if we have one from previous conversations - if (req.session.chatId) { - flowisePayload.chatId = req.session.chatId; + // Process content based on display mode + switch (displayMode) { + case 'html': + if (isMarkdownContent || autoDetect === false) { + // Convert markdown to safe HTML + renderedContent = markdownToSafeHtml(content); + } else { + // Just escape HTML and preserve line breaks for plain text + renderedContent = escapeHtml(content).replace(/\n/g, '
'); + } + break; + + case 'markdown': + case 'raw': + default: + // Return raw content (for markdown view or plain text) + renderedContent = content; + break; } - - - // Call Flowise API for chat with session history and sessionId - const response = await axios.post(`${FLOWISE_API_URL}/${FLOWISE_CHATFLOW_ID}`, flowisePayload); - - - - const aiResponse = response.data.text || response.data.answer || 'No response received'; - - // Save the chatId from Flowise response for future requests - if (response.data.chatId) { - req.session.chatId = response.data.chatId; - - } - - // Add the conversation to session history - req.session.chatHistory.push({ - human: message, - ai: aiResponse - }); - - // Save session explicitly since we modified it - req.session.save((err) => { - if (err) { - console.error('Error saving chat session:', err); - } - }); - - - - res.json({ - success: true, - response: aiResponse - }); - } catch (error) { - console.error('Chat error:', error); - res.status(500).json({ - success: false, - error: 'Failed to get chat response', - details: error.message - }); - } -}); - -// Get chat history from session -app.get('/api/chat/history', requireAuth, (req, res) => { - try { - const chatHistory = req.session.chatHistory || []; - - res.json({ success: true, - history: chatHistory + renderedContent: renderedContent, + displayMode: displayMode, + detectedFormat: detectedFormat, + isMarkdownContent: isMarkdownContent }); + } catch (error) { - console.error('Error getting chat history:', error); + console.error('Error rendering content:', error); res.status(500).json({ success: false, - error: 'Failed to get chat history', + error: 'Failed to render content', details: error.message }); } }); -// Clear chat history -app.delete('/api/chat/history', requireAuth, (req, res) => { - try { - req.session.chatHistory = []; - // Reset the session ID to start a fresh conversation - req.session.chatSessionId = `${req.session.userId}-${Date.now()}`; - // Clear the Flowise chatId - delete req.session.chatId; - - req.session.save((err) => { - if (err) { - console.error('Error clearing chat session:', err); - return res.status(500).json({ - success: false, - error: 'Failed to clear chat history' - }); - } - - - res.json({ - success: true, - message: 'Chat history cleared' - }); - }); - } catch (error) { - console.error('Error clearing chat history:', error); - res.status(500).json({ - success: false, - error: 'Failed to clear chat history', - details: error.message - }); - } -}); - -app.get('/dashboard', requireAuth, async (req, res) => { - try { - // Load persistent files for this user - const persistentFiles = await loadUserFiles(req.session.userId); - - // Load revised files separately - const revisedFiles = await loadRevisedFiles(req.session.userId); - - // Merge with session files (in case there are newly uploaded files not yet saved) - const sessionFiles = req.session.uploadedFiles || []; - const allFiles = [...persistentFiles]; - - // Add any session files that aren't already in persistent storage - sessionFiles.forEach(sessionFile => { - if (!persistentFiles.find(f => f.id === sessionFile.id)) { - allFiles.push(sessionFile); - } - }); - - // Merge revised files from session - const sessionRevisedFiles = req.session.revisedFiles || []; - const allRevisedFiles = [...revisedFiles]; - sessionRevisedFiles.forEach(sessionFile => { - if (!revisedFiles.find(f => f.id === sessionFile.id)) { - allRevisedFiles.push(sessionFile); - } - }); - - // Update session with merged files for current session use - req.session.uploadedFiles = allFiles; - req.session.revisedFiles = allRevisedFiles; - - res.render('dashboard', { - title: 'Dashboard - EduCat', - files: allFiles, - revisedFiles: allRevisedFiles - }); - } catch (error) { - console.error('Error loading dashboard:', error); - res.render('dashboard', { - title: 'Dashboard - EduCat', - files: req.session.uploadedFiles || [], - revisedFiles: [] - }); - } -}); - -// File management endpoints +// Get file preview endpoint (simplified for dashboard) app.get('/api/files/:fileId/preview', requireAuth, async (req, res) => { try { const fileId = req.params.fileId; @@ -1417,12 +1495,102 @@ app.get('/api/files/:fileId/preview', requireAuth, async (req, res) => { const file = files.find(f => f.id === fileId); if (!file) { - return res.status(404).json({ success: false, error: 'File not found' }); + // Try to load from persistent storage + try { + const persistentFiles = await loadUserFiles(req.session.userId); + const persistentFile = persistentFiles.find(f => f.id === fileId); + + if (!persistentFile) { + return res.status(404).json({ success: false, error: 'File not found' }); + } + + // Process the persistent file + const filePath = path.join(__dirname, persistentFile.path); + const fileExtension = path.extname(persistentFile.originalName).toLowerCase(); + + // Check if file exists + if (!await fs.pathExists(filePath)) { + return res.json({ + success: true, + file: { + id: persistentFile.id, + originalName: persistentFile.originalName, + size: persistentFile.size, + uploadDate: persistentFile.uploadDate, + content: 'File not found on disk', + previewType: 'error', + message: `File "${persistentFile.originalName}" was uploaded but the physical file is no longer available on disk. Please re-upload the file.` + } + }); + } + + // Try to extract text from the document + const extractionResult = await extractTextFromDocument(filePath, fileExtension); + + if (extractionResult.success) { + const extractedText = extractionResult.text; + const previewContent = extractedText.length > 5000 ? + extractedText.substring(0, 5000) + '\n\n... (content truncated for preview)' : + extractedText; + + return res.json({ + success: true, + file: { + id: persistentFile.id, + originalName: persistentFile.originalName, + size: persistentFile.size, + uploadDate: persistentFile.uploadDate, + content: previewContent, + previewType: 'extracted-text', + extractionInfo: { + method: extractionResult.method, + totalLength: extractionResult.extractedLength, + pages: extractionResult.pages || null, + sheets: extractionResult.sheets || null, + truncated: extractedText.length > 5000 + }, + message: `Text successfully extracted from ${fileExtension.toUpperCase()} file. ${extractedText.length > 5000 ? 'Preview truncated to first 5000 characters.' : ''}` + } + }); + } else { + return res.json({ + success: true, + file: { + id: persistentFile.id, + originalName: persistentFile.originalName, + size: persistentFile.size, + uploadDate: persistentFile.uploadDate, + content: 'Preview not available for this file type. File content is available for AI processing.', + previewType: 'extraction-failed', + message: `Failed to extract text from ${fileExtension.toUpperCase()} file: ${extractionResult.error}. The file has been uploaded and may still be usable for AI processing.` + } + }); + } + } catch (error) { + console.error('Error loading from persistent storage:', error); + return res.status(404).json({ success: false, error: 'File not found' }); + } } const filePath = path.join(__dirname, file.path); const fileExtension = path.extname(file.originalName).toLowerCase(); + // Check if file exists + if (!await fs.pathExists(filePath)) { + return res.json({ + success: true, + file: { + id: file.id, + originalName: file.originalName, + size: file.size, + uploadDate: file.uploadDate, + content: 'File not found on disk', + previewType: 'error', + message: `File "${file.originalName}" was uploaded but the physical file is no longer available on disk. Please re-upload the file.` + } + }); + } + // Try to extract text from the document const extractionResult = await extractTextFromDocument(filePath, fileExtension); @@ -1435,7 +1603,7 @@ app.get('/api/files/:fileId/preview', requireAuth, async (req, res) => { extractedText.substring(0, 5000) + '\n\n... (content truncated for preview)' : extractedText; - res.json({ + return res.json({ success: true, file: { id: file.id, @@ -1457,7 +1625,6 @@ app.get('/api/files/:fileId/preview', requireAuth, async (req, res) => { } else { // Failed to extract text, fall back to file type detection const textFormats = ['.txt', '.md', '.json', '.js', '.html', '.css', '.xml', '.csv']; - const binaryFormats = ['.pdf', '.docx', '.doc', '.xlsx', '.xls', '.pptx', '.ppt']; if (textFormats.includes(fileExtension)) { // Try reading as plain text (should have been handled by extraction, but fallback) @@ -1467,7 +1634,7 @@ app.get('/api/files/:fileId/preview', requireAuth, async (req, res) => { fileContent.substring(0, 5000) + '\n\n... (truncated)' : fileContent; - res.json({ + return res.json({ success: true, file: { id: file.id, @@ -1479,7 +1646,7 @@ app.get('/api/files/:fileId/preview', requireAuth, async (req, res) => { } }); } catch (readError) { - res.json({ + return res.json({ success: true, file: { id: file.id, @@ -1494,14 +1661,213 @@ app.get('/api/files/:fileId/preview', requireAuth, async (req, res) => { } } else { // Binary format that couldn't be processed - res.json({ + return res.json({ success: true, file: { id: file.id, originalName: file.originalName, size: file.size, uploadDate: file.uploadDate, - content: 'Text extraction failed', + content: 'Preview not available for this file type. File content is available for AI processing.', + previewType: 'extraction-failed', + message: `Failed to extract text from ${fileExtension.toUpperCase()} file: ${extractionResult.error}. The file has been uploaded and may still be usable for AI processing.` + } + }); + } + } + } catch (error) { + console.error('Error getting file preview:', error); + res.status(500).json({ + success: false, + error: 'Failed to get file preview', + details: error.message + }); + } +}); + +// Get file preview content endpoint (for notes and revisions) +app.get('/api/files/:fileId/preview-content', requireAuth, async (req, res) => { + try { + const fileId = req.params.fileId; + const { displayMode = 'markdown' } = req.query; + const files = req.session.uploadedFiles || []; + const file = files.find(f => f.id === fileId); + + if (!file) { + // Try to load from persistent storage + try { + const persistentFiles = await loadUserFiles(req.session.userId); + const persistentFile = persistentFiles.find(f => f.id === fileId); + + if (!persistentFile) { + return res.status(404).json({ success: false, error: 'File not found' }); + } + + // Process the persistent file + const filePath = path.join(__dirname, persistentFile.path); + const fileExtension = path.extname(persistentFile.originalName).toLowerCase(); + + // Check if file exists + if (!await fs.pathExists(filePath)) { + return res.json({ + success: true, + file: { + id: persistentFile.id, + originalName: persistentFile.originalName, + size: persistentFile.size, + uploadDate: persistentFile.uploadDate, + content: 'File not found on disk', + previewType: 'error', + message: `File "${persistentFile.originalName}" was uploaded but the physical file is no longer available on disk. Please re-upload the file.` + } + }); + } + + // Try to extract text from the document + const extractionResult = await extractTextFromDocument(filePath, fileExtension); + + if (extractionResult.success) { + const extractedText = extractionResult.text; + const previewContent = extractedText.length > 5000 ? + extractedText.substring(0, 5000) + '\n\n... (content truncated for preview)' : + extractedText; + + return res.json({ + success: true, + file: { + id: persistentFile.id, + originalName: persistentFile.originalName, + size: persistentFile.size, + uploadDate: persistentFile.uploadDate, + content: previewContent, + previewType: 'extracted-text', + extractionInfo: { + method: extractionResult.method, + totalLength: extractionResult.extractedLength, + pages: extractionResult.pages || null, + sheets: extractionResult.sheets || null, + truncated: extractedText.length > 5000 + }, + message: `Text successfully extracted from ${fileExtension.toUpperCase()} file. ${extractedText.length > 5000 ? 'Preview truncated to first 5000 characters.' : ''}` + } + }); + } else { + return res.json({ + success: true, + file: { + id: persistentFile.id, + originalName: persistentFile.originalName, + size: persistentFile.size, + uploadDate: persistentFile.uploadDate, + content: 'Preview not available for this file type. File content is available for AI processing.', + previewType: 'extraction-failed', + message: `Failed to extract text from ${fileExtension.toUpperCase()} file: ${extractionResult.error}. The file has been uploaded and may still be usable for AI processing.` + } + }); + } + } catch (error) { + console.error('Error loading from persistent storage:', error); + return res.status(404).json({ success: false, error: 'File not found' }); + } + } + + const filePath = path.join(__dirname, file.path); + const fileExtension = path.extname(file.originalName).toLowerCase(); + + // Check if file exists + if (!await fs.pathExists(filePath)) { + return res.json({ + success: true, + file: { + id: file.id, + originalName: file.originalName, + size: file.size, + uploadDate: file.uploadDate, + content: 'File not found on disk', + previewType: 'error', + message: `File "${file.originalName}" was uploaded but the physical file is no longer available on disk. Please re-upload the file.` + } + }); + } + + // Try to extract text from the document + const extractionResult = await extractTextFromDocument(filePath, fileExtension); + + if (extractionResult.success) { + // Successfully extracted text + const extractedText = extractionResult.text; + + // Limit preview to first 5000 characters to avoid huge responses + const previewContent = extractedText.length > 5000 ? + extractedText.substring(0, 5000) + '\n\n... (content truncated for preview)' : + extractedText; + + return res.json({ + success: true, + file: { + id: file.id, + originalName: file.originalName, + size: file.size, + uploadDate: file.uploadDate, + content: previewContent, + previewType: 'extracted-text', + extractionInfo: { + method: extractionResult.method, + totalLength: extractionResult.extractedLength, + pages: extractionResult.pages || null, + sheets: extractionResult.sheets || null, + truncated: extractedText.length > 5000 + }, + message: `Text successfully extracted from ${fileExtension.toUpperCase()} file. ${extractedText.length > 5000 ? 'Preview truncated to first 5000 characters.' : ''}` + } + }); + } else { + // Failed to extract text, fall back to file type detection + const textFormats = ['.txt', '.md', '.json', '.js', '.html', '.css', '.xml', '.csv']; + + if (textFormats.includes(fileExtension)) { + // Try reading as plain text (should have been handled by extraction, but fallback) + try { + const fileContent = await fs.readFile(filePath, 'utf-8'); + const previewContent = fileContent.length > 5000 ? + fileContent.substring(0, 5000) + '\n\n... (truncated)' : + fileContent; + + return res.json({ + success: true, + file: { + id: file.id, + originalName: file.originalName, + size: file.size, + uploadDate: file.uploadDate, + content: previewContent, + previewType: 'text' + } + }); + } catch (readError) { + return res.json({ + success: true, + file: { + id: file.id, + originalName: file.originalName, + size: file.size, + uploadDate: file.uploadDate, + content: 'File preview not available', + previewType: 'error', + message: `Error reading ${fileExtension.toUpperCase()} file: ${readError.message}` + } + }); + } + } else { + // Binary format that couldn't be processed + return res.json({ + success: true, + file: { + id: file.id, + originalName: file.originalName, + size: file.size, + uploadDate: file.uploadDate, + content: 'Preview not available for this file type. File content is available for AI processing.', previewType: 'extraction-failed', message: `Failed to extract text from ${fileExtension.toUpperCase()} file: ${extractionResult.error}. The file has been uploaded and may still be usable for AI processing.` } @@ -2338,6 +2704,147 @@ app.listen(PORT, () => { console.log(`EduCat server running on http://localhost:${PORT}`); }); +// Helper function to convert markdown to safe HTML +function markdownToSafeHtml(markdownText) { + try { + // Configure marked options for better security and features + marked.setOptions({ + gfm: true, // GitHub Flavored Markdown + breaks: true, // Convert line breaks to
+ sanitize: false, // We'll use DOMPurify instead for better control + smartLists: true, + smartypants: true, + highlight: null // No code highlighting to avoid security issues + }); + + // Convert markdown to HTML + const rawHtml = marked.parse(markdownText); + + // Sanitize the HTML with DOMPurify + const cleanHtml = DOMPurify.sanitize(rawHtml, { + ALLOWED_TAGS: [ + 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', + 'p', 'br', 'hr', + 'strong', 'b', 'em', 'i', 'u', 'mark', + 'ul', 'ol', 'li', + 'blockquote', 'pre', 'code', + 'table', 'thead', 'tbody', 'tr', 'th', 'td', + 'a', 'img', + 'div', 'span' + ], + ALLOWED_ATTR: [ + 'href', 'target', 'rel', + 'src', 'alt', 'title', + 'class', 'id', + 'width', 'height' + ], + ALLOW_DATA_ATTR: false, + FORBID_TAGS: ['script', 'object', 'embed', 'iframe', 'form', 'input'], + FORBID_ATTR: ['onclick', 'onload', 'onerror', 'style'], + ADD_ATTR: { + 'a': { 'target': '_blank', 'rel': 'noopener noreferrer' } + } + }); + + return cleanHtml; + } catch (error) { + console.error('Error converting markdown to HTML:', error); + // Return escaped plain text as fallback + return escapeHtml(markdownText); + } +} + +// Helper function to escape HTML +function escapeHtml(text) { + const map = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' + }; + return text.replace(/[&<>"']/g, function(m) { return map[m]; }); +} + +// Helper function to detect if content is likely markdown +function isLikelyMarkdown(text) { + const markdownIndicators = [ + /^#{1,6}\s+.+$/m, // Headers + /\*{1,2}[^*]+\*{1,2}/, // Bold/italic + /^[\s]*[-*+]\s+/m, // Bullet lists + /^\d+\.\s+/m, // Numbered lists + /```[\s\S]*?```/, // Code blocks + /`[^`]+`/, // Inline code + /\[.+\]\(.+\)/, // Links + /^\>.+$/m // Blockquotes + ]; + + return markdownIndicators.some(pattern => pattern.test(text)); +} + +// Chat history storage persistence +const CHAT_HISTORY_DIR = path.join(__dirname, 'data', 'chat-history'); + +// Ensure chat history directory exists +async function ensureChatHistoryDirectory() { + await fs.ensureDir(CHAT_HISTORY_DIR); +} + +// Save chat history to persistent storage +async function saveChatHistory(userId, chatHistory) { + try { + await ensureChatHistoryDirectory(); + const historyPath = path.join(CHAT_HISTORY_DIR, `chat-${userId}.json`); + await fs.writeJSON(historyPath, chatHistory, { spaces: 2 }); + } catch (error) { + console.error('Error saving chat history:', error); + throw error; + } +} + +// Load chat history from persistent storage +async function loadChatHistory(userId) { + try { + await ensureChatHistoryDirectory(); + const historyPath = path.join(CHAT_HISTORY_DIR, `chat-${userId}.json`); + + if (await fs.pathExists(historyPath)) { + return await fs.readJSON(historyPath); + } else { + return []; + } + } catch (error) { + console.error('Error loading chat history:', error); + return []; + } +} + +// Clear chat history for a user +async function clearChatHistory(userId) { + try { + await ensureChatHistoryDirectory(); + const historyPath = path.join(CHAT_HISTORY_DIR, `chat-${userId}.json`); + + if (await fs.pathExists(historyPath)) { + await fs.unlink(historyPath); + } + } catch (error) { + console.error('Error clearing chat history:', error); + throw error; + } +} + +// Initialize directory structures on startup +async function initializeDataDirectories() { + await ensureUserFilesDirectory(); + await ensureRevisedFilesDirectory(); + await ensureQuizResultsDirectory(); + await ensureChatHistoryDirectory(); +} + +// Call initialization +initializeDataDirectories().catch(console.error); + diff --git a/views/chat.ejs b/views/chat.ejs index 9c4e1e2..a9eed54 100644 --- a/views/chat.ejs +++ b/views/chat.ejs @@ -101,12 +101,6 @@ document.addEventListener('DOMContentLoaded', function() { • Explanations of complex concepts • Creating study plans and schedules -I support **rich formatting** in my responses including: -- **Bold text** and *italic text* -- \`Code snippets\` and code blocks -- Numbered lists and bullet points -- Headers and structured content - How can I assist you today?`; const formattedWelcome = formatMessage(welcomeText, true); diff --git a/views/dashboard.ejs b/views/dashboard.ejs index adc90e9..64182d6 100644 --- a/views/dashboard.ejs +++ b/views/dashboard.ejs @@ -890,25 +890,100 @@ async function previewRevisedFile(fileId) { const file = fileInfo.file; - // Download the file content - const contentResponse = await fetch(`/uploads/revised-notes/${file.filename}`); + // Get file content using the new API endpoint + const contentResponse = await fetch(`/api/revised-files/${fileId}/content`); if (!contentResponse.ok) { throw new Error('Failed to load file content'); } - const content = await contentResponse.text(); + const contentResult = await contentResponse.json(); + if (!contentResult.success) { + throw new Error('Failed to load file content'); + } + + const content = contentResult.content; const modal = new bootstrap.Modal(document.getElementById('previewModal')); + // Create preview content with display mode toggle document.getElementById('preview-content').innerHTML = ` -
- - AI-Revised Content • ${file.revisionType} • From: ${file.originalFileName || 'Unknown'} +
+
+ + AI-Revised Content • ${file.revisionType} • From: ${file.originalFileName || 'Unknown'} +
+
+ + + + + +
-
-
${escapeHtml(content)}
+
+
${escapeHtml(content)}
`; + // Add event listeners for display mode toggle + const modeMarkdown = document.getElementById('preview-mode-markdown'); + const modeHtml = document.getElementById('preview-mode-html'); + const contentContainer = document.getElementById('preview-content-container'); + + async function updatePreviewMode() { + const selectedMode = document.querySelector('input[name="previewDisplayMode"]:checked').value; + + try { + const renderResponse = await fetch('/api/render-revised-content', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + content: content, + displayMode: selectedMode, + autoDetect: true + }) + }); + + const renderResult = await renderResponse.json(); + + if (renderResult.success) { + if (selectedMode === 'html') { + let htmlContent = `
${renderResult.renderedContent}
`; + + if (renderResult.isMarkdownContent) { + htmlContent = ` +
+ Markdown formatting detected and rendered +
+ ${htmlContent} + `; + } + + contentContainer.innerHTML = htmlContent; + contentContainer.style.backgroundColor = '#ffffff'; + } else { + contentContainer.innerHTML = `
${escapeHtml(renderResult.renderedContent)}
`; + contentContainer.style.backgroundColor = '#f8f9fa'; + } + } else { + throw new Error(renderResult.error || 'Failed to render content'); + } + } catch (error) { + console.error('Error rendering preview:', error); + // Fallback to escaped text + contentContainer.innerHTML = `
${escapeHtml(content)}
`; + contentContainer.style.backgroundColor = '#f8f9fa'; + } + } + + modeMarkdown.addEventListener('change', updatePreviewMode); + modeHtml.addEventListener('change', updatePreviewMode); + modal.show(); } catch (error) { console.error('Error previewing revised file:', error); diff --git a/views/revise.ejs b/views/revise.ejs index a6c89d0..8cf67b5 100644 --- a/views/revise.ejs +++ b/views/revise.ejs @@ -35,7 +35,20 @@
-
AI-Revised Notes
+
+
AI-Revised Notes
+ +

Select a revision type and click "Revise" to see AI-enhanced notes here.

@@ -139,11 +152,72 @@ document.addEventListener('DOMContentLoaded', function() { const revisionType = document.getElementById('revision-type'); const saveBtn = document.getElementById('save-btn'); const downloadBtn = document.getElementById('download-btn'); + const displayModeToggle = document.getElementById('display-mode-toggle'); + const modeMarkdown = document.getElementById('mode-markdown'); + const modeHtml = document.getElementById('mode-html'); const fileId = '<%= file.id %>'; const content = <%- JSON.stringify(content) %>; let currentRevisedContent = ''; let currentRevisionType = ''; + let currentDisplayMode = 'markdown'; + + // Handle display mode changes + function updateDisplayMode() { + if (!currentRevisedContent) return; + + const selectedMode = document.querySelector('input[name="displayMode"]:checked').value; + currentDisplayMode = selectedMode; + + renderRevisedContent(currentRevisedContent, selectedMode); + } + + // Render revised content based on display mode + async function renderRevisedContent(content, displayMode = 'markdown') { + try { + const response = await fetch('/api/render-revised-content', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + content: content, + displayMode: displayMode, + autoDetect: true + }) + }); + + const result = await response.json(); + + if (result.success) { + if (displayMode === 'html') { + // Render as HTML + revisedContent.innerHTML = `
${result.renderedContent}
`; + + // Show format detection info if available + if (result.isMarkdownContent) { + const formatInfo = document.createElement('div'); + formatInfo.className = 'alert alert-info alert-sm mb-2'; + formatInfo.innerHTML = 'Markdown formatting detected and rendered'; + revisedContent.insertBefore(formatInfo, revisedContent.firstChild); + } + } else { + // Show as raw markdown/text + revisedContent.innerHTML = `
${escapeHtml(result.renderedContent)}
`; + } + } else { + throw new Error(result.error || 'Failed to render content'); + } + } catch (error) { + console.error('Error rendering content:', error); + // Fallback to escaped text + revisedContent.innerHTML = `
${escapeHtml(content)}
`; + } + } + + // Add event listeners for display mode toggle + modeMarkdown.addEventListener('change', updateDisplayMode); + modeHtml.addEventListener('change', updateDisplayMode); reviseBtn.addEventListener('click', async function() { const type = revisionType.value; @@ -153,6 +227,7 @@ document.addEventListener('DOMContentLoaded', function() { reviseBtn.disabled = true; revisionProgress.classList.remove('d-none'); + displayModeToggle.style.display = 'none'; // Hide toggle during processing revisedContent.innerHTML = '
Processing...

AI is processing your notes...

'; try { @@ -175,9 +250,15 @@ document.addEventListener('DOMContentLoaded', function() { console.log('Revision result:', result); if (result.success) { - revisedContent.innerHTML = '
' + escapeHtml(result.revisedContent) + '
'; currentRevisedContent = result.revisedContent; currentRevisionType = type; + + // Show display mode toggle + displayModeToggle.style.display = 'block'; + + // Render content based on current display mode + await renderRevisedContent(currentRevisedContent, currentDisplayMode); + saveBtn.disabled = false; downloadBtn.disabled = false; } else {