BREAKING_CHANGE: 전체 구조를 도메인 모델 기반으로 변경하고 Group 기능을 추가

This commit is contained in:
sechan100 2024-12-13 20:26:32 +09:00
parent 7012b76ffa
commit 9aa4fe783d
96 changed files with 3276 additions and 1600 deletions

View file

@ -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을 파일 경로로 변환

422
package-lock.json generated
View file

@ -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",

View file

@ -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",

View file

@ -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 {

View file

@ -7,5 +7,5 @@ export abstract class NoteDependent {
/**
* , .
*/
abstract restoreData(newNote: RoutineNote): RoutineNote;
abstract restoreData(newNote: RoutineNote): void;
}

View file

@ -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,
}
}
}

View file

@ -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 };
}
}
}

View file

@ -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<RoutineNote> => {
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;
}
// 모든 등록된 비동기 로직 이후에 실행을 예약

View file

@ -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<T extends TaskDto = RoutineTaskDto | TodoTaskDto> implements TaskElement<T> {
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<string, TaskNameValidation> {
return AbstractTask
.validateName(newName, taskNames)
.andThen(validated => {
this.name = validated;
return ok(validated);
});
}
static validateName(name: string, taskNames: string[]): Result<string, TaskNameValidation> {
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;
}
}
}

View file

@ -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<any>;
export class RoutineNote implements JsonConvertible<RoutineNoteDto>, 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<RoutineNote, string> {
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<string, AbstractTask> {
const map = new Map<string, AbstractTask>();
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<any>[] {
return [...this.root];
}
addTask(
task: AbstractTask,
idx: (arr: TaskElement<any>[]) => number = () => 0
): void {
this.addEl(task, idx);
}
/**
*
* @param el TaskElement. el이 addEl에 .
* @param idx
* @returns
*/
addEl(
el: TaskEl,
idx: (arr: TaskElement<any>[]) => number = () => 0
): Result<TaskEl, string> {
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;
}
}

View file

@ -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<RoutineTaskDto> {
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<RoutineTask, string> {
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(),
}
}
}

View file

@ -0,0 +1,10 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { JsonConvertible } from "@shared/JsonConvertible";
import { TaskElementDto } from "../types/task";
export interface TaskElement<T extends TaskElementDto> extends JsonConvertible<T> {
getName(): string;
serialize(): string;
}

View file

@ -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<TaskGroupDto>, 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<any>[] {
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<any>[]) => number = () => 0
): Result<AbstractTask, string> {
task.makeOrphan();
task.setParent(this);
this.tasks.splice(idx(this.tasks), 0, task);
return ok(task);
}
changeName(newName: string, taskNames: string[]): Result<string, string> {
return AbstractTask
.validateName(newName, taskNames)
.andThen(validated => {
this.name = validated;
return ok(validated);
});
}
/**
*
* @param groupBlock:
* `
* ## {name}
* ...tasks
* ` 형식의 마크다운 블록
*/
static deserialize(groupBlock: string): Result<TaskGroup, string> {
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<string, string> {
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);
}
}

View file

@ -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<any>[]) => number): void;
getChildren(): TaskElement<any>[];
}

View file

@ -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<TodoTaskDto> {
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<TodoTask, string> {
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(),
}
}
}

View file

@ -1,3 +1,13 @@
export { NoteService } from "./routine-note-service";
export type { RoutineNote, RoutineTask, Task, TaskType, TodoTask } from "./types";
export { NoteRepository } from "./note-repository";
// 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";

View file

