From 65e541e249dda1f2788a57d7a50c21a637a890e8 Mon Sep 17 00:00:00 2001 From: Anton Reshetov Date: Sun, 23 Feb 2025 16:00:04 +0300 Subject: [PATCH 1/3] feat(api): add snippets, folders & tags CRUDs --- package.json | 5 +- pnpm-lock.yaml | 179 ++++++++++++++++ src/main/api/dto/common/query.ts | 14 ++ src/main/api/dto/common/response.ts | 5 + src/main/api/dto/folders.ts | 32 +++ src/main/api/dto/snippet-contents.ts | 14 ++ src/main/api/dto/snippets.ts | 60 ++++++ src/main/api/dto/tags.ts | 18 ++ src/main/api/index.ts | 32 +++ src/main/api/routes/folders.ts | 152 +++++++++++++ src/main/api/routes/snippets.ts | 310 +++++++++++++++++++++++++++ src/main/api/routes/tags.ts | 77 +++++++ src/main/db/index.ts | 22 +- src/main/db/migrate.ts | 7 +- src/main/index.ts | 20 +- src/main/store/module/preferences.ts | 2 + 16 files changed, 931 insertions(+), 18 deletions(-) create mode 100644 src/main/api/dto/common/query.ts create mode 100644 src/main/api/dto/common/response.ts create mode 100644 src/main/api/dto/folders.ts create mode 100644 src/main/api/dto/snippet-contents.ts create mode 100644 src/main/api/dto/snippets.ts create mode 100644 src/main/api/dto/tags.ts create mode 100644 src/main/api/index.ts create mode 100644 src/main/api/routes/folders.ts create mode 100644 src/main/api/routes/snippets.ts create mode 100644 src/main/api/routes/tags.ts diff --git a/package.json b/package.json index b4e024f8..bd1f2e48 100644 --- a/package.json +++ b/package.json @@ -30,8 +30,11 @@ "prepare": "simple-git-hooks && npm run rebuild" }, "dependencies": { + "@elysiajs/node": "^1.2.5", + "@elysiajs/swagger": "^1.2.2", "better-sqlite3": "^11.8.1", - "electron-store": "^8.2.0" + "electron-store": "^8.2.0", + "elysia": "^1.2.15" }, "devDependencies": { "@antfu/eslint-config": "^3.16.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8783d141..0232bef8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,12 +8,21 @@ importers: .: dependencies: + '@elysiajs/node': + specifier: ^1.2.5 + version: 1.2.5(elysia@1.2.15(@sinclair/typebox@0.34.27)(openapi-types@12.1.3)(typescript@5.7.3))(formidable@3.5.2)(ws@8.18.1) + '@elysiajs/swagger': + specifier: ^1.2.2 + version: 1.2.2(elysia@1.2.15(@sinclair/typebox@0.34.27)(openapi-types@12.1.3)(typescript@5.7.3)) better-sqlite3: specifier: ^11.8.1 version: 11.8.1 electron-store: specifier: ^8.2.0 version: 8.2.0 + elysia: + specifier: ^1.2.15 + version: 1.2.15(@sinclair/typebox@0.34.27)(openapi-types@12.1.3)(typescript@5.7.3) devDependencies: '@antfu/eslint-config': specifier: ^3.16.0 @@ -290,6 +299,22 @@ packages: resolution: {integrity: sha512-fKpv9kg4SPmt+hY7SVBnIYULE9QJl8L3sCfcBsnqbJwwBwAeTLokJ9TRt9y7bK0JAzIW2y78TVVjvnQEms/yyA==} engines: {node: '>=16.4'} + '@elysiajs/node@1.2.5': + resolution: {integrity: sha512-g5iE2csoixsx4KT4Q57BVjQqjcjJPigWF1ruGHguxrwmI/nfTlWNnpKAR6XU+MERhr3Cf7dd6GI826BBNgo0xw==} + peerDependencies: + bufferutil: '>= 4.0.1' + elysia: '>= 1.2.7' + formidable: '>= 3.5.2' + ws: '>= 8.18.0' + peerDependenciesMeta: + bufferutil: + optional: true + + '@elysiajs/swagger@1.2.2': + resolution: {integrity: sha512-DG0PbX/wzQNQ6kIpFFPCvmkkWTIbNWDS7lVLv3Puy6ONklF14B4NnbDfpYjX1hdSYKeCqKBBOuenh6jKm8tbYA==} + peerDependencies: + elysia: '>= 1.2.0' + '@es-joy/jsdoccomment@0.49.0': resolution: {integrity: sha512-xjZTSFgECpb9Ohuk5yMX5RhUEbfeQcuOp8IF60e+wyzWEF0M5xeSgqsfLtvPEX8BIyOX9saZqzuGPmZ8oWc+5Q==} engines: {node: '>=16'} @@ -678,6 +703,29 @@ packages: cpu: [x64] os: [win32] + '@scalar/openapi-types@0.1.1': + resolution: {integrity: sha512-NMy3QNk6ytcCoPUGJH0t4NNr36OWXgZhA3ormr3TvhX1NDgoF95wFyodGVH8xiHeUyn2/FxtETm8UBLbB5xEmg==} + engines: {node: '>=18'} + + '@scalar/openapi-types@0.1.8': + resolution: {integrity: sha512-iufA5/6hPCmRIVD2eh7qGpoKvoA08Gw/qUb2JECifBtAwA93fo7+1k9uHK440f2LMJsbxIzA+nv7RS0BmfiO/g==} + engines: {node: '>=18'} + + '@scalar/themes@0.9.68': + resolution: {integrity: sha512-466ac2fdQJOBBSLkGUf88vuZVF+qNMeVpjb0aAHrKkxhpjucTPKdTYO8r2dsX1R5k9A13gWPnm594VW5G/bGHw==} + engines: {node: '>=18'} + + '@scalar/types@0.0.12': + resolution: {integrity: sha512-XYZ36lSEx87i4gDqopQlGCOkdIITHHEvgkuJFrXFATQs9zHARop0PN0g4RZYWj+ZpCUclOcaOjbCt8JGe22mnQ==} + engines: {node: '>=18'} + + '@scalar/types@0.0.34': + resolution: {integrity: sha512-q01ctijmHArM5KOny2zU+sHfhpsgOAENrDENecK2TsQNn5FYLmFZouMKeW2M6F7KFLPZnFxUiL/rT88b6Rp/Kg==} + engines: {node: '>=18'} + + '@sinclair/typebox@0.34.27': + resolution: {integrity: sha512-C7mxE1VC3WC2McOufZXEU48IfRVI+BcKxk4NOyNn3+JMUNdJHEWGS5CqjuDX+ij2NCCz8/nse1mT7yn8Fv2GHg==} + '@sindresorhus/is@4.6.0': resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} engines: {node: '>=10'} @@ -806,6 +854,9 @@ packages: resolution: {integrity: sha512-oWWhcWDLwDfu++BGTZcmXWqpwtkwb5o7fxUIGksMQQDSdPW9prsSnfIOZMlsj4vBOSrcnjIUZMiIjODgGosFhQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@unhead/schema@1.11.19': + resolution: {integrity: sha512-7VhYHWK7xHgljdv+C01MepCSYZO2v6OhgsfKWPxRQBDDGfUKCUaChox0XMq3tFvXP6u4zSp6yzcDw2yxCfVMwg==} + '@vitejs/plugin-vue@5.2.1': resolution: {integrity: sha512-cxh314tzaWwOLqVes2gnnCtvBDcM1UMdn+iFR+UjAn411dPT3tOmqrJjbMd7koZpMAmBM/GqeV4n9ge7JSiJJQ==} engines: {node: ^18.0.0 || >=20.0.0} @@ -984,6 +1035,9 @@ packages: array-ify@1.0.0: resolution: {integrity: sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng==} + asap@2.0.6: + resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} + assert-plus@1.0.0: resolution: {integrity: sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==} engines: {node: '>=0.8'} @@ -1293,6 +1347,10 @@ packages: engines: {node: '>=16'} hasBin: true + cookie@1.0.2: + resolution: {integrity: sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==} + engines: {node: '>=18'} + core-js-compat@3.40.0: resolution: {integrity: sha512-0XEDpr5y5mijvw8Lbc6E5AkjrHfp7eEoPlu36SWeAbcL8fn1G1ANe8DBlo2XoNN89oVpxWwOjYIPVzR4ZvsKCQ==} @@ -1429,6 +1487,9 @@ packages: devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + dezalgo@1.0.4: + resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==} + didyoumean@1.2.2: resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} @@ -1502,6 +1563,18 @@ packages: engines: {node: '>=10.0.0'} hasBin: true + elysia@1.2.15: + resolution: {integrity: sha512-/oUSNb83jIWAGi6uSmbQ7Uy0RSJ9NimbVToSLnYS8jjsGId3zgdHqprsdf4rIMInOmEM8skjsFhZ4x8C5AB6+w==} + peerDependencies: + '@sinclair/typebox': '>= 0.34.0' + openapi-types: '>= 12.0.0' + typescript: '>= 5.0.0' + peerDependenciesMeta: + openapi-types: + optional: true + typescript: + optional: true + emoji-regex@10.4.0: resolution: {integrity: sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==} @@ -1859,6 +1932,9 @@ packages: resolution: {integrity: sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==} engines: {node: '>= 6'} + formidable@3.5.2: + resolution: {integrity: sha512-Jqc1btCy3QzRbJaICGwKcBfGWuLADRerLzDqi2NwSt/UkXLsHJw2TVResiaoBufHVHy9aSgClOHCeJsSsFLTbg==} + fraction.js@4.3.7: resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==} @@ -2009,6 +2085,13 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + hexoid@2.0.0: + resolution: {integrity: sha512-qlspKUK7IlSQv2o+5I7yhUd7TxlOG2Vr5LTa3ve2XSNVKAL/n/u/7KLvKmFNimomDIKvZFXWHv0T12mv7rT8Aw==} + engines: {node: '>=8'} + + hookable@5.5.3: + resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} + hosted-git-info@2.8.9: resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==} @@ -2436,6 +2519,9 @@ packages: mdast-util-to-string@4.0.0: resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + memoirist@0.3.0: + resolution: {integrity: sha512-wR+4chMgVPq+T6OOsk40u9Wlpw1Pjx66NMNiYxCQQ4EUJ7jDs3D9kTCeKdBOkvAiqXlHLVJlvYL01PvIJ1MPNg==} + meow@12.1.1: resolution: {integrity: sha512-BhXM0Au22RwUneMPwSCnyhTOizdWoIEPU9sp0Aqa1PnDMR5Wv2FGXYDjuzJEIX+Eo2Rb8xuYe5jrnm5QowQFkw==} engines: {node: '>=16.10'} @@ -2763,6 +2849,9 @@ packages: resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} engines: {node: '>=18'} + openapi-types@12.1.3: + resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -3666,6 +3755,18 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + ws@8.18.1: + resolution: {integrity: sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + xml-name-validator@4.0.0: resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==} engines: {node: '>=12'} @@ -3709,6 +3810,9 @@ packages: resolution: {integrity: sha512-b4JR1PFR10y1mKjhHY9LaGo6tmrgjit7hxVIeAmyMw3jegXR4dhYqLaQF5zMXZxY7tLpMyJeLjr1C4rLmkVe8g==} engines: {node: '>=12.20'} + zhead@2.2.4: + resolution: {integrity: sha512-8F0OI5dpWIA5IGG5NHUg9staDwz/ZPxZtvGVf01j7vHqSyZ0raHY+78atOVxRqb73AotX22uV1pXt3gYSstGag==} + zip-stream@4.1.1: resolution: {integrity: sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==} engines: {node: '>= 10'} @@ -4027,6 +4131,20 @@ snapshots: transitivePeerDependencies: - supports-color + '@elysiajs/node@1.2.5(elysia@1.2.15(@sinclair/typebox@0.34.27)(openapi-types@12.1.3)(typescript@5.7.3))(formidable@3.5.2)(ws@8.18.1)': + dependencies: + elysia: 1.2.15(@sinclair/typebox@0.34.27)(openapi-types@12.1.3)(typescript@5.7.3) + formidable: 3.5.2 + ws: 8.18.1 + + '@elysiajs/swagger@1.2.2(elysia@1.2.15(@sinclair/typebox@0.34.27)(openapi-types@12.1.3)(typescript@5.7.3))': + dependencies: + '@scalar/themes': 0.9.68 + '@scalar/types': 0.0.12 + elysia: 1.2.15(@sinclair/typebox@0.34.27)(openapi-types@12.1.3)(typescript@5.7.3) + openapi-types: 12.1.3 + pathe: 1.1.2 + '@es-joy/jsdoccomment@0.49.0': dependencies: comment-parser: 1.4.1 @@ -4317,6 +4435,26 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.34.4': optional: true + '@scalar/openapi-types@0.1.1': {} + + '@scalar/openapi-types@0.1.8': {} + + '@scalar/themes@0.9.68': + dependencies: + '@scalar/types': 0.0.34 + + '@scalar/types@0.0.12': + dependencies: + '@scalar/openapi-types': 0.1.1 + '@unhead/schema': 1.11.19 + + '@scalar/types@0.0.34': + dependencies: + '@scalar/openapi-types': 0.1.8 + '@unhead/schema': 1.11.19 + + '@sinclair/typebox@0.34.27': {} + '@sindresorhus/is@4.6.0': {} '@stylistic/eslint-plugin@2.13.0(eslint@9.19.0(jiti@2.4.2))(typescript@5.7.3)': @@ -4490,6 +4628,11 @@ snapshots: '@typescript-eslint/types': 8.23.0 eslint-visitor-keys: 4.2.0 + '@unhead/schema@1.11.19': + dependencies: + hookable: 5.5.3 + zhead: 2.2.4 + '@vitejs/plugin-vue@5.2.1(vite@6.1.0(@types/node@22.13.1)(jiti@2.4.2)(yaml@2.7.0))(vue@3.5.13(typescript@5.7.3))': dependencies: vite: 6.1.0(@types/node@22.13.1)(jiti@2.4.2)(yaml@2.7.0) @@ -4726,6 +4869,8 @@ snapshots: array-ify@1.0.0: {} + asap@2.0.6: {} + assert-plus@1.0.0: optional: true @@ -5089,6 +5234,8 @@ snapshots: meow: 12.1.1 split2: 4.2.0 + cookie@1.0.2: {} + core-js-compat@3.40.0: dependencies: browserslist: 4.24.4 @@ -5207,6 +5354,11 @@ snapshots: dependencies: dequal: 2.0.3 + dezalgo@1.0.4: + dependencies: + asap: 2.0.6 + wrappy: 1.0.2 + didyoumean@1.2.2: {} dir-compare@4.2.0: @@ -5329,6 +5481,15 @@ snapshots: runtime-required: 1.1.0 watchboy: 0.4.3 + elysia@1.2.15(@sinclair/typebox@0.34.27)(openapi-types@12.1.3)(typescript@5.7.3): + dependencies: + '@sinclair/typebox': 0.34.27 + cookie: 1.0.2 + memoirist: 0.3.0 + optionalDependencies: + openapi-types: 12.1.3 + typescript: 5.7.3 + emoji-regex@10.4.0: {} emoji-regex@8.0.0: {} @@ -5806,6 +5967,12 @@ snapshots: combined-stream: 1.0.8 mime-types: 2.1.35 + formidable@3.5.2: + dependencies: + dezalgo: 1.0.4 + hexoid: 2.0.0 + once: 1.4.0 + fraction.js@4.3.7: {} fs-constants@1.0.0: {} @@ -5988,6 +6155,10 @@ snapshots: dependencies: function-bind: 1.1.2 + hexoid@2.0.0: {} + + hookable@5.5.3: {} + hosted-git-info@2.8.9: {} hosted-git-info@4.1.0: @@ -6465,6 +6636,8 @@ snapshots: dependencies: '@types/mdast': 4.0.4 + memoirist@0.3.0: {} + meow@12.1.1: {} merge-stream@2.0.0: {} @@ -6888,6 +7061,8 @@ snapshots: dependencies: mimic-function: 5.0.1 + openapi-types@12.1.3: {} + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -7786,6 +7961,8 @@ snapshots: wrappy@1.0.2: {} + ws@8.18.1: {} + xml-name-validator@4.0.0: {} xmlbuilder@15.1.1: {} @@ -7823,6 +8000,8 @@ snapshots: yocto-queue@1.1.1: {} + zhead@2.2.4: {} + zip-stream@4.1.1: dependencies: archiver-utils: 3.0.4 diff --git a/src/main/api/dto/common/query.ts b/src/main/api/dto/common/query.ts new file mode 100644 index 00000000..f0f5fc05 --- /dev/null +++ b/src/main/api/dto/common/query.ts @@ -0,0 +1,14 @@ +import { t } from 'elysia' + +const Order = { + ASC: 'ASC', + DESC: 'DESC', +} as const + +export const commonQuery = t.Optional( + t.Object({ + search: t.Optional(t.String()), + sort: t.Optional(t.String()), + order: t.Optional(t.Enum(Order)), + }), +) diff --git a/src/main/api/dto/common/response.ts b/src/main/api/dto/common/response.ts new file mode 100644 index 00000000..078f8cec --- /dev/null +++ b/src/main/api/dto/common/response.ts @@ -0,0 +1,5 @@ +import { t } from 'elysia' + +export const commonAddResponse = t.Object({ + id: t.Union([t.Number(), t.BigInt()]), +}) diff --git a/src/main/api/dto/folders.ts b/src/main/api/dto/folders.ts new file mode 100644 index 00000000..175cfdf4 --- /dev/null +++ b/src/main/api/dto/folders.ts @@ -0,0 +1,32 @@ +import Elysia, { t } from 'elysia' + +const foldersAdd = t.Object({ + name: t.String(), +}) + +const foldersUpdate = t.Object({ + name: t.String(), + icon: t.Union([t.String(), t.Null()]), + defaultLanguage: t.String(), + parentId: t.Union([t.Number(), t.Null()]), + isOpen: t.Number({ minimum: 0, maximum: 1 }), +}) + +const foldersResponse = t.Object({ + id: t.Number(), + name: t.String(), + createdAt: t.Number(), + updatedAt: t.Number(), + icon: t.Union([t.String(), t.Null()]), + isOpen: t.Number(), + defaultLanguage: t.String(), +}) + +export const foldersDTO = new Elysia().model({ + foldersAdd, + foldersResponse, + foldersUpdate, +}) + +export type FoldersAdd = typeof foldersAdd.static +export type FoldersResponse = typeof foldersResponse.static diff --git a/src/main/api/dto/snippet-contents.ts b/src/main/api/dto/snippet-contents.ts new file mode 100644 index 00000000..fcc27adc --- /dev/null +++ b/src/main/api/dto/snippet-contents.ts @@ -0,0 +1,14 @@ +import Elysia, { t } from 'elysia' + +const snippetContentsAdd = t.Object({ + snippetId: t.Number(), + label: t.Union([t.String(), t.Null()]), + value: t.Union([t.String(), t.Null()]), + language: t.String(), +}) + +export const snippetContentsDTO = new Elysia().model({ + snippetContentsAdd, +}) + +export type SnippetContentsAdd = typeof snippetContentsAdd.static diff --git a/src/main/api/dto/snippets.ts b/src/main/api/dto/snippets.ts new file mode 100644 index 00000000..fa1fbd50 --- /dev/null +++ b/src/main/api/dto/snippets.ts @@ -0,0 +1,60 @@ +import Elysia, { t } from 'elysia' +import { commonQuery } from './common/query' + +const snippetsAdd = t.Object({ + name: t.String(), + folderId: t.Optional(t.Union([t.Number(), t.Null()])), +}) + +const snippetsUpdate = t.Object({ + ...snippetsAdd.properties, + folderId: t.Union([t.Number(), t.Null()]), + description: t.Union([t.String(), t.Null()]), + isDeleted: t.Number({ minimum: 0, maximum: 1 }), + isFavorites: t.Number({ minimum: 0, maximum: 1 }), +}) + +const snippetContentsAdd = t.Object({ + label: t.String(), + value: t.Union([t.String(), t.Null()]), + language: t.String(), // TODO: enum +}) + +const snippetsResponse = t.Object({ + id: t.Number(), + name: t.String(), + description: t.Union([t.String(), t.Null()]), + folderId: t.Union([t.Number(), t.Null()]), + tags: t.Array( + t.Object({ + id: t.Number(), + name: t.String(), + }), + ), + contents: t.Array( + t.Object({ + id: t.Number(), + label: t.String(), + value: t.Union([t.String(), t.Null()]), + language: t.String(), + }), + ), + isFavorites: t.Number(), + createdAt: t.Number(), + updatedAt: t.Number(), +}) + +export const snippetsDTO = new Elysia().model({ + snippetContentsAdd, + snippetsAdd, + snippetsUpdate, + snippetsQuery: t.Object({ + ...commonQuery.properties, + folderId: t.Optional(t.Number()), + tagId: t.Optional(t.Number()), + }), + snippetsResponse, +}) + +export type SnippetsAdd = typeof snippetsAdd.static +export type SnippetsResponse = typeof snippetsResponse.static diff --git a/src/main/api/dto/tags.ts b/src/main/api/dto/tags.ts new file mode 100644 index 00000000..0de13423 --- /dev/null +++ b/src/main/api/dto/tags.ts @@ -0,0 +1,18 @@ +import Elysia, { t } from 'elysia' + +const tagsAdd = t.Object({ + name: t.String(), +}) + +export const tagsResponse = t.Object({ + id: t.Number(), + name: t.String(), +}) + +export const tagsDTO = new Elysia().model({ + tagsAdd, + tagsResponse, +}) + +export type TagsAdd = typeof tagsAdd.static +export type TagsResponse = typeof tagsResponse.static diff --git a/src/main/api/index.ts b/src/main/api/index.ts new file mode 100644 index 00000000..4f82d92a --- /dev/null +++ b/src/main/api/index.ts @@ -0,0 +1,32 @@ +import { node } from '@elysiajs/node' +import { swagger } from '@elysiajs/swagger' +import { Elysia } from 'elysia' +import { version } from '../../../package.json' +import { store } from '../store' +import folders from './routes/folders' +import snippets from './routes/snippets' +import tags from './routes/tags' + +export function initApi() { + const app = new Elysia({ adapter: node() }) + const port = store.preferences.get('apiPort') + + app + .use( + swagger({ + documentation: { + info: { + title: 'massCode API', + version, + }, + }, + }), + ) + .use(snippets) + .use(folders) + .use(tags) + .listen(port) + + // eslint-disable-next-line no-console + console.log(`\nAPI started on port ${port}\n`) +} diff --git a/src/main/api/routes/folders.ts b/src/main/api/routes/folders.ts new file mode 100644 index 00000000..ca5ebeb6 --- /dev/null +++ b/src/main/api/routes/folders.ts @@ -0,0 +1,152 @@ +import type { FoldersResponse } from '../dto/folders' +import { Elysia } from 'elysia' +import { useDB } from '../../db' +import { commonAddResponse } from '../dto/common/response' +import { foldersDTO } from '../dto/folders' + +const app = new Elysia({ prefix: '/folders' }) +const db = useDB() + +app + .use(foldersDTO) + // Получение списка папок + .get( + '/', + () => { + const stmt = db.prepare(` + SELECT + id, + name, + defaultLanguage, + isOpen, + createdAt, + updatedAt, + icon + FROM folders + ORDER BY createdAt DESC + `) + + const result = stmt.all() + + return result as FoldersResponse[] + }, + { + response: 'foldersResponse[]', + detail: { + tags: ['Folders'], + }, + }, + ) + // Добавление папки + .post( + '/', + ({ body }) => { + const { name } = body + const now = Date.now() + + const stmt = db.prepare(` + INSERT INTO folders ( + name, + defaultLanguage, + isOpen, + createdAt, + updatedAt + ) VALUES (?, ?, ?, ?, ?) + `) + + const { lastInsertRowid } = stmt.run(name, 'plain_text', 0, now, now) + + return { id: lastInsertRowid } + }, + { + body: 'foldersAdd', + response: commonAddResponse, + detail: { + tags: ['Folders'], + }, + }, + ) + // Обновление папки + .put( + '/:id', + ({ params, body }) => { + const now = Date.now() + const { id } = params + const { name, icon, defaultLanguage, parentId, isOpen } = body + + const stmt = db.prepare(` + UPDATE folders + SET name = ?, + icon = ?, + defaultLanguage = ?, + isOpen = ?, + parentId = ?, + updatedAt = ? + WHERE id = ? + `) + + const { changes } = stmt.run( + name, + icon, + defaultLanguage, + isOpen, + parentId, + now, + id, + ) + + if (!changes) { + throw new Error('Folder not found') + } + + return { message: 'Folder updated' } + }, + { + body: 'foldersUpdate', + detail: { + tags: ['Folders'], + }, + }, + ) + // Удаление папки + .delete( + '/:id', + ({ params }) => { + const { id } = params + const transaction = db.transaction(() => { + // Мягкое удаление сниппетов в папке, а так же удаляем связь с папкой + db.prepare( + ` + UPDATE snippets + SET isDeleted = 1, + folderId = null + WHERE folderId = ? + `, + ).run(id) + + // Удаляем папку + const { changes } = db + .prepare( + ` + DELETE FROM folders WHERE id = ? + `, + ) + .run(id) + + if (!changes) { + throw new Error('Folder not found') + } + }) + + transaction() + + return { message: 'Folder deleted' } + }, + { + detail: { + tags: ['Folders'], + }, + }, + ) + +export default app diff --git a/src/main/api/routes/snippets.ts b/src/main/api/routes/snippets.ts new file mode 100644 index 00000000..12ffc18a --- /dev/null +++ b/src/main/api/routes/snippets.ts @@ -0,0 +1,310 @@ +import type { SnippetsResponse } from '../dto/snippets' +import Elysia from 'elysia' +import { useDB } from '../../db' +import { commonAddResponse } from '../dto/common/response' +import { snippetsDTO } from '../dto/snippets' + +const app = new Elysia({ prefix: '/snippets' }) +const db = useDB() + +app + .use(snippetsDTO) + // Получение списка сниппетов c возможностью фильтрации + .get( + '/', + ({ query }) => { + const { search, order, folderId, tagId } = query + const searchQuery = search ? `%${query.search}%` : undefined + + const WHERE: any[] = [] + const ORDER = order || 'DESC' + const params: any[] = [] + + // Добавляем условие для поиска + if (searchQuery) { + WHERE.push(`( + unicode_lower(s.name) LIKE unicode_lower(?) OR + unicode_lower(s.description) LIKE unicode_lower(?) OR + unicode_lower(sc.value) LIKE unicode_lower(?) + )`) + params.push(searchQuery, searchQuery, searchQuery) + } + + // Добавляем условие для папки + if (folderId) { + WHERE.push('s.folderId = ?') + params.push(folderId) + } + + // Добавляем условие для тега + if (tagId) { + WHERE.push( + 'EXISTS (SELECT 1 FROM snippet_tags st2 WHERE st2.snippetId = s.id AND st2.tagId = ?)', + ) + params.push(tagId) + } + + // Всегда добавляем условие isDeleted + WHERE.push('s.isDeleted = 0') + + const whereCondition = WHERE.length ? `WHERE ${WHERE.join(' AND ')}` : '' + + const stmt = db.prepare(` + WITH snippet_data AS ( + SELECT + s.id, + s.name, + s.description, + s.isFavorites, + s.folderId, + s.createdAt, + s.updatedAt, + json_group_array( + json_object( + 'id', t.id, + 'name', t.name + ) + ) FILTER (WHERE t.id IS NOT NULL) as tags, + json_group_array( + json_object( + 'id', sc.id, + 'label', sc.label, + 'value', sc.value, + 'language', sc.language + ) + ) FILTER (WHERE sc.id IS NOT NULL) as contents + FROM snippets s + LEFT JOIN snippet_tags st ON s.id = st.snippetId + LEFT JOIN tags t ON st.tagId = t.id + LEFT JOIN snippet_contents sc ON s.id = sc.snippetId + ${whereCondition} + GROUP BY s.id + ) + SELECT + id, + name, + description, + isFavorites, + folderId, + tags, + contents, + createdAt, + updatedAt + FROM snippet_data + ORDER BY createdAt ${ORDER} + `) + + const result = stmt.all(...params) as SnippetsResponse[] + + result.forEach((snippet) => { + snippet.contents = JSON.parse(snippet.contents as unknown as string) + snippet.tags = JSON.parse(snippet.tags as unknown as string) + }) + + return result + }, + { + query: 'snippetsQuery', + response: 'snippetsResponse[]', + detail: { + tags: ['Snippets'], + }, + }, + ) + // Создание сниппета + .post( + '/', + ({ body }) => { + const { name, folderId } = body + + const stmt = db.prepare(` + INSERT INTO snippets (name, description, folderId, isDeleted, isFavorites, createdAt, updatedAt) + VALUES (?, ?, ?, ?, ?, ?, ?) + `) + + const now = new Date().getTime() + + const { lastInsertRowid } = stmt.run( + name, + null, + folderId, + 0, + 0, + now, + now, + ) + + return { id: lastInsertRowid } + }, + { + body: 'snippetsAdd', + response: commonAddResponse, + detail: { + tags: ['Snippets'], + }, + }, + ) + // Добавление содержимого сниппета + .post( + '/:id/contents', + ({ params, body }) => { + const { id } = params + const { label, value, language } = body + + const stmt = db.prepare(` + INSERT INTO snippet_contents (snippetId, label, value, language) + VALUES (?, ?, ?, ?) + `) + + const result = stmt.run(id, label, value || null, language) + + return { id: result.lastInsertRowid } + }, + { + body: 'snippetContentsAdd', + response: commonAddResponse, + detail: { + tags: ['Snippets'], + }, + }, + ) + // Обновление сниппета + .put( + '/:id', + ({ params, body, set }) => { + const { id } = params + const { name, description, folderId, isFavorites, isDeleted } = body + + const stmt = db.prepare(` + UPDATE snippets SET + name = ?, + description = ?, + folderId = ?, + isFavorites = ?, + isDeleted = ?, + updatedAt = ? + WHERE id = ? + `) + + const now = new Date().getTime() + + const result = stmt.run( + name, + description, + folderId, + isFavorites, + isDeleted, + now, + id, + ) + + if (!result.changes) { + set.status = 404 + throw new Error('Snippet not found') + } + + return { message: 'Snippet updated' } + }, + { + body: 'snippetsUpdate', + detail: { + tags: ['Snippets'], + }, + }, + ) + // Обновление содержимого сниппета + .put( + '/:id/contents/:contentId', + ({ params, body, set }) => { + const { id, contentId } = params + const { label, value, language } = body + + // обновляем updateAt для сниппета + const snippetsStmt = db.prepare(` + UPDATE snippets SET updatedAt = ? WHERE id = ? + `) + + const now = new Date().getTime() + const snippetResult = snippetsStmt.run(now, id) + + if (!snippetResult.changes) { + set.status = 404 + throw new Error('Snippet not found') + } + + const contentsStmt = db.prepare(` + UPDATE snippet_contents SET + label = ?, + value = ?, + language = ? + WHERE id = ? + `) + + const contentsResult = contentsStmt.run( + label, + value, + language, + contentId, + ) + + if (!contentsResult.changes) { + set.status = 404 + throw new Error('Snippet content not found') + } + + return { message: 'Snippet content updated' } + }, + { + body: 'snippetContentsAdd', + detail: { + tags: ['Snippets'], + }, + }, + ) + // Удаление сниппета + .delete( + '/:id', + ({ params, set }) => { + const { id } = params + + const transaction = db.transaction(() => { + // Удаляем связи с тегами + db.prepare( + ` + DELETE FROM snippet_tags WHERE snippetId = ? + `, + ).run(id) + + // Удаляем содержимое сниппета + db.prepare( + ` + DELETE FROM snippet_contents WHERE snippetId = ? + `, + ).run(id) + + // Удаляем сам сниппет + const result = db + .prepare( + ` + DELETE FROM snippets WHERE id = ? + `, + ) + .run(id) + + if (!result.changes) { + set.status = 404 + throw new Error('Snippet not found') + } + }) + + transaction() + return { message: 'Snippet deleted' } + }, + { + detail: { + tags: ['Snippets'], + }, + }, + ) + +export default app diff --git a/src/main/api/routes/tags.ts b/src/main/api/routes/tags.ts new file mode 100644 index 00000000..b827e0e9 --- /dev/null +++ b/src/main/api/routes/tags.ts @@ -0,0 +1,77 @@ +import type { TagsResponse } from '../dto/tags' +import Elysia from 'elysia' +import { useDB } from '../../db' +import { tagsDTO } from '../dto/tags' + +const app = new Elysia({ prefix: '/tags' }) +const db = useDB() + +app + .use(tagsDTO) + // Получение списка тегов + .get( + '/', + () => { + const stmt = db.prepare(`SELECT * FROM tags`) + const result = stmt.all() + + return result as TagsResponse[] + }, + { + response: 'tagsResponse[]', + detail: { + tags: ['Tags'], + }, + }, + ) + // Добавление тега + .post( + '/', + ({ body }) => { + const stmt = db.prepare( + `INSERT INTO tags (name, createdAt, updatedAt) VALUES (?, ?, ?)`, + ) + const now = new Date().getTime() + + const { lastInsertRowid } = stmt.run(body.name, now, now) + + return { id: lastInsertRowid } + }, + { + body: 'tagsAdd', + detail: { + tags: ['Tags'], + }, + }, + ) + // Удаление тега и удаление его из всех сниппетов + .delete( + '/:id', + ({ params }) => { + const transaction = db.transaction(() => { + db.prepare( + ` + DELETE FROM snippet_tags WHERE tagId = ? + `, + ).run(params.id) + + const stmt = db.prepare(`DELETE FROM tags WHERE id = ?`) + const { changes } = stmt.run(params.id) + + if (!changes) { + throw new Error('Tag not found') + } + }) + + transaction() + + return { message: 'Tag deleted' } + }, + { + detail: { + tags: ['Tags'], + }, + }, + ) + +export default app diff --git a/src/main/db/index.ts b/src/main/db/index.ts index e4af9d40..3ce5888d 100644 --- a/src/main/db/index.ts +++ b/src/main/db/index.ts @@ -5,11 +5,16 @@ import { store } from '../store' const DB_NAME = 'app.db' const isDev = process.env.NODE_ENV === 'development' -export function initDB() { +let db: Database.Database | null = null + +export function useDB() { + if (db) + return db + const dbPath = `${store.preferences.get('storagePath')}/${DB_NAME}` try { - const db = new Database(dbPath, { + db = new Database(dbPath, { // eslint-disable-next-line no-console verbose: isDev ? console.log : undefined, }) @@ -17,15 +22,22 @@ export function initDB() { db.pragma('journal_mode = WAL') db.pragma('foreign_keys = ON') + // Поскольку из коробки в SQLite регистронезависимый поиск возможен только для ASCII, + // то добавляем самостоятельно функцию для сравнения строк без учета регистра + db.function('unicode_lower', (str: unknown) => { + if (typeof str !== 'string') + return str + return str.toLowerCase() + }) + // Таблица для папок db.exec(` CREATE TABLE IF NOT EXISTS folders ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, - defaultLanguage TEXT, + defaultLanguage TEXT NOT NULL, parentId INTEGER, isOpen INTEGER NOT NULL, - isSystem INTEGER NOT NULL, createdAt INTEGER NOT NULL, updatedAt INTEGER NOT NULL, icon TEXT, @@ -64,7 +76,7 @@ export function initDB() { db.exec(` CREATE TABLE IF NOT EXISTS tags ( id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, + name TEXT NOT NULL UNIQUE, createdAt INTEGER NOT NULL, updatedAt INTEGER NOT NULL ) diff --git a/src/main/db/migrate.ts b/src/main/db/migrate.ts index 95658443..f512b4ac 100644 --- a/src/main/db/migrate.ts +++ b/src/main/db/migrate.ts @@ -4,8 +4,8 @@ import type { JSONDB } from './types' export function migrateJsonToSqlite(jsonData: JSONDB, db: Database.Database) { // Подготовленные выражения для вставки данных const insertFolderStmt = db.prepare(` - INSERT INTO folders (name, defaultLanguage, parentId, isOpen, isSystem, createdAt, updatedAt, icon) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO folders (name, defaultLanguage, parentId, isOpen, createdAt, updatedAt, icon) + VALUES (?, ?, ?, ?, ?, ?, ?) `) const updateFolderParentStmt = db.prepare(` @@ -43,10 +43,9 @@ export function migrateJsonToSqlite(jsonData: JSONDB, db: Database.Database) { jsonData.folders.forEach((folder) => { const result = insertFolderStmt.run( folder.name, - folder.defaultLanguage || null, + folder.defaultLanguage || 'plain_text', null, // parentId обновим позже folder.isOpen ? 1 : 0, - folder.isSystem ? 1 : 0, folder.createdAt, folder.updatedAt, folder.icon || null, diff --git a/src/main/index.ts b/src/main/index.ts index fc5d7550..fd81b01f 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -5,7 +5,8 @@ import { readFileSync } from 'node:fs' import os from 'node:os' import path from 'node:path' import { app, BrowserWindow, ipcMain } from 'electron' -import { initDB } from './db' +import { initApi } from './api' +import { useDB } from './db' import { migrateJsonToSqlite } from './db/migrate' import { store } from './store' @@ -55,21 +56,22 @@ function createWindow() { app.whenReady().then(() => { createWindow() - db = initDB() + db = useDB() + initApi() if (store.app.get('isAutoMigratedFromJson')) { return } - const jsonDbPath = `${store.preferences.get('storagePath')}/db.json` - const jsonData = readFileSync(jsonDbPath, 'utf8') - try { + const jsonDbPath = `${store.preferences.get('storagePath')}/db.json` + const jsonData = readFileSync(jsonDbPath, 'utf8') + migrateJsonToSqlite(JSON.parse(jsonData), db) store.app.set('isAutoMigratedFromJson', true) } catch (err) { - console.error('Error migrating JSON to SQLite:', err) + console.error('Error on auto migration JSON to SQLite:', err) } }) @@ -101,13 +103,15 @@ ipcMain.on('request-info', (event) => { ipcMain.handle('db-query', async (event, args: DBQueryArgs) => { const { sql, params = [] } = args + const stmt = db.prepare(sql) + const trimmedSql = sql.trim() - if (/^(?:INSERT|UPDATE|DELETE)/i.test(sql)) { + if (/^(?:INSERT|UPDATE|DELETE)/i.test(trimmedSql)) { return stmt.run(params) } - if (/^SELECT/i.test(sql)) { + if (/^SELECT|WITH/i.test(trimmedSql)) { return stmt.all(params) } diff --git a/src/main/store/module/preferences.ts b/src/main/store/module/preferences.ts index d62e69c0..8f89f03c 100644 --- a/src/main/store/module/preferences.ts +++ b/src/main/store/module/preferences.ts @@ -4,6 +4,7 @@ import Store from 'electron-store' interface StoreSchema { storagePath: string backupPath: string + apiPort: number } const isWin = platform() === 'win32' @@ -18,5 +19,6 @@ export default new Store({ defaults: { storagePath, backupPath, + apiPort: 4321, }, }) From eb8d2655d001e59304d0b2a9e6b0c66604644bb5 Mon Sep 17 00:00:00 2001 From: Anton Reshetov Date: Sun, 23 Feb 2025 16:46:18 +0300 Subject: [PATCH 2/3] refactor(api): update DTOs for folders and snippets --- src/main/api/dto/folders.ts | 4 +++- src/main/api/dto/snippets.ts | 4 +++- src/main/api/index.ts | 6 ++++-- src/main/api/routes/folders.ts | 4 ++-- src/main/api/routes/snippets.ts | 4 ++-- 5 files changed, 14 insertions(+), 8 deletions(-) diff --git a/src/main/api/dto/folders.ts b/src/main/api/dto/folders.ts index 175cfdf4..8d089cdb 100644 --- a/src/main/api/dto/folders.ts +++ b/src/main/api/dto/folders.ts @@ -12,7 +12,7 @@ const foldersUpdate = t.Object({ isOpen: t.Number({ minimum: 0, maximum: 1 }), }) -const foldersResponse = t.Object({ +const foldersItem = t.Object({ id: t.Number(), name: t.String(), createdAt: t.Number(), @@ -22,6 +22,8 @@ const foldersResponse = t.Object({ defaultLanguage: t.String(), }) +const foldersResponse = t.Array(foldersItem) + export const foldersDTO = new Elysia().model({ foldersAdd, foldersResponse, diff --git a/src/main/api/dto/snippets.ts b/src/main/api/dto/snippets.ts index fa1fbd50..6e0687af 100644 --- a/src/main/api/dto/snippets.ts +++ b/src/main/api/dto/snippets.ts @@ -20,7 +20,7 @@ const snippetContentsAdd = t.Object({ language: t.String(), // TODO: enum }) -const snippetsResponse = t.Object({ +const snippetItem = t.Object({ id: t.Number(), name: t.String(), description: t.Union([t.String(), t.Null()]), @@ -44,6 +44,8 @@ const snippetsResponse = t.Object({ updatedAt: t.Number(), }) +const snippetsResponse = t.Array(snippetItem) + export const snippetsDTO = new Elysia().model({ snippetContentsAdd, snippetsAdd, diff --git a/src/main/api/index.ts b/src/main/api/index.ts index 4f82d92a..5dc7fff5 100644 --- a/src/main/api/index.ts +++ b/src/main/api/index.ts @@ -1,7 +1,8 @@ +import { cors } from '@elysiajs/cors' import { node } from '@elysiajs/node' import { swagger } from '@elysiajs/swagger' +import { app as electronApp } from 'electron' import { Elysia } from 'elysia' -import { version } from '../../../package.json' import { store } from '../store' import folders from './routes/folders' import snippets from './routes/snippets' @@ -12,12 +13,13 @@ export function initApi() { const port = store.preferences.get('apiPort') app + .use(cors({ origin: '*' })) .use( swagger({ documentation: { info: { title: 'massCode API', - version, + version: electronApp.getVersion(), }, }, }), diff --git a/src/main/api/routes/folders.ts b/src/main/api/routes/folders.ts index ca5ebeb6..1ce6d5de 100644 --- a/src/main/api/routes/folders.ts +++ b/src/main/api/routes/folders.ts @@ -28,10 +28,10 @@ app const result = stmt.all() - return result as FoldersResponse[] + return result as FoldersResponse }, { - response: 'foldersResponse[]', + response: 'foldersResponse', detail: { tags: ['Folders'], }, diff --git a/src/main/api/routes/snippets.ts b/src/main/api/routes/snippets.ts index 12ffc18a..48f687d7 100644 --- a/src/main/api/routes/snippets.ts +++ b/src/main/api/routes/snippets.ts @@ -94,7 +94,7 @@ app ORDER BY createdAt ${ORDER} `) - const result = stmt.all(...params) as SnippetsResponse[] + const result = stmt.all(...params) as SnippetsResponse result.forEach((snippet) => { snippet.contents = JSON.parse(snippet.contents as unknown as string) @@ -105,7 +105,7 @@ app }, { query: 'snippetsQuery', - response: 'snippetsResponse[]', + response: 'snippetsResponse', detail: { tags: ['Snippets'], }, From 52da8817f76d6eaebaa1a69c3fd0b78b4b7b008a Mon Sep 17 00:00:00 2001 From: Anton Reshetov Date: Mon, 24 Feb 2025 10:06:56 +0300 Subject: [PATCH 3/3] feat(api): generate API client and add ky for HTTP requests --- eslint.config.js | 1 + package.json | 4 +- pnpm-lock.yaml | 21 + scripts/api-generate.ts | 20 + src/renderer/services/api/generated/index.ts | 649 +++++++++++++++++++ src/renderer/services/api/index.ts | 7 + 6 files changed, 701 insertions(+), 1 deletion(-) create mode 100644 scripts/api-generate.ts create mode 100644 src/renderer/services/api/generated/index.ts create mode 100644 src/renderer/services/api/index.ts diff --git a/eslint.config.js b/eslint.config.js index 74032c94..d1b1dfa6 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -9,4 +9,5 @@ module.exports = antfu({ }, ], }, + ignores: ['src/renderer/services/api/generated/**/*'], }) diff --git a/package.json b/package.json index bd1f2e48..38a2b94e 100644 --- a/package.json +++ b/package.json @@ -30,11 +30,13 @@ "prepare": "simple-git-hooks && npm run rebuild" }, "dependencies": { + "@elysiajs/cors": "^1.2.0", "@elysiajs/node": "^1.2.5", "@elysiajs/swagger": "^1.2.2", "better-sqlite3": "^11.8.1", "electron-store": "^8.2.0", - "elysia": "^1.2.15" + "elysia": "^1.2.15", + "ky": "^1.7.5" }, "devDependencies": { "@antfu/eslint-config": "^3.16.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0232bef8..86003674 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@elysiajs/cors': + specifier: ^1.2.0 + version: 1.2.0(elysia@1.2.15(@sinclair/typebox@0.34.27)(openapi-types@12.1.3)(typescript@5.7.3)) '@elysiajs/node': specifier: ^1.2.5 version: 1.2.5(elysia@1.2.15(@sinclair/typebox@0.34.27)(openapi-types@12.1.3)(typescript@5.7.3))(formidable@3.5.2)(ws@8.18.1) @@ -23,6 +26,9 @@ importers: elysia: specifier: ^1.2.15 version: 1.2.15(@sinclair/typebox@0.34.27)(openapi-types@12.1.3)(typescript@5.7.3) + ky: + specifier: ^1.7.5 + version: 1.7.5 devDependencies: '@antfu/eslint-config': specifier: ^3.16.0 @@ -299,6 +305,11 @@ packages: resolution: {integrity: sha512-fKpv9kg4SPmt+hY7SVBnIYULE9QJl8L3sCfcBsnqbJwwBwAeTLokJ9TRt9y7bK0JAzIW2y78TVVjvnQEms/yyA==} engines: {node: '>=16.4'} + '@elysiajs/cors@1.2.0': + resolution: {integrity: sha512-qsJwDAg6WfdQRMfj6uSMcDPSpXvm/zQFeAX1uuJXhIgazH8itSfcDxcH9pMuXVRX1yQNi2pPwNQLJmAcw5mzvw==} + peerDependencies: + elysia: '>= 1.2.0' + '@elysiajs/node@1.2.5': resolution: {integrity: sha512-g5iE2csoixsx4KT4Q57BVjQqjcjJPigWF1ruGHguxrwmI/nfTlWNnpKAR6XU+MERhr3Cf7dd6GI826BBNgo0xw==} peerDependencies: @@ -2357,6 +2368,10 @@ packages: resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} engines: {node: '>=6'} + ky@1.7.5: + resolution: {integrity: sha512-HzhziW6sc5m0pwi5M196+7cEBtbt0lCYi67wNsiwMUmz833wloE0gbzJPWKs1gliFKQb34huItDQX97LyOdPdA==} + engines: {node: '>=18'} + lazy-val@1.0.5: resolution: {integrity: sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==} @@ -4131,6 +4146,10 @@ snapshots: transitivePeerDependencies: - supports-color + '@elysiajs/cors@1.2.0(elysia@1.2.15(@sinclair/typebox@0.34.27)(openapi-types@12.1.3)(typescript@5.7.3))': + dependencies: + elysia: 1.2.15(@sinclair/typebox@0.34.27)(openapi-types@12.1.3)(typescript@5.7.3) + '@elysiajs/node@1.2.5(elysia@1.2.15(@sinclair/typebox@0.34.27)(openapi-types@12.1.3)(typescript@5.7.3))(formidable@3.5.2)(ws@8.18.1)': dependencies: elysia: 1.2.15(@sinclair/typebox@0.34.27)(openapi-types@12.1.3)(typescript@5.7.3) @@ -6387,6 +6406,8 @@ snapshots: kleur@3.0.3: {} + ky@1.7.5: {} + lazy-val@1.0.5: {} lazystream@1.0.1: diff --git a/scripts/api-generate.ts b/scripts/api-generate.ts new file mode 100644 index 00000000..47811d35 --- /dev/null +++ b/scripts/api-generate.ts @@ -0,0 +1,20 @@ +import child_process from 'node:child_process' +import { styleText } from 'node:util' + +const url = `http://localhost:4321/swagger/json` + +async function generateApi() { + try { + console.log(styleText('blue', 'Generating API...')) + child_process.execSync( + `npx swagger-typescript-api -p ${url} -o ./src/renderer/services/api/generated -n index.ts`, + ) + console.log(styleText('green', 'API is successfully generated')) + } + catch (err) { + console.log(styleText('red', 'Error generating API')) + console.log(err) + } +} + +generateApi() diff --git a/src/renderer/services/api/generated/index.ts b/src/renderer/services/api/generated/index.ts new file mode 100644 index 00000000..5941538d --- /dev/null +++ b/src/renderer/services/api/generated/index.ts @@ -0,0 +1,649 @@ +/* eslint-disable */ +/* tslint:disable */ +/* + * --------------------------------------------------------------- + * ## THIS FILE WAS GENERATED VIA SWAGGER-TYPESCRIPT-API ## + * ## ## + * ## AUTHOR: acacode ## + * ## SOURCE: https://github.com/acacode/swagger-typescript-api ## + * --------------------------------------------------------------- + */ + +export interface SnippetContentsAdd { + label: string; + value: string | null; + language: string; +} + +export interface SnippetsAdd { + name: string; + folderId?: + | ( + | string + | ( + | string + | (string | (string | (string | (string | (string | number))))) + ) + ) + | null; +} + +export interface SnippetsUpdate { + name: string; + folderId: + | ( + | string + | ( + | string + | (string | (string | (string | (string | (string | number))))) + ) + ) + | null; + description: string | null; + /** + * @min 0 + * @max 1 + */ + isDeleted: + | string + | (string | (string | (string | (string | (string | (string | number)))))); + /** + * @min 0 + * @max 1 + */ + isFavorites: + | string + | (string | (string | (string | (string | (string | (string | number)))))); +} + +export interface SnippetsQuery { + search?: string; + sort?: string; + order?: "ASC" | "DESC"; + folderId?: + | string + | ( + | string + | ( + | string + | (string | (string | (string | (string | (string | number))))) + ) + ); + tagId?: + | string + | ( + | string + | ( + | string + | (string | (string | (string | (string | (string | number))))) + ) + ); +} + +export type SnippetsResponse = { + id: number; + name: string; + description: string | null; + folderId: number | null; + tags: { + id: number; + name: string; + }[]; + contents: { + id: number; + label: string; + value: string | null; + language: string; + }[]; + isFavorites: number; + createdAt: number; + updatedAt: number; +}[]; + +export interface FoldersAdd { + name: string; +} + +export type FoldersResponse = { + id: number; + name: string; + createdAt: number; + updatedAt: number; + icon: string | null; + isOpen: number; + defaultLanguage: string; +}[]; + +export interface FoldersUpdate { + name: string; + icon: string | null; + defaultLanguage: string; + parentId: + | (string | (string | (string | (string | (string | (string | number)))))) + | null; + /** + * @min 0 + * @max 1 + */ + isOpen: + | string + | (string | (string | (string | (string | (string | number))))); +} + +export interface TagsAdd { + name: string; +} + +export interface TagsResponse { + id: string | (string | (string | (string | (string | number)))); + name: string; +} + +export type QueryParamsType = Record; +export type ResponseFormat = keyof Omit; + +export interface FullRequestParams extends Omit { + /** set parameter to `true` for call `securityWorker` for this request */ + secure?: boolean; + /** request path */ + path: string; + /** content type of request body */ + type?: ContentType; + /** query params */ + query?: QueryParamsType; + /** format of response (i.e. response.json() -> format: "json") */ + format?: ResponseFormat; + /** request body */ + body?: unknown; + /** base url */ + baseUrl?: string; + /** request cancellation token */ + cancelToken?: CancelToken; +} + +export type RequestParams = Omit< + FullRequestParams, + "body" | "method" | "query" | "path" +>; + +export interface ApiConfig { + baseUrl?: string; + baseApiParams?: Omit; + securityWorker?: ( + securityData: SecurityDataType | null, + ) => Promise | RequestParams | void; + customFetch?: typeof fetch; +} + +export interface HttpResponse + extends Response { + data: D; + error: E; +} + +type CancelToken = Symbol | string | number; + +export enum ContentType { + Json = "application/json", + FormData = "multipart/form-data", + UrlEncoded = "application/x-www-form-urlencoded", + Text = "text/plain", +} + +export class HttpClient { + public baseUrl: string = ""; + private securityData: SecurityDataType | null = null; + private securityWorker?: ApiConfig["securityWorker"]; + private abortControllers = new Map(); + private customFetch = (...fetchParams: Parameters) => + fetch(...fetchParams); + + private baseApiParams: RequestParams = { + credentials: "same-origin", + headers: {}, + redirect: "follow", + referrerPolicy: "no-referrer", + }; + + constructor(apiConfig: ApiConfig = {}) { + Object.assign(this, apiConfig); + } + + public setSecurityData = (data: SecurityDataType | null) => { + this.securityData = data; + }; + + protected encodeQueryParam(key: string, value: any) { + const encodedKey = encodeURIComponent(key); + return `${encodedKey}=${encodeURIComponent(typeof value === "number" ? value : `${value}`)}`; + } + + protected addQueryParam(query: QueryParamsType, key: string) { + return this.encodeQueryParam(key, query[key]); + } + + protected addArrayQueryParam(query: QueryParamsType, key: string) { + const value = query[key]; + return value.map((v: any) => this.encodeQueryParam(key, v)).join("&"); + } + + protected toQueryString(rawQuery?: QueryParamsType): string { + const query = rawQuery || {}; + const keys = Object.keys(query).filter( + (key) => "undefined" !== typeof query[key], + ); + return keys + .map((key) => + Array.isArray(query[key]) + ? this.addArrayQueryParam(query, key) + : this.addQueryParam(query, key), + ) + .join("&"); + } + + protected addQueryParams(rawQuery?: QueryParamsType): string { + const queryString = this.toQueryString(rawQuery); + return queryString ? `?${queryString}` : ""; + } + + private contentFormatters: Record any> = { + [ContentType.Json]: (input: any) => + input !== null && (typeof input === "object" || typeof input === "string") + ? JSON.stringify(input) + : input, + [ContentType.Text]: (input: any) => + input !== null && typeof input !== "string" + ? JSON.stringify(input) + : input, + [ContentType.FormData]: (input: any) => + Object.keys(input || {}).reduce((formData, key) => { + const property = input[key]; + formData.append( + key, + property instanceof Blob + ? property + : typeof property === "object" && property !== null + ? JSON.stringify(property) + : `${property}`, + ); + return formData; + }, new FormData()), + [ContentType.UrlEncoded]: (input: any) => this.toQueryString(input), + }; + + protected mergeRequestParams( + params1: RequestParams, + params2?: RequestParams, + ): RequestParams { + return { + ...this.baseApiParams, + ...params1, + ...(params2 || {}), + headers: { + ...(this.baseApiParams.headers || {}), + ...(params1.headers || {}), + ...((params2 && params2.headers) || {}), + }, + }; + } + + protected createAbortSignal = ( + cancelToken: CancelToken, + ): AbortSignal | undefined => { + if (this.abortControllers.has(cancelToken)) { + const abortController = this.abortControllers.get(cancelToken); + if (abortController) { + return abortController.signal; + } + return void 0; + } + + const abortController = new AbortController(); + this.abortControllers.set(cancelToken, abortController); + return abortController.signal; + }; + + public abortRequest = (cancelToken: CancelToken) => { + const abortController = this.abortControllers.get(cancelToken); + + if (abortController) { + abortController.abort(); + this.abortControllers.delete(cancelToken); + } + }; + + public request = async ({ + body, + secure, + path, + type, + query, + format, + baseUrl, + cancelToken, + ...params + }: FullRequestParams): Promise> => { + const secureParams = + ((typeof secure === "boolean" ? secure : this.baseApiParams.secure) && + this.securityWorker && + (await this.securityWorker(this.securityData))) || + {}; + const requestParams = this.mergeRequestParams(params, secureParams); + const queryString = query && this.toQueryString(query); + const payloadFormatter = this.contentFormatters[type || ContentType.Json]; + const responseFormat = format || requestParams.format; + + return this.customFetch( + `${baseUrl || this.baseUrl || ""}${path}${queryString ? `?${queryString}` : ""}`, + { + ...requestParams, + headers: { + ...(requestParams.headers || {}), + ...(type && type !== ContentType.FormData + ? { "Content-Type": type } + : {}), + }, + signal: + (cancelToken + ? this.createAbortSignal(cancelToken) + : requestParams.signal) || null, + body: + typeof body === "undefined" || body === null + ? null + : payloadFormatter(body), + }, + ).then(async (response) => { + const r = response.clone() as HttpResponse; + r.data = null as unknown as T; + r.error = null as unknown as E; + + const data = !responseFormat + ? r + : await response[responseFormat]() + .then((data) => { + if (r.ok) { + r.data = data; + } else { + r.error = data; + } + return r; + }) + .catch((e) => { + r.error = e; + return r; + }); + + if (cancelToken) { + this.abortControllers.delete(cancelToken); + } + + if (!response.ok) throw data; + return data; + }); + }; +} + +/** + * @title massCode API + * @version 3.11.0 + * + * Development documentation + */ +export class Api< + SecurityDataType extends unknown, +> extends HttpClient { + snippets = { + /** + * No description + * + * @tags Snippets + * @name GetSnippets + * @request GET:/snippets/ + */ + getSnippets: ( + query?: { + search?: string; + sort?: string; + order?: "ASC" | "DESC"; + folderId?: + | string + | ( + | string + | ( + | string + | ( + | string + | (string | (string | (string | (string | number)))) + ) + ) + ); + tagId?: + | string + | ( + | string + | ( + | string + | ( + | string + | (string | (string | (string | (string | number)))) + ) + ) + ); + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/snippets/`, + method: "GET", + query: query, + format: "json", + ...params, + }), + + /** + * No description + * + * @tags Snippets + * @name PostSnippets + * @request POST:/snippets/ + */ + postSnippets: (data: SnippetsAdd, params: RequestParams = {}) => + this.request< + { + id: number | bigint; + }, + any + >({ + path: `/snippets/`, + method: "POST", + body: data, + type: ContentType.Json, + format: "json", + ...params, + }), + + /** + * No description + * + * @tags Snippets + * @name PostSnippetsByIdContents + * @request POST:/snippets/{id}/contents + */ + postSnippetsByIdContents: ( + id: string, + data: SnippetContentsAdd, + params: RequestParams = {}, + ) => + this.request< + { + id: number | bigint; + }, + any + >({ + path: `/snippets/${id}/contents`, + method: "POST", + body: data, + type: ContentType.Json, + format: "json", + ...params, + }), + + /** + * No description + * + * @tags Snippets + * @name PutSnippetsById + * @request PUT:/snippets/{id} + */ + putSnippetsById: ( + id: string, + data: SnippetsUpdate, + params: RequestParams = {}, + ) => + this.request({ + path: `/snippets/${id}`, + method: "PUT", + body: data, + type: ContentType.Json, + ...params, + }), + + /** + * No description + * + * @tags Snippets + * @name DeleteSnippetsById + * @request DELETE:/snippets/{id} + */ + deleteSnippetsById: (id: string, params: RequestParams = {}) => + this.request({ + path: `/snippets/${id}`, + method: "DELETE", + ...params, + }), + + /** + * No description + * + * @tags Snippets + * @name PutSnippetsByIdContentsByContentId + * @request PUT:/snippets/{id}/contents/{contentId} + */ + putSnippetsByIdContentsByContentId: ( + id: string, + contentId: string, + data: SnippetContentsAdd, + params: RequestParams = {}, + ) => + this.request({ + path: `/snippets/${id}/contents/${contentId}`, + method: "PUT", + body: data, + type: ContentType.Json, + ...params, + }), + }; + folders = { + /** + * No description + * + * @tags Folders + * @name GetFolders + * @request GET:/folders/ + */ + getFolders: (params: RequestParams = {}) => + this.request({ + path: `/folders/`, + method: "GET", + format: "json", + ...params, + }), + + /** + * No description + * + * @tags Folders + * @name PostFolders + * @request POST:/folders/ + */ + postFolders: (data: FoldersAdd, params: RequestParams = {}) => + this.request< + { + id: number | bigint; + }, + any + >({ + path: `/folders/`, + method: "POST", + body: data, + type: ContentType.Json, + format: "json", + ...params, + }), + + /** + * No description + * + * @tags Folders + * @name PutFoldersById + * @request PUT:/folders/{id} + */ + putFoldersById: ( + id: string, + data: FoldersUpdate, + params: RequestParams = {}, + ) => + this.request({ + path: `/folders/${id}`, + method: "PUT", + body: data, + type: ContentType.Json, + ...params, + }), + + /** + * No description + * + * @tags Folders + * @name DeleteFoldersById + * @request DELETE:/folders/{id} + */ + deleteFoldersById: (id: string, params: RequestParams = {}) => + this.request({ + path: `/folders/${id}`, + method: "DELETE", + ...params, + }), + }; + tags = { + /** + * No description + * + * @tags Tags + * @name PostTags + * @request POST:/tags/ + */ + postTags: (data: TagsAdd, params: RequestParams = {}) => + this.request({ + path: `/tags/`, + method: "POST", + body: data, + type: ContentType.Json, + ...params, + }), + + /** + * No description + * + * @tags Tags + * @name DeleteTagsById + * @request DELETE:/tags/{id} + */ + deleteTagsById: (id: string, params: RequestParams = {}) => + this.request({ + path: `/tags/${id}`, + method: "DELETE", + ...params, + }), + }; +} diff --git a/src/renderer/services/api/index.ts b/src/renderer/services/api/index.ts new file mode 100644 index 00000000..4d28210b --- /dev/null +++ b/src/renderer/services/api/index.ts @@ -0,0 +1,7 @@ +import ky from 'ky' +import { Api } from './generated' + +export const api = new Api({ + baseUrl: 'http://localhost:4321', + customFetch: ky, +})