Compare commits
214 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fe05e6a464 | |||
| d1aa6aa7f8 | |||
| 79af7e8abf | |||
| a142d975d7 | |||
| 67691d1e99 | |||
| 9b827b4801 | |||
| 421f4dbf60 | |||
| 8f119c4265 | |||
| 9866702850 | |||
| a313a9bb31 | |||
| e53f5fdd29 | |||
| b6c0a04394 | |||
| 1e3b8315a0 | |||
| dc5d705f8c | |||
| 4f10c12092 | |||
| 3aa44f9f6d | |||
| 78b4733fb9 | |||
| 37bb3bc546 | |||
| b43bfce254 | |||
| 0d6281ef6b | |||
| 8e2844e0c5 | |||
| c27e38b11c | |||
| cf75054106 | |||
| 4ed987229b | |||
| a1068b6fe3 | |||
| 91a61e058a | |||
| bebcabc292 | |||
| 5bdf15aece | |||
| d347b7a91e | |||
| 2c653f170c | |||
| 75218ef826 | |||
| b7fbbed895 | |||
| f8c0d73d2d | |||
| 006cb56d28 | |||
| 55dee0df7e | |||
| 2a275b2875 | |||
| 4246232fbe | |||
| 9d8c9609c3 | |||
| ef5492074e | |||
| 40d5686031 | |||
| 4276a7a56d | |||
| d6fa1c7c80 | |||
| 6cbc3c78e2 | |||
| 29966451b6 | |||
| 64f0662a61 | |||
| 0ccfd0c743 | |||
| 0ea1733acc | |||
| 8fb59bbfb9 | |||
| 7c401040c8 | |||
| 43541bde2c | |||
| 1e01c4dc46 | |||
| e85d368f87 | |||
| 95376f78f6 | |||
| 30daea0c4f | |||
| b891e08928 | |||
| 94df09c8c0 | |||
| bdf6d4d42a | |||
| cb2e1ae355 | |||
| 9705406b82 | |||
| 4e00b1b0cd | |||
| fe5ba6850b | |||
| 5690dab6bb | |||
| 65506a5b30 | |||
| f09bb2689c | |||
| e6f376ae66 | |||
| 1c2998c4d8 | |||
| fc5d5cf231 | |||
| 2a4fc7dcc3 | |||
| b68d6e2693 | |||
| 0b34b142c8 | |||
| 0b2a9d8511 | |||
| 7c9c5c316b | |||
| 180ae206c7 | |||
| 69e3ba6bed | |||
| bf5b7e747d | |||
| 24154c95f2 | |||
| 0b10d24eb2 | |||
| 06ec59eca4 | |||
| 35a8817442 | |||
| c7378e78d9 | |||
| ab182dc22a | |||
| f554e5a357 | |||
| 749486ba3c | |||
| 26aae0e637 | |||
| 95c8d8fc9c | |||
| 1021023577 | |||
| 3d0c6004c0 | |||
| 6c53bd00f6 | |||
| cc759bef56 | |||
| d670adc11f | |||
| 349c383604 | |||
| f407488128 | |||
| 086b10717f | |||
| eefd0444c8 | |||
| 65a61ed270 | |||
| 5e7db4631e | |||
| 0d6114e25e | |||
| ce5a6fa3ac | |||
| 246851fdbe | |||
| 21c30ac157 | |||
| 269d92e637 | |||
| ffe145d436 | |||
| 37cd841992 | |||
| 315ab94c94 | |||
| 1aa9963e07 | |||
| 88ade2c0b7 | |||
| 4ada7dce77 | |||
| a323689ee6 | |||
| 2ea612e2fe | |||
| ca2557eb7e | |||
| e0f95686f3 | |||
| 29770e8bfb | |||
| b959acc69d | |||
| 486cf8bdac | |||
| ea911387f1 | |||
| 0b9b89db81 | |||
| eaf89aa2f2 | |||
| 4bd373ba57 | |||
| 7b29c65f58 | |||
| 2298ac6ff3 | |||
| 4477f47c25 | |||
| 4a2fe3aaae | |||
| 0f65b8bdd3 | |||
| 778282ae29 | |||
| 85dc29bfb9 | |||
| 05e46ee4a8 | |||
| 6a3b052fa2 | |||
| 222c792539 | |||
| 51fb8ddb07 | |||
| 8e63b8f289 | |||
| 17c35be426 | |||
| dfd461518f | |||
| df5818cdc5 | |||
| 5894104524 | |||
| 09732df0f5 | |||
| ae8f292306 | |||
| 9f8c0ce567 | |||
| c7cf35c7de | |||
| e5c9d8604d | |||
| b2c22977c1 | |||
| c0edb72b3d | |||
| 33d31b7dca | |||
| 4c465bef2d | |||
| 3bde9543f3 | |||
| cff0e86989 | |||
| 65f7aa7914 | |||
| 06f5a5788e | |||
| 52c8ac2ad3 | |||
| 0c80654cc2 | |||
| a2180f177f | |||
| b992d07f3e | |||
| dc3052e225 | |||
| 9b2e57cee5 | |||
| 77a3513a6b | |||
| 0dd2337663 | |||
| d316c04837 | |||
| 63468dbaf3 | |||
| 2acd5d4af2 | |||
| 6c1cc1d283 | |||
| 15cfc9e1f5 | |||
| 004713d4cd | |||
| 7a6eb53e0f | |||
| 02c26d5bb4 | |||
| c60c58ed69 | |||
| 366afdd1e4 | |||
| 307483e499 | |||
| 4608894e56 | |||
| a1066322c8 | |||
| 050a2d39fa | |||
| 13aa61bbc0 | |||
| f4d0e8c948 | |||
| e7db587a9e | |||
| 059080f194 | |||
| 78c159cd59 | |||
| 721aa3c907 | |||
| 77178afad5 | |||
| fd7b8c3293 | |||
| 660908e436 | |||
| 8d694f7732 | |||
| bdfa9f7a56 | |||
| 19ef97a7ec | |||
| 2c17ea703d | |||
| 1591fdf61c | |||
| 811f3340e9 | |||
| 7e8d1128f8 | |||
| 54db6eda04 | |||
| c5b26e3310 | |||
| 7079faf2b9 | |||
| 707d1a96eb | |||
| 76801dfa4f | |||
| 6e4577f721 | |||
| 7b0987610c | |||
| 50fa560d4a | |||
| b8a7df21f2 | |||
| d1a4348048 | |||
| 020b211660 | |||
| 4657e58b52 | |||
| 22971c3a93 | |||
| 5fa9aa3c22 | |||
| fbce43870f | |||
| b1e6956441 | |||
| ad462cec29 | |||
| a0a42285d0 | |||
| b0b2776d03 | |||
| e9ac6affef | |||
| 5eea1339c9 | |||
| f303dc21a0 | |||
| d68891d91d | |||
| 987bb80770 | |||
| b884327a53 | |||
| 4743818fe7 | |||
| 43575e6f54 | |||
| 89f9dc5640 | |||
| f7aca99d52 |
@@ -0,0 +1 @@
|
||||
ko_fi: stevenlgtm
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -43,6 +43,21 @@ jobs:
|
||||
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:
|
||||
|
||||
+6
-1
@@ -10,4 +10,9 @@ web/dist
|
||||
# build folder
|
||||
build
|
||||
|
||||
.DS_Store
|
||||
.DS_Store
|
||||
|
||||
# Jetbrains
|
||||
.idea
|
||||
|
||||
bin/air
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<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">An open-source, self-hosted memo hub for knowledge management and collaboration.</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/usememos/memos/stargazers"><img alt="GitHub stars" src="https://img.shields.io/github/stars/usememos/memos" /></a>
|
||||
@@ -17,82 +17,45 @@
|
||||
|
||||
## Features
|
||||
|
||||
- 🦄 Fully open source;
|
||||
- 📜 Writing in plain textarea without any burden,
|
||||
- and support some useful markdown syntax 💪.
|
||||
- 🌄 Share the memo in a pretty image or personal page like Twitter;
|
||||
- 🚀 Fast self-hosting with `Docker`;
|
||||
- 🤠 Pleasant UI and UX;
|
||||
- 🦄 Open source and free forever;
|
||||
- 🚀 Support for self-hosting with `Docker` in seconds;
|
||||
- 📜 Plain textarea first and support some useful markdown syntax;
|
||||
- 👥 Collaborate and share with your teammates;
|
||||
- 🧑💻 RESTful API for self-service.
|
||||
|
||||
## Deploy with Docker
|
||||
## Deploy with Docker in seconds
|
||||
|
||||
#### Docker Run
|
||||
### Docker Run
|
||||
|
||||
```docker
|
||||
docker run -d --name memos -p 5230:5230 -v ~/.memos/:/var/opt/memos neosmemo/memos:latest
|
||||
```
|
||||
|
||||
#### Docker Compose
|
||||
|
||||
```
|
||||
version: "3.0"
|
||||
services:
|
||||
memos:
|
||||
image: neosmemo/memos:latest
|
||||
container_name: memos
|
||||
volumes:
|
||||
- ~/.memos/:/var/opt/memos
|
||||
ports:
|
||||
- 5230:5230
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
## Development
|
||||
### Docker Compose
|
||||
|
||||
Memos is built with a curated tech stack. It is optimized for developer experience and is very easy to start working on the code:
|
||||
Example Compose YAML file: [`docker-compose.yaml`](./docker-compose.yaml).
|
||||
|
||||
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.
|
||||
If you want to upgrade the version of memos, use the following command.
|
||||
|
||||
### Tech Stack
|
||||
```sh
|
||||
docker-compose down && docker image rm neosmemo/memos:latest && docker-compose up -d
|
||||
```
|
||||
|
||||
<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
|
||||
## Contribute
|
||||
|
||||
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. 🥰
|
||||
|
||||
See more in [development guide](https://github.com/usememos/memos/tree/main/docs/development.md).
|
||||
|
||||
## Community Products
|
||||
|
||||
- [Moe Memos](https://memos.moe/) - Third party client for iOS and Android
|
||||
- [lmm214/memos-bber](https://github.com/lmm214/memos-bber) - Chrome extension
|
||||
- [Rabithua/memos_wmp](https://github.com/Rabithua/memos_wmp) - Wechat miniprogram
|
||||
- [qazxcdswe123/telegramMemoBot](https://github.com/qazxcdswe123/telegramMemoBot) - Telegram bot
|
||||
|
||||
## Star history
|
||||
|
||||
[](https://star-history.com/#usememos/memos&Date)
|
||||
|
||||
+14
-2
@@ -37,6 +37,11 @@ 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 {
|
||||
@@ -46,18 +51,25 @@ type MemoCreate struct {
|
||||
// Domain specific fields
|
||||
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"`
|
||||
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 {
|
||||
@@ -78,5 +90,5 @@ type MemoFind struct {
|
||||
}
|
||||
|
||||
type MemoDelete struct {
|
||||
ID int `json:"id"`
|
||||
ID int
|
||||
}
|
||||
|
||||
@@ -19,3 +19,8 @@ type MemoOrganizerUpsert struct {
|
||||
UserID int
|
||||
Pinned bool `json:"pinned"`
|
||||
}
|
||||
|
||||
type MemoOrganizerDelete struct {
|
||||
MemoID *int
|
||||
UserID *int
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+14
-3
@@ -13,6 +13,9 @@ type Resource struct {
|
||||
Blob []byte `json:"-"`
|
||||
Type string `json:"type"`
|
||||
Size int64 `json:"size"`
|
||||
|
||||
// Related fields
|
||||
LinkedMemoAmount int `json:"linkedMemoAmount"`
|
||||
}
|
||||
|
||||
type ResourceCreate struct {
|
||||
@@ -32,13 +35,21 @@ type ResourceFind struct {
|
||||
// Standard fields
|
||||
CreatorID *int `json:"creatorId"`
|
||||
|
||||
// Domain specific fields
|
||||
Filename *string `json:"filename"`
|
||||
MemoID *int
|
||||
}
|
||||
|
||||
type ResourcePatch struct {
|
||||
ID int
|
||||
|
||||
// Standard fields
|
||||
UpdatedTs *int64
|
||||
|
||||
// Domain specific fields
|
||||
Filename *string `json:"filename"`
|
||||
}
|
||||
|
||||
type ResourceDelete struct {
|
||||
ID int
|
||||
|
||||
// Standard fields
|
||||
CreatorID int
|
||||
}
|
||||
|
||||
+5
-1
@@ -27,6 +27,7 @@ type ShortcutPatch struct {
|
||||
ID int
|
||||
|
||||
// Standard fields
|
||||
UpdatedTs *int64
|
||||
RowStatus *RowStatus `json:"rowStatus"`
|
||||
|
||||
// Domain specific fields
|
||||
@@ -45,5 +46,8 @@ type ShortcutFind struct {
|
||||
}
|
||||
|
||||
type ShortcutDelete struct {
|
||||
ID int
|
||||
ID *int
|
||||
|
||||
// Standard fields
|
||||
CreatorID *int
|
||||
}
|
||||
|
||||
@@ -5,4 +5,9 @@ import "github.com/usememos/memos/server/profile"
|
||||
type SystemStatus struct {
|
||||
Host *User `json:"host"`
|
||||
Profile *profile.Profile `json:"profile"`
|
||||
// System settings
|
||||
// Allow sign up.
|
||||
AllowSignUp bool `json:"allowSignUp"`
|
||||
// Additional style.
|
||||
AdditionalStyle string `json:"additionalStyle"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type SystemSettingName string
|
||||
|
||||
const (
|
||||
// SystemSettingAllowSignUpName is the key type of allow signup setting.
|
||||
SystemSettingAllowSignUpName SystemSettingName = "allowSignUp"
|
||||
// SystemSettingAdditionalStyleName is the key type of additional style.
|
||||
SystemSettingAdditionalStyleName SystemSettingName = "additionalStyle"
|
||||
)
|
||||
|
||||
func (key SystemSettingName) String() string {
|
||||
switch key {
|
||||
case SystemSettingAllowSignUpName:
|
||||
return "allowSignUp"
|
||||
case SystemSettingAdditionalStyleName:
|
||||
return "additionalStyle"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
var (
|
||||
SystemSettingAllowSignUpValue = []bool{true, false}
|
||||
)
|
||||
|
||||
type SystemSetting struct {
|
||||
Name SystemSettingName
|
||||
// Value is a JSON string with basic value
|
||||
Value string
|
||||
Description string
|
||||
}
|
||||
|
||||
type SystemSettingUpsert struct {
|
||||
Name SystemSettingName `json:"name"`
|
||||
Value string `json:"value"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
func (upsert SystemSettingUpsert) Validate() error {
|
||||
if upsert.Name == SystemSettingAllowSignUpName {
|
||||
value := false
|
||||
err := json.Unmarshal([]byte(upsert.Value), &value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to unmarshal system setting allow signup value")
|
||||
}
|
||||
|
||||
invalid := true
|
||||
for _, v := range SystemSettingAllowSignUpValue {
|
||||
if value == v {
|
||||
invalid = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if invalid {
|
||||
return fmt.Errorf("invalid system setting allow signup value")
|
||||
}
|
||||
} else if upsert.Name == SystemSettingAdditionalStyleName {
|
||||
value := ""
|
||||
err := json.Unmarshal([]byte(upsert.Value), &value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to unmarshal system setting additional style value")
|
||||
}
|
||||
} else {
|
||||
return fmt.Errorf("invalid system setting name")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type SystemSettingFind struct {
|
||||
Name *SystemSettingName `json:"name"`
|
||||
}
|
||||
@@ -71,6 +71,7 @@ type UserPatch struct {
|
||||
ID int
|
||||
|
||||
// Standard fields
|
||||
UpdatedTs *int64
|
||||
RowStatus *RowStatus `json:"rowStatus"`
|
||||
|
||||
// Domain specific fields
|
||||
|
||||
+41
-14
@@ -12,8 +12,10 @@ const (
|
||||
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"
|
||||
// UserSettingMobileEditorStyleKey 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.
|
||||
@@ -23,16 +25,20 @@ func (key UserSettingKey) String() string {
|
||||
return "locale"
|
||||
case UserSettingMemoVisibilityKey:
|
||||
return "memoVisibility"
|
||||
case UserSettingEditorFontStyleKey:
|
||||
return "editorFontFamily"
|
||||
case UserSettingMobileEditorStyleKey:
|
||||
return "mobileEditorStyle"
|
||||
case UserSettingMemoDisplayTsOptionKey:
|
||||
return "memoDisplayTsOption"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
var (
|
||||
UserSettingLocaleValue = []string{"en", "zh"}
|
||||
UserSettingMemoVisibilityValue = []Visibility{Privite, Protected, Public}
|
||||
UserSettingEditorFontStyleValue = []string{"normal", "mono"}
|
||||
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 {
|
||||
@@ -83,22 +89,39 @@ func (upsert UserSettingUpsert) Validate() error {
|
||||
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)
|
||||
} 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 editor font style")
|
||||
return fmt.Errorf("failed to unmarshal user setting mobile editor style")
|
||||
}
|
||||
|
||||
invalid := true
|
||||
for _, value := range UserSettingEditorFontStyleValue {
|
||||
if editorFontStyleValue == value {
|
||||
for _, value := range UserSettingMobileEditorStyleValue {
|
||||
if mobileEditorStyleValue == value {
|
||||
invalid = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if invalid {
|
||||
return fmt.Errorf("invalid user setting editor font style value")
|
||||
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")
|
||||
@@ -112,3 +135,7 @@ type UserSettingFind struct {
|
||||
|
||||
Key *UserSettingKey `json:"key"`
|
||||
}
|
||||
|
||||
type UserSettingDelete struct {
|
||||
UserID int
|
||||
}
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"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
|
||||
}
|
||||
+67
-2
@@ -5,11 +5,76 @@ import (
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
|
||||
"github.com/usememos/memos/bin/server/cmd"
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
metric "github.com/usememos/memos/plugin/metrics"
|
||||
"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)
|
||||
}
|
||||
|
||||
serverInstance := server.NewServer(profile)
|
||||
storeInstance := store.New(db.Db, profile)
|
||||
serverInstance.Store = storeInstance
|
||||
|
||||
metricCollector := server.NewMetricCollector(profile, storeInstance)
|
||||
serverInstance.Collector = &metricCollector
|
||||
|
||||
println(greetingBanner)
|
||||
fmt.Printf("Version %s has started at :%d\n", profile.Version, profile.Port)
|
||||
metricCollector.Collect(ctx, &metric.Metric{
|
||||
Name: "service started",
|
||||
})
|
||||
|
||||
return serverInstance.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() {
|
||||
if err := cmd.Execute(); err != nil {
|
||||
if err := execute(); err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,3 +28,10 @@ func ValidateEmail(email string) bool {
|
||||
func GenUUID() string {
|
||||
return uuid.New().String()
|
||||
}
|
||||
|
||||
func Min(x, y int) int {
|
||||
if x < y {
|
||||
return x
|
||||
}
|
||||
return y
|
||||
}
|
||||
|
||||
@@ -7,4 +7,3 @@ services:
|
||||
- ~/.memos/:/var/opt/memos
|
||||
ports:
|
||||
- 5230:5230
|
||||
restart: unless-stopped
|
||||
@@ -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
|
||||
|
||||

|
||||
|
||||
## 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.
|
||||
@@ -8,31 +8,40 @@ 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-20220405052023-b1e9470b6e64 // 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/feeds v1.1.1
|
||||
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/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 // indirect
|
||||
github.com/kr/pretty v0.3.1 // indirect
|
||||
github.com/segmentio/backo-go v1.0.1 // indirect
|
||||
github.com/xtgo/uuid v0.0.0-20140804021211-a0b114877d4c // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/cespare/xxhash/v2 v2.1.2 // indirect
|
||||
github.com/golang/snappy v0.0.4 // indirect
|
||||
github.com/segmentio/analytics-go v3.1.0+incompatible
|
||||
)
|
||||
|
||||
@@ -52,7 +52,9 @@ github.com/appleboy/gofight/v2 v2.1.2/go.mod h1:frW+U1QZEdDgixycTj4CygQ48yLTUhpl
|
||||
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/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 h1:DDGfHa7BWjL4YnC6+E63dPcxHo2sUxDIu8g3QgEJdRY=
|
||||
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4=
|
||||
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=
|
||||
@@ -63,7 +65,11 @@ github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMn
|
||||
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=
|
||||
@@ -77,6 +83,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=
|
||||
@@ -90,9 +97,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=
|
||||
@@ -139,6 +148,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=
|
||||
@@ -157,6 +168,8 @@ github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+
|
||||
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
|
||||
github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8=
|
||||
github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
|
||||
github.com/gorilla/feeds v1.1.1 h1:HwKXxqzcRNg9to+BbvJog4+f3s/xzvtZXICcQGutYfY=
|
||||
github.com/gorilla/feeds v1.1.1/go.mod h1:Nk0jZrvPFZX1OBe5NPiddPw7CfwF6Q9eqzaBbaightA=
|
||||
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
|
||||
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
|
||||
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
|
||||
@@ -178,6 +191,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=
|
||||
@@ -190,24 +204,22 @@ github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxv
|
||||
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
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 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
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=
|
||||
@@ -217,6 +229,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=
|
||||
@@ -230,8 +243,9 @@ 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/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
@@ -241,6 +255,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=
|
||||
@@ -249,15 +265,24 @@ 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=
|
||||
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/segmentio/analytics-go v3.1.0+incompatible h1:IyiOfUgQFVHvsykKKbdI7ZsH374uv3/DfZUo9+G0Z80=
|
||||
github.com/segmentio/analytics-go v3.1.0+incompatible/go.mod h1:C7CYBtQWk4vRk2RyLu0qOcbHJ18E3F1HV2C/8JvKN48=
|
||||
github.com/segmentio/backo-go v1.0.1 h1:68RQccglxZeyURy93ASB/2kc9QudzgIDexJ927N++y4=
|
||||
github.com/segmentio/backo-go v1.0.1/go.mod h1:9/Rh6yILuLysoQnZ2oNooD2g7aBnvM7r/fNVxRNWfBc=
|
||||
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
|
||||
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
|
||||
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
|
||||
@@ -277,12 +302,13 @@ 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=
|
||||
github.com/xdg-go/scram v1.0.2/go.mod h1:1WAq6h33pAW+iRreB34OORO2Nf7qel3VV3fjBj+hCSs=
|
||||
github.com/xdg-go/stringprep v1.0.2/go.mod h1:8F9zXuvzgwmyT5DUm4GUfZGDdT3W+LCvS6+da4O5kxM=
|
||||
github.com/xtgo/uuid v0.0.0-20140804021211-a0b114877d4c h1:3lbZUMbMiGUW/LMkfsEABsc5zNT9+b1CvsJx47JzJ8g=
|
||||
github.com/xtgo/uuid v0.0.0-20140804021211-a0b114877d4c/go.mod h1:UrdRz5enIKZ63MEE3IF9l2/ebyx59GyGgPi+tICQdmM=
|
||||
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
@@ -302,8 +328,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=
|
||||
@@ -372,15 +399,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=
|
||||
@@ -391,12 +423,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=
|
||||
@@ -405,7 +437,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=
|
||||
@@ -415,7 +446,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=
|
||||
@@ -433,17 +463,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/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220405052023-b1e9470b6e64 h1:D1v9ucDTYBtbz5vNuBbAhIMAGhQhJ6Ym5ah3maMVNX4=
|
||||
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=
|
||||
@@ -456,8 +491,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=
|
||||
@@ -576,6 +612,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=
|
||||
@@ -589,6 +626,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=
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
package metric
|
||||
|
||||
// Collector is the interface definition for metric collector.
|
||||
type Collector interface {
|
||||
Collect(metric *Metric) error
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package metric
|
||||
|
||||
// Metric is the API message for metric.
|
||||
type Metric struct {
|
||||
Name string
|
||||
Labels map[string]string
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package segment
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/segmentio/analytics-go"
|
||||
metric "github.com/usememos/memos/plugin/metrics"
|
||||
)
|
||||
|
||||
var (
|
||||
sessionUUID = uuid.NewString()
|
||||
)
|
||||
|
||||
// collector is the metrics collector https://segment.com/.
|
||||
type collector struct {
|
||||
client analytics.Client
|
||||
}
|
||||
|
||||
// NewCollector creates a new instance of segment.
|
||||
func NewCollector(key string) metric.Collector {
|
||||
client := analytics.New(key)
|
||||
|
||||
return &collector{
|
||||
client: client,
|
||||
}
|
||||
}
|
||||
|
||||
// Collect will exec all the segment collector.
|
||||
func (c *collector) Collect(metric *metric.Metric) error {
|
||||
properties := analytics.NewProperties()
|
||||
for key, value := range metric.Labels {
|
||||
properties.Set(key, value)
|
||||
}
|
||||
|
||||
return c.client.Enqueue(analytics.Track{
|
||||
Event: string(metric.Name),
|
||||
AnonymousId: sessionUUID,
|
||||
Properties: properties,
|
||||
Timestamp: time.Now().UTC(),
|
||||
})
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
version: "3.2"
|
||||
services:
|
||||
portainer:
|
||||
image: neosmemo/memos:latest
|
||||
container_name: memos
|
||||
ports:
|
||||
- "5230:5230"
|
||||
volumes:
|
||||
- ~/.memos/:/var/opt/memos
|
||||
command: --mode=prod --port=5230
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 63 KiB |
+26
-20
@@ -22,10 +22,10 @@ func getUserIDContextKey() string {
|
||||
}
|
||||
|
||||
func setUserSession(ctx echo.Context, user *api.User) error {
|
||||
sess, _ := session.Get("session", ctx)
|
||||
sess, _ := session.Get("memos_session", ctx)
|
||||
sess.Options = &sessions.Options{
|
||||
Path: "/",
|
||||
MaxAge: 1000 * 3600 * 24 * 30,
|
||||
MaxAge: 3600 * 24 * 30,
|
||||
HttpOnly: true,
|
||||
}
|
||||
sess.Values[userIDContextKey] = user.ID
|
||||
@@ -37,7 +37,7 @@ func setUserSession(ctx echo.Context, user *api.User) error {
|
||||
}
|
||||
|
||||
func removeUserSession(ctx echo.Context) error {
|
||||
sess, _ := session.Get("session", ctx)
|
||||
sess, _ := session.Get("memos_session", ctx)
|
||||
sess.Options = &sessions.Options{
|
||||
Path: "/",
|
||||
MaxAge: 0,
|
||||
@@ -64,25 +64,27 @@ func aclMiddleware(s *Server, next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return next(c)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
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)
|
||||
{
|
||||
// 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", c)
|
||||
sess, _ := session.Get("memos_session", c)
|
||||
userIDValue := sess.Values[userIDContextKey]
|
||||
if userIDValue != nil {
|
||||
userID, _ := strconv.Atoi(fmt.Sprintf("%v", userIDValue))
|
||||
@@ -90,7 +92,7 @@ func aclMiddleware(s *Server, next echo.HandlerFunc) echo.HandlerFunc {
|
||||
ID: &userID,
|
||||
}
|
||||
user, err := s.Store.FindUser(ctx, userFind)
|
||||
if err != nil {
|
||||
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 {
|
||||
@@ -102,7 +104,11 @@ func aclMiddleware(s *Server, next echo.HandlerFunc) echo.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
if common.HasPrefixes(path, "/api/memo", "/api/tag", "/api/shortcut") && c.Request().Method == http.MethodGet {
|
||||
if common.HasPrefixes(path, "/api/memo/all", "/api/memo/:memoId", "/api/memo/amount") && c.Request().Method == http.MethodGet {
|
||||
return next(c)
|
||||
}
|
||||
|
||||
if common.HasPrefixes(path, "/api/memo", "/api/tag", "/api/shortcut", "/api/memo/stats") && c.Request().Method == http.MethodGet {
|
||||
if _, err := strconv.Atoi(c.QueryParam("creatorId")); err == nil {
|
||||
return next(c)
|
||||
}
|
||||
|
||||
+36
-7
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"github.com/usememos/memos/api"
|
||||
"github.com/usememos/memos/common"
|
||||
metric "github.com/usememos/memos/plugin/metrics"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
@@ -24,7 +25,7 @@ func (s *Server) registerAuthRoutes(g *echo.Group) {
|
||||
Email: &signin.Email,
|
||||
}
|
||||
user, err := s.Store.FindUser(ctx, userFind)
|
||||
if err != nil {
|
||||
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 {
|
||||
@@ -42,6 +43,9 @@ func (s *Server) registerAuthRoutes(g *echo.Group) {
|
||||
if err = setUserSession(c, user); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to set signin session").SetInternal(err)
|
||||
}
|
||||
s.Collector.Collect(ctx, &metric.Metric{
|
||||
Name: "user signed in",
|
||||
})
|
||||
|
||||
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
|
||||
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(user)); err != nil {
|
||||
@@ -51,10 +55,14 @@ func (s *Server) registerAuthRoutes(g *echo.Group) {
|
||||
})
|
||||
|
||||
g.POST("/auth/logout", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
err := removeUserSession(c)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to set logout session").SetInternal(err)
|
||||
}
|
||||
s.Collector.Collect(ctx, &metric.Metric{
|
||||
Name: "user logout",
|
||||
})
|
||||
|
||||
c.Response().WriteHeader(http.StatusOK)
|
||||
return nil
|
||||
@@ -62,22 +70,40 @@ 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.
|
||||
signup := &api.Signup{}
|
||||
if err := json.NewDecoder(c.Request().Body).Decode(signup); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted signup request").SetInternal(err)
|
||||
}
|
||||
|
||||
hostUserType := api.Host
|
||||
hostUserFind := api.UserFind{
|
||||
Role: &hostUserType,
|
||||
}
|
||||
hostUser, err := s.Store.FindUser(ctx, &hostUserFind)
|
||||
if err != nil {
|
||||
if err != nil && common.ErrorCode(err) != common.NotFound {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find host user").SetInternal(err)
|
||||
}
|
||||
if hostUser != nil {
|
||||
if signup.Role == api.Host && hostUser != nil {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Site Host existed, please contact the site host to signin account firstly.").SetInternal(err)
|
||||
}
|
||||
|
||||
signup := &api.Signup{}
|
||||
if err := json.NewDecoder(c.Request().Body).Decode(signup); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted signup request").SetInternal(err)
|
||||
systemSettingAllowSignUpName := api.SystemSettingAllowSignUpName
|
||||
allowSignUpSetting, err := s.Store.FindSystemSetting(ctx, &api.SystemSettingFind{
|
||||
Name: &systemSettingAllowSignUpName,
|
||||
})
|
||||
if err != nil && common.ErrorCode(err) != common.NotFound {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find system setting").SetInternal(err)
|
||||
}
|
||||
|
||||
allowSignUpSettingValue := false
|
||||
if allowSignUpSetting != nil {
|
||||
err = json.Unmarshal([]byte(allowSignUpSetting.Value), &allowSignUpSettingValue)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to unmarshal system setting allow signup").SetInternal(err)
|
||||
}
|
||||
}
|
||||
if !allowSignUpSettingValue && hostUser != nil {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Site Host existed, please contact the site host to signin account firstly.").SetInternal(err)
|
||||
}
|
||||
|
||||
userCreate := &api.UserCreate{
|
||||
@@ -102,6 +128,9 @@ func (s *Server) registerAuthRoutes(g *echo.Group) {
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create user").SetInternal(err)
|
||||
}
|
||||
s.Collector.Collect(ctx, &metric.Metric{
|
||||
Name: "user signed up",
|
||||
})
|
||||
|
||||
err = setUserSession(c, user)
|
||||
if err != nil {
|
||||
|
||||
@@ -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"),
|
||||
}))
|
||||
}
|
||||
|
||||
+349
-66
@@ -4,11 +4,14 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/usememos/memos/api"
|
||||
"github.com/usememos/memos/common"
|
||||
metric "github.com/usememos/memos/plugin/metrics"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
@@ -20,40 +23,61 @@ func (s *Server) registerMemoRoutes(g *echo.Group) {
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
|
||||
}
|
||||
|
||||
memoCreate := &api.MemoCreate{
|
||||
CreatorID: userID,
|
||||
// Private is the default memo visibility.
|
||||
Visibility: api.Privite,
|
||||
}
|
||||
if err := json.NewDecoder(c.Request().Body).Decode(memoCreate); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post memo request").SetInternal(err)
|
||||
}
|
||||
|
||||
if memoCreate.Content == "" {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Memo content shouldn't be empty")
|
||||
}
|
||||
|
||||
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 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 unmarshal user setting value").SetInternal(err)
|
||||
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
|
||||
}
|
||||
memoCreate.Visibility = memoVisibility
|
||||
}
|
||||
|
||||
memo, err := s.Store.CreateMemo(ctx, memoCreate)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create memo").SetInternal(err)
|
||||
}
|
||||
s.Collector.Collect(ctx, &metric.Metric{
|
||||
Name: "memo created",
|
||||
})
|
||||
|
||||
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 {
|
||||
@@ -64,13 +88,28 @@ 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: ¤tTs,
|
||||
}
|
||||
if err := json.NewDecoder(c.Request().Body).Decode(memoPatch); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted patch memo request").SetInternal(err)
|
||||
@@ -81,6 +120,20 @@ func (s *Server) registerMemoRoutes(g *echo.Group) {
|
||||
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)
|
||||
@@ -91,7 +144,6 @@ 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
|
||||
}
|
||||
@@ -144,9 +196,195 @@ func (s *Server) registerMemoRoutes(g *echo.Group) {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch memo list").SetInternal(err)
|
||||
}
|
||||
|
||||
var pinnedMemoList []*api.Memo
|
||||
var unpinnedMemoList []*api.Memo
|
||||
|
||||
for _, memo := range list {
|
||||
if memo.Pinned {
|
||||
pinnedMemoList = append(pinnedMemoList, memo)
|
||||
} else {
|
||||
unpinnedMemoList = append(unpinnedMemoList, memo)
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(pinnedMemoList, func(i, j int) bool {
|
||||
return pinnedMemoList[i].DisplayTs > pinnedMemoList[j].DisplayTs
|
||||
})
|
||||
sort.Slice(unpinnedMemoList, func(i, j int) bool {
|
||||
return unpinnedMemoList[i].DisplayTs > unpinnedMemoList[j].DisplayTs
|
||||
})
|
||||
|
||||
memoList := []*api.Memo{}
|
||||
memoList = append(memoList, pinnedMemoList...)
|
||||
memoList = append(memoList, unpinnedMemoList...)
|
||||
|
||||
if memoFind.Limit != 0 {
|
||||
memoList = memoList[memoFind.Offset:common.Min(len(memoList), memoFind.Offset+memoFind.Limit)]
|
||||
}
|
||||
|
||||
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
|
||||
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(memoList)); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode memo list response").SetInternal(err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
g.GET("/memo/amount", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
normalRowStatus := api.Normal
|
||||
memoFind := &api.MemoFind{
|
||||
RowStatus: &normalRowStatus,
|
||||
}
|
||||
if userID, err := strconv.Atoi(c.QueryParam("userId")); err == nil {
|
||||
memoFind.CreatorID = &userID
|
||||
}
|
||||
|
||||
memoList, err := s.Store.FindMemoList(ctx, memoFind)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find memo list").SetInternal(err)
|
||||
}
|
||||
|
||||
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
|
||||
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(len(memoList))); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode memo amount").SetInternal(err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
g.GET("/memo/stats", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
normalStatus := api.Normal
|
||||
memoFind := &api.MemoFind{
|
||||
RowStatus: &normalStatus,
|
||||
}
|
||||
if creatorID, err := strconv.Atoi(c.QueryParam("creatorId")); err == nil {
|
||||
memoFind.CreatorID = &creatorID
|
||||
}
|
||||
if memoFind.CreatorID == nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Missing user id to find memo")
|
||||
}
|
||||
|
||||
currentUserID, ok := c.Get(getUserIDContextKey()).(int)
|
||||
if !ok {
|
||||
memoFind.VisibilityList = []api.Visibility{api.Public}
|
||||
} else {
|
||||
if *memoFind.CreatorID != currentUserID {
|
||||
memoFind.VisibilityList = []api.Visibility{api.Public, api.Protected}
|
||||
} else {
|
||||
memoFind.VisibilityList = []api.Visibility{api.Public, api.Protected, api.Privite}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
})
|
||||
|
||||
if memoFind.Limit != 0 {
|
||||
list = list[memoFind.Offset:common.Min(len(list), memoFind.Offset+memoFind.Limit)]
|
||||
}
|
||||
|
||||
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)
|
||||
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
|
||||
})
|
||||
@@ -193,70 +431,115 @@ 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: ¤tTs,
|
||||
}
|
||||
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(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)
|
||||
}
|
||||
|
||||
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 {
|
||||
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)
|
||||
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(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)
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, true)
|
||||
})
|
||||
|
||||
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")
|
||||
}
|
||||
normalRowStatus := api.Normal
|
||||
memoFind := &api.MemoFind{
|
||||
CreatorID: &userID,
|
||||
RowStatus: &normalRowStatus,
|
||||
}
|
||||
|
||||
memoList, err := s.Store.FindMemoList(ctx, memoFind)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find memo list").SetInternal(err)
|
||||
}
|
||||
|
||||
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
|
||||
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(len(memoList))); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode memo amount").SetInternal(err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
metric "github.com/usememos/memos/plugin/metrics"
|
||||
"github.com/usememos/memos/plugin/metrics/segment"
|
||||
"github.com/usememos/memos/server/profile"
|
||||
"github.com/usememos/memos/server/version"
|
||||
"github.com/usememos/memos/store"
|
||||
)
|
||||
|
||||
// MetricCollector is the metric collector.
|
||||
type MetricCollector struct {
|
||||
collector metric.Collector
|
||||
profile *profile.Profile
|
||||
store *store.Store
|
||||
}
|
||||
|
||||
const (
|
||||
segmentMetricWriteKey = "fTn5BumOkj352n3TGw9tu0ARH2dOkcoQ"
|
||||
)
|
||||
|
||||
func NewMetricCollector(profile *profile.Profile, store *store.Store) MetricCollector {
|
||||
c := segment.NewCollector(segmentMetricWriteKey)
|
||||
|
||||
return MetricCollector{
|
||||
collector: c,
|
||||
profile: profile,
|
||||
store: store,
|
||||
}
|
||||
}
|
||||
|
||||
func (mc *MetricCollector) Collect(_ context.Context, metric *metric.Metric) {
|
||||
if mc.profile.Mode == "dev" {
|
||||
return
|
||||
}
|
||||
|
||||
if metric.Labels == nil {
|
||||
metric.Labels = map[string]string{}
|
||||
}
|
||||
metric.Labels["version"] = version.GetCurrentVersion(mc.profile.Mode)
|
||||
|
||||
err := mc.collector.Collect(metric)
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to request segment, error: %+v\n", err)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -68,7 +68,7 @@ func GetProfile() (*Profile, error) {
|
||||
|
||||
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, nil
|
||||
}
|
||||
|
||||
+102
-2
@@ -3,11 +3,15 @@ package server
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/usememos/memos/api"
|
||||
"github.com/usememos/memos/common"
|
||||
metric "github.com/usememos/memos/plugin/metrics"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
@@ -56,6 +60,9 @@ func (s *Server) registerResourceRoutes(g *echo.Group) {
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create resource").SetInternal(err)
|
||||
}
|
||||
s.Collector.Collect(ctx, &metric.Metric{
|
||||
Name: "resource created",
|
||||
})
|
||||
|
||||
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
|
||||
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(resource)); err != nil {
|
||||
@@ -78,6 +85,16 @@ func (s *Server) registerResourceRoutes(g *echo.Group) {
|
||||
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)
|
||||
@@ -153,14 +170,97 @@ func (s *Server) registerResourceRoutes(g *echo.Group) {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("resourceId"))).SetInternal(err)
|
||||
}
|
||||
|
||||
resource, err := s.Store.FindResource(ctx, &api.ResourceFind{
|
||||
ID: &resourceID,
|
||||
CreatorID: &userID,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find resource").SetInternal(err)
|
||||
}
|
||||
if resource == nil {
|
||||
return echo.NewHTTPError(http.StatusNotFound, "Not find resource").SetInternal(err)
|
||||
}
|
||||
|
||||
resourceDelete := &api.ResourceDelete{
|
||||
ID: resourceID,
|
||||
CreatorID: userID,
|
||||
ID: resourceID,
|
||||
}
|
||||
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)
|
||||
})
|
||||
|
||||
g.PATCH("/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)
|
||||
}
|
||||
|
||||
resourceFind := &api.ResourceFind{
|
||||
ID: &resourceID,
|
||||
CreatorID: &userID,
|
||||
}
|
||||
if _, err := s.Store.FindResource(ctx, resourceFind); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find resource").SetInternal(err)
|
||||
}
|
||||
|
||||
currentTs := time.Now().Unix()
|
||||
resourcePatch := &api.ResourcePatch{
|
||||
ID: resourceID,
|
||||
UpdatedTs: ¤tTs,
|
||||
}
|
||||
if err := json.NewDecoder(c.Request().Body).Decode(resourcePatch); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted patch resource request").SetInternal(err)
|
||||
}
|
||||
|
||||
resource, err := s.Store.PatchResource(ctx, resourcePatch)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to patch 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
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/feeds"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/usememos/memos/api"
|
||||
)
|
||||
|
||||
func (s *Server) registerRSSRoutes(g *echo.Group) {
|
||||
g.GET("/u/:id/rss.xml", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "User id is not a number").SetInternal(err)
|
||||
}
|
||||
|
||||
normalStatus := api.Normal
|
||||
memoFind := api.MemoFind{
|
||||
CreatorID: &id,
|
||||
RowStatus: &normalStatus,
|
||||
VisibilityList: []api.Visibility{
|
||||
api.Public,
|
||||
},
|
||||
}
|
||||
memoList, err := s.Store.FindMemoList(ctx, &memoFind)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find memo list").SetInternal(err)
|
||||
}
|
||||
|
||||
userFind := api.UserFind{
|
||||
ID: &id,
|
||||
}
|
||||
user, err := s.Store.FindUser(ctx, &userFind)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err)
|
||||
}
|
||||
|
||||
baseURL := c.Scheme() + "://" + c.Request().Host
|
||||
|
||||
feed := &feeds.Feed{
|
||||
Title: "Memos",
|
||||
Link: &feeds.Link{Href: baseURL},
|
||||
Description: "Memos",
|
||||
Author: &feeds.Author{Name: user.Name},
|
||||
Created: time.Now(),
|
||||
}
|
||||
|
||||
feed.Items = make([]*feeds.Item, len(memoList))
|
||||
for i, memo := range memoList {
|
||||
feed.Items[i] = &feeds.Item{
|
||||
Title: memo.Content,
|
||||
Link: &feeds.Link{Href: baseURL + "/m/" + strconv.Itoa(memo.ID)},
|
||||
Description: memo.Content,
|
||||
Created: time.Unix(memo.CreatedTs, 0),
|
||||
}
|
||||
}
|
||||
|
||||
rss, err := feed.ToRss()
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate rss").SetInternal(err)
|
||||
}
|
||||
|
||||
rssPrefix := `<?xml version="1.0" encoding="UTF-8"?>`
|
||||
|
||||
return c.XMLBlob(http.StatusOK, []byte(rss[len(rssPrefix):]))
|
||||
})
|
||||
}
|
||||
+9
-2
@@ -17,6 +17,8 @@ import (
|
||||
type Server struct {
|
||||
e *echo.Echo
|
||||
|
||||
Collector *MetricCollector
|
||||
|
||||
Profile *profile.Profile
|
||||
|
||||
Store *store.Store
|
||||
@@ -56,9 +58,14 @@ func NewServer(profile *profile.Profile) *Server {
|
||||
Profile: profile,
|
||||
}
|
||||
|
||||
// Webhooks api skips auth checker.
|
||||
rootGroup := e.Group("")
|
||||
s.registerRSSRoutes(rootGroup)
|
||||
|
||||
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 {
|
||||
|
||||
+13
-2
@@ -5,8 +5,11 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/usememos/memos/api"
|
||||
"github.com/usememos/memos/common"
|
||||
metric "github.com/usememos/memos/plugin/metrics"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
@@ -29,6 +32,9 @@ func (s *Server) registerShortcutRoutes(g *echo.Group) {
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create shortcut").SetInternal(err)
|
||||
}
|
||||
s.Collector.Collect(ctx, &metric.Metric{
|
||||
Name: "shortcut created",
|
||||
})
|
||||
|
||||
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
|
||||
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(shortcut)); err != nil {
|
||||
@@ -44,8 +50,10 @@ func (s *Server) registerShortcutRoutes(g *echo.Group) {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("shortcutId"))).SetInternal(err)
|
||||
}
|
||||
|
||||
currentTs := time.Now().Unix()
|
||||
shortcutPatch := &api.ShortcutPatch{
|
||||
ID: shortcutID,
|
||||
ID: shortcutID,
|
||||
UpdatedTs: ¤tTs,
|
||||
}
|
||||
if err := json.NewDecoder(c.Request().Body).Decode(shortcutPatch); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted patch shortcut request").SetInternal(err)
|
||||
@@ -120,9 +128,12 @@ func (s *Server) registerShortcutRoutes(g *echo.Group) {
|
||||
}
|
||||
|
||||
shortcutDelete := &api.ShortcutDelete{
|
||||
ID: shortcutID,
|
||||
ID: &shortcutID,
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
+77
-3
@@ -5,6 +5,7 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/usememos/memos/api"
|
||||
"github.com/usememos/memos/common"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
@@ -27,7 +28,7 @@ func (s *Server) registerSystemRoutes(g *echo.Group) {
|
||||
Role: &hostUserType,
|
||||
}
|
||||
hostUser, err := s.Store.FindUser(ctx, &hostUserFind)
|
||||
if err != nil {
|
||||
if err != nil && common.ErrorCode(err) != common.NotFound {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find host user").SetInternal(err)
|
||||
}
|
||||
|
||||
@@ -37,8 +38,28 @@ func (s *Server) registerSystemRoutes(g *echo.Group) {
|
||||
}
|
||||
|
||||
systemStatus := api.SystemStatus{
|
||||
Host: hostUser,
|
||||
Profile: s.Profile,
|
||||
Host: hostUser,
|
||||
Profile: s.Profile,
|
||||
AllowSignUp: false,
|
||||
AdditionalStyle: "",
|
||||
}
|
||||
|
||||
systemSettingList, err := s.Store.FindSystemSettingList(ctx, &api.SystemSettingFind{})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find system setting list").SetInternal(err)
|
||||
}
|
||||
for _, systemSetting := range systemSettingList {
|
||||
var value interface{}
|
||||
err = json.Unmarshal([]byte(systemSetting.Value), &value)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to unmarshal system setting").SetInternal(err)
|
||||
}
|
||||
|
||||
if systemSetting.Name == api.SystemSettingAllowSignUpName {
|
||||
systemStatus.AllowSignUp = value.(bool)
|
||||
} else if systemSetting.Name == api.SystemSettingAdditionalStyleName {
|
||||
systemStatus.AdditionalStyle = value.(string)
|
||||
}
|
||||
}
|
||||
|
||||
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
|
||||
@@ -47,4 +68,57 @@ func (s *Server) registerSystemRoutes(g *echo.Group) {
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
g.POST("/system/setting", 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")
|
||||
}
|
||||
|
||||
user, err := s.Store.FindUser(ctx, &api.UserFind{
|
||||
ID: &userID,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err)
|
||||
}
|
||||
if user == nil {
|
||||
return echo.NewHTTPError(http.StatusNotFound, "Current signin user not found")
|
||||
} else if user.Role != api.Host {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
|
||||
}
|
||||
|
||||
systemSettingUpsert := &api.SystemSettingUpsert{}
|
||||
if err := json.NewDecoder(c.Request().Body).Decode(systemSettingUpsert); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post system setting request").SetInternal(err)
|
||||
}
|
||||
if err := systemSettingUpsert.Validate(); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "system setting invalidate").SetInternal(err)
|
||||
}
|
||||
|
||||
systemSetting, err := s.Store.UpsertSystemSetting(ctx, systemSettingUpsert)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert system setting").SetInternal(err)
|
||||
}
|
||||
|
||||
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
|
||||
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(systemSetting)); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode system setting response").SetInternal(err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
g.GET("/system/setting", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
systemSettingList, err := s.Store.FindSystemSettingList(ctx, &api.SystemSettingFind{})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find system setting list").SetInternal(err)
|
||||
}
|
||||
|
||||
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
|
||||
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(systemSettingList)); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode system setting list response").SetInternal(err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
+21
-16
@@ -12,6 +12,8 @@ import (
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
var tagRegexpList = []*regexp.Regexp{regexp.MustCompile(`^#([^\s#]+?) `), regexp.MustCompile(`[^\S#]?#([^\s#]+?) `)}
|
||||
|
||||
func (s *Server) registerTagRoutes(g *echo.Group) {
|
||||
g.GET("/tag", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
@@ -45,24 +47,10 @@ func (s *Server) registerTagRoutes(g *echo.Group) {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find memo list").SetInternal(err)
|
||||
}
|
||||
|
||||
tagMapSet := make(map[string]bool)
|
||||
|
||||
r := regexp.MustCompile(`#([^\s#]+?) `)
|
||||
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")
|
||||
tagMapSet[tag] = true
|
||||
}
|
||||
}
|
||||
|
||||
tagList := []string{}
|
||||
for tag := range tagMapSet {
|
||||
tagList = append(tagList, tag)
|
||||
for _, memo := range memoList {
|
||||
tagList = append(tagList, findTagListFromMemoContent(memo.Content)...)
|
||||
}
|
||||
|
||||
sort.Strings(tagList)
|
||||
|
||||
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
|
||||
@@ -72,3 +60,20 @@ func (s *Server) registerTagRoutes(g *echo.Group) {
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func findTagListFromMemoContent(memoContent string) []string {
|
||||
tagMapSet := make(map[string]bool)
|
||||
for _, tagRegexp := range tagRegexpList {
|
||||
for _, rawTag := range tagRegexp.FindAllString(memoContent, -1) {
|
||||
tag := tagRegexp.ReplaceAllString(rawTag, "$1")
|
||||
tagMapSet[tag] = true
|
||||
}
|
||||
}
|
||||
|
||||
tagList := []string{}
|
||||
for tag := range tagMapSet {
|
||||
tagList = append(tagList, tag)
|
||||
}
|
||||
sort.Strings(tagList)
|
||||
return tagList
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFindTagListFromMemoContent(t *testing.T) {
|
||||
tests := []struct {
|
||||
memoContent string
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
memoContent: "#tag1 ",
|
||||
want: []string{"tag1"},
|
||||
},
|
||||
{
|
||||
memoContent: "#tag1 #tag2 ",
|
||||
want: []string{"tag1", "tag2"},
|
||||
},
|
||||
{
|
||||
memoContent: "#tag1 #tag2 \n#tag3 ",
|
||||
want: []string{"tag1", "tag2", "tag3"},
|
||||
},
|
||||
{
|
||||
memoContent: "#tag1 #tag2 \n#tag3 #tag4 ",
|
||||
want: []string{"tag1", "tag2", "tag3", "tag4"},
|
||||
},
|
||||
{
|
||||
memoContent: "#tag1 #tag2 \n#tag3 #tag4 ",
|
||||
want: []string{"tag1", "tag2", "tag3", "tag4"},
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
result := findTagListFromMemoContent(test.memoContent)
|
||||
if len(result) != len(test.want) {
|
||||
t.Errorf("Find tag list %s: got result %v, want %v.", test.memoContent, result, test.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
-1
@@ -5,9 +5,11 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/usememos/memos/api"
|
||||
"github.com/usememos/memos/common"
|
||||
metric "github.com/usememos/memos/plugin/metrics"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
@@ -51,6 +53,9 @@ func (s *Server) registerUserRoutes(g *echo.Group) {
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create user").SetInternal(err)
|
||||
}
|
||||
s.Collector.Collect(ctx, &metric.Metric{
|
||||
Name: "user created",
|
||||
})
|
||||
|
||||
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
|
||||
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(user)); err != nil {
|
||||
@@ -185,8 +190,10 @@ func (s *Server) registerUserRoutes(g *echo.Group) {
|
||||
return echo.NewHTTPError(http.StatusForbidden, "Access forbidden for current session user").SetInternal(err)
|
||||
}
|
||||
|
||||
currentTs := time.Now().Unix()
|
||||
userPatch := &api.UserPatch{
|
||||
ID: userID,
|
||||
ID: userID,
|
||||
UpdatedTs: ¤tTs,
|
||||
}
|
||||
if err := json.NewDecoder(c.Request().Body).Decode(userPatch); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted patch user request").SetInternal(err)
|
||||
@@ -250,6 +257,9 @@ func (s *Server) registerUserRoutes(g *echo.Group) {
|
||||
ID: userID,
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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.4.2"
|
||||
var Version = "0.7.2"
|
||||
|
||||
// DevVersion is the service current development version.
|
||||
var DevVersion = "0.4.2"
|
||||
var DevVersion = "0.7.2"
|
||||
|
||||
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, ".")
|
||||
@@ -1,44 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html"
|
||||
"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 {
|
||||
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)
|
||||
if _, err := c.Response().Writer.Write(resource.Blob); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to write response").SetInternal(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
+17
-17
@@ -12,8 +12,8 @@ import (
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/usememos/memos/common"
|
||||
"github.com/usememos/memos/server/profile"
|
||||
"github.com/usememos/memos/server/version"
|
||||
)
|
||||
|
||||
//go:embed migration
|
||||
@@ -42,35 +42,35 @@ func (db *DB) Open(ctx context.Context) (err error) {
|
||||
return fmt.Errorf("dsn required")
|
||||
}
|
||||
|
||||
// Connect to the database.
|
||||
sqlDB, err := sql.Open("sqlite3", db.profile.DSN)
|
||||
// Connect to the database without foreign_key.
|
||||
sqlDB, err := sql.Open("sqlite3", db.profile.DSN+"?_foreign_keys=0")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open db with dsn: %s, err: %w", db.profile.DSN, err)
|
||||
}
|
||||
|
||||
db.Db = sqlDB
|
||||
|
||||
// If mode is dev, we should migrate and seed the database.
|
||||
if db.profile.Mode == "dev" {
|
||||
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)
|
||||
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(ctx)
|
||||
if err != nil {
|
||||
if err := db.applyLatestSchema(ctx); err != nil {
|
||||
return fmt.Errorf("failed to apply latest schema: %w", err)
|
||||
}
|
||||
} else {
|
||||
err := db.createMigrationHistoryTable(ctx)
|
||||
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)
|
||||
currentVersion := version.GetCurrentVersion(db.profile.Mode)
|
||||
migrationHistory, err := db.FindMigrationHistory(ctx, &MigrationHistoryFind{})
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -84,7 +84,7 @@ func (db *DB) Open(ctx context.Context) (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
|
||||
@@ -101,7 +101,7 @@ func (db *DB) Open(ctx context.Context) (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(ctx, minorVersion); err != nil {
|
||||
return fmt.Errorf("failed to apply minor version migration: %w", err)
|
||||
@@ -118,7 +118,7 @@ func (db *DB) Open(ctx context.Context) (err error) {
|
||||
}
|
||||
}
|
||||
|
||||
return err
|
||||
return nil
|
||||
}
|
||||
|
||||
const (
|
||||
|
||||
@@ -1,17 +1,8 @@
|
||||
-- drop all tables
|
||||
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
|
||||
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',
|
||||
@@ -20,23 +11,6 @@ CREATE TABLE user (
|
||||
open_id TEXT NOT NULL UNIQUE
|
||||
);
|
||||
|
||||
INSERT INTO
|
||||
sqlite_sequence (name, seq)
|
||||
VALUES
|
||||
('user', 100);
|
||||
|
||||
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;
|
||||
|
||||
-- memo
|
||||
CREATE TABLE memo (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -45,43 +19,18 @@ CREATE TABLE memo (
|
||||
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
|
||||
visibility TEXT NOT NULL CHECK (visibility IN ('PUBLIC', 'PROTECTED', 'PRIVATE')) DEFAULT 'PRIVATE'
|
||||
);
|
||||
|
||||
INSERT INTO
|
||||
sqlite_sequence (name, seq)
|
||||
VALUES
|
||||
('memo', 1000);
|
||||
|
||||
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;
|
||||
|
||||
-- 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
|
||||
sqlite_sequence (name, seq)
|
||||
VALUES
|
||||
('memo_organizer', 1000);
|
||||
|
||||
-- shortcut
|
||||
CREATE TABLE shortcut (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -90,27 +39,9 @@ CREATE TABLE shortcut (
|
||||
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
|
||||
payload TEXT NOT NULL DEFAULT '{}'
|
||||
);
|
||||
|
||||
INSERT INTO
|
||||
sqlite_sequence (name, seq)
|
||||
VALUES
|
||||
('shortcut', 10000);
|
||||
|
||||
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;
|
||||
|
||||
-- resource
|
||||
CREATE TABLE resource (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -118,35 +49,33 @@ 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
|
||||
size INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
INSERT INTO
|
||||
sqlite_sequence (name, seq)
|
||||
VALUES
|
||||
('resource', 10000);
|
||||
|
||||
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;
|
||||
|
||||
-- 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)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX user_setting_key_user_id_index ON user_setting(key, user_id);
|
||||
-- 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')),
|
||||
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)
|
||||
);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
);
|
||||
@@ -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)
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE resource ADD COLUMN external_link TEXT NOT NULL DEFAULT '';
|
||||
@@ -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;
|
||||
@@ -0,0 +1,147 @@
|
||||
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 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'
|
||||
);
|
||||
|
||||
INSERT INTO memo SELECT * FROM _memo_old;
|
||||
|
||||
DROP TABLE IF EXISTS _memo_old;
|
||||
|
||||
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,
|
||||
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 '{}'
|
||||
);
|
||||
|
||||
INSERT INTO shortcut SELECT * FROM _shortcut_old;
|
||||
|
||||
DROP TABLE IF EXISTS _shortcut_old;
|
||||
|
||||
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,
|
||||
external_link TEXT NOT NULL DEFAULT '',
|
||||
type TEXT NOT NULL DEFAULT '',
|
||||
size INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
INSERT INTO resource (
|
||||
id, creator_id, created_ts, updated_ts,
|
||||
filename, blob, external_link, type,
|
||||
size
|
||||
)
|
||||
SELECT
|
||||
id,
|
||||
creator_id,
|
||||
created_ts,
|
||||
updated_ts,
|
||||
filename,
|
||||
blob,
|
||||
external_link,
|
||||
type,
|
||||
size
|
||||
FROM
|
||||
_resource_old;
|
||||
|
||||
DROP TABLE IF EXISTS _resource_old;
|
||||
|
||||
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,
|
||||
UNIQUE(user_id, key)
|
||||
);
|
||||
|
||||
INSERT INTO user_setting SELECT * FROM _user_setting_old;
|
||||
|
||||
DROP TABLE IF EXISTS _user_setting_old;
|
||||
|
||||
DROP TABLE IF EXISTS _memo_resource_old;
|
||||
|
||||
ALTER TABLE memo_resource RENAME TO _memo_resource_old;
|
||||
|
||||
-- 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')),
|
||||
UNIQUE(memo_id, resource_id)
|
||||
);
|
||||
|
||||
INSERT INTO memo_resource SELECT * FROM _memo_resource_old;
|
||||
|
||||
DROP TABLE IF EXISTS _memo_resource_old;
|
||||
@@ -0,0 +1,4 @@
|
||||
DROP TRIGGER IF EXISTS `trigger_update_user_modification_time`;
|
||||
DROP TRIGGER IF EXISTS `trigger_update_memo_modification_time`;
|
||||
DROP TRIGGER IF EXISTS `trigger_update_shortcut_modification_time`;
|
||||
DROP TRIGGER IF EXISTS `trigger_update_resource_modification_time`;
|
||||
@@ -1,17 +1,8 @@
|
||||
-- drop all tables
|
||||
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
|
||||
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',
|
||||
@@ -20,23 +11,6 @@ CREATE TABLE user (
|
||||
open_id TEXT NOT NULL UNIQUE
|
||||
);
|
||||
|
||||
INSERT INTO
|
||||
sqlite_sequence (name, seq)
|
||||
VALUES
|
||||
('user', 100);
|
||||
|
||||
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;
|
||||
|
||||
-- memo
|
||||
CREATE TABLE memo (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -45,43 +19,18 @@ CREATE TABLE memo (
|
||||
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
|
||||
visibility TEXT NOT NULL CHECK (visibility IN ('PUBLIC', 'PROTECTED', 'PRIVATE')) DEFAULT 'PRIVATE'
|
||||
);
|
||||
|
||||
INSERT INTO
|
||||
sqlite_sequence (name, seq)
|
||||
VALUES
|
||||
('memo', 1000);
|
||||
|
||||
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;
|
||||
|
||||
-- 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
|
||||
sqlite_sequence (name, seq)
|
||||
VALUES
|
||||
('memo_organizer', 1000);
|
||||
|
||||
-- shortcut
|
||||
CREATE TABLE shortcut (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -90,27 +39,9 @@ CREATE TABLE shortcut (
|
||||
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
|
||||
payload TEXT NOT NULL DEFAULT '{}'
|
||||
);
|
||||
|
||||
INSERT INTO
|
||||
sqlite_sequence (name, seq)
|
||||
VALUES
|
||||
('shortcut', 10000);
|
||||
|
||||
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;
|
||||
|
||||
-- resource
|
||||
CREATE TABLE resource (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -118,35 +49,33 @@ 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
|
||||
size INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
INSERT INTO
|
||||
sqlite_sequence (name, seq)
|
||||
VALUES
|
||||
('resource', 10000);
|
||||
|
||||
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;
|
||||
|
||||
-- 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)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX user_setting_key_user_id_index ON user_setting(key, user_id);
|
||||
-- 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')),
|
||||
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)
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
'好好学习,天天向上。🤜🤛',
|
||||
"**Bytebase** - Database CI/CD for DevOps teams.
|
||||

|
||||
🌐 [https://bytebase.com](https://www.bytebase.com)",
|
||||
101,
|
||||
'PUBLIC'
|
||||
);
|
||||
|
||||
@@ -10,3 +10,16 @@ VALUES
|
||||
101,
|
||||
1
|
||||
);
|
||||
|
||||
INSERT INTO
|
||||
memo_organizer (
|
||||
`memo_id`,
|
||||
`user_id`,
|
||||
`pinned`
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
1003,
|
||||
101,
|
||||
1
|
||||
);
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
INSERT INTO
|
||||
system_setting (
|
||||
`name`,
|
||||
`value`,
|
||||
`description`
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
'allowSignUp',
|
||||
'true',
|
||||
''
|
||||
);
|
||||
+82
-29
@@ -3,6 +3,7 @@ package store
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
@@ -41,12 +42,11 @@ func (raw *memoRaw) toMemo() *api.Memo {
|
||||
// Domain specific fields
|
||||
Content: raw.Content,
|
||||
Visibility: raw.Visibility,
|
||||
DisplayTs: raw.CreatedTs,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Store) composeMemo(ctx context.Context, raw *memoRaw) (*api.Memo, error) {
|
||||
memo := raw.toMemo()
|
||||
|
||||
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,
|
||||
@@ -57,6 +57,32 @@ func (s *Store) composeMemo(ctx context.Context, raw *memoRaw) (*api.Memo, error
|
||||
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
|
||||
}
|
||||
|
||||
@@ -76,12 +102,12 @@ func (s *Store) CreateMemo(ctx context.Context, create *api.MemoCreate) (*api.Me
|
||||
return nil, FormatError(err)
|
||||
}
|
||||
|
||||
memo, err := s.composeMemo(ctx, memoRaw)
|
||||
if err != nil {
|
||||
if err := s.cache.UpsertCache(api.MemoCache, memoRaw.ID, memoRaw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := s.cache.UpsertCache(api.MemoCache, memo.ID, memo); err != nil {
|
||||
memo, err := s.ComposeMemo(ctx, memoRaw.toMemo())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -104,12 +130,12 @@ func (s *Store) PatchMemo(ctx context.Context, patch *api.MemoPatch) (*api.Memo,
|
||||
return nil, FormatError(err)
|
||||
}
|
||||
|
||||
memo, err := s.composeMemo(ctx, memoRaw)
|
||||
if err != nil {
|
||||
if err := s.cache.UpsertCache(api.MemoCache, memoRaw.ID, memoRaw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := s.cache.UpsertCache(api.MemoCache, memo.ID, memo); err != nil {
|
||||
memo, err := s.ComposeMemo(ctx, memoRaw.toMemo())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -130,7 +156,7 @@ func (s *Store) FindMemoList(ctx context.Context, find *api.MemoFind) ([]*api.Me
|
||||
|
||||
list := []*api.Memo{}
|
||||
for _, raw := range memoRawList {
|
||||
memo, err := s.composeMemo(ctx, raw)
|
||||
memo, err := s.ComposeMemo(ctx, raw.toMemo())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -143,12 +169,16 @@ func (s *Store) FindMemoList(ctx context.Context, find *api.MemoFind) ([]*api.Me
|
||||
|
||||
func (s *Store) FindMemo(ctx context.Context, find *api.MemoFind) (*api.Memo, error) {
|
||||
if find.ID != nil {
|
||||
memo := &api.Memo{}
|
||||
has, err := s.cache.FindCache(api.MemoCache, *find.ID, memo)
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -168,12 +198,13 @@ func (s *Store) FindMemo(ctx context.Context, find *api.MemoFind) (*api.Memo, er
|
||||
return nil, &common.Error{Code: common.NotFound, Err: fmt.Errorf("not found")}
|
||||
}
|
||||
|
||||
memo, err := s.composeMemo(ctx, list[0])
|
||||
if err != nil {
|
||||
memoRaw := list[0]
|
||||
if err := s.cache.UpsertCache(api.MemoCache, memoRaw.ID, memoRaw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := s.cache.UpsertCache(api.MemoCache, memo.ID, memo); err != nil {
|
||||
memo, err := s.ComposeMemo(ctx, memoRaw.toMemo())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -190,6 +221,9 @@ func (s *Store) DeleteMemo(ctx context.Context, delete *api.MemoDelete) error {
|
||||
if err := deleteMemo(ctx, tx, delete); err != nil {
|
||||
return FormatError(err)
|
||||
}
|
||||
if err := vacuum(ctx, tx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return FormatError(err)
|
||||
@@ -234,6 +268,9 @@ func patchMemoRaw(ctx context.Context, tx *sql.Tx, patch *api.MemoPatch) (*memoR
|
||||
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)
|
||||
}
|
||||
@@ -281,7 +318,7 @@ func findMemoRawList(ctx context.Context, tx *sql.Tx, find *api.MemoFind) ([]*me
|
||||
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+"%")
|
||||
@@ -295,14 +332,6 @@ func findMemoRawList(ctx context.Context, tx *sql.Tx, find *api.MemoFind) ([]*me
|
||||
where = append(where, fmt.Sprintf("visibility in (%s)", strings.Join(list, ",")))
|
||||
}
|
||||
|
||||
pagination := ""
|
||||
if find.Limit > 0 {
|
||||
pagination = fmt.Sprintf("%s LIMIT %d", pagination, find.Limit)
|
||||
if find.Offset > 0 {
|
||||
pagination = fmt.Sprintf("%s OFFSET %d", pagination, find.Offset)
|
||||
}
|
||||
}
|
||||
|
||||
query := `
|
||||
SELECT
|
||||
id,
|
||||
@@ -315,7 +344,7 @@ func findMemoRawList(ctx context.Context, tx *sql.Tx, find *api.MemoFind) ([]*me
|
||||
FROM memo
|
||||
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)
|
||||
@@ -348,10 +377,34 @@ func findMemoRawList(ctx context.Context, tx *sql.Tx, find *api.MemoFind) ([]*me
|
||||
}
|
||||
|
||||
func deleteMemo(ctx context.Context, tx *sql.Tx, delete *api.MemoDelete) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
PRAGMA foreign_keys = ON;
|
||||
DELETE FROM memo WHERE id = ?
|
||||
`, delete.ID)
|
||||
where, args := []string{"id = ?"}, []interface{}{delete.ID}
|
||||
|
||||
stmt := `DELETE FROM memo WHERE ` + strings.Join(where, " AND ")
|
||||
result, err := tx.ExecContext(ctx, stmt, args...)
|
||||
if err != nil {
|
||||
return FormatError(err)
|
||||
}
|
||||
|
||||
rows, _ := result.RowsAffected()
|
||||
if rows == 0 {
|
||||
return &common.Error{Code: common.NotFound, Err: fmt.Errorf("memo not found")}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func vacuumMemo(ctx context.Context, tx *sql.Tx) error {
|
||||
stmt := `
|
||||
DELETE FROM
|
||||
memo
|
||||
WHERE
|
||||
creator_id NOT IN (
|
||||
SELECT
|
||||
id
|
||||
FROM
|
||||
user
|
||||
)`
|
||||
_, err := tx.ExecContext(ctx, stmt)
|
||||
if err != nil {
|
||||
return FormatError(err)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/usememos/memos/api"
|
||||
"github.com/usememos/memos/common"
|
||||
@@ -65,6 +66,24 @@ func (s *Store) UpsertMemoOrganizer(ctx context.Context, upsert *api.MemoOrganiz
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteMemoOrganizer(ctx context.Context, delete *api.MemoOrganizerDelete) error {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return FormatError(err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if err := deleteMemoOrganizer(ctx, tx, delete); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return FormatError(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func findMemoOrganizer(ctx context.Context, tx *sql.Tx, find *api.MemoOrganizerFind) (*memoOrganizerRaw, error) {
|
||||
query := `
|
||||
SELECT
|
||||
@@ -127,3 +146,52 @@ func upsertMemoOrganizer(ctx context.Context, tx *sql.Tx, upsert *api.MemoOrgani
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func deleteMemoOrganizer(ctx context.Context, tx *sql.Tx, delete *api.MemoOrganizerDelete) error {
|
||||
where, args := []string{}, []interface{}{}
|
||||
|
||||
if v := delete.MemoID; v != nil {
|
||||
where, args = append(where, "memo_id = ?"), append(args, *v)
|
||||
}
|
||||
if v := delete.UserID; v != nil {
|
||||
where, args = append(where, "user_id = ?"), append(args, *v)
|
||||
}
|
||||
|
||||
stmt := `DELETE FROM memo_organizer WHERE ` + strings.Join(where, " AND ")
|
||||
result, err := tx.ExecContext(ctx, stmt, args...)
|
||||
if err != nil {
|
||||
return FormatError(err)
|
||||
}
|
||||
|
||||
rows, _ := result.RowsAffected()
|
||||
if rows == 0 {
|
||||
return &common.Error{Code: common.NotFound, Err: fmt.Errorf("memo organizer not found")}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func vacuumMemoOrganizer(ctx context.Context, tx *sql.Tx) error {
|
||||
stmt := `
|
||||
DELETE FROM
|
||||
memo_organizer
|
||||
WHERE
|
||||
memo_id NOT IN (
|
||||
SELECT
|
||||
id
|
||||
FROM
|
||||
memo
|
||||
)
|
||||
OR user_id NOT IN (
|
||||
SELECT
|
||||
id
|
||||
FROM
|
||||
user
|
||||
)`
|
||||
_, err := tx.ExecContext(ctx, stmt)
|
||||
if err != nil {
|
||||
return FormatError(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
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{}, []interface{}{}
|
||||
|
||||
if v := delete.MemoID; v != nil {
|
||||
where, args = append(where, "memo_id = ?"), append(args, *v)
|
||||
}
|
||||
if v := delete.ResourceID; v != nil {
|
||||
where, args = append(where, "resource_id = ?"), append(args, *v)
|
||||
}
|
||||
|
||||
stmt := `DELETE FROM memo_resource WHERE ` + strings.Join(where, " AND ")
|
||||
result, err := tx.ExecContext(ctx, stmt, 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
|
||||
}
|
||||
|
||||
func vacuumMemoResource(ctx context.Context, tx *sql.Tx) error {
|
||||
stmt := `
|
||||
DELETE FROM
|
||||
memo_resource
|
||||
WHERE
|
||||
memo_id NOT IN (
|
||||
SELECT
|
||||
id
|
||||
FROM
|
||||
memo
|
||||
)
|
||||
OR resource_id NOT IN (
|
||||
SELECT
|
||||
id
|
||||
FROM
|
||||
resource
|
||||
)`
|
||||
_, err := tx.ExecContext(ctx, stmt)
|
||||
if err != nil {
|
||||
return FormatError(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
+141
-15
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/usememos/memos/api"
|
||||
@@ -44,6 +45,40 @@ func (raw *resourceRaw) toResource() *api.Resource {
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -60,12 +95,12 @@ func (s *Store) CreateResource(ctx context.Context, create *api.ResourceCreate)
|
||||
return nil, FormatError(err)
|
||||
}
|
||||
|
||||
resource := resourceRaw.toResource()
|
||||
|
||||
if err := s.cache.UpsertCache(api.ResourceCache, resource.ID, resource); err != nil {
|
||||
if err := s.cache.UpsertCache(api.ResourceCache, resourceRaw.ID, resourceRaw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resource := resourceRaw.toResource()
|
||||
|
||||
return resource, nil
|
||||
}
|
||||
|
||||
@@ -91,13 +126,13 @@ func (s *Store) FindResourceList(ctx context.Context, find *api.ResourceFind) ([
|
||||
|
||||
func (s *Store) FindResource(ctx context.Context, find *api.ResourceFind) (*api.Resource, error) {
|
||||
if find.ID != nil {
|
||||
resource := &api.Resource{}
|
||||
has, err := s.cache.FindCache(api.ResourceCache, *find.ID, resource)
|
||||
resourceRaw := &resourceRaw{}
|
||||
has, err := s.cache.FindCache(api.ResourceCache, *find.ID, resourceRaw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if has {
|
||||
return resource, nil
|
||||
return resourceRaw.toResource(), nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,12 +151,14 @@ func (s *Store) FindResource(ctx context.Context, find *api.ResourceFind) (*api.
|
||||
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, resource.ID, resource); err != nil {
|
||||
if err := s.cache.UpsertCache(api.ResourceCache, resourceRaw.ID, resourceRaw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resource := resourceRaw.toResource()
|
||||
|
||||
return resource, nil
|
||||
}
|
||||
|
||||
@@ -132,8 +169,10 @@ func (s *Store) DeleteResource(ctx context.Context, delete *api.ResourceDelete)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
err = deleteResource(ctx, tx, delete)
|
||||
if err != nil {
|
||||
if err := deleteResource(ctx, tx, delete); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := vacuum(ctx, tx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -146,6 +185,31 @@ func (s *Store) DeleteResource(ctx context.Context, delete *api.ResourceDelete)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) PatchResource(ctx context.Context, patch *api.ResourcePatch) (*api.Resource, error) {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, FormatError(err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
resourceRaw, err := patchResource(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.ResourceCache, resourceRaw.ID, resourceRaw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resource := resourceRaw.toResource()
|
||||
|
||||
return resource, nil
|
||||
}
|
||||
|
||||
func createResource(ctx context.Context, tx *sql.Tx, create *api.ResourceCreate) (*resourceRaw, error) {
|
||||
query := `
|
||||
INSERT INTO resource (
|
||||
@@ -175,6 +239,41 @@ func createResource(ctx context.Context, tx *sql.Tx, create *api.ResourceCreate)
|
||||
return &resourceRaw, nil
|
||||
}
|
||||
|
||||
func patchResource(ctx context.Context, tx *sql.Tx, patch *api.ResourcePatch) (*resourceRaw, error) {
|
||||
set, args := []string{}, []interface{}{}
|
||||
|
||||
if v := patch.UpdatedTs; v != nil {
|
||||
set, args = append(set, "updated_ts = ?"), append(args, *v)
|
||||
}
|
||||
if v := patch.Filename; v != nil {
|
||||
set, args = append(set, "filename = ?"), append(args, *v)
|
||||
}
|
||||
|
||||
args = append(args, patch.ID)
|
||||
|
||||
query := `
|
||||
UPDATE resource
|
||||
SET ` + strings.Join(set, ", ") + `
|
||||
WHERE id = ?
|
||||
RETURNING id, filename, blob, type, size, creator_id, created_ts, updated_ts
|
||||
`
|
||||
var resourceRaw resourceRaw
|
||||
if err := tx.QueryRowContext(ctx, query, args...).Scan(
|
||||
&resourceRaw.ID,
|
||||
&resourceRaw.Filename,
|
||||
&resourceRaw.Blob,
|
||||
&resourceRaw.Type,
|
||||
&resourceRaw.Size,
|
||||
&resourceRaw.CreatorID,
|
||||
&resourceRaw.CreatedTs,
|
||||
&resourceRaw.UpdatedTs,
|
||||
); err != nil {
|
||||
return nil, FormatError(err)
|
||||
}
|
||||
|
||||
return &resourceRaw, nil
|
||||
}
|
||||
|
||||
func findResourceList(ctx context.Context, tx *sql.Tx, find *api.ResourceFind) ([]*resourceRaw, error) {
|
||||
where, args := []string{"1 = 1"}, []interface{}{}
|
||||
|
||||
@@ -187,6 +286,9 @@ func findResourceList(ctx context.Context, tx *sql.Tx, find *api.ResourceFind) (
|
||||
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)
|
||||
}
|
||||
|
||||
query := `
|
||||
SELECT
|
||||
@@ -200,7 +302,7 @@ func findResourceList(ctx context.Context, tx *sql.Tx, find *api.ResourceFind) (
|
||||
updated_ts
|
||||
FROM resource
|
||||
WHERE ` + strings.Join(where, " AND ") + `
|
||||
ORDER BY created_ts DESC
|
||||
ORDER BY id DESC
|
||||
`
|
||||
rows, err := tx.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
@@ -235,10 +337,34 @@ func findResourceList(ctx context.Context, tx *sql.Tx, find *api.ResourceFind) (
|
||||
}
|
||||
|
||||
func deleteResource(ctx context.Context, tx *sql.Tx, delete *api.ResourceDelete) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
PRAGMA foreign_keys = ON;
|
||||
DELETE FROM resource WHERE id = ? AND creator_id = ?
|
||||
`, delete.ID, delete.CreatorID)
|
||||
where, args := []string{"id = ?"}, []interface{}{delete.ID}
|
||||
|
||||
stmt := `DELETE FROM resource WHERE ` + strings.Join(where, " AND ")
|
||||
result, err := tx.ExecContext(ctx, stmt, args...)
|
||||
if err != nil {
|
||||
return FormatError(err)
|
||||
}
|
||||
|
||||
rows, _ := result.RowsAffected()
|
||||
if rows == 0 {
|
||||
return &common.Error{Code: common.NotFound, Err: fmt.Errorf("resource not found")}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func vacuumResource(ctx context.Context, tx *sql.Tx) error {
|
||||
stmt := `
|
||||
DELETE FROM
|
||||
resource
|
||||
WHERE
|
||||
creator_id NOT IN (
|
||||
SELECT
|
||||
id
|
||||
FROM
|
||||
user
|
||||
)`
|
||||
_, err := tx.ExecContext(ctx, stmt)
|
||||
if err != nil {
|
||||
return FormatError(err)
|
||||
}
|
||||
|
||||
+52
-16
@@ -56,12 +56,12 @@ func (s *Store) CreateShortcut(ctx context.Context, create *api.ShortcutCreate)
|
||||
return nil, FormatError(err)
|
||||
}
|
||||
|
||||
shortcut := shortcutRaw.toShortcut()
|
||||
|
||||
if err := s.cache.UpsertCache(api.ShortcutCache, shortcut.ID, shortcut); err != nil {
|
||||
if err := s.cache.UpsertCache(api.ShortcutCache, shortcutRaw.ID, shortcutRaw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
shortcut := shortcutRaw.toShortcut()
|
||||
|
||||
return shortcut, nil
|
||||
}
|
||||
|
||||
@@ -81,12 +81,12 @@ func (s *Store) PatchShortcut(ctx context.Context, patch *api.ShortcutPatch) (*a
|
||||
return nil, FormatError(err)
|
||||
}
|
||||
|
||||
shortcut := shortcutRaw.toShortcut()
|
||||
|
||||
if err := s.cache.UpsertCache(api.ShortcutCache, shortcut.ID, shortcut); err != nil {
|
||||
if err := s.cache.UpsertCache(api.ShortcutCache, shortcutRaw.ID, shortcutRaw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
shortcut := shortcutRaw.toShortcut()
|
||||
|
||||
return shortcut, nil
|
||||
}
|
||||
|
||||
@@ -112,13 +112,13 @@ func (s *Store) FindShortcutList(ctx context.Context, find *api.ShortcutFind) ([
|
||||
|
||||
func (s *Store) FindShortcut(ctx context.Context, find *api.ShortcutFind) (*api.Shortcut, error) {
|
||||
if find.ID != nil {
|
||||
shortcut := &api.Shortcut{}
|
||||
has, err := s.cache.FindCache(api.ShortcutCache, *find.ID, shortcut)
|
||||
shortcutRaw := &shortcutRaw{}
|
||||
has, err := s.cache.FindCache(api.ShortcutCache, *find.ID, shortcutRaw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if has {
|
||||
return shortcut, nil
|
||||
return shortcutRaw.toShortcut(), nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,12 +137,14 @@ func (s *Store) FindShortcut(ctx context.Context, find *api.ShortcutFind) (*api.
|
||||
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, shortcut.ID, shortcut); err != nil {
|
||||
if err := s.cache.UpsertCache(api.ShortcutCache, shortcutRaw.ID, shortcutRaw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
shortcut := shortcutRaw.toShortcut()
|
||||
|
||||
return shortcut, nil
|
||||
}
|
||||
|
||||
@@ -162,7 +164,7 @@ func (s *Store) DeleteShortcut(ctx context.Context, delete *api.ShortcutDelete)
|
||||
return FormatError(err)
|
||||
}
|
||||
|
||||
s.cache.DeleteCache(api.ShortcutCache, delete.ID)
|
||||
s.cache.DeleteCache(api.ShortcutCache, *delete.ID)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -196,6 +198,9 @@ func createShortcut(ctx context.Context, tx *sql.Tx, create *api.ShortcutCreate)
|
||||
func patchShortcut(ctx context.Context, tx *sql.Tx, patch *api.ShortcutPatch) (*shortcutRaw, error) {
|
||||
set, args := []string{}, []interface{}{}
|
||||
|
||||
if v := patch.UpdatedTs; v != nil {
|
||||
set, args = append(set, "updated_ts = ?"), append(args, *v)
|
||||
}
|
||||
if v := patch.Title; v != nil {
|
||||
set, args = append(set, "title = ?"), append(args, *v)
|
||||
}
|
||||
@@ -287,10 +292,41 @@ func findShortcutList(ctx context.Context, tx *sql.Tx, find *api.ShortcutFind) (
|
||||
}
|
||||
|
||||
func deleteShortcut(ctx context.Context, tx *sql.Tx, delete *api.ShortcutDelete) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
PRAGMA foreign_keys = ON;
|
||||
DELETE FROM shortcut WHERE id = ?
|
||||
`, delete.ID)
|
||||
where, args := []string{}, []interface{}{}
|
||||
|
||||
if v := delete.ID; v != nil {
|
||||
where, args = append(where, "id = ?"), append(args, *v)
|
||||
}
|
||||
if v := delete.CreatorID; v != nil {
|
||||
where, args = append(where, "creator_id = ?"), append(args, *v)
|
||||
}
|
||||
|
||||
stmt := `DELETE FROM shortcut WHERE ` + strings.Join(where, " AND ")
|
||||
result, err := tx.ExecContext(ctx, stmt, args...)
|
||||
if err != nil {
|
||||
return FormatError(err)
|
||||
}
|
||||
|
||||
rows, _ := result.RowsAffected()
|
||||
if rows == 0 {
|
||||
return &common.Error{Code: common.NotFound, Err: fmt.Errorf("shortcut not found")}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func vacuumShortcut(ctx context.Context, tx *sql.Tx) error {
|
||||
stmt := `
|
||||
DELETE FROM
|
||||
shortcut
|
||||
WHERE
|
||||
creator_id NOT IN (
|
||||
SELECT
|
||||
id
|
||||
FROM
|
||||
user
|
||||
)`
|
||||
_, err := tx.ExecContext(ctx, stmt)
|
||||
if err != nil {
|
||||
return FormatError(err)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"github.com/usememos/memos/api"
|
||||
@@ -24,3 +25,51 @@ func New(db *sql.DB, profile *profile.Profile) *Store {
|
||||
cache: cacheService,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Store) Vacuum(ctx context.Context) error {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return FormatError(err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if err := vacuum(ctx, tx); 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
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Exec vacuum records in a transcation.
|
||||
func vacuum(ctx context.Context, tx *sql.Tx) error {
|
||||
if err := vacuumMemo(ctx, tx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := vacuumResource(ctx, tx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := vacuumShortcut(ctx, tx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := vacuumUserSetting(ctx, tx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := vacuumMemoOrganizer(ctx, tx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := vacuumMemoResource(ctx, tx); err != nil {
|
||||
// Prevent revive warning.
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/usememos/memos/api"
|
||||
"github.com/usememos/memos/common"
|
||||
)
|
||||
|
||||
type systemSettingRaw struct {
|
||||
Name api.SystemSettingName
|
||||
Value string
|
||||
Description string
|
||||
}
|
||||
|
||||
func (raw *systemSettingRaw) toSystemSetting() *api.SystemSetting {
|
||||
return &api.SystemSetting{
|
||||
Name: raw.Name,
|
||||
Value: raw.Value,
|
||||
Description: raw.Description,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Store) UpsertSystemSetting(ctx context.Context, upsert *api.SystemSettingUpsert) (*api.SystemSetting, error) {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, FormatError(err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
systemSettingRaw, err := upsertSystemSetting(ctx, tx, upsert)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
systemSetting := systemSettingRaw.toSystemSetting()
|
||||
|
||||
return systemSetting, nil
|
||||
}
|
||||
|
||||
func (s *Store) FindSystemSettingList(ctx context.Context, find *api.SystemSettingFind) ([]*api.SystemSetting, error) {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, FormatError(err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
systemSettingRawList, err := findSystemSettingList(ctx, tx, find)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
list := []*api.SystemSetting{}
|
||||
for _, raw := range systemSettingRawList {
|
||||
list = append(list, raw.toSystemSetting())
|
||||
}
|
||||
|
||||
return list, nil
|
||||
}
|
||||
|
||||
func (s *Store) FindSystemSetting(ctx context.Context, find *api.SystemSettingFind) (*api.SystemSetting, error) {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, FormatError(err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
systemSettingRawList, err := findSystemSettingList(ctx, tx, find)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(systemSettingRawList) == 0 {
|
||||
return nil, &common.Error{Code: common.NotFound, Err: fmt.Errorf("not found")}
|
||||
}
|
||||
|
||||
return systemSettingRawList[0].toSystemSetting(), nil
|
||||
}
|
||||
|
||||
func upsertSystemSetting(ctx context.Context, tx *sql.Tx, upsert *api.SystemSettingUpsert) (*systemSettingRaw, error) {
|
||||
query := `
|
||||
INSERT INTO system_setting (
|
||||
name, value, description
|
||||
)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(name) DO UPDATE
|
||||
SET
|
||||
value = EXCLUDED.value,
|
||||
description = EXCLUDED.description
|
||||
RETURNING name, value, description
|
||||
`
|
||||
var systemSettingRaw systemSettingRaw
|
||||
if err := tx.QueryRowContext(ctx, query, upsert.Name, upsert.Value, upsert.Description).Scan(
|
||||
&systemSettingRaw.Name,
|
||||
&systemSettingRaw.Value,
|
||||
&systemSettingRaw.Description,
|
||||
); err != nil {
|
||||
return nil, FormatError(err)
|
||||
}
|
||||
|
||||
return &systemSettingRaw, nil
|
||||
}
|
||||
|
||||
func findSystemSettingList(ctx context.Context, tx *sql.Tx, find *api.SystemSettingFind) ([]*systemSettingRaw, error) {
|
||||
where, args := []string{"1 = 1"}, []interface{}{}
|
||||
|
||||
if v := find.Name; v != nil {
|
||||
where, args = append(where, "name = ?"), append(args, v.String())
|
||||
}
|
||||
|
||||
query := `
|
||||
SELECT
|
||||
name,
|
||||
value,
|
||||
description
|
||||
FROM system_setting
|
||||
WHERE ` + strings.Join(where, " AND ")
|
||||
rows, err := tx.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, FormatError(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
systemSettingRawList := make([]*systemSettingRaw, 0)
|
||||
for rows.Next() {
|
||||
var systemSettingRaw systemSettingRaw
|
||||
if err := rows.Scan(
|
||||
&systemSettingRaw.Name,
|
||||
&systemSettingRaw.Value,
|
||||
&systemSettingRaw.Description,
|
||||
); err != nil {
|
||||
return nil, FormatError(err)
|
||||
}
|
||||
|
||||
systemSettingRawList = append(systemSettingRawList, &systemSettingRaw)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, FormatError(err)
|
||||
}
|
||||
|
||||
return systemSettingRawList, nil
|
||||
}
|
||||
+47
-21
@@ -44,6 +44,22 @@ func (raw *userRaw) toUser() *api.User {
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -60,12 +76,12 @@ func (s *Store) CreateUser(ctx context.Context, create *api.UserCreate) (*api.Us
|
||||
return nil, FormatError(err)
|
||||
}
|
||||
|
||||
user := userRaw.toUser()
|
||||
|
||||
if err := s.cache.UpsertCache(api.UserCache, user.ID, user); err != nil {
|
||||
if err := s.cache.UpsertCache(api.UserCache, userRaw.ID, userRaw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user := userRaw.toUser()
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
@@ -85,12 +101,12 @@ func (s *Store) PatchUser(ctx context.Context, patch *api.UserPatch) (*api.User,
|
||||
return nil, FormatError(err)
|
||||
}
|
||||
|
||||
user := userRaw.toUser()
|
||||
|
||||
if err := s.cache.UpsertCache(api.UserCache, user.ID, user); err != nil {
|
||||
if err := s.cache.UpsertCache(api.UserCache, userRaw.ID, userRaw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user := userRaw.toUser()
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
@@ -116,13 +132,13 @@ func (s *Store) FindUserList(ctx context.Context, find *api.UserFind) ([]*api.Us
|
||||
|
||||
func (s *Store) FindUser(ctx context.Context, find *api.UserFind) (*api.User, error) {
|
||||
if find.ID != nil {
|
||||
user := &api.User{}
|
||||
has, err := s.cache.FindCache(api.UserCache, *find.ID, user)
|
||||
userRaw := &userRaw{}
|
||||
has, err := s.cache.FindCache(api.UserCache, *find.ID, userRaw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if has {
|
||||
return user, nil
|
||||
return userRaw.toUser(), nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,17 +154,17 @@ func (s *Store) FindUser(ctx context.Context, find *api.UserFind) (*api.User, er
|
||||
}
|
||||
|
||||
if len(list) == 0 {
|
||||
return nil, nil
|
||||
} 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.NotFound, Err: fmt.Errorf("not found user with filter %+v", find)}
|
||||
}
|
||||
|
||||
user := list[0].toUser()
|
||||
userRaw := list[0]
|
||||
|
||||
if err := s.cache.UpsertCache(api.UserCache, user.ID, user); err != nil {
|
||||
if err := s.cache.UpsertCache(api.UserCache, userRaw.ID, userRaw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user := userRaw.toUser()
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
@@ -159,13 +175,15 @@ func (s *Store) DeleteUser(ctx context.Context, delete *api.UserDelete) error {
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
err = deleteUser(ctx, tx, delete)
|
||||
if err != nil {
|
||||
return FormatError(err)
|
||||
if err := deleteUser(ctx, tx, delete); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := vacuum(ctx, tx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return FormatError(err)
|
||||
return err
|
||||
}
|
||||
|
||||
s.cache.DeleteCache(api.UserCache, delete.ID)
|
||||
@@ -212,6 +230,9 @@ func createUser(ctx context.Context, tx *sql.Tx, create *api.UserCreate) (*userR
|
||||
func patchUser(ctx context.Context, tx *sql.Tx, patch *api.UserPatch) (*userRaw, error) {
|
||||
set, args := []string{}, []interface{}{}
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -336,12 +357,17 @@ func findUserList(ctx context.Context, tx *sql.Tx, find *api.UserFind) ([]*userR
|
||||
}
|
||||
|
||||
func deleteUser(ctx context.Context, tx *sql.Tx, delete *api.UserDelete) error {
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
PRAGMA foreign_keys = ON;
|
||||
result, err := tx.ExecContext(ctx, `
|
||||
DELETE FROM user WHERE id = ?
|
||||
`, delete.ID); err != nil {
|
||||
`, delete.ID)
|
||||
if err != nil {
|
||||
return FormatError(err)
|
||||
}
|
||||
|
||||
rows, _ := result.RowsAffected()
|
||||
if rows == 0 {
|
||||
return &common.Error{Code: common.NotFound, Err: fmt.Errorf("user not found")}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -149,3 +149,22 @@ func findUserSettingList(ctx context.Context, tx *sql.Tx, find *api.UserSettingF
|
||||
|
||||
return userSettingRawList, nil
|
||||
}
|
||||
|
||||
func vacuumUserSetting(ctx context.Context, tx *sql.Tx) error {
|
||||
stmt := `
|
||||
DELETE FROM
|
||||
user_setting
|
||||
WHERE
|
||||
user_id NOT IN (
|
||||
SELECT
|
||||
id
|
||||
FROM
|
||||
user
|
||||
)`
|
||||
_, err := tx.ExecContext(ctx, stmt)
|
||||
if err != nil {
|
||||
return FormatError(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
+2
-1
@@ -21,7 +21,8 @@
|
||||
"endOfLine": "auto"
|
||||
}
|
||||
],
|
||||
"@typescript-eslint/no-empty-interface": ["off"],
|
||||
"no-unused-vars": "off",
|
||||
"@typescript-eslint/no-unused-vars": "error",
|
||||
"@typescript-eslint/no-explicit-any": ["off"],
|
||||
"react/react-in-jsx-scope": "off"
|
||||
},
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
<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");
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
/* eslint-disable no-undef */
|
||||
/** @type {import('ts-jest').JestConfigWithTsJest} */
|
||||
module.exports = {
|
||||
preset: "ts-jest",
|
||||
testEnvironment: "node",
|
||||
moduleNameMapper: {
|
||||
"lodash-es": "lodash",
|
||||
},
|
||||
};
|
||||
+20
-7
@@ -1,29 +1,39 @@
|
||||
{
|
||||
"name": "memos",
|
||||
"version": "0.4.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": {
|
||||
"@emotion/react": "^11.10.5",
|
||||
"@emotion/styled": "^11.10.5",
|
||||
"@mui/joy": "^5.0.0-alpha.52",
|
||||
"@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": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-feather": "^2.0.10",
|
||||
"react-redux": "^8.0.1"
|
||||
"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",
|
||||
@@ -32,10 +42,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"
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 63 KiB |
@@ -1,17 +1,17 @@
|
||||
{
|
||||
"short_name": "Memos",
|
||||
"name": "Memos",
|
||||
"description": "usememos/memos",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/logo.webp",
|
||||
"type": "image/*",
|
||||
"sizes": "200x200"
|
||||
"type": "image/webp",
|
||||
"sizes": "520x520"
|
||||
}
|
||||
],
|
||||
"start_url": "/",
|
||||
"background_color": "#f6f5f4",
|
||||
"display": "standalone",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"theme_color": "#f6f5f4",
|
||||
"description": "usememos/memos"
|
||||
"background_color": "#f6f5f4"
|
||||
}
|
||||
|
||||
+26
-17
@@ -1,41 +1,50 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import useI18n from "./hooks/useI18n";
|
||||
import { appRouterSwitch } from "./routers";
|
||||
import { CssVarsProvider } from "@mui/joy/styles";
|
||||
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 api from "./helpers/api";
|
||||
import * as storage from "./helpers/storage";
|
||||
|
||||
function App() {
|
||||
const { setLocale } = useI18n();
|
||||
const user = useAppSelector((state) => state.user.user);
|
||||
const { i18n } = useTranslation();
|
||||
const global = useAppSelector((state) => state.global);
|
||||
const pathname = useAppSelector((state) => state.location.pathname);
|
||||
const [isLoading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
locationService.updateStateWithLocation();
|
||||
window.onpopstate = () => {
|
||||
locationService.updateStateWithLocation();
|
||||
};
|
||||
globalService.initialState().then(() => {
|
||||
setLoading(false);
|
||||
|
||||
globalService.initialState();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
api.getSystemStatus().then(({ data }) => {
|
||||
const { data: status } = data;
|
||||
if (status.additionalStyle) {
|
||||
const styleEl = document.createElement("style");
|
||||
styleEl.innerHTML = status.additionalStyle;
|
||||
styleEl.setAttribute("type", "text/css");
|
||||
document.head.appendChild(styleEl);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (user?.setting.locale) {
|
||||
globalService.setLocale(user.setting.locale);
|
||||
}
|
||||
}, [user?.setting.locale]);
|
||||
|
||||
useEffect(() => {
|
||||
setLocale(global.locale);
|
||||
i18n.changeLanguage(global.locale);
|
||||
storage.set({
|
||||
locale: global.locale,
|
||||
});
|
||||
}, [global.locale]);
|
||||
|
||||
return <>{isLoading ? null : appRouterSwitch(pathname)}</>;
|
||||
return (
|
||||
<CssVarsProvider>
|
||||
<RouterProvider router={router} />
|
||||
</CssVarsProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as api from "../helpers/api";
|
||||
import useI18n from "../hooks/useI18n";
|
||||
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 } = useI18n();
|
||||
const { t } = useTranslation();
|
||||
const [profile, setProfile] = useState<Profile>();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -50,7 +49,7 @@ const AboutSiteDialog: React.FC<Props> = ({ destroy }: Props) => {
|
||||
<br />
|
||||
<div className="addtion-info-container">
|
||||
<GitHubBadge />
|
||||
<Only when={profile !== undefined}>
|
||||
{profile !== undefined && (
|
||||
<>
|
||||
{t("common.version")}:
|
||||
<span className="pre-text">
|
||||
@@ -58,7 +57,7 @@ const AboutSiteDialog: React.FC<Props> = ({ destroy }: Props) => {
|
||||
</span>
|
||||
🎉
|
||||
</>
|
||||
</Only>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { IMAGE_URL_REG } from "../helpers/marked";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as utils from "../helpers/utils";
|
||||
import useI18n from "../hooks/useI18n";
|
||||
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 {
|
||||
@@ -14,21 +12,15 @@ 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 { t } = useI18n();
|
||||
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) {
|
||||
console.error(error);
|
||||
toastHelper.error(error.response.data.message);
|
||||
@@ -44,8 +36,8 @@ 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) {
|
||||
console.error(error);
|
||||
toastHelper.error(error.response.data.message);
|
||||
@@ -61,7 +53,9 @@ 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}>
|
||||
{t("common.restore")}
|
||||
@@ -72,14 +66,8 @@ const ArchivedMemo: React.FC<Props> = (props: Props) => {
|
||||
</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 resourceList={memo.resourceList} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useLoading from "../hooks/useLoading";
|
||||
import { memoService } from "../services";
|
||||
import { useAppSelector } from "../store";
|
||||
@@ -8,9 +9,10 @@ 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();
|
||||
@@ -36,7 +38,7 @@ 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}>
|
||||
<Icon.X className="icon-img" />
|
||||
@@ -45,11 +47,11 @@ const ArchivedMemoDialog: React.FC<Props> = (props: Props) => {
|
||||
<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">No archived memos.</p>
|
||||
<p className="tip-text">{t("archived.no-archived-memos")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="archived-memos-container">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import dayjs from "dayjs";
|
||||
import useI18n from "../hooks/useI18n";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { memoService } from "../services";
|
||||
import Icon from "./Icon";
|
||||
import { generateDialog } from "./Dialog";
|
||||
@@ -12,20 +12,21 @@ interface Props extends DialogProps {
|
||||
}
|
||||
|
||||
const ChangeMemoCreatedTsDialog: React.FC<Props> = (props: Props) => {
|
||||
const { t } = useI18n();
|
||||
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("Memo not found.");
|
||||
destroy();
|
||||
}
|
||||
memoService.getMemoById(memoId).then((memo) => {
|
||||
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 = () => {
|
||||
@@ -42,7 +43,7 @@ const ChangeMemoCreatedTsDialog: React.FC<Props> = (props: Props) => {
|
||||
const createdTs = dayjs(createdAt).unix();
|
||||
|
||||
if (createdTs > nowTs) {
|
||||
toastHelper.error("Invalid created datetime.");
|
||||
toastHelper.error(t("message.invalid-created-datetime"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -51,7 +52,7 @@ const ChangeMemoCreatedTsDialog: React.FC<Props> = (props: Props) => {
|
||||
id: memoId,
|
||||
createdTs,
|
||||
});
|
||||
toastHelper.info("Memo created datetime changed.");
|
||||
toastHelper.info(t("message.memo-updated-datetime"));
|
||||
handleCloseBtnClick();
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
@@ -62,7 +63,7 @@ const ChangeMemoCreatedTsDialog: React.FC<Props> = (props: Props) => {
|
||||
return (
|
||||
<>
|
||||
<div className="dialog-header-container">
|
||||
<p className="title-text">Change memo created time</p>
|
||||
<p className="title-text">{t("message.change-memo-created-time")}</p>
|
||||
<button className="btn close-btn" onClick={handleCloseBtnClick}>
|
||||
<Icon.X />
|
||||
</button>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { validate, ValidatorConfig } from "../helpers/validator";
|
||||
import useI18n from "../hooks/useI18n";
|
||||
import { userService } from "../services";
|
||||
import Icon from "./Icon";
|
||||
import { generateDialog } from "./Dialog";
|
||||
@@ -14,10 +14,10 @@ const validateConfig: ValidatorConfig = {
|
||||
noChinese: true,
|
||||
};
|
||||
|
||||
interface Props extends DialogProps {}
|
||||
type Props = DialogProps;
|
||||
|
||||
const ChangePasswordDialog: React.FC<Props> = ({ destroy }: Props) => {
|
||||
const { t } = useI18n();
|
||||
const { t } = useTranslation();
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [newPasswordAgain, setNewPasswordAgain] = useState("");
|
||||
|
||||
@@ -41,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;
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ 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) {
|
||||
console.error(error);
|
||||
@@ -74,17 +74,22 @@ const ChangePasswordDialog: React.FC<Props> = ({ destroy }: Props) => {
|
||||
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}>
|
||||
<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}>
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { resourceService } from "../services";
|
||||
import Icon from "./Icon";
|
||||
import { generateDialog } from "./Dialog";
|
||||
import toastHelper from "./Toast";
|
||||
import "../less/change-resource-filename-dialog.less";
|
||||
|
||||
interface Props extends DialogProps {
|
||||
resourceId: ResourceId;
|
||||
resourceFilename: string;
|
||||
}
|
||||
|
||||
const validateFilename = (filename: string): boolean => {
|
||||
if (filename.length === 0 || filename.length >= 128) {
|
||||
return false;
|
||||
}
|
||||
const startReg = /^([+\-.]).*/;
|
||||
const illegalReg = /[/@#$%^&*()[\]]/;
|
||||
if (startReg.test(filename) || illegalReg.test(filename)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const ChangeResourceFilenameDialog: React.FC<Props> = (props: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { destroy, resourceId, resourceFilename } = props;
|
||||
const [filename, setFilename] = useState<string>(resourceFilename);
|
||||
|
||||
const handleFilenameChanged = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const nextUsername = e.target.value as string;
|
||||
setFilename(nextUsername);
|
||||
};
|
||||
|
||||
const handleCloseBtnClick = () => {
|
||||
destroy();
|
||||
};
|
||||
|
||||
const handleSaveBtnClick = async () => {
|
||||
if (filename === resourceFilename) {
|
||||
handleCloseBtnClick();
|
||||
return;
|
||||
}
|
||||
if (!validateFilename(filename)) {
|
||||
toastHelper.error(t("message.invalid-resource-filename"));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await resourceService.patchResource({
|
||||
id: resourceId,
|
||||
filename: filename,
|
||||
});
|
||||
toastHelper.info(t("message.resource-filename-updated"));
|
||||
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-resource-filename")}</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="text" value={filename} onChange={handleFilenameChanged} />
|
||||
</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 showChangeResourceFilenameDialog(resourceId: ResourceId, resourceFilename: string) {
|
||||
generateDialog(
|
||||
{
|
||||
className: "change-resource-filename-dialog",
|
||||
},
|
||||
ChangeResourceFilenameDialog,
|
||||
{
|
||||
resourceId,
|
||||
resourceFilename,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export default showChangeResourceFilenameDialog;
|
||||
@@ -1,4 +1,5 @@
|
||||
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";
|
||||
@@ -7,6 +8,7 @@ import { generateDialog } from "./Dialog";
|
||||
import toastHelper from "./Toast";
|
||||
import Selector from "./common/Selector";
|
||||
import "../less/create-shortcut-dialog.less";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
interface Props extends DialogProps {
|
||||
shortcutId?: ShortcutId;
|
||||
@@ -14,10 +16,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);
|
||||
@@ -43,7 +45,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;
|
||||
}
|
||||
|
||||
@@ -71,7 +73,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;
|
||||
}
|
||||
}
|
||||
@@ -99,7 +101,7 @@ 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}>
|
||||
<Icon.X />
|
||||
@@ -107,11 +109,17 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
|
||||
</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 (
|
||||
@@ -125,7 +133,7 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
|
||||
);
|
||||
})}
|
||||
<div className="create-filter-btn" onClick={handleAddFilterBenClick}>
|
||||
New Filter
|
||||
{t("filter.new-filter")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -134,10 +142,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>
|
||||
@@ -154,19 +162,30 @@ interface 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 dataSource =
|
||||
|
||||
const operatorDataSource = Object.values(filterConsts[type as FilterType].operators).map(({ text, value }) => ({ text: t(text), value }));
|
||||
|
||||
const valueDataSource =
|
||||
type === "TYPE"
|
||||
? filterConsts["TYPE"].values
|
||||
? filterConsts["TYPE"].values.map(({ text, value }) => ({ text: t(text), value }))
|
||||
: tags.sort().map((t) => {
|
||||
return { text: t, value: t };
|
||||
});
|
||||
|
||||
const maxDatetimeValue = dayjs().format("9999-12-31T23:59");
|
||||
|
||||
useEffect(() => {
|
||||
setValue(filter.value.value);
|
||||
if (type === "DISPLAY_TIME") {
|
||||
const nowDatetimeValue = dayjs().format("YYYY-MM-DDTHH:mm");
|
||||
handleValueChange(nowDatetimeValue);
|
||||
} else {
|
||||
setValue(filter.value.value);
|
||||
}
|
||||
}, [type]);
|
||||
|
||||
const handleRelationChange = (value: string) => {
|
||||
@@ -235,7 +254,7 @@ const MemoFilterInputer: React.FC<MemoFilterInputerProps> = (props: MemoFilterIn
|
||||
/>
|
||||
<Selector
|
||||
className="operator-selector"
|
||||
dataSource={Object.values(filterConsts[type as FilterType].operators)}
|
||||
dataSource={operatorDataSource}
|
||||
value={filter.value.operator}
|
||||
handleValueChanged={handleOperatorChange}
|
||||
/>
|
||||
@@ -247,9 +266,20 @@ const MemoFilterInputer: React.FC<MemoFilterInputerProps> = (props: MemoFilterIn
|
||||
onChange={(event) => {
|
||||
handleValueChange(event.target.value);
|
||||
}}
|
||||
placeholder={t("filter.text-placeholder")}
|
||||
/>
|
||||
) : type === "DISPLAY_TIME" ? (
|
||||
<input
|
||||
className="datetime-selector"
|
||||
type="datetime-local"
|
||||
value={value}
|
||||
max={maxDatetimeValue}
|
||||
onChange={(event) => {
|
||||
handleValueChange(event.target.value);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Selector className="value-selector" dataSource={dataSource} value={value} handleValueChanged={handleValueChange} />
|
||||
<Selector className="value-selector" dataSource={valueDataSource} value={value} handleValueChanged={handleValueChange} />
|
||||
)}
|
||||
<Icon.X className="remove-btn" onClick={handleRemoveBtnClick} />
|
||||
</div>
|
||||
|
||||
@@ -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 resourceList={memo.resourceList} />
|
||||
</div>
|
||||
<div className="split-line"></div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAppSelector } from "../store";
|
||||
import toImage from "../labs/html2image";
|
||||
import useI18n from "../hooks/useI18n";
|
||||
import useToggle from "../hooks/useToggle";
|
||||
import { DAILY_TIMESTAMP } from "../helpers/consts";
|
||||
import * as utils from "../helpers/utils";
|
||||
@@ -20,7 +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 } = useI18n();
|
||||
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);
|
||||
@@ -30,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) {
|
||||
@@ -97,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">
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Icon from "../Icon";
|
||||
import { generateDialog } from "./BaseDialog";
|
||||
import "../../less/common-dialog.less";
|
||||
@@ -18,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,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,47 +1,35 @@
|
||||
import { forwardRef, ReactNode, useCallback, useEffect, useImperativeHandle, useRef } from "react";
|
||||
import useI18n from "../../hooks/useI18n";
|
||||
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;
|
||||
insertText: (text: string, prefix?: string, suffix?: string) => void;
|
||||
setContent: (text: string) => void;
|
||||
getContent: () => string;
|
||||
getSelectedContent: () => string;
|
||||
getCursorPosition: () => number;
|
||||
}
|
||||
|
||||
interface EditorProps {
|
||||
interface Props {
|
||||
className: string;
|
||||
initialContent: string;
|
||||
placeholder: string;
|
||||
fullscreen: boolean;
|
||||
showConfirmBtn: boolean;
|
||||
tools?: ReactNode;
|
||||
onConfirmBtnClick: (content: string) => void;
|
||||
onContentChange: (content: string) => void;
|
||||
onPaste: (event: React.ClipboardEvent) => void;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react/display-name
|
||||
const Editor = forwardRef((props: EditorProps, ref: React.ForwardedRef<EditorRefActions>) => {
|
||||
const {
|
||||
className,
|
||||
initialContent,
|
||||
placeholder,
|
||||
fullscreen,
|
||||
showConfirmBtn,
|
||||
onConfirmBtnClick: handleConfirmBtnClickCallback,
|
||||
onContentChange: handleContentChangeCallback,
|
||||
} = props;
|
||||
const { t } = useI18n();
|
||||
const Editor = forwardRef(function Editor(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);
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -55,20 +43,27 @@ const Editor = forwardRef((props: EditorProps, ref: React.ForwardedRef<EditorRef
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
element: editorRef.current as HTMLTextAreaElement,
|
||||
focus: () => {
|
||||
editorRef.current?.focus();
|
||||
},
|
||||
insertText: (rawText: string) => {
|
||||
insertText: (content = "", prefix = "", suffix = "") => {
|
||||
if (!editorRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const prevValue = editorRef.current.value;
|
||||
const cursorPosition = editorRef.current.selectionStart;
|
||||
editorRef.current.value = prevValue.slice(0, cursorPosition) + rawText + prevValue.slice(cursorPosition);
|
||||
const endPosition = editorRef.current.selectionEnd;
|
||||
const prevValue = editorRef.current.value;
|
||||
const value =
|
||||
prevValue.slice(0, cursorPosition) +
|
||||
prefix +
|
||||
(content || prevValue.slice(cursorPosition, endPosition)) +
|
||||
suffix +
|
||||
prevValue.slice(endPosition);
|
||||
|
||||
editorRef.current.value = value;
|
||||
editorRef.current.focus();
|
||||
editorRef.current.selectionEnd = cursorPosition + rawText.length;
|
||||
editorRef.current.selectionEnd = endPosition + prefix.length + suffix.length + content.length;
|
||||
handleContentChangeCallback(editorRef.current.value);
|
||||
refresh();
|
||||
},
|
||||
@@ -86,6 +81,11 @@ const Editor = forwardRef((props: EditorProps, ref: React.ForwardedRef<EditorRef
|
||||
getCursorPosition: (): number => {
|
||||
return editorRef.current?.selectionStart ?? 0;
|
||||
},
|
||||
getSelectedContent: () => {
|
||||
const start = editorRef.current?.selectionStart;
|
||||
const end = editorRef.current?.selectionEnd;
|
||||
return editorRef.current?.value.slice(start, end) ?? "";
|
||||
},
|
||||
}),
|
||||
[]
|
||||
);
|
||||
@@ -95,25 +95,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 = "";
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={"common-editor-wrapper " + className}>
|
||||
<textarea
|
||||
@@ -121,21 +102,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={showConfirmBtn}>
|
||||
<button className="action-btn confirm-btn" disabled={editorRef.current?.value === ""} onClick={handleCommonConfirmBtnClick}>
|
||||
{t("editor.save")} <span className="icon-text">✍️</span>
|
||||
</button>
|
||||
</Only>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
@@ -3,9 +3,7 @@ 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(() => {
|
||||
|
||||
@@ -2,20 +2,21 @@ import showPreviewImageDialog from "./PreviewImageDialog";
|
||||
import "../less/image.less";
|
||||
|
||||
interface Props {
|
||||
imgUrl: string;
|
||||
imgUrls: string[];
|
||||
index: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const Image: React.FC<Props> = (props: Props) => {
|
||||
const { className, imgUrl } = props;
|
||||
const { className, imgUrls, index } = props;
|
||||
|
||||
const handleImageClick = () => {
|
||||
showPreviewImageDialog(imgUrl);
|
||||
showPreviewImageDialog(imgUrls, index);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={"image-container " + className} onClick={handleImageClick}>
|
||||
<img src={imgUrl} decoding="async" loading="lazy" />
|
||||
<img src={imgUrls[index]} decoding="async" loading="lazy" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
+104
-106
@@ -1,82 +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 { memo, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import "dayjs/locale/zh";
|
||||
import useI18n from "../hooks/useI18n";
|
||||
import { UNKNOWN_ID } from "../helpers/consts";
|
||||
import { DONE_BLOCK_REG, formatMemoContent, IMAGE_URL_REG, TODO_BLOCK_REG } from "../helpers/marked";
|
||||
import { editorStateService, locationService, memoService, userService } from "../services";
|
||||
import Icon from "./Icon";
|
||||
import Only from "./common/OnlyWhen";
|
||||
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, locale = "en"): string => {
|
||||
if (Date.now() - createdTs < 1000 * 60 * 60 * 24) {
|
||||
return dayjs(createdTs).locale(locale).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).locale(locale).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 { t, locale } = useI18n();
|
||||
const [state, setState] = useState<State>({
|
||||
expandButtonStatus: -1,
|
||||
});
|
||||
const [createdAtStr, setCreatedAtStr] = useState<string>(getFormatedMemoCreatedAtStr(memo.createdTs, locale));
|
||||
const { t, i18n } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [displayTimeStr, setDisplayTimeStr] = useState<string>(getFormatedMemoTimeStr(memo.displayTs, i18n.language));
|
||||
const memoContainerRef = useRef<HTMLDivElement>(null);
|
||||
const memoContentContainerRef = 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 (!memoContentContainerRef) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Number(memoContentContainerRef.current?.clientHeight) > MAX_MEMO_CONTAINER_HEIGHT) {
|
||||
setState({
|
||||
...state,
|
||||
expandButtonStatus: 0,
|
||||
});
|
||||
}
|
||||
|
||||
let intervalFlag = -1;
|
||||
if (Date.now() - memo.createdTs < 1000 * 60 * 60 * 24) {
|
||||
let intervalFlag: any = -1;
|
||||
if (Date.now() - memo.displayTs < 1000 * 60 * 60 * 24) {
|
||||
intervalFlag = setInterval(() => {
|
||||
setCreatedAtStr(getFormatedMemoCreatedAtStr(memo.createdTs, locale));
|
||||
setDisplayTimeStr(getFormatedMemoTimeStr(memo.displayTs, i18n.language));
|
||||
}, 1000 * 1);
|
||||
}
|
||||
|
||||
return () => {
|
||||
clearInterval(intervalFlag);
|
||||
};
|
||||
}, [locale]);
|
||||
}, [i18n.language]);
|
||||
|
||||
const handleShowMemoStoryDialog = () => {
|
||||
if (isVisitorMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -122,12 +113,12 @@ const Memo: React.FC<Props> = (props: Props) => {
|
||||
|
||||
if (targetEl.className === "memo-link-text") {
|
||||
const memoId = targetEl.dataset?.value;
|
||||
const memoTemp = memoService.getMemoById(Number(memoId) ?? UNKNOWN_ID);
|
||||
const memoTemp = await memoService.getMemoById(Number(memoId) ?? UNKNOWN_ID);
|
||||
|
||||
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") {
|
||||
@@ -144,11 +135,11 @@ const Memo: React.FC<Props> = (props: Props) => {
|
||||
}
|
||||
|
||||
const status = targetEl.dataset?.value;
|
||||
const todoElementList = [...(memoContentContainerRef.current?.querySelectorAll(`span.todo-block[data-value=${status}]`) ?? [])];
|
||||
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++) {
|
||||
@@ -169,83 +160,90 @@ const Memo: React.FC<Props> = (props: Props) => {
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (targetEl.tagName === "IMG") {
|
||||
const currImgUrl = targetEl.getAttribute("src");
|
||||
|
||||
if (currImgUrl) {
|
||||
// use regex to get all image urls from memo content
|
||||
const imageUrls = memo.content.match(/!\[.*?\]\((.*?)\)/g)?.map((item) => item.match(/\((.*?)\)/)?.[1] ?? "") ?? [];
|
||||
showPreviewImageDialog(
|
||||
imageUrls,
|
||||
imageUrls.findIndex((item) => item === currImgUrl)
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleExpandBtnClick = () => {
|
||||
const expandButtonStatus = Boolean(!state.expandButtonStatus);
|
||||
if (!expandButtonStatus) {
|
||||
memoContainerRef.current?.scrollIntoView();
|
||||
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;
|
||||
}
|
||||
|
||||
setState({
|
||||
expandButtonStatus: Number(expandButtonStatus) as ExpandButtonStatus,
|
||||
});
|
||||
editorStateService.setEditMemoWithId(memo.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`memo-wrapper ${"memos-" + memo.id} ${memo.pinned ? "pinned" : ""}`} ref={memoContainerRef}>
|
||||
{memo.pinned && <div className="corner-container"></div>}
|
||||
<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">
|
||||
<Icon.MoreHorizontal className="icon-img" />
|
||||
<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}>
|
||||
<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>
|
||||
{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 ? "text-green-600" : ""}`} />
|
||||
<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}>
|
||||
{t("common.mark")}
|
||||
</span>
|
||||
<span className="btn" onClick={handleShowMemoStoryDialog}>
|
||||
{t("memo.view-story")}
|
||||
</span>
|
||||
<span className="btn archive-btn" onClick={handleArchiveMemoClick}>
|
||||
{t("common.archive")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
ref={memoContentContainerRef}
|
||||
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"}
|
||||
<Icon.ChevronRight className="icon-img" />
|
||||
</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 resourceList={memo.resourceList} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import copy from "copy-to-clipboard";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { editorStateService, memoService, userService } from "../services";
|
||||
import { useAppSelector } from "../store";
|
||||
import { UNKNOWN_ID, VISIBILITY_SELECTOR_ITEMS } from "../helpers/consts";
|
||||
import * as utils from "../helpers/utils";
|
||||
import { formatMemoContent, IMAGE_URL_REG, MEMO_LINK_REG, 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 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";
|
||||
|
||||
@@ -23,19 +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 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]);
|
||||
@@ -43,12 +54,12 @@ const MemoCardDialog: React.FC<Props> = (props: Props) => {
|
||||
continue;
|
||||
}
|
||||
|
||||
const memoTemp = memoService.getMemoById(id);
|
||||
const memoTemp = await memoService.getMemoById(id);
|
||||
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),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -59,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) {
|
||||
@@ -72,10 +83,16 @@ const MemoCardDialog: React.FC<Props> = (props: Props) => {
|
||||
};
|
||||
|
||||
fetchLinkedMemos();
|
||||
setMemo(memoService.getMemoById(memo.id) as Memo);
|
||||
memoService.getMemoById(memo.id).then((memo) => {
|
||||
setMemo(memo);
|
||||
});
|
||||
}, [memos, memo.id]);
|
||||
|
||||
const handleMemoCreatedAtClick = () => {
|
||||
if (isVisitorMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
showChangeMemoCreatedTsDialog(memo.id);
|
||||
};
|
||||
|
||||
@@ -84,18 +101,18 @@ const MemoCardDialog: React.FC<Props> = (props: Props) => {
|
||||
|
||||
if (targetEl.className === "memo-link-text") {
|
||||
const nextMemoId = targetEl.dataset?.value;
|
||||
const memoTemp = memoService.getMemoById(Number(nextMemoId) ?? UNKNOWN_ID);
|
||||
const memoTemp = await memoService.getMemoById(Number(nextMemoId) ?? UNKNOWN_ID);
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -107,11 +124,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;
|
||||
@@ -129,51 +155,47 @@ const MemoCardDialog: React.FC<Props> = (props: Props) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Only when={!userService.isVisitorMode()}>
|
||||
{!isVisitorMode && (
|
||||
<div className="card-header-container">
|
||||
<div className="visibility-selector-container">
|
||||
<Icon.Eye className="icon-img" />
|
||||
<Selector
|
||||
className="visibility-selector"
|
||||
dataSource={VISIBILITY_SELECTOR_ITEMS}
|
||||
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" onClick={handleMemoCreatedAtClick}>
|
||||
{utils.getDateTimeString(memo.createdTs)}
|
||||
{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}>
|
||||
<Icon.Edit3 className="icon-img" />
|
||||
</button>
|
||||
<span className="split-line">/</span>
|
||||
</>
|
||||
</Only>
|
||||
)}
|
||||
<button className="btn close-btn" onClick={props.destroy}>
|
||||
<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 resourceList={memo.resourceList} />
|
||||
</div>
|
||||
<div className="layer-container"></div>
|
||||
{linkMemos.map((_, idx) => {
|
||||
@@ -199,7 +221,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>
|
||||
@@ -213,7 +235,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>
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
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 { t } = useTranslation();
|
||||
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 ? t("common.expand") : t("common.fold")}
|
||||
<Icon.ChevronRight className="icon-img" />
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MemoContent;
|
||||
+334
-167
@@ -1,34 +1,79 @@
|
||||
import { IEmojiData } from "emoji-picker-react";
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { UNKNOWN_ID } from "../helpers/consts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { deleteMemoResource, upsertMemoResource } from "../helpers/api";
|
||||
import { TAB_SPACE_WIDTH, UNKNOWN_ID, VISIBILITY_SELECTOR_ITEMS } from "../helpers/consts";
|
||||
import { editorStateService, locationService, memoService, resourceService } from "../services";
|
||||
import useI18n from "../hooks/useI18n";
|
||||
import { useAppSelector } from "../store";
|
||||
import * as storage from "../helpers/storage";
|
||||
import Icon from "./Icon";
|
||||
import toastHelper from "./Toast";
|
||||
import Selector from "./common/Selector";
|
||||
import Editor, { EditorRefActions } from "./Editor/Editor";
|
||||
import EmojiPicker from "./Editor/EmojiPicker";
|
||||
import { toLower } from "lodash";
|
||||
import "../less/memo-editor.less";
|
||||
|
||||
interface Props {}
|
||||
const getEditorContentCache = (): string => {
|
||||
return storage.get(["editorContentCache"]).editorContentCache ?? "";
|
||||
};
|
||||
|
||||
const setEditorContentCache = (content: string) => {
|
||||
storage.set({
|
||||
editorContentCache: content,
|
||||
});
|
||||
};
|
||||
|
||||
const setEditingMemoVisibilityCache = (visibility: Visibility) => {
|
||||
storage.set({
|
||||
editingMemoVisibilityCache: visibility,
|
||||
});
|
||||
};
|
||||
|
||||
interface State {
|
||||
isUploadingResource: boolean;
|
||||
fullscreen: boolean;
|
||||
isUploadingResource: boolean;
|
||||
resourceList: Resource[];
|
||||
shouldShowEmojiPicker: boolean;
|
||||
isEditorFocused: boolean;
|
||||
}
|
||||
|
||||
const MemoEditor: React.FC<Props> = () => {
|
||||
const { t, locale } = useI18n();
|
||||
const user = useAppSelector((state) => state.user.user);
|
||||
const MemoEditor: React.FC = () => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const user = useAppSelector((state) => state.user.user as User);
|
||||
const setting = user.setting;
|
||||
const editorState = useAppSelector((state) => state.editor);
|
||||
const tags = useAppSelector((state) => state.memo.tags);
|
||||
const [state, setState] = useState<State>({
|
||||
isUploadingResource: false,
|
||||
fullscreen: false,
|
||||
shouldShowEmojiPicker: false,
|
||||
resourceList: [],
|
||||
isEditorFocused: false,
|
||||
});
|
||||
const editorRef = useRef<EditorRefActions>(null);
|
||||
const [allowSave, setAllowSave] = useState<boolean>(false);
|
||||
const prevGlobalStateRef = useRef(editorState);
|
||||
const editorRef = useRef<EditorRefActions>(null);
|
||||
const tagSeletorRef = useRef<HTMLDivElement>(null);
|
||||
const editorFontStyle = user?.setting.editorFontStyle || "normal";
|
||||
const mobileEditorStyle = user?.setting.mobileEditorStyle || "normal";
|
||||
const memoVisibilityOptionSelectorItems = VISIBILITY_SELECTOR_ITEMS.map((item) => {
|
||||
return {
|
||||
value: item.value,
|
||||
text: t(`memo.visibility.${toLower(item.value)}`),
|
||||
};
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const { editingMemoIdCache, editingMemoVisibilityCache } = storage.get(["editingMemoIdCache", "editingMemoVisibilityCache"]);
|
||||
if (editingMemoIdCache) {
|
||||
editorStateService.setEditMemoWithId(editingMemoIdCache);
|
||||
}
|
||||
if (editingMemoVisibilityCache) {
|
||||
editorStateService.setMemoVisibility(editingMemoVisibilityCache as "PUBLIC" | "PROTECTED" | "PRIVATE");
|
||||
} else {
|
||||
editorStateService.setMemoVisibility(setting.memoVisibility);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (editorState.markMemoId && editorState.markMemoId !== UNKNOWN_ID) {
|
||||
@@ -37,123 +82,148 @@ const MemoEditor: React.FC<Props> = () => {
|
||||
editorRef.current?.insertText(memoLinkText);
|
||||
editorStateService.clearMarkMemo();
|
||||
}
|
||||
}, [editorState.markMemoId]);
|
||||
|
||||
if (
|
||||
editorState.editMemoId &&
|
||||
editorState.editMemoId !== UNKNOWN_ID &&
|
||||
editorState.editMemoId !== prevGlobalStateRef.current.editMemoId
|
||||
) {
|
||||
const editMemo = memoService.getMemoById(editorState.editMemoId ?? UNKNOWN_ID);
|
||||
if (editMemo) {
|
||||
editorRef.current?.setContent(editMemo.content ?? "");
|
||||
editorRef.current?.focus();
|
||||
}
|
||||
useEffect(() => {
|
||||
if (editorState.editMemoId) {
|
||||
memoService.getMemoById(editorState.editMemoId ?? UNKNOWN_ID).then((memo) => {
|
||||
if (memo) {
|
||||
handleEditorFocus();
|
||||
editorStateService.setMemoVisibility(memo.visibility);
|
||||
editorRef.current?.setContent(memo.content ?? "");
|
||||
setState((state) => {
|
||||
return {
|
||||
...state,
|
||||
resourceList: memo.resourceList,
|
||||
};
|
||||
});
|
||||
}
|
||||
});
|
||||
storage.set({
|
||||
editingMemoIdCache: editorState.editMemoId,
|
||||
});
|
||||
} else {
|
||||
storage.remove(["editingMemoIdCache"]);
|
||||
}
|
||||
|
||||
prevGlobalStateRef.current = editorState;
|
||||
}, [editorState.markMemoId, editorState.editMemoId]);
|
||||
}, [editorState.editMemoId]);
|
||||
|
||||
useEffect(() => {
|
||||
const handlePasteEvent = async (event: ClipboardEvent) => {
|
||||
if (event.clipboardData && event.clipboardData.files.length > 0) {
|
||||
event.preventDefault();
|
||||
const file = event.clipboardData.files[0];
|
||||
const url = await handleUploadFile(file);
|
||||
if (url) {
|
||||
editorRef.current?.insertText(``);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDropEvent = async (event: DragEvent) => {
|
||||
if (event.dataTransfer && event.dataTransfer.files.length > 0) {
|
||||
event.preventDefault();
|
||||
const file = event.dataTransfer.files[0];
|
||||
const url = await handleUploadFile(file);
|
||||
if (url) {
|
||||
editorRef.current?.insertText(``);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleClickEvent = () => {
|
||||
handleContentChange(editorRef.current?.element.value ?? "");
|
||||
};
|
||||
|
||||
const handleKeyDownEvent = () => {
|
||||
setTimeout(() => {
|
||||
handleContentChange(editorRef.current?.element.value ?? "");
|
||||
});
|
||||
};
|
||||
|
||||
editorRef.current?.element.addEventListener("paste", handlePasteEvent);
|
||||
editorRef.current?.element.addEventListener("drop", handleDropEvent);
|
||||
editorRef.current?.element.addEventListener("click", handleClickEvent);
|
||||
editorRef.current?.element.addEventListener("keydown", handleKeyDownEvent);
|
||||
|
||||
return () => {
|
||||
editorRef.current?.element.removeEventListener("paste", handlePasteEvent);
|
||||
editorRef.current?.element.removeEventListener("drop", handleDropEvent);
|
||||
editorRef.current?.element.removeEventListener("click", handleClickEvent);
|
||||
editorRef.current?.element.removeEventListener("keydown", handleKeyDownEvent);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleUploadFile = useCallback(
|
||||
async (file: File) => {
|
||||
if (state.isUploadingResource) {
|
||||
const handleKeyDown = (event: React.KeyboardEvent) => {
|
||||
if (event.key === "Escape" && state.fullscreen) {
|
||||
handleFullscreenBtnClick();
|
||||
return;
|
||||
}
|
||||
if (event.key === "Tab") {
|
||||
event.preventDefault();
|
||||
editorRef.current?.insertText(" ".repeat(TAB_SPACE_WIDTH));
|
||||
return;
|
||||
}
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
if (event.key === "Enter") {
|
||||
handleSaveBtnClick();
|
||||
return;
|
||||
}
|
||||
if (event.key === "b") {
|
||||
event.preventDefault();
|
||||
editorRef.current?.insertText("", "**", "**");
|
||||
return;
|
||||
}
|
||||
if (event.key === "i") {
|
||||
event.preventDefault();
|
||||
editorRef.current?.insertText("", "*", "*");
|
||||
return;
|
||||
}
|
||||
if (event.key === "e") {
|
||||
event.preventDefault();
|
||||
editorRef.current?.insertText("", "`", "`");
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDropEvent = async (event: React.DragEvent) => {
|
||||
if (event.dataTransfer && event.dataTransfer.files.length > 0) {
|
||||
event.preventDefault();
|
||||
const resourceList: Resource[] = [];
|
||||
for (const file of event.dataTransfer.files) {
|
||||
const resource = await handleUploadResource(file);
|
||||
if (resource) {
|
||||
resourceList.push(resource);
|
||||
if (editorState.editMemoId) {
|
||||
await upsertMemoResource(editorState.editMemoId, resource.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
setState({
|
||||
...state,
|
||||
isUploadingResource: true,
|
||||
resourceList: [...state.resourceList, ...resourceList],
|
||||
});
|
||||
const { type } = file;
|
||||
}
|
||||
};
|
||||
|
||||
if (!type.startsWith("image")) {
|
||||
toastHelper.error("Only image file supported.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const image = await resourceService.upload(file);
|
||||
const url = `/h/r/${image.id}/${image.filename}`;
|
||||
return url;
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
toastHelper.error(error.response.data.message);
|
||||
} finally {
|
||||
const handlePasteEvent = async (event: React.ClipboardEvent) => {
|
||||
if (event.clipboardData && event.clipboardData.files.length > 0) {
|
||||
event.preventDefault();
|
||||
const file = event.clipboardData.files[0];
|
||||
const resource = await handleUploadResource(file);
|
||||
if (resource) {
|
||||
setState({
|
||||
...state,
|
||||
isUploadingResource: false,
|
||||
resourceList: [...state.resourceList, resource],
|
||||
});
|
||||
}
|
||||
},
|
||||
[state]
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveBtnClick = async (content: string) => {
|
||||
if (content === "") {
|
||||
toastHelper.error("Content can't be empty");
|
||||
const handleUploadResource = async (file: File) => {
|
||||
setState({
|
||||
...state,
|
||||
isUploadingResource: true,
|
||||
});
|
||||
|
||||
let resource = undefined;
|
||||
|
||||
try {
|
||||
resource = await resourceService.upload(file);
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
toastHelper.error(error.response.data.message);
|
||||
}
|
||||
|
||||
setState({
|
||||
...state,
|
||||
isUploadingResource: false,
|
||||
});
|
||||
return resource;
|
||||
};
|
||||
|
||||
const handleSaveBtnClick = async () => {
|
||||
const content = editorRef.current?.getContent();
|
||||
if (!content) {
|
||||
toastHelper.error(t("editor.cant-empty"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { editMemoId } = editorStateService.getState();
|
||||
if (editMemoId && editMemoId !== UNKNOWN_ID) {
|
||||
const prevMemo = memoService.getMemoById(editMemoId ?? UNKNOWN_ID);
|
||||
const prevMemo = await memoService.getMemoById(editMemoId ?? UNKNOWN_ID);
|
||||
|
||||
if (prevMemo && prevMemo.content !== content) {
|
||||
if (prevMemo) {
|
||||
await memoService.patchMemo({
|
||||
id: prevMemo.id,
|
||||
content,
|
||||
visibility: editorState.memoVisibility,
|
||||
resourceIdList: state.resourceList.map((resource) => resource.id),
|
||||
});
|
||||
}
|
||||
editorStateService.clearEditMemo();
|
||||
} else {
|
||||
await memoService.createMemo({
|
||||
content,
|
||||
visibility: editorState.memoVisibility,
|
||||
resourceIdList: state.resourceList.map((resource) => resource.id),
|
||||
});
|
||||
locationService.clearQuery();
|
||||
}
|
||||
@@ -165,19 +235,32 @@ const MemoEditor: React.FC<Props> = () => {
|
||||
setState({
|
||||
...state,
|
||||
fullscreen: false,
|
||||
resourceList: [],
|
||||
});
|
||||
setEditorContentCache("");
|
||||
storage.remove(["editingMemoVisibilityCache"]);
|
||||
editorRef.current?.setContent("");
|
||||
};
|
||||
|
||||
const handleCancelEditingBtnClick = useCallback(() => {
|
||||
const handleCancelEdit = () => {
|
||||
setState({
|
||||
...state,
|
||||
resourceList: [],
|
||||
});
|
||||
editorStateService.clearEditMemo();
|
||||
editorRef.current?.setContent("");
|
||||
setEditorContentCache("");
|
||||
}, []);
|
||||
storage.remove(["editingMemoVisibilityCache"]);
|
||||
};
|
||||
|
||||
const handleContentChange = useCallback((content: string) => {
|
||||
const handleContentChange = (content: string) => {
|
||||
setAllowSave(content !== "");
|
||||
setEditorContentCache(content);
|
||||
}, []);
|
||||
};
|
||||
|
||||
const handleEmojiPickerBtnClick = () => {
|
||||
handleChangeShouldShowEmojiPicker(!state.shouldShowEmojiPicker);
|
||||
};
|
||||
|
||||
const handleCheckBoxBtnClick = () => {
|
||||
if (!editorRef.current) {
|
||||
@@ -187,9 +270,9 @@ const MemoEditor: React.FC<Props> = () => {
|
||||
const cursorPosition = editorRef.current.getCursorPosition();
|
||||
const prevValue = editorRef.current.getContent().slice(0, cursorPosition);
|
||||
if (prevValue === "" || prevValue.endsWith("\n")) {
|
||||
editorRef.current?.insertText("- [ ] ");
|
||||
editorRef.current?.insertText("", "- [ ] ");
|
||||
} else {
|
||||
editorRef.current?.insertText("\n- [ ] ");
|
||||
editorRef.current?.insertText("", "\n- [ ] ");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -201,13 +284,13 @@ const MemoEditor: React.FC<Props> = () => {
|
||||
const cursorPosition = editorRef.current.getCursorPosition();
|
||||
const prevValue = editorRef.current.getContent().slice(0, cursorPosition);
|
||||
if (prevValue === "" || prevValue.endsWith("\n")) {
|
||||
editorRef.current?.insertText("```\n\n```");
|
||||
editorRef.current?.insertText("", "```\n", "\n```");
|
||||
} else {
|
||||
editorRef.current?.insertText("\n```\n\n```");
|
||||
editorRef.current?.insertText("", "\n```\n", "\n```");
|
||||
}
|
||||
};
|
||||
|
||||
const handleUploadFileBtnClick = useCallback(() => {
|
||||
const handleUploadFileBtnClick = () => {
|
||||
const inputEl = document.createElement("input");
|
||||
inputEl.style.position = "fixed";
|
||||
inputEl.style.top = "-100vh";
|
||||
@@ -215,22 +298,30 @@ const MemoEditor: React.FC<Props> = () => {
|
||||
document.body.appendChild(inputEl);
|
||||
inputEl.type = "file";
|
||||
inputEl.multiple = true;
|
||||
inputEl.accept = "image/*";
|
||||
inputEl.accept = "*";
|
||||
inputEl.onchange = async () => {
|
||||
if (!inputEl.files || inputEl.files.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const resourceList: Resource[] = [];
|
||||
for (const file of inputEl.files) {
|
||||
const url = await handleUploadFile(file);
|
||||
if (url) {
|
||||
editorRef.current?.insertText(``);
|
||||
const resource = await handleUploadResource(file);
|
||||
if (resource) {
|
||||
resourceList.push(resource);
|
||||
if (editorState.editMemoId) {
|
||||
await upsertMemoResource(editorState.editMemoId, resource.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
setState({
|
||||
...state,
|
||||
resourceList: [...state.resourceList, ...resourceList],
|
||||
});
|
||||
document.body.removeChild(inputEl);
|
||||
};
|
||||
inputEl.click();
|
||||
document.body.removeChild(inputEl);
|
||||
}, []);
|
||||
};
|
||||
|
||||
const handleFullscreenBtnClick = () => {
|
||||
setState({
|
||||
@@ -242,84 +333,160 @@ const MemoEditor: React.FC<Props> = () => {
|
||||
const handleTagSeletorClick = useCallback((event: React.MouseEvent) => {
|
||||
if (tagSeletorRef.current !== event.target && tagSeletorRef.current?.contains(event.target as Node)) {
|
||||
editorRef.current?.insertText(`#${(event.target as HTMLElement).textContent} ` ?? "");
|
||||
editorRef.current?.focus();
|
||||
handleEditorFocus();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleChangeShouldShowEmojiPicker = (status: boolean) => {
|
||||
setState({
|
||||
...state,
|
||||
shouldShowEmojiPicker: status,
|
||||
});
|
||||
};
|
||||
|
||||
const handleEmojiClick = (_: any, emojiObject: IEmojiData) => {
|
||||
if (!editorRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
editorRef.current.insertText(`${emojiObject.emoji}`);
|
||||
handleChangeShouldShowEmojiPicker(false);
|
||||
};
|
||||
|
||||
const handleDeleteResource = async (resourceId: ResourceId) => {
|
||||
setState({
|
||||
...state,
|
||||
resourceList: state.resourceList.filter((resource) => resource.id !== resourceId),
|
||||
});
|
||||
|
||||
if (editorState.editMemoId) {
|
||||
await deleteMemoResource(editorState.editMemoId, resourceId);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMemoVisibilityOptionChanged = async (value: string) => {
|
||||
const visibilityValue = value as Visibility;
|
||||
editorStateService.setMemoVisibility(visibilityValue);
|
||||
setEditingMemoVisibilityCache(visibilityValue);
|
||||
};
|
||||
|
||||
const handleEditorFocus = () => {
|
||||
editorRef.current?.focus();
|
||||
setState({
|
||||
...state,
|
||||
isEditorFocused: true,
|
||||
});
|
||||
};
|
||||
|
||||
const handleEditorBlur = () => {
|
||||
setState({
|
||||
...state,
|
||||
isEditorFocused: false,
|
||||
});
|
||||
};
|
||||
|
||||
const isEditing = Boolean(editorState.editMemoId && editorState.editMemoId !== UNKNOWN_ID);
|
||||
|
||||
const editorConfig = useMemo(
|
||||
() => ({
|
||||
className: `memo-editor ${editorFontStyle}`,
|
||||
className: `memo-editor`,
|
||||
initialContent: getEditorContentCache(),
|
||||
placeholder: t("editor.placeholder"),
|
||||
fullscreen: state.fullscreen,
|
||||
showConfirmBtn: true,
|
||||
onConfirmBtnClick: handleSaveBtnClick,
|
||||
onContentChange: handleContentChange,
|
||||
onPaste: handlePasteEvent,
|
||||
}),
|
||||
[isEditing, state.fullscreen, locale, editorFontStyle]
|
||||
[state.fullscreen, i18n.language]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`memo-editor-container ${isEditing ? "edit-ing" : ""} ${state.fullscreen ? "fullscreen" : ""}`}>
|
||||
<div className={`tip-container ${isEditing ? "" : "!hidden"}`}>
|
||||
<span className="tip-text">{t("editor.editing")}</span>
|
||||
<button className="cancel-btn" onClick={handleCancelEditingBtnClick}>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
<div
|
||||
className={`memo-editor-container ${mobileEditorStyle} ${isEditing ? "edit-ing" : ""} ${state.fullscreen ? "fullscreen" : ""}`}
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyDown}
|
||||
onDrop={handleDropEvent}
|
||||
onFocus={handleEditorFocus}
|
||||
onBlur={handleEditorBlur}
|
||||
>
|
||||
<div className="editor-header-container">
|
||||
<Selector
|
||||
className={`visibility-selector ${state.isEditorFocused || allowSave ? "" : "!hidden"}`}
|
||||
value={editorState.memoVisibility}
|
||||
dataSource={memoVisibilityOptionSelectorItems}
|
||||
handleValueChanged={handleMemoVisibilityOptionChanged}
|
||||
/>
|
||||
<div className={`editing-container ${isEditing ? "" : "!hidden"}`}>
|
||||
<span className="tip-text">{t("editor.editing")}</span>
|
||||
<button className="cancel-btn" onClick={handleCancelEdit}>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Editor
|
||||
ref={editorRef}
|
||||
{...editorConfig}
|
||||
tools={
|
||||
<>
|
||||
<div className="action-btn tag-action">
|
||||
<Icon.Hash className="icon-img" />
|
||||
<div ref={tagSeletorRef} className="tag-list" onClick={handleTagSeletorClick}>
|
||||
{tags.length > 0 ? (
|
||||
tags.map((tag) => {
|
||||
return (
|
||||
<span className="item-container" key={tag}>
|
||||
{tag}
|
||||
</span>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<p className="tip-text" onClick={(e) => e.stopPropagation()}>
|
||||
{t("common.null")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Editor ref={editorRef} {...editorConfig} />
|
||||
<div className="common-tools-wrapper">
|
||||
<div className="common-tools-container">
|
||||
<div className="action-btn tag-action">
|
||||
<Icon.Hash className="icon-img" />
|
||||
<div ref={tagSeletorRef} className="tag-list" onClick={handleTagSeletorClick}>
|
||||
{tags.length > 0 ? (
|
||||
tags.map((tag) => {
|
||||
return (
|
||||
<span className="item-container" key={tag}>
|
||||
{tag}
|
||||
</span>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<p className="tip-text" onClick={(e) => e.stopPropagation()}>
|
||||
{t("common.null")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button className="action-btn">
|
||||
<Icon.CheckSquare className="icon-img" onClick={handleCheckBoxBtnClick} />
|
||||
</button>
|
||||
<button className="action-btn">
|
||||
<Icon.Code className="icon-img" onClick={handleCodeBlockBtnClick} />
|
||||
</button>
|
||||
<button className="action-btn">
|
||||
<Icon.Image className="icon-img" onClick={handleUploadFileBtnClick} />
|
||||
<span className={`tip-text ${state.isUploadingResource ? "!block" : ""}`}>Uploading</span>
|
||||
</button>
|
||||
<button className="action-btn" onClick={handleFullscreenBtnClick}>
|
||||
{state.fullscreen ? <Icon.Minimize className="icon-img" /> : <Icon.Maximize className="icon-img" />}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<button className="action-btn !hidden sm:!flex ">
|
||||
<Icon.Smile className="icon-img" onClick={handleEmojiPickerBtnClick} />
|
||||
</button>
|
||||
<button className="action-btn">
|
||||
<Icon.CheckSquare className="icon-img" onClick={handleCheckBoxBtnClick} />
|
||||
</button>
|
||||
<button className="action-btn">
|
||||
<Icon.Code className="icon-img" onClick={handleCodeBlockBtnClick} />
|
||||
</button>
|
||||
<button className="action-btn">
|
||||
<Icon.FileText className="icon-img" onClick={handleUploadFileBtnClick} />
|
||||
<span className={`tip-text ${state.isUploadingResource ? "!block" : ""}`}>Uploading</span>
|
||||
</button>
|
||||
<button className="action-btn" onClick={handleFullscreenBtnClick}>
|
||||
{state.fullscreen ? <Icon.Minimize className="icon-img" /> : <Icon.Maximize className="icon-img" />}
|
||||
</button>
|
||||
<EmojiPicker
|
||||
shouldShow={state.shouldShowEmojiPicker}
|
||||
onEmojiClick={handleEmojiClick}
|
||||
onShouldShowEmojiPickerChange={handleChangeShouldShowEmojiPicker}
|
||||
/>
|
||||
</div>
|
||||
<div className="btns-container">
|
||||
<button className="action-btn confirm-btn" disabled={!allowSave || state.isUploadingResource} onClick={handleSaveBtnClick}>
|
||||
{t("editor.save")}
|
||||
<img className="icon-img" src="/logo.webp" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{state.resourceList.length > 0 && (
|
||||
<div className="resource-list-wrapper">
|
||||
{state.resourceList.map((resource) => {
|
||||
return (
|
||||
<div key={resource.id} className="resource-container">
|
||||
{resource.type.includes("image") ? <Icon.Image className="icon-img" /> : <Icon.FileText className="icon-img" />}
|
||||
<span className="name-text">{resource.filename}</span>
|
||||
<Icon.X className="close-icon" onClick={() => handleDeleteResource(resource.id)} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function getEditorContentCache(): string {
|
||||
return storage.get(["editorContentCache"]).editorContentCache ?? "";
|
||||
}
|
||||
|
||||
function setEditorContentCache(content: string) {
|
||||
storage.set({
|
||||
editorContentCache: content,
|
||||
});
|
||||
}
|
||||
|
||||
export default MemoEditor;
|
||||
|
||||
@@ -1,44 +1,44 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAppSelector } from "../store";
|
||||
import { locationService, shortcutService } from "../services";
|
||||
import * as utils from "../helpers/utils";
|
||||
import { getTextWithMemoType } from "../helpers/filter";
|
||||
import Icon from "./Icon";
|
||||
import "../less/memo-filter.less";
|
||||
|
||||
interface FilterProps {}
|
||||
|
||||
const MemoFilter: React.FC<FilterProps> = () => {
|
||||
const MemoFilter = () => {
|
||||
const { t } = useTranslation();
|
||||
const query = useAppSelector((state) => state.location.query);
|
||||
useAppSelector((state) => state.shortcut.shortcuts);
|
||||
const { tag: tagQuery, duration, type: memoType, text: textQuery, shortcutId } = query;
|
||||
const shortcut = shortcutId ? shortcutService.getShortcutById(shortcutId) : null;
|
||||
const showFilter = Boolean(tagQuery || (duration && duration.from < duration.to) || memoType || textQuery || shortcut);
|
||||
|
||||
return (
|
||||
<div className={`filter-query-container ${showFilter ? "" : "!hidden"}`}>
|
||||
<span className="tip-text">Filter:</span>
|
||||
<span className="tip-text">{t("common.filter")}:</span>
|
||||
<div
|
||||
className={"filter-item-container " + (shortcut ? "" : "hidden")}
|
||||
className={"filter-item-container " + (shortcut ? "" : "!hidden")}
|
||||
onClick={() => {
|
||||
locationService.setMemoShortcut(undefined);
|
||||
}}
|
||||
>
|
||||
<span className="icon-text">🎯</span> {shortcut?.title}
|
||||
<Icon.Target className="icon-text" /> {shortcut?.title}
|
||||
</div>
|
||||
<div
|
||||
className={"filter-item-container " + (tagQuery ? "" : "hidden")}
|
||||
className={"filter-item-container " + (tagQuery ? "" : "!hidden")}
|
||||
onClick={() => {
|
||||
locationService.setTagQuery(undefined);
|
||||
}}
|
||||
>
|
||||
<span className="icon-text">🏷️</span> {tagQuery}
|
||||
<Icon.Tag className="icon-text" /> {tagQuery}
|
||||
</div>
|
||||
<div
|
||||
className={"filter-item-container " + (memoType ? "" : "hidden")}
|
||||
className={"filter-item-container " + (memoType ? "" : "!hidden")}
|
||||
onClick={() => {
|
||||
locationService.setMemoTypeQuery(undefined);
|
||||
}}
|
||||
>
|
||||
<span className="icon-text">📦</span> {getTextWithMemoType(memoType as MemoSpecType)}
|
||||
<Icon.Box className="icon-text" /> {t(getTextWithMemoType(memoType as MemoSpecType))}
|
||||
</div>
|
||||
{duration && duration.from < duration.to ? (
|
||||
<div
|
||||
@@ -47,16 +47,16 @@ const MemoFilter: React.FC<FilterProps> = () => {
|
||||
locationService.setFromAndToQuery();
|
||||
}}
|
||||
>
|
||||
<span className="icon-text">🗓️</span> {utils.getDateString(duration.from)} to {utils.getDateString(duration.to)}
|
||||
<Icon.Calendar className="icon-text" /> {utils.getDateString(duration.from)} to {utils.getDateString(duration.to)}
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
className={"filter-item-container " + (textQuery ? "" : "hidden")}
|
||||
className={"filter-item-container " + (textQuery ? "" : "!hidden")}
|
||||
onClick={() => {
|
||||
locationService.setTextQuery("");
|
||||
}}
|
||||
>
|
||||
<span className="icon-text">🔍</span> {textQuery}
|
||||
<Icon.Search className="icon-text" /> {textQuery}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,22 +1,21 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { memoService, shortcutService } from "../services";
|
||||
import useI18n from "../hooks/useI18n";
|
||||
import { DEFAULT_MEMO_LIMIT } from "../services/memoService";
|
||||
import { useAppSelector } from "../store";
|
||||
import { IMAGE_URL_REG, LINK_URL_REG, MEMO_LINK_REG, TAG_REG } from "../helpers/marked";
|
||||
import { TAG_REG, LINK_REG } from "../labs/marked/parser";
|
||||
import * as utils from "../helpers/utils";
|
||||
import { checkShouldShowMemoWithFilters } from "../helpers/filter";
|
||||
import toastHelper from "./Toast";
|
||||
import Only from "./common/OnlyWhen";
|
||||
import Memo from "./Memo";
|
||||
import "../less/memo-list.less";
|
||||
|
||||
interface Props {}
|
||||
|
||||
const MemoList: React.FC<Props> = () => {
|
||||
const { t } = useI18n();
|
||||
const MemoList = () => {
|
||||
const { t } = useTranslation();
|
||||
const query = useAppSelector((state) => state.location.query);
|
||||
const memoDisplayTsOption = useAppSelector((state) => state.user.user?.setting.memoDisplayTsOption);
|
||||
const { memos, isFetching } = useAppSelector((state) => state.memo);
|
||||
const wrapperElement = useRef<HTMLDivElement>(null);
|
||||
const [isComplete, setIsComplete] = useState<boolean>(false);
|
||||
|
||||
const { tag: tagQuery, duration, type: memoType, text: textQuery, shortcutId } = query ?? {};
|
||||
const shortcut = shortcutId ? shortcutService.getShortcutById(shortcutId) : null;
|
||||
@@ -33,10 +32,9 @@ const MemoList: React.FC<Props> = () => {
|
||||
shouldShow = checkShouldShowMemoWithFilters(memo, filters);
|
||||
}
|
||||
}
|
||||
|
||||
if (tagQuery) {
|
||||
const tagsSet = new Set<string>();
|
||||
for (const t of Array.from(memo.content.match(TAG_REG) ?? [])) {
|
||||
for (const t of Array.from(memo.content.match(new RegExp(TAG_REG, "g")) ?? [])) {
|
||||
const tag = t.replace(TAG_REG, "$1").trim();
|
||||
const items = tag.split("/");
|
||||
let temp = "";
|
||||
@@ -53,22 +51,18 @@ const MemoList: React.FC<Props> = () => {
|
||||
if (
|
||||
duration &&
|
||||
duration.from < duration.to &&
|
||||
(utils.getTimeStampByDate(memo.createdTs) < duration.from || utils.getTimeStampByDate(memo.createdTs) > duration.to)
|
||||
(utils.getTimeStampByDate(memo.displayTs) < duration.from || utils.getTimeStampByDate(memo.displayTs) > duration.to)
|
||||
) {
|
||||
shouldShow = false;
|
||||
}
|
||||
if (memoType) {
|
||||
if (memoType === "NOT_TAGGED" && memo.content.match(TAG_REG) !== null) {
|
||||
shouldShow = false;
|
||||
} else if (memoType === "LINKED" && memo.content.match(LINK_URL_REG) === null) {
|
||||
shouldShow = false;
|
||||
} else if (memoType === "IMAGED" && memo.content.match(IMAGE_URL_REG) === null) {
|
||||
shouldShow = false;
|
||||
} else if (memoType === "CONNECTED" && memo.content.match(MEMO_LINK_REG) === null) {
|
||||
} else if (memoType === "LINKED" && memo.content.match(LINK_REG) === null) {
|
||||
shouldShow = false;
|
||||
}
|
||||
}
|
||||
if (textQuery && !memo.content.includes(textQuery)) {
|
||||
if (textQuery && !memo.content.toLowerCase().includes(textQuery.toLowerCase())) {
|
||||
shouldShow = false;
|
||||
}
|
||||
|
||||
@@ -78,41 +72,87 @@ const MemoList: React.FC<Props> = () => {
|
||||
|
||||
const pinnedMemos = shownMemos.filter((m) => m.pinned);
|
||||
const unpinnedMemos = shownMemos.filter((m) => !m.pinned);
|
||||
const memoSort = (mi: Memo, mj: Memo) => {
|
||||
return mj.displayTs - mi.displayTs;
|
||||
};
|
||||
pinnedMemos.sort(memoSort);
|
||||
unpinnedMemos.sort(memoSort);
|
||||
const sortedMemos = pinnedMemos.concat(unpinnedMemos).filter((m) => m.rowStatus === "NORMAL");
|
||||
|
||||
useEffect(() => {
|
||||
memoService
|
||||
.fetchAllMemos()
|
||||
.then(() => {
|
||||
// do nth
|
||||
.fetchMemos()
|
||||
.then((fetchedMemos) => {
|
||||
if (fetchedMemos.length < DEFAULT_MEMO_LIMIT) {
|
||||
setIsComplete(true);
|
||||
} else {
|
||||
setIsComplete(false);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
toastHelper.error(error.response.data.message);
|
||||
});
|
||||
}, []);
|
||||
}, [memoDisplayTsOption]);
|
||||
|
||||
useEffect(() => {
|
||||
wrapperElement.current?.scrollTo({
|
||||
top: 0,
|
||||
});
|
||||
const pageWrapper = document.body.querySelector(".page-wrapper");
|
||||
if (pageWrapper) {
|
||||
pageWrapper.scrollTo(0, 0);
|
||||
}
|
||||
}, [query]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isFetching || isComplete) {
|
||||
return;
|
||||
}
|
||||
if (sortedMemos.length < DEFAULT_MEMO_LIMIT) {
|
||||
handleFetchMoreClick();
|
||||
}
|
||||
}, [isFetching, isComplete, query, sortedMemos.length]);
|
||||
|
||||
const handleFetchMoreClick = async () => {
|
||||
try {
|
||||
const fetchedMemos = await memoService.fetchMemos(DEFAULT_MEMO_LIMIT, memos.length);
|
||||
if (fetchedMemos.length < DEFAULT_MEMO_LIMIT) {
|
||||
setIsComplete(true);
|
||||
} else {
|
||||
setIsComplete(false);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
toastHelper.error(error.response.data.message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`memo-list-container ${isFetching ? "" : "completed"}`} ref={wrapperElement}>
|
||||
<Only when={isFetching}>
|
||||
<div className="memo-list-container">
|
||||
{sortedMemos.map((memo) => (
|
||||
<Memo key={`${memo.id}-${memo.displayTs}`} memo={memo} />
|
||||
))}
|
||||
{isFetching ? (
|
||||
<div className="status-text-container fetching-tip">
|
||||
<p className="status-text">{t("memo-list.fetching-data")}</p>
|
||||
</div>
|
||||
</Only>
|
||||
{sortedMemos.map((memo) => (
|
||||
<Memo key={`${memo.id}-${memo.createdTs}-${memo.updatedTs}`} memo={memo} />
|
||||
))}
|
||||
<Only when={!isFetching}>
|
||||
) : (
|
||||
<div className="status-text-container">
|
||||
<p className="status-text">{sortedMemos.length === 0 ? "no memos 🌃" : showMemoFilter ? "" : "all memos are ready 🎉"}</p>
|
||||
<p className="status-text">
|
||||
{isComplete ? (
|
||||
sortedMemos.length === 0 ? (
|
||||
t("message.no-memos")
|
||||
) : (
|
||||
t("message.memos-ready")
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<span className="cursor-pointer hover:text-green-600" onClick={handleFetchMoreClick}>
|
||||
{t("memo-list.fetch-more")}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</Only>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import Image from "./Image";
|
||||
import Icon from "./Icon";
|
||||
import "../less/memo-resources.less";
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
resourceList: Resource[];
|
||||
}
|
||||
|
||||
const MemoResources: React.FC<Props> = (props: Props) => {
|
||||
const { className, resourceList } = props;
|
||||
const imageList = resourceList.filter((resource) => resource.type.includes("image"));
|
||||
const otherResourceList = resourceList.filter((resource) => !resource.type.includes("image"));
|
||||
|
||||
const handlPreviewBtnClick = (resource: Resource) => {
|
||||
const resourceUrl = `${window.location.origin}/o/r/${resource.id}/${resource.filename}`;
|
||||
window.open(resourceUrl);
|
||||
};
|
||||
|
||||
const imgUrls = imageList.map((resource) => {
|
||||
return `/o/r/${resource.id}/${resource.filename}`;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={`resource-wrapper ${className || ""}`}>
|
||||
{imageList.length > 0 && (
|
||||
<div className="images-wrapper">
|
||||
{imageList.map((resource, index) => (
|
||||
<Image className="memo-img" key={resource.id} imgUrls={imgUrls} index={index} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="other-resource-wrapper">
|
||||
{otherResourceList.map((resource) => {
|
||||
return (
|
||||
<div className="other-resource-container" key={resource.id} onClick={() => handlPreviewBtnClick(resource)}>
|
||||
<Icon.FileText className="icon-img" />
|
||||
<span className="name-text">{resource.filename}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MemoResources;
|
||||
@@ -8,9 +8,7 @@ import "../less/memos-header.less";
|
||||
|
||||
let prevRequestTimestamp = Date.now();
|
||||
|
||||
interface Props {}
|
||||
|
||||
const MemosHeader: React.FC<Props> = () => {
|
||||
const MemosHeader = () => {
|
||||
const query = useAppSelector((state) => state.location.query);
|
||||
const shortcuts = useAppSelector((state) => state.shortcut.shortcuts);
|
||||
const [titleText, setTitleText] = useState("MEMOS");
|
||||
@@ -31,7 +29,7 @@ const MemosHeader: React.FC<Props> = () => {
|
||||
const now = Date.now();
|
||||
if (now - prevRequestTimestamp > 1 * 1000) {
|
||||
prevRequestTimestamp = now;
|
||||
memoService.fetchAllMemos().catch(() => {
|
||||
memoService.fetchMemos().catch(() => {
|
||||
// do nth
|
||||
});
|
||||
}
|
||||
@@ -40,7 +38,7 @@ const MemosHeader: React.FC<Props> = () => {
|
||||
return (
|
||||
<div className="section-header-container memos-header-container">
|
||||
<div className="title-container">
|
||||
<div className="action-btn" onClick={toggleSiderbar}>
|
||||
<div className="action-btn" onClick={() => toggleSiderbar(true)}>
|
||||
<Icon.Menu className="icon-img" />
|
||||
</div>
|
||||
<span className="title-text" onClick={handleTitleTextClick}>
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import * as api from "../helpers/api";
|
||||
import { locationService, userService } from "../services";
|
||||
import useI18n from "../hooks/useI18n";
|
||||
import toastHelper from "./Toast";
|
||||
import Only from "./common/OnlyWhen";
|
||||
import showAboutSiteDialog from "./AboutSiteDialog";
|
||||
import "../less/menu-btns-popup.less";
|
||||
|
||||
interface Props {
|
||||
shownStatus: boolean;
|
||||
setShownStatus: (status: boolean) => void;
|
||||
}
|
||||
|
||||
const MenuBtnsPopup: React.FC<Props> = (props: Props) => {
|
||||
const { shownStatus, setShownStatus } = props;
|
||||
const { t } = useI18n();
|
||||
const popupElRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (shownStatus) {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (!popupElRef.current?.contains(event.target as Node)) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
setShownStatus(false);
|
||||
};
|
||||
window.addEventListener("click", handleClickOutside, {
|
||||
capture: true,
|
||||
once: true,
|
||||
});
|
||||
}
|
||||
}, [shownStatus]);
|
||||
|
||||
const handlePingBtnClick = () => {
|
||||
api
|
||||
.getSystemStatus()
|
||||
.then(({ data }) => {
|
||||
const {
|
||||
data: { profile },
|
||||
} = data;
|
||||
toastHelper.info(JSON.stringify(profile, null, 4));
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
toastHelper.error(error.response.data.message);
|
||||
});
|
||||
};
|
||||
|
||||
const handleAboutBtnClick = () => {
|
||||
showAboutSiteDialog();
|
||||
};
|
||||
|
||||
const handleSignOutBtnClick = async () => {
|
||||
userService
|
||||
.doSignOut()
|
||||
.then(() => {
|
||||
locationService.replaceHistory("/auth");
|
||||
window.location.reload();
|
||||
})
|
||||
.catch(() => {
|
||||
// do nth
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`menu-btns-popup ${shownStatus ? "" : "hidden"}`} ref={popupElRef}>
|
||||
<button className="btn action-btn" onClick={handleAboutBtnClick}>
|
||||
<span className="icon">🤠</span> {t("common.about")}
|
||||
</button>
|
||||
<button className="btn action-btn" onClick={handlePingBtnClick}>
|
||||
<span className="icon">🎯</span> Ping
|
||||
</button>
|
||||
<Only when={!userService.isVisitorMode()}>
|
||||
<button className="btn action-btn" onClick={handleSignOutBtnClick}>
|
||||
<span className="icon">👋</span> {t("common.sign-out")}
|
||||
</button>
|
||||
</Only>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MenuBtnsPopup;
|
||||
@@ -1,26 +1,42 @@
|
||||
import { useState } from "react";
|
||||
import * as utils from "../helpers/utils";
|
||||
import Icon from "./Icon";
|
||||
import { generateDialog } from "./Dialog";
|
||||
import "../less/preview-image-dialog.less";
|
||||
|
||||
interface Props extends DialogProps {
|
||||
imgUrl: string;
|
||||
imgUrls: string[];
|
||||
initialIndex: number;
|
||||
}
|
||||
|
||||
const PreviewImageDialog: React.FC<Props> = ({ destroy, imgUrl }: Props) => {
|
||||
const PreviewImageDialog: React.FC<Props> = ({ destroy, imgUrls, initialIndex }: Props) => {
|
||||
const [currentIndex, setCurrentIndex] = useState(initialIndex);
|
||||
|
||||
const handleCloseBtnClick = () => {
|
||||
destroy();
|
||||
};
|
||||
|
||||
const handleDownloadBtnClick = () => {
|
||||
const a = document.createElement("a");
|
||||
a.href = imgUrl;
|
||||
a.href = imgUrls[currentIndex];
|
||||
a.download = `memos-${utils.getDateTimeString(Date.now())}.png`;
|
||||
a.click();
|
||||
};
|
||||
|
||||
const handleImgContainerClick = () => {
|
||||
destroy();
|
||||
const handleImgContainerClick = (event: React.MouseEvent) => {
|
||||
if (event.clientX < window.innerWidth / 2) {
|
||||
if (currentIndex > 0) {
|
||||
setCurrentIndex(currentIndex - 1);
|
||||
} else {
|
||||
destroy();
|
||||
}
|
||||
} else {
|
||||
if (currentIndex < imgUrls.length - 1) {
|
||||
setCurrentIndex(currentIndex + 1);
|
||||
} else {
|
||||
destroy();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -34,18 +50,21 @@ const PreviewImageDialog: React.FC<Props> = ({ destroy, imgUrl }: Props) => {
|
||||
</button>
|
||||
</div>
|
||||
<div className="img-container" onClick={handleImgContainerClick}>
|
||||
<img onClick={(e) => e.stopPropagation()} src={imgUrl} />
|
||||
<img onClick={(e) => e.stopPropagation()} src={imgUrls[currentIndex]} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default function showPreviewImageDialog(imgUrl: string): void {
|
||||
export default function showPreviewImageDialog(imgUrls: string[] | string, initialIndex?: number): void {
|
||||
generateDialog(
|
||||
{
|
||||
className: "preview-image-dialog",
|
||||
},
|
||||
PreviewImageDialog,
|
||||
{ imgUrl }
|
||||
{
|
||||
imgUrls: Array.isArray(imgUrls) ? imgUrls : [imgUrls],
|
||||
initialIndex: initialIndex || 0,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,34 +1,37 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import copy from "copy-to-clipboard";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as utils from "../helpers/utils";
|
||||
import useI18n from "../hooks/useI18n";
|
||||
import useLoading from "../hooks/useLoading";
|
||||
import { resourceService } from "../services";
|
||||
import { useAppSelector } from "../store";
|
||||
import Icon from "./Icon";
|
||||
import toastHelper from "./Toast";
|
||||
import Dropdown from "./common/Dropdown";
|
||||
import { generateDialog } from "./Dialog";
|
||||
import { showCommonDialog } from "./Dialog/CommonDialog";
|
||||
import showPreviewImageDialog from "./PreviewImageDialog";
|
||||
import Icon from "./Icon";
|
||||
import toastHelper from "./Toast";
|
||||
import showChangeResourceFilenameDialog from "./ChangeResourceFilenameDialog";
|
||||
import "../less/resources-dialog.less";
|
||||
|
||||
interface Props extends DialogProps {}
|
||||
type Props = DialogProps;
|
||||
|
||||
interface State {
|
||||
resources: Resource[];
|
||||
isUploadingResource: boolean;
|
||||
}
|
||||
|
||||
const ResourcesDialog: React.FC<Props> = (props: Props) => {
|
||||
const { destroy } = props;
|
||||
const { t } = useI18n();
|
||||
const { t } = useTranslation();
|
||||
const loadingState = useLoading();
|
||||
const { resources } = useAppSelector((state) => state.resource);
|
||||
const [state, setState] = useState<State>({
|
||||
resources: [],
|
||||
isUploadingResource: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchResources()
|
||||
resourceService
|
||||
.fetchResourceList()
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
toastHelper.error(error.response.data.message);
|
||||
@@ -38,14 +41,6 @@ const ResourcesDialog: React.FC<Props> = (props: Props) => {
|
||||
});
|
||||
}, []);
|
||||
|
||||
const fetchResources = async () => {
|
||||
const data = await resourceService.getResourceList();
|
||||
setState({
|
||||
...state,
|
||||
resources: data,
|
||||
});
|
||||
};
|
||||
|
||||
const handleUploadFileBtnClick = async () => {
|
||||
if (state.isUploadingResource) {
|
||||
return;
|
||||
@@ -58,7 +53,7 @@ const ResourcesDialog: React.FC<Props> = (props: Props) => {
|
||||
document.body.appendChild(inputEl);
|
||||
inputEl.type = "file";
|
||||
inputEl.multiple = true;
|
||||
inputEl.accept = "image/*";
|
||||
inputEl.accept = "*";
|
||||
inputEl.onchange = async () => {
|
||||
if (!inputEl.files || inputEl.files.length === 0) {
|
||||
return;
|
||||
@@ -70,7 +65,6 @@ const ResourcesDialog: React.FC<Props> = (props: Props) => {
|
||||
});
|
||||
|
||||
for (const file of inputEl.files) {
|
||||
await resourceService.upload(file);
|
||||
try {
|
||||
await resourceService.upload(file);
|
||||
} catch (error: any) {
|
||||
@@ -84,33 +78,91 @@ const ResourcesDialog: React.FC<Props> = (props: Props) => {
|
||||
}
|
||||
}
|
||||
|
||||
await fetchResources();
|
||||
document.body.removeChild(inputEl);
|
||||
};
|
||||
inputEl.click();
|
||||
document.body.removeChild(inputEl);
|
||||
};
|
||||
|
||||
const handlPreviewBtnClick = (resource: Resource) => {
|
||||
showPreviewImageDialog(`${window.location.origin}/h/r/${resource.id}/${resource.filename}`);
|
||||
const getResouceUrl = useCallback((resource: Resource) => {
|
||||
return `${window.location.origin}/o/r/${resource.id}/${resource.filename}`;
|
||||
}, []);
|
||||
|
||||
const handlePreviewBtnClick = (resource: Resource) => {
|
||||
const resourceUrl = getResouceUrl(resource);
|
||||
if (resource.type.startsWith("image")) {
|
||||
showPreviewImageDialog(
|
||||
resources.filter((r) => r.type.startsWith("image")).map((r) => getResouceUrl(r)),
|
||||
resources.findIndex((r) => r.id === resource.id)
|
||||
);
|
||||
} else {
|
||||
window.open(resourceUrl);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRenameBtnClick = (resource: Resource) => {
|
||||
showChangeResourceFilenameDialog(resource.id, resource.filename);
|
||||
};
|
||||
|
||||
const handleCopyResourceLinkBtnClick = (resource: Resource) => {
|
||||
utils.copyTextToClipboard(`${window.location.origin}/h/r/${resource.id}/${resource.filename}`);
|
||||
copy(`${window.location.origin}/o/r/${resource.id}/${resource.filename}`);
|
||||
toastHelper.success("Succeed to copy resource link to clipboard");
|
||||
};
|
||||
|
||||
const handleDeleteResourceBtnClick = (resource: Resource) => {
|
||||
const handleDeleteUnusedResourcesBtnClick = () => {
|
||||
let warningText = t("resources.warning-text-unused");
|
||||
const unusedResources = resources.filter((resource) => {
|
||||
if (resource.linkedMemoAmount === 0) {
|
||||
warningText = warningText + `\n- ${resource.filename}`;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (unusedResources.length === 0) {
|
||||
toastHelper.success(t("resources.no-unused-resources"));
|
||||
return;
|
||||
}
|
||||
showCommonDialog({
|
||||
title: `Delete Resource`,
|
||||
content: `Are you sure to delete this resource? THIS ACTION IS IRREVERSIABLE.❗️`,
|
||||
title: t("resources.delete-resource"),
|
||||
content: warningText,
|
||||
style: "warning",
|
||||
onConfirm: async () => {
|
||||
await resourceService.deleteResourceById(resource.id);
|
||||
await fetchResources();
|
||||
for (const resource of unusedResources) {
|
||||
await resourceService.deleteResourceById(resource.id);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteResourceBtnClick = (resource: Resource) => {
|
||||
let warningText = t("resources.warning-text");
|
||||
if (resource.linkedMemoAmount > 0) {
|
||||
warningText = warningText + `\n${t("resources.linked-amount")}: ${resource.linkedMemoAmount}`;
|
||||
}
|
||||
|
||||
showCommonDialog({
|
||||
title: t("resources.delete-resource"),
|
||||
content: warningText,
|
||||
style: "warning",
|
||||
onConfirm: async () => {
|
||||
await resourceService.deleteResourceById(resource.id);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleResourceNameOrTypeMouseEnter = useCallback((event: React.MouseEvent, nameOrType: string) => {
|
||||
const tempDiv = document.createElement("div");
|
||||
tempDiv.className = "usage-detail-container pop-up";
|
||||
const bounding = utils.getElementBounding(event.target as HTMLElement);
|
||||
tempDiv.style.left = bounding.left + "px";
|
||||
tempDiv.style.top = bounding.top - 2 + "px";
|
||||
tempDiv.innerHTML = `<span>${nameOrType}</span>`;
|
||||
document.body.appendChild(tempDiv);
|
||||
}, []);
|
||||
|
||||
const handleResourceNameOrTypeMouseLeave = useCallback(() => {
|
||||
document.body.querySelectorAll("div.usage-detail-container.pop-up").forEach((node) => node.remove());
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="dialog-header-container">
|
||||
@@ -123,41 +175,77 @@ const ResourcesDialog: React.FC<Props> = (props: Props) => {
|
||||
</button>
|
||||
</div>
|
||||
<div className="dialog-content-container">
|
||||
<div className="tip-text-container">(👨💻WIP) View your static resources in memos. e.g. images</div>
|
||||
<div className="upload-resource-container" onClick={() => handleUploadFileBtnClick()}>
|
||||
<div className="upload-resource-btn">
|
||||
<Icon.File className="icon-img" />
|
||||
<span>Upload</span>
|
||||
<div className="action-buttons-container">
|
||||
<div className="buttons-wrapper">
|
||||
<div className="upload-resource-btn" onClick={() => handleUploadFileBtnClick()}>
|
||||
<Icon.File className="icon-img" />
|
||||
<span>{t("resources.upload")}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="buttons-wrapper">
|
||||
<div className="delete-unused-resource-btn" onClick={handleDeleteUnusedResourcesBtnClick}>
|
||||
<Icon.Trash2 className="icon-img" />
|
||||
<span>{t("resources.clear-unused-resources")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{loadingState.isLoading ? (
|
||||
<div className="loading-text-container">
|
||||
<p className="tip-text">fetching data...</p>
|
||||
<p className="tip-text">{t("resources.fetching-data")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="resource-table-container">
|
||||
<div className="fields-container">
|
||||
<span className="field-text">ID</span>
|
||||
<span className="field-text id-text">ID</span>
|
||||
<span className="field-text name-text">NAME</span>
|
||||
<span className="field-text">TYPE</span>
|
||||
<span></span>
|
||||
</div>
|
||||
{state.resources.length === 0 ? (
|
||||
<p className="tip-text">No resource.</p>
|
||||
{resources.length === 0 ? (
|
||||
<p className="tip-text">{t("resources.no-resources")}</p>
|
||||
) : (
|
||||
state.resources.map((resource) => (
|
||||
resources.map((resource) => (
|
||||
<div key={resource.id} className="resource-container">
|
||||
<span className="field-text">{resource.id}</span>
|
||||
<span className="field-text name-text">{resource.filename}</span>
|
||||
<span className="field-text">{resource.type}</span>
|
||||
<span className="field-text id-text">{resource.id}</span>
|
||||
<span className="field-text name-text">
|
||||
<span
|
||||
onMouseEnter={(e) => handleResourceNameOrTypeMouseEnter(e, resource.filename)}
|
||||
onMouseLeave={handleResourceNameOrTypeMouseLeave}
|
||||
>
|
||||
{resource.filename}
|
||||
</span>
|
||||
</span>
|
||||
<div className="buttons-container">
|
||||
<Dropdown className="actions-dropdown">
|
||||
<button onClick={() => handlPreviewBtnClick(resource)}>Preview</button>
|
||||
<button onClick={() => handleCopyResourceLinkBtnClick(resource)}>Copy Link</button>
|
||||
<button className="delete-btn" onClick={() => handleDeleteResourceBtnClick(resource)}>
|
||||
Delete
|
||||
</button>
|
||||
</Dropdown>
|
||||
<Dropdown
|
||||
actionsClassName="!w-28"
|
||||
actions={
|
||||
<>
|
||||
<button
|
||||
className="w-full text-left text-sm leading-6 py-1 px-3 cursor-pointer rounded hover:bg-gray-100"
|
||||
onClick={() => handlePreviewBtnClick(resource)}
|
||||
>
|
||||
{t("resources.preview")}
|
||||
</button>
|
||||
<button
|
||||
className="w-full text-left text-sm leading-6 py-1 px-3 cursor-pointer rounded hover:bg-gray-100"
|
||||
onClick={() => handleRenameBtnClick(resource)}
|
||||
>
|
||||
{t("resources.rename")}
|
||||
</button>
|
||||
<button
|
||||
className="w-full text-left text-sm leading-6 py-1 px-3 cursor-pointer rounded hover:bg-gray-100"
|
||||
onClick={() => handleCopyResourceLinkBtnClick(resource)}
|
||||
>
|
||||
{t("resources.copy-link")}
|
||||
</button>
|
||||
<button
|
||||
className="w-full text-left text-sm leading-6 py-1 px-3 cursor-pointer rounded text-red-600 hover:bg-gray-100"
|
||||
onClick={() => handleDeleteResourceBtnClick(resource)}
|
||||
>
|
||||
{t("common.delete")}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { locationService } from "../services";
|
||||
import { useAppSelector } from "../store";
|
||||
import { memoSpecialTypes } from "../helpers/filter";
|
||||
import Icon from "./Icon";
|
||||
import "../less/search-bar.less";
|
||||
|
||||
interface Props {}
|
||||
|
||||
const SearchBar: React.FC<Props> = () => {
|
||||
const SearchBar = () => {
|
||||
const { t } = useTranslation();
|
||||
const memoType = useAppSelector((state) => state.location.query?.type);
|
||||
|
||||
const handleMemoTypeItemClick = (type: MemoSpecType | undefined) => {
|
||||
@@ -30,20 +30,20 @@ const SearchBar: React.FC<Props> = () => {
|
||||
</div>
|
||||
<div className="quickly-action-wrapper">
|
||||
<div className="quickly-action-container">
|
||||
<p className="title-text">QUICKLY FILTER</p>
|
||||
<p className="title-text">{t("search.quickly-filter").toUpperCase()}</p>
|
||||
<div className="section-container types-container">
|
||||
<span className="section-text">Type:</span>
|
||||
<span className="section-text">{t("common.type").toUpperCase()}:</span>
|
||||
<div className="values-container">
|
||||
{memoSpecialTypes.map((t, idx) => {
|
||||
{memoSpecialTypes.map((type, idx) => {
|
||||
return (
|
||||
<div key={t.value}>
|
||||
<div key={type.value}>
|
||||
<span
|
||||
className={`type-item ${memoType === t.value ? "selected" : ""}`}
|
||||
className={`type-item ${memoType === type.value ? "selected" : ""}`}
|
||||
onClick={() => {
|
||||
handleMemoTypeItemClick(t.value as MemoSpecType);
|
||||
handleMemoTypeItemClick(type.value as MemoSpecType);
|
||||
}}
|
||||
>
|
||||
{t.text}
|
||||
{t(type.text)}
|
||||
</span>
|
||||
{idx + 1 < memoSpecialTypes.length ? <span className="split-text">/</span> : null}
|
||||
</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user