@ -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<RoutineNote | null>;
// start와 end를 포함한 사이의 모든 루틴 노트를 가져온다.
loadBetween(start: Day, end: Day): Promise<RoutineNote[]>;
/**
* routine note가 .
* @param routineNote
* @param strict true, false로 .
*/
persist(routineNote: RoutineNote, strict?: boolean): Promise<void>;
/**
* routine note가 .
* @param routineNote
* @param strict true, false로 .
*/
update(routineNote: RoutineNote, strict?: boolean): Promise<void>;
/**
* routine note가 update, persist한다.
*/
forceSave(routineNote: RoutineNote): Promise<void>;
/**
* update, persist를 ,
* routine note가 persist하고 update해야하는 .
* @param routineNote
* @returns true, false를 .
*/
saveOnUserConfirm(routineNote: RoutineNote): Promise<boolean>;
// delete
delete(day: Day): Promise<void>;
}
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<RoutineNote[]> {
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<RoutineNote> => {
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`;
}

View file

@ -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<RoutineNote | null>;
// start와 end를 '포함한' 사이의 모든 루틴 노트를 가져온다.
loadBetween(start: Day, end: Day): Promise<RoutineNote[]>;
isExist(day: Day): Promise<boolean>;
/**
* routine note가 .
* @return false를 .
*/
persist(routineNote: RoutineNote): Promise<boolean>;
/**
* . .
*/
saveOnUserConfirm(routineNote: RoutineNote): Promise<boolean>;
update(routineNote: RoutineNote): Promise<void>;
delete(day: Day): Promise<void>;
}
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<RoutineNote[]> {
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<RoutineNote> => {
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`;
};

View file

@ -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<Routine[]>): (day: Day) => Promise<RoutineNote>;
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
}
}
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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
}

View file

@ -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<RoutineNoteCreator> {
return new RoutineNoteCreator(
await RoutineRepository.loadAll(),
await GroupRepository.loadAll()
);
}
create(day: Day): RoutineNote {
const r_root: (Routine | RoutineGroup)[] = [...this.groups];
const routineMap = new Map<string, Routine[]>(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<any>[] = 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);
}
}

View file

@ -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<Properties> {
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;
}
}

View file

@ -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<RoutineDto> {
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<string, RoutineNameValidation> {
return Routine
.validateName(newName, routineNames)
.andThen(validated => {
this.name = validated;
return ok(validated);
});
}
static validateName(name: string, routineNames: string[]): Result<string, RoutineNameValidation> {
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;
}
}

View file

@ -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<RoutineGroupDto> {
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<string, GroupNameValidation> {
return RoutineGroup
.validateName(newName, groupNames)
.andThen(validated => {
this.name = validated;
return ok(validated);
});
}
static validateName(name: string, groupNames: string[]): Result<string, GroupNameValidation> {
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;
}
}

View file

@ -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<RoutinePropertiesDto> {
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<number[], string> {
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<number, string> {
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];
}
}

View file

@ -1,3 +1,12 @@
export { RoutineService } from "./routine-service";
export { RoutineRepository } from "./routine-repository";
export * from "./routine";
// 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";

View file

@ -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<RoutineGroup> => {
const content = await fileAccessor.readFileAsReadonly(file);
const obj = parseFrontmatter(content);
return new RoutineGroup(
file.basename,
GroupProperties.fromJsonObj(obj)
);
}
interface GroupRepository {
loadAll(): Promise<RoutineGroup[]>;
load(groupName: string): Promise<RoutineGroup>;
isExist(groupName: string): Promise<boolean>;
persist(group: RoutineGroup): Promise<boolean>;
delete(groupName: string): Promise<void>;
changeName(originalName: string, newName: string): Promise<void>;
}
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));
},
}

View file

@ -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<Routine> => {
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<Routine[]>;
load(routineName: string): Promise<Routine>;
persist(entity: Routine): Promise<boolean>;
delete(routineName: string): Promise<void>;
changeName(originalName: string, newName: string): Promise<void>;
update(entity: Routine): Promise<Routine>;
}
export const RoutineRepository: RoutineRepository = {
async loadAll(){
const routinePromises: Promise<Routine>[] = (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;
},
}

View file

@ -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}`;
};

View file

@ -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}'`);
}

View file

