mirror of
https://github.com/sechan100/daily-routine-2.git
synced 2026-07-22 05:37:51 +00:00
BREAKING_CHANGE: 전체 구조를 도메인 모델 기반으로 변경하고 Group 기능을 추가
This commit is contained in:
parent
7012b76ffa
commit
9aa4fe783d
96 changed files with 3276 additions and 1600 deletions
|
|
@ -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
422
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -7,5 +7,5 @@ export abstract class NoteDependent {
|
|||
/**
|
||||
* 노트에 의존적인 데이터를 기존의 노트로 부터 분리해냈기 때문에, 이를 다시 새로운 노트에 복원해내야한다.
|
||||
*/
|
||||
abstract restoreData(newNote: RoutineNote): RoutineNote;
|
||||
abstract restoreData(newNote: RoutineNote): void;
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
// 모든 등록된 비동기 로직 이후에 실행을 예약
|
||||
|
|
|
|||
112
src/entities/note/domain/AbstractTask.ts
Normal file
112
src/entities/note/domain/AbstractTask.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
265
src/entities/note/domain/RoutineNote.ts
Normal file
265
src/entities/note/domain/RoutineNote.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
77
src/entities/note/domain/RoutineTask.ts
Normal file
77
src/entities/note/domain/RoutineTask.ts
Normal 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(),
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
10
src/entities/note/domain/TaskElement.ts
Normal file
10
src/entities/note/domain/TaskElement.ts
Normal 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;
|
||||
}
|
||||
152
src/entities/note/domain/TaskGroup.ts
Normal file
152
src/entities/note/domain/TaskGroup.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
16
src/entities/note/domain/TaskParent.ts
Normal file
16
src/entities/note/domain/TaskParent.ts
Normal 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>[];
|
||||
}
|
||||
69
src/entities/note/domain/TodoTask.ts
Normal file
69
src/entities/note/domain/TodoTask.ts
Normal 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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
@ -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`;
|
||||
}
|
||||
130
src/entities/note/persistence/note-repository.ts
Normal file
130
src/entities/note/persistence/note-repository.ts
Normal 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`;
|
||||
};
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
16
src/entities/note/types/routine-note.ts
Normal file
16
src/entities/note/types/routine-note.ts
Normal 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;
|
||||
}
|
||||
29
src/entities/note/types/task.ts
Normal file
29
src/entities/note/types/task.ts
Normal 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
|
||||
}
|
||||
66
src/entities/routine-to-note/RoutineNoteCreator.ts
Normal file
66
src/entities/routine-to-note/RoutineNoteCreator.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
71
src/entities/routine/domain/GroupProperties.ts
Normal file
71
src/entities/routine/domain/GroupProperties.ts
Normal 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;
|
||||
}
|
||||
|
||||
}
|
||||
100
src/entities/routine/domain/Routine.ts
Normal file
100
src/entities/routine/domain/Routine.ts
Normal 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;
|
||||
}
|
||||
|
||||
}
|
||||
58
src/entities/routine/domain/RoutineGroup.ts
Normal file
58
src/entities/routine/domain/RoutineGroup.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
196
src/entities/routine/domain/RoutineProperties.ts
Normal file
196
src/entities/routine/domain/RoutineProperties.ts
Normal 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];
|
||||
}
|
||||
}
|
||||
|
|
@ -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";
|
||||
68
src/entities/routine/persistence/group-repository.ts
Normal file
68
src/entities/routine/persistence/group-repository.ts
Normal 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));
|
||||
},
|
||||
}
|
||||
78
src/entities/routine/persistence/routine-repository.ts
Normal file
78
src/entities/routine/persistence/routine-repository.ts
Normal 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;
|
||||
},
|
||||
}
|
||||
22
src/entities/routine/persistence/utils.ts
Normal file
22
src/entities/routine/persistence/utils.ts
Normal 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}`;
|
||||
};
|
||||
|
||||
|
||||
|
|
@ -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}'`);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
},
|
||||
|
||||
}
|
||||
|
|
@ -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],
|
||||
}
|
||||
})
|
||||
25
src/entities/routine/types.ts
Normal file
25
src/entities/routine/types.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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를 설정하는 옵션 컴포넌트
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
||||
|
|
|
|||
5
src/features/task-el/dnd/drag-item.ts
Normal file
5
src/features/task-el/dnd/drag-item.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { TaskElementDto } from "@entities/note";
|
||||
|
||||
export interface TaskElDragItem {
|
||||
el: TaskElementDto;
|
||||
}
|
||||
71
src/features/task-el/dnd/hit-area.ts
Normal file
71
src/features/task-el/dnd/hit-area.ts
Normal 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";
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
48
src/features/task-el/dnd/indicator.tsx
Normal file
48
src/features/task-el/dnd/indicator.tsx
Normal 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)
|
||||
]}/>
|
||||
)
|
||||
})
|
||||
142
src/features/task-el/dnd/use-group-dnd.tsx
Normal file
142
src/features/task-el/dnd/use-group-dnd.tsx
Normal 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
|
||||
};
|
||||
}
|
||||
162
src/features/task-el/dnd/use-task-dnd.tsx
Normal file
162
src/features/task-el/dnd/use-task-dnd.tsx
Normal 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
|
||||
};
|
||||
}
|
||||
9
src/features/task-el/index.ts
Normal file
9
src/features/task-el/index.ts
Normal 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";
|
||||
16
src/features/task-el/model/check-task.ts
Normal file
16
src/features/task-el/model/check-task.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
115
src/features/task-el/model/reorder-elements.ts
Normal file
115
src/features/task-el/model/reorder-elements.ts
Normal 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,
|
||||
}
|
||||
144
src/features/task-el/ui/BaseTaskFeature.tsx
Normal file
144
src/features/task-el/ui/BaseTaskFeature.tsx
Normal 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>
|
||||
)
|
||||
})
|
||||
|
|
@ -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
|
||||
77
src/features/task-el/ui/ElementPreview.tsx
Normal file
77
src/features/task-el/ui/ElementPreview.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
27
src/features/task-el/ui/OptionIcon.tsx
Normal file
27
src/features/task-el/ui/OptionIcon.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
169
src/features/task-el/ui/TaskGroupHeadFeature.tsx
Normal file
169
src/features/task-el/ui/TaskGroupHeadFeature.tsx
Normal 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>
|
||||
)
|
||||
})
|
||||
47
src/features/task-el/ui/TaskName.tsx
Normal file
47
src/features/task-el/ui/TaskName.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
62
src/features/task-el/ui/base-element-style.ts
Normal file
62
src/features/task-el/ui/base-element-style.ts
Normal 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)",
|
||||
}
|
||||
});
|
||||
|
|
@ -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}
|
||||
|
|
@ -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
|
||||
};
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
@ -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>
|
||||
)
|
||||
})
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
19
src/pages/routine-note/TaskGroupWidget.tsx
Normal file
19
src/pages/routine-note/TaskGroupWidget.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
8
src/pages/routine-note/get-completion.ts
Normal file
8
src/pages/routine-note/get-completion.ts
Normal 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();
|
||||
}
|
||||
11
src/pages/routine-note/render-task-widget.tsx
Normal file
11
src/pages/routine-note/render-task-widget.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
6
src/shared/JsonConvertible.ts
Normal file
6
src/shared/JsonConvertible.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
|
||||
|
||||
|
||||
export interface JsonConvertible<T> {
|
||||
toJSON(): T;
|
||||
}
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -1,7 +0,0 @@
|
|||
|
||||
|
||||
export class FileNotFoundError extends Error {
|
||||
constructor(path: string){
|
||||
super(`File not found: ${path}`);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
11
src/shared/file/parse-frontmatter.ts
Normal file
11
src/shared/file/parse-frontmatter.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
14
src/shared/validation/validate-obsidian-file-title.ts
Normal file
14
src/shared/validation/validate-obsidian-file-title.ts
Normal 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);
|
||||
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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} />;
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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개
|
||||
}
|
||||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// }
|
||||
// }
|
||||
11
src/widgets/routine/model/save-routine.ts
Normal file
11
src/widgets/routine/model/save-routine.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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"), []);
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue