diff --git a/esbuild.config.mjs b/esbuild.config.mjs index a8ca37b..1320114 100644 --- a/esbuild.config.mjs +++ b/esbuild.config.mjs @@ -104,8 +104,8 @@ async function copyPluginFilesTo_iCloud() { 'styles.css', ]; - const destinationDir = '/Users/sechan/Library/Mobile Documents/iCloud~md~obsidian/Documents/main/.obsidian/plugins/daily-routine-2/'; - // const destinationDir = '/Users/sechan/Library/Mobile Documents/iCloud~md~obsidian/Documents/cloud-test/.obsidian/plugins/daily-routine-2/'; + // const destinationDir = '/Users/sechan/Library/Mobile Documents/iCloud~md~obsidian/Documents/main/.obsidian/plugins/daily-routine-2/'; + const destinationDir = '/Users/sechan/Library/Mobile Documents/iCloud~md~obsidian/Documents/cloud-test/.obsidian/plugins/daily-routine-2/'; // 현재 파일의 URL을 파일 경로로 변환 diff --git a/package-lock.json b/package-lock.json index e70c9e8..0b87367 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "eslint-plugin-react-hooks": "^4.6.2", "lodash": "^4.17.21", "mitt": "^3.0.1", + "neverthrow": "^8.1.1", "npm": "^10.8.3", "react": "^18.3.1", "react-bem-helper": "^1.4.1", @@ -161,15 +162,6 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/helper-compilation-targets/node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dev": true, - "dependencies": { - "yallist": "^3.0.2" - } - }, "node_modules/@babel/helper-compilation-targets/node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -179,12 +171,6 @@ "semver": "bin/semver.js" } }, - "node_modules/@babel/helper-compilation-targets/node_modules/yallist": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "dev": true - }, "node_modules/@babel/helper-module-imports": { "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.25.9.tgz", @@ -1216,15 +1202,6 @@ "sprintf-js": "~1.0.2" } }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/camelcase": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", - "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", - "dev": true, - "engines": { - "node": ">=6" - } - }, "node_modules/@istanbuljs/load-nyc-config/node_modules/find-up": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", @@ -1299,12 +1276,6 @@ "node": ">=8" } }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/sprintf-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", - "dev": true - }, "node_modules/@istanbuljs/schema": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", @@ -1378,21 +1349,6 @@ } } }, - "node_modules/@jest/core/node_modules/ci-info": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", - "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], - "engines": { - "node": ">=8" - } - }, "node_modules/@jest/environment": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", @@ -1508,6 +1464,28 @@ } } }, + "node_modules/@jest/reporters/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/@jest/reporters/node_modules/istanbul-lib-instrument": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", @@ -1612,19 +1590,6 @@ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "dev": true }, - "node_modules/@jest/transform/node_modules/write-file-atomic": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", - "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", - "dev": true, - "dependencies": { - "imurmurhash": "^0.1.4", - "signal-exit": "^3.0.7" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } - }, "node_modules/@jest/types": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", @@ -1948,6 +1913,18 @@ "resolved": "https://registry.npmjs.org/@react-dnd/shallowequal/-/shallowequal-4.0.2.tgz", "integrity": "sha512-/RVXdLvJxLg4QKvMoM5WlwNR9ViO9z8B/qPcc+C0Sa/teJY7QG7kJ441DwzOjMYEY7GmU4dj5EcGHIkKZiQZCA==" }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.28.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.28.0.tgz", + "integrity": "sha512-Nl4KIzteVEKE9BdAvYoTkW19pa7LR/RBrT6F1dJCV/3pbjwDcaOq+edkP0LXuJ9kflW/xOK414X78r+K84+msw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ] + }, "node_modules/@sinclair/typebox": { "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", @@ -2929,6 +2906,15 @@ "node": ">=6" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001680", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001680.tgz", @@ -2973,6 +2959,21 @@ "node": ">=10" } }, + "node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "engines": { + "node": ">=8" + } + }, "node_modules/cjs-module-lexer": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.1.tgz", @@ -2993,29 +2994,6 @@ "node": ">=12" } }, - "node_modules/cliui/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/cliui/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -3115,9 +3093,9 @@ } }, "node_modules/cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -3219,11 +3197,11 @@ } }, "node_modules/debug": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz", - "integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==", + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", "dependencies": { - "ms": "2.1.2" + "ms": "^2.1.3" }, "engines": { "node": ">=6.0" @@ -4247,7 +4225,8 @@ "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==" + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC" }, "node_modules/fsevents": { "version": "2.3.3", @@ -4374,26 +4353,6 @@ "url": "https://github.com/wojtekmaj/get-user-locale?sponsor=1" } }, - "node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -4682,6 +4641,7 @@ "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", "dependencies": { "once": "^1.3.0", "wrappy": "1" @@ -4690,7 +4650,8 @@ "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" }, "node_modules/internal-slot": { "version": "1.0.7", @@ -4837,6 +4798,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/is-generator-fn": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", @@ -5350,19 +5320,26 @@ } } }, - "node_modules/jest-config/node_modules/ci-info": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", - "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "node_modules/jest-config/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, "engines": { - "node": ">=8" + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/jest-diff": { @@ -5689,6 +5666,28 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/jest-runtime/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/jest-snapshot": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz", @@ -5737,21 +5736,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/jest-util/node_modules/ci-info": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", - "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], - "engines": { - "node": ">=8" - } - }, "node_modules/jest-validate": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", @@ -6061,6 +6045,15 @@ "loose-envify": "cli.js" } }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "dependencies": { + "yallist": "^3.0.2" + } + }, "node_modules/make-error": { "version": "1.3.6", "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", @@ -6118,9 +6111,9 @@ } }, "node_modules/micromatch": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.7.tgz", - "integrity": "sha512-LPP/3KorzCwBxfeUuZmaR6bG2kdeHSbe0P2tY3FLRU4vYrjYz5hI4QZwV0njUx3jeuKe67YukQ1LSPZBKDqO/Q==", + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "dev": true, "dependencies": { "braces": "^3.0.3", @@ -6185,15 +6178,26 @@ } }, "node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==" }, + "node_modules/neverthrow": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/neverthrow/-/neverthrow-8.1.1.tgz", + "integrity": "sha512-DpbZ/UDI0B+TxJB1JysXSfi1++3YK2xLBqQLTlRN0b4zxlZ2MoiB+dKKV8dMRzt1fAFjRKDknXOVJgpl+a4Amw==", + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@rollup/rollup-linux-x64-gnu": "^4.24.0" + } + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -8674,6 +8678,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", "dependencies": { "wrappy": "1" } @@ -8817,6 +8822,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", "engines": { "node": ">=0.10.0" } @@ -9380,6 +9386,28 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/rimraf/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "license": "ISC", + "peer": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -9587,6 +9615,12 @@ "node": ">=0.10.0" } }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true + }, "node_modules/stack-utils": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", @@ -9621,6 +9655,20 @@ "node": ">=10" } }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/string.prototype.matchall": { "version": "4.0.11", "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.11.tgz", @@ -9813,6 +9861,28 @@ "node": ">=8" } }, + "node_modules/test-exclude/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -10340,33 +10410,24 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/wrap-ansi/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/write-file-atomic": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", + "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", + "dev": true, + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^3.0.7" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } }, "node_modules/xml-name-validator": { "version": "4.0.0", @@ -10392,6 +10453,12 @@ "node": ">=10" } }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true + }, "node_modules/yaml": { "version": "1.10.2", "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", @@ -10427,29 +10494,6 @@ "node": ">=12" } }, - "node_modules/yargs/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/yargs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 5274dc3..a891901 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "eslint-plugin-react-hooks": "^4.6.2", "lodash": "^4.17.21", "mitt": "^3.0.1", + "neverthrow": "^8.1.1", "npm": "^10.8.3", "react": "^18.3.1", "react-bem-helper": "^1.4.1", diff --git a/src/entities/archives/ensure-archive.ts b/src/entities/archives/ensure-archive.ts index 1d28539..6d344b0 100644 --- a/src/entities/archives/ensure-archive.ts +++ b/src/entities/archives/ensure-archive.ts @@ -1,6 +1,5 @@ import { DR_SETTING } from "@app/settings/setting-provider"; import { fileAccessor } from "@shared/file/file-accessor"; -import assert from "assert"; export type DrArchives = "notes" | "routines"; @@ -10,9 +9,8 @@ export const ensureArchive = async (archive: DrArchives) => { notes: DR_SETTING.noteFolderPath(), routines: DR_SETTING.routineFolderPath(), }[archive]; - assert(archivePath, `Archive path is not set. ${archive}`); - const folder = fileAccessor.getFolder(archivePath); + const folder = fileAccessor.loadFolder(archivePath); if(!folder){ return await fileAccessor.createFolder(archivePath); } else { diff --git a/src/entities/note-synchronize/dependents/NoteDependent.ts b/src/entities/note-synchronize/dependents/NoteDependent.ts index 5585b5f..9026095 100644 --- a/src/entities/note-synchronize/dependents/NoteDependent.ts +++ b/src/entities/note-synchronize/dependents/NoteDependent.ts @@ -7,5 +7,5 @@ export abstract class NoteDependent { /** * 노트에 의존적인 데이터를 기존의 노트로 부터 분리해냈기 때문에, 이를 다시 새로운 노트에 복원해내야한다. */ - abstract restoreData(newNote: RoutineNote): RoutineNote; + abstract restoreData(newNote: RoutineNote): void; } \ No newline at end of file diff --git a/src/entities/note-synchronize/dependents/TaskCheckedStateNoteDep.ts b/src/entities/note-synchronize/dependents/TaskCheckedStateNoteDep.ts index e4f7d54..0ba30fb 100644 --- a/src/entities/note-synchronize/dependents/TaskCheckedStateNoteDep.ts +++ b/src/entities/note-synchronize/dependents/TaskCheckedStateNoteDep.ts @@ -12,22 +12,18 @@ import { NoteDependent } from "./NoteDependent"; export class TaskCheckedStateNoteDep extends NoteDependent { #checkedTasks: string[] = []; - constructor(private note: RoutineNote) { + constructor(note: RoutineNote) { super(); - this.#checkedTasks = note.tasks.filter(t => t.checked).map(t => t.name); + this.#checkedTasks = note.createTaskArray() + .filter(t => t.isChecked()) + .map(t => t.getName()); } - restoreData(note: RoutineNote): RoutineNote { - const newTasks = note.tasks.map(t => { - if(this.#checkedTasks.includes(t.name)){ - t.checked = true; + restoreData(note: RoutineNote) { + for(const task of note.createTaskArray()){ + if(this.#checkedTasks.includes(task.getName())){ + task.check(); } - return t; - }) - - return { - ...note, - tasks: newTasks, } } } \ No newline at end of file diff --git a/src/entities/note-synchronize/dependents/TodoTaskNoteDep.ts b/src/entities/note-synchronize/dependents/TodoTaskNoteDep.ts index 7db6041..8d3e70c 100644 --- a/src/entities/note-synchronize/dependents/TodoTaskNoteDep.ts +++ b/src/entities/note-synchronize/dependents/TodoTaskNoteDep.ts @@ -1,13 +1,13 @@ -import { RoutineNote, Task, TodoTask } from "@entities/note"; +import { RoutineNote, AbstractTask, TodoTask, TaskGroup } from "@entities/note"; import { NoteDependent } from "./NoteDependent"; - - +import { RoutineGroup } from "@entities/routine"; interface SandwitchedTask { - prev: Task | null; + group: string; + prev: AbstractTask | null; task: TodoTask; - next: Task | null; + next: AbstractTask | null; originalIndex: number; } @@ -15,15 +15,21 @@ interface SandwitchedTask { export class TodoTaskNoteDep extends NoteDependent { #sandwitches: SandwitchedTask[] = []; - constructor(private note: RoutineNote) { + constructor(note: RoutineNote){ super(); - note.tasks.forEach((task, i) => { - if (task.type === "todo") { - const prev = i > 0 ? note.tasks[i - 1] : null; - const next = note.tasks.length !== i + 1 ? note.tasks[i + 1] : null; - const originalIndex = i; - this.#sandwitches.push({ prev, task, next, originalIndex }); - } + const tasks: TodoTask[] = note.createTaskArray().filter(t => t instanceof TodoTask) + this.#sandwitches = tasks.map((task, i) => { + const group = (() => { + const p = task.getParent(); + if(p === null) throw new Error('todo task must have parent'); + if(p instanceof RoutineNote) return RoutineGroup.UNGROUPED_NAME; + else if(p instanceof RoutineGroup) return p.getName(); + else throw new Error('invalid parent type'); + })(); + const prev = i > 0 ? tasks[i - 1] : null; + const next = tasks.length !== i + 1 ? tasks[i + 1] : null; + const originalIndex = i; + return { group, prev, task, next, originalIndex } }); } @@ -35,16 +41,22 @@ export class TodoTaskNoteDep extends NoteDependent { 4. 만약 둘 중 하나의 루틴이 없다면 있는 쪽에 위치시킨다.(Bad) 5. 만약 둘 다 없다면 맨 앞에 넣는다 (Worst) */ - restoreData(note: RoutineNote): RoutineNote { - const tasks = [...note.tasks]; - this.#sandwitches.forEach(({ prev, task, next, originalIndex }) => { - const prevIndex = prev ? tasks.findIndex(t => t.name === prev.name) : -1; - const nextIndex = next ? tasks.findIndex(t => t.name === next.name) : -1; + restoreData(note: RoutineNote) { + for(const { group: groupName, prev, task, next, originalIndex } of this.#sandwitches){ + const groupOrNull = note.findGroup(groupName); + // 고아가됐다면, 맨 위에 넣는다. + if(groupOrNull === null){ + note.addEl(task); + } + const group = groupOrNull as TaskGroup; + const tasks = group.getTasks(); + const prevIndex = prev ? tasks.findIndex(t => t.getName() === prev.getName()) : -1; + const nextIndex = next ? tasks.findIndex(t => t.getName() === next.getName()) : -1; // 앞뒤 다 없음 if (prevIndex === -1 && nextIndex === -1) { tasks.unshift(task); - } + } // 앞만 없음 else if (prevIndex === -1) { // 앞이 없어진게 아니라 원래 없었던 경우: 원래 맨 앞이었다는 뜻 @@ -73,7 +85,6 @@ export class TodoTaskNoteDep extends NoteDependent { tasks.splice(nextIndex, 0, task); } } - }); - return { ...note, tasks }; + } } } \ No newline at end of file diff --git a/src/entities/note-synchronize/synchronize.ts b/src/entities/note-synchronize/synchronize.ts index d1e1349..7f95ea6 100644 --- a/src/entities/note-synchronize/synchronize.ts +++ b/src/entities/note-synchronize/synchronize.ts @@ -1,8 +1,8 @@ -import { RoutineNote, NoteRepository, NoteService } from "@entities/note"; +import { NoteRepository, RoutineNote } from "@entities/note"; +import { RoutineNoteCreator } from "@entities/routine-to-note/RoutineNoteCreator"; +import { Day } from "@shared/period/day"; import { TaskCheckedStateNoteDep } from "./dependents/TaskCheckedStateNoteDep"; import { TodoTaskNoteDep } from "./dependents/TodoTaskNoteDep"; -import { Day } from "@shared/period/day"; -import { Routine, RoutineRepository } from "@entities/routine"; @@ -19,23 +19,22 @@ export interface RoutineNoteSynchronizer { } export const executeRoutineNotesSynchronize: RoutineNoteSynchronizer = async (cb?) => { - const routines: Routine[] = await RoutineRepository.loadAll(); - const createSyncecNote = NoteService.setLoaderForCreate(() => routines); + const noteCreator = await RoutineNoteCreator.withLoadFromRepositoryAsync(); const doSync = async (note: RoutineNote): Promise => { + const day = note.getDay(); // note로부터 NoteDepentdent를 추출한다. 구체적인 추출 로직은 각 클래스의 생성자에서 담당한다. const dependents = [ new TodoTaskNoteDep(note), new TaskCheckedStateNoteDep(note), ] - await NoteRepository.delete(note.day); - - const newlyCreatedNote = createSyncecNote(note.day); - const restoredNote = dependents.reduce((note, dep) => { - return dep.restoreData(note); - }, newlyCreatedNote); - NoteRepository.persist(restoredNote); - return restoredNote; + await NoteRepository.delete(day); + const newNote = noteCreator.create(day); + for(const dep of dependents){ + dep.restoreData(note); + } + await NoteRepository.persist(newNote); + return newNote; } // 모든 등록된 비동기 로직 이후에 실행을 예약 diff --git a/src/entities/note/domain/AbstractTask.ts b/src/entities/note/domain/AbstractTask.ts new file mode 100644 index 0000000..109aece --- /dev/null +++ b/src/entities/note/domain/AbstractTask.ts @@ -0,0 +1,112 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { ObsidianFileTitleValidation, validateObsidianFileTitle } from "@shared/validation/validate-obsidian-file-title"; +import { err, ok, Result } from "neverthrow"; +import { RoutineTaskDto, TaskDto, TodoTaskDto } from "../types/task"; +import { TaskElement } from "./TaskElement"; +import { TaskParent } from "./TaskParent"; + + +type TaskNameValidation = 'duplicated' | & ObsidianFileTitleValidation; + + +export abstract class AbstractTask implements TaskElement { + private name: string; + private checked: boolean; + private showOnCalendar: boolean; + /** + * null은 초기화되지 않음을 의미(lazy initialization) + */ + private parent: TaskParent | null; + + constructor( + name: string, + checked: boolean, + showOnCalendar: boolean, + parent?: TaskParent + ){ + this.name = name; + this.checked = checked; + this.showOnCalendar = showOnCalendar; + this.parent = parent??null; + } + + abstract toJSON(): T; + abstract serialize(): string; + + static investigateTaskType(line: string): "routine" | "todo" { + line = line.trim(); + if(line === '') throw new Error('empty-line'); + + const metaDataErr = 'invalid-task-meta-data-format'; + const regex = /%%(.*?)%%/; + const match = line.match(regex); + if(!match) throw new Error(metaDataErr); + const metaData = JSON.parse(match[1]); + if("type" in metaData === false) throw new Error(metaDataErr); + + return metaData.type; + } + + check(check?: boolean){ + this.checked = check !== undefined ? check : true; + } + + setShowOnCalendar(show?: boolean){ + this.showOnCalendar = show !== undefined ? show : true; + } + + changeName(newName: string, taskNames: string[]): Result { + return AbstractTask + .validateName(newName, taskNames) + .andThen(validated => { + this.name = validated; + return ok(validated); + }); + } + + static validateName(name: string, taskNames: string[]): Result { + return validateObsidianFileTitle(name) + .andThen(validated => { + taskNames = taskNames.filter(tn => tn !== name); + return taskNames.includes(validated) ? err('duplicated') : ok(validated); + }); + } + + isChecked(){ + return this.checked; + } + + getName(){ + return this.name; + } + + isShowOnCalendar(){ + return this.showOnCalendar; + } + + setParent(newParent: TaskParent | null){ + this.parent = newParent; + } + + getParent(): TaskParent { + if(this.parent === null) throw new Error('This task is orphan'); + return this.parent; + } + + isOrphan(){ + return this.parent === null; + } + + makeOrphan(): boolean { + const p = this.parent; + if(p !== null){ + p.removeEl(this.name); + this.parent = null; + return true; + } else { + return false; + } + } + + +} \ No newline at end of file diff --git a/src/entities/note/domain/RoutineNote.ts b/src/entities/note/domain/RoutineNote.ts new file mode 100644 index 0000000..882a85e --- /dev/null +++ b/src/entities/note/domain/RoutineNote.ts @@ -0,0 +1,265 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { JsonConvertible } from "@shared/JsonConvertible"; +import { Day } from "@shared/period/day"; +import dedent from "dedent"; +import { err, ok, Result } from "neverthrow"; +import { NoteCompletion, RoutineNoteDto } from "../types/routine-note"; +import { AbstractTask } from "./AbstractTask"; +import { TaskElement } from "./TaskElement"; +import { TaskGroup } from "./TaskGroup"; +import { TaskParent } from "./TaskParent"; +import { TaskDto, TaskGroupDto } from "../types/task"; +import { RoutineTask } from "./RoutineTask"; +import { TodoTask } from "./TodoTask"; + + + +type TaskEl = TaskElement; + + +export class RoutineNote implements JsonConvertible, TaskParent { + private day: Day; + private root: TaskEl[]; + + constructor(day: Day, root: TaskEl[]){ + this.day = day; + root.forEach(t => { + if(t instanceof AbstractTask) t.setParent(this); + }); + this.root = [...root]; + } + + static fromJSON(json: RoutineNoteDto): RoutineNote { + const root = json.root.map(t => { + if(t.elementType === 'group'){ + return TaskGroup.fromJSON(t as TaskGroupDto); + } else { + const json = t as TaskDto; + if(json.taskType === 'routine'){ + return new RoutineTask(json.name, json.checked, json.showOnCalendar); + } else { + return new TodoTask(json.name, json.checked, json.showOnCalendar); + } + } + }); + return new RoutineNote(json.day, root); + } + + getCompletion(): NoteCompletion { + const tasks = this.createTaskArray(); + const total = tasks.length; + const completed = tasks.filter(t => t.isChecked()).length; + const uncompleted = total - completed; + const percentage = total === 0 ? 0 : (completed / total) * 100; + const percentageRounded = Math.round(percentage); + return { total, completed, uncompleted, percentage, percentageRounded }; + } + + getDay(): Day { + return this.day.clone(); + } + + toJSON(): RoutineNoteDto { + return { + day: this.day.clone(), + root: this.root.map(t => t.toJSON()) + } + } + + static deserialize(day: Day, content: string): Result { + const regex = /##\s*.*?\n[\s\S]*?(?=\n##|$)/g; + const blocks = content.match(regex); + if(!blocks) return err('no-task-blocks'); + + const root: TaskEl[] = blocks.flatMap(b => { + if(b.startsWith("## UNGROUPED")){ + const lines = b.replace("## UNGROUPED", '').trim().split('\n'); + return lines + .map(l => { + if(AbstractTask.investigateTaskType(l) === "routine"){ + return RoutineTask.deserialize(l); + } else { + return TodoTask.deserialize(l); + } + }) + .map(r => { + if(r.isErr()) throw new Error('task deserialization error: ' + r.error); + return r.value; + }) + } else { + const result = TaskGroup.deserialize(b); + if(result.isErr()) throw new Error('group deserialization error: ' + result.error); + return [result.value] as TaskEl[]; + } + }); + + return ok(new RoutineNote(day, root)); + + } + + serialize() { + type ElBlock = { + isUngroupedTaskBlock: boolean; + serialized: string; + } + const taskElBlocks: ElBlock[] = []; + + for(const el of this.root){ + let elBlock: ElBlock; + + if(el instanceof TaskGroup){ + elBlock = { + isUngroupedTaskBlock: false, + serialized: el.serialize() + } + } else if(el instanceof AbstractTask){ + const prev = taskElBlocks[taskElBlocks.length - 1]; + if(prev && prev.isUngroupedTaskBlock){ + prev.serialized += '\n' + el.serialize(); + continue; + } else { + elBlock = { + isUngroupedTaskBlock: true, + serialized: el.serialize() + } + } + } else { + throw new Error('Invalid TaskElement Type'); + } + + taskElBlocks.push(elBlock); + } + + const tasks = taskElBlocks.map(block => { + if(block.isUngroupedTaskBlock){ + return dedent` + ## UNGROUPED + ${block.serialized} + `; + } else { + return block.serialized; + } + }).join('\n'); + + + return dedent` + # Tasks + ${tasks} + `; + } + + + createTasksMap(): Map { + const map = new Map(); + for(const el of this.root){ + if(el instanceof TaskGroup){ + el.getTasks().forEach(t => map.set(t.getName(), t)); + } else { + map.set(el.getName(), el as AbstractTask); + } + } + return map; + } + + createTaskArray(): AbstractTask[] { + const arr = []; + for(const el of this.root){ + if(el instanceof TaskGroup){ + arr.push(...el.getTasks()); + } else { + arr.push(el as AbstractTask); + } + } + return arr; + } + + // ******************************* + // *********** Node ************** + // ******************************* + + findNode(name: string, type: "group" | "task"): TaskEl | null { + for(const el of this.root){ + if(el instanceof TaskGroup){ + if(type === "group" && el.getName() === name) return el; + const found = el.getTask(name); + if(found) return found; + } else { + if(type === "task" && el.getName() === name) return el; + } + } + return null; + } + + getChildren(): TaskElement[] { + return [...this.root]; + } + + addTask( + task: AbstractTask, + idx: (arr: TaskElement[]) => number = () => 0 + ): void { + this.addEl(task, idx); + } + + /** + * + * @param el 다른 어느 곳에도 소속되지 않은 TaskElement. 만약 el이 이미 부모가 존재한다면 addEl에 실패한다. + * @param idx + * @returns + */ + addEl( + el: TaskEl, + idx: (arr: TaskElement[]) => number = () => 0 + ): Result { + if(el instanceof AbstractTask){ + el.makeOrphan(); + el.setParent(this); + } else if(el instanceof TaskGroup){ + this.removeEl(el.getName()); + } else { + return err('Invalid TaskElement Type'); + } + this.root.splice(idx(this.root), 0, el); + return ok(el); + } + + findTask(name: string): AbstractTask | null { + const found = this.findNode(name, "task"); + if(found instanceof AbstractTask) return found; + return null; + } + + findGroup(name: string): TaskGroup | null { + const found = this.root.find(e => e.getName() === name); + if(found instanceof TaskGroup) return found; + return null; + } + + removeEl(name: string): void { + const idx = this.root.findIndex(el => el.getName() === name); + if(idx === -1) throw new Error('Remove Target TaskElement not found'); + const removed = this.root.splice(idx, 1); + if(removed[0] instanceof AbstractTask){ + (removed[0] as AbstractTask).setParent(null); + } + } + + deleteTask(name: string): boolean { + const task = this.findTask(name); + if(!task) return false; + task.getParent()?.removeEl(name); + return true; + } + + deleteGroup(name: string, deleteSubTask = false): boolean { + const group = this.findGroup(name); + if(!group) return false; + + if(!deleteSubTask){ + group.getTasks().forEach(t => this.addEl(t)); + } + + this.removeEl(name); + return true; + } +} diff --git a/src/entities/note/domain/RoutineTask.ts b/src/entities/note/domain/RoutineTask.ts new file mode 100644 index 0000000..a98236f --- /dev/null +++ b/src/entities/note/domain/RoutineTask.ts @@ -0,0 +1,77 @@ +import { AbstractTask } from "./AbstractTask"; +import { RoutineTaskDto } from "../types/task"; +import { err, ok, Result } from "neverthrow"; +import { TaskParent } from "./TaskParent"; +import { Routine } from "@entities/routine"; + + +export class RoutineTask extends AbstractTask { + + constructor( + name: string, + checked: boolean, + showOnCalendar: boolean, + parent?: TaskParent + ){ + super(name, checked, showOnCalendar, parent); + } + + static create(name: string): RoutineTask { + return new RoutineTask(name, false, false); + } + + static fromRoutine(routine: Routine): RoutineTask { + return new RoutineTask( + routine.getName(), + false, + routine.getProperties().isShowOnCalendar() + ); + } + + static fromJSON(json: RoutineTaskDto): RoutineTask { + return new RoutineTask( + json.name, + json.checked, + json.showOnCalendar + ) + } + + static deserialize(line: string): Result { + line = line.trim(); + if(line === '') return err('empty-line'); + + const regex = /-\s*\[(x|\s?)\]\s*(\[\[(.*?)\]\]|(.*?))\s*%%(.*?)%%/; + const match = line.match(regex); + if(!match) return err('invalid-task-format'); + + const checked = match[1] === 'x'; + const name = match[3] || match[4]; + const metaData = JSON.parse(match[5]); + + return ok(new RoutineTask( + name, + checked, + metaData.soc + )) + } + + serialize(): string { + const c = this.isChecked() ? 'x' : ' '; + const meta = { + type: 'routine', + soc: Number(this.isShowOnCalendar()) + } + return `- [${c}] [[${this.getName()}]]%%${JSON.stringify(meta)}%%`; + } + + toJSON(): RoutineTaskDto { + return { + elementType: 'task', + taskType: 'routine', + name: this.getName(), + checked: this.isChecked(), + showOnCalendar: this.isShowOnCalendar(), + } + } + +} \ No newline at end of file diff --git a/src/entities/note/domain/TaskElement.ts b/src/entities/note/domain/TaskElement.ts new file mode 100644 index 0000000..7e44575 --- /dev/null +++ b/src/entities/note/domain/TaskElement.ts @@ -0,0 +1,10 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { JsonConvertible } from "@shared/JsonConvertible"; +import { TaskElementDto } from "../types/task"; + + + +export interface TaskElement extends JsonConvertible { + getName(): string; + serialize(): string; +} \ No newline at end of file diff --git a/src/entities/note/domain/TaskGroup.ts b/src/entities/note/domain/TaskGroup.ts new file mode 100644 index 0000000..1528dd3 --- /dev/null +++ b/src/entities/note/domain/TaskGroup.ts @@ -0,0 +1,152 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { TaskDto, TaskGroupDto } from "../types/task"; +import { AbstractTask } from "./AbstractTask"; +import { err, ok, Result } from "neverthrow"; +import { validateObsidianFileTitle } from "@shared/validation/validate-obsidian-file-title"; +import { TaskElement } from "./TaskElement"; +import dedent from "dedent"; +import { TaskParent } from "./TaskParent"; +import { RoutineGroup } from "@entities/routine"; +import { RoutineTask } from "./RoutineTask"; +import { TodoTask } from "./TodoTask"; +import { remove } from "lodash"; + + +export class TaskGroup implements TaskElement, TaskParent { + private name: string; + private tasks: AbstractTask[]; + + constructor(name: string, orderedTasks: AbstractTask[]){ + this.name = name; + + orderedTasks.forEach(t => t.setParent(this)); + this.tasks = [...orderedTasks]; + } + + toJSON(): TaskGroupDto { + return { + elementType: 'group', + name: this.name, + tasks: this.tasks.map(t => t.toJSON()), + } + } + + static fromJSON(json: TaskGroupDto): TaskGroup { + const group = new TaskGroup( + json.name, + json.tasks.map(task => { + if(task.taskType === 'routine'){ + return new RoutineTask(task.name, task.checked, task.showOnCalendar); + } else { + return new TodoTask(task.name, task.checked, task.showOnCalendar); + } + }) + ); + return group; + } + + getChildren(): TaskElement[] { + return this.getTasks(); + } + + getTasks(){ + return [...this.tasks]; + } + + getTask(name: string): AbstractTask | null { + return this.tasks.find(t => t.getName() === name) || null; + } + + /** + * task를 해당 TaskGroup에 추가한다. + * task 이전에 이미 어떤(해당 Group 포함) TaskGroup, 또는 RoutineNote에 속해있었던 경우에는 그로부터 먼저 제거된다. + */ + addTask( + task: AbstractTask, + idx: (arr: TaskElement[]) => number = () => 0 + ): Result { + task.makeOrphan(); + task.setParent(this); + this.tasks.splice(idx(this.tasks), 0, task); + return ok(task); + } + + changeName(newName: string, taskNames: string[]): Result { + return AbstractTask + .validateName(newName, taskNames) + .andThen(validated => { + this.name = validated; + return ok(validated); + }); + } + + /** + * + * @param groupBlock: + * ` + * ## {name} + * ...tasks + * ` 형식의 마크다운 블록 + */ + static deserialize(groupBlock: string): Result { + const regex = /##\s*(.+?)\s*\n([\s\S]*)/; + const match = groupBlock.match(regex); + if(!match) return err('invalid-group-format'); + + const name = match[1].trim(); + const taskLines = match[2].trim().split("\n"); + const tasks: AbstractTask[] = taskLines + .map(l => { + if(AbstractTask.investigateTaskType(l) === "routine"){ + return RoutineTask.deserialize(l); + } else { + return TodoTask.deserialize(l); + } + }) + .map(result => { + if(result.isErr()){ + throw new Error(`Error while deserializing task: ${result.error}`); + } else { + return result.value; + } + }); + + return ok(new TaskGroup(name, tasks)); + } + + serialize(): string { + return dedent` + ## ${this.name} + ${this.tasks.map(t => t.serialize()).join('\n')} + `; + } + + static validateName(name: string, taskNames: string[]): Result { + return validateObsidianFileTitle(name) + .andThen(validated => { + taskNames = taskNames.filter(tn => tn !== name); + return taskNames.includes(validated) ? err('duplicated') : ok(validated); + }) + .andThen(validated => { + return validated === 'UNGROUPED' ? err('reserved-name') : ok(validated); + }) + } + + getName(){ + return this.name; + } + + moveTaks(name: string, targetIdx: number): void { + const idx = this.tasks.findIndex(t => t.getName() === name); + if(idx === -1) throw new Error('Task not found'); + const task = this.tasks.splice(idx, 1)[0]; + this.tasks.splice(targetIdx, 0, task); + } + + removeEl(name: string){ + const idx = this.tasks.findIndex(t => t.getName() === name); + if(idx === -1) throw new Error('Remove Target Task not found'); + const removed = this.tasks.splice(idx, 1); + removed[0].setParent(null); + } +} \ No newline at end of file diff --git a/src/entities/note/domain/TaskParent.ts b/src/entities/note/domain/TaskParent.ts new file mode 100644 index 0000000..e375a93 --- /dev/null +++ b/src/entities/note/domain/TaskParent.ts @@ -0,0 +1,16 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { AbstractTask } from "./AbstractTask"; +import { TaskElement } from "./TaskElement"; + + +export interface TaskParent { + removeEl(name: string): void; + /** + * + * @param task + * @param idx task가 이미 해당 부모에게 속해있다면 추가하기전에 먼저 제거된다. + * 이 경우 외부에서 따로 계산한 index와 원하는 삽입 index가 달라질 수 있기 때문에 callback을 통해 index를 lazy하게 계산해야한다. + */ + addTask(task: AbstractTask, idx?: (arr: TaskElement[]) => number): void; + getChildren(): TaskElement[]; +} \ No newline at end of file diff --git a/src/entities/note/domain/TodoTask.ts b/src/entities/note/domain/TodoTask.ts new file mode 100644 index 0000000..b5d9e57 --- /dev/null +++ b/src/entities/note/domain/TodoTask.ts @@ -0,0 +1,69 @@ +import { AbstractTask } from './AbstractTask'; +import { TaskMetaData, TodoTaskDto } from '../types/task'; +import { TaskGroup } from './TaskGroup'; +import { err, ok, Result } from 'neverthrow'; +import { TaskParent } from './TaskParent'; + + +export class TodoTask extends AbstractTask { + constructor( + name: string, + checked: boolean, + showOnCalendar: boolean, + parent?: TaskParent + ){ + super(name, checked, showOnCalendar, parent); + } + + static initTask(name: string): TodoTask { + return new TodoTask(name, false, true); + } + + static fromJSON(json: TodoTaskDto): TodoTask { + return new TodoTask( + json.name, + json.checked, + json.showOnCalendar, + ) + } + + static deserialize(line: string): Result { + line = line.trim(); + if(line === '') return err('empty-line'); + + const regex = /-\s*\[(x|\s?)\]\s*(\[\[(.*?)\]\]|(.*?))\s*%%(.*?)%%/; + const match = line.match(regex); + if(!match) return err('invalid-task-format'); + + const checked = match[1] === 'x'; + const name = match[3] || match[4]; + const metaData = JSON.parse(match[5]); + + return ok(new TodoTask( + name, + checked, + metaData.soc, + )) + } + + serialize(): string { + const c = this.isChecked() ? 'x' : ' '; + const meta = { + type: 'todo', + soc: Number(this.isShowOnCalendar()) + } + return `- [${c}] ${this.getName()}%%${JSON.stringify(meta)}%%`; + } + + toJSON(): TodoTaskDto { + return { + elementType: 'task', + taskType: 'todo', + name: this.getName(), + checked: this.isChecked(), + showOnCalendar: this.isShowOnCalendar(), + } + } + + +} \ No newline at end of file diff --git a/src/entities/note/index.ts b/src/entities/note/index.ts index fd1e0ca..80229a0 100644 --- a/src/entities/note/index.ts +++ b/src/entities/note/index.ts @@ -1,3 +1,13 @@ -export { NoteService } from "./routine-note-service"; -export type { RoutineNote, RoutineTask, Task, TaskType, TodoTask } from "./types"; -export { NoteRepository } from "./note-repository"; \ No newline at end of file +// REPOSITORY +export { NoteRepository } from "./persistence/note-repository"; + +// DOMAIN +export { AbstractTask } from "./domain/AbstractTask"; +export { RoutineNote } from "./domain/RoutineNote"; +export { RoutineTask } from "./domain/RoutineTask"; +export { TaskGroup } from "./domain/TaskGroup"; +export { TodoTask } from "./domain/TodoTask"; + +// TYPES +export * from "./types/routine-note"; +export * from "./types/task"; \ No newline at end of file diff --git a/src/entities/note/note-repository.ts b/src/entities/note/note-repository.ts deleted file mode 100644 index 727b278..0000000 --- a/src/entities/note/note-repository.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { NoteService } from "./routine-note-service"; -import { RoutineNote } from "./types"; -import { fileAccessor } from "@shared/file/file-accessor"; -import { plugin } from "@shared/plugin-service-locator"; -import { TAbstractFile, TFile } from "obsidian"; -import { Day } from "@shared/period/day"; -import { FileNotFoundError } from "@shared/file/errors"; -import { doConfirm } from "@shared/components/modal/confirm-modal"; -import { DR_SETTING } from "@app/settings/setting-provider"; -import { ensureArchive } from "@entities/archives"; - - - -interface NoteRepository { - // day에 해당하는 RoutineNote를 archive에서 가져온다. - load(day: Day): Promise; - - // start와 end를 포함한 사이의 모든 루틴 노트를 가져온다. - loadBetween(start: Day, end: Day): Promise; - - /** - * 이미 routine note가 존재한다면 에러를 발생시킨다. - * @param routineNote - * @param strict 기본값 true, false로 설정한 경우 노트가 이미 존재한다면 에러를 발생시키지 않고 무시한다. - */ - persist(routineNote: RoutineNote, strict?: boolean): Promise; - - /** - * 이미 routine note가 존재하지 않는다면 에러를 발생시킨다. - * @param routineNote - * @param strict 기본값 true, false로 설정한 경우 노트가 존재하지 않는다면 에러를 발생시키지 않고 무시한다. - */ - update(routineNote: RoutineNote, strict?: boolean): Promise; - - /** - * 이미 routine note가 존재한다면 update, 존재하지 않는다면 persist한다. - */ - forceSave(routineNote: RoutineNote): Promise; - - /** - * 기왕이면 update, persist를 사용하도록 하지만, - * 만약 routine note가 아직 존재하지 않아서 persist하고 update해야하는 경우라면 해당 함수를 사용할 수 있다. - * @param routineNote - * @returns 이미 노트가 존재했거나 새로 생성하여 업데이트에 성공한 경우 true, 변경사항을 취소한 경우 false를 반환한다. - */ - saveOnUserConfirm(routineNote: RoutineNote): Promise; - - // delete - delete(day: Day): Promise; -} - -export const NoteRepository: NoteRepository = { - - load(day: Day){ - const file = getRoutineNoteFile(day); - if(file){ - return parseFile(file); - } else { - return Promise.resolve(null); - } - }, - - async loadBetween(start: Day, end: Day): Promise { - const notes: RoutineNote[] = []; - const routineNoteFiles: TAbstractFile[] = (await ensureArchive("notes")).children.filter(file => file instanceof TFile); - for(const file of routineNoteFiles){ - if(!(file instanceof TFile)) continue; - const day = Day.fromString(file.basename); - if(day.isBetween(start, end, 'day', '[]')){ - notes.push(await parseFile(file)); - } - } - return notes; - }, - - async persist(routineNote, strict = true) { - const file = getRoutineNoteFile(routineNote.day); - if(!file){ - const path = getRoutineArchivePath(routineNote.day.format()); - const content = NoteService.serialize(routineNote); - await fileAccessor.createFile(path, content); - } else { - if(strict) throw new Error('RoutineNote already exists.'); - } - }, - - async update(routineNote, strict = true) { - const file = getRoutineNoteFile(routineNote.day); - if(file){ - await fileAccessor.writeFile(file, () => NoteService.serialize(routineNote)); - } else { - if(strict) throw new Error('RoutineNote does not exist.'); - } - }, - - async forceSave(routineNote) { - const file = getRoutineNoteFile(routineNote.day); - if(file){ - await NoteRepository.update(routineNote); - } else { - await NoteRepository.persist(routineNote); - } - }, - - async saveOnUserConfirm(routineNote) { - const file = getRoutineNoteFile(routineNote.day); - - // 기존 파일이 있으면 업데이트 - if(file) { - await NoteRepository.update(routineNote); - return true; - } else { - // 사용자 확인 대기 - const isUserConfirmed = await doConfirm({ - title: "Create Routine Note", - description: `Create note for ${routineNote.day.format()}?`, - confirmText: "Create", - confirmBtnVariant: "accent", - }) - - // 사용자의 응답에 따라 처리 - if(isUserConfirmed) { - await NoteRepository.persist(routineNote); - } - return isUserConfirmed; - } - }, - - - async delete(day: Day){ - const file = getRoutineNoteFile(day); - if(file) await fileAccessor.deleteFile(file); - } -} - - - - -/** - * 특정 day에 해당하는 routineNote file을 가져온다. - */ -const getRoutineNoteFile = (day: Day): TFile | null => { - const path = getRoutineArchivePath(day.format()); - try { - return fileAccessor.getFile(path); - } catch(e) { - if(e instanceof FileNotFoundError) return null; - else throw e; - } -} - - -/** - * routineNote file을 deserialize하여 RoutineNote entity로 변환한다. - */ -const parseFile = async (file: TFile): Promise => { - const content = await fileAccessor.readFileAsReadonly(file); - if(!content) throw new Error('RoutineNote file is empty.'); - return NoteService.deserialize(Day.fromString(file.basename), content); -} - - - - -/** - * 루틴 아카이브 파일 경로를 반환합니다. - */ -const getRoutineArchivePath = (routineNoteTitle: string) => { - return `${DR_SETTING.noteFolderPath()}/${routineNoteTitle}.md`; -} \ No newline at end of file diff --git a/src/entities/note/persistence/note-repository.ts b/src/entities/note/persistence/note-repository.ts new file mode 100644 index 0000000..13dd7ce --- /dev/null +++ b/src/entities/note/persistence/note-repository.ts @@ -0,0 +1,130 @@ +import { ensureArchive } from "@entities/archives"; +import { fileAccessor } from "@shared/file/file-accessor"; +import { Day } from "@shared/period/day"; +import { TAbstractFile, TFile } from "obsidian"; +import { RoutineNote } from "../domain/RoutineNote"; +import { DR_SETTING } from "@app/settings/setting-provider"; +import { doConfirm } from "@shared/components/modal/confirm-modal"; + + + +interface NoteRepository { + // day에 해당하는 RoutineNote를 archive에서 가져온다. + load(day: Day): Promise; + + // start와 end를 '포함한' 사이의 모든 루틴 노트를 가져온다. + loadBetween(start: Day, end: Day): Promise; + + isExist(day: Day): Promise; + + /** + * 이미 routine note가 존재한다면 아무것도 하지 않는다. + * @return 이미 해당 날짜의 노트가 존재했다면 false를 반환하고 아무동작도 하지 않는다. + */ + persist(routineNote: RoutineNote): Promise; + + /** + * 노트를 저장한다. 만약 노트가 존재하지 않는다면 사용자의 입력을 받아서 저장여부를 결정한다. + */ + saveOnUserConfirm(routineNote: RoutineNote): Promise; + + update(routineNote: RoutineNote): Promise; + + delete(day: Day): Promise; +} + +export const NoteRepository: NoteRepository = { + + async load(day: Day){ + const file = ROUTINE_NOTE_FILE(day); + if(file){ + return await parseFileToDomain(day, file); + } else { + return Promise.resolve(null); + } + }, + + async loadBetween(start: Day, end: Day): Promise { + const notes: RoutineNote[] = []; + const routineNoteFiles: TAbstractFile[] = (await ensureArchive("notes")).children.filter(file => file instanceof TFile); + for(const file of routineNoteFiles){ + if(!(file instanceof TFile)) continue; + const day = Day.fromString(file.basename); + if(day.isBetween(start, end, 'day', '[]')){ + notes.push(await parseFileToDomain(day, file)); + } + } + return notes; + }, + + async isExist(day: Day){ + return ROUTINE_NOTE_FILE(day) !== null; + }, + + async persist(routineNote) { + const day = routineNote.getDay(); + const file = ROUTINE_NOTE_FILE(day); + if(!file){ + const path = ROUTINE_ARCHIVE_PATH(day); + await fileAccessor.createFile(path, routineNote.serialize()); + return true; + } else { + return false; + } + }, + + async saveOnUserConfirm(routineNote: RoutineNote){ + const file = ROUTINE_NOTE_FILE(routineNote.getDay()); + + if(file) { + await NoteRepository.update(routineNote); + return true; + } else { + // 사용자 확인 대기 + const isUserConfirmed = await doConfirm({ + title: "Create Routine Note", + description: `Create note for ${routineNote.getDay().format()}?`, + confirmText: "Create", + confirmBtnVariant: "accent", + }) + + // 사용자의 응답에 따라 처리 + if(isUserConfirmed) { + await NoteRepository.persist(routineNote); + } + return isUserConfirmed; + } + }, + + async update(routineNote: RoutineNote){ + const day = routineNote.getDay(); + const file = ROUTINE_NOTE_FILE(day); + if(file){ + await fileAccessor.writeFile(file, () => routineNote.serialize()); + } else { + throw new Error('RoutineNote file is not exist.'); + } + }, + + async delete(day: Day){ + const file = ROUTINE_NOTE_FILE(day); + if(file) await fileAccessor.deleteFile(file); + } +} + + +const parseFileToDomain = async (day: Day, file: TFile): Promise => { + const content = await fileAccessor.readFileAsReadonly(file); + if(!content) throw new Error('RoutineNote file is empty.'); + const result = RoutineNote.deserialize(day, content); + if(result.isErr()) throw result.error; + return result.value; +}; +export const ROUTINE_NOTE_FILE = (day: Day): TFile | null => { + const path = ROUTINE_ARCHIVE_PATH(day); + return fileAccessor.loadFile(path); +}; + +export const ROUTINE_ARCHIVE_PATH = (day: Day) => { + return `${DR_SETTING.noteFolderPath()}/${day.format()}.md`; +}; diff --git a/src/entities/note/routine-note-service.ts b/src/entities/note/routine-note-service.ts deleted file mode 100644 index 8d2350f..0000000 --- a/src/entities/note/routine-note-service.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { Day } from "@shared/period/day"; -import { RoutineNote, TaskCompletion, Task, TodoTask, TaskMetaData } from "./types"; -import { Routine, RoutineService } from "@entities/routine"; -import _ from "lodash"; - - -interface NoteService { - getTaskCompletion(routineNote: RoutineNote): TaskCompletion; - - serialize(routineNote: RoutineNote): string; - - deserialize(day: Day, content: string): RoutineNote; - - /** - * @param loader curring을 통해서 loader에 대한 의존을 주입한다. - * 이 때, loader가 반환하는 routine들은 적절한 평가를 거쳐서 최종 RoutineNote 데이터에 반영된다. - */ - setLoaderForCreateAsync(loader: () => Promise): (day: Day) => Promise; - setLoaderForCreate(loader: () => Routine[]): (day: Day) => RoutineNote; - - checkTask(routineNote: RoutineNote, task: Task, checked: boolean): RoutineNote; - - addTodoTask(routineNote: RoutineNote, todoTask: TodoTask): RoutineNote; - - deleteTodoTask(routineNote: RoutineNote, todoTaskName: string): RoutineNote; - - editTodoTask(routineNote: RoutineNote, originalName: string, todoTask: TodoTask): RoutineNote; -} - -export const NoteService: NoteService = { - getTaskCompletion(routineNote){ - const total = routineNote.tasks.length; - const completed = routineNote.tasks.filter(task => task.checked).length; - const uncompleted = total - completed; - const percentage = total === 0 ? 0 : (completed / total) * 100; - const percentageRounded = Math.round(percentage); - return { total, completed, uncompleted, percentage, percentageRounded }; - }, - - serialize(routineNote){ - const content = -`--- ---- -# Tasks -${routineNote.tasks.map(task => { - const checked = task.checked ? 'x' : ' '; - const metaData = { - type: task.type, - soc: Number(task.showOnCalendar) - } - return `- [${checked}] [[${task.name}]]%%${JSON.stringify(metaData)}%%`; -}).join('\n')} -`; - return content; - }, - - deserialize(day, content){ - // const props = parseProperties(content); - // "# Tasks" 이후의 내용을 tasks로 변환한다. - const tasks: Task[] = content.split('# Tasks')[1].split('\n').flatMap(line => { - if(line.trim() === '') return []; - const checked = line.startsWith('- [x]'); - - const name = line.split('] ')[1].split('%%')[0].replace('[[', '').replace(']]', ''); - - const _d = JSON.parse(line.split('%%')[1].replace('%%', '')) as TaskMetaData; - const metaData = { - type: _d.type, - soc: _d.soc ? _d.soc : (_d.type === 'todo' ? true : false) - } - - return { - type: metaData.type, - name, - checked, - showOnCalendar: metaData.soc - }; - }); - - return { - day, - tasks - }; - }, - - setLoaderForCreate: (loader) => (day) => { - const routines = loader(); - const tasks = routines.flatMap(routine => { - if(RoutineService.isRoutineDueTo(routine, day)){ - return RoutineService.deriveRoutineToTask(routine); - } - else { - return []; - } - }); - return { - day, - tasks: tasks - }; - }, - - setLoaderForCreateAsync: (loader) => async (day) => { - const routines = await loader(); - return NoteService.setLoaderForCreate(() => routines)(day); - }, - - checkTask(routineNote, task, checked){ - return { - ...routineNote, - tasks: routineNote.tasks.map(t => { - if(t.name === task.name){ - return { - ...t, - checked - } - } else { - return t; - } - }) - } - }, - - addTodoTask(routineNote, todoTask){ - // 중복검사 - if(routineNote.tasks.find(task => task.name === todoTask.name)) throw new Error("Duplicated task name"); - return { - ...routineNote, - tasks: [ - todoTask, - ...routineNote.tasks, - ] - } - }, - - deleteTodoTask(routineNote, todoTaskName){ - return { - ...routineNote, - tasks: routineNote.tasks.filter(task => task.name !== todoTaskName) - } - }, - - editTodoTask(routineNote, originalName, todoTask){ - const newName = todoTask.name; - if(originalName !== newName && routineNote.tasks.some(task => task.name === newName)){ - throw new Error("Duplicated task name"); - } - - const newTasks: Task[] = routineNote.tasks.map(task => { - if(task.name === originalName){ - return { - ...task, - ...todoTask, - } - } else { - return task; - } - }); - - return { - ...routineNote, - tasks: newTasks - } - } - -} \ No newline at end of file diff --git a/src/entities/note/types.ts b/src/entities/note/types.ts deleted file mode 100644 index e7be6a0..0000000 --- a/src/entities/note/types.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { Day } from "@shared/period/day"; - - -export type TaskType = "routine" | "todo"; -export interface Task { - type: TaskType; - name: string; - checked: boolean; - showOnCalendar: boolean; -} -export interface RoutineTask extends Task { } -export interface TodoTask extends Task { } -export interface TaskMetaData { - type: TaskType; - soc: boolean; // showOnCalendar -} - - -export interface RoutineNote { - day: Day; - tasks: Task[]; -} -export interface TaskCompletion { - total: number; - uncompleted: number; - completed: number; - percentage: number; - percentageRounded: number; -} diff --git a/src/entities/note/types/routine-note.ts b/src/entities/note/types/routine-note.ts new file mode 100644 index 0000000..fa41af3 --- /dev/null +++ b/src/entities/note/types/routine-note.ts @@ -0,0 +1,16 @@ +import { Day } from "@shared/period/day"; +import { TaskElementDto } from "./task"; + + +export interface RoutineNoteDto { + day: Day; + root: TaskElementDto[]; +} + +export interface NoteCompletion { + total: number; + uncompleted: number; + completed: number; + percentage: number; + percentageRounded: number; +} diff --git a/src/entities/note/types/task.ts b/src/entities/note/types/task.ts new file mode 100644 index 0000000..1ae495b --- /dev/null +++ b/src/entities/note/types/task.ts @@ -0,0 +1,29 @@ + + + +export interface TaskElementDto { + elementType: "group" | "task"; + name: string; +} + +export interface TaskGroupDto extends TaskElementDto { + elementType: "group"; + tasks: TaskDto[]; +} + +export type TaskType = "routine" | "todo"; + +export interface TaskDto extends TaskElementDto { + taskType: TaskType; + checked: boolean; + showOnCalendar: boolean; +} + +export interface RoutineTaskDto extends TaskDto { } + +export interface TodoTaskDto extends TaskDto { } + +export interface TaskMetaData { + type: TaskType; + soc: boolean; // showOnCalendar +} diff --git a/src/entities/routine-to-note/RoutineNoteCreator.ts b/src/entities/routine-to-note/RoutineNoteCreator.ts new file mode 100644 index 0000000..ad9b79f --- /dev/null +++ b/src/entities/routine-to-note/RoutineNoteCreator.ts @@ -0,0 +1,66 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { RoutineNote, RoutineTask, TaskGroup } from "@entities/note"; +import { TaskElement } from "@entities/note/domain/TaskElement"; +import { GroupRepository, Routine, RoutineGroup, RoutineRepository } from "@entities/routine"; +import { Day } from "@shared/period/day"; + + + + +export class RoutineNoteCreator { + private rouitnes: Routine[]; + private groups: RoutineGroup[]; + + private constructor(routines: Routine[], groups: RoutineGroup[]){ + this.rouitnes = routines; + this.groups = groups; + } + + static with(routines: Routine[], groups: RoutineGroup[]): RoutineNoteCreator { + return new RoutineNoteCreator(routines, groups); + } + + static async withLoadFromRepositoryAsync(): Promise { + return new RoutineNoteCreator( + await RoutineRepository.loadAll(), + await GroupRepository.loadAll() + ); + } + + create(day: Day): RoutineNote { + const r_root: (Routine | RoutineGroup)[] = [...this.groups]; + const routineMap = new Map(this.groups.map(g => [g.getName(), []])); + + // routine을 Group, 또는 UNGROUPED에 맞게 배치 + for(const routine of this.rouitnes){ + if(!routine.isDueTo(day)) continue; + + const groupName = routine.getProperties().getGroup(); + if(groupName === RoutineGroup.UNGROUPED_NAME){ + r_root.push(routine); + continue; + } + const routines = routineMap.get(groupName); + if(!routines) throw new Error(`${routine.getName()}'s Group '${groupName}' not found.`); + routines.push(routine); + } + // routine과 routineGroup으로 이루어진 임시 root를 정렬 + r_root.sort((a, b) => a.getProperties().getOrder() - b.getProperties().getOrder()); + + const root: TaskElement[] = r_root.map(routineOrGroup => { + if(routineOrGroup instanceof Routine){ + return RoutineTask.fromRoutine(routineOrGroup); + } else { + const routineGroup = routineOrGroup as RoutineGroup; + const routines = routineMap.get(routineGroup.getName()); + if(!routines) throw new Error(`RoutineGroup ${routineGroup.getName()} not found.`); + + routines.sort((a, b) => a.getProperties().getOrder() - b.getProperties().getOrder()); + const tasks = routines.map(routine => RoutineTask.fromRoutine(routine)); + return new TaskGroup(routineGroup.getName(), tasks); + } + }); + + return new RoutineNote(day, root); + } +} \ No newline at end of file diff --git a/src/entities/routine/domain/GroupProperties.ts b/src/entities/routine/domain/GroupProperties.ts new file mode 100644 index 0000000..543b8dc --- /dev/null +++ b/src/entities/routine/domain/GroupProperties.ts @@ -0,0 +1,71 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { stringifyYaml } from "obsidian"; +import { RoutineGroupDto } from "../types"; +import { JsonConvertible } from "@shared/JsonConvertible"; + + +type Properties = RoutineGroupDto['properties']; + + +const getGroupPropertiesError = (propertyName: string, value: any): Error => { + return new Error(`Group properties error: ${propertyName} is '${value}'`); +} + + +export class GroupProperties implements JsonConvertible { + private order: number; // unsigned int + + constructor(properties?: Properties){ + const p = properties ?? { + order: 0, + } + this.order = p.order; + } + + /** + * @param frontmatter frontmatter를 해석한 js object + */ + static fromJsonObj(jsonObj: any): GroupProperties { + if(typeof jsonObj !== 'object'){ + throw new Error('Group frontmatter is not object.'); + } + const fm = new GroupProperties(); + + if( + 'order' in jsonObj && + typeof jsonObj.order === 'number' + ){ + fm.setOrder(jsonObj.order); + } else throw getGroupPropertiesError('order', jsonObj.order); + + return fm; + } + + serialize(): string { + const frontmatter = [ + "---", + stringifyYaml(this.toJSON()), + "---" + ].join('\n'); + return frontmatter; + } + + toJSON(): Properties { + return { + order: this.order, + } + } + + setOrder(order: number){ + if(order < 0){ + throw new Error('Order must be a non-negative integer.'); + } + this.order = order; + return this; + } + + getOrder(){ + return this.order; + } + +} diff --git a/src/entities/routine/domain/Routine.ts b/src/entities/routine/domain/Routine.ts new file mode 100644 index 0000000..ae2bb7d --- /dev/null +++ b/src/entities/routine/domain/Routine.ts @@ -0,0 +1,100 @@ +import { err, ok, Result } from "neverthrow"; +import { RoutineDto } from "../types"; +import { RoutineProperties } from "./RoutineProperties"; +import { ObsidianFileTitleValidation, validateObsidianFileTitle } from "@shared/validation/validate-obsidian-file-title"; +import { parseFrontmatter } from "@shared/file/parse-frontmatter"; +import { JsonConvertible } from "@shared/JsonConvertible"; +import { Day } from "@shared/period/day"; +import { RoutineTask } from "@entities/note"; + + + +type RoutineNameValidation = 'duplicated' | & ObsidianFileTitleValidation; + + +export class Routine implements JsonConvertible { + private name: string; + private properties: RoutineProperties; + + constructor(name: string, properties?: RoutineProperties){ + this.name = name; + this.properties = properties ?? new RoutineProperties(); + } + + static fromFile(name: string, content: string): Routine { + const fm = parseFrontmatter(content); + return new Routine(name, RoutineProperties.fromObject(fm)); + } + + static fromJSON(json: RoutineDto): Routine { + return new Routine(json.name, new RoutineProperties(json.properties)); + } + + serialize(): string { + const properties = this.properties.serialize(); + const content = ""; + return `${properties}\n${content}`; + } + + toJSON(): RoutineDto { + return { + name: this.name, + properties: this.properties.toJSON() + } + } + + getName(): string { + return this.name; + } + + getProperties(): RoutineProperties { + return this.properties; + } + + /** + * + * @param newName 변경할 이름 + * @param routineNames 기존에 존재하는 루틴 이름들 + */ + changeName(newName: string, routineNames: string[]): Result { + return Routine + .validateName(newName, routineNames) + .andThen(validated => { + this.name = validated; + return ok(validated); + }); + } + + static validateName(name: string, routineNames: string[]): Result { + return validateObsidianFileTitle(name) + .andThen(validated => { + routineNames = routineNames.filter(rn => rn !== name); + return routineNames.includes(validated) ? err('duplicated') : ok(validated); + }); + } + + /** + * NOTE: daysOfWeek와 daysOfMonth를 기준으로 루틴을 수행할지 말지를 결정한다. + * - daysOfMonth가 0인 경우는 매월의 마지막 날을 의미한다. + */ + isDueTo(day: Day): boolean { + // MONTH 기준 + if(this.properties.getActiveCriteria() === "month") { + const days = Array.from(this.properties.getDaysOfMonth()); + // 0이 존재하는 경우, 0을 매개받은 day의 달의 마지막 날짜로 치환한다. + if(days.contains(0)) { + const lastDayOfMonth = day.daysInMonth(); + days.remove(0); + days.push(lastDayOfMonth); + } + if(!days.contains(day.date)) return false; + } + + // WEEK 기준 + if(this.properties.getActiveCriteria() === "week") { + if(!this.properties.getDaysOfWeek().contains(day.dow)) return false; + } + return true; + } + +} \ No newline at end of file diff --git a/src/entities/routine/domain/RoutineGroup.ts b/src/entities/routine/domain/RoutineGroup.ts new file mode 100644 index 0000000..3fb119d --- /dev/null +++ b/src/entities/routine/domain/RoutineGroup.ts @@ -0,0 +1,58 @@ +import { ObsidianFileTitleValidation, validateObsidianFileTitle } from "@shared/validation/validate-obsidian-file-title"; +import { err, ok, Result } from "neverthrow"; +import { GroupProperties } from "./GroupProperties"; +import { RoutineGroupDto } from "../types"; +import { JsonConvertible } from "@shared/JsonConvertible"; +import { GROUP_PREFIX } from "../persistence/utils"; + + +type GroupNameValidation = 'duplicated' | & ObsidianFileTitleValidation; + +export class RoutineGroup implements JsonConvertible { + public static UNGROUPED_NAME = "UNGROUPED"; + private name: string; + private properties: GroupProperties; + + constructor(name: string, properties?: GroupProperties){ + this.name = name.startsWith(GROUP_PREFIX) ? name.slice(GROUP_PREFIX.length) : name; + this.properties = properties ?? new GroupProperties(); + } + + serialize(): string { + const properties = this.properties.serialize(); + const content = ""; + return `${properties}\n${content}`; + } + + changeName(newName: string, groupNames: string[]): Result { + return RoutineGroup + .validateName(newName, groupNames) + .andThen(validated => { + this.name = validated; + return ok(validated); + }); + } + + static validateName(name: string, groupNames: string[]): Result { + return validateObsidianFileTitle(name) + .andThen(validated => { + groupNames = groupNames.filter(gn => gn !== name); + return groupNames.includes(validated) ? err('duplicated') : ok(validated); + }); + } + + toJSON(): RoutineGroupDto { + return { + name: this.getName(), + properties: this.getProperties().toJSON() + } + } + + getName(): string { + return this.name; + } + + getProperties(): GroupProperties { + return this.properties; + } +} \ No newline at end of file diff --git a/src/entities/routine/domain/RoutineProperties.ts b/src/entities/routine/domain/RoutineProperties.ts new file mode 100644 index 0000000..4d16f81 --- /dev/null +++ b/src/entities/routine/domain/RoutineProperties.ts @@ -0,0 +1,196 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { Day, DayOfWeek } from "@shared/period/day"; +import { keys } from "lodash"; +import { stringifyYaml } from "obsidian"; +import { RoutinePropertiesDto } from "../types"; +import { err, Result, ok } from "neverthrow"; +import { JsonConvertible } from "@shared/JsonConvertible"; +import { RoutineGroup } from "./RoutineGroup"; + + + +const getRoutinePropertiesError = (propertyName: string, value: any): Error => { + return new Error(`Routine frontmatter parse fail: ${propertyName} is '${value}'`); +} + + +export class RoutineProperties implements JsonConvertible { + private order: number; // routine들 순서(음이 아닌 정수 0, 1, 2, ...) + private group: string; // 루틴 그룹 이름 + private showOnCalendar: boolean; // 캘린더에 표시할지 여부 + private activeCriteria: "week" | "month"; // 루틴이 활성화되는 기준 + private daysOfWeek: DayOfWeek[]; // 요일 + private daysOfMonth: number[]; // 0 ~ 31 (0은 매월 마지막 날) + + constructor(properties?: RoutinePropertiesDto) { + const p = properties ?? { + order: 0, + group: RoutineGroup.UNGROUPED_NAME, + showOnCalendar: false, + activeCriteria: "week", + daysOfWeek: Day.getDaysOfWeek(), + daysOfMonth: [Day.now().date], + } + this.order = p.order; + this.group = p.group; + this.showOnCalendar = p.showOnCalendar; + this.activeCriteria = p.activeCriteria; + this.daysOfWeek = p.daysOfWeek; + this.daysOfMonth = p.daysOfMonth; + } + + /** + * @param frontmatter frontmatter를 해석한 js object + */ + static fromObject(obj: any): RoutineProperties { + if(typeof obj !== 'object'){ + throw new Error('Routine frontmatter is not object.'); + } + const fm = new RoutineProperties(); + + if( + 'order' in obj && + typeof obj.order === 'number' + ){ + fm.setOrder(obj.order); + } else throw getRoutinePropertiesError('order', obj.order); + + if( + 'group' in obj && + typeof obj.group === 'string' + ){ + fm.setGroup(obj.group); + } else throw getRoutinePropertiesError('group', obj.group); + + if( + 'showOnCalendar' in obj && + typeof obj.showOnCalendar === 'boolean' + ){ + fm.setShowOnCalendar(obj.showOnCalendar); + } else throw getRoutinePropertiesError('showOnCalendar', obj.showOnCalendar); + + if( + 'activeCriteria' in obj && + (obj.activeCriteria === "week" || obj.activeCriteria === "month") + ){ + fm.setActiveCriteria(obj.activeCriteria); + } else throw getRoutinePropertiesError('activeCriteria', obj.activeCriteria); + + if( + 'daysOfWeek' in obj && + Array.isArray(obj.daysOfWeek) && + obj.daysOfWeek.every((d: any) => typeof d === 'string' && keys(DayOfWeek).includes(d)) + ){ + fm.setDaysOfWeek(obj.daysOfWeek); + } else throw getRoutinePropertiesError('daysOfWeek', obj.daysOfWeek); + + if( + 'daysOfMonth' in obj && + Array.isArray(obj.daysOfMonth) && + obj.daysOfMonth.every((d: any) => typeof d === 'number' && 0 <= d && d <= 31) + ){ + fm.setDaysOfMonth(obj.daysOfMonth); + } else throw getRoutinePropertiesError('daysOfMonth', obj.daysOfMonth); + + return fm; + } + + serialize(): string { + const frontmatter = [ + "---", + stringifyYaml(this.toJSON()), + "---" + ].join('\n'); + return frontmatter; + } + + toJSON(): RoutinePropertiesDto { + return { + order: this.getOrder(), + group: this.getGroup(), + showOnCalendar: this.isShowOnCalendar(), + activeCriteria: this.getActiveCriteria(), + daysOfWeek: this.getDaysOfWeek(), + daysOfMonth: this.getDaysOfMonth(), + } + } + + setOrder(order: number){ + if(order < 0){ + throw new Error('Order must be a non-negative integer.'); + } + this.order = order; + return this; + } + + setGroup(group: string){ + this.group = group; + } + + ungroup(){ + this.group = "UNGROUPED"; + } + + setShowOnCalendar(showOnCalendar: boolean){ + this.showOnCalendar = showOnCalendar; + } + + setActiveCriteria(activeCriteria: "week" | "month"){ + this.activeCriteria = activeCriteria; + } + + setDaysOfWeek(daysOfWeek: DayOfWeek[]){ + this.daysOfWeek = [...daysOfWeek]; + } + + addDaysOfWeek(dayOfWeek: DayOfWeek){ + this.daysOfWeek.push(dayOfWeek); + } + + setDaysOfMonth(daysOfMonth: number[]): Result { + this.daysOfMonth = []; + for(const dayOfMonth of daysOfMonth){ + const result = this.addDayOfMonth(dayOfMonth); + if(result.isErr()){ + return err(result.error); + } + } + return ok(this.daysOfMonth); + } + + addDayOfMonth(dayOfMonth: number): Result { + if(dayOfMonth < 0 || dayOfMonth > 31){ + return err('out-of-range'); // 0 ~ 31 + } + + this.daysOfMonth.push(dayOfMonth); + return ok(dayOfMonth); + } + + // ********************************************************* + // ********************** Getters ************************** + // ********************************************************* + getOrder(){ + return this.order; + } + + getGroup(){ + return this.group; + } + + isShowOnCalendar(){ + return this.showOnCalendar; + } + + getActiveCriteria(){ + return this.activeCriteria; + } + + getDaysOfMonth(){ + return [...this.daysOfMonth]; + } + + getDaysOfWeek(){ + return [...this.daysOfWeek]; + } +} diff --git a/src/entities/routine/index.ts b/src/entities/routine/index.ts index 20173f7..d789ad0 100644 --- a/src/entities/routine/index.ts +++ b/src/entities/routine/index.ts @@ -1,3 +1,12 @@ -export { RoutineService } from "./routine-service"; -export { RoutineRepository } from "./routine-repository"; -export * from "./routine"; \ No newline at end of file +// persistence +export { RoutineRepository } from "./persistence/routine-repository"; +export { GroupRepository } from "./persistence/group-repository"; + +// types +export * from "./types"; + +// domain +export { Routine } from "./domain/Routine"; +export { RoutineProperties } from "./domain/RoutineProperties"; +export { RoutineGroup } from "./domain/RoutineGroup"; +export { GroupProperties } from "./domain/GroupProperties"; \ No newline at end of file diff --git a/src/entities/routine/persistence/group-repository.ts b/src/entities/routine/persistence/group-repository.ts new file mode 100644 index 0000000..b9a731d --- /dev/null +++ b/src/entities/routine/persistence/group-repository.ts @@ -0,0 +1,68 @@ +import { fileAccessor } from "@shared/file/file-accessor"; +import { RoutineGroup } from "../domain/RoutineGroup"; +import { GROUP_PATH, GROUP_PREFIX } from "./utils"; +import { parseFrontmatter } from "@shared/file/parse-frontmatter"; +import { GroupProperties } from "../domain/GroupProperties"; +import { TFile } from "obsidian"; +import { ensureArchive } from "@entities/archives"; + + +const parse = async (file: TFile): Promise => { + const content = await fileAccessor.readFileAsReadonly(file); + const obj = parseFrontmatter(content); + return new RoutineGroup( + file.basename, + GroupProperties.fromJsonObj(obj) + ); +} + + +interface GroupRepository { + loadAll(): Promise; + load(groupName: string): Promise; + isExist(groupName: string): Promise; + persist(group: RoutineGroup): Promise; + delete(groupName: string): Promise; + changeName(originalName: string, newName: string): Promise; +} +export const GroupRepository: GroupRepository = { + + async loadAll(){ + const groupFiles = (await ensureArchive("routines")).children + .filter(file => file instanceof TFile) + .filter(file => file.name.startsWith(GROUP_PREFIX)); + return Promise.all(groupFiles.map(parse)); + }, + + async load(groupName: string){ + const file = fileAccessor.loadFile(GROUP_PATH(groupName)); + if(!file) throw new Error('Group file not found.'); + return await parse(file); + }, + + async isExist(groupName: string){ + return fileAccessor.loadFile(GROUP_PATH(groupName)) !== null; + }, + + async persist(group){ + const path = GROUP_PATH(group.getName()); + const file = fileAccessor.loadFile(path); + if(file) return false; + + await fileAccessor.createFile(path, group.serialize()); + return true; + }, + + async delete(groupName: string){ + const file = fileAccessor.loadFile(GROUP_PATH(groupName)); + if(!file) return; + await fileAccessor.deleteFile(file); + }, + + + async changeName(originalName: string, newName: string){ + const file = fileAccessor.loadFile(GROUP_PATH(originalName)); + if(!file) throw new Error('Group file not found.'); + await fileAccessor.renameFileWithLinks(file, GROUP_PATH(newName)); + }, +} \ No newline at end of file diff --git a/src/entities/routine/persistence/routine-repository.ts b/src/entities/routine/persistence/routine-repository.ts new file mode 100644 index 0000000..6cd05a7 --- /dev/null +++ b/src/entities/routine/persistence/routine-repository.ts @@ -0,0 +1,78 @@ +import { ensureArchive } from "@entities/archives"; +import { compose } from "@shared/compose"; +import { fileAccessor } from "@shared/file/file-accessor"; +import { TFile } from "obsidian"; +import { Routine } from "../domain/Routine"; +import { GROUP_PREFIX, ROUTINE_PATH } from "./utils"; + + +const parse = async (file: TFile): Promise => { + const fileContent = await fileAccessor.readFileAsReadonly(file); + if(!fileContent) { + throw new Error('Routine file is empty.'); + } + return Routine.fromFile( + file.basename, + fileContent + ); +} + +interface RoutineRepository { + loadAll(): Promise; + load(routineName: string): Promise; + persist(entity: Routine): Promise; + delete(routineName: string): Promise; + changeName(originalName: string, newName: string): Promise; + update(entity: Routine): Promise; +} +export const RoutineRepository: RoutineRepository = { + + async loadAll(){ + const routinePromises: Promise[] = (await ensureArchive("routines")).children + .filter(file => file instanceof TFile) + .filter(file => !file.name.startsWith(GROUP_PREFIX)) + .map(file => parse(file as TFile)); + + return await Promise.all(routinePromises); + }, + + async load(routineName: string){ + const path = ROUTINE_PATH(routineName); + const file = fileAccessor.loadFile(path); + if(!file) throw new Error('Routine file not found.'); + return await parse(file); + }, + + async persist(routine: Routine){ + const path = ROUTINE_PATH(routine.getName()); + const file = fileAccessor.loadFile(path); + if(!file){ + await fileAccessor.createFile(path, routine.serialize()); + return true; + } else { + return false; + } + }, + + async delete(routineName: string){ + const composed = compose( + fileAccessor.deleteFile, + fileAccessor.loadFile, + ROUTINE_PATH + ); + await composed(routineName); + }, + + async changeName(originalName: string, newName: string){ + const file = fileAccessor.loadFile(ROUTINE_PATH(originalName)); + if(!file) throw new Error('Routine file not found.'); + await fileAccessor.renameFileWithLinks(file, ROUTINE_PATH(newName)); + }, + + async update(routine: Routine){ + const file = fileAccessor.loadFile(ROUTINE_PATH(routine.getName())); + if(!file) throw new Error('Routine file not found.'); + await fileAccessor.writeFrontMatter(file, () => routine.getProperties().serialize()); + return routine; + }, +} \ No newline at end of file diff --git a/src/entities/routine/persistence/utils.ts b/src/entities/routine/persistence/utils.ts new file mode 100644 index 0000000..aaae1fd --- /dev/null +++ b/src/entities/routine/persistence/utils.ts @@ -0,0 +1,22 @@ +import { DR_SETTING } from "@app/settings/setting-provider"; + + +export const ROUTINE_FOLDER_PATH = () => { + const path = DR_SETTING.routineFolderPath(); + if (!path) { + throw new Error('Routine folder path is not set.'); + } + return path; +}; + +export const ROUTINE_PATH = (routineName: string) => { + return `${ROUTINE_FOLDER_PATH()}/${routineName}.md`; +}; + +export const GROUP_PREFIX = "_g_"; + +export const GROUP_PATH = (groupName: string) => { + return `${ROUTINE_FOLDER_PATH()}/${GROUP_PREFIX}${groupName}`; +}; + + diff --git a/src/entities/routine/routine-frontmatter-parser.ts b/src/entities/routine/routine-frontmatter-parser.ts deleted file mode 100644 index a9b0f03..0000000 --- a/src/entities/routine/routine-frontmatter-parser.ts +++ /dev/null @@ -1,106 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { getFrontMatterInfo, Notice, parseYaml, stringifyYaml } from "obsidian"; -import { Day, DayOfWeek } from "@shared/period/day"; -import { keys } from "lodash"; -import { DEFAULT_ROUTINE, RoutineProperties } from "./routine"; - - -interface RoutineFrontMatterParser { - // frontmatter: frontmatter를 해석한 js object - parse(frontmatter: any): RoutineProperties; - deserialize(fileContent: string): RoutineProperties; - serialize(properties: RoutineProperties): string; -} - -export const RoutineFrontMatterParser: RoutineFrontMatterParser = { - - parse(frontmatter: any): RoutineProperties { - const fm = (() => { - if(typeof frontmatter !== 'object'){ - throw new Error('Routine frontmatter is not object.'); - } - return frontmatter; - })() - - // 기본값 정의 - const properties: RoutineProperties = DEFAULT_ROUTINE().properties; - - // order - if('order' in fm){ - if( - typeof fm.order === 'number' - ){ - properties.order = fm.order; - } - else throw getRoutinePropertiesError('order', fm.order); - } - - // showOnCalendar - if('showOnCalendar' in fm){ - if( - typeof fm.showOnCalendar === 'boolean' - ){ - properties.showOnCalendar = fm.showOnCalendar; - } - else throw getRoutinePropertiesError('showOnCalendar', fm.showOnCalendar); - } - - // activeCriteria - if('activeCriteria' in fm){ - if( - fm.activeCriteria === "week" || fm.activeCriteria === "month" - ){ - properties.activeCriteria = fm.activeCriteria; - } - else throw getRoutinePropertiesError('activeCriteria', fm.activeCriteria); - } - - // daysOfWeek - if('daysOfWeek' in fm){ - if( - Array.isArray(fm.daysOfWeek) && - fm.daysOfWeek.every((d: any) => typeof d === 'string' && keys(DayOfWeek).includes(d)) - ){ - properties.daysOfWeek = fm.daysOfWeek; - } - else throw getRoutinePropertiesError('daysOfWeek', fm.daysOfWeek); - } - - // daysOfMonth - if('daysOfMonth' in fm){ - if( - Array.isArray(fm.daysOfMonth) && - fm.daysOfMonth.every((d: any) => typeof d === 'number' && 0 <= d && d <= 31) - ){ - properties.daysOfMonth = fm.daysOfMonth; - } - else throw getRoutinePropertiesError('daysOfMonth', fm.daysOfMonth); - } - - return properties; - }, - - deserialize: (fileContent: string): RoutineProperties => { - const fmInfo = getFrontMatterInfo(fileContent); - if(!fmInfo.exists){ - throw new Error('Routine frontmatter is not found.'); - } - const yaml = fmInfo.frontmatter.replace('---', '').trim() - const fm = parseYaml(yaml); - return RoutineFrontMatterParser.parse(fm); - }, - - serialize: (properties: RoutineProperties): string => { - const frontmatter = [ - "---", - stringifyYaml(properties), - "---" - ].join('\n'); - return frontmatter; - } -} - - -const getRoutinePropertiesError = (propertyName: string, value: any): Error => { - return new Error(`Routine properties error: ${propertyName} is '${value}'`); -} \ No newline at end of file diff --git a/src/entities/routine/routine-repository.ts b/src/entities/routine/routine-repository.ts deleted file mode 100644 index b3e386a..0000000 --- a/src/entities/routine/routine-repository.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { plugin } from "@shared/plugin-service-locator"; -import { Routine } from "./routine"; -import { fileAccessor } from "@shared/file/file-accessor"; -import { TFile } from "obsidian"; -import { routineSerializer } from "./routine-serializer"; -import { compose } from "@shared/compose"; -import { DR_SETTING } from "@app/settings/setting-provider"; -import { ensureArchive } from "@entities/archives"; - - -const ROUTINE_PATH = (routineName: string) =>{ - return `${ROUTINE_FOLDER_PATH()}/${routineName}.md` -} - -const ROUTINE_FILE = (routineName: string) => { - return fileAccessor.getFile(ROUTINE_PATH(routineName)); -} - -const ROUTINE_FOLDER_PATH = () => { - const path = DR_SETTING.routineFolderPath(); - if(!path) { - throw new Error('Routine folder path is not set.'); - } - return path; -} - - -const parseRoutineFile = async (file: TFile): Promise => { - const fileContent = await fileAccessor.readFileAsReadonly(file); - if(!fileContent) { - throw new Error('Routine file is empty.'); - } - const name = file.name.replace('.md', ''); - return routineSerializer.deserialize(name, fileContent); -} - - -interface RoutineRepository { - /** - * 모든 루틴들 가져오기 - * orderBy: RoutineProperties.order - */ - loadAll(): Promise; - - load(routineName: string): Promise; - - persist(routine: Routine): Promise; - - delete(routineName: string): Promise; - - update(originalName: string, routine: Routine): Promise; -} - -export const RoutineRepository: RoutineRepository = { - async loadAll() { - const routinePromises: Promise[] = (await ensureArchive("routines")).children - .filter(file => file instanceof TFile) - .map(file => parseRoutineFile(file as TFile)); - - const routines = await Promise.all(routinePromises); - return routines.sort((a, b) => a.properties.order - b.properties.order); - }, - - async load(routineName: string) { - const file = ROUTINE_FILE(routineName); - return await parseRoutineFile(file); - }, - - persist(routine: Routine) { - return fileAccessor.createFile( - ROUTINE_PATH(routine.name), - routineSerializer.serialize(routine) - ); - }, - - delete: compose( - fileAccessor.deleteFile, - ROUTINE_FILE - ), - - async update(originalName: string, routine: Routine) { - const file = ROUTINE_FILE(originalName); - // name - if(routine.name !== originalName) { - await fileAccessor.renameFileWithLinks(file, ROUTINE_PATH(routine.name)); - } - // properties - await fileAccessor.writeFrontMatter(file, () => routine.properties); - return routine; - } -} \ No newline at end of file diff --git a/src/entities/routine/routine-serializer.ts b/src/entities/routine/routine-serializer.ts deleted file mode 100644 index dc82ef2..0000000 --- a/src/entities/routine/routine-serializer.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Routine } from "./routine"; -import { RoutineFrontMatterParser } from "./routine-frontmatter-parser"; - - -interface RoutineSerializer { - serialize(routine: Routine): string; - deserialize(name: string, content: string): Routine; -} - -export const routineSerializer: RoutineSerializer = { - - serialize(routine: Routine) { - const properties = RoutineFrontMatterParser.serialize(routine.properties); - const content = ""; - - return `${properties}\n${content}`; - }, - - deserialize(name: string, content: string): Routine { - const properties = RoutineFrontMatterParser.deserialize(content); - return { - name, - properties - } - } - -} \ No newline at end of file diff --git a/src/entities/routine/routine-service.ts b/src/entities/routine/routine-service.ts deleted file mode 100644 index dafe643..0000000 --- a/src/entities/routine/routine-service.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { Routine } from "./routine"; -import { Day } from "@shared/period/day"; -import { RoutineTask } from "@entities/note"; - - -interface RoutineService { - - isRoutineDueTo(routine: Routine, day: Day): boolean; - - deriveRoutineToTask(routine: Routine): RoutineTask; -} -export const RoutineService: RoutineService = { - - /** - * NOTE: daysOfWeek와 daysOfMonth를 기준으로 루틴을 수행할지 말지를 결정한다. - * - daysOfMonth가 0인 경우는 매월의 마지막 날을 의미한다. - */ - isRoutineDueTo(routine: Routine, day: Day): boolean { - - // MONTH 기준 - if (routine.properties.activeCriteria === "month") { - const days = Array.from(routine.properties.daysOfMonth); - // 0이 존재하는 경우, 0을 매개받은 day의 달의 마지막 날짜로 치환한다. - if(days.contains(0)) { - const lastDayOfMonth = day.daysInMonth(); - days.remove(0); - days.push(lastDayOfMonth); - } - if (!days.contains(day.date)) return false; - } - - // WEEK 기준 - if (routine.properties.activeCriteria === "week") { - if (!routine.properties.daysOfWeek.contains(day.dow)) return false; - } - - return true; - }, - - deriveRoutineToTask(routine: Routine): RoutineTask { - return { - type: "routine", - name: routine.name, - checked: false, - showOnCalendar: false - } - }, - -} \ No newline at end of file diff --git a/src/entities/routine/routine.ts b/src/entities/routine/routine.ts deleted file mode 100644 index 512c30c..0000000 --- a/src/entities/routine/routine.ts +++ /dev/null @@ -1,28 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { Day, DayOfWeek } from "@shared/period/day"; - - - -export interface Routine { - name: string; // 루틴 파일 제목 겸 루틴 내용 - properties: RoutineProperties; // 루틴 파일의 프로퍼티 -} - -export interface RoutineProperties { - order: number; // routine들 순서(음이 아닌 정수 0, 1, 2, ...) - showOnCalendar: boolean; // 캘린더에 표시할지 여부 - activeCriteria: "week" | "month"; // 루틴이 활성화되는 기준 - daysOfWeek: DayOfWeek[]; // 요일 - daysOfMonth: number[]; // 0 ~ 31 (0은 매월 마지막 날) -} - -export const DEFAULT_ROUTINE: () => Routine = () => ({ - name: "", - properties: { - order: 0, - showOnCalendar: false, - activeCriteria: "week", - daysOfWeek: Day.getDaysOfWeek(), - daysOfMonth: [Day.now().date], - } -}) \ No newline at end of file diff --git a/src/entities/routine/types.ts b/src/entities/routine/types.ts new file mode 100644 index 0000000..10e0da7 --- /dev/null +++ b/src/entities/routine/types.ts @@ -0,0 +1,25 @@ +import { DayOfWeek } from "@shared/period/day"; + + + + +export interface RoutineDto { + name: string; + properties: RoutinePropertiesDto; +} + +export interface RoutinePropertiesDto { + order: number; + group: string; + showOnCalendar: boolean; + activeCriteria: "week" | "month"; + daysOfWeek: DayOfWeek[]; + daysOfMonth: number[]; +} + +export interface RoutineGroupDto { + name: string; + properties: { + order: number; + } +} \ No newline at end of file diff --git a/src/features/note/resolve-note.ts b/src/features/note/resolve-note.ts index 83e0b6a..cd4fe75 100644 --- a/src/features/note/resolve-note.ts +++ b/src/features/note/resolve-note.ts @@ -1,23 +1,20 @@ -import { RoutineNote, NoteRepository, NoteService } from "@entities/note"; -import { RoutineRepository } from "@entities/routine"; +import { NoteRepository, RoutineNoteDto } from "@entities/note"; +import { RoutineNoteCreator } from "@entities/routine-to-note/RoutineNoteCreator"; import { Day } from "@shared/period/day"; - - -const createNoteAsync = NoteService.setLoaderForCreateAsync(RoutineRepository.loadAll); - /** * 최종적으로 RoutineNote 데이터를 가져오고 처리하는 일련의 과정을 수행한다. * note가 존재한다면 반환하고, 존재하지 않는다면 생성한 다음 오늘에 해당하는 노트라면 저장하는등의 복잡한 과정을 수행할 수 있다. */ -export const resolveRoutineNote = async (day: Day): Promise => { +export const resolveRoutineNote = async (day: Day): Promise => { let routineNote = await NoteRepository.load(day); if(!routineNote){ - routineNote = await createNoteAsync(day); + const noteCreator = await RoutineNoteCreator.withLoadFromRepositoryAsync(); + routineNote = noteCreator.create(day); if(day.isToday()){ - await NoteRepository.persist(routineNote, false); + await NoteRepository.persist(routineNote); } } - return routineNote; + return routineNote.toJSON(); } \ No newline at end of file diff --git a/src/features/note/use-routine-note.ts b/src/features/note/use-routine-note.ts index c10c773..bc06060 100644 --- a/src/features/note/use-routine-note.ts +++ b/src/features/note/use-routine-note.ts @@ -1,21 +1,21 @@ -import { RoutineNote } from "@entities/note"; +import { RoutineNoteDto } from "@entities/note"; import { createStoreContext } from "@shared/zustand/create-store-context"; import { Day } from "@shared/period/day"; import { resolveRoutineNote } from "./resolve-note"; interface UseRoutineNote { - note: RoutineNote; - setNote(note: RoutineNote): void; + note: RoutineNoteDto; + setNote(note: RoutineNoteDto): void; setNote(day: Day): void; } export const [UseRoutineNoteProvider, useRoutineNote] = -createStoreContext((routineNote, set, get) => ({ +createStoreContext((routineNote, set, get) => ({ note: routineNote, - async setNote(noteOrDay: RoutineNote | Day){ - let note: RoutineNote; + async setNote(noteOrDay: RoutineNoteDto | Day){ + let note: RoutineNoteDto; if(noteOrDay instanceof Day){ note = await resolveRoutineNote(noteOrDay); } else { diff --git a/src/features/task/ui/NameOption.tsx b/src/features/option/NameOption.tsx similarity index 100% rename from src/features/task/ui/NameOption.tsx rename to src/features/option/NameOption.tsx diff --git a/src/features/task/ui/ShowOnCalendarOption.tsx b/src/features/option/ShowOnCalendarOption.tsx similarity index 100% rename from src/features/task/ui/ShowOnCalendarOption.tsx rename to src/features/option/ShowOnCalendarOption.tsx diff --git a/src/features/routine/routine-reducer.ts b/src/features/routine/routine-reducer.ts index 84e6a20..76a7d57 100644 --- a/src/features/routine/routine-reducer.ts +++ b/src/features/routine/routine-reducer.ts @@ -1,4 +1,4 @@ -import { Routine, RoutineProperties } from "@entities/routine"; +import { RoutineDto, RoutinePropertiesDto } from "@entities/routine"; export type RoutineReduceAction = @@ -7,11 +7,11 @@ export type RoutineReduceAction = payload: string; } | { type: "SET_PROPERTIES", - payload: Partial; + payload: Partial; } export interface RoutineReducer { - (state: Routine, action: RoutineReduceAction): Routine; + (state: RoutineDto, action: RoutineReduceAction): RoutineDto; } diff --git a/src/features/routine/ui/ActiveCriteria.tsx b/src/features/routine/ui/ActiveCriteria.tsx index 26a7c3d..a0cba14 100644 --- a/src/features/routine/ui/ActiveCriteria.tsx +++ b/src/features/routine/ui/ActiveCriteria.tsx @@ -5,13 +5,13 @@ import { Button } from "@shared/components/Button"; import { WeekOption } from "./active-criteria-week"; import { MonthOption } from "./active-criteria-month"; import { TEXT_CSS } from "@shared/constants/text-style"; -import { Routine, RoutineProperties } from "@entities/routine"; +import { RoutineDto, RoutinePropertiesDto } from "@entities/routine"; interface ActiveCriteriaProps { className?: string; - routine: Routine; - setProperties: (properties: Partial) => void; + routine: RoutineDto; + setProperties: (properties: Partial) => void; } /** * daysOfWeek, 또는 daysOfMonth를 설정하는 옵션 컴포넌트 diff --git a/src/features/routine/ui/active-criteria-month.tsx b/src/features/routine/ui/active-criteria-month.tsx index dca4702..740c2b1 100644 --- a/src/features/routine/ui/active-criteria-month.tsx +++ b/src/features/routine/ui/active-criteria-month.tsx @@ -1,11 +1,11 @@ /** @jsxImportSource @emotion/react */ import { css } from "@emotion/react"; +import { RoutineDto, RoutinePropertiesDto } from "@entities/routine"; import { Button } from "@shared/components/Button"; import clsx from "clsx"; import { useState, useMemo, useEffect, useCallback } from "react"; import Calendar from "react-calendar"; import ReactDOM from "react-dom"; -import { RoutineProperties } from "@entities/routine"; // TODO: BaseCalendar를 사용해서 리팩터링하기 @@ -57,7 +57,7 @@ const calendarCss = css({ interface MonthOptionProps { className?: string; daysOfMonth: number[]; - setProperties: (properties: Partial) => void; + setProperties: (properties: Partial) => void; } export const MonthOption = ({ className, daysOfMonth, setProperties }: MonthOptionProps) => { diff --git a/src/features/routine/ui/active-criteria-week.tsx b/src/features/routine/ui/active-criteria-week.tsx index b8002e4..ea006a0 100644 --- a/src/features/routine/ui/active-criteria-week.tsx +++ b/src/features/routine/ui/active-criteria-week.tsx @@ -2,13 +2,13 @@ import { useCallback } from "react"; import { Button } from "@shared/components/Button"; import { DayOfWeek, Day } from "@shared/period/day"; -import { RoutineProperties } from "@entities/routine"; +import { RoutinePropertiesDto } from "@entities/routine"; interface WeekOptionProps { className?: string; daysOfWeek: DayOfWeek[]; - setProperties: (properties: Partial) => void; + setProperties: (properties: Partial) => void; } export const WeekOption = ({ className, daysOfWeek, setProperties }: WeekOptionProps) => { diff --git a/src/features/task-el/dnd/drag-item.ts b/src/features/task-el/dnd/drag-item.ts new file mode 100644 index 0000000..8343ccd --- /dev/null +++ b/src/features/task-el/dnd/drag-item.ts @@ -0,0 +1,5 @@ +import { TaskElementDto } from "@entities/note"; + +export interface TaskElDragItem { + el: TaskElementDto; +} \ No newline at end of file diff --git a/src/features/task-el/dnd/hit-area.ts b/src/features/task-el/dnd/hit-area.ts new file mode 100644 index 0000000..aeee17d --- /dev/null +++ b/src/features/task-el/dnd/hit-area.ts @@ -0,0 +1,71 @@ +import { XYCoord } from "react-dnd"; + + +export type TaskHitArea = "top" | "bottom"; + +export type GroupHitArea = "top" | "in" | "bottom"; + +type X = XYCoord["x"]; +type Y = XYCoord["y"]; + +type NodeRect = DOMRect; + + +// group-bottom은 보류 +// const getIsXOnLeft = (x: X, rect: NodeRect): boolean => { +// // 어차피 getBoundingClientRect()는 마진을 제외하기 떄문에, 왼쪽을 가리킨다는 것은 이미 rect의 x축 방향 경계를 벗어났다는 것이다. +// return x < rect.left; +// } + +export const HitAreaEvaluator = { + /** + * @param param0 + * @param node + * @param isLastNode 전체 리스트를 기준으로 가장 마지막 노드인 경우에만 true. 개별 group의 마지막인 경우는 false로 처리해야한다. + */ + evaluateTask({ x, y }: XYCoord, node: HTMLElement, isLastNode: boolean): TaskHitArea | null { + const rect: NodeRect = node.getBoundingClientRect(); + const dropTargetHeight = rect.height; + const hitBoundary = 0.40; + const hixbox = dropTargetHeight * hitBoundary; + + // TOP HIT + if(y < rect.top + hixbox){ + return "top"; + + // BOTTOM HIT + } else if(y > rect.bottom - hixbox){ + return "bottom"; + // MIDDLE HIT + } else { + return null; + } + }, + + evaluateGroup({ y }: XYCoord, node: HTMLElement, isOpen: boolean): GroupHitArea | null { + const rect: NodeRect = node.getBoundingClientRect(); + const dropTargetHeight = rect.height; + const hitBoundary = 0.30; + const hixbox = dropTargetHeight * hitBoundary; + + // TOP HIT + if(y < rect.top + hixbox){ + return "top"; + + // BOTTOM HIT + } else if(y > rect.bottom - hixbox){ + if(isOpen){ + return null; + } else { + return "bottom"; + } + // MIDDLE HIT + } else { + if(isOpen){ + return null; + } else { + return "in"; + } + } + }, +} \ No newline at end of file diff --git a/src/features/task-el/dnd/indicator.tsx b/src/features/task-el/dnd/indicator.tsx new file mode 100644 index 0000000..2fdfbd2 --- /dev/null +++ b/src/features/task-el/dnd/indicator.tsx @@ -0,0 +1,48 @@ +/** @jsxImportSource @emotion/react */ +import { css } from "@emotion/react"; +import React from "react"; + + +export type IndicatorMode = "top" | "bottom" | "in"; + +const lineStyle = (direction: "top" | "bottom") => css({ + width: "100%", + height: "2px", + backgroundColor: "hsla(var(--color-accent-1-hsl), 1)", + top: direction === "top" ? "0" : "calc(100%)", + zIndex: 10, + + // dot + "&::before": { + content: "''", + position: "absolute", + width: "10px", + height: "10px", + borderRadius: "100%", + backgroundColor: "hsla(var(--color-accent-1-hsl), 1)", + top: "-4px", + left: "0", + transform: "translateX(-50%)", + zIndex: 10, + } +}) + +const inActiveStyle = css({ + top: "0", + right: "0", + bottom: "0", + left: "0", + borderRadius: "5px", + backgroundColor: "hsla(var(--color-accent-1-hsl), 0.5)", +}) + +export const DndIndicator = React.memo(({ mode }: { mode: IndicatorMode }) => { + return ( +
+ ) +}) \ No newline at end of file diff --git a/src/features/task/hooks/use-dnd-scroll.ts b/src/features/task-el/dnd/use-dnd-scroll.ts similarity index 100% rename from src/features/task/hooks/use-dnd-scroll.ts rename to src/features/task-el/dnd/use-dnd-scroll.ts diff --git a/src/features/task-el/dnd/use-group-dnd.tsx b/src/features/task-el/dnd/use-group-dnd.tsx new file mode 100644 index 0000000..8a167e1 --- /dev/null +++ b/src/features/task-el/dnd/use-group-dnd.tsx @@ -0,0 +1,142 @@ +/** @jsxImportSource @emotion/react */ +import { RoutineNoteDto, TaskDto, TaskElementDto, TaskGroupDto } from "@entities/note"; +import { useRoutineNote } from "@features/note"; +import { useLeaf } from "@shared/view/use-leaf"; +import React, { RefObject, useCallback, useEffect, useMemo, useReducer, useState } from "react"; +import { DropTargetMonitor, useDrag, useDrop } from "react-dnd"; +import { TaskElDragItem } from "./drag-item"; +import { GroupHitArea, HitAreaEvaluator } from "./hit-area"; +import { DndIndicator } from "./indicator"; +import { DroppedElReplacer } from "../model/reorder-elements"; + + + +interface UseGroupDndOption { + group: TaskGroupDto; + groupRef: RefObject; // 드래그 타겟 + isGroupOpen: boolean; + onElDragEnd?: () => void; + onElDrop?: (updatedNote: RoutineNoteDto, dropped: TaskElementDto) => void; +} +interface UseGroupDndResult { + isDragging: boolean; + indicator: React.ReactNode | null; +} +export const useGroupDnd = ({ + group, + groupRef, + isGroupOpen, + onElDragEnd, + onElDrop +}: UseGroupDndOption): UseGroupDndResult => { + const setNote = useRoutineNote(s=>s.setNote); + const { view } = useLeaf(); + const [hit, setHit] = useState(null); + const indicator = useMemo(() => { + if(!hit) return null; + return + }, [hit]) + + const [{ isDragging }, drag, preview] = useDrag({ + type: "GROUP", + + item: () => ({ + el: group, + } as TaskElDragItem), + + end: (item, monitor) => { + onElDragEnd?.(); + }, + + previewOptions: { captureDraggingState: false }, + + collect: (monitor) => ({ + isDragging: monitor.isDragging(), + }) + }, [group]) + + const evaluateHitArea = useCallback((dropped: TaskElementDto, monitor: DropTargetMonitor) => { + if(dropped.name === group.name) return null; + const coord = monitor.getClientOffset()??{x: -1, y: -1}; + const hit = HitAreaEvaluator.evaluateGroup(coord, groupRef.current as HTMLElement, isGroupOpen); + return hit; + }, [group.name, groupRef, isGroupOpen]) + + const [{ isOver }, drop ] = useDrop({ + accept: ["TASK", "GROUP"], + + hover(item: TaskElDragItem, monitor) { + const dropped = item.el; + const hit = evaluateHitArea(dropped, monitor); + if(!hit) return; + setHit(hit); + }, + + drop: async (item, monitor) => { + if(!hit) return; + const dropped = item.el; + let newNote: RoutineNoteDto; + if(dropped.elementType === "task") { + newNote = DroppedElReplacer.taskDropOnGroup({ + dropped: dropped as TaskDto, + on: group, + hit, + }); + } else { + if(hit === "in") return null; + newNote = DroppedElReplacer.groupDropOnGroup({ + dropped: dropped as TaskGroupDto, + on: group, + hit, + }); + } + setHit(null); + setNote(newNote); + onElDrop?.(newNote, dropped); + }, + + collect: (monitor) => ({ + isOver: monitor.isOver(), + }) + + }, [group, hit, onElDrop, setNote, evaluateHitArea]) + + // hoverOut시에 indicator를 제거 + useEffect(() => { + if(!isOver) { + setHit(null); + } + }, [isOver]) + + useEffect(() => { + if(!groupRef.current) return; + drag(groupRef.current); + drop(groupRef.current); + + /** + * HACK: 유지보수를 위해서 preview 로직을 backend 종류(html5, touch)에 관계없이 동일하게 처리하기 위해서, html5 backend에서 제공하는 기본 preview를 없애고 있었다. + * 이 때, preview의 크기를 아예 없애거나 보이지 않도록 조치를 취하면, 이상한 지구본 모양의 아이콘이 어디선가 나타나는 기현상이 계속해서 발생함. + * 그래서 그냥 html5인 경우에 preview를 최대한 안보이게 찌끄맣게 만들어서 할당해둠. + * 실제 모든 preveiw에 관한 처리는 TaskPreview 컴포넌트에서 처리함. + */ + let pseudoPreview = document.querySelector("#dr-pseudo-preview"); + if(!pseudoPreview){ + const div = document.createElement("div"); + div.setCssStyles({ + backgroundColor: "red", + width: "0.01px", + height: "0.01px", + }) + div.id = "dr-pseudo-preview"; + view.containerEl.appendChild(div); + pseudoPreview = div; + } + preview(pseudoPreview); + + }, [drop, drag, groupRef, preview, group, view.containerEl]) + + return { + isDragging, + indicator + }; +} \ No newline at end of file diff --git a/src/features/task-el/dnd/use-task-dnd.tsx b/src/features/task-el/dnd/use-task-dnd.tsx new file mode 100644 index 0000000..c55e0c4 --- /dev/null +++ b/src/features/task-el/dnd/use-task-dnd.tsx @@ -0,0 +1,162 @@ +/** @jsxImportSource @emotion/react */ +import { RoutineNoteDto, TaskDto, TaskElementDto, TaskGroupDto } from "@entities/note"; +import { useRoutineNote } from "@features/note"; +import { useLeaf } from "@shared/view/use-leaf"; +import { RefObject, useCallback, useEffect, useMemo, useReducer, useState } from "react"; +import { DropTargetMonitor, useDrag, useDrop } from "react-dnd"; +import { HitAreaEvaluator, TaskHitArea } from "./hit-area"; +import { DndIndicator } from "./indicator"; +import { DroppedElReplacer } from "../model/reorder-elements"; +import { TaskElDragItem } from "./drag-item"; + + +interface UseTaskDndOption { + task: TaskDto; + group: TaskGroupDto | null; + taskRef: RefObject; // 드래그 타겟 + onElDragEnd?: () => void; + onElDrop?: (updatedNote: RoutineNoteDto, dropped: TaskElementDto) => void; +} +interface UseTaksDndResult { + isDragging: boolean; + indicator: React.ReactNode | null; +} +export const useTaskDnd = ({ + task, + group, + taskRef, + onElDragEnd, + onElDrop, +}: UseTaskDndOption): UseTaksDndResult => { + const setNote = useRoutineNote(s=>s.setNote); + const { view } = useLeaf(); + const [hit, setHit] = useState(null); + const indicator = useMemo(() => { + if(!hit) return null; + return + }, [hit]) + + const isLastNode = useMemo(() => { + if(!group) return false; + const note = useRoutineNote.getState().note; + const lastGroup = (() => { + const groupOrTask = note.root[note.root.length - 1]; + if(groupOrTask.elementType === "group"){ + return groupOrTask as TaskGroupDto; + } else { + return null; + } + })(); + + if(lastGroup){ + return lastGroup.tasks[lastGroup.tasks.length - 1].name === task.name; + } else { + return false; + } + }, [group, task.name]) + + const [{ isDragging }, drag, preview] = useDrag({ + type: "TASK", + + item: () => ({ + el: task, + } as TaskElDragItem), + + end: (item, monitor) => { + onElDragEnd?.(); + }, + + previewOptions: {captureDraggingState: false}, + + collect: (monitor) => ({ + isDragging: monitor.isDragging(), + }) + }, [task]) + + const evaluateHitArea = useCallback((dropped: TaskElementDto, monitor: DropTargetMonitor) => { + if(dropped.name === task.name) return null; + const coord = monitor.getClientOffset()??{x: -1, y: -1}; + const hit = HitAreaEvaluator.evaluateTask(coord, taskRef.current as HTMLElement, isLastNode); + return hit; + }, [task, taskRef, isLastNode]) + + const [{ isOver }, drop ] = useDrop({ + // 이미 그룹에 속한 task가 또 그룹을 받으면 계층이 깊어짐 + accept: group ? "TASK" : ["TASK", "GROUP"], + + hover(item: TaskElDragItem, monitor) { + const dropped = item.el; + const newHit = evaluateHitArea(dropped, monitor); + if(!newHit) return; + if(newHit !== hit){ + setHit(newHit); + } + }, + + drop: async (item, monitor) => { + if(!hit) return; + const dropped = item.el; + let newNote: RoutineNoteDto; + if(dropped.elementType === "task") { + newNote = DroppedElReplacer.taskDropOnTask({ + dropped: dropped as TaskDto, + on: task, + hit + }); + } else { + newNote = DroppedElReplacer.groupDropOnTask({ + dropped: dropped as TaskGroupDto, + on: task, + hit, + }); + } + setHit(null); + setNote(newNote); + onElDrop?.(newNote, dropped); + }, + + collect: (monitor) => ({ + isOver: monitor.isOver(), + }) + + }, [task, setNote, group, onElDrop, evaluateHitArea, hit]) + + // hoverOut시에 hit을 초기화 + useEffect(() => { + if(!isOver) { + setHit(null); + } + }, [isOver]) + + useEffect(() => { + if(!taskRef.current) return; + drag(taskRef.current); + drop(taskRef.current); + + /** + * HACK: 유지보수를 위해서 preview 로직을 backend 종류(html5, touch)에 관계없이 동일하게 처리하기 위해서, html5 backend에서 제공하는 기본 preview를 없애고 있었다. + * 이 때, preview의 크기를 아예 없애거나 보이지 않도록 조치를 취하면, 이상한 지구본 모양의 아이콘이 어디선가 나타나는 기현상이 계속해서 발생함. + * 그래서 그냥 html5인 경우에 preview를 최대한 안보이게 찌끄맣게 만들어서 할당해둠. + * 실제 모든 preveiw에 관한 처리는 TaskPreview 컴포넌트에서 처리함. + */ + let pseudoPreview = document.querySelector("#dr-pseudo-preview"); + if(!pseudoPreview) { + const div = document.createElement("div"); + div.setCssStyles({ + backgroundColor: "red", + width: "0.01px", + height: "0.01px", + }) + div.id = "dr-pseudo-preview"; + view.containerEl.appendChild(div); + pseudoPreview = div; + } + preview(pseudoPreview); + + }, [drop, drag, taskRef, preview, task, view.containerEl]) + + return { + isDragging, + indicator + }; +} \ No newline at end of file diff --git a/src/features/task-el/index.ts b/src/features/task-el/index.ts new file mode 100644 index 0000000..7a23cfc --- /dev/null +++ b/src/features/task-el/index.ts @@ -0,0 +1,9 @@ +import { NameOption } from "../option/NameOption"; +import { ShowOnCalendarOption } from "../option/ShowOnCalendarOption"; + +export const TaskOption = { + Name: NameOption, + ShowOnCalendar: ShowOnCalendarOption +}; +export { TaskDndContext } from "./ui/dnd-context"; +export { BaseTaskFeature as AbstractTaskWidget } from "./ui/BaseTaskFeature"; \ No newline at end of file diff --git a/src/features/task-el/model/check-task.ts b/src/features/task-el/model/check-task.ts new file mode 100644 index 0000000..afff1a8 --- /dev/null +++ b/src/features/task-el/model/check-task.ts @@ -0,0 +1,16 @@ +import { NoteRepository, RoutineNote, RoutineNoteDto, TaskDto } from "@entities/note"; + + +export const checkTask = async (noteDto: RoutineNoteDto, taskDto: TaskDto, check: boolean): Promise => { + const note = RoutineNote.fromJSON(noteDto); + const task = note.findTask(taskDto.name); + if(!task) throw new Error("Check state change target task not found"); + + task.check(check); + const isSaved = await NoteRepository.saveOnUserConfirm(note); + if(isSaved){ + return note.toJSON(); + } else { + return { ...noteDto }; + } +} \ No newline at end of file diff --git a/src/features/task-el/model/reorder-elements.ts b/src/features/task-el/model/reorder-elements.ts new file mode 100644 index 0000000..c45e676 --- /dev/null +++ b/src/features/task-el/model/reorder-elements.ts @@ -0,0 +1,115 @@ +import { NoteRepository, RoutineNote, RoutineNoteDto, TaskDto, TaskElementDto, TaskGroupDto } from "@entities/note"; +import { useRoutineNote } from "@features/note"; + + +const NOTE_DOMAIN = () => { + return RoutineNote.fromJSON(useRoutineNote.getState().note); +} + +const save = async (note: RoutineNote) => { + if(await NoteRepository.isExist(note.getDay())){ + await NoteRepository.update(note); + } +} + + +type TaskOnTask = { + dropped: TaskDto; + on: TaskDto; + hit: "top" | "bottom"; +} +const taskDropOnTask = (args: TaskOnTask) => { + if(args.dropped.name === args.on.name) throw new Error("Cannot drop on itself"); + + const note = NOTE_DOMAIN(); + const on = note.findTask(args.on.name); + const dropped = note.findTask(args.dropped.name); + if(!on || !dropped) throw new Error("Dest Task or Dropped Task not found"); + + const parent = on.getParent(); + parent.addTask(dropped, (arr) => { + const idx = arr.indexOf(on); + return args.hit === "top" ? idx : idx + 1 + }); + save(note); + + return note.toJSON(); +} + +type GroupOnTask = { + dropped: TaskGroupDto; + on: TaskDto; + hit: "top" | "bottom"; +} +const groupDropOnTask = (args: GroupOnTask) => { + const note = NOTE_DOMAIN(); + const root = note.getChildren(); + const on = root.find(t => t.getName() === args.on.name); + const dropped = root.find(t => t.getName() === args.dropped.name); + if(!on || !dropped) throw new Error("Dest Task or Dropped Task not found. Or group has dropped in another group."); + + const idx = root.indexOf(on); + note.addEl(dropped, (arr) => { + const idx = arr.indexOf(on); + return args.hit === "top" ? idx : idx + 1 + }); + save(note); + + return note.toJSON(); +} + +type TaskOnGroup = { + dropped: TaskDto; + on: TaskGroupDto; + hit: "top" | "bottom" | "in"; +} +const taskDropOnGroup = (args: TaskOnGroup) => { + const note = NOTE_DOMAIN(); + const on = note.findGroup(args.on.name); + const dropped = note.findTask(args.dropped.name); + if(!on || !dropped) throw new Error("Dest Group or Dropped Task not found."); + + if(args.hit === "in"){ + on.addTask(dropped); + } else { + const idx = note.getChildren().indexOf(on); + note.addTask(dropped, (arr) => { + const idx = arr.indexOf(on); + return args.hit === "top" ? idx : idx + 1 + }); + } + save(note); + + return note.toJSON(); +} + +type GroupOnGroup = { + dropped: TaskGroupDto; + on: TaskGroupDto; + hit: "top" | "bottom"; +} +const groupDropOnGroup = (args: GroupOnGroup) => { + const note = NOTE_DOMAIN(); + const root = note.getChildren(); + const on = root.find(t => t.getName() === args.on.name); + const dropped = root.find(t => t.getName() === args.dropped.name); + if(!on || !dropped) throw new Error("Dest Group or Dropped Group not found."); + + const idx = root.indexOf(on); + note.addEl(dropped, (arr) => { + const idx = arr.indexOf(on); + return args.hit === "top" ? idx : idx + 1 + }); + save(note); + + return note.toJSON(); +} + + + +export const DroppedElReplacer = { + taskDropOnTask, + groupDropOnTask, + taskDropOnGroup, + groupDropOnGroup, +} \ No newline at end of file diff --git a/src/features/task-el/ui/BaseTaskFeature.tsx b/src/features/task-el/ui/BaseTaskFeature.tsx new file mode 100644 index 0000000..25eab71 --- /dev/null +++ b/src/features/task-el/ui/BaseTaskFeature.tsx @@ -0,0 +1,144 @@ +/** @jsxImportSource @emotion/react */ +import { RoutineNoteDto, TaskDto, TaskGroupDto } from '@entities/note'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react" +import { useRoutineNote } from "@features/note"; +import _ from "lodash"; +import { Touchable } from '@shared/components/Touchable'; +import { dr } from '@shared/daily-routine-bem'; +import { Icon } from '@shared/components/Icon'; +import { TaskName } from './TaskName'; +import { Checkbox } from './Checkbox'; +import { useTaskDnd } from '../dnd/use-task-dnd'; +import { checkTask } from '../model/check-task'; +import { OptionIcon } from './OptionIcon'; +import { DELAY_TOUCH_START } from './dnd-context'; +import { baseHeaderStyle, pressedStyle, dragReadyStyle, draggingStyle, elementHeight } from './base-element-style'; +import { css } from '@emotion/react'; + + +const indentStyle = css({ + borderLeft: "1px solid var(--color-base-30)", + margin: "0 0 0 13px", + paddingLeft: "9px", +}) + +const bem = dr("task"); + +type TaskMode = "idle" | "pressed" | "drag-ready" | "dragging"; + +interface TaskProps { + task: T; + parent: TaskGroupDto | null; + onOptionMenu: (task: T) => void; + + className?: string; + onTaskReorder?: (note: RoutineNoteDto, task: T) => void; + onTaskClick?: (task: T) => void; +} +export const BaseTaskFeature = React.memo(({ + task, + parent, + className, + onTaskReorder, + onOptionMenu, + onTaskClick +}: TaskProps) => { + const taskRef = useRef(null); + const [taskMode, setTaskMode] = useState("idle"); + const { setNote, note } = useRoutineNote(); + + const onElDrop = useCallback((newNote: RoutineNoteDto, dropped: T) => { + onTaskReorder?.(newNote, dropped); + }, [onTaskReorder]) + + const onElDragEnd = useCallback(() => { + setTaskMode("idle"); + }, []) + + const { isDragging: _isDragging, indicator } = useTaskDnd({ + task, + taskRef, + group: parent, + onElDragEnd, + onElDrop, + }); + + // isDragging sync + useEffect(() => { + if(_isDragging){ + setTaskMode("dragging"); + }else{ + setTaskMode("idle"); + } + }, [_isDragging]) + + const onPressChange = useCallback((isPressed: boolean) => { + if(isPressed && taskMode !== "pressed"){ + setTaskMode("pressed"); + } else if(!isPressed && taskMode !== "dragging"){ + setTaskMode("idle"); + } + }, [taskMode]) + + // 지정시간동안 press 하고나서 + const onAfterLongPressDelay = useCallback(() => { + setTaskMode("drag-ready"); + }, []) + + + // click + const disableTouch = useRef(false); + const onClick = useCallback(async (e: React.TouchEvent | React.MouseEvent) => { + if(disableTouch.current) return; + disableTouch.current = true; + // HACK: 이유는 모르겠지만 이거 안하면 모바일 환경에서 모달이 열리자마다 닫혀버림. + e.preventDefault(); + + const checked = !task.checked; + const newNote = await checkTask(note, task, checked); + setNote(newNote); + if(onTaskClick) onTaskClick({ ...task, checked }); + + // HACK: 빠르게 인접한 task를 클릭하면 클릭히 씹히거나 두번 클릭되는 문제가 있어서, 일단 0.5초 정도 막아둠으로 해결 + setTimeout(() => { + disableTouch.current = false; + }, 500); + }, [note, onTaskClick, setNote, task]) + + return ( +
+ + + + + onOptionMenu(task)} /> + {indicator} +
+ ) +}) \ No newline at end of file diff --git a/src/features/task/ui/Checkbox.tsx b/src/features/task-el/ui/Checkbox.tsx similarity index 85% rename from src/features/task/ui/Checkbox.tsx rename to src/features/task-el/ui/Checkbox.tsx index b1d0e60..740f11f 100644 --- a/src/features/task/ui/Checkbox.tsx +++ b/src/features/task-el/ui/Checkbox.tsx @@ -1,14 +1,15 @@ /** @jsxImportSource @emotion/react */ -import { DailyRoutineBEM } from "@shared/daily-routine-bem"; +import { dr } from "@shared/daily-routine-bem"; +const bem = dr("task-cbx") -interface CheckboxProps { - bem: DailyRoutineBEM; +interface Props { + isChecked: boolean } -export const Checkbox = ({ bem }: CheckboxProps) => { +export const Checkbox = ({ isChecked }: Props) => { return ( { height: "calc(14/16 * 1em)", marginRight: "calc(0.5em)", border: "calc(1/16 * 1em) solid #c8ccd4", + borderColor: isChecked ? "transparent" : undefined, borderRadius: "3px", cursor: "pointer", + transition: "scale", // 체크표시 V - "&:before": { + "&::before": { content: "''", position: "absolute", top: "50%", @@ -34,10 +37,15 @@ export const Checkbox = ({ bem }: CheckboxProps) => { borderRadius: "100%", background: "hsla(var(--color-accent-1-hsl), 1.0)", transform: "scale(0)", + ...(isChecked && { + transform: "scale(1)", + opacity: "0", + transition: "all 0.3s ease", + }) }, // 체크 이펙트 - "&:after": { + "&::after": { content: "''", position: "absolute", top: "calc(5/16 * 1em)", @@ -47,22 +55,12 @@ export const Checkbox = ({ bem }: CheckboxProps) => { borderRadius: "2px", boxShadow: "0 calc(9/8 * -1em) 0 hsla(var(--color-accent-1-hsl), 1.0), calc(3/4 * 1em) calc(3/4 * -1em) 0 hsla(var(--color-accent-1-hsl), 1.0), calc(9/8 * 1em) 0 0 hsla(var(--color-accent-1-hsl), 1.0), calc(3/4 * 1em) calc(3/4 * 1em) 0 hsla(var(--color-accent-1-hsl), 1.0), 0 calc(9/8 * 1em) 0 hsla(var(--color-accent-1-hsl), 1.0), calc(3/4 * -1em) calc(3/4 * 1em) 0 hsla(var(--color-accent-1-hsl), 1.0), calc(9/8 * -1em) 0 0 hsla(var(--color-accent-1-hsl), 1.0), calc(3/4 * -1em) calc(3/4 * -1em) 0 hsla(var(--color-accent-1-hsl), 1.0)", transform: "scale(0)", - }, - - // 체크된 경우 - ".dr-task--checked &": { - borderColor: "transparent", - "&:before": { - transform: "scale(1)", - opacity: "0", - transition: "all 0.3s ease", - }, - "&:after": { + ...(isChecked && { transform: "scale(1)", opacity: "0", transition: "all 0.6s ease", - } - } + }) + }, }} > { fill: "none", strokeLinecap: "round", strokeLinejoin: "round", - ".dr-task--checked &": { + ...(isChecked && { transform: "scale(1)", transition: "all 0.4s ease", transitionDelay: "0.1s", - } + }) }} > { + + const { currentOffset } = useDragLayer((monitor) => ({ + currentOffset: monitor.getClientOffset(), + })); + + const leaf = useLeaf(); + + const previewStyle = useMemo(() => { + if (!currentOffset) return { display: 'none' }; + + const previewWidth = 300; + const previewHeight = 50; + + const { x: x_viewport, y: y_viewport } = currentOffset; + // @ts-ignore + // const appEl = plugin().app.dom.appContainerEl; + const leafEl = leaf.view.containerEl; + const x_leaf = x_viewport - (window.innerWidth - leafEl.clientWidth); + const y_leaf = y_viewport - (window.innerHeight - leafEl.clientHeight); + + const getTransform = (backend: "html5" | "touch") => { + return `translate(${x_leaf - previewWidth/2}px, ${y_leaf - previewHeight}px)`; + } + const transform = getTransform(backend); + return { + ...style, + width: previewWidth, + height: previewHeight, + backgroundColor: "transparent", + transform, + WebkitTransform: transform, + display: "flex", + justifyContent: "center", + alignItems: "center", + }; + }, [backend, currentOffset, leaf.view.containerEl, style]); + + return ( +
+
+
{item.el.elementType}
+
{item.el.name}
+
+
+ ) +} \ No newline at end of file diff --git a/src/features/task-el/ui/OptionIcon.tsx b/src/features/task-el/ui/OptionIcon.tsx new file mode 100644 index 0000000..eb42e82 --- /dev/null +++ b/src/features/task-el/ui/OptionIcon.tsx @@ -0,0 +1,27 @@ +/** @jsxImportSource @emotion/react */ +import { Icon } from "@shared/components/Icon"; + + + +interface OptionIconProps { + onClick: () => void; +} +export const OptionIcon = ({ + onClick, +}: OptionIconProps) => { + return ( +
+ +
+ ) +} \ No newline at end of file diff --git a/src/features/task-el/ui/TaskGroupHeadFeature.tsx b/src/features/task-el/ui/TaskGroupHeadFeature.tsx new file mode 100644 index 0000000..1f5a561 --- /dev/null +++ b/src/features/task-el/ui/TaskGroupHeadFeature.tsx @@ -0,0 +1,169 @@ +/** @jsxImportSource @emotion/react */ +import { RoutineNoteDto, TaskGroupDto } from '@entities/note'; +import { Accordion, AccordionDetails, AccordionSummary, accordionSummaryClasses } from '@mui/material'; +import { Icon } from '@shared/components/Icon'; +import { Touchable } from '@shared/components/Touchable'; +import { dr } from '@shared/daily-routine-bem'; +import { useLeaf } from '@shared/view/use-leaf'; +import React, { useCallback, useEffect, useRef, useState } from "react"; +import { useGroupDnd } from '../dnd/use-group-dnd'; +import { baseHeaderStyle, draggingStyle, dragReadyStyle, elementHeight, pressedStyle } from './base-element-style'; +import { DELAY_TOUCH_START } from './dnd-context'; +import { OptionIcon } from './OptionIcon'; + + + +const bem = dr("group"); + +type GroupMode = "idle" | "pressed" | "drag-ready" | "dragging"; + +interface Props { + group: TaskGroupDto; + children: React.ReactNode; + + onOptionMenu?: (group: TaskGroupDto) => void; + className?: string; + onGroupReorder?: (note: RoutineNoteDto, group: TaskGroupDto) => void; + onGroupClick?: (group: TaskGroupDto) => void; +} +export const BaseGroupHeadFeature = React.memo(({ + group, + children, + className, + onGroupReorder, + onOptionMenu, + onGroupClick, +}: Props) => { + const groupRef = useRef(null); + const [groupMode, setGroupMode] = useState("idle"); + const bgColor = useLeaf(s=>s.leafBgColor); + const [open, setOpen] = useState(true); + + const onElDrop = useCallback((newNote: RoutineNoteDto, dropped: TaskGroupDto) => { + onGroupReorder?.(newNote, dropped); + }, [onGroupReorder]) + + const dragEndCallbacksRef = useRef<(() => void)[]>([]); + const onElDragEnd = useCallback(() => { + dragEndCallbacksRef.current.forEach(cb => cb()); + dragEndCallbacksRef.current = []; + setGroupMode("idle"); + }, []) + + const { isDragging: _isDragging, indicator } = useGroupDnd({ + group, + groupRef, + isGroupOpen: open, + onElDrop, + onElDragEnd, + }); + + // isDragging sync + useEffect(() => { + if(_isDragging){ + setGroupMode("dragging"); + } else { + setGroupMode("idle"); + } + }, [_isDragging]) + + useEffect(() => { + switch(groupMode){ + case "drag-ready": + case "dragging": { + if(open){ + setOpen(false); + dragEndCallbacksRef.current.push(() => setOpen(true)); + } + } + } + }, [groupMode, open]) + + const onPressChange = useCallback((isPressed: boolean) => { + if(isPressed && groupMode !== "pressed"){ + setGroupMode("pressed"); + } else if(!isPressed && groupMode !== "dragging"){ + setGroupMode("idle"); + } + }, [groupMode]) + + // 지정시간동안 press 하고나서 + const onAfterLongPressDelay = useCallback(() => { + setGroupMode("drag-ready"); + }, []) + + + const onClick = useCallback((e: React.TouchEvent | React.MouseEvent) => { + onGroupClick?.(group); + }, [group, onGroupClick]) + + + return ( +
+ setOpen(!open)} + css={{ + backgroundColor: bgColor, + "&::before": { + display: "none", + } + }} + > +
+ + } + css={{ + padding: "0", + width: "100%", + flexDirection: "row-reverse", + gap: "0.5em", + fontWeight: "500", + + // 열림상태 + [`& .${accordionSummaryClasses.expandIconWrapper}.${accordionSummaryClasses.expanded}`]: { + transform: 'rotate(90deg)', + }, + }} + > + {group.name} + + + onOptionMenu?.(group)} /> + {indicator} +
+ + {children} + +
+
+ ) +}) \ No newline at end of file diff --git a/src/features/task-el/ui/TaskName.tsx b/src/features/task-el/ui/TaskName.tsx new file mode 100644 index 0000000..3c2f0aa --- /dev/null +++ b/src/features/task-el/ui/TaskName.tsx @@ -0,0 +1,47 @@ +/** @jsxImportSource @emotion/react */ +import { css } from "@emotion/react"; +import { DailyRoutineBEM, dr } from "@shared/daily-routine-bem" + + +const bem = dr("task-name"); + +const baseStyle = css({ + position: "relative", + cursor: "pointer", + transition: "color 0.3s ease", + "&:after": { + content: "''", + position: "absolute", + top: "50%", + left: 0, + width: 0, + height: "1px", + background: "#9098a9", + } +}) + +const chechedStyle = css({ + color: "#9098a9", + "&:after": { + width: "100%", + transition: "all 0.4s ease", + } +}) + +interface Props { + name: string; + isChecked: boolean; +} +export const TaskName = ({ + name, + isChecked +}: Props) => { + return ( + + {name} + + ) +} \ No newline at end of file diff --git a/src/features/task-el/ui/base-element-style.ts b/src/features/task-el/ui/base-element-style.ts new file mode 100644 index 0000000..106f5f0 --- /dev/null +++ b/src/features/task-el/ui/base-element-style.ts @@ -0,0 +1,62 @@ +import { css } from "@emotion/react"; + + +export const elementHeight = "2.5em"; + +export const baseHeaderStyle = css({ + display: "flex", + position: "relative", + alignItems: "center", + justifyContent: "space-between", + "& *": { + touchAction: "manipulation", + userSelect: "none", + }, + "&::after": { + content: "''", + } +}); + +export const pressedStyle = css({ + position: "relative", + "&::after": { + content: "''", + position: "absolute", + top: "0", + left: "0", + width: "100%", + height: "100%", + borderRadius: "5px", + backgroundColor: "var(--color-base-40)", + transition: "ease-in 0.25s", + opacity: 0.5, + } +}); + +export const dragReadyStyle = css({ + position: "relative", + "&::after": { + content: "''", + position: "absolute", + top: "0", + left: "0", + width: "100%", + height: "100%", + borderRadius: "5px", + backgroundColor: "hsla(var(--color-accent-1-hsl), 0.7)", + } +}); + +export const draggingStyle = css({ + position: "relative", + "&::after": { + content: "''", + position: "absolute", + top: "0", + left: "0", + width: "100%", + height: "100%", + borderRadius: "5px", + backgroundColor: "hsla(var(--color-accent-1-hsl), 0.7)", + } +}); diff --git a/src/features/task/ui/dnd-context.tsx b/src/features/task-el/ui/dnd-context.tsx similarity index 88% rename from src/features/task/ui/dnd-context.tsx rename to src/features/task-el/ui/dnd-context.tsx index 33a6b7a..2d69aa5 100644 --- a/src/features/task/ui/dnd-context.tsx +++ b/src/features/task-el/ui/dnd-context.tsx @@ -10,12 +10,13 @@ import { createTransition, } from "react-dnd-multi-backend"; import { Preview } from "react-dnd-preview"; -import { TaskPreview } from "./TaskPreview"; -import { useDndScroll } from "../hooks/use-dnd-scroll"; +import { ElementPreview } from "./ElementPreview"; +import { useDndScroll } from "../dnd/use-dnd-scroll"; import { useDragDropManager } from "react-dnd"; -import { DragItem } from "../hooks/use-task-dnd"; +import { TaskElDragItem } from '../dnd/drag-item'; +export const DELAY_TOUCH_START = 1000; export const TaskDndContext = ({ children }: {children: React.ReactNode }) => { @@ -47,7 +48,7 @@ export const TaskDndContext = ({ children }: {children: React.ReactNode }) => { backend: TouchBackend, options: { enableMouseEvents: false, - delayTouchStart: 500, + delayTouchStart: DELAY_TOUCH_START, ignoreContextMenu: false }, transition: TouchTransition @@ -63,8 +64,8 @@ export const TaskDndContext = ({ children }: {children: React.ReactNode }) => {
( - @@ -101,7 +102,7 @@ const ScrollComponent = ({ children }: ScrollComponentProps) => { css={{ height: "100%", overflowY: "auto", - padding: "0 10px", + padding: "0 0.5em", }} > {children} diff --git a/src/features/task/hooks/use-task-dnd.ts b/src/features/task/hooks/use-task-dnd.ts deleted file mode 100644 index 58550cd..0000000 --- a/src/features/task/hooks/use-task-dnd.ts +++ /dev/null @@ -1,133 +0,0 @@ - -/** @jsxImportSource @emotion/react */ -import { RoutineNote, Task } from "@entities/note"; -import { useRoutineNote } from "@features/note"; -import { useRef, useCallback, useEffect, RefObject } from "react"; -import { useDrag, XYCoord, useDrop } from "react-dnd"; -import { useLeaf } from "@shared/view/use-leaf"; - - - -export interface DragItem { - task: Task; - previewSource: HTMLElement; - width: number; // px - dragHandleWidth: number; // px -} - -interface UseTaskDndOption { - task: Task; - taskRef: RefObject; // 드롭 타겟 - handleRef: RefObject; // 드래그 핸들 - onTaskDrop?: (newNote: RoutineNote, droped: Task) => void; -} -interface UseTaskDndResult { - isDragging: boolean; -} -export const useTaskDnd = ({ task, taskRef, handleRef, onTaskDrop }: UseTaskDndOption): UseTaskDndResult => { - const setNote = useRoutineNote(s=>s.setNote); - const { view } = useLeaf(); - const dragStartNoteSnapshot = useRef(null); - - const [{ isDragging }, drag, preview] = useDrag({ - type: "task", - item(){ - dragStartNoteSnapshot.current = useRoutineNote.getState().note; - const item: DragItem = { - task, - previewSource: taskRef.current as HTMLElement, - width: taskRef.current?.clientWidth??0, - dragHandleWidth: handleRef.current?.clientWidth??0, - }; - return item; - }, - end: (item, monitor) => { - }, - previewOptions: {captureDraggingState: false}, - collect: (monitor) => ({ - isDragging: monitor.isDragging(), - }) - }, [task]) - - const getHixArea = useCallback(({ y }: XYCoord): "top" | "bottom" | null => { - if(!handleRef.current) return null; - const dropElOffset = handleRef.current.getBoundingClientRect(); - const dropTargetHeight = dropElOffset.height; - // 전체 task 높이 중에서 윗부분 n%, 아랫부분 n%에 hit한 경우 각각 topHix, bottomHix로 처리 - const hitBoundary = 0.35; - const dropTargetAvailableHeightPerHixboxes = dropTargetHeight * hitBoundary; - - // RETURN - if(y < dropElOffset.top + dropTargetAvailableHeightPerHixboxes) return "bottom"; - if(y > dropElOffset.bottom - dropTargetAvailableHeightPerHixboxes) return "top"; - return null; - }, [handleRef]) - - const replaceTask = useCallback((target: Task, hit: "top" | "bottom"): RoutineNote => { - const note = useRoutineNote.getState().note; - return { - ...note, - tasks: note.tasks.reduce((acc, t) => { - if(t.name === task.name && hit === "top"){ - acc.push(target); - } - if(t.name === target.name) return acc; - acc.push(t); - if(t.name === task.name && hit === "bottom"){ - acc.push(target); - } - return acc; - }, [] as Task[]) - } - }, [task]) - - const [ , drop ] = useDrop({ - accept: "task", - hover(item: DragItem, monitor) { - if(item.task.name === task.name) return; - const xy = monitor.getClientOffset()??{x: -1, y: -1}; - const hit = getHixArea(xy); - if(!hit) return; - - const newRoutineNote = replaceTask(item.task, hit); - setNote(newRoutineNote); - }, - drop: async (item) => { - const note = useRoutineNote.getState().note; - if(dragStartNoteSnapshot.current === note) return; - onTaskDrop?.(note, item.task); - } - }, [task, getHixArea, replaceTask, setNote]) - - useEffect(() => { - if(!handleRef.current || !taskRef.current) return; - drag(handleRef.current); - drop(taskRef.current); - - - /** - * HACK: 유지보수를 위해서 preview 로직을 backend 종류(html5, touch)에 관계없이 동일하게 처리하기 위해서, html5 backend에서 제공하는 기본 preview를 없애고 있었다. - * 이 때, preview의 크기를 아예 없애거나 보이지 않도록 조치를 취하면, 이상한 지구본 모양의 아이콘이 어디선가 나타나는 기현상이 계속해서 발생함. - * 그래서 그냥 html5인 경우에 preview를 최대한 안보이게 찌끄맣게 만들어서 할당해둠. - * 실제 모든 preveiw에 관한 처리는 TaskPreview 컴포넌트에서 처리함. - */ - let pseudoPreview = document.querySelector("#dr-pseudo-preview"); - if(!pseudoPreview) { - const div = document.createElement("div"); - div.setCssStyles({ - backgroundColor: "red", - width: "0.01px", - height: "0.01px", - }) - div.id = "dr-pseudo-preview"; - view.containerEl.appendChild(div); - pseudoPreview = div; - } - preview(pseudoPreview); - - }, [handleRef, drop, drag, taskRef, preview, task, view.containerEl]) - - return { - isDragging - }; -} \ No newline at end of file diff --git a/src/features/task/index.ts b/src/features/task/index.ts deleted file mode 100644 index 34cb658..0000000 --- a/src/features/task/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { NameOption } from "./ui/NameOption"; -import { ShowOnCalendarOption } from "./ui/ShowOnCalendarOption"; - -export const TaskOption = { - Name: NameOption, - ShowOnCalendar: ShowOnCalendarOption -}; -export { TaskDndContext } from "./ui/dnd-context"; -export { AbstractTask } from "./ui/AbstractTask"; \ No newline at end of file diff --git a/src/features/task/ui/AbstractTask.tsx b/src/features/task/ui/AbstractTask.tsx deleted file mode 100644 index 9dcc573..0000000 --- a/src/features/task/ui/AbstractTask.tsx +++ /dev/null @@ -1,175 +0,0 @@ -/** @jsxImportSource @emotion/react */ -import { RoutineNote, NoteRepository, NoteService, Task as TaskEntity } from '@entities/note'; -import React, { useCallback, useMemo, useRef, useState } from "react" -import { useRoutineNote } from "@features/note"; -import _ from "lodash"; -import { Touchable } from '@shared/components/Touchable'; -import { dr } from '@shared/daily-routine-bem'; -import { Icon } from '@shared/components/Icon'; -import { TaskName } from './TaskName'; -import { Checkbox } from './Checkbox'; -import { css } from '@emotion/react'; -import { useTaskDnd } from '../hooks/use-task-dnd'; - - -// [[ Styles -const taskPressedAndDraggingStyle = css({ - position: "relative", - "&::after": { - content: "''", - position: "absolute", - top: "0", - left: "0", - width: "100%", - height: "100%", - borderRadius: "5px", - backgroundColor: "hsla(var(--color-accent-1-hsl), 0.5)", - transition: "background-color 0.5s", - } -}) - -const taskHeight = "2.5em"; -// ]] - - - -interface TaskProps { - className?: string; - task: T; - - /** - * 옵션 버튼을 클릭, 또는 모바일에서 long press시에 호출 - */ - onOptionMenu: (task: T) => void; - - /** - * 순서를 조정하고, 이를 현재 보고있는 note에 반영하는 로직은 내부적으로 담당한다. - * 그 외에 이를 기반으로 실제 routine들의 순서를 업데이트하는 등의 로직은 해당 콜백으로 실행해야한다. - */ - onTaskReorder?: (tasks: TaskEntity[]) => void; - - /** - * checked 상태는 내부적으로 변경해준다. - * 그 이후 처리가 필요한 경우 호출 - * @param task checked 상태가 변경된 task - */ - onTaskClick?: (task: T) => void; -} -export const AbstractTask = React.memo(({ className, task, onTaskReorder, onOptionMenu, onTaskClick }: TaskProps) => { - const taskRef = useRef(null); - const handleRef = useRef(null); - const [isPressed, setIsPressed] = useState(false); - const { setNote, note } = useRoutineNote(); - - const onTaskDrop = useCallback<(newNote: RoutineNote, droped: T) => void>((note, droped) => { - NoteRepository.update(note, false); - onTaskReorder?.(note.tasks); - }, [onTaskReorder]) - - const { isDragging } = useTaskDnd({ task, taskRef, handleRef, onTaskDrop }); - - - const onLongPressStart = useCallback((e: React.TouchEvent) => { - }, []) - - const onAfterLongPressDelay = useCallback(() => { - if(onOptionMenu){ - onOptionMenu(task); - } - }, [onOptionMenu, task]) - - const onLongPressEnd = useCallback((e: React.TouchEvent) => { - - }, []) - - // HACK: 빠르게 인접한 task를 클릭하면 클릭히 씹히거나 두번 클릭되는 문제가 있어서, 0.5초만 막아두면 적당하더라.. - const disableTouch = useRef(false); - const onClick = useCallback((e: React.TouchEvent | React.MouseEvent) => { - if(disableTouch.current) return; - disableTouch.current = true; - - // HACK: 이유는 모르겠지만 이거 안하면 모바일 환경에서 모달이 열리자마다 닫혀버림. - e.preventDefault(); - - const newNote = NoteService.checkTask(note, task, !task.checked); - NoteRepository - .saveOnUserConfirm(newNote) - .then(isUpdated => { - if(isUpdated) setNote(newNote); - }); - - if(onTaskClick) onTaskClick({ - ...task, - checked: !task.checked - }); - setTimeout(() => { - disableTouch.current = false; - }, 500); - }, [note, onTaskClick, setNote, task]) - - - const bem = useMemo(() => dr("task"), []); - return ( -
- - - - -
{ - e.preventDefault(); - e.stopPropagation(); - }} - > - -
-
- ) -}) \ No newline at end of file diff --git a/src/features/task/ui/TaskName.tsx b/src/features/task/ui/TaskName.tsx deleted file mode 100644 index b46fb7f..0000000 --- a/src/features/task/ui/TaskName.tsx +++ /dev/null @@ -1,41 +0,0 @@ -/** @jsxImportSource @emotion/react */ -import { DailyRoutineBEM } from "@shared/daily-routine-bem" - - - - - -interface TaskNameProps { - name: string; - bem: DailyRoutineBEM; -} -export const TaskName = ({ name, bem }: TaskNameProps) => { - return ( - - {name} - - ) -} \ No newline at end of file diff --git a/src/features/task/ui/TaskPreview.tsx b/src/features/task/ui/TaskPreview.tsx deleted file mode 100644 index 04c4ca6..0000000 --- a/src/features/task/ui/TaskPreview.tsx +++ /dev/null @@ -1,76 +0,0 @@ -/** @jsxImportSource @emotion/react */ -import { Task as TaskEntity } from '@entities/note'; -import { CSSProperties, useEffect, useMemo, useRef } from 'react'; -import { useDragLayer } from 'react-dnd'; -import { Icon } from '@shared/components/Icon'; -import { dr } from '@shared/daily-routine-bem'; -import { Checkbox } from './Checkbox'; -import { TaskName } from './TaskName'; -import { DragItem } from '../hooks/use-task-dnd'; -import { plugin } from '@shared/plugin-service-locator'; -import { useLeaf } from "@shared/view/use-leaf"; - - - -interface TaskPreviewProps { - item: DragItem; - style: React.CSSProperties; - backend: "touch" | "html5"; -} -export const TaskPreview = ({ item, style, backend }: TaskPreviewProps) => { - const previewContainerRef = useRef(null); - - const { currentOffset } = useDragLayer((monitor) => ({ - currentOffset: monitor.getSourceClientOffset(), - })); - - const leaf = useLeaf(); - - const previewStyle = useMemo(() => { - if (!currentOffset) return { display: 'none' }; - - const { x: x_viewport, y: y_viewport } = currentOffset; - // @ts-ignore - // const appEl = plugin().app.dom.appContainerEl; - const leafEl = leaf.view.containerEl; - const x_leaf = x_viewport - (window.innerWidth - leafEl.clientWidth); - const y_leaf = y_viewport - (window.innerHeight - leafEl.clientHeight); - - const taksWidth = item.width; - const getTransform = (backend: "html5" | "touch") => { - if(backend === "html5") { - return `translate(${x_leaf - (taksWidth - item.dragHandleWidth)}px, ${y_leaf}px)`; - } else { - return `translate(${x_leaf - (taksWidth - item.dragHandleWidth)}px, ${y_leaf}px)`; - } - } - const transform = getTransform(backend); - return { - ...style, - backgroundColor: "var(--background-primary)", - boxShadow: "0 0 0.5em 0.5em rgba(0, 0, 0, 0.2)", - transform, - WebkitTransform: transform, - width: taksWidth, - }; - }, [backend, currentOffset, item, leaf.view.containerEl, style]); - - useEffect(() => { - if(!previewContainerRef.current || !item.previewSource) return; - const container = previewContainerRef.current; - const preview = item.previewSource.cloneNode(true) as HTMLElement; - preview.removeClass("dr-task--dragging"); - container.appendChild(preview); - - return () => { - container.removeChild(preview); - } - }, [item.previewSource]) - - return ( -
- ) -} \ No newline at end of file diff --git a/src/features/todo/model/validate-todo.ts b/src/features/todo/model/validate-todo.ts index 8fc2cf0..0ad34b9 100644 --- a/src/features/todo/model/validate-todo.ts +++ b/src/features/todo/model/validate-todo.ts @@ -1,4 +1,4 @@ -import { RoutineNote } from "@entities/note"; +import { RoutineNoteDto } from "@entities/note"; export interface TodoValidation { @@ -15,7 +15,7 @@ export const VALID_TODO_VALIDATION: TodoValidation = { interface ValidateNameData { - note: RoutineNote; + note: RoutineNoteDto; originalName?: string; } const validateName = (name: string, { note, originalName }: ValidateNameData): TodoValidation => { @@ -27,7 +27,7 @@ const validateName = (name: string, { note, originalName }: ValidateNameData): T }; } - const sameNameTodo = note.tasks.filter((task) => task.name === name)[0] ?? null; + const sameNameTodo = note.root.filter((task) => task.name === name)[0] ?? null; if(sameNameTodo && sameNameTodo.name !== originalName) { return { isValid: false, diff --git a/src/pages/routine-note/RoutineNotePage.tsx b/src/pages/routine-note/RoutineNotePage.tsx index c4c4bb5..6ed1ca1 100644 --- a/src/pages/routine-note/RoutineNotePage.tsx +++ b/src/pages/routine-note/RoutineNotePage.tsx @@ -1,7 +1,7 @@ /** @jsxImportSource @emotion/react */ -import { NoteService, RoutineNote } from '@entities/note'; +import { RoutineNoteDto, RoutineTaskDto, TaskDto, TaskGroupDto, TodoTask, TodoTaskDto } from '@entities/note'; import { UseRoutineNoteProvider, resolveRoutineNote, useRoutineNote } from "@features/note"; -import { TaskDndContext } from '@features/task'; +import { TaskDndContext } from '@features/task-el'; import { MenuComponent } from "@shared/components/Menu"; import { dr } from "@shared/daily-routine-bem"; import { Day } from "@shared/period/day"; @@ -10,13 +10,17 @@ import { TodoTaskWidget, useAddTodoModal } from '@widgets/todo'; import { WeeksWidget } from "@widgets/weeks"; import { Menu } from "obsidian"; import { useCallback, useEffect, useMemo, useState } from "react"; +import { getCompletion } from './get-completion'; +import { BaseGroupHeadFeature } from '@features/task-el/ui/TaskGroupHeadFeature'; +import { TaskGroupWidget } from './TaskGroupWidget'; +import { renderTask } from './render-task-widget'; interface RoutineNoteProps { day: Day; } const RoutineNotePageContext = ({ day }: RoutineNoteProps) => { - const [ note, setNote ] = useState(null); + const [ note, setNote ] = useState(null); useEffect(() => { (async () => { @@ -40,7 +44,7 @@ const RoutineNotePageContext = ({ day }: RoutineNoteProps) => { const PageComponent = () => { const note = useRoutineNote(n=>n.note); - const percentage = useMemo(() => NoteService.getTaskCompletion(note).percentageRounded, [note]); + const percentage = useMemo(() => getCompletion(note).percentageRounded, [note]); const AddTodoModal = useAddTodoModal(); const StartRoutineModal = useStartRoutineModal(); @@ -108,15 +112,17 @@ const PageComponent = () => { overflowY: "auto", }} > - - {note.tasks.map(task => { - switch(task.type){ - case "routine": return - case "todo": return - default: task as never; - } - })} - + {note.root.map(el => { + if(el.elementType === "group"){ + return ( + ) + } else { + return renderTask(el as TaskDto, null); + } + })}
@@ -125,4 +131,6 @@ const PageComponent = () => { } + + export const RoutineNotePage = RoutineNotePageContext; \ No newline at end of file diff --git a/src/pages/routine-note/TaskGroupWidget.tsx b/src/pages/routine-note/TaskGroupWidget.tsx new file mode 100644 index 0000000..3d7be47 --- /dev/null +++ b/src/pages/routine-note/TaskGroupWidget.tsx @@ -0,0 +1,19 @@ +import { TaskGroupDto } from "@entities/note"; +import { BaseGroupHeadFeature } from "@features/task-el/ui/TaskGroupHeadFeature" +import { renderTask } from "./render-task-widget"; + + + +interface Props { + group: TaskGroupDto; +} +export const TaskGroupWidget = ({ + group +}: Props) => { + + return ( + + {group.tasks.map(task => renderTask(task, group))} + + ) +} \ No newline at end of file diff --git a/src/pages/routine-note/get-completion.ts b/src/pages/routine-note/get-completion.ts new file mode 100644 index 0000000..e219a41 --- /dev/null +++ b/src/pages/routine-note/get-completion.ts @@ -0,0 +1,8 @@ +import { NoteCompletion, RoutineNote, RoutineNoteDto } from "@entities/note"; + + + +export const getCompletion = (noteDto: RoutineNoteDto): NoteCompletion => { + const note = RoutineNote.fromJSON(noteDto); + return note.getCompletion(); +} \ No newline at end of file diff --git a/src/pages/routine-note/render-task-widget.tsx b/src/pages/routine-note/render-task-widget.tsx new file mode 100644 index 0000000..2aed36e --- /dev/null +++ b/src/pages/routine-note/render-task-widget.tsx @@ -0,0 +1,11 @@ +import { RoutineTaskDto, TaskDto, TaskGroupDto, TodoTaskDto } from "@entities/note"; +import { RoutineTaskWidget } from '@widgets/routine'; +import { TodoTaskWidget } from '@widgets/todo'; + +export const renderTask = (task: TaskDto, parent: TaskGroupDto | null) => { + switch(task.taskType) { + case "todo": return ; + case "routine": return ; + default: return null; + } +} \ No newline at end of file diff --git a/src/shared/JsonConvertible.ts b/src/shared/JsonConvertible.ts new file mode 100644 index 0000000..0d1a7fb --- /dev/null +++ b/src/shared/JsonConvertible.ts @@ -0,0 +1,6 @@ + + + +export interface JsonConvertible { + toJSON(): T; +} \ No newline at end of file diff --git a/src/shared/components/Touchable.tsx b/src/shared/components/Touchable.tsx index e3fb0f3..2dd04d2 100644 --- a/src/shared/components/Touchable.tsx +++ b/src/shared/components/Touchable.tsx @@ -22,7 +22,7 @@ interface TouchableProps { longPressDelay?: number; // pressed 상태가 변경될 때 호출 - onChangePressedState?: (isPressed: boolean) => void; + onPressChange?: (isPressed: boolean) => void; // click인지, long press인지 판단하기 위한 지연시간 longPressStartDelay?: number; @@ -55,7 +55,7 @@ export const Touchable = memo(forwardRef((props: ); const longPressStartDebounce = useRef void>>( _.debounce((e: React.TouchEvent) => { - props.onChangePressedState?.(true); + props.onPressChange?.(true); props.onLongPressStart?.(e); }, startDelay) ); @@ -70,7 +70,7 @@ export const Touchable = memo(forwardRef((props: isTouchRef.current = false; longPressDebounce.current.cancel(); longPressStartDebounce.current.cancel(); - props.onChangePressedState?.(false); + props.onPressChange?.(false); }, [props]); const onClick = useCallback((e: React.MouseEvent) => { diff --git a/src/shared/file/errors.ts b/src/shared/file/errors.ts deleted file mode 100644 index 0bd6b69..0000000 --- a/src/shared/file/errors.ts +++ /dev/null @@ -1,7 +0,0 @@ - - -export class FileNotFoundError extends Error { - constructor(path: string){ - super(`File not found: ${path}`); - } -} diff --git a/src/shared/file/file-accessor.ts b/src/shared/file/file-accessor.ts index 33a0d9a..aeb76a3 100644 --- a/src/shared/file/file-accessor.ts +++ b/src/shared/file/file-accessor.ts @@ -1,21 +1,19 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { TFile, TFolder } from "obsidian"; import { plugin } from "@shared/plugin-service-locator"; -import { FileNotFoundError } from "./errors"; +import { TFile, TFolder } from "obsidian"; export interface FileAccessor { /** * 경로로부터 vault의 파일을 읽어온다. - * @throws FileNotFoundError 만약 경로에 존재하는 파일이 없거나 folder인 경우 에러 */ - getFile: (path: string) => TFile; + loadFile: (path: string) => TFile | null; /** * 경로로부터 vault의 폴더를 읽어온다. */ - getFolder: (path: string) => TFolder | null; + loadFolder: (path: string) => TFolder | null; /** * 폴더를 생성한다. @@ -63,16 +61,16 @@ export interface FileAccessor { export const fileAccessor: FileAccessor = { - getFile: (path: string): TFile => { + loadFile: (path) => { const file = plugin().app.vault.getAbstractFileByPath(path); if(file && file instanceof TFile) { return file; } else { - throw new FileNotFoundError(path); + return null; } }, - getFolder: (path: string) => { + loadFolder: (path: string) => { const file = plugin().app.vault.getAbstractFileByPath(path); if(file && file instanceof TFolder) { return file; diff --git a/src/shared/file/parse-frontmatter.ts b/src/shared/file/parse-frontmatter.ts new file mode 100644 index 0000000..3a9e79c --- /dev/null +++ b/src/shared/file/parse-frontmatter.ts @@ -0,0 +1,11 @@ +import { getFrontMatterInfo, parseYaml } from "obsidian"; + + +export const parseFrontmatter = (fileContent: string) => { + const fmInfo = getFrontMatterInfo(fileContent); + if(!fmInfo.exists){ + throw new Error('File frontmatter is not found.'); + } + const yaml = fmInfo.frontmatter.replace('---', '').trim() + return parseYaml(yaml); +} \ No newline at end of file diff --git a/src/shared/period/day.ts b/src/shared/period/day.ts index 213d776..24fbae1 100644 --- a/src/shared/period/day.ts +++ b/src/shared/period/day.ts @@ -1,5 +1,4 @@ import { moment } from "obsidian"; -import { plugin } from "../plugin-service-locator"; import _ from "lodash"; import { DR_SETTING } from "@app/settings/setting-provider"; diff --git a/src/shared/validation/validate-obsidian-file-title.ts b/src/shared/validation/validate-obsidian-file-title.ts new file mode 100644 index 0000000..efe616d --- /dev/null +++ b/src/shared/validation/validate-obsidian-file-title.ts @@ -0,0 +1,14 @@ +import { err, ok, Result } from "neverthrow"; + + + +export type ObsidianFileTitleValidation = 'contains-invalid-characters'; + +export const validateObsidianFileTitle = (title: string): Result => { + const invalidCharsRegex = /[:/\\#[\]|^]/; + if(invalidCharsRegex.test(title)){ + return err('contains-invalid-characters'); + } + return ok(title); + +} \ No newline at end of file diff --git a/src/widgets/calendar/CalendarNavigation.tsx b/src/widgets/calendar/CalendarNavigation.tsx index 9eaab27..5e93749 100644 --- a/src/widgets/calendar/CalendarNavigation.tsx +++ b/src/widgets/calendar/CalendarNavigation.tsx @@ -1,11 +1,10 @@ /** @jsxImportSource @emotion/react */ import { css } from '@emotion/react'; -import { Day } from "@shared/period/day"; import { Month } from "@shared/period/month"; import { useLeaf } from "@shared/view/use-leaf"; -import { Dispatch, SetStateAction, useCallback, useEffect, useMemo, useRef } from "react"; +import { useCallback, useEffect, useMemo, useRef } from "react"; import Calendar from "react-calendar"; -import { OnArgs, TileArgs } from "react-calendar/dist/cjs/shared/types"; +import { OnArgs } from "react-calendar/dist/cjs/shared/types"; diff --git a/src/widgets/calendar/CalendarSlider.tsx b/src/widgets/calendar/CalendarSlider.tsx index 87906fd..bf2307c 100644 --- a/src/widgets/calendar/CalendarSlider.tsx +++ b/src/widgets/calendar/CalendarSlider.tsx @@ -1,5 +1,5 @@ /** @jsxImportSource @emotion/react */ -import { Task } from "@entities/note"; +import { TaskDto } from "@entities/note"; import { BaseCalendar } from "@shared/components/BaseCalendar"; import { Day } from "@shared/period/day"; import { Month } from "@shared/period/month"; @@ -30,7 +30,7 @@ export const CalendarSlide = ({ month }: CalendarSlideProps) => { const tile = useCallback((day: Day) => { let tile = { day, - tasks: [] as Task[] + tasks: [] as TaskDto[] }; tile = tiles.get(day.format()) || tile; return ; diff --git a/src/widgets/calendar/CalendarTile.tsx b/src/widgets/calendar/CalendarTile.tsx index ab4b16d..fbe5f2f 100644 --- a/src/widgets/calendar/CalendarTile.tsx +++ b/src/widgets/calendar/CalendarTile.tsx @@ -2,7 +2,7 @@ import { Day } from "@shared/period/day"; import { Tile } from "./model/types" import { TEXT_CSS } from "@shared/constants/text-style"; -import { Task } from "@entities/note"; +import { TaskDto } from "@entities/note"; import { css } from "@emotion/react"; @@ -35,7 +35,7 @@ const DateBadge = ({ day }: { day: Day }) => { } -const TaskLineContainer = ({ tasks }: { tasks: Task[] }) => { +const TaskLineContainer = ({ tasks }: { tasks: TaskDto[] }) => { if(tasks.length <= 4){ return (
@@ -70,7 +70,7 @@ const TaskLineContainer = ({ tasks }: { tasks: Task[] }) => { ) } -const TaskLine = ({ task }: { task: Task }) => { +const TaskLine = ({ task }: { task: TaskDto }) => { return (
=> { const routines = await RoutineRepository.loadAll(); - const showOnCalendarRoutines = routines.filter((r) => r.properties.showOnCalendar); - const createTile = (day: Day) => { - const noteCreator = NoteService.setLoaderForCreate(() => showOnCalendarRoutines); - const note = noteCreator(day); + const showOnCalendarRoutines = routines.filter((r) => r.getProperties().isShowOnCalendar()); + const createTile = (day: Day): Tile => { + const tileTasks = showOnCalendarRoutines + .filter(r => r.isDueTo(day)) + .map(r => RoutineTask.fromRoutine(r)) + .map(t => t.toJSON()); return { - day: note.day, - tasks: note.tasks, + day, + tasks: tileTasks, } as Tile; } @@ -30,11 +32,14 @@ export const loadCalendar = async (month: Month): Promise => { const tiles: Map = new Map(); let d = startDay; while(d.isSameOrBefore(endDay)){ - const loaded = loadedNotes.find(n => n.day.isSameDay(d)); + const loaded = loadedNotes.find(n => n.getDay().isSameDay(d)); if(loaded){ - tiles.set(loaded.day.format(), { - day: loaded.day, - tasks: loaded.tasks.filter(t => t.showOnCalendar) + const loadedDay = loaded.getDay(); + tiles.set(loadedDay.format(), { + day: loadedDay, + tasks: loaded.createTaskArray() + .filter(t => t.isShowOnCalendar()) + .map(t => t.toJSON()) }) } else { tiles.set(d.format(), createTile(d)); diff --git a/src/widgets/calendar/model/types.ts b/src/widgets/calendar/model/types.ts index ef9b9eb..e9d5e45 100644 --- a/src/widgets/calendar/model/types.ts +++ b/src/widgets/calendar/model/types.ts @@ -1,4 +1,4 @@ -import { Task } from "@entities/note"; +import { TaskDto } from "@entities/note"; import { Day } from "@shared/period/day"; import { Month } from "@shared/period/month"; @@ -11,5 +11,5 @@ export interface Calendar { export interface Tile { day: Day; - tasks: Task[] // 0 ~ n개 + tasks: TaskDto[] // 0 ~ n개 } \ No newline at end of file diff --git a/src/widgets/note-achivement/NoteAchivementCalendar.tsx b/src/widgets/note-achivement/NoteAchivementCalendar.tsx index 79344d3..47b954f 100644 --- a/src/widgets/note-achivement/NoteAchivementCalendar.tsx +++ b/src/widgets/note-achivement/NoteAchivementCalendar.tsx @@ -1,5 +1,5 @@ /** @jsxImportSource @emotion/react */ -import { NoteRepository, RoutineNote, NoteService } from '@entities/note'; +import { NoteRepository, RoutineNote } from '@entities/note'; import { Day } from "@shared/period/day"; import { PercentageCircle } from "@shared/components/PercentageCircle"; import { BaseCalendar } from '@shared/components/BaseCalendar'; @@ -15,7 +15,7 @@ export interface NoteAchivementCalendarProps { export const NoteAchivementCalendar = ({ month: propsMonth }: NoteAchivementCalendarProps) => { const [month, setMonth] = useState(propsMonth); const { route } = useTabRoute(); - const [routineNotes, setRoutineNotes] = useState(null); + const [notes, setNotes] = useState(null); // 해당 월의 루틴 노트들을 가져온다. @@ -23,24 +23,24 @@ export const NoteAchivementCalendar = ({ month: propsMonth }: NoteAchivementCale NoteRepository .loadBetween(month.startDay, month.endDay) .then(notes => { - setRoutineNotes(notes) + setNotes(notes) }); }, [month]); const tile = useCallback((tileDay: Day) => { let percent = 0; - if(routineNotes){ - const routineNote = routineNotes.find(note => note.day.isSameDay(tileDay)); - if(routineNote){ - const completion = NoteService.getTaskCompletion(routineNote); + if(notes){ + const note = notes.find(note => note.getDay().isSameDay(tileDay)); + if(note){ + const completion = note.getCompletion(); percent = completion.percentageRounded; } } return ( ) - }, [routineNotes]); + }, [notes]); return ( diff --git a/src/widgets/routine/RoutineTaskWidget.tsx b/src/widgets/routine/RoutineTaskWidget.tsx index 46186b2..bbb9040 100644 --- a/src/widgets/routine/RoutineTaskWidget.tsx +++ b/src/widgets/routine/RoutineTaskWidget.tsx @@ -1,52 +1,38 @@ -import { RoutineTask, Task } from "@entities/note"; -import { Routine, RoutineRepository } from "@entities/routine"; +import { RoutineTaskDto, TaskDto, TaskGroupDto } from "@entities/note"; +import { RoutineDto, RoutineRepository } from "@entities/routine"; import { useRoutineOptionModal } from "./routine-option"; -import { AbstractTask } from "@features/task"; +import { AbstractTaskWidget } from "@features/task-el"; import React, { useCallback } from "react"; import { executeRoutineNotesSynchronize } from "@entities/note-synchronize"; -import { reorderRoutines } from "./model/reorder-routines"; - - -// tasks에서 routineTask들을 추출하여, 이들 사이의 순서가 반영된 Routine[]을 반환한다. -const resolveRoutinesFromTasks = async (tasks: Task[]): Promise => { - const routineTasks: RoutineTask[] = tasks.filter(t => t.type === "routine") as RoutineTask[]; - const routines = await RoutineRepository.loadAll(); - const routineMap = new Map(routines.map(r => [r.name, r])); - - return routineTasks.map(rt => { - const routine = routineMap.get(rt.name); - if(!routine) throw new Error(`Routine '${rt.name}' not found. but it is in the Task[] as a RoutineTask.`); - return routine; - }); -} - interface RoutineTaskProps { - task: RoutineTask; + task: RoutineTaskDto; + parent: TaskGroupDto | null; } -export const RoutineTaskWidget = React.memo(({ task }: RoutineTaskProps) => { +export const RoutineTaskWidget = React.memo(({ task, parent }: RoutineTaskProps) => { const RoutineOptionModal = useRoutineOptionModal(); const onOptionClick = useCallback(async () => { const routine = await RoutineRepository.load(task.name); - RoutineOptionModal.open({ routine }); + RoutineOptionModal.open({ routine: routine.toJSON() }); }, [RoutineOptionModal, task.name]) - const onTaskReorder = useCallback(async (tasks: Task[]) => { - const { routines, requireUpdateMap } = await reorderRoutines(await resolveRoutinesFromTasks(tasks)); - for(const routine of routines) { - if(!requireUpdateMap.get(routine.name)) continue; - await RoutineRepository.update(routine.name, routine); - } - executeRoutineNotesSynchronize(); + const onTaskReorder = useCallback(async (tasks: TaskDto[]) => { + // const { routines, requireUpdateMap } = await reorderRoutines(await resolveRoutinesFromTasks(tasks)); + // for(const routine of routines) { + // if(!requireUpdateMap.get(routine.name)) continue; + // await RoutineRepository.updateRoutine(routine.name, routine); + // } + // executeRoutineNotesSynchronize(); }, []) return ( <> - ; -} +// interface ReorderRoutinesResult { +// routines: Routine[]; +// requireUpdateMap: Map; +// } -/** - * @param task routine 순서 조정의 기반이되는 Task 배열. - * 해당 배열에서 routineTask들을 추출하고 이를 기반으로 실제 routine의 순서를 조정한다. - */ -export const reorderRoutines = async (routines: Routine[]): Promise => { - type Acc = { - prevOrder: number; - arr: Routine[]; - requireUpdateMap: Map; - } +// /** +// * @param task routine 순서 조정의 기반이되는 Task 배열. +// * 해당 배열에서 routineTask들을 추출하고 이를 기반으로 실제 routine의 순서를 조정한다. +// */ +// export const reorderRoutines = async (routines: Routine[]): Promise => { +// type Acc = { +// prevOrder: number; +// arr: Routine[]; +// requireUpdateMap: Map; +// } - const reduce = (acc: Acc, routine: Routine): Acc => { - const isAscending = acc.prevOrder < routine.properties.order; +// const reduce = (acc: Acc, routine: Routine): Acc => { +// const isAscending = acc.prevOrder < routine.properties.order; - const newRoutine = { ...routine } - if(isAscending){ - acc.requireUpdateMap.set(routine.name, false); - } - else { - newRoutine.properties.order = acc.prevOrder + 100; - acc.requireUpdateMap.set(routine.name, true); - } +// const newRoutine = { ...routine } +// if(isAscending){ +// acc.requireUpdateMap.set(routine.name, false); +// } +// else { +// newRoutine.properties.order = acc.prevOrder + 100; +// acc.requireUpdateMap.set(routine.name, true); +// } - acc.prevOrder = newRoutine.properties.order; - acc.arr.push(newRoutine); - return acc; - } +// acc.prevOrder = newRoutine.properties.order; +// acc.arr.push(newRoutine); +// return acc; +// } - const result = routines.reduce(reduce, { - prevOrder: -1, - arr: [] as Routine[], - requireUpdateMap: new Map() - }); +// const result = routines.reduce(reduce, { +// prevOrder: -1, +// arr: [] as Routine[], +// requireUpdateMap: new Map() +// }); - return { - routines: result.arr, - requireUpdateMap: result.requireUpdateMap - } -} \ No newline at end of file +// return { +// routines: result.arr, +// requireUpdateMap: result.requireUpdateMap +// } +// } \ No newline at end of file diff --git a/src/widgets/routine/model/save-routine.ts b/src/widgets/routine/model/save-routine.ts new file mode 100644 index 0000000..67dce59 --- /dev/null +++ b/src/widgets/routine/model/save-routine.ts @@ -0,0 +1,11 @@ +import { RoutineNoteDto } from "@entities/note"; +import { executeRoutineNotesSynchronize } from "@entities/note-synchronize"; +import { Routine, RoutineDto } from "@entities/routine"; + + + +export const saveRoutine = async (noteDto: RoutineNoteDto, dto: RoutineDto): Promise => { + const routine = Routine.fromJSON(dto); + executeRoutineNotesSynchronize(); + return noteDto; +} \ No newline at end of file diff --git a/src/widgets/routine/routine-option.tsx b/src/widgets/routine/routine-option.tsx index d0948b6..438a5a2 100644 --- a/src/widgets/routine/routine-option.tsx +++ b/src/widgets/routine/routine-option.tsx @@ -1,9 +1,9 @@ /** @jsxImportSource @emotion/react */ import { executeRoutineNotesSynchronize } from '@entities/note-synchronize'; -import { Routine, RoutineRepository, RoutineService } from '@entities/routine'; +import { RoutineDto, RoutineRepository } from '@entities/routine'; import { useRoutineNote } from "@features/note"; import { RoutineOption, routineReducer, RoutineReducer } from "@features/routine"; -import { TaskOption } from '@features/task'; +import { TaskOption } from '@features/task-el'; import { Button } from '@shared/components/Button'; import { doConfirm } from '@shared/components/modal/confirm-modal'; import { createModal, ModalApi } from '@shared/components/modal/create-modal'; @@ -11,38 +11,24 @@ import { Modal } from '@shared/components/modal/styled'; import { dr } from '@shared/daily-routine-bem'; import { Notice } from "obsidian"; import React, { useCallback, useEffect, useMemo, useReducer } from "react"; +import { saveRoutine } from './model/save-routine'; -interface RoutineOptionModalProps { - routine: Routine; +interface Props { + routine: RoutineDto; modal: ModalApi; } -export const useRoutineOptionModal = createModal(({ modal, routine: originalRoutine}: RoutineOptionModalProps) => { +export const useRoutineOptionModal = createModal(({ modal, routine: originalRoutine}: Props) => { const bem = useMemo(() => dr("routine-option"), []); const { note, setNote } = useRoutineNote(); const [routine, dispatch] = useReducer(routineReducer, originalRoutine); const id = useMemo(() => originalRoutine.name, [originalRoutine.name]); - // modal.onClose를 정의 + // modal.onClose useEffect(() => { modal.onClose(async () => { - const newNote = { ...note }; - newNote.tasks.flatMap(task => { - if(task.name !== id) return task; - if(RoutineService.isRoutineDueTo(routine, note.day)){ - return { - ...task, - name: routine.name, - } - } - else { - return []; - } - }); + const newNote = await saveRoutine(note, routine); setNote(newNote); - - await RoutineRepository.update(id, routine); - executeRoutineNotesSynchronize(); })}, [id, modal, note, routine, setNote]); @@ -59,7 +45,7 @@ export const useRoutineOptionModal = createModal(({ modal, routine: originalRout new Notice(`Routine ${routine.name} deleted.`); setNote({ ...note, - tasks: note.tasks.filter(task => task.name !== routine.name) + root: note.root.filter(task => task.name !== routine.name) }); executeRoutineNotesSynchronize(); modal.closeWithoutOnClose(); diff --git a/src/widgets/routine/start-routine.tsx b/src/widgets/routine/start-routine.tsx index cbc3bf1..66459fd 100644 --- a/src/widgets/routine/start-routine.tsx +++ b/src/widgets/routine/start-routine.tsx @@ -1,9 +1,9 @@ /** @jsxImportSource @emotion/react */ import { executeRoutineNotesSynchronize } from "@entities/note-synchronize"; -import { DEFAULT_ROUTINE, RoutineRepository, RoutineService } from "@entities/routine"; +import { RoutineRepository } from "@entities/routine"; import { useRoutineNote } from "@features/note"; import { RoutineOption, routineReducer, RoutineReducer } from "@features/routine"; -import { TaskOption } from '@features/task'; +import { TaskOption } from '@features/task-el'; import { Button } from "@shared/components/Button"; import { createModal, ModalApi } from "@shared/components/modal/create-modal"; import { Modal } from "@shared/components/modal/styled"; diff --git a/src/widgets/todo/model/reschedule-todo.tsx b/src/widgets/todo/model/reschedule-todo.tsx index 1b4d117..36bfbbb 100644 --- a/src/widgets/todo/model/reschedule-todo.tsx +++ b/src/widgets/todo/model/reschedule-todo.tsx @@ -1,4 +1,4 @@ -import { RoutineNote, NoteRepository, NoteService, Task, TodoTask } from "@entities/note"; +import { RoutineNote, NoteRepository, TodoTask } from "@entities/note"; import { RoutineRepository } from "@entities/routine"; import { Day } from "@shared/period/day"; @@ -10,21 +10,22 @@ import { Day } from "@shared/period/day"; * @returns 재조정된 루틴 노트 */ export const rescheduleTodo = async (routineNote: RoutineNote, taskName: string, newDay: Day): Promise => { - const _t = routineNote.tasks.find(task => task.name === taskName); - if(!_t || _t.type !== "todo") return routineNote; - const todoTask = _t as TodoTask; + // const _t = routineNote.tasks.find(task => task.name === taskName); + // if(!_t || _t.type !== "todo") return routineNote; + // const todoTask = _t as TodoTask; - const todoDeletedNote = NoteService.deleteTodoTask(routineNote, todoTask.name); - await NoteRepository.forceSave(todoDeletedNote); + // const todoDeletedNote = NoteService.deleteTodoTask(routineNote, todoTask.name); + // await NoteRepository.forceSave(todoDeletedNote); - const destinationNote = ( - await NoteRepository.load(newDay) - ?? - await NoteService.setLoaderForCreateAsync(RoutineRepository.loadAll)(newDay) - ) + // const destinationNote = ( + // await NoteRepository.load(newDay) + // ?? + // await NoteService.setLoaderForCreateAsync(RoutineRepository.loadAll)(newDay) + // ) - const todoAddedNote = NoteService.addTodoTask(destinationNote, todoTask); - await NoteRepository.forceSave(todoAddedNote); + // const todoAddedNote = NoteService.addTodoTask(destinationNote, todoTask); + // await NoteRepository.forceSave(todoAddedNote); - return todoDeletedNote; + // return todoDeletedNote; + return routineNote; } \ No newline at end of file diff --git a/src/widgets/todo/ui/TodoTaskWidget.tsx b/src/widgets/todo/ui/TodoTaskWidget.tsx index 845df87..e837216 100644 --- a/src/widgets/todo/ui/TodoTaskWidget.tsx +++ b/src/widgets/todo/ui/TodoTaskWidget.tsx @@ -1,13 +1,14 @@ -import { TodoTask as TodoTaskEntity } from "@entities/note"; -import { AbstractTask } from "@features/task"; +import { TaskGroupDto, TodoTaskDto } from "@entities/note"; +import { AbstractTaskWidget } from "@features/task-el"; import React, { useCallback } from "react"; import { useTodoOptionModal } from './todo-option'; interface TodoTaskProps { - task: TodoTaskEntity; + task: TodoTaskDto; + parent: TaskGroupDto | null; } -export const TodoTaskWidget = React.memo(({ task }: TodoTaskProps) => { +export const TodoTaskWidget = React.memo(({ task, parent }: TodoTaskProps) => { const TodoOptionModal = useTodoOptionModal(); const onOptionMenu = useCallback(async () => { @@ -16,9 +17,10 @@ export const TodoTaskWidget = React.memo(({ task }: TodoTaskProps) => { return ( <> - diff --git a/src/widgets/todo/ui/add-todo.tsx b/src/widgets/todo/ui/add-todo.tsx index 194a827..6df1cc4 100644 --- a/src/widgets/todo/ui/add-todo.tsx +++ b/src/widgets/todo/ui/add-todo.tsx @@ -1,7 +1,7 @@ /** @jsxImportSource @emotion/react */ -import { NoteRepository, NoteService, TodoTask } from "@entities/note"; +import { NoteRepository, TodoTaskDto } from "@entities/note"; import { useRoutineNote } from "@features/note"; -import { TaskOption } from "@features/task"; +import { TaskOption } from "@features/task-el"; import { Button } from "@shared/components/Button"; import { createModal, ModalApi } from "@shared/components/modal/create-modal"; import { Modal } from "@shared/components/modal/styled"; @@ -9,26 +9,27 @@ import { dr } from "@shared/daily-routine-bem"; import { useCallback, useMemo, useState } from "react"; -const DEFAULT_TASK: TodoTask = { +const DEFAULT_TASK: TodoTaskDto = { checked: false, name: "", - type: "todo", + elementType: "task", + taskType: "todo", showOnCalendar: true, } export const useAddTodoModal = createModal(({ modal }: { modal: ModalApi}) => { const { note, setNote } = useRoutineNote(); - const [ todo, setTodo ] = useState(DEFAULT_TASK); + const [ todo, setTodo ] = useState(DEFAULT_TASK); const onSave = useCallback(() => { - if(todo.name.trim() === "") return; - const newNote = NoteService.addTodoTask(note, todo); - setNote(newNote); + // if(todo.name.trim() === "") return; + // const newNote = NoteService.addTodoTask(note, todo); + // setNote(newNote); - // NOTE: Todo 만들기는 거의 사용자가 원하는 동작임이 분명함으로, 노트가 없다면 강제로 생성해서 저장함. (confirm 띄우지 않음) - NoteRepository.forceSave(newNote); - modal.close(); - }, [todo, note, setNote, modal]); + // // NOTE: Todo 만들기는 거의 사용자가 원하는 동작임이 분명함으로, 노트가 없다면 강제로 생성해서 저장함. (confirm 띄우지 않음) + // NoteRepository.forceSave(newNote); + // modal.close(); + }, []); const bem = useMemo(() => dr("add-todo-modal"), []); diff --git a/src/widgets/todo/ui/todo-option.tsx b/src/widgets/todo/ui/todo-option.tsx index 56fac45..bd64121 100644 --- a/src/widgets/todo/ui/todo-option.tsx +++ b/src/widgets/todo/ui/todo-option.tsx @@ -1,7 +1,7 @@ /** @jsxImportSource @emotion/react */ -import { NoteRepository, NoteService, TodoTask } from '@entities/note'; +import { NoteRepository, TodoTaskDto } from '@entities/note'; import { useRoutineNote } from '@features/note'; -import { TaskOption } from "@features/task"; +import { TaskOption } from "@features/task-el"; import { TodoValidation, todoValidator, VALID_TODO_VALIDATION } from "@features/todo"; import { Button } from '@shared/components/Button'; import { doConfirm } from '@shared/components/modal/confirm-modal'; @@ -15,13 +15,13 @@ import { rescheduleTodo } from "../model/reschedule-todo"; interface TodoOptionModalProps { - todo: TodoTask; + todo: TodoTaskDto; modal: ModalApi; } export const useTodoOptionModal = createModal(memo(({ todo: propsTodo, modal }: TodoOptionModalProps) => { const bem = useMemo(() => dr("todo-option"), []); const { note, setNote } = useRoutineNote(); - const [ todo, setTodo ] = useState(propsTodo); + const [ todo, setTodo ] = useState(propsTodo); const originalName = useMemo(() => propsTodo.name, [propsTodo]); const validation = useMemo(() => { diff --git a/src/widgets/weeks/load-weeks.tsx b/src/widgets/weeks/load-weeks.tsx index d28bb5e..6a1b668 100644 --- a/src/widgets/weeks/load-weeks.tsx +++ b/src/widgets/weeks/load-weeks.tsx @@ -1,4 +1,4 @@ -import { NoteRepository, NoteService } from "@entities/note"; +import { NoteRepository } from "@entities/note"; import { Day } from "@shared/period/day"; import { DayNode, WeekNode } from "./types"; import { Week } from "@shared/period/week"; @@ -15,8 +15,8 @@ export const loadWeekNodes: LoadWeekNodes = async (week, { prev, next } = { prev const realNotes = await NoteRepository.loadBetween(startDay, endDay); // 일단 실제로 가져온 노트들을 기반으로 dayNodes의 기본 틀을 만든다. const dayNodes: DayNode[] = realNotes.map(note => ({ - day: note.day, - percentage: NoteService.getTaskCompletion(note).percentageRounded + day: note.getDay(), + percentage: note.getCompletion().percentageRounded })) dayNodes.sort((a, b) => a.day.isBefore(b.day) ? -1 : 1);