@ -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<Routine> => {
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<Routine[]>;
load(routineName: string): Promise<Routine>;
persist(routine: Routine): Promise<TFile>;
delete(routineName: string): Promise<void>;
update(originalName: string, routine: Routine): Promise<Routine>;
}
export const RoutineRepository: RoutineRepository = {
async loadAll() {
const routinePromises: Promise<Routine>[] = (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;
}
}

View file

@ -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
}
}
}

View file

@ -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
}
},
}

View file

@ -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],
}
})

View file

@ -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;
}
}

View file

@ -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<RoutineNote> => {
export const resolveRoutineNote = async (day: Day): Promise<RoutineNoteDto> => {
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();
}

View file

@ -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, UseRoutineNote>((routineNote, set, get) => ({
createStoreContext<RoutineNoteDto, UseRoutineNote>((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 {

View file

@ -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<RoutineProperties>;
payload: Partial<RoutinePropertiesDto>;
}
export interface RoutineReducer {
(state: Routine, action: RoutineReduceAction): Routine;
(state: RoutineDto, action: RoutineReduceAction): RoutineDto;
}

View file

@ -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<RoutineProperties>) => void;
routine: RoutineDto;
setProperties: (properties: Partial<RoutinePropertiesDto>) => void;
}
/**
* daysOfWeek, daysOfMonth를

View file

@ -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<RoutineProperties>) => void;
setProperties: (properties: Partial<RoutinePropertiesDto>) => void;
}
export const MonthOption = ({ className, daysOfMonth, setProperties }: MonthOptionProps) => {

View file

@ -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<RoutineProperties>) => void;
setProperties: (properties: Partial<RoutinePropertiesDto>) => void;
}
export const WeekOption = ({ className, daysOfWeek, setProperties }: WeekOptionProps) => {

View file

@ -0,0 +1,5 @@
import { TaskElementDto } from "@entities/note";
export interface TaskElDragItem {
el: TaskElementDto;
}

View file

@ -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";
}
}
},
}

View file

@ -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 (
<div css={[
{
position: "absolute",
},
mode === "in" ? inActiveStyle : lineStyle(mode)
]}/>
)
})

View file

@ -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<HTMLElement | null>; // 드래그 타겟
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<GroupHitArea | null>(null);
const indicator = useMemo<React.ReactNode | null>(() => {
if(!hit) return null;
return <DndIndicator mode={hit} />
}, [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
};
}

View file

@ -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<HTMLElement | null>; // 드래그 타겟
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<TaskHitArea | null>(null);
const indicator = useMemo<React.ReactNode | null>(() => {
if(!hit) return null;
return <DndIndicator mode={hit} />
}, [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
};
}

View file

@ -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";

View file

@ -0,0 +1,16 @@
import { NoteRepository, RoutineNote, RoutineNoteDto, TaskDto } from "@entities/note";
export const checkTask = async (noteDto: RoutineNoteDto, taskDto: TaskDto, check: boolean): Promise<RoutineNoteDto> => {
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 };
}
}

View file

@ -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,
}

View file

@ -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<T extends TaskDto> {
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(<T extends TaskDto>({
task,
parent,
className,
onTaskReorder,
onOptionMenu,
onTaskClick
}: TaskProps<T>) => {
const taskRef = useRef<HTMLDivElement>(null);
const [taskMode, setTaskMode] = useState<TaskMode>("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<boolean>(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 (
<div
ref={taskRef}
className={bem("", '', className)}
css={[
baseHeaderStyle,
taskMode === "pressed" && pressedStyle,
taskMode === "drag-ready" && dragReadyStyle,
taskMode === "dragging" && draggingStyle,
parent !== null && indentStyle
]}
>
<Touchable
onClick={onClick}
longPressDelay={DELAY_TOUCH_START}
onPressChange={onPressChange}
onAfterLongPressDelay={onAfterLongPressDelay}
css={{
display: "flex",
alignItems: "center",
justifyContent: "start",
width: "100%",
height: elementHeight,
padding: "0 0 0 0.5em",
margin: "0 0",
cursor: "pointer",
lineHeight: 1
}}
>
<Checkbox isChecked={task.checked} />
<TaskName name={task.name} isChecked={task.checked} />
</Touchable>
<OptionIcon onClick={() => onOptionMenu(task)} />
{indicator}
</div>
)
})

View file

@ -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 (
<span
className={bem("cbx")}
@ -20,10 +21,12 @@ export const Checkbox = ({ bem }: CheckboxProps) => {
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",
}
}
})
},
}}
>
<svg
@ -76,11 +74,11 @@ export const Checkbox = ({ bem }: CheckboxProps) => {
fill: "none",
strokeLinecap: "round",
strokeLinejoin: "round",
".dr-task--checked &": {
...(isChecked && {
transform: "scale(1)",
transition: "all 0.4s ease",
transitionDelay: "0.1s",
}
})
}}
>
<polyline

View file

@ -0,0 +1,77 @@
/** @jsxImportSource @emotion/react */
import { useLeaf } from "@shared/view/use-leaf";
import { CSSProperties, useEffect, useMemo, useRef } from 'react';
import { useDragLayer } from 'react-dnd';
import { TaskElDragItem } from '../dnd/drag-item';
import { TEXT_CSS } from "@shared/constants/text-style";
import { css } from "@emotion/react";
const textStyle = css([TEXT_CSS.description, {
color: "var(--color-base-00)",
}])
interface TaskPreviewProps {
item: TaskElDragItem;
style: React.CSSProperties;
backend: "touch" | "html5";
}
export const ElementPreview = ({ item, style, backend }: TaskPreviewProps) => {
const { currentOffset } = useDragLayer((monitor) => ({
currentOffset: monitor.getClientOffset(),
}));
const leaf = useLeaf();
const previewStyle = useMemo<CSSProperties>(() => {
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 (
<div style={previewStyle}>
<div css={{
display: "flex",
justifyContent: "center",
alignItems: "center",
flexDirection: "column",
borderRadius: "5px",
padding: "5px 5px",
width: "fit-content",
opacity: 0.9,
backgroundColor: "var(--color-base-100)",
boxShadow: "0 0 0.5em 0.5em rgba(0, 0, 0, 0.2)",
color: "var(--color-base-00)",
}}>
<div css={textStyle}>{item.el.elementType}</div>
<div css={textStyle}>{item.el.name}</div>
</div>
</div>
)
}

View file

@ -0,0 +1,27 @@
/** @jsxImportSource @emotion/react */
import { Icon } from "@shared/components/Icon";
interface OptionIconProps {
onClick: () => void;
}
export const OptionIcon = ({
onClick,
}: OptionIconProps) => {
return (
<div
onClick={onClick}
css={{
display: "flex",
alignItems: "center",
justifyContent: "center",
flexGrow: "1",
width: "3em",
alignSelf: "stretch"
}}
>
<Icon icon="ellipsis" color='var(--color-base-40)' />
</div>
)
}

View file

@ -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<HTMLDivElement>(null);
const [groupMode, setGroupMode] = useState<GroupMode>("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 (
<div className={bem()}>
<Accordion
disableGutters
elevation={0}
expanded={open}
onChange={() => setOpen(!open)}
css={{
backgroundColor: bgColor,
"&::before": {
display: "none",
}
}}
>
<header
ref={groupRef}
className={bem('header', '', className)}
css={[
baseHeaderStyle,
groupMode === "pressed" && pressedStyle,
groupMode === "drag-ready" && dragReadyStyle,
groupMode === "dragging" && draggingStyle,
]}
>
<Touchable
onClick={onClick}
longPressDelay={DELAY_TOUCH_START}
onPressChange={onPressChange}
onAfterLongPressDelay={onAfterLongPressDelay}
css={{
display: "flex",
alignItems: "center",
justifyContent: "start",
width: "100%",
height: elementHeight,
padding: "0 0 0 0.3em",
margin: "0 0",
cursor: "pointer",
lineHeight: 1
}}
>
<AccordionSummary
expandIcon={<Icon icon='chevron-right' />}
css={{
padding: "0",
width: "100%",
flexDirection: "row-reverse",
gap: "0.5em",
fontWeight: "500",
// 열림상태
[`& .${accordionSummaryClasses.expandIconWrapper}.${accordionSummaryClasses.expanded}`]: {
transform: 'rotate(90deg)',
},
}}
>
{group.name}
</AccordionSummary>
</Touchable>
<OptionIcon onClick={() => onOptionMenu?.(group)} />
{indicator}
</header>
<AccordionDetails css={{padding: "0"}}>
{children}
</AccordionDetails>
</Accordion>
</div>
)
})

View file

@ -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 (
<span
className={bem("name")}
css={[baseStyle, isChecked && chechedStyle]}
>
{name}
</span>
)
}

View file

@ -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)",
}
});

