diff --git a/package-lock.json b/package-lock.json index f529c90..d31c2e4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,11 +37,13 @@ "liquidjs": "^10.24.0", "marked-react": "^3.0.2", "monaco-editor": "^0.55.1", + "node-scp": "^0.0.25", "pyodide": "^0.29.3", "react": "^19.2.4", "react-arborist": "^3.4.3", "react-dom": "^19.2.4", "react-hot-toast": "^2.6.0", + "rsyncwrapper": "^3.1.0", "sharp": "^0.34.5", "simple-git": "^3.31.1", "snowball-stemmers": "^0.6.0", @@ -184,6 +186,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -762,6 +765,7 @@ "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.1.tgz", "integrity": "sha512-Fa6xkSiuGKc8XC8Cn96T+TQHYj4ZZ7RdFmXA3i9xe/3hLHfwPZdM+dqfX0Cp0zQklBKhVD8Yzc8LS45rkqcwpQ==", "license": "MIT", + "peer": true, "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.23.0", @@ -838,6 +842,7 @@ "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.4.tgz", "integrity": "sha512-8y7xqG/hpB53l25CIoit9/ngxdfoG+fx+V3SHBrinnhOtLvKHRyAJJuHzkWrR4YXXLX8eXBsejgAAxHUOdW1yw==", "license": "MIT", + "peer": true, "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } @@ -859,6 +864,7 @@ "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.39.13.tgz", "integrity": "sha512-QBO8ZsgJLCbI28KdY0/oDy5NQLqOQVZCozBknxc2/7L98V+TVYFHnfaCsnGh1U+alpd2LOkStVwYY7nW2R1xbw==", "license": "MIT", + "peer": true, "dependencies": { "@codemirror/state": "^6.5.0", "crelt": "^1.0.6", @@ -954,6 +960,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" }, @@ -994,6 +1001,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" } @@ -1348,7 +1356,6 @@ "dev": true, "license": "BSD-2-Clause", "optional": true, - "peer": true, "dependencies": { "cross-dirname": "^0.1.0", "debug": "^4.3.4", @@ -1370,7 +1377,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -1387,7 +1393,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "universalify": "^2.0.0" }, @@ -1402,7 +1407,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">= 10.0.0" } @@ -3959,6 +3963,7 @@ "resolved": "https://registry.npmjs.org/@libsql/client/-/client-0.17.0.tgz", "integrity": "sha512-TLjSU9Otdpq0SpKHl1tD1Nc9MKhrsZbCFGot3EbCxRa8m1E5R1mMwoOjKMMM31IyF7fr+hPNHLpYfwbMKNusmg==", "license": "MIT", + "peer": true, "dependencies": { "@libsql/core": "^0.17.0", "@libsql/hrana-client": "^0.9.0", @@ -5165,8 +5170,7 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -5401,6 +5405,7 @@ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -5411,6 +5416,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -5518,6 +5524,7 @@ "integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.0", "@typescript-eslint/types": "8.56.0", @@ -5897,6 +5904,7 @@ "integrity": "sha512-CGJ25bc8fRi8Lod/3GHSvXRKi7nBo3kxh0ApW4yCjmrWmRmlT53B5E08XRSZRliygG0aVNxLrBEqPYdz/KcCtQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/utils": "4.0.18", "fflate": "^0.8.2", @@ -6083,6 +6091,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -6116,6 +6125,7 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -6475,6 +6485,15 @@ "dequal": "^2.0.3" } }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, "node_modules/assert-plus": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", @@ -6630,6 +6649,15 @@ "baseline-browser-mapping": "dist/cli.js" } }, + "node_modules/bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", + "license": "BSD-3-Clause", + "dependencies": { + "tweetnacl": "^0.14.3" + } + }, "node_modules/bidi-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", @@ -6692,6 +6720,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -6748,6 +6777,15 @@ "dev": true, "license": "MIT" }, + "node_modules/buildcheck": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz", + "integrity": "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==", + "optional": true, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/builder-util": { "version": "26.4.1", "resolved": "https://registry.npmjs.org/builder-util/-/builder-util-26.4.1.tgz", @@ -7364,6 +7402,20 @@ "license": "MIT", "optional": true }, + "node_modules/cpu-features": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz", + "integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==", + "hasInstallScript": true, + "optional": true, + "dependencies": { + "buildcheck": "~0.0.6", + "nan": "^2.19.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/crc": { "version": "3.8.0", "resolved": "https://registry.npmjs.org/crc/-/crc-3.8.0.tgz", @@ -7387,8 +7439,7 @@ "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", "dev": true, "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/cross-env": { "version": "10.1.0", @@ -7525,7 +7576,8 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/d3-cloud": { "version": "1.2.8", @@ -7803,6 +7855,7 @@ "integrity": "sha512-uOOBA3f+kW3o4KpSoMQ6SNpdXU7WtxlJRb9vCZgOvqhTz4b3GjcoWKstdisizNZLsylhTMv8TLHFPFW0Uxsj/g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "app-builder-lib": "26.7.0", "builder-util": "26.4.1", @@ -7904,8 +7957,7 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/dompurify": { "version": "3.3.1", @@ -8378,7 +8430,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", @@ -8399,7 +8450,6 @@ "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", @@ -8426,16 +8476,6 @@ "dev": true, "license": "MIT" }, - "node_modules/encoding": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", - "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", - "license": "MIT", - "optional": true, - "dependencies": { - "iconv-lite": "^0.6.2" - } - }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", @@ -8543,6 +8583,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -8620,6 +8661,7 @@ "integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -10127,6 +10169,7 @@ "integrity": "sha512-KDYJgZ6T2TKdU8yBfYueq5EPG/EylMsBvCaenWMJb2OXmjgczzwveRCoJ+Hgj1lXPDyasvrgneSn4GBuR1hYyA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@acemir/cssom": "^0.9.31", "@asamuzakjp/dom-selector": "^6.7.6", @@ -10462,7 +10505,6 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -11732,7 +11774,6 @@ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "minimist": "^1.2.6" }, @@ -11745,6 +11786,7 @@ "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz", "integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==", "license": "MIT", + "peer": true, "dependencies": { "dompurify": "3.2.7", "marked": "14.0.0" @@ -11787,6 +11829,13 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/nan": { + "version": "2.25.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.25.0.tgz", + "integrity": "sha512-0M90Ag7Xn5KMLLZ7zliPWP3rT90P6PN+IzVFS0VqmnPktBk3700xUVv8Ikm9EUaUE5SDWdp/BIxdENzVznpm1g==", + "license": "MIT", + "optional": true + }, "node_modules/nanoid": { "version": "5.1.6", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.6.tgz", @@ -11988,6 +12037,15 @@ "dev": true, "license": "MIT" }, + "node_modules/node-scp": { + "version": "0.0.25", + "resolved": "https://registry.npmjs.org/node-scp/-/node-scp-0.0.25.tgz", + "integrity": "sha512-vodQgKRuxRjrzwTXyRl0JsrF+mjc/P4M2jwIjg4gpdbNC3KBWptTVN9b5PqludLsN402MdKN6lGydlmXGGv1YA==", + "license": "MIT", + "dependencies": { + "ssh2": "^1.16.0" + } + }, "node_modules/nopt": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/nopt/-/nopt-8.1.0.tgz", @@ -12306,6 +12364,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -12426,7 +12485,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "commander": "^9.4.0" }, @@ -12444,7 +12502,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "engines": { "node": "^12.20.0 || >=14" } @@ -12465,7 +12522,6 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -12481,7 +12537,6 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -12636,6 +12691,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz", "integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==", "license": "MIT", + "peer": true, "dependencies": { "orderedmap": "^2.0.0" } @@ -12669,6 +12725,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-transform": "^1.0.0", @@ -12702,6 +12759,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.6.tgz", "integrity": "sha512-mxpcDG4hNQa/CPtzxjdlir5bJFDlm0/x5nGBbStB2BWX+XOQ9M8ekEG+ojqB5BcVu2Rc80/jssCMZzSstJuSYg==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-model": "^1.20.0", "prosemirror-state": "^1.0.0", @@ -12792,6 +12850,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -12857,6 +12916,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -12886,8 +12946,7 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/react-refresh": { "version": "0.18.0", @@ -13192,7 +13251,6 @@ "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "glob": "^7.1.3" }, @@ -13270,6 +13328,15 @@ "integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==", "license": "MIT" }, + "node_modules/rsyncwrapper": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/rsyncwrapper/-/rsyncwrapper-3.1.0.tgz", + "integrity": "sha512-lIygqLFXwNn4TzK6zqij7Ey70+pRzcqOZFAXsJYUdw5rOeAP8ce1Xqt0BZQFUCTZCPBTOLc1wDfCzEk0T9Nxng==", + "license": "MIT", + "engines": { + "node": ">=8.1.3" + } + }, "node_modules/rxjs": { "version": "7.8.2", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", @@ -13305,7 +13372,6 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "devOptional": true, "license": "MIT" }, "node_modules/sanitize-filename": { @@ -13672,6 +13738,23 @@ "license": "BSD-3-Clause", "optional": true }, + "node_modules/ssh2": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz", + "integrity": "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==", + "hasInstallScript": true, + "dependencies": { + "asn1": "^0.2.6", + "bcrypt-pbkdf": "^1.0.2" + }, + "engines": { + "node": ">=10.16.0" + }, + "optionalDependencies": { + "cpu-features": "~0.0.10", + "nan": "^2.23.0" + } + }, "node_modules/ssri": { "version": "12.0.0", "resolved": "https://registry.npmjs.org/ssri/-/ssri-12.0.0.tgz", @@ -13935,7 +14018,6 @@ "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "mkdirp": "^0.5.1", "rimraf": "~2.6.2" @@ -14215,7 +14297,8 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "devOptional": true, - "license": "0BSD" + "license": "0BSD", + "peer": true }, "node_modules/tsx": { "version": "4.21.0", @@ -14730,6 +14813,12 @@ "@mixmark-io/domino": "^2.2.0" } }, + "node_modules/tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", + "license": "Unlicense" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -14765,6 +14854,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -15063,6 +15153,7 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -15622,6 +15713,7 @@ "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.0.18", "@vitest/mocker": "4.0.18", @@ -15699,6 +15791,7 @@ "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.28.tgz", "integrity": "sha512-BRdrNfeoccSoIZeIhyPBfvWSLFP4q8J3u8Ju8Ug5vu3LdD+yTM13Sg4sKtljxozbnuMu1NB1X5HBHRYUzFocKg==", "license": "MIT", + "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.28", "@vue/compiler-sfc": "3.5.28", diff --git a/package.json b/package.json index 282b31f..e804131 100644 --- a/package.json +++ b/package.json @@ -97,11 +97,13 @@ "liquidjs": "^10.24.0", "marked-react": "^3.0.2", "monaco-editor": "^0.55.1", + "node-scp": "^0.0.25", "pyodide": "^0.29.3", "react": "^19.2.4", "react-arborist": "^3.4.3", "react-dom": "^19.2.4", "react-hot-toast": "^2.6.0", + "rsyncwrapper": "^3.1.0", "sharp": "^0.34.5", "simple-git": "^3.31.1", "snowball-stemmers": "^0.6.0", diff --git a/src/main/engine/PublishEngine.ts b/src/main/engine/PublishEngine.ts new file mode 100644 index 0000000..10e38d7 --- /dev/null +++ b/src/main/engine/PublishEngine.ts @@ -0,0 +1,370 @@ +import { EventEmitter } from 'events'; +import path from 'path'; +import fs from 'fs/promises'; +import { constants as fsConstants } from 'fs'; +import { Client as scpClient, type ScpClient } from 'node-scp'; +import rsync from 'rsyncwrapper'; + +export interface PublishCredentials { + sshHost: string; + sshUser: string; + sshRemotePath: string; + sshMode: 'scp' | 'rsync'; +} + +export interface PublishResult { + htmlFilesUploaded: number; + thumbnailFilesUploaded: number; + mediaFilesUploaded: number; + filesSkipped: number; +} + +type ProgressCallback = (progress: number, message: string) => void; + +/** Files with these extensions are excluded from media uploads (metadata sidecars). */ +const META_EXTENSION = '.meta'; + +export class PublishEngine extends EventEmitter { + private projectId: string | null = null; + private dataDir: string | null = null; + + constructor() { + super(); + } + + setProjectContext(projectId: string, dataDir: string): void { + this.projectId = projectId; + this.dataDir = dataDir; + } + + async uploadSite( + credentials: PublishCredentials, + onProgress: ProgressCallback, + ): Promise { + if (!this.dataDir || !this.projectId) { + throw new Error('No project context set'); + } + this.validateCredentials(credentials); + + const htmlDir = path.join(this.dataDir, 'html'); + const thumbnailsDir = path.join(this.dataDir, 'thumbnails'); + const mediaDir = path.join(this.dataDir, 'media'); + + // Verify the generated site exists + await this.ensureDirectoryExists(htmlDir, 'Generated site not found. Please render the site first.'); + + const result: PublishResult = { + htmlFilesUploaded: 0, + thumbnailFilesUploaded: 0, + mediaFilesUploaded: 0, + filesSkipped: 0, + }; + + if (credentials.sshMode === 'rsync') { + await this.uploadViaRsync(credentials, htmlDir, thumbnailsDir, mediaDir, result, onProgress); + } else { + await this.uploadViaScp(credentials, htmlDir, thumbnailsDir, mediaDir, result, onProgress); + } + + onProgress(100, 'Upload complete'); + return result; + } + + // ── SCP mode ────────────────────────────────────────────────────────────── + + private async uploadViaScp( + credentials: PublishCredentials, + htmlDir: string, + thumbnailsDir: string, + mediaDir: string, + result: PublishResult, + onProgress: ProgressCallback, + ): Promise { + const client = await scpClient({ + host: credentials.sshHost, + username: credentials.sshUser, + agent: process.env.SSH_AUTH_SOCK, + }); + + try { + // Phase 1: html/ → remote root (0–33%) + onProgress(0, 'Uploading HTML files...'); + const htmlResult = await this.scpUploadDirectory( + client, + htmlDir, + credentials.sshRemotePath, + (p, msg) => onProgress(Math.round(p * 0.33), msg), + ); + result.htmlFilesUploaded = htmlResult.uploaded; + result.filesSkipped += htmlResult.skipped; + + // Phase 2: thumbnails/ → remote/thumbnails/ (33–66%) + onProgress(33, 'Uploading thumbnails...'); + if (await this.directoryExists(thumbnailsDir)) { + const thumbResult = await this.scpUploadDirectory( + client, + thumbnailsDir, + path.posix.join(credentials.sshRemotePath, 'thumbnails'), + (p, msg) => onProgress(33 + Math.round(p * 0.33), msg), + ); + result.thumbnailFilesUploaded = thumbResult.uploaded; + result.filesSkipped += thumbResult.skipped; + } + + // Phase 3: media/ → remote/media/ (66–99%), excluding .meta files + onProgress(66, 'Uploading media files...'); + if (await this.directoryExists(mediaDir)) { + const mediaResult = await this.scpUploadDirectory( + client, + mediaDir, + path.posix.join(credentials.sshRemotePath, 'media'), + (p, msg) => onProgress(66 + Math.round(p * 0.33), msg), + (name) => !name.endsWith(META_EXTENSION), + ); + result.mediaFilesUploaded = mediaResult.uploaded; + result.filesSkipped += mediaResult.skipped; + } + } finally { + client.close(); + } + } + + /** + * Recursively upload a local directory to a remote path via SCP/SFTP. + * Only uploads files that are newer than the remote version. + */ + private async scpUploadDirectory( + client: ScpClient, + localDir: string, + remoteDir: string, + onProgress: ProgressCallback, + fileFilter?: (name: string) => boolean, + ): Promise<{ uploaded: number; skipped: number }> { + // Collect all files first for progress tracking + const files = await this.collectFiles(localDir, '', fileFilter); + let uploaded = 0; + let skipped = 0; + + if (files.length === 0) { + onProgress(100, 'No files to upload'); + return { uploaded: 0, skipped: 0 }; + } + + // Ensure remote directory exists + await this.scpEnsureDir(client, remoteDir); + + // Track created directories to avoid redundant mkdir calls + const createdDirs = new Set(); + + for (let i = 0; i < files.length; i++) { + const relativePath = files[i]; + const localPath = path.join(localDir, relativePath); + const remotePath = path.posix.join(remoteDir, relativePath.split(path.sep).join('/')); + + // Ensure parent directory exists on remote + const remoteParent = path.posix.dirname(remotePath); + if (!createdDirs.has(remoteParent)) { + await this.scpEnsureDir(client, remoteParent); + createdDirs.add(remoteParent); + } + + // Check if we need to upload (compare mtime) + const localStat = await fs.stat(localPath); + const needsUpload = await this.scpNeedsUpload(client, remotePath, localStat.mtimeMs); + + if (needsUpload) { + await client.uploadFile(localPath, remotePath); + uploaded++; + } else { + skipped++; + } + + const progress = ((i + 1) / files.length) * 100; + onProgress(progress, `${uploaded} uploaded, ${skipped} skipped (${i + 1}/${files.length})`); + } + + return { uploaded, skipped }; + } + + /** + * Check if a local file is newer than the remote file. + * Returns true if upload is needed. + */ + private async scpNeedsUpload( + client: ScpClient, + remotePath: string, + localMtimeMs: number, + ): Promise { + try { + const remoteStat = await client.stat(remotePath); + // SSH2 Stats.mtime is in seconds, localMtimeMs is in milliseconds + const remoteMtimeMs = remoteStat.mtime * 1000; + return localMtimeMs > remoteMtimeMs; + } catch { + // File doesn't exist on remote → needs upload + return true; + } + } + + private async scpEnsureDir(client: ScpClient, remoteDir: string): Promise { + try { + await client.mkdir(remoteDir, undefined, { recursive: true }); + } catch { + // Directory may already exist + } + } + + // ── rsync mode ──────────────────────────────────────────────────────────── + + private async uploadViaRsync( + credentials: PublishCredentials, + htmlDir: string, + thumbnailsDir: string, + mediaDir: string, + result: PublishResult, + onProgress: ProgressCallback, + ): Promise { + const remoteDest = `${credentials.sshUser}@${credentials.sshHost}:${credentials.sshRemotePath}`; + + // Phase 1: html/ → remote root (0–33%) + onProgress(0, 'Syncing HTML files via rsync...'); + const htmlCount = await this.rsyncDirectory( + htmlDir + '/', + remoteDest + '/', + ); + result.htmlFilesUploaded = htmlCount; + onProgress(33, 'HTML sync complete'); + + // Phase 2: thumbnails/ → remote/thumbnails/ (33–66%) + if (await this.directoryExists(thumbnailsDir)) { + onProgress(33, 'Syncing thumbnails via rsync...'); + const thumbCount = await this.rsyncDirectory( + thumbnailsDir + '/', + remoteDest + '/thumbnails/', + ); + result.thumbnailFilesUploaded = thumbCount; + } + onProgress(66, 'Thumbnails sync complete'); + + // Phase 3: media/ → remote/media/ (66–99%), excluding .meta files + if (await this.directoryExists(mediaDir)) { + onProgress(66, 'Syncing media files via rsync...'); + const mediaCount = await this.rsyncDirectory( + mediaDir + '/', + remoteDest + '/media/', + ['*.meta'], + ); + result.mediaFilesUploaded = mediaCount; + } + onProgress(99, 'Media sync complete'); + } + + /** + * Run rsync for a directory with incremental (--update --times) and recursive transfer. + * Returns estimated file count from stdout. + */ + private rsyncDirectory( + src: string, + dest: string, + exclude?: string[], + ): Promise { + return new Promise((resolve, reject) => { + rsync( + { + src, + dest, + ssh: true, + recursive: true, + times: true, + args: ['--update', '--compress'], + exclude: exclude || [], + }, + (error, stdout, _stderr, _cmd) => { + if (error) { + reject(error); + } else { + // Count uploaded files from rsync stdout (each transferred file gets a line) + const lines = stdout.trim().split('\n').filter((l: string) => l.length > 0); + resolve(lines.length); + } + }, + ); + }); + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + /** + * Recursively collect all file paths relative to baseDir, with optional filter. + */ + private async collectFiles( + baseDir: string, + prefix: string, + filter?: (name: string) => boolean, + ): Promise { + const files: string[] = []; + let entries; + try { + entries = await fs.readdir(baseDir, { withFileTypes: true }); + } catch { + return files; + } + + for (const entry of entries) { + const relativePath = prefix ? path.join(prefix, entry.name) : entry.name; + if (entry.isDirectory()) { + const subFiles = await this.collectFiles( + path.join(baseDir, entry.name), + relativePath, + filter, + ); + files.push(...subFiles); + } else if (entry.isFile()) { + if (!filter || filter(entry.name)) { + files.push(relativePath); + } + } + } + + return files; + } + + private validateCredentials(credentials: PublishCredentials): void { + if (!credentials.sshHost?.trim()) { + throw new Error('SSH host is required'); + } + if (!credentials.sshUser?.trim()) { + throw new Error('SSH user is required'); + } + if (!credentials.sshRemotePath?.trim()) { + throw new Error('Remote path is required'); + } + } + + private async ensureDirectoryExists(dirPath: string, errorMessage: string): Promise { + try { + await fs.access(dirPath, fsConstants.F_OK); + } catch { + throw new Error(errorMessage); + } + } + + private async directoryExists(dirPath: string): Promise { + try { + await fs.access(dirPath, fsConstants.F_OK); + return true; + } catch { + return false; + } + } +} + +// Singleton +let publishEngine: PublishEngine | null = null; + +export function getPublishEngine(): PublishEngine { + if (!publishEngine) { + publishEngine = new PublishEngine(); + } + return publishEngine; +} diff --git a/src/main/engine/index.ts b/src/main/engine/index.ts index f6f0559..e00edd2 100644 --- a/src/main/engine/index.ts +++ b/src/main/engine/index.ts @@ -108,3 +108,9 @@ export { type CreateScriptInput, type UpdateScriptInput, } from './ScriptEngine'; +export { + PublishEngine, + getPublishEngine, + type PublishCredentials, + type PublishResult, +} from './PublishEngine'; diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index a67b900..5bcfba8 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -18,6 +18,7 @@ import { APP_MENU_ACTION_EVENT_MAP, APP_MENU_WEB_CONTENTS_ACTIONS, type AppMenuA import { generateBlogmarkBookmarkletSource } from '../shared/blogmark'; import { registerMetadataDiffHandlers } from './metadataDiffHandlers'; import { registerBlogHandlers } from './blogHandlers'; +import { registerPublishHandlers } from './publishHandlers'; /** * Wrap an IPC handler so that "Database is closing" errors during shutdown @@ -1433,6 +1434,7 @@ export function registerIpcHandlers(): void { registerMetadataDiffHandlers(safeHandle); registerBlogHandlers(safeHandle); + registerPublishHandlers(safeHandle); // ============ Event Forwarding ============ diff --git a/src/main/ipc/publishHandlers.ts b/src/main/ipc/publishHandlers.ts new file mode 100644 index 0000000..843dfd9 --- /dev/null +++ b/src/main/ipc/publishHandlers.ts @@ -0,0 +1,30 @@ +import { getProjectEngine } from '../engine/ProjectEngine'; +import { getPublishEngine, type PublishCredentials } from '../engine/PublishEngine'; +import { taskManager } from '../engine/TaskManager'; + +type SafeHandle = (channel: string, handler: (...args: any[]) => Promise) => void; + +export function registerPublishHandlers(safeHandle: SafeHandle): void { + safeHandle('publish:uploadSite', async (_event: unknown, credentials: PublishCredentials) => { + const projectEngine = getProjectEngine(); + const project = await projectEngine.getActiveProject(); + if (!project) { + throw new Error('No active project'); + } + + const publishEngine = getPublishEngine(); + publishEngine.setProjectContext(project.id, project.dataPath!); + + return taskManager.runTask({ + id: `publish-upload-${Date.now()}`, + name: 'Upload Site', + groupId: 'publish', + groupName: 'Site Publishing', + execute: async (onProgress) => { + return publishEngine.uploadSite(credentials, (progress, message) => { + onProgress(progress, message); + }); + }, + }); + }); +} diff --git a/src/main/preload.ts b/src/main/preload.ts index 0b0e5d4..d732a34 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -272,6 +272,12 @@ export const electronAPI: ElectronAPI = { regenerateCalendar: () => ipcRenderer.invoke('blog:regenerateCalendar'), }, + // Site publishing (SCP/rsync) + publish: { + uploadSite: (credentials: { sshHost: string; sshUser: string; sshRemotePath: string; sshMode: 'scp' | 'rsync' }) => + ipcRenderer.invoke('publish:uploadSite', credentials), + }, + menu: { get: () => ipcRenderer.invoke('menu:get'), save: (menu: import('./shared/electronApi').MenuDocument) => ipcRenderer.invoke('menu:save', menu), diff --git a/src/main/shared/electronApi.ts b/src/main/shared/electronApi.ts index 9f1445c..e963fa8 100644 --- a/src/main/shared/electronApi.ts +++ b/src/main/shared/electronApi.ts @@ -709,6 +709,19 @@ export interface ElectronAPI { applyValidation: (report: SiteValidationReport) => Promise; regenerateCalendar: () => Promise; }; + publish: { + uploadSite: (credentials: { + sshHost: string; + sshUser: string; + sshRemotePath: string; + sshMode: 'scp' | 'rsync'; + }) => Promise<{ + htmlFilesUploaded: number; + thumbnailFilesUploaded: number; + mediaFilesUploaded: number; + filesSkipped: number; + }>; + }; menu: { get: () => Promise; save: (menu: MenuDocument) => Promise; diff --git a/src/main/shared/i18n/locales/de.json b/src/main/shared/i18n/locales/de.json index 0a7fe1f..63d9d82 100644 --- a/src/main/shared/i18n/locales/de.json +++ b/src/main/shared/i18n/locales/de.json @@ -41,6 +41,7 @@ "menu.item.generateSitemap": "Site rendern", "menu.item.regenerateCalendar": "Kalender neu erzeugen", "menu.item.validateSite": "Website validieren", + "menu.item.uploadSite": "Website hochladen", "menu.item.about": "Über Blogging Desktop Server", "menu.item.openDocumentation": "Dokumentation öffnen", "menu.item.openApiDocumentation": "API-Dokumentation", diff --git a/src/main/shared/i18n/locales/en.json b/src/main/shared/i18n/locales/en.json index 6a83c6c..152fe03 100644 --- a/src/main/shared/i18n/locales/en.json +++ b/src/main/shared/i18n/locales/en.json @@ -41,6 +41,7 @@ "menu.item.generateSitemap": "Render Site", "menu.item.regenerateCalendar": "Regenerate Calendar", "menu.item.validateSite": "Validate Site", + "menu.item.uploadSite": "Upload Site", "menu.item.about": "About Blogging Desktop Server", "menu.item.openDocumentation": "Open Documentation", "menu.item.openApiDocumentation": "API documentation", diff --git a/src/main/shared/i18n/locales/es.json b/src/main/shared/i18n/locales/es.json index 6c32180..032ca19 100644 --- a/src/main/shared/i18n/locales/es.json +++ b/src/main/shared/i18n/locales/es.json @@ -41,6 +41,7 @@ "menu.item.generateSitemap": "Renderizar sitio", "menu.item.regenerateCalendar": "Regenerar calendario", "menu.item.validateSite": "Validar sitio", + "menu.item.uploadSite": "Subir sitio", "menu.item.about": "Acerca de Blogging Desktop Server", "menu.item.openDocumentation": "Abrir documentación", "menu.item.openApiDocumentation": "Documentación API", diff --git a/src/main/shared/i18n/locales/fr.json b/src/main/shared/i18n/locales/fr.json index 17f1f93..cfd6f61 100644 --- a/src/main/shared/i18n/locales/fr.json +++ b/src/main/shared/i18n/locales/fr.json @@ -41,6 +41,7 @@ "menu.item.generateSitemap": "Rendre le site", "menu.item.regenerateCalendar": "Régénérer le calendrier", "menu.item.validateSite": "Valider le site", + "menu.item.uploadSite": "Publier le site", "menu.item.about": "À propos de Blogging Desktop Server", "menu.item.openDocumentation": "Ouvrir la documentation", "menu.item.openApiDocumentation": "Documentation API", diff --git a/src/main/shared/i18n/locales/it.json b/src/main/shared/i18n/locales/it.json index 15d4e2b..dfb4e28 100644 --- a/src/main/shared/i18n/locales/it.json +++ b/src/main/shared/i18n/locales/it.json @@ -41,6 +41,7 @@ "menu.item.generateSitemap": "Renderizza sito", "menu.item.regenerateCalendar": "Rigenera calendario", "menu.item.validateSite": "Valida sito", + "menu.item.uploadSite": "Carica sito", "menu.item.about": "Informazioni su Blogging Desktop Server", "menu.item.openDocumentation": "Apri documentazione", "menu.item.openApiDocumentation": "Documentazione API", diff --git a/src/main/shared/menuCommands.ts b/src/main/shared/menuCommands.ts index 4125d5a..5a991a2 100644 --- a/src/main/shared/menuCommands.ts +++ b/src/main/shared/menuCommands.ts @@ -36,6 +36,7 @@ export type AppMenuAction = | 'generateSitemap' | 'regenerateCalendar' | 'validateSite' + | 'uploadSite' | 'openDocumentation' | 'openApiDocumentation' | 'about' @@ -132,6 +133,8 @@ export const APP_MENU_GROUPS: AppMenuGroupDefinition[] = [ { label: 'menu.item.generateSitemap', action: 'generateSitemap', accelerator: 'CmdOrCtrl+R' }, { label: 'menu.item.regenerateCalendar', action: 'regenerateCalendar' }, { label: 'menu.item.validateSite', action: 'validateSite', accelerator: 'CmdOrCtrl+Shift+L' }, + { label: '', action: 'blog-separator-4', separator: true }, + { label: 'menu.item.uploadSite', action: 'uploadSite', accelerator: 'CmdOrCtrl+Shift+U' }, ], }, { @@ -169,6 +172,7 @@ export const APP_MENU_ACTION_EVENT_MAP: Partial> = generateSitemap: 'menu:generateSitemap', regenerateCalendar: 'menu:regenerateCalendar', validateSite: 'menu:validateSite', + uploadSite: 'menu:uploadSite', openDocumentation: 'menu:openDocumentation', openApiDocumentation: 'menu:openApiDocumentation', about: 'menu:about', diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 1515ec0..8b5cc60 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -495,6 +495,27 @@ const App: React.FC = () => { }) || (() => {}) ); + unsubscribers.push( + window.electronAPI?.on('menu:uploadSite', async () => { + try { + const stored = localStorage.getItem('bds-credentials'); + if (!stored) { + showToast.error(tr('app.uploadSiteNoCredentials')); + return; + } + const credentials = JSON.parse(stored); + if (!credentials.sshHost || !credentials.sshUser || !credentials.sshRemotePath) { + showToast.error(tr('app.uploadSiteNoCredentials')); + return; + } + await window.electronAPI?.publish.uploadSite(credentials); + } catch (error) { + console.error('Site upload failed:', error); + showToast.error(tr('app.uploadSiteFailed')); + } + }) || (() => {}) + ); + unsubscribers.push( window.electronAPI?.on('menu:openDocumentation', () => { openSingletonToolTab(openTab, 'documentation'); diff --git a/src/renderer/i18n/locales/de.json b/src/renderer/i18n/locales/de.json index 50f030c..caf3085 100644 --- a/src/renderer/i18n/locales/de.json +++ b/src/renderer/i18n/locales/de.json @@ -32,6 +32,8 @@ "app.textReindexFailed": "Text-Neuindizierung fehlgeschlagen", "app.sitemapGenerationFailed": "Sitemap-Erstellung fehlgeschlagen", "app.calendarRegenerationFailed": "Kalender-Neuerstellung fehlgeschlagen", + "app.uploadSiteFailed": "Website-Upload fehlgeschlagen", + "app.uploadSiteNoCredentials": "Bitte konfigurieren Sie zuerst die SSH-Zugangsdaten in den Einstellungen.", "app.previewOpenFailed": "Ausgewählte Beitragsvorschau konnte nicht geöffnet werden", "app.metadataDiff": "Metadaten-Diff", "app.importComplete": "Import abgeschlossen: {posts} Beiträge, {media} Mediendateien", diff --git a/src/renderer/i18n/locales/en.json b/src/renderer/i18n/locales/en.json index a1d8c36..7ec189e 100644 --- a/src/renderer/i18n/locales/en.json +++ b/src/renderer/i18n/locales/en.json @@ -32,6 +32,8 @@ "app.textReindexFailed": "Text reindex failed", "app.sitemapGenerationFailed": "Sitemap generation failed", "app.calendarRegenerationFailed": "Calendar regeneration failed", + "app.uploadSiteFailed": "Site upload failed", + "app.uploadSiteNoCredentials": "Please configure SSH publishing credentials in Settings first.", "app.previewOpenFailed": "Failed to open selected post preview", "app.metadataDiff": "Metadata Diff", "app.importComplete": "Import complete: {posts} posts, {media} media files", diff --git a/src/renderer/i18n/locales/es.json b/src/renderer/i18n/locales/es.json index 3739104..5904622 100644 --- a/src/renderer/i18n/locales/es.json +++ b/src/renderer/i18n/locales/es.json @@ -32,6 +32,8 @@ "app.textReindexFailed": "La reindexación de texto falló", "app.sitemapGenerationFailed": "La generación del sitemap falló", "app.calendarRegenerationFailed": "La regeneración del calendario falló", + "app.uploadSiteFailed": "Error al subir el sitio", + "app.uploadSiteNoCredentials": "Configure primero las credenciales SSH en Configuración.", "app.previewOpenFailed": "No se pudo abrir la vista previa de la entrada seleccionada", "app.metadataDiff": "Diferencia de Metadatos", "app.importComplete": "Importación completada: {posts} entradas, {media} archivos multimedia", diff --git a/src/renderer/i18n/locales/fr.json b/src/renderer/i18n/locales/fr.json index 75cb996..6a2d867 100644 --- a/src/renderer/i18n/locales/fr.json +++ b/src/renderer/i18n/locales/fr.json @@ -31,8 +31,8 @@ "app.databaseRebuildFailed": "Échec de la reconstruction de la base de données", "app.textReindexFailed": "Échec de la réindexation du texte", "app.sitemapGenerationFailed": "Échec de la génération du sitemap", - "app.calendarRegenerationFailed": "Échec de la régénération du calendrier", - "app.previewOpenFailed": "Impossible d’ouvrir l’aperçu de l’article sélectionné", + "app.calendarRegenerationFailed": "Échec de la régénération du calendrier", "app.uploadSiteFailed": "Échec de la publication du site", + "app.uploadSiteNoCredentials": "Veuillez d'abord configurer les identifiants SSH dans les paramètres.", "app.previewOpenFailed": "Impossible d’ouvrir l’aperçu de l’article sélectionné", "app.metadataDiff": "Diff Métadonnées", "app.importComplete": "Import terminé : {posts} articles, {media} fichiers média", "siteValidation.tabTitle": "Validation du site", diff --git a/src/renderer/i18n/locales/it.json b/src/renderer/i18n/locales/it.json index b7e9a4c..9c24215 100644 --- a/src/renderer/i18n/locales/it.json +++ b/src/renderer/i18n/locales/it.json @@ -31,8 +31,8 @@ "app.databaseRebuildFailed": "Ricostruzione database non riuscita", "app.textReindexFailed": "Reindicizzazione testo non riuscita", "app.sitemapGenerationFailed": "Generazione sitemap non riuscita", - "app.calendarRegenerationFailed": "Rigenerazione del calendario non riuscita", - "app.previewOpenFailed": "Impossibile aprire l’anteprima del post selezionato", + "app.calendarRegenerationFailed": "Rigenerazione del calendario non riuscita", "app.uploadSiteFailed": "Caricamento del sito non riuscito", + "app.uploadSiteNoCredentials": "Configurare prima le credenziali SSH nelle impostazioni.", "app.previewOpenFailed": "Impossibile aprire l’anteprima del post selezionato", "app.metadataDiff": "Diff Metadati", "app.importComplete": "Import completato: {posts} post, {media} file multimediali", "siteValidation.tabTitle": "Validazione sito", diff --git a/tests/engine/PublishEngine.test.ts b/tests/engine/PublishEngine.test.ts new file mode 100644 index 0000000..1f588d4 --- /dev/null +++ b/tests/engine/PublishEngine.test.ts @@ -0,0 +1,379 @@ +/** + * PublishEngine Unit Tests + * + * Tests the site upload engine that publishes generated site content + * via SCP or rsync to a remote server. + */ + +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import path from 'path'; +import { PublishEngine, type PublishCredentials, type PublishResult } from '../../src/main/engine/PublishEngine'; + +// Hoist mock variables so they're available inside vi.mock factories +const { + mockStat, mockUploadFile, mockMkdir, mockClose, mockScpClient, + mockReaddir, mockFsStat, mockAccess, + mockRsync, +} = vi.hoisted(() => { + const mockStat = vi.fn(); + const mockUploadFile = vi.fn(); + const mockMkdir = vi.fn(); + const mockClose = vi.fn(); + const mockScpClient = { + stat: mockStat, + uploadFile: mockUploadFile, + mkdir: mockMkdir, + close: mockClose, + }; + const mockReaddir = vi.fn(); + const mockFsStat = vi.fn(); + const mockAccess = vi.fn(); + const mockRsync = vi.fn((_options: any, callback: any) => { + callback(null, '', '', 'rsync command'); + }); + return { + mockStat, mockUploadFile, mockMkdir, mockClose, mockScpClient, + mockReaddir, mockFsStat, mockAccess, + mockRsync, + }; +}); + +// Mock node-scp +vi.mock('node-scp', () => ({ + Client: vi.fn().mockResolvedValue(mockScpClient), + default: vi.fn().mockResolvedValue(mockScpClient), +})); + +// Mock rsyncwrapper +vi.mock('rsyncwrapper', () => ({ + default: mockRsync, +})); + +// Mock fs/promises +vi.mock('fs/promises', () => ({ + default: { + readdir: (...args: any[]) => mockReaddir(...args), + stat: (...args: any[]) => mockFsStat(...args), + access: (...args: any[]) => mockAccess(...args), + }, + readdir: (...args: any[]) => mockReaddir(...args), + stat: (...args: any[]) => mockFsStat(...args), + access: (...args: any[]) => mockAccess(...args), +})); + +// Mock fs +vi.mock('fs', () => ({ + default: { constants: { F_OK: 0 } }, + constants: { F_OK: 0 }, +})); + +describe('PublishEngine', () => { + let engine: PublishEngine; + const dataDir = '/projects/test-project'; + + const defaultCredentials: PublishCredentials = { + sshHost: 'example.com', + sshUser: 'deploy', + sshRemotePath: '/var/www/html', + sshMode: 'scp', + }; + + beforeEach(() => { + vi.clearAllMocks(); + engine = new PublishEngine(); + engine.setProjectContext('test-project', dataDir); + + // Default: directories exist, files are regular files + mockAccess.mockResolvedValue(undefined); + mockFsStat.mockResolvedValue({ isDirectory: () => false, isFile: () => true, mtimeMs: Date.now() }); + mockReaddir.mockResolvedValue([]); + mockStat.mockRejectedValue(new Error('No such file')); // Remote file doesn't exist → upload + mockUploadFile.mockResolvedValue(undefined); + mockMkdir.mockResolvedValue(undefined); + mockClose.mockResolvedValue(undefined); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('constructor and project context', () => { + it('should be instantiated via getPublishEngine singleton', async () => { + const { getPublishEngine } = await import('../../src/main/engine/PublishEngine'); + const e1 = getPublishEngine(); + const e2 = getPublishEngine(); + expect(e1).toBe(e2); + }); + + it('should throw if no project context is set', async () => { + const noContextEngine = new PublishEngine(); + await expect( + noContextEngine.uploadSite(defaultCredentials, vi.fn()), + ).rejects.toThrow('No project context'); + }); + }); + + describe('credential validation', () => { + it('should throw if sshHost is empty', async () => { + await expect( + engine.uploadSite({ ...defaultCredentials, sshHost: '' }, vi.fn()), + ).rejects.toThrow('SSH host is required'); + }); + + it('should throw if sshUser is empty', async () => { + await expect( + engine.uploadSite({ ...defaultCredentials, sshUser: '' }, vi.fn()), + ).rejects.toThrow('SSH user is required'); + }); + + it('should throw if sshRemotePath is empty', async () => { + await expect( + engine.uploadSite({ ...defaultCredentials, sshRemotePath: '' }, vi.fn()), + ).rejects.toThrow('Remote path is required'); + }); + }); + + describe('directory validation', () => { + it('should throw if html directory does not exist', async () => { + mockAccess.mockImplementation(async (p: string) => { + if ((p as string).endsWith('html')) throw new Error('ENOENT'); + }); + + await expect( + engine.uploadSite(defaultCredentials, vi.fn()), + ).rejects.toThrow('Generated site not found'); + }); + }); + + describe('SCP mode upload', () => { + it('should upload html files to remote root', async () => { + // html/ contains index.html + mockReaddir.mockImplementation(async (dir: string, opts?: any) => { + if (dir === path.join(dataDir, 'html') && opts?.withFileTypes) { + return [{ name: 'index.html', isDirectory: () => false, isFile: () => true }]; + } + return []; + }); + + mockFsStat.mockResolvedValue({ + isDirectory: () => false, + isFile: () => true, + mtimeMs: Date.now(), + }); + + const onProgress = vi.fn(); + const result = await engine.uploadSite(defaultCredentials, onProgress); + + expect(result.htmlFilesUploaded).toBeGreaterThanOrEqual(0); + expect(onProgress).toHaveBeenCalled(); + }); + + it('should upload thumbnail files to remote thumbnails/', async () => { + mockReaddir.mockImplementation(async (dir: string, opts?: any) => { + if (dir === path.join(dataDir, 'html') && opts?.withFileTypes) { + return []; + } + if (dir === path.join(dataDir, 'thumbnails') && opts?.withFileTypes) { + return [{ name: 'thumb1.jpg', isDirectory: () => false, isFile: () => true }]; + } + return []; + }); + + mockFsStat.mockResolvedValue({ + isDirectory: () => false, + isFile: () => true, + mtimeMs: Date.now(), + }); + + const result = await engine.uploadSite(defaultCredentials, vi.fn()); + + expect(result.thumbnailFilesUploaded).toBeGreaterThanOrEqual(0); + }); + + it('should only upload image files from media, not .meta sidecars', async () => { + mockReaddir.mockImplementation(async (dir: string, opts?: any) => { + if (dir === path.join(dataDir, 'html') && opts?.withFileTypes) { + return []; + } + if (dir === path.join(dataDir, 'thumbnails') && opts?.withFileTypes) { + return []; + } + if (dir === path.join(dataDir, 'media') && opts?.withFileTypes) { + return [ + { name: 'photo.jpg', isDirectory: () => false, isFile: () => true }, + { name: 'photo.jpg.meta', isDirectory: () => false, isFile: () => true }, + { name: 'document.pdf', isDirectory: () => false, isFile: () => true }, + { name: 'document.pdf.meta', isDirectory: () => false, isFile: () => true }, + ]; + } + return []; + }); + + mockFsStat.mockResolvedValue({ + isDirectory: () => false, + isFile: () => true, + mtimeMs: Date.now(), + }); + + const result = await engine.uploadSite(defaultCredentials, vi.fn()); + + // Should upload photo.jpg and document.pdf, but NOT .meta files + expect(result.mediaFilesUploaded).toBeGreaterThanOrEqual(0); + }); + + it('should skip files that are not newer than remote', async () => { + const remoteTime = Date.now() / 1000; // SSH stats use seconds + const localTimeOlder = (remoteTime - 100) * 1000; // local is older (ms) + + mockReaddir.mockImplementation(async (dir: string, opts?: any) => { + if (dir === path.join(dataDir, 'html') && opts?.withFileTypes) { + return [{ name: 'old.html', isDirectory: () => false, isFile: () => true }]; + } + return []; + }); + + mockFsStat.mockResolvedValue({ + isDirectory: () => false, + isFile: () => true, + mtimeMs: localTimeOlder, + }); + + // Remote file exists and is newer + mockStat.mockResolvedValue({ mtime: remoteTime }); + + const result = await engine.uploadSite(defaultCredentials, vi.fn()); + + // File should be skipped since remote is newer + expect(mockUploadFile).not.toHaveBeenCalled(); + expect(result.filesSkipped).toBeGreaterThan(0); + }); + + it('should upload files that are newer than remote', async () => { + const remoteTime = Date.now() / 1000 - 100; // Remote is 100s old + const localTimeNewer = Date.now(); // local is current (ms) + + mockReaddir.mockImplementation(async (dir: string, opts?: any) => { + if (dir === path.join(dataDir, 'html') && opts?.withFileTypes) { + return [{ name: 'new.html', isDirectory: () => false, isFile: () => true }]; + } + return []; + }); + + mockFsStat.mockResolvedValue({ + isDirectory: () => false, + isFile: () => true, + mtimeMs: localTimeNewer, + }); + + mockStat.mockResolvedValue({ mtime: remoteTime }); + + const result = await engine.uploadSite(defaultCredentials, vi.fn()); + + expect(mockUploadFile).toHaveBeenCalled(); + expect(result.htmlFilesUploaded).toBeGreaterThan(0); + }); + + it('should recurse into subdirectories', async () => { + mockReaddir.mockImplementation(async (dir: string, opts?: any) => { + if (dir === path.join(dataDir, 'html') && opts?.withFileTypes) { + return [ + { name: '2026', isDirectory: () => true, isFile: () => false }, + ]; + } + if (dir === path.join(dataDir, 'html', '2026') && opts?.withFileTypes) { + return [ + { name: 'post.html', isDirectory: () => false, isFile: () => true }, + ]; + } + return []; + }); + + mockFsStat.mockResolvedValue({ + isDirectory: () => false, + isFile: () => true, + mtimeMs: Date.now(), + }); + + const result = await engine.uploadSite(defaultCredentials, vi.fn()); + + expect(mockMkdir).toHaveBeenCalled(); // Should create remote subdir + expect(result.htmlFilesUploaded).toBeGreaterThan(0); + }); + + it('should return a complete PublishResult', async () => { + mockReaddir.mockResolvedValue([]); + + const result = await engine.uploadSite(defaultCredentials, vi.fn()); + + expect(result).toHaveProperty('htmlFilesUploaded'); + expect(result).toHaveProperty('thumbnailFilesUploaded'); + expect(result).toHaveProperty('mediaFilesUploaded'); + expect(result).toHaveProperty('filesSkipped'); + expect(typeof result.htmlFilesUploaded).toBe('number'); + expect(typeof result.thumbnailFilesUploaded).toBe('number'); + expect(typeof result.mediaFilesUploaded).toBe('number'); + expect(typeof result.filesSkipped).toBe('number'); + }); + }); + + describe('rsync mode upload', () => { + const rsyncCredentials: PublishCredentials = { + ...defaultCredentials, + sshMode: 'rsync', + }; + + it('should call rsync for html directory', async () => { + const rsync = (await import('rsyncwrapper')).default; + + const result = await engine.uploadSite(rsyncCredentials, vi.fn()); + + expect(rsync).toHaveBeenCalled(); + expect(result.htmlFilesUploaded).toBeGreaterThanOrEqual(0); + }); + + it('should use --update and --times flags for incremental transfer', async () => { + const rsync = (await import('rsyncwrapper')).default; + + await engine.uploadSite(rsyncCredentials, vi.fn()); + + // Check that rsync was called with update semantics + const calls = vi.mocked(rsync).mock.calls; + expect(calls.length).toBeGreaterThan(0); + for (const [options] of calls) { + expect(options.args).toContain('--update'); + expect(options.times).toBe(true); + expect(options.recursive).toBe(true); + } + }); + + it('should exclude .meta files when syncing media', async () => { + const rsync = (await import('rsyncwrapper')).default; + + await engine.uploadSite(rsyncCredentials, vi.fn()); + + const calls = vi.mocked(rsync).mock.calls; + // Find the media sync call (dest contains /media) + const mediaCall = calls.find(([opts]) => + typeof opts.dest === 'string' && opts.dest.includes('/media'), + ); + if (mediaCall) { + expect(mediaCall[0].exclude).toContain('*.meta'); + } + }); + }); + + describe('progress reporting', () => { + it('should report progress through all three phases', async () => { + mockReaddir.mockResolvedValue([]); + + const onProgress = vi.fn(); + await engine.uploadSite(defaultCredentials, onProgress); + + // Should have called onProgress at least once per phase + expect(onProgress).toHaveBeenCalled(); + const progressValues = onProgress.mock.calls.map(([p]: [number]) => p); + // Should reach 100 + expect(progressValues[progressValues.length - 1]).toBe(100); + }); + }); +});