From 12a7aebeff3da80027fc3af8e3d24e1c673538e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E8=88=AA=E5=AE=87?= <3364451258@qq.com> Date: Tue, 28 Apr 2026 18:01:26 +0800 Subject: [PATCH] feat: Enhance HaloClient with retry logic and improve error handling - Added retry options to HaloClient for handling transient errors. - Refactored request methods in HaloClient to utilize retry logic. - Updated HaloService to include logging for error handling. - Introduced ApiPaths utility for managing API endpoints. - Implemented logger utility for consistent logging across services. - Added tests for ContentService, Error handling, and TaxonomyService. - Created retry utility for managing retry logic with exponential backoff. - Updated types to include additional properties for better API response handling. --- obsidian-halo/package.json | 7 +- obsidian-halo/pnpm-lock.yaml | 734 +++++++++++++++++ obsidian-halo/src/commands/export-post.ts | 16 +- .../src/modals/delete-confirm-modal.ts | 9 +- obsidian-halo/src/service/image-uploader.ts | 8 +- obsidian-halo/src/service/index.ts | 764 +----------------- .../__tests__/content-service.test.ts | 179 ++++ .../src/services/__tests__/error.test.ts | 115 +++ .../__tests__/taxonomy-service.test.ts | 242 ++++++ obsidian-halo/src/services/client.ts | 28 +- obsidian-halo/src/services/halo-service.ts | 33 +- obsidian-halo/src/services/post-service.ts | 17 +- .../src/services/taxonomy-service.ts | 21 +- obsidian-halo/src/services/types.ts | 3 + obsidian-halo/src/utils/api-paths.ts | 66 ++ obsidian-halo/src/utils/logger.ts | 60 ++ obsidian-halo/src/utils/retry.ts | 73 ++ obsidian-halo/vitest.config.ts | 13 + 18 files changed, 1573 insertions(+), 815 deletions(-) create mode 100644 obsidian-halo/src/services/__tests__/content-service.test.ts create mode 100644 obsidian-halo/src/services/__tests__/error.test.ts create mode 100644 obsidian-halo/src/services/__tests__/taxonomy-service.test.ts create mode 100644 obsidian-halo/src/utils/api-paths.ts create mode 100644 obsidian-halo/src/utils/logger.ts create mode 100644 obsidian-halo/src/utils/retry.ts create mode 100644 obsidian-halo/vitest.config.ts diff --git a/obsidian-halo/package.json b/obsidian-halo/package.json index c0b4660..733f862 100644 --- a/obsidian-halo/package.json +++ b/obsidian-halo/package.json @@ -8,7 +8,9 @@ "dev": "rslib build --watch", "build": "rslib build", "version": "node version-bump.mjs && git add manifest.json versions.json", - "check": "biome check --write src/" + "check": "biome check --write src/", + "test": "vitest", + "test:run": "vitest run" }, "author": "LHY", "maintainers": [ @@ -28,7 +30,8 @@ "builtin-modules": "3.3.0", "obsidian": "latest", "tslib": "2.6.0", - "typescript": "5.1.6" + "typescript": "5.1.6", + "vitest": "^4.1.5" }, "dependencies": { "@halo-dev/api-client": "^2.20.0", diff --git a/obsidian-halo/pnpm-lock.yaml b/obsidian-halo/pnpm-lock.yaml index 6cc2f97..852cbce 100644 --- a/obsidian-halo/pnpm-lock.yaml +++ b/obsidian-halo/pnpm-lock.yaml @@ -57,6 +57,9 @@ importers: typescript: specifier: 5.1.6 version: 5.1.6 + vitest: + specifier: ^4.1.5 + version: 4.1.5(@types/node@18.19.86)(vite@8.0.10(@types/node@18.19.86)) packages: @@ -86,24 +89,28 @@ packages: engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] + libc: [musl] '@biomejs/cli-linux-arm64@1.9.4': resolution: {integrity: sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] + libc: [glibc] '@biomejs/cli-linux-x64-musl@1.9.4': resolution: {integrity: sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] + libc: [musl] '@biomejs/cli-linux-x64@1.9.4': resolution: {integrity: sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] + libc: [glibc] '@biomejs/cli-win32-arm64@1.9.4': resolution: {integrity: sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg==} @@ -123,6 +130,15 @@ packages: '@codemirror/view@6.12.0': resolution: {integrity: sha512-xNHvbJBc2v8JuEcIGOck6EUGShpP+TYGCEMVEVQMYxbFXfMhYnoF3znxB/2GgeKR0nrxBs+nhBupiTYQqCp2kw==} + '@emnapi/core@1.10.0': + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} + + '@emnapi/runtime@1.10.0': + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + '@halo-dev/api-client@2.20.0': resolution: {integrity: sha512-da/0cmUat7Iew4Mggc/sjXQmEoO0aSl9JMe0qK72UgeEGcac3M+qJUn0yprEAHtN6EVPrTwAUdTSFzgWrWPytw==} peerDependencies: @@ -131,6 +147,9 @@ packages: '@jridgewell/sourcemap-codec@1.5.0': resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==} + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@module-federation/runtime-tools@0.5.1': resolution: {integrity: sha512-nfBedkoZ3/SWyO0hnmaxuz0R0iGPSikHZOAZ0N/dVSQaIzlffUo35B5nlC2wgWIc0JdMZfkwkjZRrnuuDIJbzg==} @@ -143,6 +162,113 @@ packages: '@module-federation/webpack-bundler-runtime@0.5.1': resolution: {integrity: sha512-mMhRFH0k2VjwHt3Jol9JkUsmI/4XlrAoBG3E0o7HoyoPYv1UFOWyqAflfANcUPgbYpvqmyLzDcO+3IT36LXnrA==} + '@napi-rs/wasm-runtime@1.1.4': + resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + + '@oxc-project/types@0.127.0': + resolution: {integrity: sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==} + + '@rolldown/binding-android-arm64@1.0.0-rc.17': + resolution: {integrity: sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.0.0-rc.17': + resolution: {integrity: sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.0.0-rc.17': + resolution: {integrity: sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.0.0-rc.17': + resolution: {integrity: sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.17': + resolution: {integrity: sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.17': + resolution: {integrity: sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.17': + resolution: {integrity: sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17': + resolution: {integrity: sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17': + resolution: {integrity: sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.17': + resolution: {integrity: sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.0.0-rc.17': + resolution: {integrity: sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.0.0-rc.17': + resolution: {integrity: sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.0.0-rc.17': + resolution: {integrity: sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.17': + resolution: {integrity: sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.17': + resolution: {integrity: sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.0-rc.17': + resolution: {integrity: sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==} + '@rsbuild/core@1.1.10': resolution: {integrity: sha512-G0aVnoMSIZ4PNcW07tKtsOSoID9M03EAnCThRmUWMj1RXDqhbGje6AFBwGun9uz63bdxYEbEp9C8wH7dGi8aYQ==} engines: {node: '>=16.7.0'} @@ -175,21 +301,25 @@ packages: resolution: {integrity: sha512-4atnoknJx/c3KaQElsMIxHMpPf2jcRRdWsH/SdqJIRSrkWWakMK9Yv4TFwH680I4HDTMf1XLboMVScHzW8e+Mg==} cpu: [arm64] os: [linux] + libc: [glibc] '@rspack/binding-linux-arm64-musl@1.1.6': resolution: {integrity: sha512-7QMtwUtgFpt3/Y3/X18fSyN+kk4H8ZnZ8tDzQskVWc/j2AQYShZq56XQYqrhClzwujcCVAHauIQ2eiuJ2ASGag==} cpu: [arm64] os: [linux] + libc: [musl] '@rspack/binding-linux-x64-gnu@1.1.6': resolution: {integrity: sha512-MTjDEfPn4TwHoqs5d5Fck06kmXiTHZctGIcRVfrpg0RK0r1NLEHN+oosavRZ9c9H70f34+NmcHk+/qvV4c8lWg==} cpu: [x64] os: [linux] + libc: [glibc] '@rspack/binding-linux-x64-musl@1.1.6': resolution: {integrity: sha512-LqDw7PTVr/4ZuGA0izgDQfamfr72USFHltR1Qhy2YVC3JmDmhG/pQi13LHcOLVaGH1xoeyCmEPNJpVizzDxSjg==} cpu: [x64] os: [linux] + libc: [musl] '@rspack/binding-win32-arm64-msvc@1.1.6': resolution: {integrity: sha512-RHApLM93YN0WdHpS35u2cm7VCqZ8Yg3CrNRL16VJtyT9e6MBqeScoe4XIgIWKPm7edFyedYAjLX0wQOApwfjkg==} @@ -222,12 +352,24 @@ packages: resolution: {integrity: sha512-VynGOEsVw2s8TAlLf/uESfrgfrq2+rcXB1muPJYBWbsm1Oa6r5qVQhjA5ggM6z/coYPrsVMgovl3Ff7Q7OCp1w==} engines: {node: '>=16.0.0'} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} + '@tybys/wasm-util@0.10.1': + resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/codemirror@5.60.8': resolution: {integrity: sha512-VjFgDF/eB+Aklcy15TtOTLQeMjTo07k7KAjql8OK5Dirr7a6sJY4T1uVBDuTVG9VEmn1uUsohOpYnVfgC6/jyw==} + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/estree@1.0.1': resolution: {integrity: sha512-LG4opVs2ANWZ1TJoKc937iMmNstM/d0ae1vNbnBvBhqCSezgVUOzcLCqbI5elV8Vy6WKwKjaqR+zO9VKirBBCA==} @@ -249,6 +391,35 @@ packages: '@types/tern@0.23.4': resolution: {integrity: sha512-JAUw1iXGO1qaWwEOzxTKJZ/5JxVeON9kvGZ/osgZaJImBnyjyn0cjovPsf6FNLmyGY8Vw9DoXZCMlfMkMwHRWg==} + '@vitest/expect@4.1.5': + resolution: {integrity: sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==} + + '@vitest/mocker@4.1.5': + resolution: {integrity: sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.5': + resolution: {integrity: sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==} + + '@vitest/runner@4.1.5': + resolution: {integrity: sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==} + + '@vitest/snapshot@4.1.5': + resolution: {integrity: sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==} + + '@vitest/spy@4.1.5': + resolution: {integrity: sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==} + + '@vitest/utils@4.1.5': + resolution: {integrity: sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==} + ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -263,6 +434,10 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} @@ -284,6 +459,10 @@ packages: caniuse-lite@1.0.30001689: resolution: {integrity: sha512-CmeR2VBycfa+5/jOfnp/NpWPGd06nf1XYiefUvhXFfZE4GkRc9jv+eGPS4nT558WS/8lYCzV8SlANCIPvbWP1g==} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + cliui@8.0.1: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} @@ -299,6 +478,9 @@ packages: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + core-js@3.39.0: resolution: {integrity: sha512-raM0ew0/jJUqkJ0E6e8UDtl+y/7ktFivgWvqw8dNSQeNWoSDLvQ1H/RN3aPXB9tBd4/FhyR4RDPGhsNIMsAn7g==} @@ -306,6 +488,10 @@ packages: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -325,6 +511,9 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} + es-module-lexer@2.1.0: + resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} + es-object-atoms@1.0.0: resolution: {integrity: sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==} engines: {node: '>= 0.4'} @@ -338,6 +527,13 @@ packages: engines: {node: '>=4'} hasBin: true + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + extend-shallow@2.0.1: resolution: {integrity: sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==} engines: {node: '>=0.10.0'} @@ -350,6 +546,15 @@ packages: picomatch: optional: true + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + follow-redirects@1.15.9: resolution: {integrity: sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==} engines: {node: '>=4.0'} @@ -363,6 +568,11 @@ packages: resolution: {integrity: sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==} engines: {node: '>= 6'} + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} @@ -413,12 +623,89 @@ packages: resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} engines: {node: '>=0.10.0'} + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + linkify-it@4.0.1: resolution: {integrity: sha512-C7bfi1UZmoj8+PQx22XyeXCuBlokoyWQL5pWSP+EI6nzRylyThouddufc2c1NDIcP9k5agmN9fLpA7VNJfIiqw==} magic-string@0.30.17: resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + markdown-it-anchor@8.6.7: resolution: {integrity: sha512-FlCHFwNnutLgVTflOYHPW2pPcl2AACqVzExlkGQNsi4CJgqOHN7YTgDd4LuhgN1BFO3TS0vLAruV1Td6dwWPJA==} peerDependencies: @@ -447,6 +734,11 @@ packages: moment@2.29.4: resolution: {integrity: sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==} + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + object-inspect@1.13.3: resolution: {integrity: sha512-kDCGIbxkDSXE3euJZZXzc6to7fCrKHNI/hSRQnRuQ+BWjFNzZwiFF8fj/6o2t2G9/jTj8PSIYTfCLelLZEeRpA==} engines: {node: '>= 0.4'} @@ -457,6 +749,12 @@ packages: '@codemirror/state': ^6.0.0 '@codemirror/view': ^6.0.0 + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -464,6 +762,14 @@ packages: resolution: {integrity: sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==} engines: {node: '>=12'} + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + postcss@8.5.12: + resolution: {integrity: sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==} + engines: {node: ^10 || ^12 || >=14} + proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} @@ -478,6 +784,11 @@ packages: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} + rolldown@1.0.0-rc.17: + resolution: {integrity: sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + rsbuild-plugin-dts@0.1.5: resolution: {integrity: sha512-p5xN+Cxv59Pk7dHZJk3ca6ryycPfIbyVPk0KWuanLE0/c2s2A5KNJCB/Qyc/MZTYEXhcJNS8FTKxEMXXJGYwvA==} engines: {node: '>=16.0.0'} @@ -511,9 +822,22 @@ packages: resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} engines: {node: '>= 0.4'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -529,10 +853,25 @@ packages: style-mod@4.0.3: resolution: {integrity: sha512-78Jv8kYJdjbvRwwijtCevYADfsI0lGzYJe4mMFdceO8l75DFFDoqBhR1jVDicDRRaX4//g1u9wKeo+ztc2h1Rw==} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.1.1: + resolution: {integrity: sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==} + engines: {node: '>=18'} + tinyglobby@0.2.10: resolution: {integrity: sha512-Zc+8eJlFMvgatPZTl6A9L/yht8QqdmUNtURHaKZLmKBE12hNPSrqNkUp2cs3M/UKmNVVAMFQYSjYIVHDjW5zew==} engines: {node: '>=12.0.0'} + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + transliteration@2.3.5: resolution: {integrity: sha512-HAGI4Lq4Q9dZ3Utu2phaWgtm3vB6PkLUFqWAScg/UW+1eZ/Tg6Exo4oC0/3VUol/w4BlefLhUUSVBr/9/ZGQOw==} engines: {node: '>=6.0.0'} @@ -555,9 +894,98 @@ packages: undici-types@5.26.5: resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} + vite@8.0.10: + resolution: {integrity: sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.1.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.5: + resolution: {integrity: sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.5 + '@vitest/browser-preview': 4.1.5 + '@vitest/browser-webdriverio': 4.1.5 + '@vitest/coverage-istanbul': 4.1.5 + '@vitest/coverage-v8': 4.1.5 + '@vitest/ui': 4.1.5 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + w3c-keyname@2.2.7: resolution: {integrity: sha512-XB8aa62d4rrVfoZYQaYNy3fy+z4nrfy2ooea3/0BnBzXW0tSdZ+lRgjzBZhk0La0H6h8fVyYCxx/qkQcAIuvfg==} + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -623,6 +1051,22 @@ snapshots: style-mod: 4.0.3 w3c-keyname: 2.2.7 + '@emnapi/core@1.10.0': + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.6.0 + optional: true + + '@emnapi/runtime@1.10.0': + dependencies: + tslib: 2.6.0 + optional: true + + '@emnapi/wasi-threads@1.2.1': + dependencies: + tslib: 2.6.0 + optional: true + '@halo-dev/api-client@2.20.0(axios@1.7.9)': dependencies: axios: 1.7.9 @@ -630,6 +1074,8 @@ snapshots: '@jridgewell/sourcemap-codec@1.5.0': {} + '@jridgewell/sourcemap-codec@1.5.5': {} + '@module-federation/runtime-tools@0.5.1': dependencies: '@module-federation/runtime': 0.5.1 @@ -646,6 +1092,66 @@ snapshots: '@module-federation/runtime': 0.5.1 '@module-federation/sdk': 0.5.1 + '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@tybys/wasm-util': 0.10.1 + optional: true + + '@oxc-project/types@0.127.0': {} + + '@rolldown/binding-android-arm64@1.0.0-rc.17': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.0-rc.17': + optional: true + + '@rolldown/binding-darwin-x64@1.0.0-rc.17': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.0-rc.17': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.17': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.17': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.17': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.17': + optional: true + + '@rolldown/binding-linux-x64-musl@1.0.0-rc.17': + optional: true + + '@rolldown/binding-openharmony-arm64@1.0.0-rc.17': + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.0-rc.17': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.17': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.17': + optional: true + + '@rolldown/pluginutils@1.0.0-rc.17': {} + '@rsbuild/core@1.1.10': dependencies: '@rspack/core': 1.1.6(@swc/helpers@0.5.15) @@ -711,14 +1217,28 @@ snapshots: '@rspack/lite-tapable@1.0.1': {} + '@standard-schema/spec@1.1.0': {} + '@swc/helpers@0.5.15': dependencies: tslib: 2.8.1 + '@tybys/wasm-util@0.10.1': + dependencies: + tslib: 2.6.0 + optional: true + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + '@types/codemirror@5.60.8': dependencies: '@types/tern': 0.23.4 + '@types/deep-eql@4.0.2': {} + '@types/estree@1.0.1': {} '@types/js-yaml@4.0.9': {} @@ -740,6 +1260,47 @@ snapshots: dependencies: '@types/estree': 1.0.1 + '@vitest/expect@4.1.5': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.5 + '@vitest/utils': 4.1.5 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.5(vite@8.0.10(@types/node@18.19.86))': + dependencies: + '@vitest/spy': 4.1.5 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.0.10(@types/node@18.19.86) + + '@vitest/pretty-format@4.1.5': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.5': + dependencies: + '@vitest/utils': 4.1.5 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.5': + dependencies: + '@vitest/pretty-format': 4.1.5 + '@vitest/utils': 4.1.5 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.5': {} + + '@vitest/utils@4.1.5': + dependencies: + '@vitest/pretty-format': 4.1.5 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + ansi-regex@5.0.1: {} ansi-styles@4.3.0: @@ -752,6 +1313,8 @@ snapshots: argparse@2.0.1: {} + assertion-error@2.0.1: {} + asynckit@0.4.0: {} axios@1.7.9: @@ -776,6 +1339,8 @@ snapshots: caniuse-lite@1.0.30001689: {} + chai@6.2.2: {} + cliui@8.0.1: dependencies: string-width: 4.2.3 @@ -792,10 +1357,14 @@ snapshots: dependencies: delayed-stream: 1.0.0 + convert-source-map@2.0.0: {} + core-js@3.39.0: {} delayed-stream@1.0.0: {} + detect-libc@2.1.2: {} + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.1 @@ -810,6 +1379,8 @@ snapshots: es-errors@1.3.0: {} + es-module-lexer@2.1.0: {} + es-object-atoms@1.0.0: dependencies: es-errors: 1.3.0 @@ -818,6 +1389,12 @@ snapshots: esprima@4.0.1: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.1 + + expect-type@1.3.0: {} + extend-shallow@2.0.1: dependencies: is-extendable: 0.1.1 @@ -826,6 +1403,10 @@ snapshots: optionalDependencies: picomatch: 4.0.2 + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + follow-redirects@1.15.9: {} form-data@4.0.1: @@ -834,6 +1415,9 @@ snapshots: combined-stream: 1.0.8 mime-types: 2.1.35 + fsevents@2.3.3: + optional: true + function-bind@1.1.2: {} get-caller-file@2.0.5: {} @@ -885,6 +1469,55 @@ snapshots: kind-of@6.0.3: {} + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + linkify-it@4.0.1: dependencies: uc.micro: 1.0.6 @@ -893,6 +1526,10 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.0 + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + markdown-it-anchor@8.6.7(@types/markdown-it@12.2.3)(markdown-it@13.0.2): dependencies: '@types/markdown-it': 12.2.3 @@ -918,6 +1555,8 @@ snapshots: moment@2.29.4: {} + nanoid@3.3.11: {} + object-inspect@1.13.3: {} obsidian@1.4.11(@codemirror/state@6.2.0)(@codemirror/view@6.12.0): @@ -927,10 +1566,22 @@ snapshots: '@types/codemirror': 5.60.8 moment: 2.29.4 + obug@2.1.1: {} + + pathe@2.0.3: {} + picocolors@1.1.1: {} picomatch@4.0.2: {} + picomatch@4.0.4: {} + + postcss@8.5.12: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + proxy-from-env@1.1.0: {} qs@6.13.1: @@ -941,6 +1592,27 @@ snapshots: require-directory@2.1.1: {} + rolldown@1.0.0-rc.17: + dependencies: + '@oxc-project/types': 0.127.0 + '@rolldown/pluginutils': 1.0.0-rc.17 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.0-rc.17 + '@rolldown/binding-darwin-arm64': 1.0.0-rc.17 + '@rolldown/binding-darwin-x64': 1.0.0-rc.17 + '@rolldown/binding-freebsd-x64': 1.0.0-rc.17 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.17 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.17 + '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.17 + '@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.17 + '@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.17 + '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.17 + '@rolldown/binding-linux-x64-musl': 1.0.0-rc.17 + '@rolldown/binding-openharmony-arm64': 1.0.0-rc.17 + '@rolldown/binding-wasm32-wasi': 1.0.0-rc.17 + '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.17 + '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.17 + rsbuild-plugin-dts@0.1.5(@rsbuild/core@1.1.10)(typescript@5.1.6): dependencies: '@rsbuild/core': 1.1.10 @@ -983,8 +1655,16 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + sprintf-js@1.0.3: {} + stackback@0.0.2: {} + + std-env@4.1.0: {} + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -999,11 +1679,22 @@ snapshots: style-mod@4.0.3: {} + tinybench@2.9.0: {} + + tinyexec@1.1.1: {} + tinyglobby@0.2.10: dependencies: fdir: 6.4.2(picomatch@4.0.2) picomatch: 4.0.2 + tinyglobby@0.2.16: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tinyrainbow@3.1.0: {} + transliteration@2.3.5: dependencies: yargs: 17.7.2 @@ -1018,8 +1709,51 @@ snapshots: undici-types@5.26.5: {} + vite@8.0.10(@types/node@18.19.86): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.5.12 + rolldown: 1.0.0-rc.17 + tinyglobby: 0.2.16 + optionalDependencies: + '@types/node': 18.19.86 + fsevents: 2.3.3 + + vitest@4.1.5(@types/node@18.19.86)(vite@8.0.10(@types/node@18.19.86)): + dependencies: + '@vitest/expect': 4.1.5 + '@vitest/mocker': 4.1.5(vite@8.0.10(@types/node@18.19.86)) + '@vitest/pretty-format': 4.1.5 + '@vitest/runner': 4.1.5 + '@vitest/snapshot': 4.1.5 + '@vitest/spy': 4.1.5 + '@vitest/utils': 4.1.5 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.1.1 + tinyglobby: 0.2.16 + tinyrainbow: 3.1.0 + vite: 8.0.10(@types/node@18.19.86) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 18.19.86 + transitivePeerDependencies: + - msw + w3c-keyname@2.2.7: {} + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 diff --git a/obsidian-halo/src/commands/export-post.ts b/obsidian-halo/src/commands/export-post.ts index 963709c..c644c95 100644 --- a/obsidian-halo/src/commands/export-post.ts +++ b/obsidian-halo/src/commands/export-post.ts @@ -2,7 +2,7 @@ import i18next from "i18next"; import { Notice, requestUrl } from "obsidian"; import { openSiteSelectionModal } from "../site-selection-modal"; import type HaloPlugin from "../main"; -import type { HaloSite } from "../settings"; +import type { Post, Snapshot } from "../services/types"; export async function exportPostAsMarkdown(plugin: HaloPlugin, postName: string, title: string): Promise { try { @@ -18,7 +18,7 @@ export async function exportPostAsMarkdown(plugin: HaloPlugin, postName: string, const snapshot = (await requestUrl({ url: `${site.url}/apis/uc.api.content.halo.run/v1alpha1/posts/${postName}/draft?patched=true`, headers, - }).json) as any; + }).json) as Snapshot; const { "content.halo.run/patched-raw": raw } = snapshot?.metadata?.annotations || {}; @@ -35,9 +35,9 @@ ${raw}`; const fileName = `${title}.md`; const file = await plugin.app.vault.create(fileName, content); - + plugin.app.workspace.getLeaf().openFile(file); - + new Notice(i18next.t("export.notice_export_success", { fileName })); } catch (error) { console.error("[HaloPlugin] Export as markdown failed:", error); @@ -59,12 +59,12 @@ export async function exportPostAsJson(plugin: HaloPlugin, postName: string, tit const post = (await requestUrl({ url: `${site.url}/apis/uc.api.content.halo.run/v1alpha1/posts/${postName}`, headers, - }).json) as any; + }).json) as Post; const snapshot = (await requestUrl({ url: `${site.url}/apis/uc.api.content.halo.run/v1alpha1/posts/${postName}/draft?patched=true`, headers, - }).json) as any; + }).json) as Snapshot; const exportData = { post: post, @@ -78,9 +78,9 @@ export async function exportPostAsJson(plugin: HaloPlugin, postName: string, tit const fileName = `${title}.json`; const file = await plugin.app.vault.create(fileName, JSON.stringify(exportData, null, 2)); - + plugin.app.workspace.getLeaf().openFile(file); - + new Notice(i18next.t("export.notice_export_success", { fileName })); } catch (error) { console.error("[HaloPlugin] Export as JSON failed:", error); diff --git a/obsidian-halo/src/modals/delete-confirm-modal.ts b/obsidian-halo/src/modals/delete-confirm-modal.ts index 2089024..1bfde32 100644 --- a/obsidian-halo/src/modals/delete-confirm-modal.ts +++ b/obsidian-halo/src/modals/delete-confirm-modal.ts @@ -1,5 +1,5 @@ import i18next from "i18next"; -import { Modal, Notice, Setting } from "obsidian"; +import { Modal, Notice, Setting, type TFile } from "obsidian"; import type HaloPlugin from "../main"; import type { HaloSite } from "../settings"; @@ -72,9 +72,10 @@ class DeleteConfirmModal extends Modal { }); }); - const hasLocalFile = this.plugin.app.metadataCache.getFileCache( - Array.from(this.plugin.app.vault.getFiles()).find(f => f.basename === this.title) || this.plugin.app.vault.getAbstractFileByPath(`/${this.title}.md`) as any - ); + const foundFile = Array.from(this.plugin.app.vault.getFiles()).find((f: TFile) => f.basename === this.title); + const abstractFile = this.plugin.app.vault.getAbstractFileByPath(`/${this.title}.md`); + const localFile = (foundFile || (abstractFile instanceof TFile ? abstractFile : null)); + const hasLocalFile = localFile ? this.plugin.app.metadataCache.getFileCache(localFile) : null; if (!hasLocalFile) { const localFile = this.plugin.app.vault.getAbstractFileByPath(`/${this.title}.md`); diff --git a/obsidian-halo/src/service/image-uploader.ts b/obsidian-halo/src/service/image-uploader.ts index 3ed8b10..2877d11 100644 --- a/obsidian-halo/src/service/image-uploader.ts +++ b/obsidian-halo/src/service/image-uploader.ts @@ -1,5 +1,7 @@ import type { TFile, Vault } from "obsidian"; import { requestUrl } from "obsidian"; +import { logger } from "../utils/logger"; +import { ApiPaths } from "../utils/api-paths"; export default class ImageUploader { private readonly siteUrl: string; @@ -37,7 +39,7 @@ export default class ImageUploader { const file = vault.getAbstractFileByPath(normalizedPath); if (!file || !(file instanceof TFile)) { - console.error(`[ImageUploader] 文件不存在或不是有效文件: ${normalizedPath}`); + logger.error("ImageUploader", `文件不存在或不是有效文件: ${normalizedPath}`); return null; } @@ -54,7 +56,7 @@ export default class ImageUploader { body += `\r\n--${boundary}--\r\n`; const response = await requestUrl({ - url: `${this.siteUrl}/apis/api.console.halo.run/v1alpha1/attachments/upload`, + url: `${this.siteUrl}${ApiPaths.attachments.upload()}`, method: "POST", headers: { ...this.headers, @@ -72,7 +74,7 @@ export default class ImageUploader { return null; } catch (error) { - console.error(`[ImageUploader] 上传图片失败: ${filePath}`, error); + logger.error("ImageUploader", `上传图片失败: ${filePath}`, error); return null; } } diff --git a/obsidian-halo/src/service/index.ts b/obsidian-halo/src/service/index.ts index abfa24d..ac613ba 100644 --- a/obsidian-halo/src/service/index.ts +++ b/obsidian-halo/src/service/index.ts @@ -1,758 +1,6 @@ -import ImageUploader from "./image-uploader"; -import i18next from "i18next"; -import { type App, Notice, requestUrl, type TFile } from "obsidian"; -import { randomUUID } from "src/utils/id"; -import markdownIt from "src/utils/markdown"; -import { slugify } from "transliteration"; -import type { HaloSetting, HaloSite } from "../settings"; -import { extractImageReferences, getAbsolutePath, replaceImagePaths } from "../utils/image"; - -class HaloService { - private readonly site: HaloSite; - private readonly app: App; - private readonly settings: HaloSetting; - private readonly headers: Record = {}; - - constructor(app: App, settings: HaloSetting, site: HaloSite) { - this.app = app; - this.settings = settings; - this.site = site; - - this.headers = { - "Content-Type": "application/json", - Authorization: `Bearer ${site.token}`, - }; - } - - public async getPost(name: string): Promise<{ post: Post; content: Content } | undefined> { - try { - const post = (await requestUrl({ - url: `${this.site.url}/apis/uc.api.content.halo.run/v1alpha1/posts/${name}`, - headers: this.headers, - }).json) as Post; - - const snapshot = (await requestUrl({ - url: `${this.site.url}/apis/uc.api.content.halo.run/v1alpha1/posts/${name}/draft?patched=true`, - headers: this.headers, - }).json) as Snapshot; - - const { "content.halo.run/patched-content": patchedContent, "content.halo.run/patched-raw": patchedRaw } = - snapshot.metadata.annotations || {}; - - const { rawType } = snapshot.spec || {}; - - const content: Content = { - content: patchedContent, - raw: patchedRaw, - rawType, - }; - - return Promise.resolve({ - post, - content, - }); - } catch (error) { - return Promise.resolve(undefined); - } - } - - public async publishPost(): Promise { - const { activeEditor } = this.app.workspace; - - if (!activeEditor || !activeEditor.file) { - return; - } - - const imageUploader = new ImageUploader(this.site.url, this.site.token); - - let params: Post = { - apiVersion: "content.halo.run/v1alpha1", - kind: "Post", - metadata: { - annotations: {}, - name: "", - }, - spec: { - allowComment: true, - baseSnapshot: "", - categories: [], - cover: "", - deleted: false, - excerpt: { - autoGenerate: true, - raw: "", - }, - headSnapshot: "", - htmlMetas: [], - owner: "", - pinned: false, - priority: 0, - publish: false, - publishTime: "", - releaseSnapshot: "", - slug: "", - tags: [], - template: "", - title: "", - visible: "PUBLIC", - }, - }; - - let content: Content = { - rawType: "markdown", - raw: "", - content: "", - }; - - const md = await this.app.vault.read(activeEditor.file); - const matterData = this.app.metadataCache.getFileCache(activeEditor.file)?.frontmatter; - const frontmatterPosition = this.app.metadataCache.getFileCache(activeEditor.file)?.frontmatterPosition; - - const raw = frontmatterPosition ? md.slice(frontmatterPosition?.end.offset) : md; - - // 检测并上传本地图片 - let processedRaw = raw; - if (this.settings.imageUpload?.enabled) { - console.log("[HaloService] 图片上传功能已启用"); - - const imageReferences = extractImageReferences(raw); - console.log(`[HaloService] 检测到 ${imageReferences.length} 个图片引用`); - - if (imageReferences.length > 0) { - const localImages = imageReferences.filter((ref) => !ref.path.startsWith("http://") && !ref.path.startsWith("https://") && !ref.path.startsWith("data:")); - console.log(`[HaloService] 其中 ${localImages.length} 个是本地图片`); - - if (localImages.length > 0) { - new Notice(`检测到 ${localImages.length} 个本地图片,正在上传...`); - - const absolutePaths = localImages - .map((ref) => ({ - original: ref.path, - absolute: getAbsolutePath(this.app.vault, ref.path, activeEditor.file.path), - })) - .filter((item) => item.absolute !== null) as { original: string; absolute: string }[]; - console.log(`[HaloService] 其中 ${absolutePaths.length} 个图片可以解析为绝对路径`); - - if (absolutePaths.length > 0) { - console.log(`[HaloService] 开始上传 ${absolutePaths.length} 个图片到 ${this.site.url}`); - const pathMapping = await imageUploader.uploadImages(absolutePaths.map((item) => item.absolute), this.app.vault); - console.log(`[HaloService] 上传完成,成功 ${pathMapping.size} 个`); - - if (pathMapping.size > 0) { - const mapping = new Map(); - absolutePaths.forEach((item) => { - const remoteUrl = pathMapping.get(item.absolute); - if (remoteUrl) { - mapping.set(item.original, remoteUrl); - } - }); - - processedRaw = replaceImagePaths(processedRaw, mapping); - - const successCount = mapping.size; - const failCount = absolutePaths.length - successCount; - - if (failCount === 0) { - new Notice(`✓ 图片上传成功 (${successCount}/${absolutePaths.length})`); - } else { - new Notice(`⚠ 部分图片上传失败 (成功: ${successCount}, 失败: ${failCount})`); - } - } else { - new Notice("⚠ 图片上传失败,保留原始路径"); - } - } - } - } - } else { - console.log("[HaloService] 图片上传功能未启用"); - } - - // 检查站点 URL - if (matterData?.halo?.site && matterData.halo.site !== this.site.url) { - new Notice(i18next.t("service.error_site_not_match")); - return; - } - - // 如果已发布,获取现有文章信息 - if (matterData?.halo?.name) { - const post = await this.getPost(matterData.halo.name); - - if (post) { - params = post.post; - content = post.content; - } - } - - content.raw = processedRaw; - content.content = markdownIt.render(processedRaw); - - // 恢复元数据 - if (matterData?.title) { - params.spec.title = matterData.title; - } - - if (matterData?.slug) { - params.spec.slug = matterData.slug; - } - - if (matterData?.excerpt) { - params.spec.excerpt.raw = matterData.excerpt; - params.spec.excerpt.autoGenerate = false; - } - - if (matterData?.cover) { - params.spec.cover = matterData.cover; - } - - if (matterData?.categories) { - const categoryNames = await this.getCategoryNames(matterData.categories); - params.spec.categories = categoryNames; - } - - if (matterData?.tags) { - const tagNames = await this.getTagNames(matterData.tags); - params.spec.tags = tagNames; - } - - try { - // 设置标题和 slug - params.spec.title = matterData?.title || activeEditor.file.basename; - params.spec.slug = matterData?.slug || slugify(params.spec.title, { trim: true }); - - // 设置内容注解 - params.metadata.annotations = { - ...params.metadata.annotations, - "content.halo.run/content-json": JSON.stringify(content), - }; - - // 设置 metadata.name(如果还没有的话) - if (!params.metadata.name) { - params.metadata.name = randomUUID(); - } - - console.log(`[HaloService] 开始发布文章,站点: ${this.site.url}`); - console.log(`[HaloService] 文章标题: ${params.spec.title}`); - console.log(`[HaloService] 文章 slug: ${params.spec.slug}`); - console.log(`[HaloService] 文章 name: ${params.metadata.name}`); - - // 发送创建/更新请求 - const isUpdate = !!matterData?.halo?.name; - - if (isUpdate) { - console.log(`[HaloService] 更新现有文章: ${params.metadata.name}`); - - await requestUrl({ - url: `${this.site.url}/apis/uc.api.content.halo.run/v1alpha1/posts/${params.metadata.name}`, - method: "PUT", - contentType: "application/json", - headers: this.headers, - body: JSON.stringify(params), - }); - - console.log(`[HaloService] 文章基本信息更新成功`); - } else { - console.log(`[HaloService] 创建新文章`); - - const post = await requestUrl({ - url: `${this.site.url}/apis/uc.api.content.halo.run/v1alpha1/posts`, - method: "POST", - contentType: "application/json", - headers: this.headers, - body: JSON.stringify(params), - }).json; - - console.log(`[HaloService] 文章创建响应:`, JSON.stringify(post)); - - if (!post || !post.metadata) { - console.error(`[HaloService] 创建文章响应格式错误:`, post); - throw new Error("创建文章响应格式错误"); - } - - console.log(`[HaloService] 文章创建成功: ${post.metadata.name}`); - params = post; - } - - // 处理发布状态 - // biome-ignore lint: no - if (matterData?.halo?.hasOwnProperty("publish")) { - if (matterData?.halo?.publish) { - await this.changePostPublish(params.metadata.name, true); - } else { - await this.changePostPublish(params.metadata.name, false); - } - } else { - if (this.settings.publishByDefault) { - await this.changePostPublish(params.metadata.name, true); - } - } - - const postResult = await this.getPost(params.metadata.name); - - if (!postResult || !postResult.post.metadata) { - console.error("[HaloService] 获取文章详情失败"); - new Notice(i18next.t("service.error_publish_failed")); - return; - } - - params = postResult.post; - - const postCategories = await this.getCategoryDisplayNames(params.spec.categories); - const postTags = await this.getTagDisplayNames(params.spec.tags); - - this.app.fileManager.processFrontMatter(activeEditor.file, (frontmatter) => { - frontmatter.title = params.spec.title; - frontmatter.slug = params.spec.slug; - frontmatter.cover = params.spec.cover; - frontmatter.excerpt = params.spec.excerpt.autoGenerate ? undefined : params.spec.excerpt.raw; - frontmatter.categories = postCategories; - frontmatter.tags = postTags; - frontmatter.halo = { - site: this.site.url, - name: params.metadata.name, - publish: params.spec.publish, - }; - }); - - new Notice(i18next.t("service.notice_publish_success")); - } catch (error) { - console.error(`[HaloService] 发布失败,错误:`, error); - new Notice(i18next.t("service.error_publish_failed")); - return; - } - } - - public async changePostPublish(name: string, publish: boolean): Promise { - await requestUrl({ - url: `${this.site.url}/apis/uc.api.content.halo.run/v1alpha1/posts/${name}/${publish ? "publish" : "unpublish"}`, - method: "PUT", - contentType: "application/json", - headers: this.headers, - }); - } - - public async getCategories(): Promise { - const data = await requestUrl({ - url: `${this.site.url}/apis/content.halo.run/v1alpha1/categories`, - headers: this.headers, - }); - return Promise.resolve(data.json.items); - } - - public async getTags(): Promise { - const data = await requestUrl({ - url: `${this.site.url}/apis/content.halo.run/v1alpha1/tags`, - headers: this.headers, - }); - return Promise.resolve(data.json.items); - } - - public async updatePost(): Promise { - const { activeEditor } = this.app.workspace; - - if (!activeEditor || !activeEditor.file) { - return; - } - - const matterData = this.app.metadataCache.getFileCache(activeEditor.file)?.frontmatter; - - if (!matterData?.halo?.name) { - new Notice(i18next.t("service.error_not_published")); - return; - } - - const post = await this.getPost(matterData.halo.name); - - if (!post) { - new Notice(i18next.t("service.error_post_not_found")); - return; - } - - const postCategories = await this.getCategoryDisplayNames(post.post.spec.categories); - const postTags = await this.getTagDisplayNames(post.post.spec.tags); - - await this.app.vault.modify(activeEditor.file, `${post.content.raw}`); - - this.app.fileManager.processFrontMatter(activeEditor.file, (frontmatter) => { - frontmatter.title = post.post.spec.title; - frontmatter.slug = post.post.spec.slug; - frontmatter.cover = post.post.spec.cover; - frontmatter.excerpt = post.post.spec.excerpt.autoGenerate ? undefined : post.post.spec.excerpt.raw; - frontmatter.categories = postCategories; - frontmatter.tags = postTags; - frontmatter.halo = { - site: this.site.url, - name: post.post.metadata.name, - publish: post.post.spec.publish, - }; - }); - } - - public async pullPost(name: string): Promise { - const post = await this.getPost(name); - - if (!post) { - new Notice(i18next.t("service.error_post_not_found")); - return; - } - - const postCategories = await this.getCategoryDisplayNames(post.post.spec.categories); - const postTags = await this.getTagDisplayNames(post.post.spec.tags); - - const file = await this.app.vault.create(`${post.post.spec.title}.md`, `${post.content.raw}`); - this.app.workspace.getLeaf().openFile(file); - - this.app.fileManager.processFrontMatter(file, (frontmatter) => { - frontmatter.title = post.post.spec.title; - frontmatter.slug = post.post.spec.slug; - frontmatter.cover = post.post.spec.cover; - frontmatter.excerpt = post.post.spec.excerpt.autoGenerate ? undefined : post.post.spec.excerpt.raw; - frontmatter.categories = postCategories; - frontmatter.tags = postTags; - frontmatter.halo = { - site: this.site.url, - name: name, - publish: post.post.spec.publish, - }; - }); - } - - public async getCategoryNames(displayNames: string[]): Promise { - const allCategories = await this.getCategories(); - - const notExistDisplayNames = displayNames.filter( - (name) => !allCategories.find((item) => item.spec.displayName === name), - ); - - const promises = notExistDisplayNames.map((name, index) => - requestUrl({ - url: `${this.site.url}/apis/content.halo.run/v1alpha1/categories`, - method: "POST", - contentType: "application/json", - headers: this.headers, - body: JSON.stringify({ - spec: { - displayName: name, - slug: slugify(name, { trim: true }), - description: "", - cover: "", - template: "", - priority: allCategories.length + index, - children: [], - }, - apiVersion: "content.halo.run/v1alpha1", - kind: "Category", - metadata: { name: "", generateName: "category-" }, - }), - }), - ); - - const newCategories = await Promise.all(promises); - - const existNames = displayNames - .map((name) => { - const found = allCategories.find((item) => item.spec.displayName === name); - return found ? found.metadata.name : undefined; - }) - .filter(Boolean) as string[]; - - return [...existNames, ...newCategories.map((item) => item.json.metadata.name)]; - } - - public async getCategoryDisplayNames(names?: string[]): Promise { - const categories = await this.getCategories(); - return names - ?.map((name) => { - const found = categories.find((item) => item.metadata.name === name); - return found ? found.spec.displayName : undefined; - }) - .filter(Boolean) as string[]; - } - - public async getTagNames(displayNames: string[]): Promise { - const allTags = await this.getTags(); - - const notExistDisplayNames = displayNames.filter((name) => !allTags.find((item) => item.spec.displayName === name)); - - const promises = notExistDisplayNames.map((name) => - requestUrl({ - url: `${this.site.url}/apis/content.halo.run/v1alpha1/tags`, - method: "POST", - contentType: "application/json", - headers: this.headers, - body: JSON.stringify({ - spec: { - displayName: name, - slug: slugify(name, { trim: true }), - color: "#ffffff", - cover: "", - }, - apiVersion: "content.halo.run/v1alpha1", - kind: "Tag", - metadata: { name: "", generateName: "tag-" }, - }), - }), - ); - - const newTags = await Promise.all(promises); - - const existNames = displayNames - .map((name) => { - const found = allTags.find((item) => item.spec.displayName === name); - return found ? found.metadata.name : undefined; - }) - .filter(Boolean) as string[]; - - return [...existNames, ...newTags.map((item) => item.json.metadata.name)]; - } - - public async getTagDisplayNames(names?: string[]): Promise { - const tags = await this.getTags(); - return names - ?.map((name) => { - const found = tags.find((item) => item.metadata.name === name); - return found ? found.spec.displayName : undefined; - }) - .filter(Boolean) as string[]; - } - - public async importPost(file: TFile, publishImmediately: boolean = false): Promise { - try { - const imageUploader = new ImageUploader(this.site.url, this.site.token); - - const md = await this.app.vault.read(file); - const matterData = this.app.metadataCache.getFileCache(file)?.frontmatter; - const frontmatterPosition = this.app.metadataCache.getFileCache(file)?.frontmatterPosition; - - let raw = frontmatterPosition ? md.slice(frontmatterPosition.end.offset) : md; - - // 检测并上传本地图片 - if (this.settings.imageUpload?.enabled) { - const imageReferences = extractImageReferences(raw); - if (imageReferences.length > 0) { - const localImages = imageReferences.filter((ref) => - !ref.path.startsWith("http://") && - !ref.path.startsWith("https://") && - !ref.path.startsWith("data:") - ); - - if (localImages.length > 0) { - new Notice(`检测到 ${localImages.length} 个本地图片,正在上传...`); - - const absolutePaths = localImages - .map((ref) => ({ - original: ref.path, - absolute: getAbsolutePath(this.app.vault, ref.path, file.path), - })) - .filter((item) => item.absolute !== null) as { original: string; absolute: string }[]; - - if (absolutePaths.length > 0) { - const pathMapping = await imageUploader.uploadImages(absolutePaths.map((item) => item.absolute), this.app.vault); - - if (pathMapping.size > 0) { - const mapping = new Map(); - absolutePaths.forEach((item) => { - const remoteUrl = pathMapping.get(item.absolute); - if (remoteUrl) { - mapping.set(item.original, remoteUrl); - } - }); - - raw = replaceImagePaths(raw, mapping); - - const successCount = mapping.size; - const failCount = absolutePaths.length - successCount; - - if (failCount === 0) { - new Notice(`✓ 图片上传成功 (${successCount}/${absolutePaths.length})`); - } else { - new Notice(`⚠ 部分图片上传失败 (成功: ${successCount}, 失败: ${failCount})`); - } - } - } - } - } - } - - const content: Content = { - rawType: "markdown", - raw: raw, - content: markdownIt.render(raw), - }; - - let params: Post = { - apiVersion: "content.halo.run/v1alpha1", - kind: "Post", - metadata: { - annotations: { - "content.halo.run/content-json": JSON.stringify(content), - }, - name: randomUUID(), - }, - spec: { - allowComment: true, - baseSnapshot: "", - categories: [], - cover: matterData?.cover || "", - deleted: false, - excerpt: { - autoGenerate: matterData?.excerpt ? false : true, - raw: matterData?.excerpt || "", - }, - headSnapshot: "", - htmlMetas: [], - owner: "", - pinned: false, - priority: 0, - publish: publishImmediately, - publishTime: "", - releaseSnapshot: "", - slug: matterData?.slug || slugify(matterData?.title || file.basename, { trim: true }), - tags: [], - template: "", - title: matterData?.title || file.basename, - visible: "PUBLIC", - }, - }; - - // 处理分类 - if (matterData?.categories) { - const categoryNames = await this.getCategoryNames(matterData.categories); - params.spec.categories = categoryNames; - } - - // 处理标签 - if (matterData?.tags) { - const tagNames = await this.getTagNames(matterData.tags); - params.spec.tags = tagNames; - } - - // 创建文章 - const post = await requestUrl({ - url: `${this.site.url}/apis/uc.api.content.halo.run/v1alpha1/posts`, - method: "POST", - contentType: "application/json", - headers: this.headers, - body: JSON.stringify(params), - }).json; - - return !!post && !!post.metadata; - } catch (error) { - console.error("[HaloService] 导入文章失败:", error); - return false; - } - } - - public async deletePost(name: string): Promise { - try { - await requestUrl({ - url: `${this.site.url}/apis/uc.api.content.halo.run/v1alpha1/posts/${name}`, - method: "DELETE", - headers: this.headers, - }); - - return true; - } catch (error) { - console.error("[HaloService] 删除文章失败:", error); - return false; - } - } - - public async createTag(displayName: string, slug: string, color: string): Promise { - await requestUrl({ - url: `${this.site.url}/apis/content.halo.run/v1alpha1/tags`, - method: "POST", - contentType: "application/json", - headers: this.headers, - body: JSON.stringify({ - spec: { - displayName, - slug, - color: color || "#ffffff", - cover: "", - }, - apiVersion: "content.halo.run/v1alpha1", - kind: "Tag", - metadata: { name: "", generateName: "tag-" }, - }), - }); - } - - public async updateTag(name: string, displayName: string, slug: string, color: string): Promise { - const tag = (await requestUrl({ - url: `${this.site.url}/apis/content.halo.run/v1alpha1/tags/${name}`, - headers: this.headers, - }).json) as any; - - tag.spec.displayName = displayName; - tag.spec.slug = slug; - tag.spec.color = color || "#ffffff"; - - await requestUrl({ - url: `${this.site.url}/apis/content.halo.run/v1alpha1/tags/${name}`, - method: "PUT", - contentType: "application/json", - headers: this.headers, - body: JSON.stringify(tag), - }); - } - - public async deleteTag(name: string): Promise { - await requestUrl({ - url: `${this.site.url}/apis/content.halo.run/v1alpha1/tags/${name}`, - method: "DELETE", - headers: this.headers, - }); - } - - public async createCategory(displayName: string, slug: string, priority: number): Promise { - await requestUrl({ - url: `${this.site.url}/apis/content.halo.run/v1alpha1/categories`, - method: "POST", - contentType: "application/json", - headers: this.headers, - body: JSON.stringify({ - spec: { - displayName, - slug, - description: "", - cover: "", - template: "", - priority, - children: [], - }, - apiVersion: "content.halo.run/v1alpha1", - kind: "Category", - metadata: { name: "", generateName: "category-" }, - }), - }); - } - - public async updateCategory(name: string, displayName: string, slug: string, priority: number): Promise { - const category = (await requestUrl({ - url: `${this.site.url}/apis/content.halo.run/v1alpha1/categories/${name}`, - headers: this.headers, - }).json) as any; - - category.spec.displayName = displayName; - category.spec.slug = slug; - category.spec.priority = priority; - - await requestUrl({ - url: `${this.site.url}/apis/content.halo.run/v1alpha1/categories/${name}`, - method: "PUT", - contentType: "application/json", - headers: this.headers, - body: JSON.stringify(category), - }); - } - - public async deleteCategory(name: string): Promise { - await requestUrl({ - url: `${this.site.url}/apis/content.halo.run/v1alpha1/categories/${name}`, - method: "DELETE", - headers: this.headers, - }); - } -} - -export default HaloService; +// 向后兼容:重新导出新的模块化 HaloService +// 新实现位于 src/services/halo-service.ts +export { + HaloService, + default, +} from "../services/halo-service"; diff --git a/obsidian-halo/src/services/__tests__/content-service.test.ts b/obsidian-halo/src/services/__tests__/content-service.test.ts new file mode 100644 index 0000000..e2f90b6 --- /dev/null +++ b/obsidian-halo/src/services/__tests__/content-service.test.ts @@ -0,0 +1,179 @@ +import { describe, it, expect } from "vitest"; +import { ContentService } from "../content-service"; + +describe("ContentService", () => { + const service = new ContentService(); + + describe("renderMarkdown", () => { + it("should render markdown to HTML", () => { + const result = service.renderMarkdown("# Hello World"); + + expect(result).toContain(" { + const result = service.renderMarkdown("**bold**"); + + expect(result).toContain("bold"); + }); + + it("should render links", () => { + const result = service.renderMarkdown("[link](https://example.com)"); + + expect(result).toContain('link'); + }); + + it("should render code blocks", () => { + const result = service.renderMarkdown("```js\nconst x = 1;\n```"); + + expect(result).toContain("
 {
+    it("should extract frontmatter from markdown", () => {
+      const md = `---
+title: Test Title
+slug: test-slug
+tags:
+  - tag1
+  - tag2
+---
+
+# Content`;
+
+      const { frontmatter, rawContent } = service.extractFrontmatter(md);
+
+      expect(frontmatter.title).toBe("Test Title");
+      expect(frontmatter.slug).toBe("test-slug");
+      expect(frontmatter.tags).toEqual(["tag1", "tag2"]);
+      expect(rawContent.trim()).toBe("# Content");
+    });
+
+    it("should handle markdown without frontmatter", () => {
+      const md = "# Just Content";
+
+      const { frontmatter, rawContent } = service.extractFrontmatter(md);
+
+      expect(frontmatter).toEqual({});
+      expect(rawContent).toBe("# Just Content");
+    });
+
+    it("should handle frontmatter with categories", () => {
+      const md = `---
+title: Categories Test
+categories:
+  - 技术
+  - 编程
+---
+
+Content here`;
+
+      const { frontmatter, rawContent } = service.extractFrontmatter(md);
+
+      expect(frontmatter.categories).toEqual(["技术", "编程"]);
+      expect(rawContent.trim()).toBe("Content here");
+    });
+
+    it("should handle frontmatter with halo metadata", () => {
+      const md = `---
+title: Halo Post
+halo:
+  site: https://example.com
+  name: abc-123
+  publish: true
+---
+
+Content`;
+
+      const { frontmatter } = service.extractFrontmatter(md);
+
+      expect(frontmatter.halo?.site).toBe("https://example.com");
+      expect(frontmatter.halo?.name).toBe("abc-123");
+      expect(frontmatter.halo?.publish).toBe(true);
+    });
+
+    it("should handle empty frontmatter", () => {
+      const md = `---
+---
+
+Content`;
+
+      const { frontmatter, rawContent } = service.extractFrontmatter(md);
+
+      expect(frontmatter).toEqual({});
+      expect(rawContent.trim()).toBe("Content");
+    });
+  });
+
+  describe("buildPostSpec", () => {
+    it("should build post spec with default values", () => {
+      const spec = service.buildPostSpec(
+        "Test Title",
+        "test-slug",
+        "# Content",
+        {},
+        { rawType: "markdown", raw: "# Content", content: "

Content

" } + ); + + expect(spec.title).toBe("Test Title"); + expect(spec.slug).toBe("test-slug"); + expect(spec.allowComment).toBe(true); + expect(spec.visible).toBe("PUBLIC"); + expect(spec.publish).toBe(false); + expect(spec.excerpt.autoGenerate).toBe(true); + }); + + it("should use provided slug", () => { + const spec = service.buildPostSpec( + "Title", + "custom-slug", + "# Content", + {}, + { rawType: "markdown", raw: "# Content", content: "

Content

" } + ); + + expect(spec.slug).toBe("custom-slug"); + }); + + it("should auto-generate slug from title if not provided", () => { + const spec = service.buildPostSpec( + "Test Title", + "", + "# Content", + {}, + { rawType: "markdown", raw: "# Content", content: "

Content

" } + ); + + // slugify should convert "Test Title" to "test-title" + expect(spec.slug).toBeTruthy(); + expect(spec.slug).toContain("test"); + }); + + it("should use excerpt from frontmatter", () => { + const spec = service.buildPostSpec( + "Title", + "slug", + "# Content", + { excerpt: "Custom excerpt" }, + { rawType: "markdown", raw: "# Content", content: "

Content

" } + ); + + expect(spec.excerpt.autoGenerate).toBe(false); + expect(spec.excerpt.raw).toBe("Custom excerpt"); + }); + + it("should use cover from frontmatter", () => { + const spec = service.buildPostSpec( + "Title", + "slug", + "# Content", + { cover: "https://example.com/cover.jpg" }, + { rawType: "markdown", raw: "# Content", content: "

Content

" } + ); + + expect(spec.cover).toBe("https://example.com/cover.jpg"); + }); + }); +}); \ No newline at end of file diff --git a/obsidian-halo/src/services/__tests__/error.test.ts b/obsidian-halo/src/services/__tests__/error.test.ts new file mode 100644 index 0000000..c23865e --- /dev/null +++ b/obsidian-halo/src/services/__tests__/error.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect } from "vitest"; +import { HaloError, HttpError } from "../error"; + +describe("HaloError", () => { + it("should create error with all properties", () => { + const error = new HaloError("Test message", "TEST_CODE", 400, new Error("original")); + + expect(error.message).toBe("Test message"); + expect(error.code).toBe("TEST_CODE"); + expect(error.statusCode).toBe(400); + expect(error.originalError).toBeInstanceOf(Error); + expect(error.name).toBe("HaloError"); + }); + + it("should create network error", () => { + const error = HaloError.network(new Error("fetch failed")); + + expect(error.code).toBe("NETWORK_ERROR"); + expect(error.message).toBe("网络请求失败,请检查网络连接"); + expect(error.isNetworkError()).toBe(true); + expect(error.isAuthError()).toBe(false); + }); + + it("should create unauthorized error", () => { + const error = HaloError.unauthorized(); + + expect(error.code).toBe("UNAUTHORIZED"); + expect(error.statusCode).toBe(401); + expect(error.isAuthError()).toBe(true); + }); + + it("should create forbidden error", () => { + const error = HaloError.forbidden(); + + expect(error.code).toBe("FORBIDDEN"); + expect(error.statusCode).toBe(403); + expect(error.isAuthError()).toBe(true); + }); + + it("should create notFound error", () => { + const error = HaloError.notFound("Post"); + + expect(error.code).toBe("NOT_FOUND"); + expect(error.statusCode).toBe(404); + expect(error.message).toBe("Post 未找到"); + expect(error.isNotFound()).toBe(true); + }); + + it("should create server error", () => { + const error = HaloError.serverError(); + + expect(error.code).toBe("SERVER_ERROR"); + expect(error.statusCode).toBe(500); + }); + + it("should create validation error", () => { + const error = HaloError.validationError("Invalid slug"); + + expect(error.code).toBe("VALIDATION_ERROR"); + expect(error.message).toBe("Invalid slug"); + }); + + it("should create unknown error from Error", () => { + const original = new Error("Something went wrong"); + const error = HaloError.unknown(original); + + expect(error.code).toBe("UNKNOWN"); + expect(error.message).toBe("Something went wrong"); + }); + + it("should create unknown error from non-Error", () => { + const error = HaloError.unknown("string error"); + + expect(error.code).toBe("UNKNOWN"); + expect(error.message).toBe("未知错误"); + }); +}); + +describe("HttpError", () => { + it("should create from status code 400", () => { + const error = HttpError.fromStatus(400, { message: "bad request" }); + + expect(error.statusCode).toBe(400); + expect(error.message).toBe("请求参数错误"); + expect(error.name).toBe("HttpError"); + }); + + it("should create from status code 401", () => { + const error = HttpError.fromStatus(401); + + expect(error.statusCode).toBe(401); + expect(error.message).toBe("认证失败"); + }); + + it("should create from status code 404", () => { + const error = HttpError.fromStatus(404); + + expect(error.statusCode).toBe(404); + expect(error.message).toBe("资源不存在"); + }); + + it("should create from status code 500", () => { + const error = HttpError.fromStatus(500); + + expect(error.statusCode).toBe(500); + expect(error.message).toBe("服务器内部错误"); + }); + + it("should create from unknown status code", () => { + const error = HttpError.fromStatus(418); + + expect(error.statusCode).toBe(418); + expect(error.message).toBe("HTTP 错误 (418)"); + }); +}); \ No newline at end of file diff --git a/obsidian-halo/src/services/__tests__/taxonomy-service.test.ts b/obsidian-halo/src/services/__tests__/taxonomy-service.test.ts new file mode 100644 index 0000000..6e815f4 --- /dev/null +++ b/obsidian-halo/src/services/__tests__/taxonomy-service.test.ts @@ -0,0 +1,242 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { TaxonomyService } from "../taxonomy-service"; +import type { HaloClient } from "../client"; +import type { Category, Tag } from "../types"; + +describe("TaxonomyService", () => { + let service: TaxonomyService; + let mockClient: HaloClient; + + beforeEach(() => { + mockClient = { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + delete: vi.fn(), + } as unknown as HaloClient; + service = new TaxonomyService(mockClient); + }); + + describe("getCategories", () => { + it("should return categories from API", async () => { + const mockCategories: Category[] = [ + { + metadata: { name: "cat-1" }, + spec: { displayName: "技术", slug: "tech", description: "", cover: "", template: "", priority: 0, children: [] }, + }, + { + metadata: { name: "cat-2" }, + spec: { displayName: "生活", slug: "life", description: "", cover: "", template: "", priority: 1, children: [] }, + }, + ]; + + vi.mocked(mockClient.get).mockResolvedValue({ items: mockCategories }); + + const result = await service.getCategories(); + + expect(result).toEqual(mockCategories); + expect(mockClient.get).toHaveBeenCalledWith("/apis/content.halo.run/v1alpha1/categories"); + }); + }); + + describe("getTags", () => { + it("should return tags from API", async () => { + const mockTags: Tag[] = [ + { + metadata: { name: "tag-1" }, + spec: { displayName: "Python", slug: "python", color: "#3776AB", cover: "" }, + }, + ]; + + vi.mocked(mockClient.get).mockResolvedValue({ items: mockTags }); + + const result = await service.getTags(); + + expect(result).toEqual(mockTags); + expect(mockClient.get).toHaveBeenCalledWith("/apis/content.halo.run/v1alpha1/tags"); + }); + }); + + describe("getCategoryNames", () => { + it("should return existing category names", async () => { + const mockCategories: Category[] = [ + { + metadata: { name: "cat-1" }, + spec: { displayName: "技术", slug: "tech", description: "", cover: "", template: "", priority: 0, children: [] }, + }, + ]; + + vi.mocked(mockClient.get).mockResolvedValue({ items: mockCategories }); + + const result = await service.getCategoryNames(["技术"]); + + expect(result).toContain("cat-1"); + }); + + it("should create new categories if not exist", async () => { + const mockCategories: Category[] = []; + const newCategory: Category = { + metadata: { name: "new-cat" }, + spec: { displayName: "新分类", slug: "xin-fen-lei", description: "", cover: "", template: "", priority: 0, children: [] }, + }; + + vi.mocked(mockClient.get).mockResolvedValue({ items: mockCategories }); + vi.mocked(mockClient.post).mockResolvedValue(newCategory); + + const result = await service.getCategoryNames(["新分类"]); + + expect(result).toContain("new-cat"); + expect(mockClient.post).toHaveBeenCalled(); + }); + }); + + describe("getTagNames", () => { + it("should return existing tag names", async () => { + const mockTags: Tag[] = [ + { + metadata: { name: "tag-1" }, + spec: { displayName: "Python", slug: "python", color: "#3776AB", cover: "" }, + }, + ]; + + vi.mocked(mockClient.get).mockResolvedValue({ items: mockTags }); + + const result = await service.getTagNames(["Python"]); + + expect(result).toContain("tag-1"); + }); + + it("should create new tags if not exist", async () => { + const mockTags: Tag[] = []; + const newTag: Tag = { + metadata: { name: "new-tag" }, + spec: { displayName: "新标签", slug: "xin-biao-qian", color: "#ffffff", cover: "" }, + }; + + vi.mocked(mockClient.get).mockResolvedValue({ items: mockTags }); + vi.mocked(mockClient.post).mockResolvedValue(newTag); + + const result = await service.getTagNames(["新标签"]); + + expect(result).toContain("new-tag"); + expect(mockClient.post).toHaveBeenCalled(); + }); + }); + + describe("getCategoryDisplayNames", () => { + it("should convert category names to display names", async () => { + const mockCategories: Category[] = [ + { + metadata: { name: "cat-1" }, + spec: { displayName: "技术", slug: "tech", description: "", cover: "", template: "", priority: 0, children: [] }, + }, + ]; + + vi.mocked(mockClient.get).mockResolvedValue({ items: mockCategories }); + + const result = await service.getCategoryDisplayNames(["cat-1"]); + + expect(result).toEqual(["技术"]); + }); + + it("should return empty array for empty input", async () => { + const result = await service.getCategoryDisplayNames([]); + + expect(result).toEqual([]); + }); + + it("should filter out unknown category names", async () => { + const mockCategories: Category[] = [ + { + metadata: { name: "cat-1" }, + spec: { displayName: "技术", slug: "tech", description: "", cover: "", template: "", priority: 0, children: [] }, + }, + ]; + + vi.mocked(mockClient.get).mockResolvedValue({ items: mockCategories }); + + const result = await service.getCategoryDisplayNames(["cat-1", "unknown-cat"]); + + expect(result).toEqual(["技术"]); + }); + }); + + describe("getTagDisplayNames", () => { + it("should convert tag names to display names", async () => { + const mockTags: Tag[] = [ + { + metadata: { name: "tag-1" }, + spec: { displayName: "Python", slug: "python", color: "#3776AB", cover: "" }, + }, + ]; + + vi.mocked(mockClient.get).mockResolvedValue({ items: mockTags }); + + const result = await service.getTagDisplayNames(["tag-1"]); + + expect(result).toEqual(["Python"]); + }); + }); + + describe("createCategory", () => { + it("should create a new category", async () => { + const newCategory: Category = { + metadata: { name: "cat-created" }, + spec: { displayName: "新分类", slug: "xin-fen-lei", description: "", cover: "", template: "", priority: 0, children: [] }, + }; + + vi.mocked(mockClient.post).mockResolvedValue(newCategory); + + const result = await service.createCategory("新分类", "xin-fen-lei", 0); + + expect(result).toEqual(newCategory); + expect(mockClient.post).toHaveBeenCalledWith( + "/apis/content.halo.run/v1alpha1/categories", + expect.objectContaining({ + spec: expect.objectContaining({ displayName: "新分类", slug: "xin-fen-lei" }), + }), + ); + }); + }); + + describe("createTag", () => { + it("should create a new tag", async () => { + const newTag: Tag = { + metadata: { name: "tag-created" }, + spec: { displayName: "新标签", slug: "xin-biao-qian", color: "#ffffff", cover: "" }, + }; + + vi.mocked(mockClient.post).mockResolvedValue(newTag); + + const result = await service.createTag("新标签", "xin-biao-qian", "#ffffff"); + + expect(result).toEqual(newTag); + expect(mockClient.post).toHaveBeenCalledWith( + "/apis/content.halo.run/v1alpha1/tags", + expect.objectContaining({ + spec: expect.objectContaining({ displayName: "新标签", slug: "xin-biao-qian", color: "#ffffff" }), + }), + ); + }); + }); + + describe("deleteCategory", () => { + it("should delete a category", async () => { + vi.mocked(mockClient.delete).mockResolvedValue(); + + await service.deleteCategory("cat-1"); + + expect(mockClient.delete).toHaveBeenCalledWith("/apis/content.halo.run/v1alpha1/categories/cat-1"); + }); + }); + + describe("deleteTag", () => { + it("should delete a tag", async () => { + vi.mocked(mockClient.delete).mockResolvedValue(); + + await service.deleteTag("tag-1"); + + expect(mockClient.delete).toHaveBeenCalledWith("/apis/content.halo.run/v1alpha1/tags/tag-1"); + }); + }); +}); \ No newline at end of file diff --git a/obsidian-halo/src/services/client.ts b/obsidian-halo/src/services/client.ts index f455bf0..b7ec5aa 100644 --- a/obsidian-halo/src/services/client.ts +++ b/obsidian-halo/src/services/client.ts @@ -1,17 +1,24 @@ import { requestUrl } from "obsidian"; import type { HaloSite } from "../settings"; import { HaloError, HttpError } from "./error"; +import { withRetry, type RetryOptions } from "../utils/retry"; + +export interface HaloClientOptions { + retry?: RetryOptions; +} export class HaloClient { private readonly baseUrl: string; private readonly headers: Record; + private readonly retryOptions: RetryOptions; - constructor(site: HaloSite) { + constructor(site: HaloSite, options: HaloClientOptions = {}) { this.baseUrl = site.url; this.headers = { "Content-Type": "application/json", Authorization: `Bearer ${site.token}`, }; + this.retryOptions = options.retry || {}; } private handleResponse(response: { json: unknown; status: number }): T { @@ -25,7 +32,7 @@ export class HaloClient { } private async request(path: string, options: { method: string; body?: object }): Promise { - try { + const doRequest = async () => { const response = await requestUrl({ url: `${this.baseUrl}${path}`, method: options.method, @@ -35,12 +42,15 @@ export class HaloClient { }); return this.handleResponse({ json: response.json, status: response.status }); + }; + + try { + return await withRetry(doRequest, this.retryOptions); } catch (error) { if (error instanceof HttpError) { throw this.mapHttpError(error); } if (error instanceof TypeError) { - // Network error throw HaloError.network(error); } throw HaloError.unknown(error); @@ -77,7 +87,7 @@ export class HaloClient { } async delete(path: string): Promise { - try { + const doDelete = async () => { const response = await requestUrl({ url: `${this.baseUrl}${path}`, method: "DELETE", @@ -87,6 +97,10 @@ export class HaloClient { if (response.status >= 400) { throw HttpError.fromStatus(response.status, response.json); } + }; + + try { + await withRetry(doDelete, this.retryOptions); } catch (error) { if (error instanceof HttpError) { throw this.mapHttpError(error); @@ -99,7 +113,7 @@ export class HaloClient { } async putVoid(path: string, body?: object): Promise { - try { + const doPut = async () => { const response = await requestUrl({ url: `${this.baseUrl}${path}`, method: "PUT", @@ -111,6 +125,10 @@ export class HaloClient { if (response.status >= 400) { throw HttpError.fromStatus(response.status, response.json); } + }; + + try { + await withRetry(doPut, this.retryOptions); } catch (error) { if (error instanceof HttpError) { throw this.mapHttpError(error); diff --git a/obsidian-halo/src/services/halo-service.ts b/obsidian-halo/src/services/halo-service.ts index 8a46853..238ed7b 100644 --- a/obsidian-halo/src/services/halo-service.ts +++ b/obsidian-halo/src/services/halo-service.ts @@ -1,19 +1,21 @@ import { type App, Notice, type TFile } from "obsidian"; -import { randomUUID } from "./utils/id"; -import markdownIt from "./utils/markdown"; +import { randomUUID } from "../utils/id"; +import markdownIt from "../utils/markdown"; import { slugify } from "transliteration"; -import type { HaloSetting, HaloSite } from "./settings"; -import type { HaloClient } from "./services/client"; -import { PostService } from "./services/post-service"; -import { ImageService } from "./services/image-service"; -import { TaxonomyService } from "./services/taxonomy-service"; -import { ContentService } from "./services/content-service"; -import type { Content, Post } from "./services/types"; +import type { HaloSetting, HaloSite } from "../settings"; +import { HaloClient } from "./client"; +import { PostService } from "./post-service"; +import { ImageService } from "./image-service"; +import { TaxonomyService } from "./taxonomy-service"; +import { ContentService } from "./content-service"; +import type { Content, Post } from "./types"; import { HaloError } from "./error"; +import { logger } from "../utils/logger"; export class HaloService { private readonly app: App; private readonly siteUrl: string; + private readonly settings: HaloSetting; private readonly client: HaloClient; private readonly postService: PostService; private readonly imageService: ImageService; @@ -23,6 +25,7 @@ export class HaloService { constructor(app: App, settings: HaloSetting, site: HaloSite) { this.app = app; this.siteUrl = site.url; + this.settings = settings; this.client = new HaloClient(site); this.postService = new PostService(app, this.client); this.imageService = new ImageService(site); @@ -162,10 +165,10 @@ export class HaloService { new Notice("发布成功"); } catch (error) { if (error instanceof HaloError) { - console.error(`[HaloService] 发布失败 [${error.code}]:`, error.message); + logger.error("HaloService", `发布失败 [${error.code}]`, error.message); new Notice(error.message); } else { - console.error("[HaloService] 发布失败:", error); + logger.error("HaloService", "发布失败", error); new Notice("发布失败"); } } @@ -317,9 +320,9 @@ export class HaloService { return !!post && !!post.metadata; } catch (error) { if (error instanceof HaloError) { - console.error(`[HaloService] 导入文章失败 [${error.code}]:`, error.message); + logger.error("HaloService", `导入文章失败 [${error.code}]`, error.message); } else { - console.error("[HaloService] 导入文章失败:", error); + logger.error("HaloService", "导入文章失败", error); } return false; } @@ -331,9 +334,9 @@ export class HaloService { return true; } catch (error) { if (error instanceof HaloError) { - console.error(`[HaloService] 删除文章失败 [${error.code}]:`, error.message); + logger.error("HaloService", `删除文章失败 [${error.code}]`, error.message); } else { - console.error("[HaloService] 删除文章失败:", error); + logger.error("HaloService", "删除文章失败", error); } return false; } diff --git a/obsidian-halo/src/services/post-service.ts b/obsidian-halo/src/services/post-service.ts index d9ffa9d..2ec280a 100644 --- a/obsidian-halo/src/services/post-service.ts +++ b/obsidian-halo/src/services/post-service.ts @@ -3,6 +3,7 @@ import { randomUUID } from "../utils/id"; import type { HaloClient } from "./client"; import type { Post, Snapshot, Content } from "./types"; import { HaloError } from "./error"; +import { ApiPaths } from "../utils/api-paths"; export class PostService { constructor( @@ -12,11 +13,9 @@ export class PostService { async getPost(name: string): Promise<{ post: Post; content: Content } | undefined> { try { - const post = await this.client.get(`/apis/uc.api.content.halo.run/v1alpha1/posts/${name}`); + const post = await this.client.get(ApiPaths.posts.get(name)); - const snapshot = await this.client.get( - `/apis/uc.api.content.halo.run/v1alpha1/posts/${name}/draft?patched=true`, - ); + const snapshot = await this.client.get(ApiPaths.posts.getDraft(name)); const { "content.halo.run/patched-content": patchedContent, "content.halo.run/patched-raw": patchedRaw } = snapshot.metadata.annotations || {}; @@ -39,21 +38,19 @@ export class PostService { } async createPost(params: Post): Promise { - return await this.client.post("/apis/uc.api.content.halo.run/v1alpha1/posts", params); + return await this.client.post(ApiPaths.posts.create(), params); } async updatePost(name: string, params: Post): Promise { - await this.client.put(`/apis/uc.api.content.halo.run/v1alpha1/posts/${name}`, params); + await this.client.put(ApiPaths.posts.update(name), params); } async deletePost(name: string): Promise { - await this.client.delete(`/apis/uc.api.content.halo.run/v1alpha1/posts/${name}`); + await this.client.delete(ApiPaths.posts.delete(name)); } async changePublishStatus(name: string, publish: boolean): Promise { - await this.client.putVoid( - `/apis/uc.api.content.halo.run/v1alpha1/posts/${name}/${publish ? "publish" : "unpublish"}`, - ); + await this.client.putVoid(publish ? ApiPaths.posts.publish(name) : ApiPaths.posts.unpublish(name)); } async createPostFromFile( diff --git a/obsidian-halo/src/services/taxonomy-service.ts b/obsidian-halo/src/services/taxonomy-service.ts index 06c84fa..e8bdc5c 100644 --- a/obsidian-halo/src/services/taxonomy-service.ts +++ b/obsidian-halo/src/services/taxonomy-service.ts @@ -1,17 +1,18 @@ import { slugify } from "transliteration"; import type { HaloClient } from "./client"; import type { Category, Tag } from "./types"; +import { ApiPaths } from "../utils/api-paths"; export class TaxonomyService { constructor(private client: HaloClient) {} async getCategories(): Promise { - const data = await this.client.get<{ items: Category[] }>("/apis/content.halo.run/v1alpha1/categories"); + const data = await this.client.get<{ items: Category[] }>(ApiPaths.categories.list()); return data.items; } async getTags(): Promise { - const data = await this.client.get<{ items: Tag[] }>("/apis/content.halo.run/v1alpha1/tags"); + const data = await this.client.get<{ items: Tag[] }>(ApiPaths.tags.list()); return data.items; } @@ -50,7 +51,7 @@ export class TaxonomyService { } async createCategory(displayName: string, slug: string, priority: number): Promise { - return await this.client.post("/apis/content.halo.run/v1alpha1/categories", { + return await this.client.post(ApiPaths.categories.create(), { spec: { displayName, slug, @@ -67,15 +68,15 @@ export class TaxonomyService { } async updateCategory(name: string, displayName: string, slug: string, priority: number): Promise { - const category = await this.client.get(`/apis/content.halo.run/v1alpha1/categories/${name}`); + const category = await this.client.get(ApiPaths.categories.get(name)); category.spec.displayName = displayName; category.spec.slug = slug; category.spec.priority = priority; - await this.client.put(`/apis/content.halo.run/v1alpha1/categories/${name}`, category); + await this.client.put(ApiPaths.categories.update(name), category); } async deleteCategory(name: string): Promise { - await this.client.delete(`/apis/content.halo.run/v1alpha1/categories/${name}`); + await this.client.delete(ApiPaths.categories.delete(name)); } async getTagNames(displayNames: string[]): Promise { @@ -111,7 +112,7 @@ export class TaxonomyService { } async createTag(displayName: string, slug: string, color: string): Promise { - return await this.client.post("/apis/content.halo.run/v1alpha1/tags", { + return await this.client.post(ApiPaths.tags.create(), { spec: { displayName, slug, @@ -125,14 +126,14 @@ export class TaxonomyService { } async updateTag(name: string, displayName: string, slug: string, color: string): Promise { - const tag = await this.client.get(`/apis/content.halo.run/v1alpha1/tags/${name}`); + const tag = await this.client.get(ApiPaths.tags.get(name)); tag.spec.displayName = displayName; tag.spec.slug = slug; tag.spec.color = color || "#ffffff"; - await this.client.put(`/apis/content.halo.run/v1alpha1/tags/${name}`, tag); + await this.client.put(ApiPaths.tags.update(name), tag); } async deleteTag(name: string): Promise { - await this.client.delete(`/apis/content.halo.run/v1alpha1/tags/${name}`); + await this.client.delete(ApiPaths.tags.delete(name)); } } diff --git a/obsidian-halo/src/services/types.ts b/obsidian-halo/src/services/types.ts index 83cf101..530a327 100644 --- a/obsidian-halo/src/services/types.ts +++ b/obsidian-halo/src/services/types.ts @@ -34,9 +34,12 @@ export interface Post { export interface Snapshot { metadata: { annotations?: Record; + name?: string; }; spec?: { rawType?: string; + displayName?: string; + slug?: string; }; } diff --git a/obsidian-halo/src/utils/api-paths.ts b/obsidian-halo/src/utils/api-paths.ts new file mode 100644 index 0000000..c02c1e2 --- /dev/null +++ b/obsidian-halo/src/utils/api-paths.ts @@ -0,0 +1,66 @@ +// Halo API 路径常量 +// 便于统一管理和 Halo API 升级时批量修改 + +export const ApiPaths = { + // 文章相关 (UC API) + posts: { + list: (page: number, size: number, labelSelector?: string) => + `/apis/uc.api.content.halo.run/v1alpha1/posts?page=${page}&size=${size}${labelSelector ? `&labelSelector=${encodeURIComponent(labelSelector)}` : ""}`, + get: (name: string) => + `/apis/uc.api.content.halo.run/v1alpha1/posts/${name}`, + getDraft: (name: string) => + `/apis/uc.api.content.halo.run/v1alpha1/posts/${name}/draft?patched=true`, + create: () => + `/apis/uc.api.content.halo.run/v1alpha1/posts`, + update: (name: string) => + `/apis/uc.api.content.halo.run/v1alpha1/posts/${name}`, + delete: (name: string) => + `/apis/uc.api.content.halo.run/v1alpha1/posts/${name}`, + publish: (name: string) => + `/apis/uc.api.content.halo.run/v1alpha1/posts/${name}/publish`, + unpublish: (name: string) => + `/apis/uc.api.content.halo.run/v1alpha1/posts/${name}/unpublish`, + }, + + // 分类相关 (Content API) + categories: { + list: () => + `/apis/content.halo.run/v1alpha1/categories`, + get: (name: string) => + `/apis/content.halo.run/v1alpha1/categories/${name}`, + create: () => + `/apis/content.halo.run/v1alpha1/categories`, + update: (name: string) => + `/apis/content.halo.run/v1alpha1/categories/${name}`, + delete: (name: string) => + `/apis/content.halo.run/v1alpha1/categories/${name}`, + }, + + // 标签相关 (Content API) + tags: { + list: () => + `/apis/content.halo.run/v1alpha1/tags`, + get: (name: string) => + `/apis/content.halo.run/v1alpha1/tags/${name}`, + create: () => + `/apis/content.halo.run/v1alpha1/tags`, + update: (name: string) => + `/apis/content.halo.run/v1alpha1/tags/${name}`, + delete: (name: string) => + `/apis/content.halo.run/v1alpha1/tags/${name}`, + }, + + // 附件/图片上传 (Console API) + attachments: { + upload: () => + `/apis/api.console.halo.run/v1alpha1/attachments/upload`, + }, + + // 用户权限 (Console API) + users: { + permissions: () => + `/apis/api.console.halo.run/v1alpha1/users/-/permissions`, + }, +} as const; + +export type ApiPaths = typeof ApiPaths; \ No newline at end of file diff --git a/obsidian-halo/src/utils/logger.ts b/obsidian-halo/src/utils/logger.ts new file mode 100644 index 0000000..7623981 --- /dev/null +++ b/obsidian-halo/src/utils/logger.ts @@ -0,0 +1,60 @@ +export enum LogLevel { + DEBUG = 0, + INFO = 1, + WARN = 2, + ERROR = 3, + NONE = 4, +} + +const currentLevel = LogLevel.INFO; + +const PREFIX = { + HaloService: "HaloService", + PostService: "PostService", + ImageService: "ImageService", + TaxonomyService: "TaxonomyService", + ContentService: "ContentService", + HaloClient: "HaloClient", + Plugin: "HaloPlugin", + ImageUploader: "ImageUploader", +}; + +function formatMessage(prefix: string, message: string): string { + return `[${prefix}] ${message}`; +} + +function shouldLog(level: LogLevel): boolean { + return level >= currentLevel; +} + +export const logger = { + debug(prefix: keyof typeof PREFIX, message: string, ...args: unknown[]): void { + if (shouldLog(LogLevel.DEBUG)) { + console.debug(formatMessage(PREFIX[prefix], message), ...args); + } + }, + + info(prefix: keyof typeof PREFIX, message: string, ...args: unknown[]): void { + if (shouldLog(LogLevel.INFO)) { + console.log(formatMessage(PREFIX[prefix], message), ...args); + } + }, + + warn(prefix: keyof typeof PREFIX, message: string, ...args: unknown[]): void { + if (shouldLog(LogLevel.WARN)) { + console.warn(formatMessage(PREFIX[prefix], message), ...args); + } + }, + + error(prefix: keyof typeof PREFIX, message: string, error?: unknown): void { + if (shouldLog(LogLevel.ERROR)) { + if (error) { + console.error(formatMessage(PREFIX[prefix], message), error); + } else { + console.error(formatMessage(PREFIX[prefix], message)); + } + } + }, +}; + +export default logger; \ No newline at end of file diff --git a/obsidian-halo/src/utils/retry.ts b/obsidian-halo/src/utils/retry.ts new file mode 100644 index 0000000..0e8f27e --- /dev/null +++ b/obsidian-halo/src/utils/retry.ts @@ -0,0 +1,73 @@ +import { HaloError } from "../services/error"; + +export interface RetryOptions { + maxRetries?: number; + delayMs?: number; + backoffMultiplier?: number; + retryableStatuses?: number[]; +} + +const DEFAULT_OPTIONS: Required = { + maxRetries: 3, + delayMs: 1000, + backoffMultiplier: 2, + retryableStatuses: [408, 429, 500, 502, 503, 504], +}; + +export async function withRetry( + fn: () => Promise, + options: RetryOptions = {}, +): Promise { + const opts = { ...DEFAULT_OPTIONS, ...options }; + let lastError: unknown; + let delay = opts.delayMs; + + for (let attempt = 0; attempt <= opts.maxRetries; attempt++) { + try { + return await fn(); + } catch (error) { + lastError = error; + + if (attempt === opts.maxRetries) { + break; + } + + if (error instanceof HaloError && !error.isNetworkError()) { + if (!error.isAuthError() && !error.isNotFound()) { + const statusCode = error.statusCode; + if (statusCode && opts.retryableStatuses.includes(statusCode)) { + await sleep(delay); + delay *= opts.backoffMultiplier; + continue; + } + } + throw error; + } + + if (error instanceof TypeError) { + await sleep(delay); + delay *= opts.backoffMultiplier; + continue; + } + + throw error; + } + } + + throw lastError; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export class RetryError extends Error { + constructor( + message: string, + public readonly attempts: number, + public readonly lastError?: unknown, + ) { + super(message); + this.name = "RetryError"; + } +} diff --git a/obsidian-halo/vitest.config.ts b/obsidian-halo/vitest.config.ts new file mode 100644 index 0000000..4ff6e0c --- /dev/null +++ b/obsidian-halo/vitest.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['src/**/*.test.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html'], + }, + }, +}); \ No newline at end of file