View file

@ -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 }) => {
</ScrollComponent>
<div className="dr-task-preview-context">
<Preview generator={({ item, style }) => (
<TaskPreview
item={item as DragItem}
<ElementPreview
item={item as TaskElDragItem}
style={style}
backend={backend}
/>
@ -101,7 +102,7 @@ const ScrollComponent = ({ children }: ScrollComponentProps) => {
css={{
height: "100%",
overflowY: "auto",
padding: "0 10px",
padding: "0 0.5em",
}}
>
{children}

View file

@ -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<HTMLElement | null>; // 드롭 타겟
handleRef: RefObject<HTMLElement | null>; // 드래그 핸들
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<RoutineNote | null>(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
};
}

View file

@ -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";

View file

@ -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<T extends TaskEntity> {
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(<T extends TaskEntity>({ className, task, onTaskReorder, onOptionMenu, onTaskClick }: TaskProps<T>) => {
const taskRef = useRef<HTMLDivElement>(null);
const handleRef = useRef<HTMLDivElement>(null);
const [isPressed, setIsPressed] = useState<boolean>(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<boolean>(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 (
<div
ref={taskRef}
className={bem("", {
"checked": task.checked,
"pressed": isPressed,
"dragging": isDragging,
}, className)}
css={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
"& *": {
touchAction: "manipulation",
userSelect: "none",
},
"&::after": {
content: "''",
},
"&.dr-task--pressed, &.dr-task--dragging": taskPressedAndDraggingStyle
}}
>
<Touchable
onClick={onClick}
longPressDelay={500}
onChangePressedState={setIsPressed}
onLongPressStart={onLongPressStart}
onAfterLongPressDelay={onAfterLongPressDelay}
onLongPressEnd={onLongPressEnd}
css={{
display: "flex",
alignItems: "center",
justifyContent: "start",
width: "100%",
height: taskHeight,
padding: "0 0 0 0.5em",
margin: "0 0",
cursor: "pointer",
lineHeight: 1
}}
>
<Checkbox bem={bem} />
<TaskName bem={bem} name={task.name} />
</Touchable>
<div
ref={handleRef}
css={{
display: "flex",
alignItems: "center",
justifyContent: "center",
flexGrow: "1",
width: "3em",
alignSelf: "stretch"
}}
onContextMenu={e => {
e.preventDefault();
e.stopPropagation();
}}
>
<Icon icon="menu" color='var(--color-base-40)' />
</div>
</div>
)
})

View file

@ -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 (
<span
className={bem("name")}
css={{
position: "relative",
cursor: "pointer",
transition: "color 0.3s ease",
"&:after": {
content: "''",
position: "absolute",
top: "50%",
left: 0,
width: 0,
height: "1px",
background: "#9098a9",
},
".dr-task--checked &": {
color: "#9098a9",
"&:after": {
width: "100%",
transition: "all 0.4s ease",
}
}
}}
>
{name}
</span>
)
}

View file

@ -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<HTMLDivElement>(null);
const { currentOffset } = useDragLayer((monitor) => ({
currentOffset: monitor.getSourceClientOffset(),
}));
const leaf = useLeaf();
const previewStyle = useMemo<CSSProperties>(() => {
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 (
<div
ref={previewContainerRef}
style={previewStyle}
/>
)
}

View file

@ -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,

View file

@ -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<RoutineNote | null>(null);
const [ note, setNote ] = useState<RoutineNoteDto | null>(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",
}}
>
<TaskDndContext>
{note.tasks.map(task => {
switch(task.type){
case "routine": return <RoutineTaskWidget key={task.name} task={task} />
case "todo": return <TodoTaskWidget key={task.name} task={task} />
default: task as never;
}
})}
</TaskDndContext>
<TaskDndContext> {note.root.map(el => {
if(el.elementType === "group"){
return (
<TaskGroupWidget
key={`${el.elementType}-${el.name}`}
group={el as TaskGroupDto}
/>)
} else {
return renderTask(el as TaskDto, null);
}
})}</TaskDndContext>
</div>
<AddTodoModal />
<StartRoutineModal />
@ -125,4 +131,6 @@ const PageComponent = () => {
}
export const RoutineNotePage = RoutineNotePageContext;

View file

@ -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 (
<BaseGroupHeadFeature group={group}>
{group.tasks.map(task => renderTask(task, group))}
</BaseGroupHeadFeature>
)
}

View file

@ -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();
}

View file

@ -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 <TodoTaskWidget key={task.name} task={task as TodoTaskDto} parent={parent} />;
case "routine": return <RoutineTaskWidget key={task.name} task={task as RoutineTaskDto} parent={parent} />;
default: return null;
}
}

