Compare commits

...

235 Commits

Author SHA1 Message Date
boojack
f09bb2689c chore: release v0.6.1 (#332) 2022-10-21 15:21:37 +00:00
boojack
e6f376ae66 fix: user setting name (#331) 2022-10-21 15:06:15 +00:00
boojack
1c2998c4d8 feat: pagination for memo list (#330) 2022-10-21 14:51:41 +00:00
Zeng1998
fc5d5cf231 fix: patch memo with resource list (#328)
fix: 修改memo时添加图片不显示
2022-10-21 22:16:05 +08:00
boojack
2a4fc7dcc3 chore: update memo display time (#327)
* chore: update memo display time

* chore: update
2022-10-21 20:26:00 +08:00
boojack
b68d6e2693 feat: update memo sort option setting (#326)
feat: add memo display ts
2022-10-21 11:57:57 +00:00
boojack
0b34b142c8 chore: update marked (#325) 2022-10-21 09:13:19 +08:00
boojack
0b2a9d8511 fix: bold and emphasis regex (#323)
* fix: bold and emphasis regex

* chore: udpate
2022-10-20 21:57:40 +08:00
winwin2011
7c9c5c316b chore: update i18n in settings (#324)
chore: i18n
2022-10-20 21:40:03 +08:00
winwin2011
180ae206c7 feat: inline code within link (#321)
* feat: inline code with link

* fix: decoration style
2022-10-20 21:21:30 +08:00
boojack
69e3ba6bed chore: update demo seeding data (#318)
chore: update seeding data
2022-10-20 17:19:37 +08:00
Zeng1998
bf5b7e747d feat: customize memo list sorting rules (#312)
* chore: update .gitignore

* feat: 添加Memo列表按更新时间排序

* fix go-static-checks

* update

* update

* update Memo.tsx/MemoList.tsx

* handle conflict

Co-authored-by: boojack <stevenlgtm@gmail.com>
2022-10-19 21:00:34 +08:00
f97
24154c95f2 feat: editor tab support (#309)
* feat: editor tab support

* Update web/src/components/MemoEditor.tsx

Co-authored-by: boojack <stevenlgtm@gmail.com>

* chore: if return style

Co-authored-by: boojack <stevenlgtm@gmail.com>
Co-authored-by: hyoban <hi@hyoban.cc>
2022-10-19 18:19:50 +08:00
boojack
0b10d24eb2 chore: update bug report template (#316)
chore: update but report template
2022-10-19 08:22:56 +08:00
boojack
06ec59eca4 chore: add issue template (#314) 2022-10-18 23:13:41 +08:00
boojack
35a8817442 fix: memo list auto scroll to top (#313)
fix: editor initial content
2022-10-18 22:56:43 +08:00
boojack
c7378e78d9 chore: update mask animation styles (#306) 2022-10-17 18:43:02 +08:00
Tiger
ab182dc22a chore: update zh.json (#304)
Update zh.json
2022-10-16 13:49:58 +08:00
boojack
f554e5a357 fix: close button z-index in setting dialog (#303) 2022-10-16 12:37:17 +08:00
Steven
749486ba3c chore: update marked tests 2022-10-15 21:57:56 +08:00
Hyoban
26aae0e637 fix: blank line after table (#298) 2022-10-15 17:51:17 +08:00
Hyoban
95c8d8fc9c docs: update docker-compose tip (#299) 2022-10-15 15:32:42 +08:00
Hyoban
1021023577 docs: docker compose tip (#296)
* docs: docker compose tip

* chore: update

Co-authored-by: boojack <stevenlgtm@gmail.com>

Co-authored-by: boojack <stevenlgtm@gmail.com>
2022-10-15 13:27:06 +08:00
Hyoban
3d0c6004c0 feat: support markdown table (#294)
* feat: support markdown table

* chore: update table style

* test: for markdown table parse
2022-10-15 11:42:04 +08:00
Steven
6c53bd00f6 chore: restore user email 2022-10-15 11:27:12 +08:00
Steven
cc759bef56 fix: handle highlight unknown language error 2022-10-15 06:11:17 +08:00
Steven
d670adc11f chore: release v0.6.0 2022-10-14 23:25:07 +08:00
Steven
349c383604 chore: reorder memo resource 2022-10-14 23:14:08 +08:00
Steven
f407488128 chore: update dev version 2022-10-14 23:03:54 +08:00
boojack
086b10717f feat: upload files by dropping (#292) 2022-10-14 22:59:30 +08:00
boojack
eefd0444c8 feat: add highlight for code block (#291)
* feat: add highlight for code block

* chore: update test
2022-10-14 22:29:28 +08:00
Hyoban
65a61ed270 fix: can not click sidebar (#289) 2022-10-14 21:43:43 +08:00
Hyoban
5e7db4631e feat: handle esc keyboard event for editor (#288) 2022-10-14 10:14:35 +08:00
boojack
0d6114e25e feat: update sidebar mask styles (#287) 2022-10-14 07:26:43 +08:00
h2o2o
ce5a6fa3ac chore: update sidebar styles in mobile view (#285)
* 添加移动端点击自动关闭侧边栏

* 添加移动端点击自动关闭侧边栏

* 添加移动端点击自动关闭侧边栏

* move closeSidebar function to utils

* move closeSidebar function to utils

* 侧边栏优化

* 移动端侧边栏优化

* 移动端侧边栏优化

* 移动端侧边栏优化
2022-10-13 22:56:42 +08:00
boojack
246851fdbe fix: create memo with visibility (#281) 2022-10-13 09:01:09 +08:00
boojack
21c30ac157 chore: hide user email (#282) 2022-10-13 09:01:02 +08:00
Steven
269d92e637 fix: create triggers after dropping old tables 2022-10-13 08:23:05 +08:00
Steven
ffe145d436 chore: revert sidebar updates 2022-10-13 08:05:59 +08:00
zburu
37cd841992 chore: adjust sidebar style (#277) 2022-10-13 07:54:34 +08:00
h2o2o
315ab94c94 添加移动端点击自动关闭侧边栏 (#271) 2022-10-11 08:12:35 +08:00
f97
1aa9963e07 fix: i18n for filter (#275) 2022-10-10 17:46:52 +08:00
Steven
88ade2c0b7 chore: update i18n for filter 2022-10-09 08:54:05 +08:00
winwin2011
4ada7dce77 chore: update i18n for shortcut filter (#270)
* chore: resources i18n

* chore: shortcut-list i18n

* chore: resources i18n

* chore: resources i18n

* chore: resources i18n
2022-10-08 22:27:23 +08:00
steven
a323689ee6 chore: update copy button style 2022-10-05 20:55:04 +08:00
f97
2ea612e2fe feat: add copy button to memo (#267)
* feat: copy-content

* Update web/src/less/memo-detail.less

Co-authored-by: boojack <stevenlgtm@gmail.com>
2022-10-05 16:39:04 +08:00
boojack
ca2557eb7e fix: tag filter (#266) 2022-10-04 21:02:07 +08:00
steven
e0f95686f3 chore: update header style 2022-10-04 20:42:33 +08:00
f97
29770e8bfb chore: update vietnamese i18n (#261) 2022-10-04 14:37:44 +08:00
steven
b959acc69d fix: update marked test cases 2022-10-04 14:35:07 +08:00
steven
486cf8bdac feat: add escape to renderer 2022-10-04 13:53:36 +08:00
steven
ea911387f1 chore: update migration sql file 2022-10-04 12:04:26 +08:00
steven
0b9b89db81 chore: update VACUUM 2022-10-04 10:48:45 +08:00
boojack
eaf89aa2f2 feat: update marked parsers (#260)
* chore: remove external match functions

* chore: update parsers
2022-10-04 10:44:16 +08:00
boojack
4bd373ba57 fix: tag selector position (#259) 2022-10-04 08:48:43 +08:00
steven
7b29c65f58 feat: add inline-code syntax parser 2022-10-03 19:51:54 +08:00
steven
2298ac6ff3 chore: add FUNDING.yml 2022-10-03 19:43:56 +08:00
steven
4477f47c25 chore: release version 0.5.0 2022-10-03 18:49:11 +08:00
steven
4a2fe3aaae chore: update user banner style 2022-10-03 18:48:44 +08:00
steven
0f65b8bdd3 fix: re-generate table columns foreign keys 2022-10-03 18:47:31 +08:00
steven
778282ae29 chore: update resource dialog 2022-10-03 09:39:59 +08:00
steven
85dc29bfb9 feat: add linkedMemoAmount to resource 2022-10-03 09:39:49 +08:00
steven
05e46ee4a8 fix: emoji picker style 2022-10-03 09:11:07 +08:00
steven
6a3b052fa2 chore: don't clean data in dev mode 2022-10-03 08:49:20 +08:00
boojack
222c792539 fix: tag regexp (#253) 2022-10-02 23:12:06 +08:00
boojack
51fb8ddb07 feat: simple markdown parser (#252)
* feat: simple markdown parser

* chore: rename test file name

* feat: add plain text link parser

* chore: update style
2022-10-02 22:49:30 +08:00
steven
8e63b8f289 chore: update interval flag type 2022-10-01 23:01:32 +08:00
steven
17c35be426 chore: add --passWithNoTests to jest 2022-10-01 22:59:00 +08:00
steven
dfd461518f chore: add jest github action 2022-10-01 22:57:32 +08:00
steven
df5818cdc5 chore: add jest 2022-10-01 22:56:20 +08:00
steven
5894104524 chore: update inline image 2022-10-01 20:00:45 +08:00
steven
09732df0f5 chore: regenerate yarn.lock 2022-10-01 19:59:15 +08:00
steven
ae8f292306 feat: create memo with resourceIdList 2022-10-01 10:57:14 +08:00
steven
9f8c0ce567 fix: raw data cache 2022-10-01 10:37:02 +08:00
steven
c7cf35c7de feat: update memo resource component 2022-10-01 10:02:16 +08:00
steven
e5c9d8604d feat: compose memo resource list 2022-10-01 09:49:59 +08:00
steven
b2c22977c1 feat: update memo editor with uploading resources 2022-10-01 00:10:31 +08:00
steven
c0edb72b3d chore: update react version 2022-10-01 00:07:06 +08:00
steven
33d31b7dca fix: delete memo resource 2022-10-01 00:06:56 +08:00
steven
4c465bef2d feat: add memo resource apis 2022-09-30 22:58:59 +08:00
steven
3bde9543f3 chore: update dev version v0.5.0 2022-09-30 22:58:12 +08:00
steven
cff0e86989 feat: add memo_resource model 2022-09-30 20:20:00 +08:00
boojack
65f7aa7914 fix: emoji picker position in fullscreen (#251) 2022-09-30 18:40:46 +08:00
boojack
06f5a5788e fix: mobile editor float style (#247) 2022-09-27 22:58:03 +08:00
boojack
52c8ac2ad3 chore: update emoji picker toggle logic (#244) 2022-09-26 22:33:41 +08:00
Steven
0c80654cc2 chore: update dropdown action button style 2022-09-26 22:06:06 +08:00
Steven
a2180f177f chore: remove vite-plugin-pwa 2022-09-26 21:40:37 +08:00
Steven
b992d07f3e chore: update memo detail header 2022-09-24 10:05:51 +08:00
Steven
dc3052e225 chore: release v0.4.5 2022-09-24 09:41:14 +08:00
Steven
9b2e57cee5 fix: api access checks 2022-09-24 09:34:01 +08:00
Steven
77a3513a6b chore: update memo detail page 2022-09-24 09:31:20 +08:00
Steven
0dd2337663 chore: update router 2022-09-21 21:30:33 +08:00
Steven
d316c04837 feat: update dev latest schema 2022-09-21 19:34:54 +08:00
Steven
63468dbaf3 chore: update memo detail access handler 2022-09-21 19:31:02 +08:00
f97
2acd5d4af2 chore: support html form (#236) 2022-09-21 14:12:16 +08:00
Steven
6c1cc1d283 chore: use conditional rendering instead of OnlyWhen 2022-09-20 23:30:25 +08:00
Steven
15cfc9e1f5 chore: add memo detail page 2022-09-20 22:55:24 +08:00
Steven
004713d4cd chore: update dropdown component 2022-09-20 21:11:33 +08:00
f97
7a6eb53e0f feat: float mobile editor (#234)
* feat: float mobile editor

* fix: fix pr comment

* lint: fix golangci-lint
2022-09-20 20:42:14 +08:00
boojack
02c26d5bb4 chore: update i18n for memo visibility (#233) 2022-09-20 08:34:49 +08:00
Steven
c60c58ed69 chore: fix memo content click handler 2022-09-19 23:01:14 +08:00
Steven
366afdd1e4 feat: use i18next 2022-09-19 22:27:50 +08:00
Steven
307483e499 feat: use react-router 2022-09-19 21:53:27 +08:00
Steven
4608894e56 fix: add _foreign_keys to sqlite dsn 2022-09-18 22:48:26 +08:00
boojack
a1066322c8 chore: add vite-plugin-pwa (#230) 2022-09-18 11:58:03 +08:00
f97
050a2d39fa chore: fix i18n vietnamese typos and update MemoCardDialog (#228) 2022-09-18 09:17:46 +08:00
Steven
13aa61bbc0 chore: update i18n for change password dialog 2022-09-17 13:33:40 +08:00
boojack
f4d0e8c948 chore: hide the searchbar of emoji picker (#222)
chore: hide emoji picker searchbar
2022-09-17 09:23:53 +08:00
f97
e7db587a9e feat: add emoji picker in editor (#221)
* feat: add vietnamese

* feat: add emoji picker in editor

* fix failing checks

* move emoji button before upload button

* move script to body index.html

* Update web/index.html

Co-authored-by: boojack <stevenlgtm@gmail.com>
2022-09-17 00:42:52 +08:00
Steven
059080f194 chore: fix docker tag 2022-09-16 23:34:57 +08:00
Steven
78c159cd59 chore: fix build tag 2022-09-16 23:15:16 +08:00
Steven
721aa3c907 chore: update v0.4.4 2022-09-16 23:04:40 +08:00
Steven
77178afad5 chore: add no data tip 2022-09-16 22:55:39 +08:00
Steven
fd7b8c3293 chore: add copy non-private memo link 2022-09-16 22:49:11 +08:00
Steven
660908e436 chore: add react-router 2022-09-16 22:48:41 +08:00
Steven
8d694f7732 chore: remove language beta badge 2022-09-16 21:47:00 +08:00
f97
bdfa9f7a56 chore: update i18n for UserBanner (#219)
* feat: add vietnamese

* chore: update i18n

* Update web/src/locales/zh.json

Co-authored-by: boojack <stevenlgtm@gmail.com>

Co-authored-by: boojack <stevenlgtm@gmail.com>
2022-09-16 21:48:18 +08:00
Steven
19ef97a7ec chore: update dev version 2022-09-16 21:45:56 +08:00
Steven
2c17ea703d chore: fix manifest.json image 2022-09-16 21:33:31 +08:00
Steven
1591fdf61c chore: update i18n structures 2022-09-16 21:25:39 +08:00
f97
811f3340e9 feat: add vietnamese (#218) 2022-09-16 21:09:59 +08:00
ChasLui
7e8d1128f8 chore: update i18n (#215) 2022-09-15 06:53:51 +08:00
Steven
54db6eda04 chore: update common locales 2022-09-14 19:35:45 +08:00
boojack
c5b26e3310 chore: fix copy to clipboard (#214) 2022-09-14 19:24:13 +08:00
ChasLui
7079faf2b9 chore: update i18n (#212)
* feat: 增加部分 i18n

* feat: 增加部分 i18n

* feat: 增加部分 i18n
2022-09-14 19:14:45 +08:00
Steven
707d1a96eb chore: move version pkg to server/version 2022-09-12 17:25:34 +08:00
boojack
76801dfa4f chore: vacuum db file after deleting resource (#210) 2022-09-10 23:43:19 +08:00
Steven
6e4577f721 feat: add MemoContent component 2022-09-10 21:22:26 +08:00
winwin2011
7b0987610c feat: i18n for dialogs (#203)
* feat: i18n in dialog

* fix: use common translation
2022-09-10 09:30:10 +08:00
boojack
50fa560d4a chore: update username validate config (#209) 2022-09-09 22:30:24 +08:00
Steven
b8a7df21f2 chore: release v0.4.3 2022-09-09 20:00:04 +08:00
boojack
d1a4348048 chore: support double-click to edit memo (#207) 2022-09-09 19:42:04 +08:00
Steven
020b211660 chore: update explore page style 2022-09-09 19:41:12 +08:00
Steven
4657e58b52 chore: update loading section 2022-09-09 08:22:49 +08:00
Steven
22971c3a93 chore: support upload more file type 2022-09-09 08:10:39 +08:00
Steven
5fa9aa3c22 chore: add memo resources component 2022-09-09 07:53:02 +08:00
Steven
fbce43870f chore: update resource base url 2022-09-09 07:40:21 +08:00
Steven
b1e6956441 chore: add cache for resource 2022-09-09 00:50:58 +08:00
Steven
ad462cec29 chore: update explore style 2022-09-09 00:37:31 +08:00
Steven
a0a42285d0 chore: update docs 2022-09-09 00:32:01 +08:00
boojack
b0b2776d03 chore: update button entries (#206) 2022-09-09 00:24:41 +08:00
boojack
e9ac6affef feat: add explore page (#205) 2022-09-09 00:06:05 +08:00
winwin2011
5eea1339c9 fix: upload resouces double time (#204) 2022-09-08 00:13:21 +08:00
Steven
f303dc21a0 chore: add get all memo api 2022-09-05 21:14:17 +08:00
Steven
d68891d91d chore: fix tag regex 2022-09-05 20:15:34 +08:00
winwin2011
987bb80770 fix: heatmap popover blink (#195) 2022-09-04 22:34:24 +08:00
Steven
b884327a53 chore: update eslint rules 2022-09-04 06:48:19 +08:00
Steven
4743818fe7 chore: update not found handler in deleting 2022-09-03 18:54:22 +08:00
Steven
43575e6f54 chore: update readme 2022-09-03 18:46:16 +08:00
Steven
89f9dc5640 chore: update resources 2022-09-03 18:41:54 +08:00
Steven
f7aca99d52 fix: upload image in iOS safari 2022-09-03 00:10:50 +08:00
Steven
12a48ae2db chore: release v0.4.2 2022-09-02 20:20:00 +08:00
Steven
bcb684d1cc chore: fix daily memo style 2022-09-02 20:13:56 +08:00
Steven
d3b26f7126 chore: update about dialog 2022-09-02 20:12:13 +08:00
boojack
422e190c96 chore: support uploads multi files (#191) 2022-09-02 20:05:40 +08:00
Steven
3e13fa1ce6 chore: update marked helper 2022-09-02 09:07:32 +08:00
boojack
dc9f531447 fix: find latest migration history (#190)
* fix: auth action button

* fix: find latest migration history
2022-09-02 00:01:08 +08:00
Steven
e330159f55 chore: update resources file format 2022-09-01 07:36:14 +08:00
boojack
b88846fff5 chore: update readme 2022-08-31 21:55:18 +08:00
Steven
93b6a910ae chore: update logo resources 2022-08-31 21:51:49 +08:00
boojack
8d161b4526 fix: create shortcut input (#186)
* fix: create shortcut input

* chore: add yarn build
2022-08-31 20:21:56 +08:00
boojack
b6acf62aab chore: add eslint check in action (#185)
* chore: add eslint check in action

* chore: update cache path
2022-08-31 19:27:17 +08:00
Steven
2a11aed881 chore: update dialog event listener 2022-08-30 07:41:28 +08:00
zburu
e7b287902b chore: update mobile style (#181) 2022-08-29 20:03:07 +08:00
TheNexter
c012ce0481 Simple docker-compose.yml (#179)
* Create docker-compose.yml

* Add docker compose in readme.md

* Good service name in docker-compose.yml

* Update docker-compose.yml

Change 2.1 to 3.0 version + path

* Update docker-compose.yml

Change 2.1 to 3.0 version + path

* Update readme

Change docker-compose to 2.0 + path

* Update README.md

* Update README.md

Co-authored-by: boojack <imrealleonardo@gmail.com>
2022-08-29 19:59:24 +08:00
steven
c97399a8ea fix: update isFetching default value 2022-08-28 00:30:26 +08:00
steven
75d622f4a2 chore: update version v0.4.1 2022-08-27 08:58:13 +08:00
steven
5bdf7654fc chore: update detail styles 2022-08-27 08:57:29 +08:00
steven
62657f7f4e chore: update build folder 2022-08-27 08:57:05 +08:00
Steven
64332c3e6a chore: update tag regexp 2022-08-25 23:58:03 +08:00
Steven
57f51d1c58 feat: allow updating memo createdTs 2022-08-25 22:02:32 +08:00
Steven
9f3f730723 chore: update selector style 2022-08-25 21:05:31 +08:00
boojack
e9d303326f feat: set editor font style (#174)
feat: editor font style
2022-08-25 20:44:32 +08:00
Steven
20d7112a05 chore: update config files 2022-08-25 20:44:17 +08:00
Steven
922cc21abc chore: update seed data 2022-08-25 19:53:30 +08:00
boojack
cdbd934c4e fix: tag regexp (#173) 2022-08-25 19:24:21 +08:00
Steven
ca1170583e chore: update i18n 2022-08-24 22:27:19 +08:00
Steven
f5629c8227 fix: overflow style 2022-08-24 22:14:46 +08:00
Steven
2f58032ad8 fix: text overflow style 2022-08-24 22:11:56 +08:00
boojack
466bfe4b49 chore: update golangci-lint config (#168)
chore: update ci lint
2022-08-24 22:03:07 +08:00
Steven
7d0407013e chore: make golangci-lint happy 2022-08-24 21:53:12 +08:00
Steven
0e4e2e4bc5 chore: add golangci-lint action 2022-08-24 21:12:08 +08:00
Steven
a8a3cf31b4 chore: make golangci-lint happy 2022-08-24 20:40:56 +08:00
boojack
f784516470 chore: update word-break style (#164) 2022-08-23 23:23:20 +08:00
Steven
2aed7c70aa chore: update cancel editing button 2022-08-23 21:33:53 +08:00
Lim Ding Wen
b1062f5387 fix: Fix Typo (Editting -> Editing) (#161) 2022-08-23 07:50:57 +08:00
Steven
29c835d27a chore: update readme 2022-08-22 20:52:54 +08:00
Steven
0698c9c853 chore: set default flags in dockerfile 2022-08-22 20:52:30 +08:00
boojack
e54ff5ec9e fix: unescape filename (#158) 2022-08-22 20:26:47 +08:00
Steven
f0a23f4620 fix: generate uuid when creating new user 2022-08-21 19:10:02 +08:00
Steven
c60bb12424 chore: update user setting validator 2022-08-20 21:51:28 +08:00
Steven
3b1bb4a95d chore: disable setting memo visibility when creating 2022-08-20 21:22:36 +08:00
Steven
05a5c59a7e chore: update user create validator 2022-08-20 21:03:15 +08:00
Steven
734d5f3aed chore: update create tag tip style 2022-08-20 11:48:56 +08:00
Steven
2cf07753e7 chore: update version 0.4.0 2022-08-20 11:37:19 +08:00
Steven
2935057ca7 chore: update i18n 2022-08-20 11:36:33 +08:00
Steven
68b30063a9 chore: update prod schema 2022-08-20 11:36:24 +08:00
Steven
f06a3d171b chore: update error message handler 2022-08-20 07:34:39 +08:00
Steven
a98e64cf0a chore: update i18n dialog 2022-08-19 22:12:24 +08:00
Steven
dd04bc9e1d chore: add beta badge 2022-08-19 22:08:47 +08:00
Steven
2f33eceada chore: set default memo visibility 2022-08-19 21:59:50 +08:00
Steven
d5b88775d9 chore: update user setting keys 2022-08-19 21:56:22 +08:00
Steven
a7a01df79a chore: update i18n 2022-08-19 21:30:31 +08:00
Steven
e3fac742c5 chore: update logger 2022-08-19 00:45:02 +08:00
Steven
ce390f3f79 chore: add memo visibility with user setting 2022-08-19 00:00:47 +08:00
Steven
43a8b7d0e1 chore: fix lint 2022-08-14 18:44:20 +08:00
Steven
b596d04939 chore: update i18n for auth page 2022-08-14 18:43:46 +08:00
Steven
84a3548232 chore: update scripts 2022-08-14 17:55:36 +08:00
Steven
63388a7d97 chore: update .gitignore 2022-08-14 16:44:00 +08:00
Steven
35f980d2b8 chore: go mod tidy 2022-08-14 16:35:09 +08:00
boojack
90b881502d feat: add user_setting model (#145)
* feat: add `user_setting` model

* chore: add global store

* chore: update settings in web

* chore: update `i18n` example
2022-08-13 14:35:33 +08:00
XQ
dfac877957 feat: add manifest.json for pwa (#144)
* chore: update `i18nStore`

* feat: add pwa `manifest.json`
2022-08-12 21:30:59 +08:00
leopold
87f5ac8b71 chore: add docker-compose.yaml (#142)
Adding docker-compose.yaml
2022-08-11 20:15:46 +08:00
XQ
3c1a416afc chore: update i18nStore (#141) 2022-08-09 21:57:56 +08:00
XQ
972a49d6aa chore: code clean (#140) 2022-08-09 21:36:48 +08:00
XQ
cea16fac88 chore: remove useAppContext in dialog props 2022-08-08 23:33:53 +08:00
boojack
7e994c8f11 chore: remove test action 2022-08-08 23:32:56 +08:00
boojack
646a41e931 chore: add i18n based with useContext 2022-08-07 22:48:22 +08:00
boojack
735938395b chore: use transaction for migration history 2022-08-07 10:17:56 +08:00
boojack
d8e10ba399 chore: use tx for stores 2022-08-07 10:17:12 +08:00
boojack
8c28721839 chore: use tx for user store 2022-08-07 09:23:46 +08:00
boojack
da333b0b1e chore: add store cache service 2022-08-07 08:09:43 +08:00
boojack
44d07ac401 chore: hide resource blob field 2022-08-07 07:07:04 +08:00
boojack
d08ba56c28 chore: release v0.3.1 (#138) 2022-08-07 02:20:39 +08:00
boojack
c991a48df6 chore: add upload resource button 2022-08-07 01:56:10 +08:00
boojack
fd44255668 chore: use dropdown in member section 2022-08-07 01:35:20 +08:00
boojack
84564891be feat: add view resource dialog 2022-08-07 01:30:48 +08:00
boojack
8c8bb9e59f chore: update search bar styles 2022-07-31 09:10:30 +08:00
boojack
99df4acfe9 chore: use cors middleware 2022-07-30 14:52:37 +08:00
boojack
47ffd99c3b chore: fix typo 2022-07-30 07:47:18 +08:00
boojack
c703f281d9 chore: update feather icon 2022-07-30 00:29:20 +08:00
boojack
e179c65e52 chore: fix typo 2022-07-29 21:41:56 +08:00
boojack
29da70be56 chore: update readme 2022-07-29 21:39:52 +08:00
boojack
a9dce26099 chore: remove build dev image 2022-07-29 21:26:08 +08:00
boojack
2c27f5d425 chore: release v0.3.0 (#136) 2022-07-29 21:09:48 +08:00
223 changed files with 9602 additions and 3269 deletions

1
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1 @@
ko_fi: stevenlgtm

38
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View File

@@ -0,0 +1,38 @@
name: Bug Report
description: Create a report to help us improve
labels: [bug]
body:
- type: markdown
attributes:
value: |
If you are reporting a new issue, make sure that we do not have any duplicates already open. You can ensure this by searching the issue list for this repository. If there is a duplicate, please close your issue and add a comment to the existing issue instead.
- type: textarea
attributes:
label: Describe the bug
description: |
Briefly describe the problem you are having in a few paragraphs.
validations:
required: true
- type: textarea
attributes:
label: Steps to reproduce
description: |
Provide the steps to reproduce the issue.
placeholder: |
1. Go to '...'
3. Click on '....'
4. See error
validations:
required: true
- type: textarea
attributes:
label: Expected behavior
description: |
Describe what you expected to happen.
validations:
required: true
- type: textarea
attributes:
label: Screenshots or additional context
description: |
Add screenshots or any other context about the problem here.

View File

@@ -0,0 +1,28 @@
name: Feature Request
description: Suggest an idea for this project
labels: [enhancement]
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to suggest an idea for Memos!
- type: textarea
attributes:
label: Is your feature request related to a problem?
description: |
A clear and concise description of what the problem is.
placeholder: |
I'm always frustrated when [...]
validations:
required: true
- type: textarea
attributes:
label: Describe the solution you'd like
description: |
A clear and concise description of what you want to happen.
validations:
required: true
- type: textarea
attributes:
label: Additional context
description: Add any other context or screenshots about the feature request here.

View File

@@ -1,37 +0,0 @@
name: build-and-push-dev-image
on:
push:
branches:
- "main"
jobs:
build-and-push-dev-image:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Login to Docker Hub
uses: docker/login-action@v2
with:
username: neosmemo
password: ${{ secrets.DOCKER_NEOSMEMO_TOKEN }}
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v2
with:
install: true
- name: Build and Push
id: docker_build
uses: docker/build-push-action@v3
with:
context: ./
file: ./Dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: neosmemo/memos:dev

View File

@@ -4,7 +4,7 @@ on:
push:
branches:
# Run on pushing branches like `release/1.0.0`
- "release/v*.*.*"
- "release/*.*.*"
jobs:
build-and-push-release-image:
@@ -17,9 +17,9 @@ jobs:
- name: Extract build args
# Extract version from branch name
# Example: branch name `release/v1.0.0` sets up env.VERSION=1.0.0
# Example: branch name `release/1.0.0` sets up env.VERSION=1.0.0
run: |
echo "VERSION=${GITHUB_REF_NAME#release/v}" >> $GITHUB_ENV
echo "VERSION=${GITHUB_REF_NAME#release/}" >> $GITHUB_ENV
- name: Login to Docker Hub
uses: docker/login-action@v2

View File

@@ -1,37 +0,0 @@
name: build-and-push-test-image
on:
push:
branches:
- "test/*"
jobs:
build-and-push-dev-image:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Login to Docker Hub
uses: docker/login-action@v2
with:
username: neosmemo
password: ${{ secrets.DOCKER_NEOSMEMO_TOKEN }}
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v2
with:
install: true
- name: Build and Push
id: docker_build
uses: docker/build-push-action@v3
with:
context: ./
file: ./Dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: neosmemo/memos:test

View File

@@ -13,12 +13,12 @@ name: "CodeQL"
on:
push:
branches: [ main ]
branches: [main]
pull_request:
# The branches below must be a subset of the branches above
branches: [ main ]
branches: [main]
schedule:
- cron: '27 12 * * 0'
- cron: "27 12 * * 0"
jobs:
analyze:
@@ -32,39 +32,39 @@ jobs:
strategy:
fail-fast: false
matrix:
language: [ 'go', 'javascript' ]
language: ["go", "javascript"]
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
# Learn more about CodeQL language support at https://git.io/codeql-language-support
steps:
- name: Checkout repository
uses: actions/checkout@v2
- name: Checkout repository
uses: actions/checkout@v2
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v1
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# queries: ./path/to/local/query, your-org/your-repo/queries@main
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v1
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# queries: ./path/to/local/query, your-org/your-repo/queries@main
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v1
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v1
# Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
# Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
# and modify them (or add more) to build your code if your project
# uses a compiled language
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
# and modify them (or add more) to build your code if your project
# uses a compiled language
#- run: |
# make bootstrap
# make release
#- run: |
# make bootstrap
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v1
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v1

88
.github/workflows/tests.yml vendored Normal file
View File

@@ -0,0 +1,88 @@
name: Test
on:
push:
branches:
- main
- "release/v*.*.*"
pull_request:
branches: [main]
jobs:
go-static-checks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v3
with:
go-version: 1.18
check-latest: true
cache: true
- name: Verify go.mod is tidy
run: |
go mod tidy
git diff --exit-code
- name: golangci-lint
uses: golangci/golangci-lint-action@v3
with:
args: -v
skip-cache: true
eslint-checks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: "16"
cache: yarn
cache-dependency-path: "web/yarn.lock"
- run: yarn
working-directory: web
- name: Run eslint check
run: yarn lint
working-directory: web
jest-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: "16"
cache: yarn
cache-dependency-path: "web/yarn.lock"
- run: yarn
working-directory: web
- name: Run jest
run: yarn test
working-directory: web
frontend-build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: "16"
cache: yarn
cache-dependency-path: "web/yarn.lock"
- run: yarn
working-directory: web
- name: Run frontend build
run: yarn build
working-directory: web
go-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v3
with:
go-version: 1.18
check-latest: true
cache: true
- name: Run all tests
run: go test -v ./... | tee test.log; exit ${PIPESTATUS[0]}
- name: Pretty print tests running time
run: grep --color=never -e '--- PASS:' -e '--- FAIL:' test.log | sed 's/[:()]//g' | awk '{print $2,$3,$4}' | sort -t' ' -nk3 -r | awk '{sum += $3; print $1,$2,$3,sum"s"}'

10
.gitignore vendored
View File

@@ -1,10 +1,16 @@
# Air (hot reload) generated
.air
# temp folder
tmp
# Frontend asset
web/dist
# build folder
memos-build
build
.DS_Store
.DS_Store
# Jetbrains
.idea

64
.golangci.yaml Normal file
View File

@@ -0,0 +1,64 @@
linters:
enable:
- goimports
- revive
- govet
- staticcheck
- misspell
- gocritic
- sqlclosecheck
- rowserrcheck
- nilerr
- godot
issues:
exclude:
- Rollback
- fmt.Printf
- fmt.Print
linters-settings:
revive:
enable-all-rules: true
rules:
- name: file-header
disabled: true
- name: line-length-limit
disabled: true
- name: function-length
disabled: true
- name: max-public-structs
disabled: true
- name: function-result-limit
disabled: true
- name: banned-characters
disabled: true
- name: argument-limit
disabled: true
- name: cognitive-complexity
disabled: true
- name: cyclomatic
disabled: true
- name: confusing-results
disabled: true
- name: add-constant
disabled: true
- name: flag-parameter
disabled: true
- name: nested-structs
disabled: true
- name: import-shadowing
disabled: true
- name: early-return
disabled: true
gocritic:
disabled-checks:
- ifElseChain
govet:
settings:
printf:
funcs:
- common.Errorf
forbidigo:
forbid:
- 'fmt\.Errorf(# Please use errors\.Wrap\|Wrapf\|Errorf instead)?'

4
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,4 @@
{
"go.lintOnSave": "workspace",
"go.lintTool": "golangci-lint"
}

View File

@@ -17,9 +17,7 @@ RUN apk --no-cache add gcc musl-dev
COPY . .
COPY --from=frontend /frontend-build/dist ./server/dist
RUN go build \
-o memos \
./bin/server/main.go
RUN go build -o memos ./bin/server/main.go
# Make workspace with above generated files.
FROM alpine:3.16.0 AS monolithic
@@ -30,4 +28,4 @@ COPY --from=backend /backend-build/memos /usr/local/memos/
# Directory to store the data, which can be referenced as the mounting point.
RUN mkdir -p /var/opt/memos
ENTRYPOINT ["./memos"]
ENTRYPOINT ["./memos", "--mode", "prod", "--port", "5230"]

View File

@@ -1,9 +1,9 @@
<h1 align="center">✍️ Memos</h1>
<p align="center"><a href="https://usememos.com"><img height="64px" src="https://raw.githubusercontent.com/usememos/memos/main/resources/logo-full.webp" alt="✍️ memos" /></a></p>
<p align="center">An open source, self-hosted knowledge base that works with a SQLite db file.</p>
<p align="center">
<img alt="GitHub stars" src="https://img.shields.io/github/stars/usememos/memos" />
<a href="https://github.com/usememos/memos/stargazers"><img alt="GitHub stars" src="https://img.shields.io/github/stars/usememos/memos" /></a>
<a href="https://hub.docker.com/r/neosmemo/memos"><img alt="Docker pull" src="https://img.shields.io/docker/pulls/neosmemo/memos.svg" /></a>
<img alt="Go report" src="https://goreportcard.com/badge/github.com/usememos/memos" />
</p>
@@ -15,7 +15,7 @@
![demo](https://raw.githubusercontent.com/usememos/memos/main/resources/demo.webp)
## Features
## Features
- 🦄 Fully open source;
- 📜 Writing in plain textarea without any burden,
@@ -24,67 +24,32 @@
- 🚀 Fast self-hosting with `Docker`;
- 🤠 Pleasant UI and UX;
## ⚓️ Deploy with Docker
## Deploy with Docker
### Docker Run
```docker
docker run \
--name memos \
--publish 5230:5230 \
--volume ~/.memos/:/var/opt/memos \
neosmemo/memos:latest \
--mode prod \
--port 5230
docker run -d --name memos -p 5230:5230 -v ~/.memos/:/var/opt/memos neosmemo/memos:latest
```
Memos should now be running at [http://localhost:5230](http://localhost:5230). If the `~/.memos/` does not have a `memos_prod.db` file, then `memos` will auto generate it.
Memos should be running at [http://localhost:5230](http://localhost:5230). If the `~/.memos/` does not have a `memos_prod.db` file, then memos will auto generate it.
⚠️ Please DO NOT use `dev` tag of docker image if you have no experience.
### Docker Compose
## 🏗 Development
See more in the example [`docker-compose.yaml`](./docker-compose.yaml) file.
Memos is built with a curated tech stack. It is optimized for developer experience and is very easy to start working on the code:
If you want to upgrade the version of memos, use the following command.
1. It has no external dependency.
2. It requires zero config.
3. 1 command to start backend and 1 command to start frontend, both with live reload support.
```sh
docker-compose down && docker image rm neosmemo/memos:latest && docker-compose up -d
```
### Tech Stack
<img alt="tech stack" src="https://raw.githubusercontent.com/usememos/memos/main/resources/tech-stack.png" width="360" />
### Prerequisites
- [Go](https://golang.org/doc/install)
- [Air](https://github.com/cosmtrek/air#installation) for backend live reload
- [Node.js](https://nodejs.org/)
- [yarn](https://yarnpkg.com/getting-started/install)
### Steps
1. pull source code
```bash
git clone https://github.com/usememos/memos
```
2. start backend using air(with live reload)
```bash
air -c scripts/.air.toml
```
3. start frontend dev server
```bash
cd web && yarn && yarn dev
```
Memos should now be running at [http://localhost:3000](http://localhost:3000) and change either frontend or backend code would trigger live reload.
### Contributing
## Contributing
Contributions are what make the open source community such an amazing place to be learn, inspire, and create. Any contributions you make are greatly appreciated. 🥰
## 🌟 Star history
Gets more about [development guide](https://github.com/usememos/memos/tree/main/docs/development.md).
## Star history
[![Star History Chart](https://api.star-history.com/svg?repos=usememos/memos&type=Date)](https://star-history.com/#usememos/memos&Date)

View File

@@ -1,9 +1,5 @@
package api
var (
UNKNOWN_ID = 0
)
type Signin struct {
Email string `json:"email"`
Password string `json:"password"`

22
api/cache.go Normal file
View File

@@ -0,0 +1,22 @@
package api
// CacheNamespace is the type of a cache.
type CacheNamespace string
const (
// UserCache is the cache type of users.
UserCache CacheNamespace = "u"
// MemoCache is the cache type of memos.
MemoCache CacheNamespace = "m"
// ShortcutCache is the cache type of shortcuts.
ShortcutCache CacheNamespace = "s"
// ResourceCache is the cache type of resources.
ResourceCache CacheNamespace = "r"
)
// CacheService is the service for caches.
type CacheService interface {
FindCache(namespace CacheNamespace, id int, entry interface{}) (bool, error)
UpsertCache(namespace CacheNamespace, id int, entry interface{}) error
DeleteCache(namespace CacheNamespace, id int)
}

View File

@@ -37,28 +37,39 @@ type Memo struct {
Content string `json:"content"`
Visibility Visibility `json:"visibility"`
Pinned bool `json:"pinned"`
DisplayTs int64 `json:"displayTs"`
// Related fields
Creator *User `json:"creator"`
ResourceList []*Resource `json:"resourceList"`
}
type MemoCreate struct {
// Standard fields
CreatorID int
// Used to import memos with a clearly created ts.
CreatedTs *int64 `json:"createdTs"`
// Domain specific fields
Content string `json:"content"`
Visibility *Visibility `json:"visibility"`
Visibility Visibility `json:"visibility"`
Content string `json:"content"`
// Related fields
ResourceIDList []int `json:"resourceIdList"`
}
type MemoPatch struct {
ID int
// Standard fields
CreatedTs *int64 `json:"createdTs"`
UpdatedTs *int64
RowStatus *RowStatus `json:"rowStatus"`
// Domain specific fields
Content *string `json:"content"`
Visibility *Visibility `json:"visibility"`
// Related fields
ResourceIDList []int `json:"resourceIdList"`
}
type MemoFind struct {

24
api/memo_resource.go Normal file
View File

@@ -0,0 +1,24 @@
package api
type MemoResource struct {
MemoID int
ResourceID int
CreatedTs int64
UpdatedTs int64
}
type MemoResourceUpsert struct {
MemoID int
ResourceID int
UpdatedTs *int64
}
type MemoResourceFind struct {
MemoID *int
ResourceID *int
}
type MemoResourceDelete struct {
MemoID int
ResourceID *int
}

View File

@@ -10,9 +10,12 @@ type Resource struct {
// Domain specific fields
Filename string `json:"filename"`
Blob []byte `json:"blob"`
Blob []byte `json:"-"`
Type string `json:"type"`
Size int64 `json:"size"`
// Related fields
LinkedMemoAmount int `json:"linkedMemoAmount"`
}
type ResourceCreate struct {
@@ -34,8 +37,12 @@ type ResourceFind struct {
// Domain specific fields
Filename *string `json:"filename"`
MemoID *int
}
type ResourceDelete struct {
ID int
// Standard fields
CreatorID int
}

View File

@@ -1,5 +1,11 @@
package api
import (
"fmt"
"github.com/usememos/memos/common"
)
// Role is the type of a role.
type Role string
@@ -29,11 +35,12 @@ type User struct {
UpdatedTs int64 `json:"updatedTs"`
// Domain specific fields
Email string `json:"email"`
Role Role `json:"role"`
Name string `json:"name"`
PasswordHash string `json:"-"`
OpenID string `json:"openId"`
Email string `json:"email"`
Role Role `json:"role"`
Name string `json:"name"`
PasswordHash string `json:"-"`
OpenID string `json:"openId"`
UserSettingList []*UserSetting `json:"userSettingList"`
}
type UserCreate struct {
@@ -46,6 +53,20 @@ type UserCreate struct {
OpenID string
}
func (create UserCreate) Validate() error {
if !common.ValidateEmail(create.Email) {
return fmt.Errorf("invalid email format")
}
if len(create.Email) < 6 {
return fmt.Errorf("email is too short, minimum length is 6")
}
if len(create.Password) < 6 {
return fmt.Errorf("password is too short, minimum length is 6")
}
return nil
}
type UserPatch struct {
ID int

158
api/user_setting.go Normal file
View File

@@ -0,0 +1,158 @@
package api
import (
"encoding/json"
"fmt"
)
type UserSettingKey string
const (
// UserSettingLocaleKey is the key type for user locale.
UserSettingLocaleKey UserSettingKey = "locale"
// UserSettingMemoVisibilityKey is the key type for user preference memo default visibility.
UserSettingMemoVisibilityKey UserSettingKey = "memoVisibility"
// UserSettingEditorFontStyleKey is the key type for editor font style.
UserSettingEditorFontStyleKey UserSettingKey = "editorFontStyle"
// UserSettingEditorFontStyleKey is the key type for mobile editor style.
UserSettingMobileEditorStyleKey UserSettingKey = "mobileEditorStyle"
// UserSettingMemoDisplayTsOptionKey is the key type for memo display ts option.
UserSettingMemoDisplayTsOptionKey UserSettingKey = "memoDisplayTsOption"
)
// String returns the string format of UserSettingKey type.
func (key UserSettingKey) String() string {
switch key {
case UserSettingLocaleKey:
return "locale"
case UserSettingMemoVisibilityKey:
return "memoVisibility"
case UserSettingEditorFontStyleKey:
return "editorFontFamily"
case UserSettingMobileEditorStyleKey:
return "mobileEditorStyle"
case UserSettingMemoDisplayTsOptionKey:
return "memoDisplayTsOption"
}
return ""
}
var (
UserSettingLocaleValue = []string{"en", "zh", "vi"}
UserSettingMemoVisibilityValue = []Visibility{Privite, Protected, Public}
UserSettingEditorFontStyleValue = []string{"normal", "mono"}
UserSettingMobileEditorStyleValue = []string{"normal", "float"}
UserSettingMemoDisplayTsOptionKeyValue = []string{"created_ts", "updated_ts"}
)
type UserSetting struct {
UserID int
Key UserSettingKey `json:"key"`
// Value is a JSON string with basic value
Value string `json:"value"`
}
type UserSettingUpsert struct {
UserID int
Key UserSettingKey `json:"key"`
Value string `json:"value"`
}
func (upsert UserSettingUpsert) Validate() error {
if upsert.Key == UserSettingLocaleKey {
localeValue := "en"
err := json.Unmarshal([]byte(upsert.Value), &localeValue)
if err != nil {
return fmt.Errorf("failed to unmarshal user setting locale value")
}
invalid := true
for _, value := range UserSettingLocaleValue {
if localeValue == value {
invalid = false
break
}
}
if invalid {
return fmt.Errorf("invalid user setting locale value")
}
} else if upsert.Key == UserSettingMemoVisibilityKey {
memoVisibilityValue := Privite
err := json.Unmarshal([]byte(upsert.Value), &memoVisibilityValue)
if err != nil {
return fmt.Errorf("failed to unmarshal user setting memo visibility value")
}
invalid := true
for _, value := range UserSettingMemoVisibilityValue {
if memoVisibilityValue == value {
invalid = false
break
}
}
if invalid {
return fmt.Errorf("invalid user setting memo visibility value")
}
} else if upsert.Key == UserSettingEditorFontStyleKey {
editorFontStyleValue := "normal"
err := json.Unmarshal([]byte(upsert.Value), &editorFontStyleValue)
if err != nil {
return fmt.Errorf("failed to unmarshal user setting editor font style")
}
invalid := true
for _, value := range UserSettingEditorFontStyleValue {
if editorFontStyleValue == value {
invalid = false
break
}
}
if invalid {
return fmt.Errorf("invalid user setting editor font style value")
}
} else if upsert.Key == UserSettingMobileEditorStyleKey {
mobileEditorStyleValue := "normal"
err := json.Unmarshal([]byte(upsert.Value), &mobileEditorStyleValue)
if err != nil {
return fmt.Errorf("failed to unmarshal user setting mobile editor style")
}
invalid := true
for _, value := range UserSettingMobileEditorStyleValue {
if mobileEditorStyleValue == value {
invalid = false
break
}
}
if invalid {
return fmt.Errorf("invalid user setting mobile editor style value")
}
} else if upsert.Key == UserSettingMemoDisplayTsOptionKey {
memoDisplayTsOption := "created_ts"
err := json.Unmarshal([]byte(upsert.Value), &memoDisplayTsOption)
if err != nil {
return fmt.Errorf("failed to unmarshal user setting memo display ts option")
}
invalid := true
for _, value := range UserSettingMemoDisplayTsOptionKeyValue {
if memoDisplayTsOption == value {
invalid = false
break
}
}
if invalid {
return fmt.Errorf("invalid user setting memo display ts option value")
}
} else {
return fmt.Errorf("invalid user setting key")
}
return nil
}
type UserSettingFind struct {
UserID int
Key *UserSettingKey `json:"key"`
}

View File

@@ -1,63 +0,0 @@
package cmd
import (
"fmt"
"os"
"github.com/usememos/memos/server"
"github.com/usememos/memos/server/profile"
"github.com/usememos/memos/store"
DB "github.com/usememos/memos/store/db"
)
const (
greetingBanner = `
███╗ ███╗███████╗███╗ ███╗ ██████╗ ███████╗
████╗ ████║██╔════╝████╗ ████║██╔═══██╗██╔════╝
██╔████╔██║█████╗ ██╔████╔██║██║ ██║███████╗
██║╚██╔╝██║██╔══╝ ██║╚██╔╝██║██║ ██║╚════██║
██║ ╚═╝ ██║███████╗██║ ╚═╝ ██║╚██████╔╝███████║
╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝ ╚═════╝ ╚══════╝
`
)
type Main struct {
profile *profile.Profile
}
func (m *Main) Run() error {
db := DB.NewDB(m.profile)
if err := db.Open(); err != nil {
return fmt.Errorf("cannot open db: %w", err)
}
s := server.NewServer(m.profile)
storeInstance := store.New(db.Db, m.profile)
s.Store = storeInstance
println(greetingBanner)
fmt.Printf("Version %s has started at :%d\n", m.profile.Version, m.profile.Port)
return s.Run()
}
func Execute() {
profile := profile.GetProfile()
m := Main{
profile: profile,
}
println("---")
println("profile")
println("mode:", profile.Mode)
println("port:", profile.Port)
println("dsn:", profile.DSN)
println("version:", profile.Version)
println("---")
if err := m.Run(); err != nil {
fmt.Printf("error: %+v\n", err)
os.Exit(1)
}
}

View File

@@ -1,7 +1,74 @@
package main
import "github.com/usememos/memos/bin/server/cmd"
import (
"os"
_ "github.com/mattn/go-sqlite3"
"context"
"fmt"
"github.com/usememos/memos/server"
"github.com/usememos/memos/server/profile"
"github.com/usememos/memos/store"
DB "github.com/usememos/memos/store/db"
)
const (
greetingBanner = `
███╗ ███╗███████╗███╗ ███╗ ██████╗ ███████╗
████╗ ████║██╔════╝████╗ ████║██╔═══██╗██╔════╝
██╔████╔██║█████╗ ██╔████╔██║██║ ██║███████╗
██║╚██╔╝██║██╔══╝ ██║╚██╔╝██║██║ ██║╚════██║
██║ ╚═╝ ██║███████╗██║ ╚═╝ ██║╚██████╔╝███████║
╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝ ╚═════╝ ╚══════╝
`
)
func run(profile *profile.Profile) error {
ctx := context.Background()
db := DB.NewDB(profile)
if err := db.Open(ctx); err != nil {
return fmt.Errorf("cannot open db: %w", err)
}
s := server.NewServer(profile)
storeInstance := store.New(db.Db, profile)
s.Store = storeInstance
println(greetingBanner)
fmt.Printf("Version %s has started at :%d\n", profile.Version, profile.Port)
return s.Run()
}
func execute() error {
profile, err := profile.GetProfile()
if err != nil {
return err
}
println("---")
println("profile")
println("mode:", profile.Mode)
println("port:", profile.Port)
println("dsn:", profile.DSN)
println("version:", profile.Version)
println("---")
if err := run(profile); err != nil {
fmt.Printf("error: %+v\n", err)
return err
}
return nil
}
func main() {
cmd.Execute()
if err := execute(); err != nil {
os.Exit(1)
}
}

View File

@@ -9,7 +9,7 @@ type Code int
// Application error codes.
const (
// 0 ~ 99 general error
// 0 ~ 99 general error.
Ok Code = 0
Internal Code = 1
NotAuthorized Code = 2
@@ -17,11 +17,6 @@ const (
NotFound Code = 4
Conflict Code = 5
NotImplemented Code = 6
// 101 ~ 199 db error
DbConnectionFailure Code = 101
DbStatementSyntaxError Code = 102
DbExecutionError Code = 103
)
// Error represents an application-specific error. Application errors can be

View File

@@ -1,6 +1,7 @@
package common
import (
"net/mail"
"strings"
"github.com/google/uuid"
@@ -16,6 +17,14 @@ func HasPrefixes(src string, prefixes ...string) bool {
return false
}
// ValidateEmail validates the email.
func ValidateEmail(email string) bool {
if _, err := mail.ParseAddress(email); err != nil {
return false
}
return true
}
func GenUUID() string {
return uuid.New().String()
}

31
common/util_test.go Normal file
View File

@@ -0,0 +1,31 @@
package common
import (
"testing"
)
func TestValidateEmail(t *testing.T) {
tests := []struct {
email string
want bool
}{
{
email: "t@gmail.com",
want: true,
},
{
email: "@qq.com",
want: false,
},
{
email: "1@gmail",
want: true,
},
}
for _, test := range tests {
result := ValidateEmail(test.email)
if result != test.want {
t.Errorf("Validate Email %s: got result %v, want %v.", test.email, result, test.want)
}
}
}

9
docker-compose.yaml Normal file
View File

@@ -0,0 +1,9 @@
version: "3.0"
services:
memos:
image: neosmemo/memos:latest
container_name: memos
volumes:
- ~/.memos/:/var/opt/memos
ports:
- 5230:5230

40
docs/development.md Normal file
View File

@@ -0,0 +1,40 @@
# Development
Memos is built with a curated tech stack. It is optimized for developer experience and is very easy to start working on the code:
1. It has no external dependency.
2. It requires zero config.
3. 1 command to start backend and 1 command to start frontend, both with live reload support.
## Tech Stack
![tech-stack](https://raw.githubusercontent.com/usememos/memos/main/resources/tech-stack.png)
## Prerequisites
- [Go](https://golang.org/doc/install)
- [Air](https://github.com/cosmtrek/air#installation) for backend live reload
- [Node.js](https://nodejs.org/)
- [yarn](https://yarnpkg.com/getting-started/install)
## Steps
1. pull source code
```bash
git clone https://github.com/usememos/memos
```
2. start backend using air(with live reload)
```bash
air -c scripts/.air.toml
```
3. start frontend dev server
```bash
cd web && yarn && yarn dev
```
Memos should now be running at [http://localhost:3000](http://localhost:3000) and change either frontend or backend code would trigger live reload.

20
go.mod
View File

@@ -8,25 +8,31 @@ require github.com/google/uuid v1.3.0
require (
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
github.com/mattn/go-colorable v0.1.11 // indirect
github.com/mattn/go-colorable v0.1.12 // indirect
github.com/mattn/go-isatty v0.0.14 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.1 // indirect
golang.org/x/crypto v0.0.0-20210920023735-84f357641f63
golang.org/x/net v0.0.0-20210917221730-978cfadd31cf // indirect
golang.org/x/sys v0.0.0-20211103235746-7861aae1554b // indirect
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa
golang.org/x/net v0.0.0-20220728030405-41545e8bf201 // indirect
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10 // indirect
golang.org/x/text v0.3.7 // indirect
golang.org/x/time v0.0.0-20201208040808-7e3f01d25324 // indirect
golang.org/x/time v0.0.0-20220722155302-e5dcc9cfc0b9 // indirect
)
require (
github.com/gorilla/context v1.1.1 // indirect
github.com/labstack/echo/v4 v4.6.3
github.com/labstack/echo/v4 v4.9.0
github.com/labstack/gommon v0.3.1 // indirect
)
require (
github.com/VictoriaMetrics/fastcache v1.10.0
github.com/gorilla/securecookie v1.1.1
github.com/gorilla/sessions v1.2.1
github.com/labstack/echo-contrib v0.12.0
github.com/labstack/echo-contrib v0.13.0
)
require (
github.com/cespare/xxhash/v2 v2.1.2 // indirect
github.com/golang/snappy v0.0.4 // indirect
)

78
go.sum
View File

@@ -37,27 +37,37 @@ github.com/HdrHistogram/hdrhistogram-go v1.1.2/go.mod h1:yDgFjdqOqDEKOvasDdhWNXY
github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible/go.mod h1:r7JcOSlj0wfOMncg0iLm8Leh48TZaKVeNIfJntJ2wa0=
github.com/Shopify/sarama v1.30.0/go.mod h1:zujlQQx1kzHsh4jfV1USnptCQrHAEZ2Hk8fTKCulPVs=
github.com/Shopify/toxiproxy/v2 v2.1.6-0.20210914104332-15ea381dcdae/go.mod h1:/cvHQkZ1fst0EmZnA5dFtiQdWCNCFYzb+uE2vqVgvx0=
github.com/VictoriaMetrics/fastcache v1.10.0 h1:5hDJnLsKLpnUEToub7ETuRu8RCkb40woBZAUiKonXzY=
github.com/VictoriaMetrics/fastcache v1.10.0/go.mod h1:tjiYeEfYXCqacuvYw/7UoDIeJaNxq6132xHICNP77w8=
github.com/ajstarks/svgo v0.0.0-20180226025133-644b8db467af/go.mod h1:K08gAheRH3/J6wwsYMMT4xOr94bZjxIelGM0+d/wbFw=
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=
github.com/allegro/bigcache v1.2.1-0.20190218064605-e24eb225f156 h1:eMwmnE/GDgah4HI848JfFxHt+iPb26b4zyfspmqY0/8=
github.com/allegro/bigcache v1.2.1-0.20190218064605-e24eb225f156/go.mod h1:Cb/ax3seSYIx7SuZdm2G2xzfwmv3TPSk2ucNfQESPXM=
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
github.com/appleboy/gofight/v2 v2.1.2/go.mod h1:frW+U1QZEdDgixycTj4CygQ48yLTUhplt43+Wczp3rw=
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/casbin/casbin/v2 v2.40.6/go.mod h1:sEL80qBYTbd+BPeL4iyvwYzFT3qwLaESq5aFKVLbLfA=
github.com/casbin/casbin/v2 v2.51.1/go.mod h1:vByNa/Fchek0KZUgG5wEsl7iFsiviAYKRtgrQfcJqHg=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE=
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4/go.mod h1:6pvJx4me5XPnfI9Z40ddWsdw2W/uZgQLFXToKeRcDiI=
github.com/cncf/xds/go v0.0.0-20210805033703-aa0b78936158/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
github.com/cncf/xds/go v0.0.0-20211001041855-01bcc9b48dfe/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -71,6 +81,7 @@ github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.m
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go.mod h1:AFq3mo9L8Lqqiid3OhADV3RfLJnjiw63cSpi+fDTRC0=
github.com/envoyproxy/go-control-plane v0.10.2-0.20220325020618-49ff273808a1/go.mod h1:KJwIaB5Mv44NWtYuAOFCVOjcI94vtpEz2JU/D2v6IjE=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/fogleman/gg v1.2.1-0.20190220221249-0403632d5b90/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k=
github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g=
@@ -84,9 +95,11 @@ github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
github.com/go-kit/log v0.2.0/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0=
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
github.com/go-logfmt/logfmt v0.5.1/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
@@ -120,6 +133,7 @@ github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
@@ -132,6 +146,8 @@ github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
@@ -171,6 +187,7 @@ github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
@@ -186,21 +203,16 @@ github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfn
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/labstack/echo-contrib v0.12.0 h1:NPr1ez+XUa5s/4LujEon+32Bxg5DO6EKSW/va06pmLc=
github.com/labstack/echo-contrib v0.12.0/go.mod h1:kR62TbwsBgmpV2HVab5iQRsQtLuhPyGqCBee88XRc4M=
github.com/labstack/echo/v4 v4.6.1/go.mod h1:RnjgMWNDB9g/HucVWhQYNQP9PvbYf6adqftqryo7s9k=
github.com/labstack/echo/v4 v4.6.3 h1:VhPuIZYxsbPmo4m9KAkMU/el2442eB7EBFFhNTTT9ac=
github.com/labstack/echo/v4 v4.6.3/go.mod h1:Hk5OiHj0kDqmFq7aHe7eDqI7CUhuCrfpupQtLGGLm7A=
github.com/labstack/gommon v0.3.0/go.mod h1:MULnywXg0yavhxWKc+lOruYdAhDwPK9wf0OL7NoOu+k=
github.com/labstack/echo-contrib v0.13.0 h1:bzSG0SpuZZd7BmJLvsWtPfU23W0Enh3K0tok3aENVKA=
github.com/labstack/echo-contrib v0.13.0/go.mod h1:IF9+MJu22ADOZEHD+bAV67XMIO3vNXUy7Naz/ABPHEs=
github.com/labstack/echo/v4 v4.7.2/go.mod h1:xkCDAdFCIf8jsFQ5NnbK7oqaF/yU1A1X20Ltm0OvSks=
github.com/labstack/echo/v4 v4.9.0 h1:wPOF1CE6gvt/kmbMR4dGzWvHMPT+sAEUJOwOTtvITVY=
github.com/labstack/echo/v4 v4.9.0/go.mod h1:xkCDAdFCIf8jsFQ5NnbK7oqaF/yU1A1X20Ltm0OvSks=
github.com/labstack/gommon v0.3.1 h1:OomWaJXm7xR6L1HmEtGyQf26TEn7V6X88mktX9kee9o=
github.com/labstack/gommon v0.3.1/go.mod h1:uW6kP17uPlLJsD3ijUYn3/M5bAxtlZhMI6m3MFxTMTM=
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-colorable v0.1.11 h1:nQ+aFkoE2TMGc0b68U2OKSexC+eq46+XwZzWXHRmPYs=
github.com/mattn/go-colorable v0.1.11/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40=
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-sqlite3 v1.14.9 h1:10HX2Td0ocZpYEjhilsuo6WWtUqttj2Kb0KtD86/KYA=
@@ -210,6 +222,7 @@ github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJ
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
@@ -223,7 +236,7 @@ github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7J
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
github.com/onsi/gomega v1.16.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc=
github.com/openzipkin/zipkin-go v0.3.0/go.mod h1:4c3sLeE8xjNqehmF5RpAFLPLJxXscc0R4l6Zg0P1tTQ=
github.com/openzipkin/zipkin-go v0.4.0/go.mod h1:4c3sLeE8xjNqehmF5RpAFLPLJxXscc0R4l6Zg0P1tTQ=
github.com/pierrec/lz4 v2.6.1+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
@@ -234,6 +247,8 @@ github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXP
github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M=
github.com/prometheus/client_golang v1.11.0/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0=
github.com/prometheus/client_golang v1.12.1/go.mod h1:3Z9XVyYiZYEO+YQWt3RD2R3jrbd179Rt297l4aS6nDY=
github.com/prometheus/client_golang v1.12.2/go.mod h1:3Z9XVyYiZYEO+YQWt3RD2R3jrbd179Rt297l4aS6nDY=
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
@@ -242,10 +257,13 @@ github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y8
github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo=
github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc=
github.com/prometheus/common v0.32.1/go.mod h1:vu+V0TpY+O6vW9J44gczi3Ap/oXXR10b+M/gUGO4Hls=
github.com/prometheus/common v0.37.0/go.mod h1:phzohg0JFMnBEFGxTDbfu3QyL5GI8gTQJFhYO5B3mfA=
github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU=
github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
github.com/prometheus/procfs v0.7.3/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
github.com/prometheus/procfs v0.8.0/go.mod h1:z7EfXMXOkbkqb9IINtpCn86r/to3BnA0uaxHdg830/4=
github.com/rabbitmq/amqp091-go v1.1.0/go.mod h1:ogQDLSOACsLPsIq0NpbtiifNZi2YOz0VTJ0kHRghqbM=
github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
@@ -270,7 +288,6 @@ github.com/uber/jaeger-lib v2.4.1+incompatible/go.mod h1:ComeNDZlWwrWnDv8aPp0Ba6
github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8=
github.com/valyala/fasttemplate v1.2.1 h1:TVEnxayobAdVkhQfrfes2IzOB6o+z4roRkPF52WA1u4=
github.com/valyala/fasttemplate v1.2.1/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
@@ -295,8 +312,9 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20201112155050-0c6587e931a9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20210920023735-84f357641f63 h1:kETrAMYZq6WVGPa8IIixL0CaEcIUNi+1WX7grUoi3y8=
golang.org/x/crypto v0.0.0-20210920023735-84f357641f63/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa h1:zuSxTR4o9y82ebqCUJYNGJbGPo6sKVl54f/TVDObg1c=
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20180807140117-3d87b88a115f/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
@@ -365,15 +383,20 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20210913180222-943fd674d43e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20210917221730-978cfadd31cf h1:R150MpwJIv1MpS0N/pc+NhTM8ajzvlmxlY5OYsrevXQ=
golang.org/x/net v0.0.0-20210917221730-978cfadd31cf/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220728030405-41545e8bf201 h1:bvOltf3SADAfG05iRml8lAB3qjoEX5RCyN4K6G5v3N0=
golang.org/x/net v0.0.0-20220728030405-41545e8bf201/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -384,12 +407,12 @@ golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220601150217-0de741cfad7f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -398,7 +421,6 @@ golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -408,7 +430,6 @@ golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -426,16 +447,22 @@ golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210910150752-751e447fb3d0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211103235746-7861aae1554b h1:1VkfZQv42XQlA/jchYumAnv1UPo6RgF9rJFkTgZIxO4=
golang.org/x/sys v0.0.0-20211103235746-7861aae1554b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220405052023-b1e9470b6e64/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10 h1:WIoqL4EROvwiPdUtaip4VcDdpZ4kha7wBWZrbVKCIZg=
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -448,8 +475,9 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20201208040808-7e3f01d25324 h1:Hir2P/De0WpUhtrKGGjvSb2YxUgyZ7EFOSLIcSSpiwE=
golang.org/x/time v0.0.0-20201208040808-7e3f01d25324/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20220722155302-e5dcc9cfc0b9 h1:ftMN5LMiBFjbzleLqtoBZk7KdJwhuybIU+FckUHgoyQ=
golang.org/x/time v0.0.0-20220722155302-e5dcc9cfc0b9/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180525024113-a5b4c53f6e8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -568,6 +596,7 @@ google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM
google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0=
google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
google.golang.org/grpc v1.41.0/go.mod h1:U3l9uK9J0sini8mHphKoXyaqDA/8VyGnDee1zzIUK6k=
google.golang.org/grpc v1.48.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
@@ -581,6 +610,7 @@ google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlba
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

BIN
resources/logo-full.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

BIN
resources/logo.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -5,7 +5,7 @@ tmp_dir = ".air"
bin = "./.air/memos"
cmd = "go build -o ./.air/memos ./bin/server/main.go"
delay = 1000
exclude_dir = [".air", "web"]
exclude_dir = [".air", "web", "build"]
exclude_file = []
exclude_regex = []
exclude_unchanged = false

View File

@@ -6,8 +6,8 @@ set -e
cd "$(dirname "$0")/../"
echo "Start building..."
echo "Start building backend..."
go build -o ./memos-build/memos ./bin/server/main.go
go build -o ./build/memos ./bin/server/main.go
echo "Build finished"
echo "Backend built!"

9
scripts/start.sh Executable file
View File

@@ -0,0 +1,9 @@
#!/bin/bash
# Usage: ./scripts/start.sh
set -e
cd "$(dirname "$0")/../"
air -c ./scripts/.air.toml

View File

@@ -52,65 +52,73 @@ func removeUserSession(ctx echo.Context) error {
}
func aclMiddleware(s *Server, next echo.HandlerFunc) echo.HandlerFunc {
return func(ctx echo.Context) error {
return func(c echo.Context) error {
ctx := c.Request().Context()
path := c.Path()
// Skip auth.
if common.HasPrefixes(ctx.Path(), "/api/auth") {
return next(ctx)
if common.HasPrefixes(path, "/api/auth") {
return next(c)
}
if common.HasPrefixes(ctx.Path(), "/api/ping", "/api/status", "/api/user/:id") && ctx.Request().Method == http.MethodGet {
return next(ctx)
if common.HasPrefixes(path, "/api/ping", "/api/status", "/api/user/:id") && c.Request().Method == http.MethodGet {
return next(c)
}
// If there is openId in query string and related user is found, then skip auth.
openID := ctx.QueryParam("openId")
if openID != "" {
userFind := &api.UserFind{
OpenID: &openID,
}
user, err := s.Store.FindUser(userFind)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user by open_id").SetInternal(err)
}
if user != nil {
// Stores userID into context.
ctx.Set(getUserIDContextKey(), user.ID)
return next(ctx)
{
// If there is openId in query string and related user is found, then skip auth.
openID := c.QueryParam("openId")
if openID != "" {
userFind := &api.UserFind{
OpenID: &openID,
}
user, err := s.Store.FindUser(ctx, userFind)
if err != nil && common.ErrorCode(err) != common.NotFound {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user by open_id").SetInternal(err)
}
if user != nil {
// Stores userID into context.
c.Set(getUserIDContextKey(), user.ID)
return next(c)
}
}
}
{
sess, _ := session.Get("session", ctx)
sess, _ := session.Get("session", c)
userIDValue := sess.Values[userIDContextKey]
if userIDValue != nil {
userID, _ := strconv.Atoi(fmt.Sprintf("%v", userIDValue))
userFind := &api.UserFind{
ID: &userID,
}
user, err := s.Store.FindUser(userFind)
if err != nil {
user, err := s.Store.FindUser(ctx, userFind)
if err != nil && common.ErrorCode(err) != common.NotFound {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to find user by ID: %d", userID)).SetInternal(err)
}
if user != nil {
if user.RowStatus == api.Archived {
return echo.NewHTTPError(http.StatusForbidden, fmt.Sprintf("User has been archived with email %s", user.Email))
}
ctx.Set(getUserIDContextKey(), userID)
c.Set(getUserIDContextKey(), userID)
}
}
}
if common.HasPrefixes(ctx.Path(), "/api/memo", "/api/tag", "/api/shortcut") && ctx.Request().Method == http.MethodGet {
if _, err := strconv.Atoi(ctx.QueryParam("creatorId")); err == nil {
return next(ctx)
if common.HasPrefixes(path, "/api/memo/all", "/api/memo/:memoId") && c.Request().Method == http.MethodGet {
return next(c)
}
if common.HasPrefixes(path, "/api/memo", "/api/tag", "/api/shortcut") && c.Request().Method == http.MethodGet {
if _, err := strconv.Atoi(c.QueryParam("creatorId")); err == nil {
return next(c)
}
}
userID := ctx.Get(getUserIDContextKey())
userID := c.Get(getUserIDContextKey())
if userID == nil {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
return next(ctx)
return next(c)
}
}

View File

@@ -14,6 +14,7 @@ import (
func (s *Server) registerAuthRoutes(g *echo.Group) {
g.POST("/auth/signin", func(c echo.Context) error {
ctx := c.Request().Context()
signin := &api.Signin{}
if err := json.NewDecoder(c.Request().Body).Decode(signin); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted signin request").SetInternal(err)
@@ -22,8 +23,8 @@ func (s *Server) registerAuthRoutes(g *echo.Group) {
userFind := &api.UserFind{
Email: &signin.Email,
}
user, err := s.Store.FindUser(userFind)
if err != nil {
user, err := s.Store.FindUser(ctx, userFind)
if err != nil && common.ErrorCode(err) != common.NotFound {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to find user by email %s", signin.Email)).SetInternal(err)
}
if user == nil {
@@ -60,13 +61,14 @@ func (s *Server) registerAuthRoutes(g *echo.Group) {
})
g.POST("/auth/signup", func(c echo.Context) error {
ctx := c.Request().Context()
// Don't allow to signup by this api if site host existed.
hostUserType := api.Host
hostUserFind := api.UserFind{
Role: &hostUserType,
}
hostUser, err := s.Store.FindUser(&hostUserFind)
if err != nil {
hostUser, err := s.Store.FindUser(ctx, &hostUserFind)
if err != nil && common.ErrorCode(err) != common.NotFound {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find host user").SetInternal(err)
}
if hostUser != nil {
@@ -78,13 +80,15 @@ func (s *Server) registerAuthRoutes(g *echo.Group) {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted signup request").SetInternal(err)
}
// Validate signup form.
// We can do stricter checks later.
if len(signup.Email) < 6 {
return echo.NewHTTPError(http.StatusBadRequest, "Email is too short, minimum length is 6.")
userCreate := &api.UserCreate{
Email: signup.Email,
Role: api.Role(signup.Role),
Name: signup.Name,
Password: signup.Password,
OpenID: common.GenUUID(),
}
if len(signup.Password) < 6 {
return echo.NewHTTPError(http.StatusBadRequest, "Password is too short, minimum length is 6.")
if err := userCreate.Validate(); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid user create format.").SetInternal(err)
}
passwordHash, err := bcrypt.GenerateFromPassword([]byte(signup.Password), bcrypt.DefaultCost)
@@ -92,14 +96,9 @@ func (s *Server) registerAuthRoutes(g *echo.Group) {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate password hash").SetInternal(err)
}
userCreate := &api.UserCreate{
Email: signup.Email,
Role: api.Role(signup.Role),
Name: signup.Name,
PasswordHash: string(passwordHash),
OpenID: common.GenUUID(),
}
user, err := s.Store.CreateUser(userCreate)
userCreate.PasswordHash = string(passwordHash)
user, err := s.Store.CreateUser(ctx, userCreate)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create user").SetInternal(err)
}

View File

@@ -12,8 +12,8 @@ import (
//go:embed dist
var embeddedFiles embed.FS
func getFileSystem() http.FileSystem {
fs, err := fs.Sub(embeddedFiles, "dist")
func getFileSystem(path string) http.FileSystem {
fs, err := fs.Sub(embeddedFiles, path)
if err != nil {
panic(err)
}
@@ -22,8 +22,22 @@ func getFileSystem() http.FileSystem {
}
func embedFrontend(e *echo.Echo) {
// Use echo static middleware to serve the built dist folder
// refer: https://github.com/labstack/echo/blob/master/middleware/static.go
e.Use(middleware.StaticWithConfig(middleware.StaticConfig{
HTML5: true,
Filesystem: getFileSystem(),
Filesystem: getFileSystem("dist"),
}))
g := e.Group("assets")
g.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
c.Response().Header().Set(echo.HeaderCacheControl, "max-age=31536000, immutable")
return next(c)
}
})
g.Use(middleware.StaticWithConfig(middleware.StaticConfig{
HTML5: true,
Filesystem: getFileSystem("dist/assets"),
}))
}

View File

@@ -4,8 +4,10 @@ import (
"encoding/json"
"fmt"
"net/http"
"sort"
"strconv"
"strings"
"time"
"github.com/usememos/memos/api"
"github.com/usememos/memos/common"
@@ -15,27 +17,64 @@ import (
func (s *Server) registerMemoRoutes(g *echo.Group) {
g.POST("/memo", func(c echo.Context) error {
ctx := c.Request().Context()
userID, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
memoCreate := &api.MemoCreate{
CreatorID: userID,
}
if err := json.NewDecoder(c.Request().Body).Decode(memoCreate); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post memo request").SetInternal(err)
}
if memoCreate.Visibility == nil || *memoCreate.Visibility == "" {
private := api.Privite
memoCreate.Visibility = &private
if memoCreate.Content == "" {
return echo.NewHTTPError(http.StatusBadRequest, "Memo content shouldn't be empty")
}
memo, err := s.Store.CreateMemo(memoCreate)
if memoCreate.Visibility == "" {
userSettingMemoVisibilityKey := api.UserSettingMemoVisibilityKey
userMemoVisibilitySetting, err := s.Store.FindUserSetting(ctx, &api.UserSettingFind{
UserID: userID,
Key: &userSettingMemoVisibilityKey,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user setting").SetInternal(err)
}
if userMemoVisibilitySetting != nil {
memoVisibility := api.Privite
err := json.Unmarshal([]byte(userMemoVisibilitySetting.Value), &memoVisibility)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to unmarshal user setting value").SetInternal(err)
}
memoCreate.Visibility = memoVisibility
} else {
// Private is the default memo visibility.
memoCreate.Visibility = api.Privite
}
}
memo, err := s.Store.CreateMemo(ctx, memoCreate)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create memo").SetInternal(err)
}
for _, resourceID := range memoCreate.ResourceIDList {
if _, err := s.Store.UpsertMemoResource(ctx, &api.MemoResourceUpsert{
MemoID: memo.ID,
ResourceID: resourceID,
}); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert memo resource").SetInternal(err)
}
}
memo, err = s.Store.ComposeMemo(ctx, memo)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to compose memo").SetInternal(err)
}
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(memo)); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode memo response").SetInternal(err)
@@ -44,23 +83,53 @@ func (s *Server) registerMemoRoutes(g *echo.Group) {
})
g.PATCH("/memo/:memoId", func(c echo.Context) error {
ctx := c.Request().Context()
userID, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
memoID, err := strconv.Atoi(c.Param("memoId"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("memoId"))).SetInternal(err)
}
memoFind := &api.MemoFind{
ID: &memoID,
CreatorID: &userID,
}
if _, err := s.Store.FindMemo(ctx, memoFind); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find memo").SetInternal(err)
}
currentTs := time.Now().Unix()
memoPatch := &api.MemoPatch{
ID: memoID,
ID: memoID,
UpdatedTs: &currentTs,
}
if err := json.NewDecoder(c.Request().Body).Decode(memoPatch); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted patch memo request").SetInternal(err)
}
memo, err := s.Store.PatchMemo(memoPatch)
memo, err := s.Store.PatchMemo(ctx, memoPatch)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to patch memo").SetInternal(err)
}
for _, resourceID := range memoPatch.ResourceIDList {
if _, err := s.Store.UpsertMemoResource(ctx, &api.MemoResourceUpsert{
MemoID: memo.ID,
ResourceID: resourceID,
}); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert memo resource").SetInternal(err)
}
}
memo, err = s.Store.ComposeMemo(ctx, memo)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to compose memo").SetInternal(err)
}
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(memo)); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode memo response").SetInternal(err)
@@ -69,8 +138,8 @@ func (s *Server) registerMemoRoutes(g *echo.Group) {
})
g.GET("/memo", func(c echo.Context) error {
ctx := c.Request().Context()
memoFind := &api.MemoFind{}
if userID, err := strconv.Atoi(c.QueryParam("creatorId")); err == nil {
memoFind.CreatorID = &userID
}
@@ -118,11 +187,15 @@ func (s *Server) registerMemoRoutes(g *echo.Group) {
memoFind.Offset = offset
}
list, err := s.Store.FindMemoList(memoFind)
list, err := s.Store.FindMemoList(ctx, memoFind)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch memo list").SetInternal(err)
}
sort.Slice(list, func(i, j int) bool {
return list[i].DisplayTs > list[j].DisplayTs
})
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(list)); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode memo list response").SetInternal(err)
@@ -130,7 +203,151 @@ func (s *Server) registerMemoRoutes(g *echo.Group) {
return nil
})
g.GET("/memo/stats", func(c echo.Context) error {
ctx := c.Request().Context()
normalStatus := api.Normal
memoFind := &api.MemoFind{
RowStatus: &normalStatus,
}
if userID, err := strconv.Atoi(c.QueryParam("creatorId")); err == nil {
memoFind.CreatorID = &userID
}
currentUserID, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
if memoFind.CreatorID == nil {
return echo.NewHTTPError(http.StatusBadRequest, "Missing user id to find memo")
}
memoFind.VisibilityList = []api.Visibility{api.Public}
} else {
if memoFind.CreatorID == nil {
memoFind.CreatorID = &currentUserID
} else {
memoFind.VisibilityList = []api.Visibility{api.Public, api.Protected}
}
}
visibilitListStr := c.QueryParam("visibility")
if visibilitListStr != "" {
visibilityList := []api.Visibility{}
for _, visibility := range strings.Split(visibilitListStr, ",") {
visibilityList = append(visibilityList, api.Visibility(visibility))
}
memoFind.VisibilityList = visibilityList
}
list, err := s.Store.FindMemoList(ctx, memoFind)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch memo list").SetInternal(err)
}
displayTsList := []int64{}
for _, memo := range list {
displayTsList = append(displayTsList, memo.DisplayTs)
}
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(displayTsList)); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode memo stats response").SetInternal(err)
}
return nil
})
g.GET("/memo/all", func(c echo.Context) error {
ctx := c.Request().Context()
memoFind := &api.MemoFind{}
_, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
memoFind.VisibilityList = []api.Visibility{api.Public}
} else {
memoFind.VisibilityList = []api.Visibility{api.Public, api.Protected}
}
pinnedStr := c.QueryParam("pinned")
if pinnedStr != "" {
pinned := pinnedStr == "true"
memoFind.Pinned = &pinned
}
tag := c.QueryParam("tag")
if tag != "" {
contentSearch := "#" + tag + " "
memoFind.ContentSearch = &contentSearch
}
visibilitListStr := c.QueryParam("visibility")
if visibilitListStr != "" {
visibilityList := []api.Visibility{}
for _, visibility := range strings.Split(visibilitListStr, ",") {
visibilityList = append(visibilityList, api.Visibility(visibility))
}
memoFind.VisibilityList = visibilityList
}
if limit, err := strconv.Atoi(c.QueryParam("limit")); err == nil {
memoFind.Limit = limit
}
if offset, err := strconv.Atoi(c.QueryParam("offset")); err == nil {
memoFind.Offset = offset
}
// Only fetch normal status memos.
normalStatus := api.Normal
memoFind.RowStatus = &normalStatus
list, err := s.Store.FindMemoList(ctx, memoFind)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch all memo list").SetInternal(err)
}
sort.Slice(list, func(i, j int) bool {
return list[i].DisplayTs > list[j].DisplayTs
})
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(list)); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode all memo list response").SetInternal(err)
}
return nil
})
g.GET("/memo/:memoId", func(c echo.Context) error {
ctx := c.Request().Context()
memoID, err := strconv.Atoi(c.Param("memoId"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("memoId"))).SetInternal(err)
}
memoFind := &api.MemoFind{
ID: &memoID,
}
memo, err := s.Store.FindMemo(ctx, memoFind)
if err != nil {
if common.ErrorCode(err) == common.NotFound {
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Memo ID not found: %d", memoID)).SetInternal(err)
}
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to find memo by ID: %v", memoID)).SetInternal(err)
}
userID, ok := c.Get(getUserIDContextKey()).(int)
if memo.Visibility == api.Privite {
if !ok || memo.CreatorID != userID {
return echo.NewHTTPError(http.StatusForbidden, "this memo is private only")
}
} else if memo.Visibility == api.Protected {
if !ok {
return echo.NewHTTPError(http.StatusForbidden, "this memo is protected, missing user in session")
}
}
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(memo)); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode memo response").SetInternal(err)
}
return nil
})
g.POST("/memo/:memoId/organizer", func(c echo.Context) error {
ctx := c.Request().Context()
memoID, err := strconv.Atoi(c.Param("memoId"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("memoId"))).SetInternal(err)
@@ -148,12 +365,12 @@ func (s *Server) registerMemoRoutes(g *echo.Group) {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post memo organizer request").SetInternal(err)
}
err = s.Store.UpsertMemoOrganizer(memoOrganizerUpsert)
err = s.Store.UpsertMemoOrganizer(ctx, memoOrganizerUpsert)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert memo organizer").SetInternal(err)
}
memo, err := s.Store.FindMemo(&api.MemoFind{
memo, err := s.Store.FindMemo(ctx, &api.MemoFind{
ID: &memoID,
})
if err != nil {
@@ -171,41 +388,112 @@ func (s *Server) registerMemoRoutes(g *echo.Group) {
return nil
})
g.GET("/memo/:memoId", func(c echo.Context) error {
g.POST("/memo/:memoId/resource", func(c echo.Context) error {
ctx := c.Request().Context()
memoID, err := strconv.Atoi(c.Param("memoId"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("memoId"))).SetInternal(err)
}
currentTs := time.Now().Unix()
memoResourceUpsert := &api.MemoResourceUpsert{
MemoID: memoID,
UpdatedTs: &currentTs,
}
if err := json.NewDecoder(c.Request().Body).Decode(memoResourceUpsert); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post memo resource request").SetInternal(err)
}
if _, err := s.Store.UpsertMemoResource(ctx, memoResourceUpsert); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert memo resource").SetInternal(err)
}
resourceFind := &api.ResourceFind{
ID: &memoResourceUpsert.ResourceID,
}
resource, err := s.Store.FindResource(ctx, resourceFind)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch resource").SetInternal(err)
}
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(resource)); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode resource response").SetInternal(err)
}
return nil
})
g.GET("/memo/:memoId/resource", func(c echo.Context) error {
ctx := c.Request().Context()
memoID, err := strconv.Atoi(c.Param("memoId"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("memoId"))).SetInternal(err)
}
resourceFind := &api.ResourceFind{
MemoID: &memoID,
}
resourceList, err := s.Store.FindResourceList(ctx, resourceFind)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch resource list").SetInternal(err)
}
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(resourceList)); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode resource list response").SetInternal(err)
}
return nil
})
g.DELETE("/memo/:memoId/resource/:resourceId", func(c echo.Context) error {
ctx := c.Request().Context()
memoID, err := strconv.Atoi(c.Param("memoId"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Memo ID is not a number: %s", c.Param("memoId"))).SetInternal(err)
}
resourceID, err := strconv.Atoi(c.Param("resourceId"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Resource ID is not a number: %s", c.Param("resourceId"))).SetInternal(err)
}
memoResourceDelete := &api.MemoResourceDelete{
MemoID: memoID,
ResourceID: &resourceID,
}
if err := s.Store.DeleteMemoResource(ctx, memoResourceDelete); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch resource list").SetInternal(err)
}
return c.JSON(http.StatusOK, true)
})
g.DELETE("/memo/:memoId", func(c echo.Context) error {
ctx := c.Request().Context()
userID, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
memoID, err := strconv.Atoi(c.Param("memoId"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("memoId"))).SetInternal(err)
}
memoFind := &api.MemoFind{
ID: &memoID,
ID: &memoID,
CreatorID: &userID,
}
memo, err := s.Store.FindMemo(memoFind)
if err != nil {
if common.ErrorCode(err) == common.NotFound {
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Memo ID not found: %d", memoID)).SetInternal(err)
}
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to find memo by ID: %v", memoID)).SetInternal(err)
}
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(memo)); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode memo response").SetInternal(err)
}
return nil
})
g.DELETE("/memo/:memoId", func(c echo.Context) error {
memoID, err := strconv.Atoi(c.Param("memoId"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("memoId"))).SetInternal(err)
if _, err := s.Store.FindMemo(ctx, memoFind); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find memo").SetInternal(err)
}
memoDelete := &api.MemoDelete{
ID: memoID,
}
if err := s.Store.DeleteMemo(memoDelete); err != nil {
if err := s.Store.DeleteMemo(ctx, memoDelete); err != nil {
if common.ErrorCode(err) == common.NotFound {
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Memo ID not found: %d", memoID))
}
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to delete memo ID: %v", memoID)).SetInternal(err)
}
@@ -213,6 +501,7 @@ func (s *Server) registerMemoRoutes(g *echo.Group) {
})
g.GET("/memo/amount", func(c echo.Context) error {
ctx := c.Request().Context()
userID, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
@@ -223,7 +512,7 @@ func (s *Server) registerMemoRoutes(g *echo.Group) {
RowStatus: &normalRowStatus,
}
memoList, err := s.Store.FindMemoList(memoFind)
memoList, err := s.Store.FindMemoList(ctx, memoFind)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find memo list").SetInternal(err)
}

View File

@@ -7,7 +7,7 @@ import (
"path/filepath"
"strings"
"github.com/usememos/memos/common"
"github.com/usememos/memos/server/version"
)
// Profile is the configuration to start main server.
@@ -45,7 +45,7 @@ func checkDSN(dataDir string) (string, error) {
}
// GetDevProfile will return a profile for dev or prod.
func GetProfile() *Profile {
func GetProfile() (*Profile, error) {
profile := Profile{}
flag.StringVar(&profile.Mode, "mode", "dev", "mode of server")
flag.IntVar(&profile.Port, "port", 8080, "port of server")
@@ -63,12 +63,12 @@ func GetProfile() *Profile {
dataDir, err := checkDSN(profile.Data)
if err != nil {
fmt.Printf("Failed to check dsn: %s, err: %+v\n", dataDir, err)
os.Exit(1)
return nil, err
}
profile.Data = dataDir
profile.DSN = fmt.Sprintf("%s/memos_%s.db", dataDir, profile.Mode)
profile.Version = common.GetCurrentVersion(profile.Mode)
profile.Version = version.GetCurrentVersion(profile.Mode)
return &profile
return &profile, nil
}

View File

@@ -3,17 +3,20 @@ package server
import (
"encoding/json"
"fmt"
"io/ioutil"
"html"
"io"
"net/http"
"strconv"
"github.com/usememos/memos/api"
"github.com/usememos/memos/common"
"github.com/labstack/echo/v4"
)
func (s *Server) registerResourceRoutes(g *echo.Group) {
g.POST("/resource", func(c echo.Context) error {
ctx := c.Request().Context()
userID, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
@@ -38,7 +41,7 @@ func (s *Server) registerResourceRoutes(g *echo.Group) {
}
defer src.Close()
fileBytes, err := ioutil.ReadAll(src)
fileBytes, err := io.ReadAll(src)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to read file").SetInternal(err)
}
@@ -51,7 +54,7 @@ func (s *Server) registerResourceRoutes(g *echo.Group) {
CreatorID: userID,
}
resource, err := s.Store.CreateResource(resourceCreate)
resource, err := s.Store.CreateResource(ctx, resourceCreate)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create resource").SetInternal(err)
}
@@ -64,6 +67,7 @@ func (s *Server) registerResourceRoutes(g *echo.Group) {
})
g.GET("/resource", func(c echo.Context) error {
ctx := c.Request().Context()
userID, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
@@ -71,11 +75,21 @@ func (s *Server) registerResourceRoutes(g *echo.Group) {
resourceFind := &api.ResourceFind{
CreatorID: &userID,
}
list, err := s.Store.FindResourceList(resourceFind)
list, err := s.Store.FindResourceList(ctx, resourceFind)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch resource list").SetInternal(err)
}
for _, resource := range list {
memoResoureceList, err := s.Store.FindMemoResourceList(ctx, &api.MemoResourceFind{
ResourceID: &resource.ID,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find memo resource list").SetInternal(err)
}
resource.LinkedMemoAmount = len(memoResoureceList)
}
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(list)); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode resource list response").SetInternal(err)
@@ -84,6 +98,7 @@ func (s *Server) registerResourceRoutes(g *echo.Group) {
})
g.GET("/resource/:resourceId", func(c echo.Context) error {
ctx := c.Request().Context()
resourceID, err := strconv.Atoi(c.Param("resourceId"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("resourceId"))).SetInternal(err)
@@ -97,7 +112,7 @@ func (s *Server) registerResourceRoutes(g *echo.Group) {
ID: &resourceID,
CreatorID: &userID,
}
resource, err := s.Store.FindResource(resourceFind)
resource, err := s.Store.FindResource(ctx, resourceFind)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch resource").SetInternal(err)
}
@@ -110,6 +125,7 @@ func (s *Server) registerResourceRoutes(g *echo.Group) {
})
g.GET("/resource/:resourceId/blob", func(c echo.Context) error {
ctx := c.Request().Context()
resourceID, err := strconv.Atoi(c.Param("resourceId"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("resourceId"))).SetInternal(err)
@@ -123,7 +139,7 @@ func (s *Server) registerResourceRoutes(g *echo.Group) {
ID: &resourceID,
CreatorID: &userID,
}
resource, err := s.Store.FindResource(resourceFind)
resource, err := s.Store.FindResource(ctx, resourceFind)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch resource").SetInternal(err)
}
@@ -138,18 +154,57 @@ func (s *Server) registerResourceRoutes(g *echo.Group) {
})
g.DELETE("/resource/:resourceId", func(c echo.Context) error {
ctx := c.Request().Context()
userID, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
resourceID, err := strconv.Atoi(c.Param("resourceId"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("resourceId"))).SetInternal(err)
}
resourceDelete := &api.ResourceDelete{
ID: resourceID,
ID: resourceID,
CreatorID: userID,
}
if err := s.Store.DeleteResource(resourceDelete); err != nil {
if err := s.Store.DeleteResource(ctx, resourceDelete); err != nil {
if common.ErrorCode(err) == common.NotFound {
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Resource ID not found: %d", resourceID))
}
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to delete resource").SetInternal(err)
}
return c.JSON(http.StatusOK, true)
})
}
func (s *Server) registerResourcePublicRoutes(g *echo.Group) {
g.GET("/r/:resourceId/:filename", func(c echo.Context) error {
ctx := c.Request().Context()
resourceID, err := strconv.Atoi(c.Param("resourceId"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("resourceId"))).SetInternal(err)
}
filename := html.UnescapeString(c.Param("filename"))
resourceFind := &api.ResourceFind{
ID: &resourceID,
Filename: &filename,
}
resource, err := s.Store.FindResource(ctx, resourceFind)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to fetch resource ID: %v", resourceID)).SetInternal(err)
}
c.Response().Writer.WriteHeader(http.StatusOK)
c.Response().Writer.Header().Set("Content-Type", resource.Type)
c.Response().Header().Set(echo.HeaderCacheControl, "max-age=31536000, immutable")
if _, err := c.Response().Writer.Write(resource.Blob); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to write response").SetInternal(err)
}
return nil
})
}

View File

@@ -29,9 +29,13 @@ func NewServer(profile *profile.Profile) *Server {
e.HidePort = true
e.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{
Format: "${method} ${uri} ${status}\n",
Format: `{"time":"${time_rfc3339}",` +
`"method":"${method}","uri":"${uri}",` +
`"status":${status},"error":"${error}"}` + "\n",
}))
e.Use(middleware.CORS())
e.Use(middleware.TimeoutWithConfig(middleware.TimeoutConfig{
Skipper: middleware.DefaultSkipper,
ErrorMessage: "Request timeout",
@@ -52,9 +56,11 @@ func NewServer(profile *profile.Profile) *Server {
Profile: profile,
}
// Webhooks api skips auth checker.
webhookGroup := e.Group("/h")
s.registerWebhookRoutes(webhookGroup)
s.registerResourcePublicRoutes(webhookGroup)
publicGroup := e.Group("/o")
s.registerResourcePublicRoutes(publicGroup)
apiGroup := e.Group("/api")
apiGroup.Use(func(next echo.HandlerFunc) echo.HandlerFunc {

View File

@@ -7,12 +7,14 @@ import (
"strconv"
"github.com/usememos/memos/api"
"github.com/usememos/memos/common"
"github.com/labstack/echo/v4"
)
func (s *Server) registerShortcutRoutes(g *echo.Group) {
g.POST("/shortcut", func(c echo.Context) error {
ctx := c.Request().Context()
userID, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
@@ -24,7 +26,7 @@ func (s *Server) registerShortcutRoutes(g *echo.Group) {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post shortcut request").SetInternal(err)
}
shortcut, err := s.Store.CreateShortcut(shortcutCreate)
shortcut, err := s.Store.CreateShortcut(ctx, shortcutCreate)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create shortcut").SetInternal(err)
}
@@ -37,6 +39,7 @@ func (s *Server) registerShortcutRoutes(g *echo.Group) {
})
g.PATCH("/shortcut/:shortcutId", func(c echo.Context) error {
ctx := c.Request().Context()
shortcutID, err := strconv.Atoi(c.Param("shortcutId"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("shortcutId"))).SetInternal(err)
@@ -49,7 +52,7 @@ func (s *Server) registerShortcutRoutes(g *echo.Group) {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted patch shortcut request").SetInternal(err)
}
shortcut, err := s.Store.PatchShortcut(shortcutPatch)
shortcut, err := s.Store.PatchShortcut(ctx, shortcutPatch)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to patch shortcut").SetInternal(err)
}
@@ -62,6 +65,7 @@ func (s *Server) registerShortcutRoutes(g *echo.Group) {
})
g.GET("/shortcut", func(c echo.Context) error {
ctx := c.Request().Context()
shortcutFind := &api.ShortcutFind{}
if userID, err := strconv.Atoi(c.QueryParam("creatorId")); err == nil {
@@ -75,7 +79,7 @@ func (s *Server) registerShortcutRoutes(g *echo.Group) {
shortcutFind.CreatorID = &userID
}
list, err := s.Store.FindShortcutList(shortcutFind)
list, err := s.Store.FindShortcutList(ctx, shortcutFind)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch shortcut list").SetInternal(err)
}
@@ -88,6 +92,7 @@ func (s *Server) registerShortcutRoutes(g *echo.Group) {
})
g.GET("/shortcut/:shortcutId", func(c echo.Context) error {
ctx := c.Request().Context()
shortcutID, err := strconv.Atoi(c.Param("shortcutId"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("shortcutId"))).SetInternal(err)
@@ -96,7 +101,7 @@ func (s *Server) registerShortcutRoutes(g *echo.Group) {
shortcutFind := &api.ShortcutFind{
ID: &shortcutID,
}
shortcut, err := s.Store.FindShortcut(shortcutFind)
shortcut, err := s.Store.FindShortcut(ctx, shortcutFind)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to fetch shortcut by ID %d", *shortcutFind.ID)).SetInternal(err)
}
@@ -109,6 +114,7 @@ func (s *Server) registerShortcutRoutes(g *echo.Group) {
})
g.DELETE("/shortcut/:shortcutId", func(c echo.Context) error {
ctx := c.Request().Context()
shortcutID, err := strconv.Atoi(c.Param("shortcutId"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("shortcutId"))).SetInternal(err)
@@ -117,7 +123,10 @@ func (s *Server) registerShortcutRoutes(g *echo.Group) {
shortcutDelete := &api.ShortcutDelete{
ID: shortcutID,
}
if err := s.Store.DeleteShortcut(shortcutDelete); err != nil {
if err := s.Store.DeleteShortcut(ctx, shortcutDelete); err != nil {
if common.ErrorCode(err) == common.NotFound {
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Shortcut ID not found: %d", shortcutID))
}
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to delete shortcut").SetInternal(err)
}

View File

@@ -5,6 +5,7 @@ import (
"net/http"
"github.com/usememos/memos/api"
"github.com/usememos/memos/common"
"github.com/labstack/echo/v4"
)
@@ -21,12 +22,13 @@ func (s *Server) registerSystemRoutes(g *echo.Group) {
})
g.GET("/status", func(c echo.Context) error {
ctx := c.Request().Context()
hostUserType := api.Host
hostUserFind := api.UserFind{
Role: &hostUserType,
}
hostUser, err := s.Store.FindUser(&hostUserFind)
if err != nil {
hostUser, err := s.Store.FindUser(ctx, &hostUserFind)
if err != nil && common.ErrorCode(err) != common.NotFound {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find host user").SetInternal(err)
}
@@ -36,7 +38,7 @@ func (s *Server) registerSystemRoutes(g *echo.Group) {
}
systemStatus := api.SystemStatus{
Host: hostUser,
Host: hostUser,
Profile: s.Profile,
}

View File

@@ -12,8 +12,11 @@ import (
"github.com/labstack/echo/v4"
)
var tagRegexp = regexp.MustCompile(`[^\s]?#([^\s#]+?) `)
func (s *Server) registerTagRoutes(g *echo.Group) {
g.GET("/tag", func(c echo.Context) error {
ctx := c.Request().Context()
contentSearch := "#"
normalRowStatus := api.Normal
memoFind := api.MemoFind{
@@ -39,20 +42,16 @@ func (s *Server) registerTagRoutes(g *echo.Group) {
}
}
memoList, err := s.Store.FindMemoList(&memoFind)
memoList, err := s.Store.FindMemoList(ctx, &memoFind)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find memo list").SetInternal(err)
}
tagMapSet := make(map[string]bool)
r, err := regexp.Compile("#(.+?) ")
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to compile regexp").SetInternal(err)
}
for _, memo := range memoList {
for _, rawTag := range r.FindAllString(memo.Content, -1) {
tag := r.ReplaceAllString(rawTag, "$1")
for _, rawTag := range tagRegexp.FindAllString(memo.Content, -1) {
tag := tagRegexp.ReplaceAllString(rawTag, "$1")
tagMapSet[tag] = true
}
}

View File

@@ -15,18 +15,39 @@ import (
func (s *Server) registerUserRoutes(g *echo.Group) {
g.POST("/user", func(c echo.Context) error {
userCreate := &api.UserCreate{}
ctx := c.Request().Context()
userID, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing auth session")
}
currentUser, err := s.Store.FindUser(ctx, &api.UserFind{
ID: &userID,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user by id").SetInternal(err)
}
if currentUser.Role != api.Host {
return echo.NewHTTPError(http.StatusUnauthorized, "Only Host user can create member.")
}
userCreate := &api.UserCreate{
OpenID: common.GenUUID(),
}
if err := json.NewDecoder(c.Request().Body).Decode(userCreate); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post user request").SetInternal(err)
}
if err := userCreate.Validate(); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid user create format.").SetInternal(err)
}
passwordHash, err := bcrypt.GenerateFromPassword([]byte(userCreate.Password), bcrypt.DefaultCost)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate password hash").SetInternal(err)
}
userCreate.PasswordHash = string(passwordHash)
user, err := s.Store.CreateUser(userCreate)
user, err := s.Store.CreateUser(ctx, userCreate)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create user").SetInternal(err)
}
@@ -39,7 +60,8 @@ func (s *Server) registerUserRoutes(g *echo.Group) {
})
g.GET("/user", func(c echo.Context) error {
userList, err := s.Store.FindUserList(&api.UserFind{})
ctx := c.Request().Context()
userList, err := s.Store.FindUserList(ctx, &api.UserFind{})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch user list").SetInternal(err)
}
@@ -56,13 +78,73 @@ func (s *Server) registerUserRoutes(g *echo.Group) {
return nil
})
// GET /api/user/me is used to check if the user is logged in.
g.GET("/user/me", func(c echo.Context) error {
ctx := c.Request().Context()
userID, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing auth session")
}
userFind := &api.UserFind{
ID: &userID,
}
user, err := s.Store.FindUser(ctx, userFind)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err)
}
userSettingList, err := s.Store.FindUserSettingList(ctx, &api.UserSettingFind{
UserID: userID,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find userSettingList").SetInternal(err)
}
user.UserSettingList = userSettingList
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(user)); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode user response").SetInternal(err)
}
return nil
})
g.POST("/user/setting", func(c echo.Context) error {
ctx := c.Request().Context()
userID, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing auth session")
}
userSettingUpsert := &api.UserSettingUpsert{}
if err := json.NewDecoder(c.Request().Body).Decode(userSettingUpsert); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post user setting upsert request").SetInternal(err)
}
if err := userSettingUpsert.Validate(); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid user setting format").SetInternal(err)
}
userSettingUpsert.UserID = userID
userSetting, err := s.Store.UpsertUserSetting(ctx, userSettingUpsert)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert user setting").SetInternal(err)
}
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(userSetting)); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode user setting response").SetInternal(err)
}
return nil
})
g.GET("/user/:id", func(c echo.Context) error {
ctx := c.Request().Context()
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted user id").SetInternal(err)
}
user, err := s.Store.FindUser(&api.UserFind{
user, err := s.Store.FindUser(ctx, &api.UserFind{
ID: &id,
})
if err != nil {
@@ -81,29 +163,8 @@ func (s *Server) registerUserRoutes(g *echo.Group) {
return nil
})
// GET /api/user/me is used to check if the user is logged in.
g.GET("/user/me", func(c echo.Context) error {
userID, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing auth session")
}
userFind := &api.UserFind{
ID: &userID,
}
user, err := s.Store.FindUser(userFind)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch user").SetInternal(err)
}
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(user)); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode user response").SetInternal(err)
}
return nil
})
g.PATCH("/user/:id", func(c echo.Context) error {
ctx := c.Request().Context()
userID, err := strconv.Atoi(c.Param("id"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("id"))).SetInternal(err)
@@ -112,7 +173,7 @@ func (s *Server) registerUserRoutes(g *echo.Group) {
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
currentUser, err := s.Store.FindUser(&api.UserFind{
currentUser, err := s.Store.FindUser(ctx, &api.UserFind{
ID: &currentUserID,
})
if err != nil {
@@ -131,6 +192,10 @@ func (s *Server) registerUserRoutes(g *echo.Group) {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted patch user request").SetInternal(err)
}
if userPatch.Email != nil && !common.ValidateEmail(*userPatch.Email) {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid email format")
}
if userPatch.Password != nil && *userPatch.Password != "" {
passwordHash, err := bcrypt.GenerateFromPassword([]byte(*userPatch.Password), bcrypt.DefaultCost)
if err != nil {
@@ -146,7 +211,7 @@ func (s *Server) registerUserRoutes(g *echo.Group) {
userPatch.OpenID = &openID
}
user, err := s.Store.PatchUser(userPatch)
user, err := s.Store.PatchUser(ctx, userPatch)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to patch user").SetInternal(err)
}
@@ -159,11 +224,12 @@ func (s *Server) registerUserRoutes(g *echo.Group) {
})
g.DELETE("/user/:id", func(c echo.Context) error {
ctx := c.Request().Context()
currentUserID, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
currentUser, err := s.Store.FindUser(&api.UserFind{
currentUser, err := s.Store.FindUser(ctx, &api.UserFind{
ID: &currentUserID,
})
if err != nil {
@@ -183,7 +249,10 @@ func (s *Server) registerUserRoutes(g *echo.Group) {
userDelete := &api.UserDelete{
ID: userID,
}
if err := s.Store.DeleteUser(userDelete); err != nil {
if err := s.Store.DeleteUser(ctx, userDelete); err != nil {
if common.ErrorCode(err) == common.NotFound {
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("User ID not found: %d", userID))
}
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to delete user").SetInternal(err)
}

View File

@@ -1,4 +1,4 @@
package common
package version
import (
"strconv"
@@ -7,10 +7,10 @@ import (
// Version is the service current released version.
// Semantic versioning: https://semver.org/
var Version = "0.2.2"
var Version = "0.6.1"
// DevVersion is the service current development version.
var DevVersion = "0.3.0"
var DevVersion = "0.6.1"
func GetCurrentVersion(mode string) string {
if mode == "dev" {
@@ -27,6 +27,12 @@ func GetMinorVersion(version string) string {
return versionList[0] + "." + versionList[1]
}
func GetSchemaVersion(version string) string {
minorVersion := GetMinorVersion(version)
return minorVersion + ".0"
}
// convSemanticVersionToInt converts version string to int.
func convSemanticVersionToInt(version string) int {
versionList := strings.Split(version, ".")

View File

@@ -1,42 +0,0 @@
package server
import (
"fmt"
"net/http"
"strconv"
"github.com/usememos/memos/api"
"github.com/labstack/echo/v4"
)
func (s *Server) registerWebhookRoutes(g *echo.Group) {
g.GET("/test", func(c echo.Context) error {
return c.HTML(http.StatusOK, "<strong>Hello, World!</strong>")
})
g.GET("/r/:resourceId/:filename", func(c echo.Context) error {
resourceID, err := strconv.Atoi(c.Param("resourceId"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("resourceId"))).SetInternal(err)
}
filename := c.Param("filename")
resourceFind := &api.ResourceFind{
ID: &resourceID,
Filename: &filename,
}
resource, err := s.Store.FindResource(resourceFind)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to fetch resource ID: %v", resourceID)).SetInternal(err)
}
c.Response().Writer.WriteHeader(http.StatusOK)
c.Response().Writer.Header().Set("Content-Type", resource.Type)
if _, err := c.Response().Writer.Write(resource.Blob); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to write response").SetInternal(err)
}
return nil
})
}

72
store/cache.go Normal file
View File

@@ -0,0 +1,72 @@
package store
import (
"bytes"
"encoding/binary"
"encoding/gob"
"fmt"
"github.com/VictoriaMetrics/fastcache"
"github.com/usememos/memos/api"
)
var (
// 64 MiB.
cacheSize = 1024 * 1024 * 64
_ api.CacheService = (*CacheService)(nil)
)
// CacheService implements a cache.
type CacheService struct {
cache *fastcache.Cache
}
// NewCacheService creates a cache service.
func NewCacheService() *CacheService {
return &CacheService{
cache: fastcache.New(cacheSize),
}
}
// FindCache finds the value in cache.
func (s *CacheService) FindCache(namespace api.CacheNamespace, id int, entry interface{}) (bool, error) {
buf1 := []byte{0, 0, 0, 0, 0, 0, 0, 0}
binary.LittleEndian.PutUint64(buf1, uint64(id))
buf2, has := s.cache.HasGet(nil, append([]byte(namespace), buf1...))
if has {
dec := gob.NewDecoder(bytes.NewReader(buf2))
if err := dec.Decode(entry); err != nil {
return false, fmt.Errorf("failed to decode entry for cache namespace: %s, error: %w", namespace, err)
}
return true, nil
}
return false, nil
}
// UpsertCache upserts the value to cache.
func (s *CacheService) UpsertCache(namespace api.CacheNamespace, id int, entry interface{}) error {
buf1 := []byte{0, 0, 0, 0, 0, 0, 0, 0}
binary.LittleEndian.PutUint64(buf1, uint64(id))
var buf2 bytes.Buffer
enc := gob.NewEncoder(&buf2)
if err := enc.Encode(entry); err != nil {
return fmt.Errorf("failed to encode entry for cache namespace: %s, error: %w", namespace, err)
}
s.cache.Set(append([]byte(namespace), buf1...), buf2.Bytes())
return nil
}
// DeleteCache deletes the cache.
func (s *CacheService) DeleteCache(namespace api.CacheNamespace, id int) {
buf1 := []byte{0, 0, 0, 0, 0, 0, 0, 0}
binary.LittleEndian.PutUint64(buf1, uint64(id))
_, has := s.cache.HasGet(nil, append([]byte(namespace), buf1...))
if has {
s.cache.Del(append([]byte(namespace), buf1...))
}
}

View File

@@ -1,21 +1,19 @@
package db
import (
"context"
"database/sql"
"embed"
"errors"
"fmt"
"io/fs"
"io/ioutil"
"os"
"regexp"
"sort"
"time"
"github.com/usememos/memos/common"
"github.com/usememos/memos/server/profile"
_ "github.com/mattn/go-sqlite3"
"github.com/usememos/memos/server/version"
)
//go:embed migration
@@ -38,47 +36,47 @@ func NewDB(profile *profile.Profile) *DB {
return db
}
func (db *DB) Open() (err error) {
func (db *DB) Open(ctx context.Context) (err error) {
// Ensure a DSN is set before attempting to open the database.
if db.profile.DSN == "" {
return fmt.Errorf("dsn required")
}
// Connect to the database.
sqlDB, err := sql.Open("sqlite3", db.profile.DSN)
// Connect to the database without foreign_keys config.
tempDB, err := sql.Open("sqlite3", db.profile.DSN)
if err != nil {
return fmt.Errorf("failed to open db with dsn: %s, err: %w", db.profile.DSN, err)
}
db.Db = sqlDB
db.Db = tempDB
// If mode is dev, we should migrate and seed the database.
if db.profile.Mode == "dev" {
if err := db.applyLatestSchema(); err != nil {
return fmt.Errorf("failed to apply latest schema: %w", err)
}
if err := db.seed(); err != nil {
return fmt.Errorf("failed to seed: %w", err)
if _, err := os.Stat(db.profile.DSN); errors.Is(err, os.ErrNotExist) {
if err := db.applyLatestSchema(ctx); err != nil {
return fmt.Errorf("failed to apply latest schema: %w", err)
}
if err := db.seed(ctx); err != nil {
return fmt.Errorf("failed to seed: %w", err)
}
}
} else {
// If db file not exists, we should migrate the database.
if _, err := os.Stat(db.profile.DSN); errors.Is(err, os.ErrNotExist) {
err := db.applyLatestSchema()
if err != nil {
if err := db.applyLatestSchema(ctx); err != nil {
return fmt.Errorf("failed to apply latest schema: %w", err)
}
} else {
err := db.createMigrationHistoryTable()
if err != nil {
if err := db.createMigrationHistoryTable(ctx); err != nil {
return fmt.Errorf("failed to create migration_history table: %w", err)
}
currentVersion := common.GetCurrentVersion(db.profile.Mode)
migrationHistory, err := findMigrationHistory(db.Db, &MigrationHistoryFind{})
currentVersion := version.GetCurrentVersion(db.profile.Mode)
migrationHistory, err := db.FindMigrationHistory(ctx, &MigrationHistoryFind{})
if err != nil {
return err
}
if migrationHistory == nil {
migrationHistory, err = upsertMigrationHistory(db.Db, &MigrationHistoryUpsert{
migrationHistory, err = db.UpsertMigrationHistory(ctx, &MigrationHistoryUpsert{
Version: currentVersion,
})
if err != nil {
@@ -86,16 +84,16 @@ func (db *DB) Open() (err error) {
}
}
if common.IsVersionGreaterThan(currentVersion, migrationHistory.Version) {
if version.IsVersionGreaterThan(version.GetSchemaVersion(currentVersion), migrationHistory.Version) {
minorVersionList := getMinorVersionList()
// backup the raw database file before migration
rawBytes, err := ioutil.ReadFile(db.profile.DSN)
rawBytes, err := os.ReadFile(db.profile.DSN)
if err != nil {
return fmt.Errorf("failed to read raw database file, err: %w", err)
}
backupDBFilePath := fmt.Sprintf("%s/memos_%s_%d_backup.db", db.profile.Data, db.profile.Version, time.Now().Unix())
if err := ioutil.WriteFile(backupDBFilePath, rawBytes, 0644); err != nil {
if err := os.WriteFile(backupDBFilePath, rawBytes, 0644); err != nil {
return fmt.Errorf("failed to write raw database file, err: %w", err)
}
@@ -103,9 +101,9 @@ func (db *DB) Open() (err error) {
println("start migrate")
for _, minorVersion := range minorVersionList {
normalizedVersion := minorVersion + ".0"
if common.IsVersionGreaterThan(normalizedVersion, migrationHistory.Version) && common.IsVersionGreaterOrEqualThan(currentVersion, normalizedVersion) {
if version.IsVersionGreaterThan(normalizedVersion, migrationHistory.Version) && version.IsVersionGreaterOrEqualThan(currentVersion, normalizedVersion) {
println("applying migration for", normalizedVersion)
if err := db.applyMigrationForMinorVersion(minorVersion); err != nil {
if err := db.applyMigrationForMinorVersion(ctx, minorVersion); err != nil {
return fmt.Errorf("failed to apply minor version migration: %w", err)
}
}
@@ -120,6 +118,18 @@ func (db *DB) Open() (err error) {
}
}
if err := tempDB.Close(); err != nil {
return fmt.Errorf("failed to close temp db without foreign_keys, err: %w", err)
}
// Connect to the database with foreign_keys config.
sqlDB, err := sql.Open("sqlite3", db.profile.DSN+"?_foreign_keys=1")
if err != nil {
return fmt.Errorf("failed to open db with dsn: %s, err: %w", db.profile.DSN, err)
}
db.Db = sqlDB
return err
}
@@ -127,20 +137,20 @@ const (
latestSchemaFileName = "LATEST__SCHEMA.sql"
)
func (db *DB) applyLatestSchema() error {
func (db *DB) applyLatestSchema(ctx context.Context) error {
latestSchemaPath := fmt.Sprintf("%s/%s/%s", "migration", db.profile.Mode, latestSchemaFileName)
buf, err := migrationFS.ReadFile(latestSchemaPath)
if err != nil {
return fmt.Errorf("failed to read latest schema %q, error %w", latestSchemaPath, err)
}
stmt := string(buf)
if err := db.execute(stmt); err != nil {
if err := db.execute(ctx, stmt); err != nil {
return fmt.Errorf("migrate error: statement:%s err=%w", stmt, err)
}
return nil
}
func (db *DB) applyMigrationForMinorVersion(minorVersion string) error {
func (db *DB) applyMigrationForMinorVersion(ctx context.Context, minorVersion string) error {
filenames, err := fs.Glob(migrationFS, fmt.Sprintf("%s/%s/*.sql", "migration/prod", minorVersion))
if err != nil {
return err
@@ -157,22 +167,28 @@ func (db *DB) applyMigrationForMinorVersion(minorVersion string) error {
}
stmt := string(buf)
migrationStmt += stmt
if err := db.execute(stmt); err != nil {
if err := db.execute(ctx, stmt); err != nil {
return fmt.Errorf("migrate error: statement:%s err=%w", stmt, err)
}
}
tx, err := db.Db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
// upsert the newest version to migration_history
if _, err = upsertMigrationHistory(db.Db, &MigrationHistoryUpsert{
if _, err = upsertMigrationHistory(ctx, tx, &MigrationHistoryUpsert{
Version: minorVersion + ".0",
}); err != nil {
return err
}
return nil
return tx.Commit()
}
func (db *DB) seed() error {
func (db *DB) seed(ctx context.Context) error {
filenames, err := fs.Glob(seedFS, fmt.Sprintf("%s/*.sql", "seed"))
if err != nil {
return err
@@ -187,22 +203,22 @@ func (db *DB) seed() error {
return fmt.Errorf("failed to read seed file, filename=%s err=%w", filename, err)
}
stmt := string(buf)
if err := db.execute(stmt); err != nil {
if err := db.execute(ctx, stmt); err != nil {
return fmt.Errorf("seed error: statement:%s err=%w", stmt, err)
}
}
return nil
}
// excecute runs a single SQL statement within a transaction.
func (db *DB) execute(stmt string) error {
// execute runs a single SQL statement within a transaction.
func (db *DB) execute(ctx context.Context, stmt string) error {
tx, err := db.Db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec(stmt); err != nil {
if _, err := tx.ExecContext(ctx, stmt); err != nil {
return err
}
@@ -234,8 +250,14 @@ func getMinorVersionList() []string {
}
// createMigrationHistoryTable creates the migration_history table if it doesn't exist.
func (db *DB) createMigrationHistoryTable() error {
if err := createTable(db.Db, `
func (db *DB) createMigrationHistoryTable(ctx context.Context) error {
tx, err := db.Db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if err := createTable(ctx, tx, `
CREATE TABLE IF NOT EXISTS migration_history (
version TEXT NOT NULL PRIMARY KEY,
created_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now'))
@@ -244,5 +266,5 @@ func (db *DB) createMigrationHistoryTable() error {
return err
}
return nil
return tx.Commit()
}

View File

@@ -1,8 +1,11 @@
-- drop all tables
DROP TABLE IF EXISTS `system_setting`;
DROP TABLE IF EXISTS `memo_resource`;
DROP TABLE IF EXISTS `memo_organizer`;
DROP TABLE IF EXISTS `memo`;
DROP TABLE IF EXISTS `shortcut`;
DROP TABLE IF EXISTS `resource`;
DROP TABLE IF EXISTS `user_setting`;
DROP TABLE IF EXISTS `user`;
-- user
@@ -10,7 +13,6 @@ CREATE TABLE user (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
updated_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
-- allowed row status are 'NORMAL', 'ARCHIVED'.
row_status TEXT NOT NULL CHECK (row_status IN ('NORMAL', 'ARCHIVED')) DEFAULT 'NORMAL',
email TEXT NOT NULL UNIQUE,
role TEXT NOT NULL CHECK (role IN ('HOST', 'USER')) DEFAULT 'USER',
@@ -117,7 +119,8 @@ CREATE TABLE resource (
created_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
updated_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
filename TEXT NOT NULL DEFAULT '',
blob BLOB NOT NULL,
blob BLOB DEFAULT NULL,
external_link TEXT NOT NULL DEFAULT '',
type TEXT NOT NULL DEFAULT '',
size INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY(creator_id) REFERENCES user(id) ON DELETE CASCADE
@@ -139,3 +142,31 @@ SET
WHERE
rowid = old.rowid;
END;
-- user_setting
CREATE TABLE user_setting (
user_id INTEGER NOT NULL,
key TEXT NOT NULL,
value TEXT NOT NULL,
FOREIGN KEY(user_id) REFERENCES user(id) ON DELETE CASCADE,
UNIQUE(user_id, key)
);
-- memo_resource
CREATE TABLE memo_resource (
memo_id INTEGER NOT NULL,
resource_id INTEGER NOT NULL,
created_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
updated_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
FOREIGN KEY(memo_id) REFERENCES memo(id) ON DELETE CASCADE,
FOREIGN KEY(resource_id) REFERENCES resource(id) ON DELETE CASCADE,
UNIQUE(memo_id, resource_id)
);
-- system_setting
CREATE TABLE system_setting (
name TEXT NOT NULL,
value TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
UNIQUE(name)
);

View File

@@ -1 +1 @@
ALTER TABLE memo ADD visibility TEXT NOT NULL CHECK (visibility IN ('PUBLIC', 'PRIVATE')) DEFAULT 'PRIVATE';
ALTER TABLE memo ADD COLUMN visibility TEXT NOT NULL CHECK (visibility IN ('PUBLIC', 'PRIVATE')) DEFAULT 'PRIVATE';

View File

@@ -0,0 +1,9 @@
-- user_setting
CREATE TABLE user_setting (
user_id INTEGER NOT NULL,
key TEXT NOT NULL,
value TEXT NOT NULL,
FOREIGN KEY(user_id) REFERENCES user(id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX user_setting_key_user_id_index ON user_setting(key, user_id);

View File

@@ -0,0 +1,177 @@
PRAGMA foreign_keys=off;
DROP TABLE IF EXISTS _user_old;
ALTER TABLE user RENAME TO _user_old;
-- user
CREATE TABLE user (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
updated_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
row_status TEXT NOT NULL CHECK (row_status IN ('NORMAL', 'ARCHIVED')) DEFAULT 'NORMAL',
email TEXT NOT NULL UNIQUE,
role TEXT NOT NULL CHECK (role IN ('HOST', 'USER')) DEFAULT 'USER',
name TEXT NOT NULL,
password_hash TEXT NOT NULL,
open_id TEXT NOT NULL UNIQUE
);
INSERT INTO user SELECT * FROM _user_old;
DROP TABLE IF EXISTS _user_old;
DROP TRIGGER IF EXISTS `trigger_update_user_modification_time`;
CREATE TRIGGER IF NOT EXISTS `trigger_update_user_modification_time`
AFTER
UPDATE
ON `user` FOR EACH ROW BEGIN
UPDATE
`user`
SET
updated_ts = (strftime('%s', 'now'))
WHERE
rowid = old.rowid;
END;
DROP TABLE IF EXISTS _memo_old;
ALTER TABLE memo RENAME TO _memo_old;
-- memo
CREATE TABLE memo (
id INTEGER PRIMARY KEY AUTOINCREMENT,
creator_id INTEGER NOT NULL,
created_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
updated_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
row_status TEXT NOT NULL CHECK (row_status IN ('NORMAL', 'ARCHIVED')) DEFAULT 'NORMAL',
content TEXT NOT NULL DEFAULT '',
visibility TEXT NOT NULL CHECK (visibility IN ('PUBLIC', 'PROTECTED', 'PRIVATE')) DEFAULT 'PRIVATE',
FOREIGN KEY(creator_id) REFERENCES user(id) ON DELETE CASCADE
);
INSERT INTO memo SELECT * FROM _memo_old;
DROP TABLE IF EXISTS _memo_old;
DROP TRIGGER IF EXISTS `trigger_update_memo_modification_time`;
CREATE TRIGGER IF NOT EXISTS `trigger_update_memo_modification_time`
AFTER
UPDATE
ON `memo` FOR EACH ROW BEGIN
UPDATE
`memo`
SET
updated_ts = (strftime('%s', 'now'))
WHERE
rowid = old.rowid;
END;
DROP TABLE IF EXISTS _memo_organizer_old;
ALTER TABLE memo_organizer RENAME TO _memo_organizer_old;
-- memo_organizer
CREATE TABLE memo_organizer (
id INTEGER PRIMARY KEY AUTOINCREMENT,
memo_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
pinned INTEGER NOT NULL CHECK (pinned IN (0, 1)) DEFAULT 0,
FOREIGN KEY(memo_id) REFERENCES memo(id) ON DELETE CASCADE,
FOREIGN KEY(user_id) REFERENCES user(id) ON DELETE CASCADE,
UNIQUE(memo_id, user_id)
);
INSERT INTO memo_organizer SELECT * FROM _memo_organizer_old;
DROP TABLE IF EXISTS _memo_organizer_old;
DROP TABLE IF EXISTS _shortcut_old;
ALTER TABLE shortcut RENAME TO _shortcut_old;
-- shortcut
CREATE TABLE shortcut (
id INTEGER PRIMARY KEY AUTOINCREMENT,
creator_id INTEGER NOT NULL,
created_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
updated_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
row_status TEXT NOT NULL CHECK (row_status IN ('NORMAL', 'ARCHIVED')) DEFAULT 'NORMAL',
title TEXT NOT NULL DEFAULT '',
payload TEXT NOT NULL DEFAULT '{}',
FOREIGN KEY(creator_id) REFERENCES user(id) ON DELETE CASCADE
);
INSERT INTO shortcut SELECT * FROM _shortcut_old;
DROP TABLE IF EXISTS _shortcut_old;
DROP TRIGGER IF EXISTS `trigger_update_shortcut_modification_time`;
CREATE TRIGGER IF NOT EXISTS `trigger_update_shortcut_modification_time`
AFTER
UPDATE
ON `shortcut` FOR EACH ROW BEGIN
UPDATE
`shortcut`
SET
updated_ts = (strftime('%s', 'now'))
WHERE
rowid = old.rowid;
END;
DROP TABLE IF EXISTS _resource_old;
ALTER TABLE resource RENAME TO _resource_old;
-- resource
CREATE TABLE resource (
id INTEGER PRIMARY KEY AUTOINCREMENT,
creator_id INTEGER NOT NULL,
created_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
updated_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
filename TEXT NOT NULL DEFAULT '',
blob BLOB DEFAULT NULL,
type TEXT NOT NULL DEFAULT '',
size INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY(creator_id) REFERENCES user(id) ON DELETE CASCADE
);
INSERT INTO resource SELECT * FROM _resource_old;
DROP TABLE IF EXISTS _resource_old;
DROP TRIGGER IF EXISTS `trigger_update_resource_modification_time`;
CREATE TRIGGER IF NOT EXISTS `trigger_update_resource_modification_time`
AFTER
UPDATE
ON `resource` FOR EACH ROW BEGIN
UPDATE
`resource`
SET
updated_ts = (strftime('%s', 'now'))
WHERE
rowid = old.rowid;
END;
DROP TABLE IF EXISTS _user_setting_old;
ALTER TABLE user_setting RENAME TO _user_setting_old;
-- user_setting
CREATE TABLE user_setting (
user_id INTEGER NOT NULL,
key TEXT NOT NULL,
value TEXT NOT NULL,
FOREIGN KEY(user_id) REFERENCES user(id) ON DELETE CASCADE,
UNIQUE(user_id, key)
);
INSERT INTO user_setting SELECT * FROM _user_setting_old;
DROP TABLE IF EXISTS _user_setting_old;
PRAGMA foreign_keys=on;

View File

@@ -0,0 +1,10 @@
-- memo_resource
CREATE TABLE memo_resource (
memo_id INTEGER NOT NULL,
resource_id INTEGER NOT NULL,
created_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
updated_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
FOREIGN KEY(memo_id) REFERENCES memo(id) ON DELETE CASCADE,
FOREIGN KEY(resource_id) REFERENCES resource(id) ON DELETE CASCADE,
UNIQUE(memo_id, resource_id)
);

View File

@@ -0,0 +1,7 @@
-- system_setting
CREATE TABLE system_setting (
name TEXT NOT NULL,
value TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
UNIQUE(name)
);

View File

@@ -0,0 +1 @@
ALTER TABLE resource ADD COLUMN external_link TEXT NOT NULL DEFAULT '';

View File

@@ -0,0 +1,55 @@
DROP TRIGGER IF EXISTS `trigger_update_user_modification_time`;
CREATE TRIGGER IF NOT EXISTS `trigger_update_user_modification_time`
AFTER
UPDATE
ON `user` FOR EACH ROW BEGIN
UPDATE
`user`
SET
updated_ts = (strftime('%s', 'now'))
WHERE
rowid = old.rowid;
END;
DROP TRIGGER IF EXISTS `trigger_update_memo_modification_time`;
CREATE TRIGGER IF NOT EXISTS `trigger_update_memo_modification_time`
AFTER
UPDATE
ON `memo` FOR EACH ROW BEGIN
UPDATE
`memo`
SET
updated_ts = (strftime('%s', 'now'))
WHERE
rowid = old.rowid;
END;
DROP TRIGGER IF EXISTS `trigger_update_shortcut_modification_time`;
CREATE TRIGGER IF NOT EXISTS `trigger_update_shortcut_modification_time`
AFTER
UPDATE
ON `shortcut` FOR EACH ROW BEGIN
UPDATE
`shortcut`
SET
updated_ts = (strftime('%s', 'now'))
WHERE
rowid = old.rowid;
END;
DROP TRIGGER IF EXISTS `trigger_update_resource_modification_time`;
CREATE TRIGGER IF NOT EXISTS `trigger_update_resource_modification_time`
AFTER
UPDATE
ON `resource` FOR EACH ROW BEGIN
UPDATE
`resource`
SET
updated_ts = (strftime('%s', 'now'))
WHERE
rowid = old.rowid;
END;

View File

@@ -1,8 +1,11 @@
-- drop all tables
DROP TABLE IF EXISTS `system_setting`;
DROP TABLE IF EXISTS `memo_resource`;
DROP TABLE IF EXISTS `memo_organizer`;
DROP TABLE IF EXISTS `memo`;
DROP TABLE IF EXISTS `shortcut`;
DROP TABLE IF EXISTS `resource`;
DROP TABLE IF EXISTS `user_setting`;
DROP TABLE IF EXISTS `user`;
-- user
@@ -10,7 +13,6 @@ CREATE TABLE user (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
updated_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
-- allowed row status are 'NORMAL', 'ARCHIVED'.
row_status TEXT NOT NULL CHECK (row_status IN ('NORMAL', 'ARCHIVED')) DEFAULT 'NORMAL',
email TEXT NOT NULL UNIQUE,
role TEXT NOT NULL CHECK (role IN ('HOST', 'USER')) DEFAULT 'USER',
@@ -117,7 +119,8 @@ CREATE TABLE resource (
created_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
updated_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
filename TEXT NOT NULL DEFAULT '',
blob BLOB NOT NULL,
blob BLOB DEFAULT NULL,
external_link TEXT NOT NULL DEFAULT '',
type TEXT NOT NULL DEFAULT '',
size INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY(creator_id) REFERENCES user(id) ON DELETE CASCADE
@@ -139,3 +142,31 @@ SET
WHERE
rowid = old.rowid;
END;
-- user_setting
CREATE TABLE user_setting (
user_id INTEGER NOT NULL,
key TEXT NOT NULL,
value TEXT NOT NULL,
FOREIGN KEY(user_id) REFERENCES user(id) ON DELETE CASCADE,
UNIQUE(user_id, key)
);
-- memo_resource
CREATE TABLE memo_resource (
memo_id INTEGER NOT NULL,
resource_id INTEGER NOT NULL,
created_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
updated_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
FOREIGN KEY(memo_id) REFERENCES memo(id) ON DELETE CASCADE,
FOREIGN KEY(resource_id) REFERENCES resource(id) ON DELETE CASCADE,
UNIQUE(memo_id, resource_id)
);
-- system_setting
CREATE TABLE system_setting (
name TEXT NOT NULL,
value TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
UNIQUE(name)
);

View File

@@ -1,6 +1,7 @@
package db
import (
"context"
"database/sql"
"strings"
)
@@ -18,23 +19,62 @@ type MigrationHistoryFind struct {
Version *string
}
func findMigrationHistoryList(db *sql.DB, find *MigrationHistoryFind) ([]*MigrationHistory, error) {
func (db *DB) FindMigrationHistory(ctx context.Context, find *MigrationHistoryFind) (*MigrationHistory, error) {
tx, err := db.Db.Begin()
if err != nil {
return nil, err
}
defer tx.Rollback()
list, err := findMigrationHistoryList(ctx, tx, find)
if err != nil {
return nil, err
}
if len(list) == 0 {
return nil, nil
}
migrationHistory := list[0]
return migrationHistory, nil
}
func (db *DB) UpsertMigrationHistory(ctx context.Context, upsert *MigrationHistoryUpsert) (*MigrationHistory, error) {
tx, err := db.Db.Begin()
if err != nil {
return nil, err
}
defer tx.Rollback()
migrationHistory, err := upsertMigrationHistory(ctx, tx, upsert)
if err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, err
}
return migrationHistory, nil
}
func findMigrationHistoryList(ctx context.Context, tx *sql.Tx, find *MigrationHistoryFind) ([]*MigrationHistory, error) {
where, args := []string{"1 = 1"}, []interface{}{}
if v := find.Version; v != nil {
where, args = append(where, "version = ?"), append(args, *v)
}
rows, err := db.Query(`
query := `
SELECT
version,
created_ts
FROM
migration_history
WHERE `+strings.Join(where, " AND ")+`
ORDER BY created_ts DESC`,
args...,
)
WHERE ` + strings.Join(where, " AND ") + `
ORDER BY version DESC
`
rows, err := tx.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
@@ -53,24 +93,15 @@ func findMigrationHistoryList(db *sql.DB, find *MigrationHistoryFind) ([]*Migrat
migrationHistoryList = append(migrationHistoryList, &migrationHistory)
}
return migrationHistoryList, nil
}
func findMigrationHistory(db *sql.DB, find *MigrationHistoryFind) (*MigrationHistory, error) {
list, err := findMigrationHistoryList(db, find)
if err != nil {
if err := rows.Err(); err != nil {
return nil, err
}
if len(list) == 0 {
return nil, nil
} else {
return list[0], nil
}
return migrationHistoryList, nil
}
func upsertMigrationHistory(db *sql.DB, upsert *MigrationHistoryUpsert) (*MigrationHistory, error) {
row, err := db.Query(`
func upsertMigrationHistory(ctx context.Context, tx *sql.Tx, upsert *MigrationHistoryUpsert) (*MigrationHistory, error) {
query := `
INSERT INTO migration_history (
version
)
@@ -79,9 +110,8 @@ func upsertMigrationHistory(db *sql.DB, upsert *MigrationHistoryUpsert) (*Migrat
SET
version=EXCLUDED.version
RETURNING version, created_ts
`,
upsert.Version,
)
`
row, err := tx.QueryContext(ctx, query, upsert.Version)
if err != nil {
return nil, err
}
@@ -96,5 +126,9 @@ func upsertMigrationHistory(db *sql.DB, upsert *MigrationHistoryUpsert) (*Migrat
return nil, err
}
if err := row.Err(); err != nil {
return nil, err
}
return &migrationHistory, nil
}

View File

@@ -7,8 +7,7 @@ INSERT INTO
VALUES
(
1001,
"#Hello 👋 Welcome to memos.
And here is old Jack's Page: [/u/102](/u/102)",
"#Hello 👋 Welcome to memos.",
101
);
@@ -23,9 +22,9 @@ VALUES
(
1002,
'#TODO
- [ ] Take more photos about **🌄 sunset**;
- [x] Take more photos about **🌄 sunset**;
- [x] Clean the room;
- [x] Read *📖 The Little Prince*;
- [ ] Read *📖 The Little Prince*;
(👆 click to toggle status)',
101,
'PROTECTED'
@@ -41,7 +40,9 @@ INSERT INTO
VALUES
(
1003,
'好好学习,天天向上。🤜🤛',
"DevJoy is **the Developer's ChinaJoy**.
![](https://www.devjoy.org/images/skateboard.webp)
🌐 [devjoy.org](https://www.devjoy.org/)",
101,
'PUBLIC'
);

View File

@@ -10,3 +10,16 @@ VALUES
101,
1
);
INSERT INTO
memo_organizer (
`memo_id`,
`user_id`,
`pinned`
)
VALUES
(
1003,
101,
1
);

View File

@@ -1,10 +1,12 @@
INSERT INTO
shortcut (
`title`,
`creator_id`
`creator_id`,
`payload`
)
VALUES
(
'All my memos',
101
'inbox',
101,
'[{"type":"TYPE","value":{"operator":"IS","value":"NOT_TAGGED"},"relation":"AND"}]'
);

View File

@@ -1,6 +1,7 @@
package db
import (
"context"
"database/sql"
"strings"
)
@@ -11,20 +12,20 @@ type Table struct {
}
//lint:ignore U1000 Ignore unused function temporarily for debugging
func findTable(db *sql.DB, tableName string) (*Table, error) {
//nolint:all
func findTable(ctx context.Context, tx *sql.Tx, tableName string) (*Table, error) {
where, args := []string{"1 = 1"}, []interface{}{}
where, args = append(where, "type = ?"), append(args, "table")
where, args = append(where, "name = ?"), append(args, tableName)
rows, err := db.Query(`
query := `
SELECT
tbl_name,
sql
FROM sqlite_schema
WHERE `+strings.Join(where, " AND "),
args...,
)
WHERE ` + strings.Join(where, " AND ")
rows, err := tx.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
@@ -54,13 +55,11 @@ func findTable(db *sql.DB, tableName string) (*Table, error) {
}
}
func createTable(db *sql.DB, sql string) error {
result, err := db.Exec(sql)
func createTable(ctx context.Context, tx *sql.Tx, stmt string) error {
_, err := tx.ExecContext(ctx, stmt)
if err != nil {
return err
}
_, err = result.RowsAffected()
return err
return nil
}

View File

@@ -1,7 +1,9 @@
package store
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"strings"
@@ -40,16 +42,71 @@ func (raw *memoRaw) toMemo() *api.Memo {
// Domain specific fields
Content: raw.Content,
Visibility: raw.Visibility,
DisplayTs: raw.CreatedTs,
}
}
func (s *Store) CreateMemo(create *api.MemoCreate) (*api.Memo, error) {
memoRaw, err := createMemoRaw(s.db, create)
func (s *Store) ComposeMemo(ctx context.Context, memo *api.Memo) (*api.Memo, error) {
memoOrganizer, err := s.FindMemoOrganizer(ctx, &api.MemoOrganizerFind{
MemoID: memo.ID,
UserID: memo.CreatorID,
})
if err != nil && common.ErrorCode(err) != common.NotFound {
return nil, err
} else if memoOrganizer != nil {
memo.Pinned = memoOrganizer.Pinned
}
if err = s.ComposeMemoCreator(ctx, memo); err != nil {
return nil, err
}
if err = s.ComposeMemoResourceList(ctx, memo); err != nil {
return nil, err
}
memoDisplayTsOptionKey := api.UserSettingMemoDisplayTsOptionKey
memoDisplayTsOptionSetting, err := s.FindUserSetting(ctx, &api.UserSettingFind{
UserID: memo.CreatorID,
Key: &memoDisplayTsOptionKey,
})
if err != nil {
return nil, err
}
memoDisplayTsOptionValue := "created_ts"
if memoDisplayTsOptionSetting != nil {
err = json.Unmarshal([]byte(memoDisplayTsOptionSetting.Value), &memoDisplayTsOptionValue)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal user setting memo display ts option value")
}
}
if memoDisplayTsOptionValue == "updated_ts" {
memo.DisplayTs = memo.UpdatedTs
}
return memo, nil
}
func (s *Store) CreateMemo(ctx context.Context, create *api.MemoCreate) (*api.Memo, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, FormatError(err)
}
defer tx.Rollback()
memoRaw, err := createMemoRaw(ctx, tx, create)
if err != nil {
return nil, err
}
memo, err := s.composeMemo(memoRaw)
if err := tx.Commit(); err != nil {
return nil, FormatError(err)
}
if err := s.cache.UpsertCache(api.MemoCache, memoRaw.ID, memoRaw); err != nil {
return nil, err
}
memo, err := s.ComposeMemo(ctx, memoRaw.toMemo())
if err != nil {
return nil, err
}
@@ -57,13 +114,27 @@ func (s *Store) CreateMemo(create *api.MemoCreate) (*api.Memo, error) {
return memo, nil
}
func (s *Store) PatchMemo(patch *api.MemoPatch) (*api.Memo, error) {
memoRaw, err := patchMemoRaw(s.db, patch)
func (s *Store) PatchMemo(ctx context.Context, patch *api.MemoPatch) (*api.Memo, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, FormatError(err)
}
defer tx.Rollback()
memoRaw, err := patchMemoRaw(ctx, tx, patch)
if err != nil {
return nil, err
}
memo, err := s.composeMemo(memoRaw)
if err := tx.Commit(); err != nil {
return nil, FormatError(err)
}
if err := s.cache.UpsertCache(api.MemoCache, memoRaw.ID, memoRaw); err != nil {
return nil, err
}
memo, err := s.ComposeMemo(ctx, memoRaw.toMemo())
if err != nil {
return nil, err
}
@@ -71,15 +142,21 @@ func (s *Store) PatchMemo(patch *api.MemoPatch) (*api.Memo, error) {
return memo, nil
}
func (s *Store) FindMemoList(find *api.MemoFind) ([]*api.Memo, error) {
memoRawList, err := findMemoRawList(s.db, find)
func (s *Store) FindMemoList(ctx context.Context, find *api.MemoFind) ([]*api.Memo, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, FormatError(err)
}
defer tx.Rollback()
memoRawList, err := findMemoRawList(ctx, tx, find)
if err != nil {
return nil, err
}
list := []*api.Memo{}
for _, raw := range memoRawList {
memo, err := s.composeMemo(raw)
memo, err := s.ComposeMemo(ctx, raw.toMemo())
if err != nil {
return nil, err
}
@@ -90,8 +167,29 @@ func (s *Store) FindMemoList(find *api.MemoFind) ([]*api.Memo, error) {
return list, nil
}
func (s *Store) FindMemo(find *api.MemoFind) (*api.Memo, error) {
list, err := findMemoRawList(s.db, find)
func (s *Store) FindMemo(ctx context.Context, find *api.MemoFind) (*api.Memo, error) {
if find.ID != nil {
memoRaw := &memoRaw{}
has, err := s.cache.FindCache(api.MemoCache, *find.ID, memoRaw)
if err != nil {
return nil, err
}
if has {
memo, err := s.ComposeMemo(ctx, memoRaw.toMemo())
if err != nil {
return nil, err
}
return memo, nil
}
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, FormatError(err)
}
defer tx.Rollback()
list, err := findMemoRawList(ctx, tx, find)
if err != nil {
return nil, err
}
@@ -100,7 +198,12 @@ func (s *Store) FindMemo(find *api.MemoFind) (*api.Memo, error) {
return nil, &common.Error{Code: common.NotFound, Err: fmt.Errorf("not found")}
}
memo, err := s.composeMemo(list[0])
memoRaw := list[0]
if err := s.cache.UpsertCache(api.MemoCache, memoRaw.ID, memoRaw); err != nil {
return nil, err
}
memo, err := s.ComposeMemo(ctx, memoRaw.toMemo())
if err != nil {
return nil, err
}
@@ -108,44 +211,40 @@ func (s *Store) FindMemo(find *api.MemoFind) (*api.Memo, error) {
return memo, nil
}
func (s *Store) DeleteMemo(delete *api.MemoDelete) error {
err := deleteMemo(s.db, delete)
func (s *Store) DeleteMemo(ctx context.Context, delete *api.MemoDelete) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return FormatError(err)
}
defer tx.Rollback()
if err := deleteMemo(ctx, tx, delete); err != nil {
return FormatError(err)
}
if err := tx.Commit(); err != nil {
return FormatError(err)
}
s.cache.DeleteCache(api.MemoCache, delete.ID)
return nil
}
func createMemoRaw(db *sql.DB, create *api.MemoCreate) (*memoRaw, error) {
set := []string{"creator_id", "content"}
placeholder := []string{"?", "?"}
args := []interface{}{create.CreatorID, create.Content}
if v := create.Visibility; v != nil {
set, placeholder, args = append(set, "visibility"), append(placeholder, "?"), append(args, *v)
}
if v := create.CreatedTs; v != nil {
set, placeholder, args = append(set, "created_ts"), append(placeholder, "?"), append(args, *v)
}
func createMemoRaw(ctx context.Context, tx *sql.Tx, create *api.MemoCreate) (*memoRaw, error) {
set := []string{"creator_id", "content", "visibility"}
args := []interface{}{create.CreatorID, create.Content, create.Visibility}
placeholder := []string{"?", "?", "?"}
query := `
INSERT INTO memo (
` + strings.Join(set, ", ") + `
)
VALUES (` + strings.Join(placeholder, ",") + `)
RETURNING id, creator_id, created_ts, updated_ts, row_status, content, visibility`
row, err := db.Query(query,
args...,
)
if err != nil {
return nil, FormatError(err)
}
defer row.Close()
row.Next()
INSERT INTO memo (
` + strings.Join(set, ", ") + `
)
VALUES (` + strings.Join(placeholder, ",") + `)
RETURNING id, creator_id, created_ts, updated_ts, row_status, content, visibility
`
var memoRaw memoRaw
if err := row.Scan(
if err := tx.QueryRowContext(ctx, query, args...).Scan(
&memoRaw.ID,
&memoRaw.CreatorID,
&memoRaw.CreatedTs,
@@ -160,36 +259,35 @@ func createMemoRaw(db *sql.DB, create *api.MemoCreate) (*memoRaw, error) {
return &memoRaw, nil
}
func patchMemoRaw(db *sql.DB, patch *api.MemoPatch) (*memoRaw, error) {
func patchMemoRaw(ctx context.Context, tx *sql.Tx, patch *api.MemoPatch) (*memoRaw, error) {
set, args := []string{}, []interface{}{}
if v := patch.Content; v != nil {
set, args = append(set, "content = ?"), append(args, *v)
if v := patch.CreatedTs; v != nil {
set, args = append(set, "created_ts = ?"), append(args, *v)
}
if v := patch.UpdatedTs; v != nil {
set, args = append(set, "updated_ts = ?"), append(args, *v)
}
if v := patch.RowStatus; v != nil {
set, args = append(set, "row_status = ?"), append(args, *v)
}
if v := patch.Content; v != nil {
set, args = append(set, "content = ?"), append(args, *v)
}
if v := patch.Visibility; v != nil {
set, args = append(set, "visibility = ?"), append(args, *v)
}
args = append(args, patch.ID)
row, err := db.Query(`
query := `
UPDATE memo
SET `+strings.Join(set, ", ")+`
SET ` + strings.Join(set, ", ") + `
WHERE id = ?
RETURNING id, creator_id, created_ts, updated_ts, row_status, content, visibility
`, args...)
if err != nil {
return nil, FormatError(err)
}
defer row.Close()
row.Next()
`
var memoRaw memoRaw
if err := row.Scan(
if err := tx.QueryRowContext(ctx, query, args...).Scan(
&memoRaw.ID,
&memoRaw.CreatorID,
&memoRaw.CreatedTs,
@@ -204,7 +302,7 @@ func patchMemoRaw(db *sql.DB, patch *api.MemoPatch) (*memoRaw, error) {
return &memoRaw, nil
}
func findMemoRawList(db *sql.DB, find *api.MemoFind) ([]*memoRaw, error) {
func findMemoRawList(ctx context.Context, tx *sql.Tx, find *api.MemoFind) ([]*memoRaw, error) {
where, args := []string{"1 = 1"}, []interface{}{}
if v := find.ID; v != nil {
@@ -217,7 +315,7 @@ func findMemoRawList(db *sql.DB, find *api.MemoFind) ([]*memoRaw, error) {
where, args = append(where, "row_status = ?"), append(args, *v)
}
if v := find.Pinned; v != nil {
where = append(where, "id in (SELECT memo_id FROM memo_organizer WHERE pinned = 1 AND user_id = memo.creator_id )")
where = append(where, "id in (SELECT memo_id FROM memo_organizer WHERE pinned = 1 AND user_id = memo.creator_id)")
}
if v := find.ContentSearch; v != nil {
where, args = append(where, "content LIKE ?"), append(args, "%"+*v+"%")
@@ -239,7 +337,7 @@ func findMemoRawList(db *sql.DB, find *api.MemoFind) ([]*memoRaw, error) {
}
}
rows, err := db.Query(`
query := `
SELECT
id,
creator_id,
@@ -249,10 +347,10 @@ func findMemoRawList(db *sql.DB, find *api.MemoFind) ([]*memoRaw, error) {
content,
visibility
FROM memo
WHERE `+strings.Join(where, " AND ")+`
ORDER BY created_ts DESC`+pagination,
args...,
)
WHERE ` + strings.Join(where, " AND ") + `
ORDER BY created_ts DESC
` + pagination
rows, err := tx.QueryContext(ctx, query, args...)
if err != nil {
return nil, FormatError(err)
}
@@ -283,10 +381,9 @@ func findMemoRawList(db *sql.DB, find *api.MemoFind) ([]*memoRaw, error) {
return memoRawList, nil
}
func deleteMemo(db *sql.DB, delete *api.MemoDelete) error {
result, err := db.Exec(`
PRAGMA foreign_keys = ON;
DELETE FROM resource WHERE id = ?
func deleteMemo(ctx context.Context, tx *sql.Tx, delete *api.MemoDelete) error {
result, err := tx.ExecContext(ctx, `
DELETE FROM memo WHERE id = ?
`, delete.ID)
if err != nil {
return FormatError(err)
@@ -299,19 +396,3 @@ func deleteMemo(db *sql.DB, delete *api.MemoDelete) error {
return nil
}
func (s *Store) composeMemo(raw *memoRaw) (*api.Memo, error) {
memo := raw.toMemo()
memoOrganizer, err := s.FindMemoOrganizer(&api.MemoOrganizerFind{
MemoID: memo.ID,
UserID: memo.CreatorID,
})
if err != nil && common.ErrorCode(err) != common.NotFound {
return nil, err
} else if memoOrganizer != nil {
memo.Pinned = memoOrganizer.Pinned
}
return memo, nil
}

View File

@@ -1,6 +1,7 @@
package store
import (
"context"
"database/sql"
"fmt"
@@ -29,8 +30,14 @@ func (raw *memoOrganizerRaw) toMemoOrganizer() *api.MemoOrganizer {
}
}
func (s *Store) FindMemoOrganizer(find *api.MemoOrganizerFind) (*api.MemoOrganizer, error) {
memoOrganizerRaw, err := findMemoOrganizer(s.db, find)
func (s *Store) FindMemoOrganizer(ctx context.Context, find *api.MemoOrganizerFind) (*api.MemoOrganizer, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, FormatError(err)
}
defer tx.Rollback()
memoOrganizerRaw, err := findMemoOrganizer(ctx, tx, find)
if err != nil {
return nil, err
}
@@ -40,17 +47,26 @@ func (s *Store) FindMemoOrganizer(find *api.MemoOrganizerFind) (*api.MemoOrganiz
return memoOrganizer, nil
}
func (s *Store) UpsertMemoOrganizer(upsert *api.MemoOrganizerUpsert) error {
err := upsertMemoOrganizer(s.db, upsert)
func (s *Store) UpsertMemoOrganizer(ctx context.Context, upsert *api.MemoOrganizerUpsert) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return FormatError(err)
}
defer tx.Rollback()
if err := upsertMemoOrganizer(ctx, tx, upsert); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return FormatError(err)
}
return nil
}
func findMemoOrganizer(db *sql.DB, find *api.MemoOrganizerFind) (*memoOrganizerRaw, error) {
row, err := db.Query(`
func findMemoOrganizer(ctx context.Context, tx *sql.Tx, find *api.MemoOrganizerFind) (*memoOrganizerRaw, error) {
query := `
SELECT
id,
memo_id,
@@ -58,7 +74,8 @@ func findMemoOrganizer(db *sql.DB, find *api.MemoOrganizerFind) (*memoOrganizerR
pinned
FROM memo_organizer
WHERE memo_id = ? AND user_id = ?
`, find.MemoID, find.UserID)
`
row, err := tx.QueryContext(ctx, query, find.MemoID, find.UserID)
if err != nil {
return nil, FormatError(err)
}
@@ -78,11 +95,15 @@ func findMemoOrganizer(db *sql.DB, find *api.MemoOrganizerFind) (*memoOrganizerR
return nil, FormatError(err)
}
if err := row.Err(); err != nil {
return nil, err
}
return &memoOrganizerRaw, nil
}
func upsertMemoOrganizer(db *sql.DB, upsert *api.MemoOrganizerUpsert) error {
row, err := db.Query(`
func upsertMemoOrganizer(ctx context.Context, tx *sql.Tx, upsert *api.MemoOrganizerUpsert) error {
query := `
INSERT INTO memo_organizer (
memo_id,
user_id,
@@ -93,20 +114,9 @@ func upsertMemoOrganizer(db *sql.DB, upsert *api.MemoOrganizerUpsert) error {
SET
pinned = EXCLUDED.pinned
RETURNING id, memo_id, user_id, pinned
`,
upsert.MemoID,
upsert.UserID,
upsert.Pinned,
)
if err != nil {
return FormatError(err)
}
defer row.Close()
row.Next()
`
var memoOrganizer api.MemoOrganizer
if err := row.Scan(
if err := tx.QueryRowContext(ctx, query, upsert.MemoID, upsert.UserID, upsert.Pinned).Scan(
&memoOrganizer.ID,
&memoOrganizer.MemoID,
&memoOrganizer.UserID,

209
store/memo_resource.go Normal file
View File

@@ -0,0 +1,209 @@
package store
import (
"context"
"database/sql"
"fmt"
"strings"
"github.com/usememos/memos/api"
"github.com/usememos/memos/common"
)
// memoResourceRaw is the store model for an MemoResource.
// Fields have exactly the same meanings as MemoResource.
type memoResourceRaw struct {
MemoID int
ResourceID int
CreatedTs int64
UpdatedTs int64
}
func (raw *memoResourceRaw) toMemoResource() *api.MemoResource {
return &api.MemoResource{
MemoID: raw.MemoID,
ResourceID: raw.ResourceID,
CreatedTs: raw.CreatedTs,
UpdatedTs: raw.UpdatedTs,
}
}
func (s *Store) FindMemoResourceList(ctx context.Context, find *api.MemoResourceFind) ([]*api.MemoResource, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, FormatError(err)
}
defer tx.Rollback()
memoResourceRawList, err := findMemoResourceList(ctx, tx, find)
if err != nil {
return nil, err
}
list := []*api.MemoResource{}
for _, raw := range memoResourceRawList {
memoResource := raw.toMemoResource()
list = append(list, memoResource)
}
return list, nil
}
func (s *Store) FindMemoResource(ctx context.Context, find *api.MemoResourceFind) (*api.MemoResource, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, FormatError(err)
}
defer tx.Rollback()
list, err := findMemoResourceList(ctx, tx, find)
if err != nil {
return nil, err
}
if len(list) == 0 {
return nil, &common.Error{Code: common.NotFound, Err: fmt.Errorf("not found")}
}
memoResourceRaw := list[0]
return memoResourceRaw.toMemoResource(), nil
}
func (s *Store) UpsertMemoResource(ctx context.Context, upsert *api.MemoResourceUpsert) (*api.MemoResource, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, FormatError(err)
}
defer tx.Rollback()
memoResourceRaw, err := upsertMemoResource(ctx, tx, upsert)
if err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, FormatError(err)
}
return memoResourceRaw.toMemoResource(), nil
}
func (s *Store) DeleteMemoResource(ctx context.Context, delete *api.MemoResourceDelete) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return FormatError(err)
}
defer tx.Rollback()
if err := deleteMemoResource(ctx, tx, delete); err != nil {
return FormatError(err)
}
if err := tx.Commit(); err != nil {
return FormatError(err)
}
return nil
}
func findMemoResourceList(ctx context.Context, tx *sql.Tx, find *api.MemoResourceFind) ([]*memoResourceRaw, error) {
where, args := []string{"1 = 1"}, []interface{}{}
if v := find.MemoID; v != nil {
where, args = append(where, "memo_id = ?"), append(args, *v)
}
if v := find.ResourceID; v != nil {
where, args = append(where, "resource_id = ?"), append(args, *v)
}
query := `
SELECT
memo_id,
resource_id,
created_ts,
updated_ts
FROM memo_resource
WHERE ` + strings.Join(where, " AND ") + `
ORDER BY updated_ts DESC
`
rows, err := tx.QueryContext(ctx, query, args...)
if err != nil {
return nil, FormatError(err)
}
defer rows.Close()
memoResourceRawList := make([]*memoResourceRaw, 0)
for rows.Next() {
var memoResourceRaw memoResourceRaw
if err := rows.Scan(
&memoResourceRaw.MemoID,
&memoResourceRaw.ResourceID,
&memoResourceRaw.CreatedTs,
&memoResourceRaw.UpdatedTs,
); err != nil {
return nil, FormatError(err)
}
memoResourceRawList = append(memoResourceRawList, &memoResourceRaw)
}
if err := rows.Err(); err != nil {
return nil, err
}
return memoResourceRawList, nil
}
func upsertMemoResource(ctx context.Context, tx *sql.Tx, upsert *api.MemoResourceUpsert) (*memoResourceRaw, error) {
set := []string{"memo_id", "resource_id"}
args := []interface{}{upsert.MemoID, upsert.ResourceID}
placeholder := []string{"?", "?"}
if v := upsert.UpdatedTs; v != nil {
set, args, placeholder = append(set, "updated_ts"), append(args, v), append(placeholder, "?")
}
query := `
INSERT INTO memo_resource (
` + strings.Join(set, ", ") + `
)
VALUES (` + strings.Join(placeholder, ",") + `)
ON CONFLICT(memo_id, resource_id) DO UPDATE
SET
updated_ts = EXCLUDED.updated_ts
RETURNING memo_id, resource_id, created_ts, updated_ts
`
var memoResourceRaw memoResourceRaw
if err := tx.QueryRowContext(ctx, query, args...).Scan(
&memoResourceRaw.MemoID,
&memoResourceRaw.ResourceID,
&memoResourceRaw.CreatedTs,
&memoResourceRaw.UpdatedTs,
); err != nil {
return nil, FormatError(err)
}
return &memoResourceRaw, nil
}
func deleteMemoResource(ctx context.Context, tx *sql.Tx, delete *api.MemoResourceDelete) error {
where, args := []string{"memo_id = ?"}, []interface{}{delete.MemoID}
if v := delete.ResourceID; v != nil {
where, args = append(where, "resource_id = ?"), append(args, *v)
}
result, err := tx.ExecContext(ctx, `
DELETE FROM memo_resource WHERE `+strings.Join(where, " AND "), args...)
if err != nil {
return FormatError(err)
}
rows, _ := result.RowsAffected()
if rows == 0 {
return &common.Error{Code: common.NotFound, Err: fmt.Errorf("memo resource not found")}
}
return nil
}

View File

@@ -1,8 +1,10 @@
package store
import (
"context"
"database/sql"
"fmt"
"sort"
"strings"
"github.com/usememos/memos/api"
@@ -43,9 +45,57 @@ func (raw *resourceRaw) toResource() *api.Resource {
}
}
func (s *Store) CreateResource(create *api.ResourceCreate) (*api.Resource, error) {
resourceRaw, err := createResource(s.db, create)
func (s *Store) ComposeMemoResourceList(ctx context.Context, memo *api.Memo) error {
resourceList, err := s.FindResourceList(ctx, &api.ResourceFind{
MemoID: &memo.ID,
})
if err != nil {
return err
}
for _, resource := range resourceList {
memoResource, err := s.FindMemoResource(ctx, &api.MemoResourceFind{
MemoID: &memo.ID,
ResourceID: &resource.ID,
})
if err != nil {
return err
}
resource.CreatedTs = memoResource.CreatedTs
resource.UpdatedTs = memoResource.UpdatedTs
}
sort.Slice(resourceList, func(i, j int) bool {
if resourceList[i].CreatedTs != resourceList[j].CreatedTs {
return resourceList[i].CreatedTs < resourceList[j].CreatedTs
}
return resourceList[i].ID < resourceList[j].ID
})
memo.ResourceList = resourceList
return nil
}
func (s *Store) CreateResource(ctx context.Context, create *api.ResourceCreate) (*api.Resource, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, FormatError(err)
}
defer tx.Rollback()
resourceRaw, err := createResource(ctx, tx, create)
if err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, FormatError(err)
}
if err := s.cache.UpsertCache(api.ResourceCache, resourceRaw.ID, resourceRaw); err != nil {
return nil, err
}
@@ -54,8 +104,14 @@ func (s *Store) CreateResource(create *api.ResourceCreate) (*api.Resource, error
return resource, nil
}
func (s *Store) FindResourceList(find *api.ResourceFind) ([]*api.Resource, error) {
resourceRawList, err := findResourceList(s.db, find)
func (s *Store) FindResourceList(ctx context.Context, find *api.ResourceFind) ([]*api.Resource, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, FormatError(err)
}
defer tx.Rollback()
resourceRawList, err := findResourceList(ctx, tx, find)
if err != nil {
return nil, err
}
@@ -68,8 +124,25 @@ func (s *Store) FindResourceList(find *api.ResourceFind) ([]*api.Resource, error
return resourceList, nil
}
func (s *Store) FindResource(find *api.ResourceFind) (*api.Resource, error) {
list, err := findResourceList(s.db, find)
func (s *Store) FindResource(ctx context.Context, find *api.ResourceFind) (*api.Resource, error) {
if find.ID != nil {
resourceRaw := &resourceRaw{}
has, err := s.cache.FindCache(api.ResourceCache, *find.ID, resourceRaw)
if err != nil {
return nil, err
}
if has {
return resourceRaw.toResource(), nil
}
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, FormatError(err)
}
defer tx.Rollback()
list, err := findResourceList(ctx, tx, find)
if err != nil {
return nil, err
}
@@ -78,22 +151,45 @@ func (s *Store) FindResource(find *api.ResourceFind) (*api.Resource, error) {
return nil, &common.Error{Code: common.NotFound, Err: fmt.Errorf("not found")}
}
resource := list[0].toResource()
resourceRaw := list[0]
if err := s.cache.UpsertCache(api.ResourceCache, resourceRaw.ID, resourceRaw); err != nil {
return nil, err
}
resource := resourceRaw.toResource()
return resource, nil
}
func (s *Store) DeleteResource(delete *api.ResourceDelete) error {
err := deleteResource(s.db, delete)
func (s *Store) DeleteResource(ctx context.Context, delete *api.ResourceDelete) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return FormatError(err)
}
defer tx.Rollback()
err = deleteResource(ctx, tx, delete)
if err != nil {
return err
}
if err := tx.Commit(); err != nil {
return FormatError(err)
}
// Vacuum sqlite database file size after deleting resource.
if _, err := s.db.Exec("VACUUM"); err != nil {
return err
}
s.cache.DeleteCache(api.ResourceCache, delete.ID)
return nil
}
func createResource(db *sql.DB, create *api.ResourceCreate) (*resourceRaw, error) {
row, err := db.Query(`
func createResource(ctx context.Context, tx *sql.Tx, create *api.ResourceCreate) (*resourceRaw, error) {
query := `
INSERT INTO resource (
filename,
blob,
@@ -102,27 +198,16 @@ func createResource(db *sql.DB, create *api.ResourceCreate) (*resourceRaw, error
creator_id
)
VALUES (?, ?, ?, ?, ?)
RETURNING id, filename, blob, type, size, created_ts, updated_ts
`,
create.Filename,
create.Blob,
create.Type,
create.Size,
create.CreatorID,
)
if err != nil {
return nil, FormatError(err)
}
defer row.Close()
row.Next()
RETURNING id, filename, blob, type, size, creator_id, created_ts, updated_ts
`
var resourceRaw resourceRaw
if err := row.Scan(
if err := tx.QueryRowContext(ctx, query, create.Filename, create.Blob, create.Type, create.Size, create.CreatorID).Scan(
&resourceRaw.ID,
&resourceRaw.Filename,
&resourceRaw.Blob,
&resourceRaw.Type,
&resourceRaw.Size,
&resourceRaw.CreatorID,
&resourceRaw.CreatedTs,
&resourceRaw.UpdatedTs,
); err != nil {
@@ -132,7 +217,7 @@ func createResource(db *sql.DB, create *api.ResourceCreate) (*resourceRaw, error
return &resourceRaw, nil
}
func findResourceList(db *sql.DB, find *api.ResourceFind) ([]*resourceRaw, error) {
func findResourceList(ctx context.Context, tx *sql.Tx, find *api.ResourceFind) ([]*resourceRaw, error) {
where, args := []string{"1 = 1"}, []interface{}{}
if v := find.ID; v != nil {
@@ -144,21 +229,25 @@ func findResourceList(db *sql.DB, find *api.ResourceFind) ([]*resourceRaw, error
if v := find.Filename; v != nil {
where, args = append(where, "filename = ?"), append(args, *v)
}
if v := find.MemoID; v != nil {
where, args = append(where, "id in (SELECT resource_id FROM memo_resource WHERE memo_id = ?)"), append(args, *v)
}
rows, err := db.Query(`
query := `
SELECT
id,
filename,
blob,
type,
size,
creator_id,
created_ts,
updated_ts
FROM resource
WHERE `+strings.Join(where, " AND ")+`
ORDER BY created_ts DESC`,
args...,
)
WHERE ` + strings.Join(where, " AND ") + `
ORDER BY id DESC
`
rows, err := tx.QueryContext(ctx, query, args...)
if err != nil {
return nil, FormatError(err)
}
@@ -173,6 +262,7 @@ func findResourceList(db *sql.DB, find *api.ResourceFind) ([]*resourceRaw, error
&resourceRaw.Blob,
&resourceRaw.Type,
&resourceRaw.Size,
&resourceRaw.CreatorID,
&resourceRaw.CreatedTs,
&resourceRaw.UpdatedTs,
); err != nil {
@@ -189,11 +279,10 @@ func findResourceList(db *sql.DB, find *api.ResourceFind) ([]*resourceRaw, error
return resourceRawList, nil
}
func deleteResource(db *sql.DB, delete *api.ResourceDelete) error {
result, err := db.Exec(`
PRAGMA foreign_keys = ON;
DELETE FROM resource WHERE id = ?
`, delete.ID)
func deleteResource(ctx context.Context, tx *sql.Tx, delete *api.ResourceDelete) error {
result, err := tx.ExecContext(ctx, `
DELETE FROM resource WHERE id = ? AND creator_id = ?
`, delete.ID, delete.CreatorID)
if err != nil {
return FormatError(err)
}

View File

@@ -1,6 +1,7 @@
package store
import (
"context"
"database/sql"
"fmt"
"strings"
@@ -39,9 +40,23 @@ func (raw *shortcutRaw) toShortcut() *api.Shortcut {
}
}
func (s *Store) CreateShortcut(create *api.ShortcutCreate) (*api.Shortcut, error) {
shortcutRaw, err := createShortcut(s.db, create)
func (s *Store) CreateShortcut(ctx context.Context, create *api.ShortcutCreate) (*api.Shortcut, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, FormatError(err)
}
defer tx.Rollback()
shortcutRaw, err := createShortcut(ctx, tx, create)
if err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, FormatError(err)
}
if err := s.cache.UpsertCache(api.ShortcutCache, shortcutRaw.ID, shortcutRaw); err != nil {
return nil, err
}
@@ -50,9 +65,23 @@ func (s *Store) CreateShortcut(create *api.ShortcutCreate) (*api.Shortcut, error
return shortcut, nil
}
func (s *Store) PatchShortcut(patch *api.ShortcutPatch) (*api.Shortcut, error) {
shortcutRaw, err := patchShortcut(s.db, patch)
func (s *Store) PatchShortcut(ctx context.Context, patch *api.ShortcutPatch) (*api.Shortcut, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, FormatError(err)
}
defer tx.Rollback()
shortcutRaw, err := patchShortcut(ctx, tx, patch)
if err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, FormatError(err)
}
if err := s.cache.UpsertCache(api.ShortcutCache, shortcutRaw.ID, shortcutRaw); err != nil {
return nil, err
}
@@ -61,8 +90,14 @@ func (s *Store) PatchShortcut(patch *api.ShortcutPatch) (*api.Shortcut, error) {
return shortcut, nil
}
func (s *Store) FindShortcutList(find *api.ShortcutFind) ([]*api.Shortcut, error) {
shortcutRawList, err := findShortcutList(s.db, find)
func (s *Store) FindShortcutList(ctx context.Context, find *api.ShortcutFind) ([]*api.Shortcut, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, FormatError(err)
}
defer tx.Rollback()
shortcutRawList, err := findShortcutList(ctx, tx, find)
if err != nil {
return nil, err
}
@@ -75,8 +110,25 @@ func (s *Store) FindShortcutList(find *api.ShortcutFind) ([]*api.Shortcut, error
return list, nil
}
func (s *Store) FindShortcut(find *api.ShortcutFind) (*api.Shortcut, error) {
list, err := findShortcutList(s.db, find)
func (s *Store) FindShortcut(ctx context.Context, find *api.ShortcutFind) (*api.Shortcut, error) {
if find.ID != nil {
shortcutRaw := &shortcutRaw{}
has, err := s.cache.FindCache(api.ShortcutCache, *find.ID, shortcutRaw)
if err != nil {
return nil, err
}
if has {
return shortcutRaw.toShortcut(), nil
}
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, FormatError(err)
}
defer tx.Rollback()
list, err := findShortcutList(ctx, tx, find)
if err != nil {
return nil, err
}
@@ -85,22 +137,40 @@ func (s *Store) FindShortcut(find *api.ShortcutFind) (*api.Shortcut, error) {
return nil, &common.Error{Code: common.NotFound, Err: fmt.Errorf("not found")}
}
shortcut := list[0].toShortcut()
shortcutRaw := list[0]
if err := s.cache.UpsertCache(api.ShortcutCache, shortcutRaw.ID, shortcutRaw); err != nil {
return nil, err
}
shortcut := shortcutRaw.toShortcut()
return shortcut, nil
}
func (s *Store) DeleteShortcut(delete *api.ShortcutDelete) error {
err := deleteShortcut(s.db, delete)
func (s *Store) DeleteShortcut(ctx context.Context, delete *api.ShortcutDelete) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return FormatError(err)
}
defer tx.Rollback()
err = deleteShortcut(ctx, tx, delete)
if err != nil {
return FormatError(err)
}
if err := tx.Commit(); err != nil {
return FormatError(err)
}
s.cache.DeleteCache(api.ShortcutCache, delete.ID)
return nil
}
func createShortcut(db *sql.DB, create *api.ShortcutCreate) (*shortcutRaw, error) {
row, err := db.Query(`
func createShortcut(ctx context.Context, tx *sql.Tx, create *api.ShortcutCreate) (*shortcutRaw, error) {
query := `
INSERT INTO shortcut (
title,
payload,
@@ -108,19 +178,9 @@ func createShortcut(db *sql.DB, create *api.ShortcutCreate) (*shortcutRaw, error
)
VALUES (?, ?, ?)
RETURNING id, title, payload, creator_id, created_ts, updated_ts, row_status
`,
create.Title,
create.Payload,
create.CreatorID,
)
if err != nil {
return nil, FormatError(err)
}
defer row.Close()
row.Next()
`
var shortcutRaw shortcutRaw
if err := row.Scan(
if err := tx.QueryRowContext(ctx, query, create.Title, create.Payload, create.CreatorID).Scan(
&shortcutRaw.ID,
&shortcutRaw.Title,
&shortcutRaw.Payload,
@@ -135,7 +195,7 @@ func createShortcut(db *sql.DB, create *api.ShortcutCreate) (*shortcutRaw, error
return &shortcutRaw, nil
}
func patchShortcut(db *sql.DB, patch *api.ShortcutPatch) (*shortcutRaw, error) {
func patchShortcut(ctx context.Context, tx *sql.Tx, patch *api.ShortcutPatch) (*shortcutRaw, error) {
set, args := []string{}, []interface{}{}
if v := patch.Title; v != nil {
@@ -150,23 +210,14 @@ func patchShortcut(db *sql.DB, patch *api.ShortcutPatch) (*shortcutRaw, error) {
args = append(args, patch.ID)
row, err := db.Query(`
query := `
UPDATE shortcut
SET `+strings.Join(set, ", ")+`
SET ` + strings.Join(set, ", ") + `
WHERE id = ?
RETURNING id, title, payload, created_ts, updated_ts, row_status
`, args...)
if err != nil {
return nil, FormatError(err)
}
defer row.Close()
if !row.Next() {
return nil, &common.Error{Code: common.NotFound, Err: fmt.Errorf("not found")}
}
`
var shortcutRaw shortcutRaw
if err := row.Scan(
if err := tx.QueryRowContext(ctx, query, args...).Scan(
&shortcutRaw.ID,
&shortcutRaw.Title,
&shortcutRaw.Payload,
@@ -180,7 +231,7 @@ func patchShortcut(db *sql.DB, patch *api.ShortcutPatch) (*shortcutRaw, error) {
return &shortcutRaw, nil
}
func findShortcutList(db *sql.DB, find *api.ShortcutFind) ([]*shortcutRaw, error) {
func findShortcutList(ctx context.Context, tx *sql.Tx, find *api.ShortcutFind) ([]*shortcutRaw, error) {
where, args := []string{"1 = 1"}, []interface{}{}
if v := find.ID; v != nil {
@@ -193,7 +244,7 @@ func findShortcutList(db *sql.DB, find *api.ShortcutFind) ([]*shortcutRaw, error
where, args = append(where, "title = ?"), append(args, *v)
}
rows, err := db.Query(`
rows, err := tx.QueryContext(ctx, `
SELECT
id,
title,
@@ -237,9 +288,8 @@ func findShortcutList(db *sql.DB, find *api.ShortcutFind) ([]*shortcutRaw, error
return shortcutRawList, nil
}
func deleteShortcut(db *sql.DB, delete *api.ShortcutDelete) error {
result, err := db.Exec(`
PRAGMA foreign_keys = ON;
func deleteShortcut(ctx context.Context, tx *sql.Tx, delete *api.ShortcutDelete) error {
result, err := tx.ExecContext(ctx, `
DELETE FROM shortcut WHERE id = ?
`, delete.ID)
if err != nil {

View File

@@ -3,19 +3,24 @@ package store
import (
"database/sql"
"github.com/usememos/memos/api"
"github.com/usememos/memos/server/profile"
)
// Store provides database access to all raw objects
// Store provides database access to all raw objects.
type Store struct {
db *sql.DB
profile *profile.Profile
cache api.CacheService
}
// New creates a new instance of Store
// New creates a new instance of Store.
func New(db *sql.DB, profile *profile.Profile) *Store {
cacheService := NewCacheService()
return &Store{
db: db,
profile: profile,
cache: cacheService,
}
}

View File

@@ -1,6 +1,7 @@
package store
import (
"context"
"database/sql"
"fmt"
"strings"
@@ -43,9 +44,39 @@ func (raw *userRaw) toUser() *api.User {
}
}
func (s *Store) CreateUser(create *api.UserCreate) (*api.User, error) {
userRaw, err := createUser(s.db, create)
func (s *Store) ComposeMemoCreator(ctx context.Context, memo *api.Memo) error {
user, err := s.FindUser(ctx, &api.UserFind{
ID: &memo.CreatorID,
})
if err != nil {
return err
}
user.Email = ""
user.OpenID = ""
user.UserSettingList = nil
memo.Creator = user
return nil
}
func (s *Store) CreateUser(ctx context.Context, create *api.UserCreate) (*api.User, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, FormatError(err)
}
defer tx.Rollback()
userRaw, err := createUser(ctx, tx, create)
if err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, FormatError(err)
}
if err := s.cache.UpsertCache(api.UserCache, userRaw.ID, userRaw); err != nil {
return nil, err
}
@@ -54,9 +85,23 @@ func (s *Store) CreateUser(create *api.UserCreate) (*api.User, error) {
return user, nil
}
func (s *Store) PatchUser(patch *api.UserPatch) (*api.User, error) {
userRaw, err := patchUser(s.db, patch)
func (s *Store) PatchUser(ctx context.Context, patch *api.UserPatch) (*api.User, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, FormatError(err)
}
defer tx.Rollback()
userRaw, err := patchUser(ctx, tx, patch)
if err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, FormatError(err)
}
if err := s.cache.UpsertCache(api.UserCache, userRaw.ID, userRaw); err != nil {
return nil, err
}
@@ -65,8 +110,14 @@ func (s *Store) PatchUser(patch *api.UserPatch) (*api.User, error) {
return user, nil
}
func (s *Store) FindUserList(find *api.UserFind) ([]*api.User, error) {
userRawList, err := findUserList(s.db, find)
func (s *Store) FindUserList(ctx context.Context, find *api.UserFind) ([]*api.User, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, FormatError(err)
}
defer tx.Rollback()
userRawList, err := findUserList(ctx, tx, find)
if err != nil {
return nil, err
}
@@ -79,34 +130,69 @@ func (s *Store) FindUserList(find *api.UserFind) ([]*api.User, error) {
return list, nil
}
func (s *Store) FindUser(find *api.UserFind) (*api.User, error) {
list, err := findUserList(s.db, find)
func (s *Store) FindUser(ctx context.Context, find *api.UserFind) (*api.User, error) {
if find.ID != nil {
userRaw := &userRaw{}
has, err := s.cache.FindCache(api.UserCache, *find.ID, userRaw)
if err != nil {
return nil, err
}
if has {
return userRaw.toUser(), nil
}
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, FormatError(err)
}
defer tx.Rollback()
list, err := findUserList(ctx, tx, find)
if err != nil {
return nil, err
}
if len(list) == 0 {
return nil, nil
return nil, &common.Error{Code: common.NotFound, Err: fmt.Errorf("not found user with filter %+v", find)}
} else if len(list) > 1 {
return nil, &common.Error{Code: common.Conflict, Err: fmt.Errorf("found %d users with filter %+v, expect 1. ", len(list), find)}
return nil, &common.Error{Code: common.Conflict, Err: fmt.Errorf("found %d users with filter %+v, expect 1", len(list), find)}
}
user := list[0].toUser()
userRaw := list[0]
if err := s.cache.UpsertCache(api.UserCache, userRaw.ID, userRaw); err != nil {
return nil, err
}
user := userRaw.toUser()
return user, nil
}
func (s *Store) DeleteUser(delete *api.UserDelete) error {
err := deleteUser(s.db, delete)
func (s *Store) DeleteUser(ctx context.Context, delete *api.UserDelete) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return FormatError(err)
}
defer tx.Rollback()
err = deleteUser(ctx, tx, delete)
if err != nil {
return FormatError(err)
}
if err := tx.Commit(); err != nil {
return FormatError(err)
}
s.cache.DeleteCache(api.UserCache, delete.ID)
return nil
}
func createUser(db *sql.DB, create *api.UserCreate) (*userRaw, error) {
row, err := db.Query(`
func createUser(ctx context.Context, tx *sql.Tx, create *api.UserCreate) (*userRaw, error) {
query := `
INSERT INTO user (
email,
role,
@@ -116,21 +202,15 @@ func createUser(db *sql.DB, create *api.UserCreate) (*userRaw, error) {
)
VALUES (?, ?, ?, ?, ?)
RETURNING id, email, role, name, password_hash, open_id, created_ts, updated_ts, row_status
`,
`
var userRaw userRaw
if err := tx.QueryRowContext(ctx, query,
create.Email,
create.Role,
create.Name,
create.PasswordHash,
create.OpenID,
)
if err != nil {
return nil, FormatError(err)
}
defer row.Close()
row.Next()
var userRaw userRaw
if err := row.Scan(
).Scan(
&userRaw.ID,
&userRaw.Email,
&userRaw.Role,
@@ -147,7 +227,7 @@ func createUser(db *sql.DB, create *api.UserCreate) (*userRaw, error) {
return &userRaw, nil
}
func patchUser(db *sql.DB, patch *api.UserPatch) (*userRaw, error) {
func patchUser(ctx context.Context, tx *sql.Tx, patch *api.UserPatch) (*userRaw, error) {
set, args := []string{}, []interface{}{}
if v := patch.RowStatus; v != nil {
@@ -168,12 +248,13 @@ func patchUser(db *sql.DB, patch *api.UserPatch) (*userRaw, error) {
args = append(args, patch.ID)
row, err := db.Query(`
query := `
UPDATE user
SET `+strings.Join(set, ", ")+`
SET ` + strings.Join(set, ", ") + `
WHERE id = ?
RETURNING id, email, role, name, password_hash, open_id, created_ts, updated_ts, row_status
`, args...)
`
row, err := tx.QueryContext(ctx, query, args...)
if err != nil {
return nil, FormatError(err)
}
@@ -195,13 +276,17 @@ func patchUser(db *sql.DB, patch *api.UserPatch) (*userRaw, error) {
return nil, FormatError(err)
}
if err := row.Err(); err != nil {
return nil, err
}
return &userRaw, nil
}
return nil, &common.Error{Code: common.NotFound, Err: fmt.Errorf("user ID not found: %d", patch.ID)}
}
func findUserList(db *sql.DB, find *api.UserFind) ([]*userRaw, error) {
func findUserList(ctx context.Context, tx *sql.Tx, find *api.UserFind) ([]*userRaw, error) {
where, args := []string{"1 = 1"}, []interface{}{}
if v := find.ID; v != nil {
@@ -220,7 +305,7 @@ func findUserList(db *sql.DB, find *api.UserFind) ([]*userRaw, error) {
where, args = append(where, "open_id = ?"), append(args, *v)
}
rows, err := db.Query(`
query := `
SELECT
id,
email,
@@ -232,10 +317,10 @@ func findUserList(db *sql.DB, find *api.UserFind) ([]*userRaw, error) {
updated_ts,
row_status
FROM user
WHERE `+strings.Join(where, " AND ")+`
ORDER BY created_ts DESC, row_status DESC`,
args...,
)
WHERE ` + strings.Join(where, " AND ") + `
ORDER BY created_ts DESC, row_status DESC
`
rows, err := tx.QueryContext(ctx, query, args...)
if err != nil {
return nil, FormatError(err)
}
@@ -268,9 +353,8 @@ func findUserList(db *sql.DB, find *api.UserFind) ([]*userRaw, error) {
return userRawList, nil
}
func deleteUser(db *sql.DB, delete *api.UserDelete) error {
result, err := db.Exec(`
PRAGMA foreign_keys = ON;
func deleteUser(ctx context.Context, tx *sql.Tx, delete *api.UserDelete) error {
result, err := tx.ExecContext(ctx, `
DELETE FROM user WHERE id = ?
`, delete.ID)
if err != nil {

151
store/user_setting.go Normal file
View File

@@ -0,0 +1,151 @@
package store
import (
"context"
"database/sql"
"strings"
"github.com/usememos/memos/api"
)
type userSettingRaw struct {
UserID int
Key api.UserSettingKey
Value string
}
func (raw *userSettingRaw) toUserSetting() *api.UserSetting {
return &api.UserSetting{
UserID: raw.UserID,
Key: raw.Key,
Value: raw.Value,
}
}
func (s *Store) UpsertUserSetting(ctx context.Context, upsert *api.UserSettingUpsert) (*api.UserSetting, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, FormatError(err)
}
defer tx.Rollback()
userSettingRaw, err := upsertUserSetting(ctx, tx, upsert)
if err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, err
}
userSetting := userSettingRaw.toUserSetting()
return userSetting, nil
}
func (s *Store) FindUserSettingList(ctx context.Context, find *api.UserSettingFind) ([]*api.UserSetting, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, FormatError(err)
}
defer tx.Rollback()
userSettingRawList, err := findUserSettingList(ctx, tx, find)
if err != nil {
return nil, err
}
list := []*api.UserSetting{}
for _, raw := range userSettingRawList {
list = append(list, raw.toUserSetting())
}
return list, nil
}
func (s *Store) FindUserSetting(ctx context.Context, find *api.UserSettingFind) (*api.UserSetting, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, FormatError(err)
}
defer tx.Rollback()
list, err := findUserSettingList(ctx, tx, find)
if err != nil {
return nil, err
}
if len(list) == 0 {
return nil, nil
}
userSetting := list[0].toUserSetting()
return userSetting, nil
}
func upsertUserSetting(ctx context.Context, tx *sql.Tx, upsert *api.UserSettingUpsert) (*userSettingRaw, error) {
query := `
INSERT INTO user_setting (
user_id, key, value
)
VALUES (?, ?, ?)
ON CONFLICT(user_id, key) DO UPDATE
SET
value = EXCLUDED.value
RETURNING user_id, key, value
`
var userSettingRaw userSettingRaw
if err := tx.QueryRowContext(ctx, query, upsert.UserID, upsert.Key, upsert.Value).Scan(
&userSettingRaw.UserID,
&userSettingRaw.Key,
&userSettingRaw.Value,
); err != nil {
return nil, FormatError(err)
}
return &userSettingRaw, nil
}
func findUserSettingList(ctx context.Context, tx *sql.Tx, find *api.UserSettingFind) ([]*userSettingRaw, error) {
where, args := []string{"1 = 1"}, []interface{}{}
if v := find.Key; v != nil {
where, args = append(where, "key = ?"), append(args, v.String())
}
where, args = append(where, "user_id = ?"), append(args, find.UserID)
query := `
SELECT
user_id,
key,
value
FROM user_setting
WHERE ` + strings.Join(where, " AND ")
rows, err := tx.QueryContext(ctx, query, args...)
if err != nil {
return nil, FormatError(err)
}
defer rows.Close()
userSettingRawList := make([]*userSettingRaw, 0)
for rows.Next() {
var userSettingRaw userSettingRaw
if err := rows.Scan(
&userSettingRaw.UserID,
&userSettingRaw.Key,
&userSettingRaw.Value,
); err != nil {
return nil, FormatError(err)
}
userSettingRawList = append(userSettingRawList, &userSettingRaw)
}
if err := rows.Err(); err != nil {
return nil, FormatError(err)
}
return userSettingRawList, nil
}

View File

@@ -21,7 +21,6 @@
"endOfLine": "auto"
}
],
"@typescript-eslint/no-empty-interface": ["off"],
"@typescript-eslint/no-explicit-any": ["off"],
"react/react-in-jsx-scope": "off"
},

View File

@@ -2,14 +2,22 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/logo.png" type="image/*" />
<link rel="icon" href="/logo.webp" type="image/*" />
<meta name="theme-color" content="#f6f5f4" />
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no" />
<link rel="manifest" href="/manifest.json" />
<title>Memos</title>
<script src="https://kit.fontawesome.com/41e3aaa6af.js" crossorigin="anonymous"></script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
<script>
var global = global || window;
window.addEventListener("load", () => {
if ("serviceWorker" in navigator) {
navigator.serviceWorker.register("/sw.js");
}
});
</script>
</body>
</html>

9
web/jest.config.js Normal file
View File

@@ -0,0 +1,9 @@
/* eslint-disable no-undef */
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
preset: "ts-jest",
testEnvironment: "node",
moduleNameMapper: {
"lodash-es": "lodash",
},
};

View File

@@ -1,28 +1,36 @@
{
"name": "memos",
"version": "0.2.2",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"serve": "vite preview",
"lint": "eslint --ext .js,.ts,.tsx, src"
"lint": "eslint --ext .js,.ts,.tsx, src",
"test": "jest --passWithNoTests"
},
"dependencies": {
"@reduxjs/toolkit": "^1.8.1",
"axios": "^0.27.2",
"copy-to-clipboard": "^3.3.2",
"dayjs": "^1.11.3",
"emoji-picker-react": "^3.6.2",
"highlight.js": "^11.6.0",
"i18next": "^21.9.2",
"lodash-es": "^4.17.21",
"qs": "^6.11.0",
"react": "^18.1.0",
"react-dom": "^18.1.0",
"react-redux": "^8.0.1"
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-feather": "^2.0.10",
"react-i18next": "^11.18.6",
"react-redux": "^8.0.1",
"react-router-dom": "^6.4.0"
},
"devDependencies": {
"@jest/globals": "^29.1.2",
"@types/lodash-es": "^4.17.5",
"@types/node": "^18.0.3",
"@types/qs": "^6.9.7",
"@types/react": "^18.0.9",
"@types/react-dom": "^18.0.4",
"@types/react": "^18.0.21",
"@types/react-dom": "^18.0.6",
"@typescript-eslint/eslint-plugin": "^5.6.0",
"@typescript-eslint/parser": "^5.6.0",
"@vitejs/plugin-react": "^2.0.0",
@@ -31,10 +39,13 @@
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-prettier": "^4.0.0",
"eslint-plugin-react": "^7.27.1",
"jest": "^29.1.2",
"less": "^4.1.1",
"lodash": "^4.17.21",
"postcss": "^8.4.5",
"prettier": "2.5.1",
"tailwindcss": "^3.0.18",
"ts-jest": "^29.0.3",
"typescript": "^4.3.2",
"vite": "^3.0.0"
}

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text x="0.1em" y=".9em" font-size="90">✍️</text></svg>

Before

Width:  |  Height:  |  Size: 121 B

BIN
web/public/logo-full.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

BIN
web/public/logo.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

17
web/public/manifest.json Normal file
View File

@@ -0,0 +1,17 @@
{
"short_name": "Memos",
"name": "Memos",
"description": "usememos/memos",
"icons": [
{
"src": "/logo.webp",
"type": "image/webp",
"sizes": "520x520"
}
],
"start_url": "/",
"scope": "/",
"display": "standalone",
"theme_color": "#f6f5f4",
"background_color": "#f6f5f4"
}

10
web/public/sw.js Normal file
View File

@@ -0,0 +1,10 @@
self.addEventListener("install", (event) => {
event.waitUntil((async () => {})());
});
self.addEventListener("activate", (event) => {
event.waitUntil((async () => {})());
});
self.addEventListener("fetch", (event) => {});

View File

@@ -1,21 +1,32 @@
import { useEffect, useState } from "react";
import { appRouterSwitch } from "./routers";
import { locationService } from "./services";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { RouterProvider } from "react-router-dom";
import { globalService, locationService } from "./services";
import { useAppSelector } from "./store";
import router from "./router";
import * as storage from "./helpers/storage";
function App() {
const pathname = useAppSelector((state) => state.location.pathname);
const [isLoading, setLoading] = useState(true);
const { i18n } = useTranslation();
const global = useAppSelector((state) => state.global);
useEffect(() => {
locationService.updateStateWithLocation();
window.onpopstate = () => {
locationService.updateStateWithLocation();
};
setLoading(false);
globalService.initialState();
}, []);
return <>{isLoading ? null : appRouterSwitch(pathname)}</>;
useEffect(() => {
i18n.changeLanguage(global.locale);
storage.set({
locale: global.locale,
});
}, [global.locale]);
return <RouterProvider router={router} />;
}
export default App;

View File

@@ -1,13 +1,15 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import * as api from "../helpers/api";
import Only from "./common/OnlyWhen";
import Icon from "./Icon";
import { generateDialog } from "./Dialog";
import GitHubBadge from "./GitHubBadge";
import "../less/about-site-dialog.less";
interface Props extends DialogProps {}
type Props = DialogProps;
const AboutSiteDialog: React.FC<Props> = ({ destroy }: Props) => {
const { t } = useTranslation();
const [profile, setProfile] = useState<Profile>();
useEffect(() => {
@@ -34,28 +36,28 @@ const AboutSiteDialog: React.FC<Props> = ({ destroy }: Props) => {
<>
<div className="dialog-header-container">
<p className="title-text">
<span className="icon-text">🤠</span>About <b>Memos</b>
<span className="icon-text">🤠</span>
{t("common.about")}
</p>
<button className="btn close-btn" onClick={handleCloseBtnClick}>
<i className="fa-solid fa-xmark fa-lg"></i>
<Icon.X />
</button>
</div>
<div className="dialog-content-container">
<p>
Memos is an <i>open source</i>, <i>self-hosted</i> knowledge base that works with a SQLite db file.
</p>
<img className="logo-img" src="/logo-full.webp" alt="" />
<p>{t("slogan")}</p>
<br />
<div className="addtion-info-container">
<GitHubBadge />
<Only when={profile !== undefined}>
{profile !== undefined && (
<>
version:
{t("common.version")}:
<span className="pre-text">
{profile?.version}-{profile?.mode}
</span>
🎉
</>
</Only>
)}
</div>
</div>
</>

View File

@@ -1,11 +1,10 @@
import { IMAGE_URL_REG } from "../helpers/consts";
import { useTranslation } from "react-i18next";
import * as utils from "../helpers/utils";
import useToggle from "../hooks/useToggle";
import { memoService } from "../services";
import { formatMemoContent } from "../helpers/marked";
import Only from "./common/OnlyWhen";
import Image from "./Image";
import toastHelper from "./Toast";
import MemoContent from "./MemoContent";
import MemoResources from "./MemoResources";
import "../less/memo.less";
interface Props {
@@ -13,22 +12,18 @@ interface Props {
}
const ArchivedMemo: React.FC<Props> = (props: Props) => {
const { memo: propsMemo } = props;
const memo = {
...propsMemo,
createdAtStr: utils.getDateTimeString(propsMemo.createdTs),
archivedAtStr: utils.getDateTimeString(propsMemo.updatedTs ?? Date.now()),
};
const { memo } = props;
const { t } = useTranslation();
const [showConfirmDeleteBtn, toggleConfirmDeleteBtn] = useToggle(false);
const imageUrls = Array.from(memo.content.match(IMAGE_URL_REG) ?? []).map((s) => s.replace(IMAGE_URL_REG, "$1"));
const handleDeleteMemoClick = async () => {
if (showConfirmDeleteBtn) {
try {
await memoService.deleteMemoById(memo.id);
await memoService.fetchAllMemos();
} catch (error: any) {
toastHelper.error(error.message);
console.error(error);
toastHelper.error(error.response.data.message);
}
} else {
toggleConfirmDeleteBtn();
@@ -41,10 +36,11 @@ const ArchivedMemo: React.FC<Props> = (props: Props) => {
id: memo.id,
rowStatus: "NORMAL",
});
await memoService.fetchAllMemos();
toastHelper.info("Restored successfully");
await memoService.fetchMemos();
toastHelper.info(t("message.restored-successfully"));
} catch (error: any) {
toastHelper.error(error.message);
console.error(error);
toastHelper.error(error.response.data.message);
}
};
@@ -57,24 +53,21 @@ const ArchivedMemo: React.FC<Props> = (props: Props) => {
return (
<div className={`memo-wrapper archived-memo ${"memos-" + memo.id}`} onMouseLeave={handleMouseLeaveMemoWrapper}>
<div className="memo-top-wrapper">
<span className="time-text">Archived at {memo.archivedAtStr}</span>
<span className="time-text">
{t("common.archived-at")} {utils.getDateTimeString(memo.updatedTs)}
</span>
<div className="btns-container">
<span className="btn restore-btn" onClick={handleRestoreMemoClick}>
Restore
{t("common.restore")}
</span>
<span className={`btn delete-btn ${showConfirmDeleteBtn ? "final-confirm" : ""}`} onClick={handleDeleteMemoClick}>
{showConfirmDeleteBtn ? "Delete!" : "Delete"}
{t("common.delete")}
{showConfirmDeleteBtn ? "!" : ""}
</span>
</div>
</div>
<div className="memo-content-text" dangerouslySetInnerHTML={{ __html: formatMemoContent(memo.content) }}></div>
<Only when={imageUrls.length > 0}>
<div className="images-wrapper">
{imageUrls.map((imgUrl, idx) => (
<Image className="memo-img" key={idx} imgUrl={imgUrl} />
))}
</div>
</Only>
<MemoContent content={memo.content} />
<MemoResources memo={memo} />
</div>
);
};

View File

@@ -1,15 +1,18 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import useLoading from "../hooks/useLoading";
import { memoService } from "../services";
import { useAppSelector } from "../store";
import Icon from "./Icon";
import { generateDialog } from "./Dialog";
import toastHelper from "./Toast";
import ArchivedMemo from "./ArchivedMemo";
import "../less/archived-memo-dialog.less";
interface Props extends DialogProps {}
type Props = DialogProps;
const ArchivedMemoDialog: React.FC<Props> = (props: Props) => {
const { t } = useTranslation();
const { destroy } = props;
const memos = useAppSelector((state) => state.memo.memos);
const loadingState = useLoading();
@@ -22,7 +25,8 @@ const ArchivedMemoDialog: React.FC<Props> = (props: Props) => {
setArchivedMemos(result);
})
.catch((error) => {
toastHelper.error("Failed to fetch archived memos: ", error);
console.error(error);
toastHelper.error(error.response.data.message);
})
.finally(() => {
loadingState.setFinish();
@@ -34,20 +38,20 @@ const ArchivedMemoDialog: React.FC<Props> = (props: Props) => {
<div className="dialog-header-container">
<p className="title-text">
<span className="icon-text">🗂</span>
Archived Memos
{t("archived.archived-memos")}
</p>
<button className="btn close-btn" onClick={destroy}>
<i className="fa-solid fa-xmark fa-lg icon-img"></i>
<Icon.X className="icon-img" />
</button>
</div>
<div className="dialog-content-container">
{loadingState.isLoading ? (
<div className="tip-text-container">
<p className="tip-text">fetching data...</p>
<p className="tip-text">{t("archived.fetching-data")}</p>
</div>
) : archivedMemos.length === 0 ? (
<div className="tip-text-container">
<p className="tip-text">Here is No Zettels.</p>
<p className="tip-text">{t("archived.no-archived-memos")}</p>
</div>
) : (
<div className="archived-memos-container">
@@ -61,11 +65,10 @@ const ArchivedMemoDialog: React.FC<Props> = (props: Props) => {
);
};
export default function showArchivedMemo(): void {
export default function showArchivedMemoDialog(): void {
generateDialog(
{
className: "archived-memo-dialog",
useAppContext: true,
},
ArchivedMemoDialog,
{}

View File

@@ -0,0 +1,13 @@
import "../less/beta-badge.less";
interface Props {
className?: string;
}
const BetaBadge: React.FC<Props> = (props: Props) => {
const { className } = props;
return <span className={`beta-badge ${className ?? ""}`}>beta</span>;
};
export default BetaBadge;

View File

@@ -0,0 +1,99 @@
import dayjs from "dayjs";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { memoService } from "../services";
import Icon from "./Icon";
import { generateDialog } from "./Dialog";
import toastHelper from "./Toast";
import "../less/change-memo-created-ts-dialog.less";
interface Props extends DialogProps {
memoId: MemoId;
}
const ChangeMemoCreatedTsDialog: React.FC<Props> = (props: Props) => {
const { t } = useTranslation();
const { destroy, memoId } = props;
const [createdAt, setCreatedAt] = useState("");
const maxDatetimeValue = dayjs().format("YYYY-MM-DDTHH:mm");
useEffect(() => {
const memo = memoService.getMemoById(memoId);
if (memo) {
const datetime = dayjs(memo.createdTs).format("YYYY-MM-DDTHH:mm");
setCreatedAt(datetime);
} else {
toastHelper.error(t("message.memo-not-found"));
destroy();
}
}, []);
const handleCloseBtnClick = () => {
destroy();
};
const handleDatetimeInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const datetime = e.target.value as string;
setCreatedAt(datetime);
};
const handleSaveBtnClick = async () => {
const nowTs = dayjs().unix();
const createdTs = dayjs(createdAt).unix();
if (createdTs > nowTs) {
toastHelper.error(t("message.invalid-created-datetime"));
return;
}
try {
await memoService.patchMemo({
id: memoId,
createdTs,
});
toastHelper.info(t("message.memo-updated-datetime"));
handleCloseBtnClick();
} catch (error: any) {
console.error(error);
toastHelper.error(error.response.data.message);
}
};
return (
<>
<div className="dialog-header-container">
<p className="title-text">{t("message.change-memo-created-time")}</p>
<button className="btn close-btn" onClick={handleCloseBtnClick}>
<Icon.X />
</button>
</div>
<div className="dialog-content-container">
<label className="form-label input-form-label">
<input type="datetime-local" value={createdAt} max={maxDatetimeValue} onChange={handleDatetimeInputChange} />
</label>
<div className="btns-container">
<span className="btn cancel-btn" onClick={handleCloseBtnClick}>
{t("common.cancel")}
</span>
<span className="btn confirm-btn" onClick={handleSaveBtnClick}>
{t("common.save")}
</span>
</div>
</div>
</>
);
};
function showChangeMemoCreatedTsDialog(memoId: MemoId) {
generateDialog(
{
className: "change-memo-created-ts-dialog",
},
ChangeMemoCreatedTsDialog,
{
memoId,
}
);
}
export default showChangeMemoCreatedTsDialog;

View File

@@ -1,6 +1,8 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { validate, ValidatorConfig } from "../helpers/validator";
import { userService } from "../services";
import Icon from "./Icon";
import { generateDialog } from "./Dialog";
import toastHelper from "./Toast";
import "../less/change-password-dialog.less";
@@ -12,9 +14,10 @@ const validateConfig: ValidatorConfig = {
noChinese: true,
};
interface Props extends DialogProps {}
type Props = DialogProps;
const ChangePasswordDialog: React.FC<Props> = ({ destroy }: Props) => {
const { t } = useTranslation();
const [newPassword, setNewPassword] = useState("");
const [newPasswordAgain, setNewPasswordAgain] = useState("");
@@ -38,19 +41,19 @@ const ChangePasswordDialog: React.FC<Props> = ({ destroy }: Props) => {
const handleSaveBtnClick = async () => {
if (newPassword === "" || newPasswordAgain === "") {
toastHelper.error("Please fill in all fields.");
toastHelper.error(t("message.fill-all"));
return;
}
if (newPassword !== newPasswordAgain) {
toastHelper.error("New passwords do not match.");
toastHelper.error(t("message.new-password-not-match"));
setNewPasswordAgain("");
return;
}
const passwordValidResult = validate(newPassword, validateConfig);
if (!passwordValidResult.result) {
toastHelper.error("Password " + passwordValidResult.reason);
toastHelper.error(`${t("common.password")} ${passwordValidResult.reason}`);
return;
}
@@ -60,34 +63,40 @@ const ChangePasswordDialog: React.FC<Props> = ({ destroy }: Props) => {
id: user.id,
password: newPassword,
});
toastHelper.info("Password changed.");
toastHelper.info(t("message.password-changed"));
handleCloseBtnClick();
} catch (error: any) {
toastHelper.error(error);
console.error(error);
toastHelper.error(error.response.data.message);
}
};
return (
<>
<div className="dialog-header-container">
<p className="title-text">Change Password</p>
<p className="title-text">{t("setting.account-section.change-password")}</p>
<button className="btn close-btn" onClick={handleCloseBtnClick}>
<i className="fa-solid fa-xmark fa-lg"></i>
<Icon.X />
</button>
</div>
<div className="dialog-content-container">
<label className="form-label input-form-label">
<input type="password" placeholder="New passworld" value={newPassword} onChange={handleNewPasswordChanged} />
<input type="password" placeholder={t("common.new-password")} value={newPassword} onChange={handleNewPasswordChanged} />
</label>
<label className="form-label input-form-label">
<input type="password" placeholder="Repeat the new password" value={newPasswordAgain} onChange={handleNewPasswordAgainChanged} />
<input
type="password"
placeholder={t("common.repeat-new-password")}
value={newPasswordAgain}
onChange={handleNewPasswordAgainChanged}
/>
</label>
<div className="btns-container">
<span className="btn cancel-btn" onClick={handleCloseBtnClick}>
Cancel
{t("common.cancel")}
</span>
<span className="btn confirm-btn" onClick={handleSaveBtnClick}>
Save
{t("common.save")}
</span>
</div>
</div>

View File

@@ -1,7 +1,9 @@
import { memo, useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { memoService, shortcutService } from "../services";
import { checkShouldShowMemoWithFilters, filterConsts, getDefaultFilter, relationConsts } from "../helpers/filter";
import useLoading from "../hooks/useLoading";
import Icon from "./Icon";
import { generateDialog } from "./Dialog";
import toastHelper from "./Toast";
import Selector from "./common/Selector";
@@ -13,10 +15,10 @@ interface Props extends DialogProps {
const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
const { destroy, shortcutId } = props;
const [title, setTitle] = useState<string>("");
const [filters, setFilters] = useState<Filter[]>([]);
const requestState = useLoading(false);
const { t } = useTranslation();
const shownMemoLength = memoService.getState().memos.filter((memo) => {
return checkShouldShowMemoWithFilters(memo, filters);
@@ -42,7 +44,7 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
const handleSaveBtnClick = async () => {
if (!title) {
toastHelper.error("Title is required");
toastHelper.error(t("shortcut-list.title-required"));
return;
}
@@ -60,7 +62,8 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
});
}
} catch (error: any) {
toastHelper.error(error.message);
console.error(error);
toastHelper.error(error.response.data.message);
}
destroy();
};
@@ -69,7 +72,7 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
if (filters.length > 0) {
const lastFilter = filters[filters.length - 1];
if (lastFilter.value.value === "") {
toastHelper.info("Please fill in previous filter value");
toastHelper.info(t("shortcut-list.fill-previous"));
return;
}
}
@@ -97,19 +100,25 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
<div className="dialog-header-container">
<p className="title-text">
<span className="icon-text">🚀</span>
{shortcutId ? "Edit Shortcut" : "Create Shortcut"}
{shortcutId ? t("shortcut-list.edit-shortcut") : t("shortcut-list.create-shortcut")}
</p>
<button className="btn close-btn" onClick={destroy}>
<i className="fa-solid fa-xmark fa-lg"></i>
<Icon.X />
</button>
</div>
<div className="dialog-content-container">
<div className="form-item-container input-form-container">
<span className="normal-text">Title</span>
<input className="title-input" type="text" placeholder="shortcut title" value={title} onChange={handleTitleInputChange} />
<span className="normal-text">{t("common.title")}</span>
<input
className="title-input"
type="text"
placeholder={t("shortcut-list.shortcut-title")}
value={title}
onChange={handleTitleInputChange}
/>
</div>
<div className="form-item-container filter-form-container">
<span className="normal-text">Filters</span>
<span className="normal-text">{t("common.filter")}</span>
<div className="filters-wrapper">
{filters.map((f, index) => {
return (
@@ -123,7 +132,7 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
);
})}
<div className="create-filter-btn" onClick={handleAddFilterBenClick}>
New Filter
{t("filter.new-filter")}
</div>
</div>
</div>
@@ -132,10 +141,10 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
<div></div>
<div className="btns-container">
<span className={`tip-text ${filters.length === 0 && "hidden"}`}>
<strong>{shownMemoLength}</strong> eligible memo
<strong>{shownMemoLength}</strong> {t("shortcut-list.eligible-memo")}
</span>
<button className={`btn save-btn ${requestState.isLoading ? "requesting" : ""}`} onClick={handleSaveBtnClick}>
Save
{t("common.save")}
</button>
</div>
</div>
@@ -150,129 +159,70 @@ interface MemoFilterInputerProps {
handleFilterRemove: (index: number) => void;
}
const FilterInputer: React.FC<MemoFilterInputerProps> = (props: MemoFilterInputerProps) => {
const MemoFilterInputer: React.FC<MemoFilterInputerProps> = (props: MemoFilterInputerProps) => {
const { index, filter, handleFilterChange, handleFilterRemove } = props;
const { t } = useTranslation();
const [value, setValue] = useState<string>(filter.value.value);
const tags = Array.from(memoService.getState().tags);
const { type } = filter;
const [inputElements, setInputElements] = useState<JSX.Element>(<></>);
const operatorDataSource = Object.values(filterConsts[type as FilterType].operators).map(({ text, value }) => ({ text: t(text), value }));
const valueDataSource =
type === "TYPE"
? filterConsts["TYPE"].values.map(({ text, value }) => ({ text: t(text), value }))
: tags.sort().map((t) => {
return { text: t, value: t };
});
useEffect(() => {
let operatorElement = <></>;
if (Object.keys(filterConsts).includes(type)) {
operatorElement = (
<Selector
className="operator-selector"
dataSource={Object.values(filterConsts[type as FilterType].operators)}
value={filter.value.operator}
handleValueChanged={handleOperatorChange}
/>
);
}
setValue(filter.value.value);
}, [type]);
let valueElement = <></>;
switch (type) {
case "TYPE": {
valueElement = (
<Selector
className="value-selector"
dataSource={filterConsts["TYPE"].values}
value={filter.value.value}
handleValueChanged={handleValueChange}
/>
);
break;
}
case "TAG": {
valueElement = (
<Selector
className="value-selector"
dataSource={tags.sort().map((t) => {
return { text: t, value: t };
})}
value={filter.value.value}
handleValueChanged={handleValueChange}
/>
);
break;
}
case "TEXT": {
valueElement = (
<input
type="text"
className="value-inputer"
value={filter.value.value}
onChange={(event) => {
handleValueChange(event.target.value);
event.target.focus();
}}
/>
);
break;
}
}
setInputElements(
<>
{operatorElement}
{valueElement}
</>
);
}, [type, filter]);
const handleRelationChange = useCallback(
(value: string) => {
if (["AND", "OR"].includes(value)) {
handleFilterChange(index, {
...filter,
relation: value as MemoFilterRalation,
});
}
},
[filter]
);
const handleTypeChange = useCallback(
(value: string) => {
if (filter.type !== value) {
const ops = Object.values(filterConsts[value as FilterType].operators);
handleFilterChange(index, {
...filter,
type: value as FilterType,
value: {
operator: ops[0].value,
value: "",
},
});
}
},
[filter]
);
const handleOperatorChange = useCallback(
(value: string) => {
const handleRelationChange = (value: string) => {
if (["AND", "OR"].includes(value)) {
handleFilterChange(index, {
...filter,
value: {
...filter.value,
operator: value,
},
relation: value as MemoFilterRalation,
});
},
[filter]
);
}
};
const handleValueChange = useCallback(
(value: string) => {
const handleTypeChange = (value: string) => {
if (filter.type !== value) {
const ops = Object.values(filterConsts[value as FilterType].operators);
handleFilterChange(index, {
...filter,
type: value as FilterType,
value: {
...filter.value,
value,
operator: ops[0].value,
value: "",
},
});
},
[filter]
);
}
};
const handleOperatorChange = (value: string) => {
handleFilterChange(index, {
...filter,
value: {
...filter.value,
operator: value,
},
});
};
const handleValueChange = (value: string) => {
setValue(value);
handleFilterChange(index, {
...filter,
value: {
...filter.value,
value,
},
});
};
const handleRemoveBtnClick = () => {
handleFilterRemove(index);
@@ -294,15 +244,29 @@ const FilterInputer: React.FC<MemoFilterInputerProps> = (props: MemoFilterInpute
value={filter.type}
handleValueChanged={handleTypeChange}
/>
{inputElements}
<i className="fa-solid fa-xmark remove-btn" onClick={handleRemoveBtnClick}></i>
<Selector
className="operator-selector"
dataSource={operatorDataSource}
value={filter.value.operator}
handleValueChanged={handleOperatorChange}
/>
{type === "TEXT" ? (
<input
type="text"
className="value-inputer"
value={value}
onChange={(event) => {
handleValueChange(event.target.value);
}}
/>
) : (
<Selector className="value-selector" dataSource={valueDataSource} value={value} handleValueChanged={handleValueChange} />
)}
<Icon.X className="remove-btn" onClick={handleRemoveBtnClick} />
</div>
);
};
const MemoFilterInputer: React.FC<MemoFilterInputerProps> = memo(FilterInputer);
export default function showCreateShortcutDialog(shortcutId?: ShortcutId): void {
generateDialog(
{

View File

@@ -1,38 +1,27 @@
import * as utils from "../helpers/utils";
import { formatMemoContent } from "../helpers/marked";
import MemoContent, { DisplayConfig } from "./MemoContent";
import MemoResources from "./MemoResources";
import "../less/daily-memo.less";
interface DailyMemo extends Memo {
createdAtStr: string;
timeStr: string;
}
interface Props {
memo: Memo;
}
const DailyMemo: React.FC<Props> = (props: Props) => {
const { memo: propsMemo } = props;
const memo: DailyMemo = {
...propsMemo,
createdAtStr: utils.getDateTimeString(propsMemo.createdTs),
timeStr: utils.getTimeString(propsMemo.createdTs),
const { memo } = props;
const displayTimeStr = utils.getTimeString(memo.displayTs);
const displayConfig: DisplayConfig = {
enableExpand: false,
};
return (
<div className="daily-memo-wrapper">
<div className="time-wrapper">
<span className="normal-text">{memo.timeStr}</span>
<span className="normal-text">{displayTimeStr}</span>
</div>
<div className="memo-content-container">
<div
className="memo-content-text"
dangerouslySetInnerHTML={{
__html: formatMemoContent(memo.content, {
inlineImage: true,
}),
}}
></div>
<div className="memo-container">
<MemoContent content={memo.content} displayConfig={displayConfig} />
<MemoResources memo={memo} />
</div>
<div className="split-line"></div>
</div>

View File

@@ -1,9 +1,11 @@
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useAppSelector } from "../store";
import toImage from "../labs/html2image";
import useToggle from "../hooks/useToggle";
import { DAILY_TIMESTAMP } from "../helpers/consts";
import * as utils from "../helpers/utils";
import Icon from "./Icon";
import { generateDialog } from "./Dialog";
import DatePicker from "./common/DatePicker";
import showPreviewImageDialog from "./PreviewImageDialog";
@@ -18,6 +20,7 @@ const monthChineseStrArray = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "
const weekdayChineseStrArray = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const DailyReviewDialog: React.FC<Props> = (props: Props) => {
const { t } = useTranslation();
const memos = useAppSelector((state) => state.memo.memos);
const [currentDateStamp, setCurrentDateStamp] = useState(utils.getDateStampByDate(utils.getDateString(props.currentDateStamp)));
const [showDatePicker, toggleShowDatePicker] = useToggle(false);
@@ -27,10 +30,10 @@ const DailyReviewDialog: React.FC<Props> = (props: Props) => {
.filter(
(m) =>
m.rowStatus === "NORMAL" &&
utils.getTimeStampByDate(m.createdTs) >= currentDateStamp &&
utils.getTimeStampByDate(m.createdTs) < currentDateStamp + DAILY_TIMESTAMP
utils.getTimeStampByDate(m.displayTs) >= currentDateStamp &&
utils.getTimeStampByDate(m.displayTs) < currentDateStamp + DAILY_TIMESTAMP
)
.sort((a, b) => utils.getTimeStampByDate(a.createdTs) - utils.getTimeStampByDate(b.createdTs));
.sort((a, b) => utils.getTimeStampByDate(a.displayTs) - utils.getTimeStampByDate(b.displayTs));
const handleShareBtnClick = () => {
if (!memosElRef.current) {
@@ -60,21 +63,21 @@ const DailyReviewDialog: React.FC<Props> = (props: Props) => {
<>
<div className="dialog-header-container">
<p className="title-text" onClick={() => toggleShowDatePicker()}>
<span className="icon-text">📅</span> Daily Review
<span className="icon-text">📅</span> {t("sidebar.daily-review")}
</p>
<div className="btns-container">
<button className="btn-text" onClick={() => setCurrentDateStamp(currentDateStamp - DAILY_TIMESTAMP)}>
<i className="fa-solid fa-chevron-left icon-img"></i>
<Icon.ChevronLeft className="icon-img" />
</button>
<button className="btn-text" onClick={() => setCurrentDateStamp(currentDateStamp + DAILY_TIMESTAMP)}>
<i className="fa-solid fa-chevron-right icon-img"></i>
<Icon.ChevronRight className="icon-img" />
</button>
<button className="btn-text" onClick={handleShareBtnClick}>
<i className="fa-solid fa-share-nodes icon-img"></i>
<button className="btn-text share" onClick={handleShareBtnClick}>
<Icon.Share className="icon-img" />
</button>
<span className="split-line">/</span>
<button className="btn-text" onClick={() => props.destroy()}>
<i className="fa-solid fa-xmark fa-lg icon-img"></i>
<Icon.X className="icon-img" />
</button>
</div>
<DatePicker
@@ -94,7 +97,7 @@ const DailyReviewDialog: React.FC<Props> = (props: Props) => {
</div>
{dailyMemos.length === 0 ? (
<div className="tip-container">
<p className="tip-text">Oops, there is nothing.</p>
<p className="tip-text">{t("daily-review.oops-nothing")}</p>
</div>
) : (
<div className="dailymemos-wrapper">
@@ -112,7 +115,6 @@ export default function showDailyReviewDialog(datestamp: DateStamp = Date.now())
generateDialog(
{
className: "daily-review-dialog",
useAppContext: true,
},
DailyReviewDialog,
{ currentDateStamp: datestamp }

View File

@@ -1,3 +1,4 @@
import { useEffect } from "react";
import { createRoot } from "react-dom/client";
import { Provider } from "react-redux";
import { ANIMATION_DURATION } from "../../helpers/consts";
@@ -6,7 +7,6 @@ import "../../less/base-dialog.less";
interface DialogConfig {
className: string;
useAppContext?: boolean;
clickSpaceDestroy?: boolean;
}
@@ -17,6 +17,20 @@ interface Props extends DialogConfig, DialogProps {
const BaseDialog: React.FC<Props> = (props: Props) => {
const { children, className, clickSpaceDestroy, destroy } = props;
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.code === "Escape") {
destroy();
}
};
document.body.addEventListener("keydown", handleKeyDown);
return () => {
document.body.removeEventListener("keydown", handleKeyDown);
};
}, []);
const handleSpaceClicked = () => {
if (clickSpaceDestroy) {
destroy();
@@ -61,16 +75,14 @@ export function generateDialog<T extends DialogProps>(
destroy: cbs.destroy,
} as T;
let Fragment = (
<BaseDialog destroy={cbs.destroy} clickSpaceDestroy={true} {...config}>
<DialogComponent {...dialogProps} />
</BaseDialog>
const Fragment = (
<Provider store={store}>
<BaseDialog destroy={cbs.destroy} clickSpaceDestroy={true} {...config}>
<DialogComponent {...dialogProps} />
</BaseDialog>
</Provider>
);
if (config.useAppContext) {
Fragment = <Provider store={store}>{Fragment}</Provider>;
}
dialog.render(Fragment);
return cbs;

View File

@@ -1,3 +1,5 @@
import { useTranslation } from "react-i18next";
import Icon from "../Icon";
import { generateDialog } from "./BaseDialog";
import "../../less/common-dialog.less";
@@ -17,15 +19,18 @@ const defaultProps = {
title: "",
content: "",
style: "info",
closeBtnText: "Close",
confirmBtnText: "Confirm",
closeBtnText: "common.close",
confirmBtnText: "common.confirm",
onClose: () => null,
onConfirm: () => null,
};
const CommonDialog: React.FC<Props> = (props: Props) => {
const { t } = useTranslation();
const { title, content, destroy, closeBtnText, confirmBtnText, onClose, onConfirm, style } = {
...defaultProps,
closeBtnText: t(defaultProps.closeBtnText),
confirmBtnText: t(defaultProps.confirmBtnText),
...props,
};
@@ -44,7 +49,7 @@ const CommonDialog: React.FC<Props> = (props: Props) => {
<div className="dialog-header-container">
<p className="title-text">{title}</p>
<button className="btn close-btn" onClick={handleCloseBtnClick}>
<i className="fa-solid fa-xmark fa-lg"></i>
<Icon.X />
</button>
</div>
<div className="dialog-content-container">

View File

@@ -1,48 +1,35 @@
import { forwardRef, ReactNode, useCallback, useEffect, useImperativeHandle, useRef } from "react";
import useRefresh from "../../hooks/useRefresh";
import Only from "../common/OnlyWhen";
import "../../less/editor.less";
export interface EditorRefActions {
element: HTMLTextAreaElement;
focus: FunctionType;
insertText: (text: string) => void;
setContent: (text: string) => void;
getContent: () => string;
getCursorPosition: () => number;
}
interface EditorProps {
interface Props {
className: string;
initialContent: string;
placeholder: string;
fullscreen: boolean;
showConfirmBtn: boolean;
showCancelBtn: boolean;
tools?: ReactNode;
onConfirmBtnClick: (content: string) => void;
onCancelBtnClick: () => void;
onPaste: (event: React.ClipboardEvent) => void;
onContentChange: (content: string) => void;
}
// eslint-disable-next-line react/display-name
const Editor = forwardRef((props: EditorProps, ref: React.ForwardedRef<EditorRefActions>) => {
const {
className,
initialContent,
placeholder,
fullscreen,
showConfirmBtn,
showCancelBtn,
onConfirmBtnClick: handleConfirmBtnClickCallback,
onCancelBtnClick: handleCancelBtnClickCallback,
onContentChange: handleContentChangeCallback,
} = props;
const Editor = forwardRef((props: Props, ref: React.ForwardedRef<EditorRefActions>) => {
const { className, initialContent, placeholder, fullscreen, onPaste, onContentChange: handleContentChangeCallback } = props;
const editorRef = useRef<HTMLTextAreaElement>(null);
const refresh = useRefresh();
useEffect(() => {
if (editorRef.current && initialContent) {
editorRef.current.value = initialContent;
handleContentChangeCallback(initialContent);
}
}, []);
@@ -56,7 +43,6 @@ const Editor = forwardRef((props: EditorProps, ref: React.ForwardedRef<EditorRef
useImperativeHandle(
ref,
() => ({
element: editorRef.current as HTMLTextAreaElement,
focus: () => {
editorRef.current?.focus();
},
@@ -66,14 +52,17 @@ const Editor = forwardRef((props: EditorProps, ref: React.ForwardedRef<EditorRef
}
const prevValue = editorRef.current.value;
editorRef.current.value =
prevValue.slice(0, editorRef.current.selectionStart) + rawText + prevValue.slice(editorRef.current.selectionStart);
const cursorPosition = editorRef.current.selectionStart;
editorRef.current.value = prevValue.slice(0, cursorPosition) + rawText + prevValue.slice(cursorPosition);
editorRef.current.focus();
editorRef.current.selectionEnd = cursorPosition + rawText.length;
handleContentChangeCallback(editorRef.current.value);
refresh();
},
setContent: (text: string) => {
if (editorRef.current) {
editorRef.current.value = text;
editorRef.current.focus();
handleContentChangeCallback(editorRef.current.value);
refresh();
}
@@ -81,6 +70,9 @@ const Editor = forwardRef((props: EditorProps, ref: React.ForwardedRef<EditorRef
getContent: (): string => {
return editorRef.current?.value ?? "";
},
getCursorPosition: (): number => {
return editorRef.current?.selectionStart ?? 0;
},
}),
[]
);
@@ -90,29 +82,6 @@ const Editor = forwardRef((props: EditorProps, ref: React.ForwardedRef<EditorRef
refresh();
}, []);
const handleEditorKeyDown = useCallback((event: React.KeyboardEvent<HTMLTextAreaElement>) => {
event.stopPropagation();
if (event.code === "Enter") {
if (event.metaKey || event.ctrlKey) {
handleCommonConfirmBtnClick();
}
}
}, []);
const handleCommonConfirmBtnClick = useCallback(() => {
if (!editorRef.current) {
return;
}
handleConfirmBtnClickCallback(editorRef.current.value);
editorRef.current.value = "";
}, []);
const handleCommonCancelBtnClick = useCallback(() => {
handleCancelBtnClickCallback();
}, []);
return (
<div className={"common-editor-wrapper " + className}>
<textarea
@@ -120,26 +89,9 @@ const Editor = forwardRef((props: EditorProps, ref: React.ForwardedRef<EditorRef
rows={1}
placeholder={placeholder}
ref={editorRef}
onPaste={onPaste}
onInput={handleEditorInput}
onKeyDown={handleEditorKeyDown}
></textarea>
<div className="common-tools-wrapper">
<div className="common-tools-container">
<Only when={props.tools !== undefined}>{props.tools}</Only>
</div>
<div className="btns-container">
<Only when={showCancelBtn}>
<button className="action-btn cancel-btn" onClick={handleCommonCancelBtnClick}>
Cancel editting
</button>
</Only>
<Only when={showConfirmBtn}>
<button className="action-btn confirm-btn" disabled={editorRef.current?.value === ""} onClick={handleCommonConfirmBtnClick}>
Save <span className="icon-text"></span>
</button>
</Only>
</div>
</div>
</div>
);
});

View File

@@ -0,0 +1,38 @@
import Picker, { IEmojiPickerProps } from "emoji-picker-react";
import { useEffect } from "react";
interface Props {
shouldShow: boolean;
onEmojiClick: IEmojiPickerProps["onEmojiClick"];
onShouldShowEmojiPickerChange: (status: boolean) => void;
}
export const EmojiPicker: React.FC<Props> = (props: Props) => {
const { shouldShow, onEmojiClick, onShouldShowEmojiPickerChange } = props;
useEffect(() => {
if (shouldShow) {
const handleClickOutside = (event: MouseEvent) => {
event.stopPropagation();
const emojiWrapper = document.querySelector(".emoji-picker-react");
const isContains = emojiWrapper?.contains(event.target as Node);
if (!isContains) {
onShouldShowEmojiPickerChange(false);
}
};
window.addEventListener("click", handleClickOutside, {
capture: true,
once: true,
});
}
}, [shouldShow]);
return (
<div className={`emoji-picker ${shouldShow ? "" : "hidden"}`}>
<Picker onEmojiClick={onEmojiClick} disableSearchBar />
</div>
);
};
export default EmojiPicker;

View File

@@ -1,10 +1,9 @@
import { useEffect, useState } from "react";
import * as api from "../helpers/api";
import Icon from "./Icon";
import "../less/github-badge.less";
interface Props {}
const GitHubBadge: React.FC<Props> = () => {
const GitHubBadge = () => {
const [starCount, setStarCount] = useState(0);
useEffect(() => {
@@ -16,13 +15,10 @@ const GitHubBadge: React.FC<Props> = () => {
return (
<a className="github-badge-container" href="https://github.com/usememos/memos">
<div className="github-icon">
<i className="fa-brands fa-github fa-lg icon-img"></i>
<Icon.GitHub className="icon-img" />
Star
</div>
<div className="count-text">
{starCount || ""}
<span className="icon-text">🌟</span>
</div>
<div className="count-text">{starCount || ""}</div>
</a>
);
};

View File

@@ -0,0 +1,3 @@
import * as Icon from "react-feather";
export default Icon;

View File

@@ -1,72 +1,73 @@
import { memo, useEffect, useRef, useState } from "react";
import { indexOf } from "lodash-es";
import copy from "copy-to-clipboard";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import { IMAGE_URL_REG, UNKNOWN_ID } from "../helpers/consts";
import { DONE_BLOCK_REG, formatMemoContent, TODO_BLOCK_REG } from "../helpers/marked";
import { memo, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import "dayjs/locale/zh";
import { UNKNOWN_ID } from "../helpers/consts";
import { editorStateService, locationService, memoService, userService } from "../services";
import Only from "./common/OnlyWhen";
import Icon from "./Icon";
import toastHelper from "./Toast";
import Image from "./Image";
import MemoContent from "./MemoContent";
import MemoResources from "./MemoResources";
import showMemoCardDialog from "./MemoCardDialog";
import showShareMemoImageDialog from "./ShareMemoImageDialog";
import showPreviewImageDialog from "./PreviewImageDialog";
import "../less/memo.less";
dayjs.extend(relativeTime);
const MAX_MEMO_CONTAINER_HEIGHT = 384;
type ExpandButtonStatus = -1 | 0 | 1;
interface Props {
memo: Memo;
}
interface State {
expandButtonStatus: ExpandButtonStatus;
}
export const getFormatedMemoCreatedAtStr = (createdTs: number): string => {
if (Date.now() - createdTs < 1000 * 60 * 60 * 24) {
return dayjs(createdTs).fromNow();
export const getFormatedMemoTimeStr = (time: number, locale = "en"): string => {
if (Date.now() - time < 1000 * 60 * 60 * 24) {
return dayjs(time).locale(locale).fromNow();
} else {
return dayjs(createdTs).format("YYYY/MM/DD HH:mm:ss");
return dayjs(time).locale(locale).format("YYYY/MM/DD HH:mm:ss");
}
};
const Memo: React.FC<Props> = (props: Props) => {
const memo = props.memo;
const [state, setState] = useState<State>({
expandButtonStatus: -1,
});
const [createdAtStr, setCreatedAtStr] = useState<string>(getFormatedMemoCreatedAtStr(memo.createdTs));
const { t, i18n } = useTranslation();
const navigate = useNavigate();
const [displayTimeStr, setDisplayTimeStr] = useState<string>(getFormatedMemoTimeStr(memo.displayTs, i18n.language));
const memoContainerRef = useRef<HTMLDivElement>(null);
const imageUrls = Array.from(memo.content.match(IMAGE_URL_REG) ?? []).map((s) => s.replace(IMAGE_URL_REG, "$1"));
const isVisitorMode = userService.isVisitorMode();
useEffect(() => {
if (!memoContainerRef) {
let intervalFlag: any = -1;
if (Date.now() - memo.displayTs < 1000 * 60 * 60 * 24) {
intervalFlag = setInterval(() => {
setDisplayTimeStr(getFormatedMemoTimeStr(memo.displayTs, i18n.language));
}, 1000 * 1);
}
return () => {
clearInterval(intervalFlag);
};
}, [i18n.language]);
const handleShowMemoStoryDialog = () => {
if (isVisitorMode) {
return;
}
if (Number(memoContainerRef.current?.clientHeight) > MAX_MEMO_CONTAINER_HEIGHT) {
setState({
...state,
expandButtonStatus: 0,
});
}
if (Date.now() - memo.createdTs < 1000 * 60 * 60 * 24) {
setInterval(() => {
setCreatedAtStr(dayjs(memo.createdTs).fromNow());
}, 1000 * 1);
}
}, []);
const handleShowMemoStoryDialog = () => {
showMemoCardDialog(memo);
};
const handleViewMemoDetailPage = () => {
navigate(`/m/${memo.id}`);
};
const handleCopyContent = () => {
copy(memo.content);
toastHelper.success(t("message.succeed-copy-content"));
};
const handleTogglePinMemoBtnClick = async () => {
try {
if (memo.pinned) {
@@ -94,7 +95,8 @@ const Memo: React.FC<Props> = (props: Props) => {
rowStatus: "ARCHIVED",
});
} catch (error: any) {
toastHelper.error(error.message);
console.error(error);
toastHelper.error(error.response.data.message);
}
if (editorStateService.getState().editMemoId === memo.id) {
@@ -116,7 +118,7 @@ const Memo: React.FC<Props> = (props: Props) => {
if (memoTemp) {
showMemoCardDialog(memoTemp);
} else {
toastHelper.error("MEMO Not Found");
toastHelper.error(t("message.memo-not-found"));
targetEl.classList.remove("memo-link-text");
}
} else if (targetEl.className === "tag-span") {
@@ -136,8 +138,8 @@ const Memo: React.FC<Props> = (props: Props) => {
const todoElementList = [...(memoContainerRef.current?.querySelectorAll(`span.todo-block[data-value=${status}]`) ?? [])];
for (const element of todoElementList) {
if (element === targetEl) {
const index = indexOf(todoElementList, element);
const tempList = memo.content.split(status === "DONE" ? DONE_BLOCK_REG : TODO_BLOCK_REG);
const index = todoElementList.indexOf(element);
const tempList = memo.content.split(status === "DONE" ? /- \[x\] / : /- \[ \] /);
let finalContent = "";
for (let i = 0; i < tempList.length; i++) {
@@ -158,78 +160,83 @@ const Memo: React.FC<Props> = (props: Props) => {
});
}
}
} else if (targetEl.tagName === "IMG") {
const imgUrl = targetEl.getAttribute("src");
if (imgUrl) {
showPreviewImageDialog(imgUrl);
}
}
};
const handleExpandBtnClick = () => {
setState({
expandButtonStatus: Number(Boolean(!state.expandButtonStatus)) as ExpandButtonStatus,
});
const handleMemoContentDoubleClick = (e: React.MouseEvent) => {
const targetEl = e.target as HTMLElement;
if (targetEl.className === "memo-link-text") {
return;
} else if (targetEl.className === "tag-span") {
return;
} else if (targetEl.classList.contains("todo-block")) {
return;
}
editorStateService.setEditMemoWithId(memo.id);
};
return (
<div className={`memo-wrapper ${"memos-" + memo.id} ${memo.pinned ? "pinned" : ""}`}>
<div className={`memo-wrapper ${"memos-" + memo.id} ${memo.pinned ? "pinned" : ""}`} ref={memoContainerRef}>
<div className="memo-top-wrapper">
<div className="status-text-container" onClick={handleShowMemoStoryDialog}>
<span className="time-text">{createdAtStr}</span>
<Only when={memo.visibility !== "PRIVATE" && !isVisitorMode}>
<span className={`status-text ${memo.visibility.toLocaleLowerCase()}`}>{memo.visibility}</span>
</Only>
</div>
<div className={`btns-container ${userService.isVisitorMode() ? "!hidden" : ""}`}>
<span className="btn more-action-btn">
<i className="fa-solid fa-ellipsis icon-img"></i>
<div className="status-text-container">
<span className="time-text" onClick={handleShowMemoStoryDialog}>
{displayTimeStr}
</span>
<div className="more-action-btns-wrapper">
<div className="more-action-btns-container">
<div className="btns-container">
<div className="btn" onClick={handleTogglePinMemoBtnClick}>
<i className={`fa-solid fa-thumbtack icon-img ${memo.pinned ? "" : "opacity-20"}`}></i>
<span className="tip-text">{memo.pinned ? "Unpin" : "Pin"}</span>
</div>
<div className="btn" onClick={handleEditMemoClick}>
<i className="fa-solid fa-pen-to-square icon-img"></i>
<span className="tip-text">Edit</span>
</div>
<div className="btn" onClick={handleGenMemoImageBtnClick}>
<i className="fa-solid fa-share-nodes icon-img"></i>
<span className="tip-text">Share</span>
{memo.visibility !== "PRIVATE" && !isVisitorMode && (
<span className={`status-text ${memo.visibility.toLocaleLowerCase()}`}>{memo.visibility}</span>
)}
</div>
{!isVisitorMode && (
<div className="btns-container">
<span className="btn more-action-btn">
<Icon.MoreHorizontal className="icon-img" />
</span>
<div className="more-action-btns-wrapper">
<div className="more-action-btns-container">
<div className="btns-container">
<div className="btn" onClick={handleTogglePinMemoBtnClick}>
<Icon.Flag className={`icon-img ${memo.pinned ? "" : "opacity-20"}`} />
<span className="tip-text">{memo.pinned ? t("common.unpin") : t("common.pin")}</span>
</div>
<div className="btn" onClick={handleEditMemoClick}>
<Icon.Edit3 className="icon-img" />
<span className="tip-text">{t("common.edit")}</span>
</div>
<div className="btn" onClick={handleGenMemoImageBtnClick}>
<Icon.Share className="icon-img" />
<span className="tip-text">{t("common.share")}</span>
</div>
</div>
<span className="btn" onClick={handleMarkMemoClick}>
{t("common.mark")}
</span>
<span className="btn" onClick={handleCopyContent}>
{t("memo.copy")}
</span>
<span className="btn" onClick={handleViewMemoDetailPage}>
{t("memo.view-detail")}
</span>
<span className="btn archive-btn" onClick={handleArchiveMemoClick}>
{t("common.archive")}
</span>
</div>
<span className="btn" onClick={handleMarkMemoClick}>
Mark
</span>
<span className="btn" onClick={handleShowMemoStoryDialog}>
View Story
</span>
<span className="btn archive-btn" onClick={handleArchiveMemoClick}>
Archive
</span>
</div>
</div>
</div>
)}
</div>
<div
ref={memoContainerRef}
className={`memo-content-text ${state.expandButtonStatus === 0 ? "expanded" : ""}`}
onClick={handleMemoContentClick}
dangerouslySetInnerHTML={{ __html: formatMemoContent(memo.content) }}
></div>
{state.expandButtonStatus !== -1 && (
<div className="expand-btn-container">
<span className={`btn ${state.expandButtonStatus === 0 ? "expand-btn" : "fold-btn"}`} onClick={handleExpandBtnClick}>
{state.expandButtonStatus === 0 ? "Expand" : "Fold"}
<i className="fa-solid fa-chevron-right icon-img"></i>
</span>
</div>
)}
<Only when={imageUrls.length > 0}>
<div className="images-wrapper">
{imageUrls.map((imgUrl, idx) => (
<Image className="memo-img" key={idx} imgUrl={imgUrl} />
))}
</div>
</Only>
<MemoContent
content={memo.content}
onMemoContentClick={handleMemoContentClick}
onMemoContentDoubleClick={handleMemoContentDoubleClick}
/>
<MemoResources memo={memo} />
</div>
);
};

View File

@@ -1,14 +1,21 @@
import copy from "copy-to-clipboard";
import { useState, useEffect, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { editorStateService, memoService, userService } from "../services";
import { IMAGE_URL_REG, MEMO_LINK_REG, UNKNOWN_ID } from "../helpers/consts";
import { useAppSelector } from "../store";
import { UNKNOWN_ID, VISIBILITY_SELECTOR_ITEMS } from "../helpers/consts";
import * as utils from "../helpers/utils";
import { formatMemoContent, parseHtmlToRawText } from "../helpers/marked";
import Only from "./common/OnlyWhen";
import { parseHTMLToRawText } from "../helpers/utils";
import { marked } from "../labs/marked";
import { MARK_REG } from "../labs/marked/parser";
import toastHelper from "./Toast";
import { generateDialog } from "./Dialog";
import Image from "./Image";
import "../less/memo-card-dialog.less";
import Icon from "./Icon";
import Selector from "./common/Selector";
import MemoContent from "./MemoContent";
import MemoResources from "./MemoResources";
import showChangeMemoCreatedTsDialog from "./ChangeMemoCreatedTsDialog";
import "../less/memo-card-dialog.less";
interface LinkedMemo extends Memo {
createdAtStr: string;
@@ -20,23 +27,26 @@ interface Props extends DialogProps {
}
const MemoCardDialog: React.FC<Props> = (props: Props) => {
const { t } = useTranslation();
const memos = useAppSelector((state) => state.memo.memos);
const [memo, setMemo] = useState<Memo>({
...props.memo,
});
const [linkMemos, setLinkMemos] = useState<LinkedMemo[]>([]);
const [linkedMemos, setLinkedMemos] = useState<LinkedMemo[]>([]);
const imageUrls = Array.from(memo.content.match(IMAGE_URL_REG) ?? []).map((s) => s.replace(IMAGE_URL_REG, "$1"));
const visibilityList = [
{ text: "PUBLIC", value: "PUBLIC" },
{ text: "PROTECTED", value: "PROTECTED" },
{ text: "PRIVATE", value: "PRIVATE" },
];
const isVisitorMode = userService.isVisitorMode();
const visibilitySelectorItems = VISIBILITY_SELECTOR_ITEMS.map((item) => {
return {
value: item.value,
text: t(`memo.visibility.${item.text.toLowerCase()}`),
};
});
useEffect(() => {
const fetchLinkedMemos = async () => {
try {
const linkMemos: LinkedMemo[] = [];
const matchedArr = [...memo.content.matchAll(MEMO_LINK_REG)];
const matchedArr = [...memo.content.matchAll(MARK_REG)];
for (const matchRes of matchedArr) {
if (matchRes && matchRes.length === 3) {
const id = Number(matchRes[2]);
@@ -48,8 +58,8 @@ const MemoCardDialog: React.FC<Props> = (props: Props) => {
if (memoTemp) {
linkMemos.push({
...memoTemp,
createdAtStr: utils.getDateTimeString(memoTemp.createdTs),
dateStr: utils.getDateString(memoTemp.createdTs),
createdAtStr: utils.getDateTimeString(memoTemp.displayTs),
dateStr: utils.getDateString(memoTemp.displayTs),
});
}
}
@@ -60,11 +70,11 @@ const MemoCardDialog: React.FC<Props> = (props: Props) => {
setLinkedMemos(
linkedMemos
.filter((m) => m.rowStatus === "NORMAL" && m.id !== memo.id)
.sort((a, b) => utils.getTimeStampByDate(b.createdTs) - utils.getTimeStampByDate(a.createdTs))
.sort((a, b) => utils.getTimeStampByDate(b.displayTs) - utils.getTimeStampByDate(a.displayTs))
.map((m) => ({
...m,
createdAtStr: utils.getDateTimeString(m.createdTs),
dateStr: utils.getDateString(m.createdTs),
createdAtStr: utils.getDateTimeString(m.displayTs),
dateStr: utils.getDateString(m.displayTs),
}))
);
} catch (error) {
@@ -73,7 +83,16 @@ const MemoCardDialog: React.FC<Props> = (props: Props) => {
};
fetchLinkedMemos();
}, [memo.id]);
setMemo(memoService.getMemoById(memo.id) as Memo);
}, [memos, memo.id]);
const handleMemoCreatedAtClick = () => {
if (isVisitorMode) {
return;
}
showChangeMemoCreatedTsDialog(memo.id);
};
const handleMemoContentClick = useCallback(async (e: React.MouseEvent) => {
const targetEl = e.target as HTMLElement;
@@ -85,13 +104,13 @@ const MemoCardDialog: React.FC<Props> = (props: Props) => {
if (memoTemp) {
const nextMemo = {
...memoTemp,
createdAtStr: utils.getDateTimeString(memoTemp.createdTs),
createdAtStr: utils.getDateTimeString(memoTemp.displayTs),
};
setLinkMemos([]);
setLinkedMemos([]);
setMemo(nextMemo);
} else {
toastHelper.error("MEMO Not Found");
toastHelper.error(t("message.memo-not-found"));
targetEl.classList.remove("memo-link-text");
}
}
@@ -103,11 +122,20 @@ const MemoCardDialog: React.FC<Props> = (props: Props) => {
setMemo(memo);
}, []);
const handleGotoMemoLinkBtnClick = () => {
window.open(`/m/${memo.id}`);
};
const handleEditMemoBtnClick = () => {
props.destroy();
editorStateService.setEditMemoWithId(memo.id);
};
const handleCopyContent = () => {
copy(memo.content);
toastHelper.success(t("message.succeed-copy-content"));
};
const handleVisibilitySelectorChange = async (visibility: Visibility) => {
if (memo.visibility === visibility) {
return;
@@ -125,49 +153,47 @@ const MemoCardDialog: React.FC<Props> = (props: Props) => {
return (
<>
<Only when={!userService.isVisitorMode()}>
{!isVisitorMode && (
<div className="card-header-container">
<div className="visibility-selector-container">
<i className="fa-solid fa-eye mr-2"></i>
<Icon.Eye className="icon-img" />
<Selector
className="visibility-selector"
dataSource={visibilityList}
dataSource={visibilitySelectorItems}
value={memo.visibility}
handleValueChanged={(value) => handleVisibilitySelectorChange(value as Visibility)}
/>
</div>
</div>
</Only>
)}
<div className="memo-card-container">
<div className="header-container">
<p className="time-text">{utils.getDateTimeString(memo.createdTs)}</p>
<p className="time-text" onClick={handleMemoCreatedAtClick}>
{utils.getDateTimeString(memo.displayTs)}
</p>
<div className="btns-container">
<Only when={!userService.isVisitorMode()}>
{!isVisitorMode && (
<>
<button className="btn edit-btn" onClick={handleGotoMemoLinkBtnClick}>
<Icon.ExternalLink className="icon-img" />
</button>
<button className="btn copy-btn" onClick={handleCopyContent}>
<Icon.Clipboard className="icon-img" />
</button>
<button className="btn edit-btn" onClick={handleEditMemoBtnClick}>
<i className="fa-solid fa-pen-to-square icon-img"></i>
<Icon.Edit3 className="icon-img" />
</button>
<span className="split-line">/</span>
</>
</Only>
)}
<button className="btn close-btn" onClick={props.destroy}>
<i className="fa-solid fa-xmark fa-lg icon-img"></i>
<Icon.X className="icon-img" />
</button>
</div>
</div>
<div className="memo-container">
<div
className="memo-content-text"
onClick={handleMemoContentClick}
dangerouslySetInnerHTML={{ __html: formatMemoContent(memo.content) }}
></div>
<Only when={imageUrls.length > 0}>
<div className="images-wrapper">
{imageUrls.map((imgUrl, idx) => (
<Image className="memo-img" key={idx} imgUrl={imgUrl} />
))}
</div>
</Only>
<MemoContent displayConfig={{ enableExpand: false }} content={memo.content} onMemoContentClick={handleMemoContentClick} />
<MemoResources memo={memo} />
</div>
<div className="layer-container"></div>
{linkMemos.map((_, idx) => {
@@ -193,7 +219,7 @@ const MemoCardDialog: React.FC<Props> = (props: Props) => {
<div className="linked-memos-wrapper">
<p className="normal-text">{linkMemos.length} related MEMO</p>
{linkMemos.map((memo, index) => {
const rawtext = parseHtmlToRawText(formatMemoContent(memo.content)).replaceAll("\n", " ");
const rawtext = parseHTMLToRawText(marked(memo.content)).replaceAll("\n", " ");
return (
<div className="linked-memo-container" key={`${index}-${memo.id}`} onClick={() => handleLinkedMemoClick(memo)}>
<span className="time-text">{memo.dateStr} </span>
@@ -207,7 +233,7 @@ const MemoCardDialog: React.FC<Props> = (props: Props) => {
<div className="linked-memos-wrapper">
<p className="normal-text">{linkedMemos.length} linked MEMO</p>
{linkedMemos.map((memo, index) => {
const rawtext = parseHtmlToRawText(formatMemoContent(memo.content)).replaceAll("\n", " ");
const rawtext = parseHTMLToRawText(marked(memo.content)).replaceAll("\n", " ");
return (
<div className="linked-memo-container" key={`${index}-${memo.id}`} onClick={() => handleLinkedMemoClick(memo)}>
<span className="time-text">{memo.dateStr} </span>

View File

@@ -0,0 +1,96 @@
import { useEffect, useRef, useState } from "react";
import { marked } from "../labs/marked";
import Icon from "./Icon";
import "../less/memo-content.less";
export interface DisplayConfig {
enableExpand: boolean;
}
interface Props {
content: string;
className?: string;
displayConfig?: Partial<DisplayConfig>;
onMemoContentClick?: (e: React.MouseEvent) => void;
onMemoContentDoubleClick?: (e: React.MouseEvent) => void;
}
type ExpandButtonStatus = -1 | 0 | 1;
interface State {
expandButtonStatus: ExpandButtonStatus;
}
const defaultDisplayConfig: DisplayConfig = {
enableExpand: true,
};
const MAX_MEMO_CONTAINER_HEIGHT = 384;
const MemoContent: React.FC<Props> = (props: Props) => {
const { className, content, onMemoContentClick, onMemoContentDoubleClick } = props;
const [state, setState] = useState<State>({
expandButtonStatus: -1,
});
const memoContentContainerRef = useRef<HTMLDivElement>(null);
const displayConfig = {
...defaultDisplayConfig,
...props.displayConfig,
};
useEffect(() => {
if (!memoContentContainerRef) {
return;
}
if (displayConfig.enableExpand) {
if (Number(memoContentContainerRef.current?.clientHeight) > MAX_MEMO_CONTAINER_HEIGHT) {
setState({
...state,
expandButtonStatus: 0,
});
}
}
}, []);
const handleMemoContentClick = async (e: React.MouseEvent) => {
if (onMemoContentClick) {
onMemoContentClick(e);
}
};
const handleMemoContentDoubleClick = async (e: React.MouseEvent) => {
if (onMemoContentDoubleClick) {
onMemoContentDoubleClick(e);
}
};
const handleExpandBtnClick = () => {
const expandButtonStatus = Boolean(!state.expandButtonStatus);
setState({
expandButtonStatus: Number(expandButtonStatus) as ExpandButtonStatus,
});
};
return (
<div className={`memo-content-wrapper ${className || ""}`}>
<div
ref={memoContentContainerRef}
className={`memo-content-text ${state.expandButtonStatus === 0 ? "expanded" : ""}`}
onClick={handleMemoContentClick}
onDoubleClick={handleMemoContentDoubleClick}
dangerouslySetInnerHTML={{ __html: marked(content) }}
></div>
{state.expandButtonStatus !== -1 && (
<div className="expand-btn-container">
<span className={`btn ${state.expandButtonStatus === 0 ? "expand-btn" : "fold-btn"}`} onClick={handleExpandBtnClick}>
{state.expandButtonStatus === 0 ? "Expand" : "Fold"}
<Icon.ChevronRight className="icon-img" />
</span>
</div>
)}
</div>
);
};
export default MemoContent;

Some files were not shown because too many files have changed in this diff Show More