View file

@ -0,0 +1,6 @@
export interface JsonConvertible<T> {
toJSON(): T;
}

View file

@ -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<HTMLDivElement, TouchableProps>((props:
);
const longPressStartDebounce = useRef<DebouncedFunc<(e: React.TouchEvent) => void>>(
_.debounce((e: React.TouchEvent) => {
props.onChangePressedState?.(true);
props.onPressChange?.(true);
props.onLongPressStart?.(e);
}, startDelay)
);
@ -70,7 +70,7 @@ export const Touchable = memo(forwardRef<HTMLDivElement, TouchableProps>((props:
isTouchRef.current = false;
longPressDebounce.current.cancel();
longPressStartDebounce.current.cancel();
props.onChangePressedState?.(false);
props.onPressChange?.(false);
}, [props]);
const onClick = useCallback((e: React.MouseEvent) => {

View file

@ -1,7 +0,0 @@
export class FileNotFoundError extends Error {
constructor(path: string){
super(`File not found: ${path}`);
}
}

View file

@ -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;

View file

@ -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);
}

View file

@ -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";

View file

@ -0,0 +1,14 @@
import { err, ok, Result } from "neverthrow";
export type ObsidianFileTitleValidation = 'contains-invalid-characters';
export const validateObsidianFileTitle = (title: string): Result<string, ObsidianFileTitleValidation> => {
const invalidCharsRegex = /[:/\\#[\]|^]/;
if(invalidCharsRegex.test(title)){
return err('contains-invalid-characters');
}
return ok(title);
}

View file

@ -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";

View file

@ -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 <CalendarTile tile={tile} />;

View file

@ -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 (
<div>
@ -70,7 +70,7 @@ const TaskLineContainer = ({ tasks }: { tasks: Task[] }) => {
)
}
const TaskLine = ({ task }: { task: Task }) => {
const TaskLine = ({ task }: { task: TaskDto }) => {
return (
<div css={[TEXT_CSS.onAccent, defaultTextStyle, {
margin: '1px 1px',

View file

@ -1,6 +1,6 @@
import { VirtualSwiper } from "@shared/components/VirtualSwiper";
import { Month } from "@shared/period/month";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useState } from "react";
import { CalendarNavigation } from "./CalendarNavigation";
import { CalendarSlide } from "./CalendarSlider";

View file

@ -1,20 +1,22 @@
import { Month } from "@shared/period/month";
import { Calendar, Tile } from "./types";
import { RoutineRepository } from "@entities/routine";
import { NoteRepository, NoteService } from "@entities/note";
import { NoteRepository, RoutineTask } from "@entities/note";
import { Day } from "@shared/period/day";
export const loadCalendar = async (month: Month): Promise<Calendar> => {
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<Calendar> => {
const tiles: Map<string, Tile> = 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));

View file

@ -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개
}

View file

@ -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<Month>(propsMonth);
const { route } = useTabRoute();
const [routineNotes, setRoutineNotes] = useState<RoutineNote[] | null>(null);
const [notes, setNotes] = useState<RoutineNote[] | null>(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 (
<PercentageCircle percentage={percent} text={tileDay.date.toString()} />
)
}, [routineNotes]);
}, [notes]);
return (

View file

@ -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<Routine[]> => {
const routineTasks: RoutineTask[] = tasks.filter(t => t.type === "routine") as RoutineTask[];
const routines = await RoutineRepository.loadAll();
const routineMap = new Map<string, Routine>(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 (
<>
<AbstractTask
onTaskReorder={onTaskReorder}
<AbstractTaskWidget
parent={parent}
// onTaskReorder={onTaskReorder}
className="dr-routine-task"
task={task}
onOptionMenu={onOptionClick}

View file

@ -1,48 +1,47 @@
import { Task } from "@entities/note";
import { Routine } from "@entities/routine";
// import { Routine } from "@entities/routine";
interface ReorderRoutinesResult {
routines: Routine[];
requireUpdateMap: Map<string, boolean>;
}
// interface ReorderRoutinesResult {
// routines: Routine[];
// requireUpdateMap: Map<string, boolean>;
// }
/**
* @param task routine Task .
* routineTask들을 routine의 .
*/
export const reorderRoutines = async (routines: Routine[]): Promise<ReorderRoutinesResult> => {
type Acc = {
prevOrder: number;
arr: Routine[];
requireUpdateMap: Map<string, boolean>;
}
// /**
// * @param task routine 순서 조정의 기반이되는 Task 배열.
// * 해당 배열에서 routineTask들을 추출하고 이를 기반으로 실제 routine의 순서를 조정한다.
// */
// export const reorderRoutines = async (routines: Routine[]): Promise<ReorderRoutinesResult> => {
// type Acc = {
// prevOrder: number;
// arr: Routine[];
// requireUpdateMap: Map<string, boolean>;
// }
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<string, boolean>()
});
// const result = routines.reduce(reduce, {
// prevOrder: -1,
// arr: [] as Routine[],
// requireUpdateMap: new Map<string, boolean>()
// });
return {
routines: result.arr,
requireUpdateMap: result.requireUpdateMap
}
}
// return {
// routines: result.arr,
// requireUpdateMap: result.requireUpdateMap
// }
// }

View file

@ -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<RoutineNoteDto> => {
const routine = Routine.fromJSON(dto);
executeRoutineNotesSynchronize();
return noteDto;
}

View file

@ -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>(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();

View file

@ -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";

View file

@ -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<RoutineNote> => {
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;
}

View file

@ -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 (
<>
<AbstractTask
className="dr-todo-task"
<AbstractTaskWidget
task={task}
parent={parent}
className="dr-todo-task"
onOptionMenu={onOptionMenu}
/>
<TodoOptionModal />

View file

@ -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<TodoTask>(DEFAULT_TASK);
const [ todo, setTodo ] = useState<TodoTaskDto>(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"), []);

View file

@ -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<TodoTask>(propsTodo);
const [ todo, setTodo ] = useState<TodoTaskDto>(propsTodo);
const originalName = useMemo(() => propsTodo.name, [propsTodo]);
const validation = useMemo(() => {

View file

@ -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);