Compare commits
135 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eea826dd5c | |||
| 3c5b0ea90a | |||
| 15e1037433 | |||
| 5da4c98f05 | |||
| a73ee7aefc | |||
| 6c5bea9caf | |||
| 93ba2f4fab | |||
| 9417797b99 | |||
| 167e5596f2 | |||
| 2a1e34fe03 | |||
| 3de00cf4a8 | |||
| 1d55545e30 | |||
| 9b5a555d1f | |||
| 9c842d0a40 | |||
| 0dc377550f | |||
| 8a91b0ad9d | |||
| 1b50ab5dca | |||
| 6053df050c | |||
| 3517c6181d | |||
| 2e126c71f0 | |||
| 46d7ecca88 | |||
| 48d8c6ee0f | |||
| 5fd3cfdb61 | |||
| 10d710cf03 | |||
| 21702b615a | |||
| d75338b6e9 | |||
| b85af714f5 | |||
| a2b32e0b75 | |||
| 0505598509 | |||
| 91d45d6d46 | |||
| de7058532a | |||
| 7c94db0ca0 | |||
| 1d8603df2b | |||
| 6a8c559e8c | |||
| 7418d2965d | |||
| ac560dfcf9 | |||
| 1afc183458 | |||
| 697d01e306 | |||
| aed137472c | |||
| bdc9632b5b | |||
| 6f32643d7c | |||
| 346d219cd5 | |||
| 6b5d5e757e | |||
| e202d7b8d6 | |||
| 5a20db0bed | |||
| 2136a954f5 | |||
| 0e8d3e6907 | |||
| 592e037f21 | |||
| c6695121f0 | |||
| 17a61bb65f | |||
| 49666ddaf3 | |||
| 29f73f0d25 | |||
| 3f3f6eaee8 | |||
| f743532e57 | |||
| 58f62f88a8 | |||
| 3a837203a5 | |||
| 1a86b3cb5a | |||
| d211f0f474 | |||
| eb80bc7798 | |||
| 3b0346d84c | |||
| 65ade1fc87 | |||
| 5dd6d505cc | |||
| 2fe2b82809 | |||
| 06fc29aecd | |||
| 536627007d | |||
| 3c58953e56 | |||
| 0d317839d2 | |||
| fa9443f121 | |||
| a7425ac558 | |||
| 9611ff7386 | |||
| 87e6277977 | |||
| 0945b14deb | |||
| 1b60180b79 | |||
| bfc6e4dd0f | |||
| 57ce96d23e | |||
| 64f67f4bda | |||
| 5b2e6a568f | |||
| 8cb9675965 | |||
| 011fcc7dd4 | |||
| a62c982a3d | |||
| 08210d55c3 | |||
| 62f0122cd5 | |||
| 8cb3994022 | |||
| cad4db128b | |||
| 4871ebf24e | |||
| 929f621be4 | |||
| 2c8ff2794d | |||
| d1a7527c0d | |||
| 3be5ea34a4 | |||
| 4ce728300b | |||
| 1999260f9d | |||
| ceef257348 | |||
| babeb468c1 | |||
| 85ce72282b | |||
| f80f0f2422 | |||
| 9f81362027 | |||
| cc54be0d1d | |||
| 40680a5e0f | |||
| f849a94dc5 | |||
| 50fee8b0f4 | |||
| efb3fad194 | |||
| 1733ed670c | |||
| cd7000da70 | |||
| b96d78ed19 | |||
| 164873b344 | |||
| 8df0711f80 | |||
| 183ce534b9 | |||
| cc2e5ab6fd | |||
| 5d6df87af0 | |||
| 7b6be96eb3 | |||
| 668276f0df | |||
| d23262856e | |||
| 069c5d6a35 | |||
| 2e5b120986 | |||
| 948b53393a | |||
| b52c16c43f | |||
| a580df5c9f | |||
| c3c2882dc5 | |||
| 0b3c77c79c | |||
| 8e01eb8702 | |||
| c34cbb19bc | |||
| 7b668f17f5 | |||
| c2e5a1a524 | |||
| 2e9152e223 | |||
| 9a3afc56e7 | |||
| 3fc907d446 | |||
| 7b8a825489 | |||
| 3fdc5bb2b1 | |||
| ae87ee6dd0 | |||
| d6680a0bcc | |||
| 304df8674a | |||
| 6fe1db42b5 | |||
| bc22f69ac5 | |||
| 0b50122aac | |||
| 5731fb8912 |
@@ -9,23 +9,29 @@ jobs:
|
||||
build-and-push-dev-image:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v1
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_NEOSMEMO_USERNAME }}
|
||||
username: neosmemo
|
||||
password: ${{ secrets.DOCKER_NEOSMEMO_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
uses: docker/setup-buildx-action@v2
|
||||
with:
|
||||
install: true
|
||||
|
||||
- name: Build and Push
|
||||
id: docker_build
|
||||
uses: docker/build-push-action@v2
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
context: ./
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ secrets.DOCKER_NEOSMEMO_USERNAME }}/memos:dev
|
||||
tags: neosmemo/memos:dev
|
||||
|
||||
@@ -10,7 +10,10 @@ jobs:
|
||||
build-and-push-release-image:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
|
||||
- name: Extract build args
|
||||
# Extract version from branch name
|
||||
@@ -19,20 +22,23 @@ jobs:
|
||||
echo "VERSION=${GITHUB_REF_NAME#release/v}" >> $GITHUB_ENV
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v1
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_NEOSMEMO_USERNAME }}
|
||||
username: neosmemo
|
||||
password: ${{ secrets.DOCKER_NEOSMEMO_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
uses: docker/setup-buildx-action@v2
|
||||
with:
|
||||
install: true
|
||||
|
||||
- name: Build and Push
|
||||
id: docker_build
|
||||
uses: docker/build-push-action@v2
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
context: ./
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ secrets.DOCKER_NEOSMEMO_USERNAME }}/memos:${{ env.VERSION }}
|
||||
tags: neosmemo/memos:latest, neosmemo/memos:${{ env.VERSION }}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
name: build-and-push-test-image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "test/*"
|
||||
|
||||
jobs:
|
||||
build-and-push-dev-image:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: neosmemo
|
||||
password: ${{ secrets.DOCKER_NEOSMEMO_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
with:
|
||||
install: true
|
||||
|
||||
- name: Build and Push
|
||||
id: docker_build
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
context: ./
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: neosmemo/memos:test
|
||||
@@ -1,28 +1,10 @@
|
||||
# Binaries for programs and plugins
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
# Test binary, built with `go test -c`
|
||||
*.test
|
||||
|
||||
# Temp output
|
||||
*.out
|
||||
*.log
|
||||
tmp
|
||||
|
||||
# Air (hot reload) generated
|
||||
.air
|
||||
|
||||
# Frontend asset
|
||||
dist
|
||||
|
||||
# Dev database
|
||||
data
|
||||
web/dist
|
||||
|
||||
# build folder
|
||||
build
|
||||
memos-build
|
||||
|
||||
.DS_Store
|
||||
@@ -1,5 +1,5 @@
|
||||
# Build frontend dist.
|
||||
FROM node:14.18.2-alpine3.14 AS frontend
|
||||
FROM node:16.15.0-alpine AS frontend
|
||||
WORKDIR /frontend-build
|
||||
|
||||
COPY ./web/ .
|
||||
@@ -8,23 +8,24 @@ RUN yarn
|
||||
RUN yarn build
|
||||
|
||||
# Build backend exec file.
|
||||
FROM golang:1.16.12-alpine3.15 AS backend
|
||||
FROM golang:1.18.3-alpine3.16 AS backend
|
||||
WORKDIR /backend-build
|
||||
|
||||
RUN apk update
|
||||
RUN apk --no-cache add gcc musl-dev
|
||||
|
||||
COPY . .
|
||||
COPY --from=frontend /frontend-build/dist ./server/dist
|
||||
|
||||
RUN go build \
|
||||
-o memos \
|
||||
./bin/server/main.go
|
||||
|
||||
# Make workspace with above generated files.
|
||||
FROM alpine:3.14.3 AS monolithic
|
||||
FROM alpine:3.16.0 AS monolithic
|
||||
WORKDIR /usr/local/memos
|
||||
|
||||
COPY --from=backend /backend-build/memos /usr/local/memos/
|
||||
COPY --from=frontend /frontend-build/dist /usr/local/memos/web/dist
|
||||
|
||||
# Directory to store the data, which can be referenced as the mounting point.
|
||||
RUN mkdir -p /var/opt/memos
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2022 Memos
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,39 +1,44 @@
|
||||
<h1 align="center">✍️ Memos</h1>
|
||||
|
||||
<p align="center">An open source, quickly self-hosted alternative to flomo</p>
|
||||
<p align="center">An open source, self-hosted knowledge base that works with a SQLite db file.</p>
|
||||
|
||||
<p align="center">
|
||||
<img alt="GitHub stars" src="https://img.shields.io/github/stars/justmemos/memos" />
|
||||
<img alt="Docker pull" src="https://img.shields.io/docker/pulls/neosmemo/memos.svg" />
|
||||
<img alt="Go report" src="https://goreportcard.com/badge/github.com/justmemos/memos" />
|
||||
<img alt="GitHub stars" src="https://img.shields.io/github/stars/usememos/memos" />
|
||||
<a href="https://hub.docker.com/r/neosmemo/memos"><img alt="Docker pull" src="https://img.shields.io/docker/pulls/neosmemo/memos.svg" /></a>
|
||||
<img alt="Go report" src="https://goreportcard.com/badge/github.com/usememos/memos" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://memos.onrender.com/">Live Demo</a> •
|
||||
<a href="https://github.com/justmemos/memos/discussions">Discussions</a>
|
||||
<a href="https://t.me/+-_tNF1k70UU4ZTc9">Discuss in Telegram 👾</a>
|
||||
</p>
|
||||
|
||||

|
||||
|
||||
## 🎯 Intentions
|
||||
|
||||
- ✍️ Write down the light-card memos very easily;
|
||||
- 🏗️ Build the fragmented knowledge management tool for yourself;
|
||||
- 📒 For noting your 📅 daily/weekly plans, 💡 fantastic ideas, 📕 reading thoughts...
|
||||

|
||||
|
||||
## ✨ Features
|
||||
|
||||
- 🦄 Fully open source;
|
||||
- 🤠 Great UI and never miss any detail;
|
||||
- 🚀 Super quick self-hosted with `Docker` and `SQLite`;
|
||||
- 📜 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;
|
||||
|
||||
## ⚓️ Deploy with Docker
|
||||
|
||||
```docker
|
||||
docker run --name memos --publish 5230:8080 --volume ~/.memos/:/var/opt/memos -e mode=prod neosmemo/memos:0.0.1
|
||||
docker run \
|
||||
--name memos \
|
||||
--publish 5230:5230 \
|
||||
--volume ~/.memos/:/var/opt/memos \
|
||||
neosmemo/memos:latest \
|
||||
--mode prod \
|
||||
--port 5230
|
||||
```
|
||||
|
||||
If the `~/.memos/` does not have a `memos_prod.db` file, then `memos` will auto generate it.
|
||||
Memos should now be running at [http://localhost:5230](http://localhost:5230). If the `~/.memos/` does not have a `memos_prod.db` file, then `memos` will auto generate it.
|
||||
|
||||
⚠️ Please DO NOT use `dev` tag of docker image if you have no experience.
|
||||
|
||||
## 🏗 Development
|
||||
|
||||
@@ -45,7 +50,7 @@ Memos is built with a curated tech stack. It is optimized for developer experien
|
||||
|
||||
### Tech Stack
|
||||
|
||||
<img alt="tech stack" src="https://raw.githubusercontent.com/justmemos/memos/main/resources/tech-stack.png" width="360" />
|
||||
<img alt="tech stack" src="https://raw.githubusercontent.com/usememos/memos/main/resources/tech-stack.png" width="360" />
|
||||
|
||||
### Prerequisites
|
||||
|
||||
@@ -58,7 +63,7 @@ Memos is built with a curated tech stack. It is optimized for developer experien
|
||||
1. pull source code
|
||||
|
||||
```bash
|
||||
git clone https://github.com/justmemos/memos
|
||||
git clone https://github.com/usememos/memos
|
||||
```
|
||||
|
||||
2. start backend using air(with live reload)
|
||||
@@ -73,11 +78,11 @@ Memos is built with a curated tech stack. It is optimized for developer experien
|
||||
cd web && yarn && yarn dev
|
||||
```
|
||||
|
||||
Memos should now be running at https://localhost:3000 and change either frontend or backend code would trigger live reload.
|
||||
Memos should now be running at [http://localhost:3000](http://localhost:3000) and change either frontend or backend code would trigger live reload.
|
||||
|
||||
## 🌟 Star history
|
||||
|
||||
[](https://star-history.com/#justmemos/memos&Date)
|
||||
[](https://star-history.com/#usememos/memos&Date)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package api
|
||||
|
||||
// RowStatus is the status for a row.
|
||||
type RowStatus string
|
||||
|
||||
const (
|
||||
// Normal is the status for a normal row.
|
||||
Normal RowStatus = "NORMAL"
|
||||
// Archived is the status for an archived row.
|
||||
Archived RowStatus = "ARCHIVED"
|
||||
)
|
||||
|
||||
func (e RowStatus) String() string {
|
||||
switch e {
|
||||
case Normal:
|
||||
return "NORMAL"
|
||||
case Archived:
|
||||
return "ARCHIVED"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
package api
|
||||
|
||||
type Login struct {
|
||||
type Signin struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
@@ -1,16 +1,38 @@
|
||||
package api
|
||||
|
||||
// Visibility is the type of a visibility.
|
||||
type Visibility string
|
||||
|
||||
const (
|
||||
// Public is the PUBLIC visibility.
|
||||
Public Visibility = "PUBLIC"
|
||||
// Privite is the PRIVATE visibility.
|
||||
Privite Visibility = "PRIVATE"
|
||||
)
|
||||
|
||||
func (e Visibility) String() string {
|
||||
switch e {
|
||||
case Public:
|
||||
return "PUBLIC"
|
||||
case Privite:
|
||||
return "PRIVATE"
|
||||
}
|
||||
return "PRIVATE"
|
||||
}
|
||||
|
||||
type Memo struct {
|
||||
ID int `json:"id"`
|
||||
|
||||
// Standard fields
|
||||
CreatedTs int64 `json:"createdTs"`
|
||||
UpdatedTs int64 `json:"updatedTs"`
|
||||
RowStatus string `json:"rowStatus"`
|
||||
RowStatus RowStatus `json:"rowStatus"`
|
||||
CreatorID int `json:"creatorId"`
|
||||
CreatedTs int64 `json:"createdTs"`
|
||||
UpdatedTs int64 `json:"updatedTs"`
|
||||
|
||||
// Domain specific fields
|
||||
Content string `json:"content"`
|
||||
CreatorID int `json:"creatorId"`
|
||||
Content string `json:"content"`
|
||||
Visibility Visibility `json:"visibility"`
|
||||
Pinned bool `json:"pinned"`
|
||||
}
|
||||
|
||||
type MemoCreate struct {
|
||||
@@ -20,25 +42,36 @@ type MemoCreate struct {
|
||||
CreatedTs *int64 `json:"createdTs"`
|
||||
|
||||
// Domain specific fields
|
||||
Content string `json:"content"`
|
||||
Content string `json:"content"`
|
||||
Visibility *Visibility `json:"visibility"`
|
||||
}
|
||||
|
||||
type MemoPatch struct {
|
||||
ID int
|
||||
|
||||
// Standard fields
|
||||
RowStatus *string `json:"rowStatus"`
|
||||
RowStatus *RowStatus `json:"rowStatus"`
|
||||
|
||||
// Domain specific fields
|
||||
Content *string `json:"content"`
|
||||
Content *string `json:"content"`
|
||||
Visibility *Visibility `json:"visibility"`
|
||||
}
|
||||
|
||||
type MemoFind struct {
|
||||
ID *int `json:"id"`
|
||||
|
||||
// Standard fields
|
||||
CreatorID *int `json:"creatorId"`
|
||||
RowStatus *string `json:"rowStatus"`
|
||||
RowStatus *RowStatus `json:"rowStatus"`
|
||||
CreatorID *int `json:"creatorId"`
|
||||
|
||||
// Domain specific fields
|
||||
Pinned *bool
|
||||
ContentSearch *string
|
||||
Visibility *Visibility
|
||||
|
||||
// Pagination
|
||||
Limit int
|
||||
Offset int
|
||||
}
|
||||
|
||||
type MemoDelete struct {
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package api
|
||||
|
||||
type MemoOrganizer struct {
|
||||
ID int
|
||||
|
||||
// Domain specific fields
|
||||
MemoID int
|
||||
UserID int
|
||||
Pinned bool
|
||||
}
|
||||
|
||||
type MemoOrganizerFind struct {
|
||||
MemoID int
|
||||
UserID int
|
||||
}
|
||||
|
||||
type MemoOrganizerUpsert struct {
|
||||
MemoID int
|
||||
UserID int
|
||||
Pinned bool `json:"pinned"`
|
||||
}
|
||||
@@ -4,10 +4,10 @@ type Shortcut struct {
|
||||
ID int `json:"id"`
|
||||
|
||||
// Standard fields
|
||||
CreatorID int
|
||||
CreatedTs int64 `json:"createdTs"`
|
||||
UpdatedTs int64 `json:"updatedTs"`
|
||||
RowStatus string `json:"rowStatus"`
|
||||
RowStatus RowStatus `json:"rowStatus"`
|
||||
CreatorID int `json:"creatorId"`
|
||||
CreatedTs int64 `json:"createdTs"`
|
||||
UpdatedTs int64 `json:"updatedTs"`
|
||||
|
||||
// Domain specific fields
|
||||
Title string `json:"title"`
|
||||
@@ -27,7 +27,7 @@ type ShortcutPatch struct {
|
||||
ID int
|
||||
|
||||
// Standard fields
|
||||
RowStatus *string `json:"rowStatus"`
|
||||
RowStatus *RowStatus `json:"rowStatus"`
|
||||
|
||||
// Domain specific fields
|
||||
Title *string `json:"title"`
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
package api
|
||||
|
||||
import "memos/common"
|
||||
import "github.com/usememos/memos/server/profile"
|
||||
|
||||
type SystemStatus struct {
|
||||
Owner *User `json:"owner"`
|
||||
Profile *common.Profile `json:"profile"`
|
||||
Host *User `json:"host"`
|
||||
Profile *profile.Profile `json:"profile"`
|
||||
}
|
||||
|
||||
@@ -4,18 +4,29 @@ package api
|
||||
type Role string
|
||||
|
||||
const (
|
||||
// Owner is the OWNER role.
|
||||
Owner Role = "OWNER"
|
||||
// Host is the HOST role.
|
||||
Host Role = "HOST"
|
||||
// NormalUser is the USER role.
|
||||
NormalUser Role = "USER"
|
||||
)
|
||||
|
||||
func (e Role) String() string {
|
||||
switch e {
|
||||
case Host:
|
||||
return "HOST"
|
||||
case NormalUser:
|
||||
return "USER"
|
||||
}
|
||||
return "USER"
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID int `json:"id"`
|
||||
|
||||
// Standard fields
|
||||
CreatedTs int64 `json:"createdTs"`
|
||||
UpdatedTs int64 `json:"updatedTs"`
|
||||
RowStatus RowStatus `json:"rowStatus"`
|
||||
CreatedTs int64 `json:"createdTs"`
|
||||
UpdatedTs int64 `json:"updatedTs"`
|
||||
|
||||
// Domain specific fields
|
||||
Email string `json:"email"`
|
||||
@@ -38,6 +49,9 @@ type UserCreate struct {
|
||||
type UserPatch struct {
|
||||
ID int
|
||||
|
||||
// Standard fields
|
||||
RowStatus *RowStatus `json:"rowStatus"`
|
||||
|
||||
// Domain specific fields
|
||||
Email *string `json:"email"`
|
||||
Name *string `json:"name"`
|
||||
@@ -50,6 +64,9 @@ type UserPatch struct {
|
||||
type UserFind struct {
|
||||
ID *int `json:"id"`
|
||||
|
||||
// Standard fields
|
||||
RowStatus *RowStatus `json:"rowStatus"`
|
||||
|
||||
// Domain specific fields
|
||||
Email *string `json:"email"`
|
||||
Role *Role
|
||||
|
||||
@@ -4,42 +4,60 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"memos/common"
|
||||
"memos/server"
|
||||
"memos/store"
|
||||
"github.com/usememos/memos/server"
|
||||
"github.com/usememos/memos/server/profile"
|
||||
"github.com/usememos/memos/store"
|
||||
DB "github.com/usememos/memos/store/db"
|
||||
)
|
||||
|
||||
const (
|
||||
greetingBanner = `
|
||||
███╗ ███╗███████╗███╗ ███╗ ██████╗ ███████╗
|
||||
████╗ ████║██╔════╝████╗ ████║██╔═══██╗██╔════╝
|
||||
██╔████╔██║█████╗ ██╔████╔██║██║ ██║███████╗
|
||||
██║╚██╔╝██║██╔══╝ ██║╚██╔╝██║██║ ██║╚════██║
|
||||
██║ ╚═╝ ██║███████╗██║ ╚═╝ ██║╚██████╔╝███████║
|
||||
╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝ ╚═════╝ ╚══════╝
|
||||
`
|
||||
)
|
||||
|
||||
type Main struct {
|
||||
profile *common.Profile
|
||||
profile *profile.Profile
|
||||
}
|
||||
|
||||
func (m *Main) Run() error {
|
||||
db := store.NewDB(m.profile)
|
||||
db := DB.NewDB(m.profile)
|
||||
if err := db.Open(); err != nil {
|
||||
return fmt.Errorf("cannot open db: %w", err)
|
||||
}
|
||||
|
||||
s := server.NewServer(m.profile)
|
||||
|
||||
storeInstance := store.New(db)
|
||||
storeInstance := store.New(db.Db, m.profile)
|
||||
s.Store = storeInstance
|
||||
|
||||
if err := s.Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
println(greetingBanner)
|
||||
fmt.Printf("Version %s has started at :%d\n", m.profile.Version, m.profile.Port)
|
||||
|
||||
return nil
|
||||
return s.Run()
|
||||
}
|
||||
|
||||
func Execute() {
|
||||
profile := common.GetProfile()
|
||||
profile := profile.GetProfile()
|
||||
m := Main{
|
||||
profile: profile,
|
||||
}
|
||||
|
||||
err := m.Run()
|
||||
if err != nil {
|
||||
fmt.Printf("%+v\n", err)
|
||||
println("---")
|
||||
println("profile")
|
||||
println("mode:", profile.Mode)
|
||||
println("port:", profile.Port)
|
||||
println("dsn:", profile.DSN)
|
||||
println("version:", profile.Version)
|
||||
println("---")
|
||||
|
||||
if err := m.Run(); err != nil {
|
||||
fmt.Printf("error: %+v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package main
|
||||
|
||||
import "memos/bin/server/cmd"
|
||||
import "github.com/usememos/memos/bin/server/cmd"
|
||||
|
||||
func main() {
|
||||
cmd.Execute()
|
||||
|
||||
@@ -1,4 +1,61 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Version is the service current released version.
|
||||
var Version = "0.0.1"
|
||||
// Semantic versioning: https://semver.org/
|
||||
var Version = "0.2.2"
|
||||
|
||||
// DevVersion is the service current development version.
|
||||
var DevVersion = "0.2.2"
|
||||
|
||||
func GetCurrentVersion(mode string) string {
|
||||
if mode == "dev" {
|
||||
return DevVersion
|
||||
}
|
||||
return Version
|
||||
}
|
||||
|
||||
func GetMinorVersion(version string) string {
|
||||
versionList := strings.Split(version, ".")
|
||||
if len(versionList) < 3 {
|
||||
return ""
|
||||
}
|
||||
return versionList[0] + "." + versionList[1]
|
||||
}
|
||||
|
||||
// convSemanticVersionToInt converts version string to int.
|
||||
func convSemanticVersionToInt(version string) int {
|
||||
versionList := strings.Split(version, ".")
|
||||
|
||||
if len(versionList) < 3 {
|
||||
return 0
|
||||
}
|
||||
major, err := strconv.Atoi(versionList[0])
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
minor, err := strconv.Atoi(versionList[1])
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
patch, err := strconv.Atoi(versionList[2])
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
return major*10000 + minor*100 + patch
|
||||
}
|
||||
|
||||
// IsVersionGreaterThanOrEqualTo returns true if version is greater than or equal to target.
|
||||
func IsVersionGreaterOrEqualThan(version, target string) bool {
|
||||
return convSemanticVersionToInt(version) >= convSemanticVersionToInt(target)
|
||||
}
|
||||
|
||||
// IsVersionGreaterThan returns true if version is greater than target.
|
||||
func IsVersionGreaterThan(version, target string) bool {
|
||||
return convSemanticVersionToInt(version) > convSemanticVersionToInt(target)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
module memos
|
||||
module github.com/usememos/memos
|
||||
|
||||
go 1.17
|
||||
|
||||
@@ -12,7 +12,7 @@ require (
|
||||
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 // indirect
|
||||
golang.org/x/crypto v0.0.0-20210920023735-84f357641f63
|
||||
golang.org/x/net v0.0.0-20210917221730-978cfadd31cf // indirect
|
||||
golang.org/x/sys v0.0.0-20211103235746-7861aae1554b // indirect
|
||||
golang.org/x/text v0.3.7 // indirect
|
||||
@@ -26,7 +26,7 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/gorilla/securecookie v1.1.1 // indirect
|
||||
github.com/gorilla/securecookie v1.1.1
|
||||
github.com/gorilla/sessions v1.2.1
|
||||
github.com/labstack/echo-contrib v0.12.0
|
||||
)
|
||||
|
||||
|
Before Width: | Height: | Size: 924 KiB |
|
After Width: | Height: | Size: 90 KiB |
@@ -0,0 +1,10 @@
|
||||
# Usage: sh ./scripts/build.sh
|
||||
set -e
|
||||
|
||||
cd "$(dirname "$0")/../"
|
||||
|
||||
echo "Start building..."
|
||||
|
||||
go build -o ./memos-build/memos ./bin/server/main.go
|
||||
|
||||
echo "Build finished"
|
||||
@@ -3,47 +3,49 @@ package server
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"memos/api"
|
||||
"memos/common"
|
||||
"net/http"
|
||||
|
||||
"github.com/usememos/memos/api"
|
||||
"github.com/usememos/memos/common"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func (s *Server) registerAuthRoutes(g *echo.Group) {
|
||||
g.POST("/auth/login", func(c echo.Context) error {
|
||||
login := &api.Login{}
|
||||
if err := json.NewDecoder(c.Request().Body).Decode(login); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted login request").SetInternal(err)
|
||||
g.POST("/auth/signin", func(c echo.Context) error {
|
||||
signin := &api.Signin{}
|
||||
if err := json.NewDecoder(c.Request().Body).Decode(signin); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted signin request").SetInternal(err)
|
||||
}
|
||||
|
||||
userFind := &api.UserFind{
|
||||
Email: &login.Email,
|
||||
Email: &signin.Email,
|
||||
}
|
||||
user, err := s.Store.FindUser(userFind)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to find user by email %s", login.Email)).SetInternal(err)
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to find user by email %s", signin.Email)).SetInternal(err)
|
||||
}
|
||||
if user == nil {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, fmt.Sprintf("User not found with email %s", login.Email))
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, fmt.Sprintf("User not found with email %s", signin.Email))
|
||||
} else if user.RowStatus == api.Archived {
|
||||
return echo.NewHTTPError(http.StatusForbidden, fmt.Sprintf("User has been archived with email %s", signin.Email))
|
||||
}
|
||||
|
||||
// Compare the stored hashed password, with the hashed version of the password that was received.
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(login.Password)); err != nil {
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(signin.Password)); err != nil {
|
||||
// If the two passwords don't match, return a 401 status.
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Incorrect password").SetInternal(err)
|
||||
}
|
||||
|
||||
if err = setUserSession(c, user); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to set login session").SetInternal(err)
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to set signin session").SetInternal(err)
|
||||
}
|
||||
|
||||
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
|
||||
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(user)); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode user response").SetInternal(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
@@ -58,17 +60,17 @@ func (s *Server) registerAuthRoutes(g *echo.Group) {
|
||||
})
|
||||
|
||||
g.POST("/auth/signup", func(c echo.Context) error {
|
||||
// Don't allow to signup by this api if site owner existed.
|
||||
ownerUserType := api.Owner
|
||||
ownerUserFind := api.UserFind{
|
||||
Role: &ownerUserType,
|
||||
// Don't allow to signup by this api if site host existed.
|
||||
hostUserType := api.Host
|
||||
hostUserFind := api.UserFind{
|
||||
Role: &hostUserType,
|
||||
}
|
||||
ownerUser, err := s.Store.FindUser(&ownerUserFind)
|
||||
hostUser, err := s.Store.FindUser(&hostUserFind)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find owner user").SetInternal(err)
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find host user").SetInternal(err)
|
||||
}
|
||||
if ownerUser != nil {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Site Owner existed, please contact the site owner to signin account firstly.").SetInternal(err)
|
||||
if hostUser != nil {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Site Host existed, please contact the site host to signin account firstly.").SetInternal(err)
|
||||
}
|
||||
|
||||
signup := &api.Signup{}
|
||||
@@ -111,7 +113,6 @@ func (s *Server) registerAuthRoutes(g *echo.Group) {
|
||||
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(user)); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode created user response").SetInternal(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,11 +2,12 @@ package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"memos/api"
|
||||
"memos/common"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/usememos/memos/api"
|
||||
"github.com/usememos/memos/common"
|
||||
|
||||
"github.com/gorilla/sessions"
|
||||
"github.com/labstack/echo-contrib/session"
|
||||
"github.com/labstack/echo/v4"
|
||||
@@ -32,7 +33,6 @@ func setUserSession(c echo.Context, user *api.User) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to set session, err: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -48,18 +48,40 @@ func removeUserSession(c echo.Context) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to set session, err: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Use session to store user.id.
|
||||
func BasicAuthMiddleware(s *Server, next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
// Skips auth
|
||||
if common.HasPrefixes(c.Path(), "/api/auth", "/api/ping", "/api/status") {
|
||||
// Skip auth for some paths.
|
||||
if common.HasPrefixes(c.Path(), "/api/auth", "/api/ping", "/api/status", "/api/user/:userId") {
|
||||
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(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 common.HasPrefixes(c.Path(), "/api/memo", "/api/tag", "/api/shortcut") && c.Request().Method == http.MethodGet {
|
||||
if _, err := strconv.Atoi(c.QueryParam("creatorId")); err == nil {
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
|
||||
sess, err := session.Get("session", c)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Missing session").SetInternal(err)
|
||||
@@ -85,6 +107,8 @@ func BasicAuthMiddleware(s *Server, next echo.HandlerFunc) echo.HandlerFunc {
|
||||
}
|
||||
if user == nil {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, fmt.Sprintf("Not found user ID: %d", userID))
|
||||
} else if user.RowStatus == api.Archived {
|
||||
return echo.NewHTTPError(http.StatusForbidden, fmt.Sprintf("User has been archived with email %s", user.Email))
|
||||
}
|
||||
|
||||
// Stores userID into context.
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<!-- THIS FILE IS A PLACEHOLDER AND SHOULD NOT BE CHANGED -->
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Memos</title>
|
||||
</head>
|
||||
<body>
|
||||
<p>No frontend embeded.</p>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,29 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/labstack/echo/v4/middleware"
|
||||
)
|
||||
|
||||
//go:embed dist
|
||||
var embeddedFiles embed.FS
|
||||
|
||||
func getFileSystem() http.FileSystem {
|
||||
fs, err := fs.Sub(embeddedFiles, "dist")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return http.FS(fs)
|
||||
}
|
||||
|
||||
func embedFrontend(e *echo.Echo) {
|
||||
e.Use(middleware.StaticWithConfig(middleware.StaticConfig{
|
||||
HTML5: true,
|
||||
Filesystem: getFileSystem(),
|
||||
}))
|
||||
}
|
||||
@@ -3,11 +3,12 @@ package server
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"memos/api"
|
||||
"memos/common"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/usememos/memos/api"
|
||||
"github.com/usememos/memos/common"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
@@ -21,6 +22,11 @@ func (s *Server) registerMemoRoutes(g *echo.Group) {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post memo request").SetInternal(err)
|
||||
}
|
||||
|
||||
if memoCreate.Visibility == nil || *memoCreate.Visibility == "" {
|
||||
private := api.Privite
|
||||
memoCreate.Visibility = &private
|
||||
}
|
||||
|
||||
memo, err := s.Store.CreateMemo(memoCreate)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create memo").SetInternal(err)
|
||||
@@ -30,7 +36,6 @@ func (s *Server) registerMemoRoutes(g *echo.Group) {
|
||||
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
|
||||
})
|
||||
|
||||
@@ -56,19 +61,50 @@ func (s *Server) registerMemoRoutes(g *echo.Group) {
|
||||
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.GET("/memo", func(c echo.Context) error {
|
||||
userID := c.Get(getUserIDContextKey()).(int)
|
||||
memoFind := &api.MemoFind{
|
||||
CreatorID: &userID,
|
||||
memoFind := &api.MemoFind{}
|
||||
|
||||
if userID, err := strconv.Atoi(c.QueryParam("creatorId")); err == nil {
|
||||
memoFind.CreatorID = &userID
|
||||
} else {
|
||||
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Missing user id to find memo")
|
||||
}
|
||||
|
||||
memoFind.CreatorID = &userID
|
||||
}
|
||||
rowStatus := c.QueryParam("rowStatus")
|
||||
|
||||
// Only can get PUBLIC memos in visitor mode
|
||||
_, ok := c.Get(getUserIDContextKey()).(int)
|
||||
if !ok {
|
||||
publicVisibility := api.Public
|
||||
memoFind.Visibility = &publicVisibility
|
||||
}
|
||||
|
||||
rowStatus := api.RowStatus(c.QueryParam("rowStatus"))
|
||||
if rowStatus != "" {
|
||||
memoFind.RowStatus = &rowStatus
|
||||
}
|
||||
pinnedStr := c.QueryParam("pinned")
|
||||
if pinnedStr != "" {
|
||||
pinned := pinnedStr == "true"
|
||||
memoFind.Pinned = &pinned
|
||||
}
|
||||
tag := c.QueryParam("tag")
|
||||
if tag != "" {
|
||||
contentSearch := "#" + tag + " "
|
||||
memoFind.ContentSearch = &contentSearch
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
list, err := s.Store.FindMemoList(memoFind)
|
||||
if err != nil {
|
||||
@@ -79,7 +115,44 @@ func (s *Server) registerMemoRoutes(g *echo.Group) {
|
||||
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 nil
|
||||
})
|
||||
|
||||
g.POST("/memo/:memoId/organizer", func(c echo.Context) error {
|
||||
memoID, err := strconv.Atoi(c.Param("memoId"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("memoId"))).SetInternal(err)
|
||||
}
|
||||
|
||||
userID := c.Get(getUserIDContextKey()).(int)
|
||||
memoOrganizerUpsert := &api.MemoOrganizerUpsert{
|
||||
MemoID: memoID,
|
||||
UserID: userID,
|
||||
}
|
||||
if err := json.NewDecoder(c.Request().Body).Decode(memoOrganizerUpsert); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post memo organizer request").SetInternal(err)
|
||||
}
|
||||
|
||||
err = s.Store.UpsertMemoOrganizer(memoOrganizerUpsert)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert memo organizer").SetInternal(err)
|
||||
}
|
||||
|
||||
memo, err := s.Store.FindMemo(&api.MemoFind{
|
||||
ID: &memoID,
|
||||
})
|
||||
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
|
||||
})
|
||||
|
||||
@@ -105,7 +178,6 @@ func (s *Server) registerMemoRoutes(g *echo.Group) {
|
||||
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
|
||||
})
|
||||
|
||||
@@ -124,8 +196,26 @@ func (s *Server) registerMemoRoutes(g *echo.Group) {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to delete memo ID: %v", memoID)).SetInternal(err)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, true)
|
||||
return c.JSON(http.StatusOK, true)
|
||||
})
|
||||
|
||||
g.GET("/memo/amount", func(c echo.Context) error {
|
||||
userID := c.Get(getUserIDContextKey()).(int)
|
||||
normalRowStatus := api.Normal
|
||||
memoFind := &api.MemoFind{
|
||||
CreatorID: &userID,
|
||||
RowStatus: &normalRowStatus,
|
||||
}
|
||||
|
||||
memoList, err := s.Store.FindMemoList(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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
package common
|
||||
package profile
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/usememos/memos/common"
|
||||
)
|
||||
|
||||
// Profile is the configuration to start main server.
|
||||
type Profile struct {
|
||||
// Mode can be "prod" or "dev"
|
||||
Mode string `json:"mode"`
|
||||
// Port is the binding port for server
|
||||
Port int `json:"port"`
|
||||
// Data is the data directory
|
||||
Data string `json:"data"`
|
||||
// DSN points to where Memos stores its own data
|
||||
DSN string `json:"dsn"`
|
||||
// Version is the current version of server
|
||||
@@ -33,42 +38,37 @@ func checkDSN(dataDir string) (string, error) {
|
||||
dataDir = strings.TrimRight(dataDir, "/")
|
||||
|
||||
if _, err := os.Stat(dataDir); err != nil {
|
||||
error := fmt.Errorf("unable to access -data %s, err %w", dataDir, err)
|
||||
return "", error
|
||||
return "", fmt.Errorf("unable to access data folder %s, err %w", dataDir, err)
|
||||
}
|
||||
|
||||
return dataDir, nil
|
||||
}
|
||||
|
||||
// GetDevProfile will return a profile for dev.
|
||||
// GetDevProfile will return a profile for dev or prod.
|
||||
func GetProfile() *Profile {
|
||||
mode := os.Getenv("mode")
|
||||
if mode != "dev" && mode != "prod" {
|
||||
mode = "dev"
|
||||
profile := Profile{}
|
||||
flag.StringVar(&profile.Mode, "mode", "dev", "mode of server")
|
||||
flag.IntVar(&profile.Port, "port", 8080, "port of server")
|
||||
flag.StringVar(&profile.Data, "data", "", "data directory")
|
||||
flag.Parse()
|
||||
|
||||
if profile.Mode != "dev" && profile.Mode != "prod" {
|
||||
profile.Mode = "dev"
|
||||
}
|
||||
|
||||
port, err := strconv.Atoi(os.Getenv("port"))
|
||||
if err != nil {
|
||||
port = 8080
|
||||
if profile.Mode == "prod" && profile.Data == "" {
|
||||
profile.Data = "/var/opt/memos"
|
||||
}
|
||||
|
||||
data := ""
|
||||
if mode == "prod" {
|
||||
data = "/var/opt/memos"
|
||||
}
|
||||
|
||||
dataDir, err := checkDSN(data)
|
||||
dataDir, err := checkDSN(profile.Data)
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to check dsn: %s, err: %+v\n", dataDir, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
dsn := fmt.Sprintf("%s/memos_%s.db", dataDir, mode)
|
||||
profile.Data = dataDir
|
||||
profile.DSN = fmt.Sprintf("%s/memos_%s.db", dataDir, profile.Mode)
|
||||
profile.Version = common.GetCurrentVersion(profile.Mode)
|
||||
|
||||
return &Profile{
|
||||
Mode: mode,
|
||||
Port: port,
|
||||
DSN: dsn,
|
||||
Version: Version,
|
||||
}
|
||||
return &profile
|
||||
}
|
||||
@@ -4,10 +4,11 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"memos/api"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/usememos/memos/api"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
@@ -56,7 +57,6 @@ func (s *Server) registerResourceRoutes(g *echo.Group) {
|
||||
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
|
||||
})
|
||||
|
||||
@@ -74,6 +74,53 @@ func (s *Server) registerResourceRoutes(g *echo.Group) {
|
||||
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)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
g.GET("/resource/:resourceId", func(c echo.Context) error {
|
||||
resourceID, err := strconv.Atoi(c.Param("resourceId"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("resourceId"))).SetInternal(err)
|
||||
}
|
||||
|
||||
userID := c.Get(getUserIDContextKey()).(int)
|
||||
resourceFind := &api.ResourceFind{
|
||||
ID: &resourceID,
|
||||
CreatorID: &userID,
|
||||
}
|
||||
resource, err := s.Store.FindResource(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("/resource/:resourceId/blob", func(c echo.Context) error {
|
||||
resourceID, err := strconv.Atoi(c.Param("resourceId"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("resourceId"))).SetInternal(err)
|
||||
}
|
||||
|
||||
userID := c.Get(getUserIDContextKey()).(int)
|
||||
resourceFind := &api.ResourceFind{
|
||||
ID: &resourceID,
|
||||
CreatorID: &userID,
|
||||
}
|
||||
resource, err := s.Store.FindResource(resourceFind)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch resource").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 resource blob").SetInternal(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
@@ -91,8 +138,6 @@ func (s *Server) registerResourceRoutes(g *echo.Group) {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to delete resource").SetInternal(err)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, true)
|
||||
|
||||
return nil
|
||||
return c.JSON(http.StatusOK, true)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,10 +2,11 @@ package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"memos/common"
|
||||
"memos/store"
|
||||
"time"
|
||||
|
||||
"github.com/usememos/memos/server/profile"
|
||||
"github.com/usememos/memos/store"
|
||||
|
||||
"github.com/gorilla/securecookie"
|
||||
"github.com/gorilla/sessions"
|
||||
"github.com/labstack/echo-contrib/session"
|
||||
@@ -16,16 +17,16 @@ import (
|
||||
type Server struct {
|
||||
e *echo.Echo
|
||||
|
||||
Profile *common.Profile
|
||||
Profile *profile.Profile
|
||||
|
||||
Store *store.Store
|
||||
}
|
||||
|
||||
func NewServer(profile *common.Profile) *Server {
|
||||
func NewServer(profile *profile.Profile) *Server {
|
||||
e := echo.New()
|
||||
e.Debug = true
|
||||
e.HideBanner = true
|
||||
e.HidePort = false
|
||||
e.HidePort = true
|
||||
|
||||
e.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{
|
||||
Format: "${method} ${uri} ${status}\n",
|
||||
@@ -37,15 +38,10 @@ func NewServer(profile *common.Profile) *Server {
|
||||
Timeout: 30 * time.Second,
|
||||
}))
|
||||
|
||||
e.Use(middleware.StaticWithConfig(middleware.StaticConfig{
|
||||
Skipper: middleware.DefaultSkipper,
|
||||
Root: "web/dist",
|
||||
Browse: false,
|
||||
HTML5: true,
|
||||
}))
|
||||
embedFrontend(e)
|
||||
|
||||
// In dev mode, set the const secret key to make login session persistence.
|
||||
secret := []byte("justmemos")
|
||||
// In dev mode, set the const secret key to make signin session persistence.
|
||||
secret := []byte("usememos")
|
||||
if profile.Mode == "prod" {
|
||||
secret = securecookie.GenerateRandomKey(16)
|
||||
}
|
||||
@@ -70,6 +66,7 @@ func NewServer(profile *common.Profile) *Server {
|
||||
s.registerMemoRoutes(apiGroup)
|
||||
s.registerShortcutRoutes(apiGroup)
|
||||
s.registerResourceRoutes(apiGroup)
|
||||
s.registerTagRoutes(apiGroup)
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
@@ -3,10 +3,11 @@ package server
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"memos/api"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/usememos/memos/api"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
@@ -29,7 +30,6 @@ func (s *Server) registerShortcutRoutes(g *echo.Group) {
|
||||
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(shortcut)); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode shortcut response").SetInternal(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
@@ -55,15 +55,23 @@ func (s *Server) registerShortcutRoutes(g *echo.Group) {
|
||||
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(shortcut)); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode shortcut response").SetInternal(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
g.GET("/shortcut", func(c echo.Context) error {
|
||||
userID := c.Get(getUserIDContextKey()).(int)
|
||||
shortcutFind := &api.ShortcutFind{
|
||||
CreatorID: &userID,
|
||||
shortcutFind := &api.ShortcutFind{}
|
||||
|
||||
if userID, err := strconv.Atoi(c.QueryParam("creatorId")); err == nil {
|
||||
shortcutFind.CreatorID = &userID
|
||||
} else {
|
||||
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Missing user id to find shortcut")
|
||||
}
|
||||
|
||||
shortcutFind.CreatorID = &userID
|
||||
}
|
||||
|
||||
list, err := s.Store.FindShortcutList(shortcutFind)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch shortcut list").SetInternal(err)
|
||||
@@ -73,7 +81,6 @@ func (s *Server) registerShortcutRoutes(g *echo.Group) {
|
||||
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(list)); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode shortcut list response").SetInternal(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
@@ -95,7 +102,6 @@ func (s *Server) registerShortcutRoutes(g *echo.Group) {
|
||||
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(shortcut)); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode shortcut response").SetInternal(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
@@ -112,8 +118,6 @@ func (s *Server) registerShortcutRoutes(g *echo.Group) {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to delete shortcut").SetInternal(err)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, true)
|
||||
|
||||
return nil
|
||||
return c.JSON(http.StatusOK, true)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,34 +2,41 @@ package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"memos/api"
|
||||
"net/http"
|
||||
|
||||
"github.com/usememos/memos/api"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
func (s *Server) registerSystemRoutes(g *echo.Group) {
|
||||
g.GET("/ping", func(c echo.Context) error {
|
||||
data := s.Profile
|
||||
|
||||
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
|
||||
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(s.Profile)); err != nil {
|
||||
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(data)); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to compose system profile").SetInternal(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
g.GET("/status", func(c echo.Context) error {
|
||||
ownerUserType := api.Owner
|
||||
ownerUserFind := api.UserFind{
|
||||
Role: &ownerUserType,
|
||||
hostUserType := api.Host
|
||||
hostUserFind := api.UserFind{
|
||||
Role: &hostUserType,
|
||||
}
|
||||
ownerUser, err := s.Store.FindUser(&ownerUserFind)
|
||||
hostUser, err := s.Store.FindUser(&hostUserFind)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find owner user").SetInternal(err)
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find host user").SetInternal(err)
|
||||
}
|
||||
|
||||
if hostUser != nil {
|
||||
// data desensitize
|
||||
hostUser.OpenID = ""
|
||||
}
|
||||
|
||||
systemStatus := api.SystemStatus{
|
||||
Owner: ownerUser,
|
||||
Host: hostUser,
|
||||
Profile: s.Profile,
|
||||
}
|
||||
|
||||
@@ -37,7 +44,6 @@ func (s *Server) registerSystemRoutes(g *echo.Group) {
|
||||
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(systemStatus)); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode system status response").SetInternal(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
|
||||
"github.com/usememos/memos/api"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
func (s *Server) registerTagRoutes(g *echo.Group) {
|
||||
g.GET("/tag", func(c echo.Context) error {
|
||||
contentSearch := "#"
|
||||
normalRowStatus := api.Normal
|
||||
memoFind := api.MemoFind{
|
||||
ContentSearch: &contentSearch,
|
||||
RowStatus: &normalRowStatus,
|
||||
}
|
||||
|
||||
if userID, err := strconv.Atoi(c.QueryParam("creatorId")); err == nil {
|
||||
memoFind.CreatorID = &userID
|
||||
} else {
|
||||
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Missing user id to find tag")
|
||||
}
|
||||
|
||||
memoFind.CreatorID = &userID
|
||||
}
|
||||
|
||||
// Only can get PUBLIC memos in visitor mode
|
||||
_, ok := c.Get(getUserIDContextKey()).(int)
|
||||
if !ok {
|
||||
publicVisibility := api.Public
|
||||
memoFind.Visibility = &publicVisibility
|
||||
}
|
||||
|
||||
memoList, err := s.Store.FindMemoList(&memoFind)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find memo list").SetInternal(err)
|
||||
}
|
||||
|
||||
tagMapSet := make(map[string]bool)
|
||||
|
||||
r, err := regexp.Compile("#(.+?) ")
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to compile regexp").SetInternal(err)
|
||||
}
|
||||
for _, memo := range memoList {
|
||||
for _, rawTag := range r.FindAllString(memo.Content, -1) {
|
||||
tag := r.ReplaceAllString(rawTag, "$1")
|
||||
tagMapSet[tag] = true
|
||||
}
|
||||
}
|
||||
|
||||
tagList := []string{}
|
||||
for tag := range tagMapSet {
|
||||
tagList = append(tagList, tag)
|
||||
}
|
||||
|
||||
sort.Strings(tagList)
|
||||
|
||||
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
|
||||
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(tagList)); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode tags response").SetInternal(err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -3,9 +3,11 @@ package server
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"memos/api"
|
||||
"memos/common"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/usememos/memos/api"
|
||||
"github.com/usememos/memos/common"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
@@ -33,7 +35,6 @@ func (s *Server) registerUserRoutes(g *echo.Group) {
|
||||
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(user)); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode user response").SetInternal(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
@@ -47,7 +48,31 @@ func (s *Server) registerUserRoutes(g *echo.Group) {
|
||||
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(userList)); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode user list response").SetInternal(err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
g.GET("/user/:id", func(c echo.Context) error {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted user id").SetInternal(err)
|
||||
}
|
||||
|
||||
user, err := s.Store.FindUser(&api.UserFind{
|
||||
ID: &id,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch user").SetInternal(err)
|
||||
}
|
||||
|
||||
if user != nil {
|
||||
// data desensitize
|
||||
user.OpenID = ""
|
||||
}
|
||||
|
||||
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
|
||||
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(user)); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode user response").SetInternal(err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
@@ -71,7 +96,6 @@ func (s *Server) registerUserRoutes(g *echo.Group) {
|
||||
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(user)); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode user response").SetInternal(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
@@ -84,19 +108,6 @@ func (s *Server) registerUserRoutes(g *echo.Group) {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted patch user request").SetInternal(err)
|
||||
}
|
||||
|
||||
if userPatch.Email != nil {
|
||||
userFind := api.UserFind{
|
||||
Email: userPatch.Email,
|
||||
}
|
||||
user, err := s.Store.FindUser(&userFind)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to find user by email %s", *userPatch.Email)).SetInternal(err)
|
||||
}
|
||||
if user != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("User with email %s existed", *userPatch.Email)).SetInternal(err)
|
||||
}
|
||||
}
|
||||
|
||||
if userPatch.Password != nil && *userPatch.Password != "" {
|
||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(*userPatch.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
@@ -121,7 +132,54 @@ func (s *Server) registerUserRoutes(g *echo.Group) {
|
||||
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(user)); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode user response").SetInternal(err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
g.PATCH("/user/:userId", func(c echo.Context) error {
|
||||
currentUserID := c.Get(getUserIDContextKey()).(int)
|
||||
currentUser, err := s.Store.FindUser(&api.UserFind{
|
||||
ID: ¤tUserID,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err)
|
||||
}
|
||||
if currentUser == nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Current session user not found with ID: %d", currentUserID)).SetInternal(err)
|
||||
} else if currentUser.Role != api.Host {
|
||||
return echo.NewHTTPError(http.StatusForbidden, "Access forbidden for current session user").SetInternal(err)
|
||||
}
|
||||
|
||||
userID, err := strconv.Atoi(c.Param("userId"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("userId"))).SetInternal(err)
|
||||
}
|
||||
|
||||
userPatch := &api.UserPatch{
|
||||
ID: userID,
|
||||
}
|
||||
if err := json.NewDecoder(c.Request().Body).Decode(userPatch); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted patch user request").SetInternal(err)
|
||||
}
|
||||
|
||||
if userPatch.Password != nil && *userPatch.Password != "" {
|
||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(*userPatch.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate password hash").SetInternal(err)
|
||||
}
|
||||
|
||||
passwordHashStr := string(passwordHash)
|
||||
userPatch.PasswordHash = &passwordHashStr
|
||||
}
|
||||
|
||||
user, err := s.Store.PatchUser(userPatch)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to patch user").SetInternal(err)
|
||||
}
|
||||
|
||||
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
|
||||
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(user)); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode user response").SetInternal(err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"memos/api"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/usememos/memos/api"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
@@ -16,133 +15,6 @@ func (s *Server) registerWebhookRoutes(g *echo.Group) {
|
||||
return c.HTML(http.StatusOK, "<strong>Hello, World!</strong>")
|
||||
})
|
||||
|
||||
g.POST("/:openId/memo", func(c echo.Context) error {
|
||||
openID := c.Param("openId")
|
||||
|
||||
userFind := &api.UserFind{
|
||||
OpenID: &openID,
|
||||
}
|
||||
user, err := s.Store.FindUser(userFind)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user by open_id").SetInternal(err)
|
||||
}
|
||||
if user == nil {
|
||||
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("User openId not found: %s", openID))
|
||||
}
|
||||
|
||||
memoCreate := &api.MemoCreate{
|
||||
CreatorID: user.ID,
|
||||
}
|
||||
if err := json.NewDecoder(c.Request().Body).Decode(memoCreate); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post memo request by open api").SetInternal(err)
|
||||
}
|
||||
|
||||
memo, err := s.Store.CreateMemo(memoCreate)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create 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)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
g.GET("/:openId/memo", func(c echo.Context) error {
|
||||
openID := c.Param("openId")
|
||||
|
||||
userFind := &api.UserFind{
|
||||
OpenID: &openID,
|
||||
}
|
||||
user, err := s.Store.FindUser(userFind)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user by open_id").SetInternal(err)
|
||||
}
|
||||
if user == nil {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, fmt.Sprintf("Unauthorized: %s", openID))
|
||||
}
|
||||
|
||||
memoFind := &api.MemoFind{
|
||||
CreatorID: &user.ID,
|
||||
}
|
||||
rowStatus := c.QueryParam("rowStatus")
|
||||
if rowStatus != "" {
|
||||
memoFind.RowStatus = &rowStatus
|
||||
}
|
||||
|
||||
list, err := s.Store.FindMemoList(memoFind)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch memo list").SetInternal(err)
|
||||
}
|
||||
|
||||
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 nil
|
||||
})
|
||||
|
||||
g.POST("/:openId/resource", func(c echo.Context) error {
|
||||
openID := c.Param("openId")
|
||||
|
||||
userFind := &api.UserFind{
|
||||
OpenID: &openID,
|
||||
}
|
||||
user, err := s.Store.FindUser(userFind)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user by open_id").SetInternal(err)
|
||||
}
|
||||
if user == nil {
|
||||
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("User openId not found: %s", openID))
|
||||
}
|
||||
|
||||
if err := c.Request().ParseMultipartForm(64 << 20); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Upload file overload max size").SetInternal(err)
|
||||
}
|
||||
|
||||
file, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Upload file not found").SetInternal(err)
|
||||
}
|
||||
|
||||
filename := file.Filename
|
||||
filetype := file.Header.Get("Content-Type")
|
||||
size := file.Size
|
||||
src, err := file.Open()
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to open file").SetInternal(err)
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
fileBytes, err := ioutil.ReadAll(src)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to read file").SetInternal(err)
|
||||
}
|
||||
|
||||
resourceCreate := &api.ResourceCreate{
|
||||
Filename: filename,
|
||||
Type: filetype,
|
||||
Size: size,
|
||||
Blob: fileBytes,
|
||||
CreatorID: user.ID,
|
||||
}
|
||||
|
||||
resource, err := s.Store.CreateResource(resourceCreate)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create 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("/r/:resourceId/:filename", func(c echo.Context) error {
|
||||
resourceID, err := strconv.Atoi(c.Param("resourceId"))
|
||||
if err != nil {
|
||||
@@ -150,12 +22,10 @@ func (s *Server) registerWebhookRoutes(g *echo.Group) {
|
||||
}
|
||||
|
||||
filename := c.Param("filename")
|
||||
|
||||
resourceFind := &api.ResourceFind{
|
||||
ID: &resourceID,
|
||||
Filename: &filename,
|
||||
}
|
||||
|
||||
resource, err := s.Store.FindResource(resourceFind)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to fetch resource ID: %v", resourceID)).SetInternal(err)
|
||||
@@ -163,7 +33,10 @@ func (s *Server) registerWebhookRoutes(g *echo.Group) {
|
||||
|
||||
c.Response().Writer.WriteHeader(http.StatusOK)
|
||||
c.Response().Writer.Header().Set("Content-Type", resource.Type)
|
||||
c.Response().Writer.Write(resource.Blob)
|
||||
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,248 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"regexp"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/usememos/memos/common"
|
||||
"github.com/usememos/memos/server/profile"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
//go:embed migration
|
||||
var migrationFS embed.FS
|
||||
|
||||
//go:embed seed
|
||||
var seedFS embed.FS
|
||||
|
||||
type DB struct {
|
||||
// sqlite db connection instance
|
||||
Db *sql.DB
|
||||
profile *profile.Profile
|
||||
}
|
||||
|
||||
// NewDB returns a new instance of DB associated with the given datasource name.
|
||||
func NewDB(profile *profile.Profile) *DB {
|
||||
db := &DB{
|
||||
profile: profile,
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func (db *DB) Open() (err error) {
|
||||
// Ensure a DSN is set before attempting to open the database.
|
||||
if db.profile.DSN == "" {
|
||||
return fmt.Errorf("dsn required")
|
||||
}
|
||||
|
||||
// Connect to the database.
|
||||
sqlDB, err := sql.Open("sqlite3", db.profile.DSN)
|
||||
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(); err != nil {
|
||||
return fmt.Errorf("failed to apply latest schema: %w", err)
|
||||
}
|
||||
if err := db.seed(); err != nil {
|
||||
return fmt.Errorf("failed to seed: %w", err)
|
||||
}
|
||||
} else {
|
||||
// If db file not exists, we should migrate the database.
|
||||
if _, err := os.Stat(db.profile.DSN); errors.Is(err, os.ErrNotExist) {
|
||||
err := db.applyLatestSchema()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to apply latest schema: %w", err)
|
||||
}
|
||||
} else {
|
||||
err := db.createMigrationHistoryTable()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create migration_history table: %w", err)
|
||||
}
|
||||
|
||||
currentVersion := common.GetCurrentVersion(db.profile.Mode)
|
||||
migrationHistory, err := findMigrationHistory(db.Db, &MigrationHistoryFind{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if migrationHistory == nil {
|
||||
migrationHistory, err = upsertMigrationHistory(db.Db, &MigrationHistoryUpsert{
|
||||
Version: currentVersion,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if common.IsVersionGreaterThan(currentVersion, migrationHistory.Version) {
|
||||
minorVersionList := getMinorVersionList()
|
||||
|
||||
// backup the raw database file before migration
|
||||
rawBytes, err := ioutil.ReadFile(db.profile.DSN)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read raw database file, err: %w", err)
|
||||
}
|
||||
backupDBFilePath := fmt.Sprintf("%s/memos_%s_%d_backup.db", db.profile.Data, db.profile.Version, time.Now().Unix())
|
||||
if err := ioutil.WriteFile(backupDBFilePath, rawBytes, 0644); err != nil {
|
||||
return fmt.Errorf("failed to write raw database file, err: %w", err)
|
||||
}
|
||||
|
||||
println("succeed to copy a backup database file")
|
||||
println("start migrate")
|
||||
for _, minorVersion := range minorVersionList {
|
||||
normalizedVersion := minorVersion + ".0"
|
||||
if common.IsVersionGreaterThan(normalizedVersion, migrationHistory.Version) && common.IsVersionGreaterOrEqualThan(currentVersion, normalizedVersion) {
|
||||
println("applying migration for", normalizedVersion)
|
||||
if err := db.applyMigrationForMinorVersion(minorVersion); err != nil {
|
||||
return fmt.Errorf("failed to apply minor version migration: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println("end migrate")
|
||||
// remove the created backup db file after migrate succeed
|
||||
if err := os.Remove(backupDBFilePath); err != nil {
|
||||
println(fmt.Sprintf("Failed to remove temp database file, err %v", err))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
const (
|
||||
latestSchemaFileName = "LATEST__SCHEMA.sql"
|
||||
)
|
||||
|
||||
func (db *DB) applyLatestSchema() error {
|
||||
latestSchemaPath := fmt.Sprintf("%s/%s", "migration", latestSchemaFileName)
|
||||
buf, err := migrationFS.ReadFile(latestSchemaPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read latest schema %q, error %w", latestSchemaPath, err)
|
||||
}
|
||||
stmt := string(buf)
|
||||
if err := db.execute(stmt); err != nil {
|
||||
return fmt.Errorf("migrate error: statement:%s err=%w", stmt, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *DB) applyMigrationForMinorVersion(minorVersion string) error {
|
||||
filenames, err := fs.Glob(migrationFS, fmt.Sprintf("%s/%s/*.sql", "migration", minorVersion))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sort.Strings(filenames)
|
||||
migrationStmt := ""
|
||||
|
||||
// Loop over all migration files and execute them in order.
|
||||
for _, filename := range filenames {
|
||||
buf, err := migrationFS.ReadFile(filename)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read minor version migration file, filename=%s err=%w", filename, err)
|
||||
}
|
||||
stmt := string(buf)
|
||||
migrationStmt += stmt
|
||||
if err := db.execute(stmt); err != nil {
|
||||
return fmt.Errorf("migrate error: statement:%s err=%w", stmt, err)
|
||||
}
|
||||
}
|
||||
|
||||
// upsert the newest version to migration_history
|
||||
if _, err = upsertMigrationHistory(db.Db, &MigrationHistoryUpsert{
|
||||
Version: minorVersion + ".0",
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *DB) seed() error {
|
||||
filenames, err := fs.Glob(seedFS, fmt.Sprintf("%s/*.sql", "seed"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sort.Strings(filenames)
|
||||
|
||||
// Loop over all seed files and execute them in order.
|
||||
for _, filename := range filenames {
|
||||
buf, err := seedFS.ReadFile(filename)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read seed file, filename=%s err=%w", filename, err)
|
||||
}
|
||||
stmt := string(buf)
|
||||
if err := db.execute(stmt); err != nil {
|
||||
return fmt.Errorf("seed error: statement:%s err=%w", stmt, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// excecute runs a single SQL statement within a transaction.
|
||||
func (db *DB) execute(stmt string) error {
|
||||
tx, err := db.Db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if _, err := tx.Exec(stmt); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// minorDirRegexp is a regular expression for minor version directory.
|
||||
var minorDirRegexp = regexp.MustCompile(`^migration/[0-9]+\.[0-9]+$`)
|
||||
|
||||
func getMinorVersionList() []string {
|
||||
minorVersionList := []string{}
|
||||
|
||||
if err := fs.WalkDir(migrationFS, "migration", func(path string, file fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if file.IsDir() && minorDirRegexp.MatchString(path) {
|
||||
minorVersionList = append(minorVersionList, file.Name())
|
||||
}
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
sort.Strings(minorVersionList)
|
||||
|
||||
return minorVersionList
|
||||
}
|
||||
|
||||
// createMigrationHistoryTable creates the migration_history table if it doesn't exist.
|
||||
func (db *DB) createMigrationHistoryTable() error {
|
||||
if err := createTable(db.Db, `
|
||||
CREATE TABLE IF NOT EXISTS migration_history (
|
||||
version TEXT NOT NULL PRIMARY KEY,
|
||||
created_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now'))
|
||||
);
|
||||
`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
-- change user role field from "OWNER"/"USER" to "HOST"/"USER".
|
||||
|
||||
PRAGMA foreign_keys = off;
|
||||
|
||||
DROP TABLE IF EXISTS _user_old;
|
||||
|
||||
ALTER TABLE
|
||||
user RENAME TO _user_old;
|
||||
|
||||
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 (
|
||||
id, created_ts, updated_ts, row_status,
|
||||
email, name, password_hash, open_id
|
||||
)
|
||||
SELECT
|
||||
id,
|
||||
created_ts,
|
||||
updated_ts,
|
||||
row_status,
|
||||
email,
|
||||
name,
|
||||
password_hash,
|
||||
open_id
|
||||
FROM
|
||||
_user_old;
|
||||
|
||||
UPDATE
|
||||
user
|
||||
SET
|
||||
role = 'HOST'
|
||||
WHERE
|
||||
id IN (
|
||||
SELECT
|
||||
id
|
||||
FROM
|
||||
_user_old
|
||||
WHERE
|
||||
role = 'OWNER'
|
||||
);
|
||||
|
||||
DROP TABLE IF EXISTS _user_old;
|
||||
|
||||
PRAGMA foreign_keys = on;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE memo ADD visibility TEXT NOT NULL CHECK (visibility IN ('PUBLIC', 'PRIVATE')) DEFAULT 'PRIVATE';
|
||||
@@ -1,10 +1,19 @@
|
||||
-- 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`;
|
||||
|
||||
-- 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 ('OWNER', 'USER')) DEFAULT 'USER',
|
||||
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
|
||||
@@ -33,16 +42,16 @@ CREATE TABLE memo (
|
||||
creator_id INTEGER NOT NULL,
|
||||
created_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
|
||||
updated_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
|
||||
-- allowed row status are 'NORMAL', 'ARCHIVED', 'HIDDEN'.
|
||||
row_status TEXT NOT NULL DEFAULT 'NORMAL',
|
||||
row_status TEXT NOT NULL CHECK (row_status IN ('NORMAL', 'ARCHIVED')) DEFAULT 'NORMAL',
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
FOREIGN KEY(creator_id) REFERENCES users(id)
|
||||
visibility TEXT NOT NULL CHECK (visibility IN ('PUBLIC', 'PRIVATE')) DEFAULT 'PRIVATE',
|
||||
FOREIGN KEY(creator_id) REFERENCES user(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
INSERT INTO
|
||||
sqlite_sequence (name, seq)
|
||||
VALUES
|
||||
('memo', 100);
|
||||
('memo', 1000);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS `trigger_update_memo_modification_time`
|
||||
AFTER
|
||||
@@ -56,23 +65,38 @@ 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,
|
||||
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 '{}',
|
||||
-- allowed row status are 'NORMAL', 'ARCHIVED'.
|
||||
row_status TEXT NOT NULL DEFAULT 'NORMAL',
|
||||
FOREIGN KEY(creator_id) REFERENCES users(id)
|
||||
FOREIGN KEY(creator_id) REFERENCES user(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
INSERT INTO
|
||||
sqlite_sequence (name, seq)
|
||||
VALUES
|
||||
('shortcut', 100);
|
||||
('shortcut', 10000);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS `trigger_update_shortcut_modification_time`
|
||||
AFTER
|
||||
@@ -96,13 +120,13 @@ CREATE TABLE resource (
|
||||
blob BLOB NOT NULL,
|
||||
type TEXT NOT NULL DEFAULT '',
|
||||
size INTEGER NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY(creator_id) REFERENCES users(id)
|
||||
FOREIGN KEY(creator_id) REFERENCES user(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
INSERT INTO
|
||||
sqlite_sequence (name, seq)
|
||||
VALUES
|
||||
('resource', 100);
|
||||
('resource', 10000);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS `trigger_update_resource_modification_time`
|
||||
AFTER
|
||||
@@ -0,0 +1,100 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type MigrationHistory struct {
|
||||
Version string
|
||||
CreatedTs int64
|
||||
}
|
||||
|
||||
type MigrationHistoryUpsert struct {
|
||||
Version string
|
||||
}
|
||||
|
||||
type MigrationHistoryFind struct {
|
||||
Version *string
|
||||
}
|
||||
|
||||
func findMigrationHistoryList(db *sql.DB, find *MigrationHistoryFind) ([]*MigrationHistory, error) {
|
||||
where, args := []string{"1 = 1"}, []interface{}{}
|
||||
|
||||
if v := find.Version; v != nil {
|
||||
where, args = append(where, "version = ?"), append(args, *v)
|
||||
}
|
||||
|
||||
rows, err := db.Query(`
|
||||
SELECT
|
||||
version,
|
||||
created_ts
|
||||
FROM
|
||||
migration_history
|
||||
WHERE `+strings.Join(where, " AND ")+`
|
||||
ORDER BY created_ts DESC`,
|
||||
args...,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
migrationHistoryList := make([]*MigrationHistory, 0)
|
||||
for rows.Next() {
|
||||
var migrationHistory MigrationHistory
|
||||
if err := rows.Scan(
|
||||
&migrationHistory.Version,
|
||||
&migrationHistory.CreatedTs,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
migrationHistoryList = append(migrationHistoryList, &migrationHistory)
|
||||
}
|
||||
|
||||
return migrationHistoryList, nil
|
||||
}
|
||||
|
||||
func findMigrationHistory(db *sql.DB, find *MigrationHistoryFind) (*MigrationHistory, error) {
|
||||
list, err := findMigrationHistoryList(db, find)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(list) == 0 {
|
||||
return nil, nil
|
||||
} else {
|
||||
return list[0], nil
|
||||
}
|
||||
}
|
||||
|
||||
func upsertMigrationHistory(db *sql.DB, upsert *MigrationHistoryUpsert) (*MigrationHistory, error) {
|
||||
row, err := db.Query(`
|
||||
INSERT INTO migration_history (
|
||||
version
|
||||
)
|
||||
VALUES (?)
|
||||
ON CONFLICT(version) DO UPDATE
|
||||
SET
|
||||
version=EXCLUDED.version
|
||||
RETURNING version, created_ts
|
||||
`,
|
||||
upsert.Version,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer row.Close()
|
||||
|
||||
row.Next()
|
||||
var migrationHistory MigrationHistory
|
||||
if err := row.Scan(
|
||||
&migrationHistory.Version,
|
||||
&migrationHistory.CreatedTs,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &migrationHistory, nil
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
DELETE FROM memo_organizer;
|
||||
DELETE FROM resource;
|
||||
DELETE FROM shortcut;
|
||||
DELETE FROM memo;
|
||||
@@ -0,0 +1,39 @@
|
||||
INSERT INTO
|
||||
user (
|
||||
`id`,
|
||||
`email`,
|
||||
`role`,
|
||||
`name`,
|
||||
`open_id`,
|
||||
`password_hash`
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
101,
|
||||
'demo@usememos.com',
|
||||
'HOST',
|
||||
'Demo Host',
|
||||
'demo_open_id',
|
||||
-- raw password: secret
|
||||
'$2a$14$ajq8Q7fbtFRQvXpdCq7Jcuy.Rx1h/L4J60Otx.gyNLbAYctGMJ9tK'
|
||||
);
|
||||
|
||||
INSERT INTO
|
||||
user (
|
||||
`id`,
|
||||
`email`,
|
||||
`role`,
|
||||
`name`,
|
||||
`open_id`,
|
||||
`password_hash`
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
102,
|
||||
'jack@usememos.com',
|
||||
'USER',
|
||||
'Jack',
|
||||
'jack_open_id',
|
||||
-- raw password: secret
|
||||
'$2a$14$ajq8Q7fbtFRQvXpdCq7Jcuy.Rx1h/L4J60Otx.gyNLbAYctGMJ9tK'
|
||||
);
|
||||
@@ -0,0 +1,78 @@
|
||||
INSERT INTO
|
||||
memo (
|
||||
`id`,
|
||||
`content`,
|
||||
`creator_id`
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
1001,
|
||||
"#Hello 👋 Welcome to memos.
|
||||
And here is old Jack's Page: [/u/102](/u/102)",
|
||||
101
|
||||
);
|
||||
|
||||
INSERT INTO
|
||||
memo (
|
||||
`id`,
|
||||
`content`,
|
||||
`creator_id`
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
1002,
|
||||
'#TODO
|
||||
- [ ] Take more photos about **🌄 sunset**;
|
||||
- [x] Clean the room;
|
||||
- [x] Read *📖 The Little Prince*;
|
||||
(👆 click to toggle status)',
|
||||
101
|
||||
);
|
||||
|
||||
INSERT INTO
|
||||
memo (
|
||||
`id`,
|
||||
`content`,
|
||||
`creator_id`,
|
||||
`visibility`
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
1003,
|
||||
'好好学习,天天向上。🤜🤛',
|
||||
101,
|
||||
'PUBLIC'
|
||||
);
|
||||
|
||||
INSERT INTO
|
||||
memo (
|
||||
`id`,
|
||||
`content`,
|
||||
`creator_id`
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
1004,
|
||||
'#TODO
|
||||
- [x] Take more photos about **🌄 sunset**;
|
||||
- [ ] Clean the classroom;
|
||||
- [ ] Watch *👦 The Boys*;
|
||||
(👆 click to toggle status)
|
||||
',
|
||||
102
|
||||
);
|
||||
|
||||
INSERT INTO
|
||||
memo (
|
||||
`id`,
|
||||
`content`,
|
||||
`creator_id`,
|
||||
`visibility`
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
1005,
|
||||
'三人行,必有我师焉!👨🏫',
|
||||
102,
|
||||
'PUBLIC'
|
||||
);
|
||||
@@ -0,0 +1,12 @@
|
||||
INSERT INTO
|
||||
memo_organizer (
|
||||
`memo_id`,
|
||||
`user_id`,
|
||||
`pinned`
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
1001,
|
||||
101,
|
||||
1
|
||||
);
|
||||
@@ -0,0 +1,66 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Table struct {
|
||||
Name string
|
||||
SQL string
|
||||
}
|
||||
|
||||
//lint:ignore U1000 Ignore unused function temporarily for debugging
|
||||
func findTable(db *sql.DB, tableName string) (*Table, error) {
|
||||
where, args := []string{"1 = 1"}, []interface{}{}
|
||||
|
||||
where, args = append(where, "type = ?"), append(args, "table")
|
||||
where, args = append(where, "name = ?"), append(args, tableName)
|
||||
|
||||
rows, err := db.Query(`
|
||||
SELECT
|
||||
tbl_name,
|
||||
sql
|
||||
FROM sqlite_schema
|
||||
WHERE `+strings.Join(where, " AND "),
|
||||
args...,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
tableList := make([]*Table, 0)
|
||||
for rows.Next() {
|
||||
var table Table
|
||||
if err := rows.Scan(
|
||||
&table.Name,
|
||||
&table.SQL,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tableList = append(tableList, &table)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(tableList) == 0 {
|
||||
return nil, nil
|
||||
} else {
|
||||
return tableList[0], nil
|
||||
}
|
||||
}
|
||||
|
||||
func createTable(db *sql.DB, sql string) error {
|
||||
result, err := db.Exec(sql)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = result.RowsAffected()
|
||||
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
)
|
||||
|
||||
func FormatError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch err {
|
||||
case sql.ErrNoRows:
|
||||
return errors.New("data not found")
|
||||
default:
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,55 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"memos/api"
|
||||
"memos/common"
|
||||
"strings"
|
||||
|
||||
"github.com/usememos/memos/api"
|
||||
"github.com/usememos/memos/common"
|
||||
)
|
||||
|
||||
// memoRaw is the store model for an Memo.
|
||||
// Fields have exactly the same meanings as Memo.
|
||||
type memoRaw struct {
|
||||
ID int
|
||||
|
||||
// Standard fields
|
||||
RowStatus api.RowStatus
|
||||
CreatorID int
|
||||
CreatedTs int64
|
||||
UpdatedTs int64
|
||||
|
||||
// Domain specific fields
|
||||
Content string
|
||||
Visibility api.Visibility
|
||||
}
|
||||
|
||||
// toMemo creates an instance of Memo based on the memoRaw.
|
||||
// This is intended to be called when we need to compose an Memo relationship.
|
||||
func (raw *memoRaw) toMemo() *api.Memo {
|
||||
return &api.Memo{
|
||||
ID: raw.ID,
|
||||
|
||||
// Standard fields
|
||||
RowStatus: raw.RowStatus,
|
||||
CreatorID: raw.CreatorID,
|
||||
CreatedTs: raw.CreatedTs,
|
||||
UpdatedTs: raw.UpdatedTs,
|
||||
|
||||
// Domain specific fields
|
||||
Content: raw.Content,
|
||||
Visibility: raw.Visibility,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Store) CreateMemo(create *api.MemoCreate) (*api.Memo, error) {
|
||||
memo, err := createMemo(s.db, create)
|
||||
memoRaw, err := createMemoRaw(s.db, create)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
memo, err := s.composeMemo(memoRaw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -17,7 +58,12 @@ func (s *Store) CreateMemo(create *api.MemoCreate) (*api.Memo, error) {
|
||||
}
|
||||
|
||||
func (s *Store) PatchMemo(patch *api.MemoPatch) (*api.Memo, error) {
|
||||
memo, err := patchMemo(s.db, patch)
|
||||
memoRaw, err := patchMemoRaw(s.db, patch)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
memo, err := s.composeMemo(memoRaw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -26,16 +72,26 @@ func (s *Store) PatchMemo(patch *api.MemoPatch) (*api.Memo, error) {
|
||||
}
|
||||
|
||||
func (s *Store) FindMemoList(find *api.MemoFind) ([]*api.Memo, error) {
|
||||
list, err := findMemoList(s.db, find)
|
||||
memoRawList, err := findMemoRawList(s.db, find)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
list := []*api.Memo{}
|
||||
for _, raw := range memoRawList {
|
||||
memo, err := s.composeMemo(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
list = append(list, memo)
|
||||
}
|
||||
|
||||
return list, nil
|
||||
}
|
||||
|
||||
func (s *Store) FindMemo(find *api.MemoFind) (*api.Memo, error) {
|
||||
list, err := findMemoList(s.db, find)
|
||||
list, err := findMemoRawList(s.db, find)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -44,7 +100,12 @@ func (s *Store) FindMemo(find *api.MemoFind) (*api.Memo, error) {
|
||||
return nil, &common.Error{Code: common.NotFound, Err: fmt.Errorf("not found")}
|
||||
}
|
||||
|
||||
return list[0], nil
|
||||
memo, err := s.composeMemo(list[0])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return memo, nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteMemo(delete *api.MemoDelete) error {
|
||||
@@ -56,22 +117,25 @@ func (s *Store) DeleteMemo(delete *api.MemoDelete) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func createMemo(db *DB, create *api.MemoCreate) (*api.Memo, error) {
|
||||
func createMemoRaw(db *sql.DB, create *api.MemoCreate) (*memoRaw, error) {
|
||||
set := []string{"creator_id", "content"}
|
||||
placeholder := []string{"?", "?"}
|
||||
args := []interface{}{create.CreatorID, create.Content}
|
||||
|
||||
if v := create.Visibility; v != nil {
|
||||
set, placeholder, args = append(set, "visibility"), append(placeholder, "?"), append(args, *v)
|
||||
}
|
||||
if v := create.CreatedTs; v != nil {
|
||||
set, placeholder, args = append(set, "created_ts"), append(placeholder, "?"), append(args, *v)
|
||||
}
|
||||
|
||||
row, err := db.Db.Query(`
|
||||
INSERT INTO memo (
|
||||
`+strings.Join(set, ", ")+`
|
||||
)
|
||||
VALUES (`+strings.Join(placeholder, ",")+`)
|
||||
RETURNING id, creator_id, created_ts, updated_ts, content, row_status
|
||||
`,
|
||||
query := `
|
||||
INSERT INTO memo (
|
||||
` + strings.Join(set, ", ") + `
|
||||
)
|
||||
VALUES (` + strings.Join(placeholder, ",") + `)
|
||||
RETURNING id, creator_id, created_ts, updated_ts, row_status, content, visibility`
|
||||
row, err := db.Query(query,
|
||||
args...,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -79,26 +143,24 @@ func createMemo(db *DB, create *api.MemoCreate) (*api.Memo, error) {
|
||||
}
|
||||
defer row.Close()
|
||||
|
||||
if !row.Next() {
|
||||
return nil, &common.Error{Code: common.NotFound, Err: fmt.Errorf("not found")}
|
||||
}
|
||||
|
||||
var memo api.Memo
|
||||
row.Next()
|
||||
var memoRaw memoRaw
|
||||
if err := row.Scan(
|
||||
&memo.ID,
|
||||
&memo.CreatorID,
|
||||
&memo.CreatedTs,
|
||||
&memo.UpdatedTs,
|
||||
&memo.Content,
|
||||
&memo.RowStatus,
|
||||
&memoRaw.ID,
|
||||
&memoRaw.CreatorID,
|
||||
&memoRaw.CreatedTs,
|
||||
&memoRaw.UpdatedTs,
|
||||
&memoRaw.RowStatus,
|
||||
&memoRaw.Content,
|
||||
&memoRaw.Visibility,
|
||||
); err != nil {
|
||||
return nil, FormatError(err)
|
||||
}
|
||||
|
||||
return &memo, nil
|
||||
return &memoRaw, nil
|
||||
}
|
||||
|
||||
func patchMemo(db *DB, patch *api.MemoPatch) (*api.Memo, error) {
|
||||
func patchMemoRaw(db *sql.DB, patch *api.MemoPatch) (*memoRaw, error) {
|
||||
set, args := []string{}, []interface{}{}
|
||||
|
||||
if v := patch.Content; v != nil {
|
||||
@@ -107,39 +169,42 @@ func patchMemo(db *DB, patch *api.MemoPatch) (*api.Memo, error) {
|
||||
if v := patch.RowStatus; v != nil {
|
||||
set, args = append(set, "row_status = ?"), append(args, *v)
|
||||
}
|
||||
if v := patch.Visibility; v != nil {
|
||||
set, args = append(set, "visibility = ?"), append(args, *v)
|
||||
}
|
||||
|
||||
args = append(args, patch.ID)
|
||||
|
||||
row, err := db.Db.Query(`
|
||||
row, err := db.Query(`
|
||||
UPDATE memo
|
||||
SET `+strings.Join(set, ", ")+`
|
||||
WHERE id = ?
|
||||
RETURNING id, created_ts, updated_ts, content, row_status
|
||||
RETURNING id, creator_id, created_ts, updated_ts, row_status, content, visibility
|
||||
`, args...)
|
||||
if err != nil {
|
||||
return nil, FormatError(err)
|
||||
}
|
||||
defer row.Close()
|
||||
|
||||
if !row.Next() {
|
||||
return nil, &common.Error{Code: common.NotFound, Err: fmt.Errorf("not found")}
|
||||
}
|
||||
row.Next()
|
||||
|
||||
var memo api.Memo
|
||||
var memoRaw memoRaw
|
||||
if err := row.Scan(
|
||||
&memo.ID,
|
||||
&memo.CreatedTs,
|
||||
&memo.UpdatedTs,
|
||||
&memo.Content,
|
||||
&memo.RowStatus,
|
||||
&memoRaw.ID,
|
||||
&memoRaw.CreatorID,
|
||||
&memoRaw.CreatedTs,
|
||||
&memoRaw.UpdatedTs,
|
||||
&memoRaw.RowStatus,
|
||||
&memoRaw.Content,
|
||||
&memoRaw.Visibility,
|
||||
); err != nil {
|
||||
return nil, FormatError(err)
|
||||
}
|
||||
|
||||
return &memo, nil
|
||||
return &memoRaw, nil
|
||||
}
|
||||
|
||||
func findMemoList(db *DB, find *api.MemoFind) ([]*api.Memo, error) {
|
||||
func findMemoRawList(db *sql.DB, find *api.MemoFind) ([]*memoRaw, error) {
|
||||
where, args := []string{"1 = 1"}, []interface{}{}
|
||||
|
||||
if v := find.ID; v != nil {
|
||||
@@ -151,17 +216,36 @@ func findMemoList(db *DB, find *api.MemoFind) ([]*api.Memo, error) {
|
||||
if v := find.RowStatus; v != nil {
|
||||
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 )")
|
||||
}
|
||||
if v := find.ContentSearch; v != nil {
|
||||
where, args = append(where, "content LIKE ?"), append(args, "%"+*v+"%")
|
||||
}
|
||||
if v := find.Visibility; v != nil {
|
||||
where, args = append(where, "visibility = ?"), append(args, *v)
|
||||
}
|
||||
|
||||
rows, err := db.Db.Query(`
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
rows, err := db.Query(`
|
||||
SELECT
|
||||
id,
|
||||
creator_id,
|
||||
created_ts,
|
||||
updated_ts,
|
||||
row_status,
|
||||
content,
|
||||
row_status
|
||||
visibility
|
||||
FROM memo
|
||||
WHERE `+strings.Join(where, " AND "),
|
||||
WHERE `+strings.Join(where, " AND ")+`
|
||||
ORDER BY created_ts DESC`+pagination,
|
||||
args...,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -169,32 +253,33 @@ func findMemoList(db *DB, find *api.MemoFind) ([]*api.Memo, error) {
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
list := make([]*api.Memo, 0)
|
||||
memoRawList := make([]*memoRaw, 0)
|
||||
for rows.Next() {
|
||||
var memo api.Memo
|
||||
var memoRaw memoRaw
|
||||
if err := rows.Scan(
|
||||
&memo.ID,
|
||||
&memo.CreatorID,
|
||||
&memo.CreatedTs,
|
||||
&memo.UpdatedTs,
|
||||
&memo.Content,
|
||||
&memo.RowStatus,
|
||||
&memoRaw.ID,
|
||||
&memoRaw.CreatorID,
|
||||
&memoRaw.CreatedTs,
|
||||
&memoRaw.UpdatedTs,
|
||||
&memoRaw.RowStatus,
|
||||
&memoRaw.Content,
|
||||
&memoRaw.Visibility,
|
||||
); err != nil {
|
||||
return nil, FormatError(err)
|
||||
}
|
||||
|
||||
list = append(list, &memo)
|
||||
memoRawList = append(memoRawList, &memoRaw)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, FormatError(err)
|
||||
}
|
||||
|
||||
return list, nil
|
||||
return memoRawList, nil
|
||||
}
|
||||
|
||||
func deleteMemo(db *DB, delete *api.MemoDelete) error {
|
||||
result, err := db.Db.Exec(`DELETE FROM memo WHERE id = ?`, delete.ID)
|
||||
func deleteMemo(db *sql.DB, delete *api.MemoDelete) error {
|
||||
result, err := db.Exec(`DELETE FROM memo WHERE id = ?`, delete.ID)
|
||||
if err != nil {
|
||||
return FormatError(err)
|
||||
}
|
||||
@@ -206,3 +291,19 @@ func deleteMemo(db *DB, delete *api.MemoDelete) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) composeMemo(raw *memoRaw) (*api.Memo, error) {
|
||||
memo := raw.toMemo()
|
||||
|
||||
memoOrganizer, err := s.FindMemoOrganizer(&api.MemoOrganizerFind{
|
||||
MemoID: memo.ID,
|
||||
UserID: memo.CreatorID,
|
||||
})
|
||||
if err != nil && common.ErrorCode(err) != common.NotFound {
|
||||
return nil, err
|
||||
} else if memoOrganizer != nil {
|
||||
memo.Pinned = memoOrganizer.Pinned
|
||||
}
|
||||
|
||||
return memo, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
"github.com/usememos/memos/api"
|
||||
"github.com/usememos/memos/common"
|
||||
)
|
||||
|
||||
// memoOrganizerRaw is the store model for an MemoOrganizer.
|
||||
// Fields have exactly the same meanings as MemoOrganizer.
|
||||
type memoOrganizerRaw struct {
|
||||
ID int
|
||||
|
||||
// Domain specific fields
|
||||
MemoID int
|
||||
UserID int
|
||||
Pinned bool
|
||||
}
|
||||
|
||||
func (raw *memoOrganizerRaw) toMemoOrganizer() *api.MemoOrganizer {
|
||||
return &api.MemoOrganizer{
|
||||
ID: raw.ID,
|
||||
|
||||
MemoID: raw.MemoID,
|
||||
UserID: raw.UserID,
|
||||
Pinned: raw.Pinned,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Store) FindMemoOrganizer(find *api.MemoOrganizerFind) (*api.MemoOrganizer, error) {
|
||||
memoOrganizerRaw, err := findMemoOrganizer(s.db, find)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
memoOrganizer := memoOrganizerRaw.toMemoOrganizer()
|
||||
|
||||
return memoOrganizer, nil
|
||||
}
|
||||
|
||||
func (s *Store) UpsertMemoOrganizer(upsert *api.MemoOrganizerUpsert) error {
|
||||
err := upsertMemoOrganizer(s.db, upsert)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func findMemoOrganizer(db *sql.DB, find *api.MemoOrganizerFind) (*memoOrganizerRaw, error) {
|
||||
row, err := db.Query(`
|
||||
SELECT
|
||||
id,
|
||||
memo_id,
|
||||
user_id,
|
||||
pinned
|
||||
FROM memo_organizer
|
||||
WHERE memo_id = ? AND user_id = ?
|
||||
`, find.MemoID, find.UserID)
|
||||
if err != nil {
|
||||
return nil, FormatError(err)
|
||||
}
|
||||
defer row.Close()
|
||||
|
||||
if !row.Next() {
|
||||
return nil, &common.Error{Code: common.NotFound, Err: fmt.Errorf("not found")}
|
||||
}
|
||||
|
||||
var memoOrganizerRaw memoOrganizerRaw
|
||||
if err := row.Scan(
|
||||
&memoOrganizerRaw.ID,
|
||||
&memoOrganizerRaw.MemoID,
|
||||
&memoOrganizerRaw.UserID,
|
||||
&memoOrganizerRaw.Pinned,
|
||||
); err != nil {
|
||||
return nil, FormatError(err)
|
||||
}
|
||||
|
||||
return &memoOrganizerRaw, nil
|
||||
}
|
||||
|
||||
func upsertMemoOrganizer(db *sql.DB, upsert *api.MemoOrganizerUpsert) error {
|
||||
row, err := db.Query(`
|
||||
INSERT INTO memo_organizer (
|
||||
memo_id,
|
||||
user_id,
|
||||
pinned
|
||||
)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(memo_id, user_id) DO UPDATE
|
||||
SET
|
||||
pinned = EXCLUDED.pinned
|
||||
RETURNING id, memo_id, user_id, pinned
|
||||
`,
|
||||
upsert.MemoID,
|
||||
upsert.UserID,
|
||||
upsert.Pinned,
|
||||
)
|
||||
if err != nil {
|
||||
return FormatError(err)
|
||||
}
|
||||
|
||||
defer row.Close()
|
||||
|
||||
row.Next()
|
||||
var memoOrganizer api.MemoOrganizer
|
||||
if err := row.Scan(
|
||||
&memoOrganizer.ID,
|
||||
&memoOrganizer.MemoID,
|
||||
&memoOrganizer.UserID,
|
||||
&memoOrganizer.Pinned,
|
||||
); err != nil {
|
||||
return FormatError(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
DROP TABLE IF EXISTS `memo`;
|
||||
DROP TABLE IF EXISTS `shortcut`;
|
||||
DROP TABLE IF EXISTS `resource`;
|
||||
DROP TABLE IF EXISTS `user`;
|
||||
@@ -1,28 +1,71 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"memos/api"
|
||||
"memos/common"
|
||||
"strings"
|
||||
|
||||
"github.com/usememos/memos/api"
|
||||
"github.com/usememos/memos/common"
|
||||
)
|
||||
|
||||
// resourceRaw is the store model for an Resource.
|
||||
// Fields have exactly the same meanings as Resource.
|
||||
type resourceRaw struct {
|
||||
ID int
|
||||
|
||||
// Standard fields
|
||||
CreatorID int
|
||||
CreatedTs int64
|
||||
UpdatedTs int64
|
||||
|
||||
// Domain specific fields
|
||||
Filename string
|
||||
Blob []byte
|
||||
Type string
|
||||
Size int64
|
||||
}
|
||||
|
||||
func (raw *resourceRaw) toResource() *api.Resource {
|
||||
return &api.Resource{
|
||||
ID: raw.ID,
|
||||
|
||||
// Standard fields
|
||||
CreatorID: raw.CreatorID,
|
||||
CreatedTs: raw.CreatedTs,
|
||||
UpdatedTs: raw.UpdatedTs,
|
||||
|
||||
// Domain specific fields
|
||||
Filename: raw.Filename,
|
||||
Blob: raw.Blob,
|
||||
Type: raw.Type,
|
||||
Size: raw.Size,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Store) CreateResource(create *api.ResourceCreate) (*api.Resource, error) {
|
||||
resource, err := createResource(s.db, create)
|
||||
resourceRaw, err := createResource(s.db, create)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resource := resourceRaw.toResource()
|
||||
|
||||
return resource, nil
|
||||
}
|
||||
|
||||
func (s *Store) FindResourceList(find *api.ResourceFind) ([]*api.Resource, error) {
|
||||
list, err := findResourceList(s.db, find)
|
||||
resourceRawList, err := findResourceList(s.db, find)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return list, nil
|
||||
resourceList := []*api.Resource{}
|
||||
for _, raw := range resourceRawList {
|
||||
resourceList = append(resourceList, raw.toResource())
|
||||
}
|
||||
|
||||
return resourceList, nil
|
||||
}
|
||||
|
||||
func (s *Store) FindResource(find *api.ResourceFind) (*api.Resource, error) {
|
||||
@@ -35,7 +78,9 @@ func (s *Store) FindResource(find *api.ResourceFind) (*api.Resource, error) {
|
||||
return nil, &common.Error{Code: common.NotFound, Err: fmt.Errorf("not found")}
|
||||
}
|
||||
|
||||
return list[0], nil
|
||||
resource := list[0].toResource()
|
||||
|
||||
return resource, nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteResource(delete *api.ResourceDelete) error {
|
||||
@@ -47,8 +92,8 @@ func (s *Store) DeleteResource(delete *api.ResourceDelete) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func createResource(db *DB, create *api.ResourceCreate) (*api.Resource, error) {
|
||||
row, err := db.Db.Query(`
|
||||
func createResource(db *sql.DB, create *api.ResourceCreate) (*resourceRaw, error) {
|
||||
row, err := db.Query(`
|
||||
INSERT INTO resource (
|
||||
filename,
|
||||
blob,
|
||||
@@ -70,27 +115,24 @@ func createResource(db *DB, create *api.ResourceCreate) (*api.Resource, error) {
|
||||
}
|
||||
defer row.Close()
|
||||
|
||||
if !row.Next() {
|
||||
return nil, &common.Error{Code: common.NotFound, Err: fmt.Errorf("not found")}
|
||||
}
|
||||
|
||||
var resource api.Resource
|
||||
row.Next()
|
||||
var resourceRaw resourceRaw
|
||||
if err := row.Scan(
|
||||
&resource.ID,
|
||||
&resource.Filename,
|
||||
&resource.Blob,
|
||||
&resource.Type,
|
||||
&resource.Size,
|
||||
&resource.CreatedTs,
|
||||
&resource.UpdatedTs,
|
||||
&resourceRaw.ID,
|
||||
&resourceRaw.Filename,
|
||||
&resourceRaw.Blob,
|
||||
&resourceRaw.Type,
|
||||
&resourceRaw.Size,
|
||||
&resourceRaw.CreatedTs,
|
||||
&resourceRaw.UpdatedTs,
|
||||
); err != nil {
|
||||
return nil, FormatError(err)
|
||||
}
|
||||
|
||||
return &resource, nil
|
||||
return &resourceRaw, nil
|
||||
}
|
||||
|
||||
func findResourceList(db *DB, find *api.ResourceFind) ([]*api.Resource, error) {
|
||||
func findResourceList(db *sql.DB, find *api.ResourceFind) ([]*resourceRaw, error) {
|
||||
where, args := []string{"1 = 1"}, []interface{}{}
|
||||
|
||||
if v := find.ID; v != nil {
|
||||
@@ -103,7 +145,7 @@ func findResourceList(db *DB, find *api.ResourceFind) ([]*api.Resource, error) {
|
||||
where, args = append(where, "filename = ?"), append(args, *v)
|
||||
}
|
||||
|
||||
rows, err := db.Db.Query(`
|
||||
rows, err := db.Query(`
|
||||
SELECT
|
||||
id,
|
||||
filename,
|
||||
@@ -113,7 +155,8 @@ func findResourceList(db *DB, find *api.ResourceFind) ([]*api.Resource, error) {
|
||||
created_ts,
|
||||
updated_ts
|
||||
FROM resource
|
||||
WHERE `+strings.Join(where, " AND "),
|
||||
WHERE `+strings.Join(where, " AND ")+`
|
||||
ORDER BY created_ts DESC`,
|
||||
args...,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -121,33 +164,33 @@ func findResourceList(db *DB, find *api.ResourceFind) ([]*api.Resource, error) {
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
list := make([]*api.Resource, 0)
|
||||
resourceRawList := make([]*resourceRaw, 0)
|
||||
for rows.Next() {
|
||||
var resource api.Resource
|
||||
var resourceRaw resourceRaw
|
||||
if err := rows.Scan(
|
||||
&resource.ID,
|
||||
&resource.Filename,
|
||||
&resource.Blob,
|
||||
&resource.Type,
|
||||
&resource.Size,
|
||||
&resource.CreatedTs,
|
||||
&resource.UpdatedTs,
|
||||
&resourceRaw.ID,
|
||||
&resourceRaw.Filename,
|
||||
&resourceRaw.Blob,
|
||||
&resourceRaw.Type,
|
||||
&resourceRaw.Size,
|
||||
&resourceRaw.CreatedTs,
|
||||
&resourceRaw.UpdatedTs,
|
||||
); err != nil {
|
||||
return nil, FormatError(err)
|
||||
}
|
||||
|
||||
list = append(list, &resource)
|
||||
resourceRawList = append(resourceRawList, &resourceRaw)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, FormatError(err)
|
||||
}
|
||||
|
||||
return list, nil
|
||||
return resourceRawList, nil
|
||||
}
|
||||
|
||||
func deleteResource(db *DB, delete *api.ResourceDelete) error {
|
||||
result, err := db.Db.Exec(`DELETE FROM resource WHERE id = ?`, delete.ID)
|
||||
func deleteResource(db *sql.DB, delete *api.ResourceDelete) error {
|
||||
result, err := db.Exec(`DELETE FROM resource WHERE id = ?`, delete.ID)
|
||||
if err != nil {
|
||||
return FormatError(err)
|
||||
}
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
INSERT INTO
|
||||
user (
|
||||
`id`,
|
||||
`name`,
|
||||
`email`,
|
||||
`open_id`,
|
||||
`password_hash`
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
101,
|
||||
'guest',
|
||||
'guest@example.com',
|
||||
'guest_open_id',
|
||||
-- "secret"
|
||||
'$2a$14$ajq8Q7fbtFRQvXpdCq7Jcuy.Rx1h/L4J60Otx.gyNLbAYctGMJ9tK'
|
||||
);
|
||||
@@ -1,23 +0,0 @@
|
||||
INSERT INTO
|
||||
memo (
|
||||
`content`,
|
||||
`creator_id`
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
'#memos 👋 Welcome to memos',
|
||||
101
|
||||
);
|
||||
|
||||
INSERT INTO
|
||||
memo (
|
||||
`content`,
|
||||
`creator_id`,
|
||||
`row_status`
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
'好好学习,天天向上。',
|
||||
101,
|
||||
'ARCHIVED'
|
||||
);
|
||||
@@ -1,36 +1,77 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"memos/api"
|
||||
"memos/common"
|
||||
"strings"
|
||||
|
||||
"github.com/usememos/memos/api"
|
||||
"github.com/usememos/memos/common"
|
||||
)
|
||||
|
||||
// shortcutRaw is the store model for an Shortcut.
|
||||
// Fields have exactly the same meanings as Shortcut.
|
||||
type shortcutRaw struct {
|
||||
ID int
|
||||
|
||||
// Standard fields
|
||||
RowStatus api.RowStatus
|
||||
CreatorID int
|
||||
CreatedTs int64
|
||||
UpdatedTs int64
|
||||
|
||||
// Domain specific fields
|
||||
Title string
|
||||
Payload string
|
||||
}
|
||||
|
||||
func (raw *shortcutRaw) toShortcut() *api.Shortcut {
|
||||
return &api.Shortcut{
|
||||
ID: raw.ID,
|
||||
|
||||
RowStatus: raw.RowStatus,
|
||||
CreatorID: raw.CreatorID,
|
||||
CreatedTs: raw.CreatedTs,
|
||||
UpdatedTs: raw.UpdatedTs,
|
||||
|
||||
Title: raw.Title,
|
||||
Payload: raw.Payload,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Store) CreateShortcut(create *api.ShortcutCreate) (*api.Shortcut, error) {
|
||||
shortcut, err := createShortcut(s.db, create)
|
||||
shortcutRaw, err := createShortcut(s.db, create)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
shortcut := shortcutRaw.toShortcut()
|
||||
|
||||
return shortcut, nil
|
||||
}
|
||||
|
||||
func (s *Store) PatchShortcut(patch *api.ShortcutPatch) (*api.Shortcut, error) {
|
||||
shortcut, err := patchShortcut(s.db, patch)
|
||||
shortcutRaw, err := patchShortcut(s.db, patch)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
shortcut := shortcutRaw.toShortcut()
|
||||
|
||||
return shortcut, nil
|
||||
}
|
||||
|
||||
func (s *Store) FindShortcutList(find *api.ShortcutFind) ([]*api.Shortcut, error) {
|
||||
list, err := findShortcutList(s.db, find)
|
||||
shortcutRawList, err := findShortcutList(s.db, find)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
list := []*api.Shortcut{}
|
||||
for _, raw := range shortcutRawList {
|
||||
list = append(list, raw.toShortcut())
|
||||
}
|
||||
|
||||
return list, nil
|
||||
}
|
||||
|
||||
@@ -44,7 +85,9 @@ func (s *Store) FindShortcut(find *api.ShortcutFind) (*api.Shortcut, error) {
|
||||
return nil, &common.Error{Code: common.NotFound, Err: fmt.Errorf("not found")}
|
||||
}
|
||||
|
||||
return list[0], nil
|
||||
shortcut := list[0].toShortcut()
|
||||
|
||||
return shortcut, nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteShortcut(delete *api.ShortcutDelete) error {
|
||||
@@ -56,8 +99,8 @@ func (s *Store) DeleteShortcut(delete *api.ShortcutDelete) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func createShortcut(db *DB, create *api.ShortcutCreate) (*api.Shortcut, error) {
|
||||
row, err := db.Db.Query(`
|
||||
func createShortcut(db *sql.DB, create *api.ShortcutCreate) (*shortcutRaw, error) {
|
||||
row, err := db.Query(`
|
||||
INSERT INTO shortcut (
|
||||
title,
|
||||
payload,
|
||||
@@ -70,30 +113,29 @@ func createShortcut(db *DB, create *api.ShortcutCreate) (*api.Shortcut, error) {
|
||||
create.Payload,
|
||||
create.CreatorID,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, FormatError(err)
|
||||
}
|
||||
defer row.Close()
|
||||
|
||||
row.Next()
|
||||
var shortcut api.Shortcut
|
||||
var shortcutRaw shortcutRaw
|
||||
if err := row.Scan(
|
||||
&shortcut.ID,
|
||||
&shortcut.Title,
|
||||
&shortcut.Payload,
|
||||
&shortcut.CreatorID,
|
||||
&shortcut.CreatedTs,
|
||||
&shortcut.UpdatedTs,
|
||||
&shortcut.RowStatus,
|
||||
&shortcutRaw.ID,
|
||||
&shortcutRaw.Title,
|
||||
&shortcutRaw.Payload,
|
||||
&shortcutRaw.CreatorID,
|
||||
&shortcutRaw.CreatedTs,
|
||||
&shortcutRaw.UpdatedTs,
|
||||
&shortcutRaw.RowStatus,
|
||||
); err != nil {
|
||||
return nil, FormatError(err)
|
||||
}
|
||||
|
||||
return &shortcut, nil
|
||||
return &shortcutRaw, nil
|
||||
}
|
||||
|
||||
func patchShortcut(db *DB, patch *api.ShortcutPatch) (*api.Shortcut, error) {
|
||||
func patchShortcut(db *sql.DB, patch *api.ShortcutPatch) (*shortcutRaw, error) {
|
||||
set, args := []string{}, []interface{}{}
|
||||
|
||||
if v := patch.Title; v != nil {
|
||||
@@ -108,7 +150,7 @@ func patchShortcut(db *DB, patch *api.ShortcutPatch) (*api.Shortcut, error) {
|
||||
|
||||
args = append(args, patch.ID)
|
||||
|
||||
row, err := db.Db.Query(`
|
||||
row, err := db.Query(`
|
||||
UPDATE shortcut
|
||||
SET `+strings.Join(set, ", ")+`
|
||||
WHERE id = ?
|
||||
@@ -123,22 +165,22 @@ func patchShortcut(db *DB, patch *api.ShortcutPatch) (*api.Shortcut, error) {
|
||||
return nil, &common.Error{Code: common.NotFound, Err: fmt.Errorf("not found")}
|
||||
}
|
||||
|
||||
var shortcut api.Shortcut
|
||||
var shortcutRaw shortcutRaw
|
||||
if err := row.Scan(
|
||||
&shortcut.ID,
|
||||
&shortcut.Title,
|
||||
&shortcut.Payload,
|
||||
&shortcut.CreatedTs,
|
||||
&shortcut.UpdatedTs,
|
||||
&shortcut.RowStatus,
|
||||
&shortcutRaw.ID,
|
||||
&shortcutRaw.Title,
|
||||
&shortcutRaw.Payload,
|
||||
&shortcutRaw.CreatedTs,
|
||||
&shortcutRaw.UpdatedTs,
|
||||
&shortcutRaw.RowStatus,
|
||||
); err != nil {
|
||||
return nil, FormatError(err)
|
||||
}
|
||||
|
||||
return &shortcut, nil
|
||||
return &shortcutRaw, nil
|
||||
}
|
||||
|
||||
func findShortcutList(db *DB, find *api.ShortcutFind) ([]*api.Shortcut, error) {
|
||||
func findShortcutList(db *sql.DB, find *api.ShortcutFind) ([]*shortcutRaw, error) {
|
||||
where, args := []string{"1 = 1"}, []interface{}{}
|
||||
|
||||
if v := find.ID; v != nil {
|
||||
@@ -151,7 +193,7 @@ func findShortcutList(db *DB, find *api.ShortcutFind) ([]*api.Shortcut, error) {
|
||||
where, args = append(where, "title = ?"), append(args, *v)
|
||||
}
|
||||
|
||||
rows, err := db.Db.Query(`
|
||||
rows, err := db.Query(`
|
||||
SELECT
|
||||
id,
|
||||
title,
|
||||
@@ -161,7 +203,8 @@ func findShortcutList(db *DB, find *api.ShortcutFind) ([]*api.Shortcut, error) {
|
||||
updated_ts,
|
||||
row_status
|
||||
FROM shortcut
|
||||
WHERE `+strings.Join(where, " AND "),
|
||||
WHERE `+strings.Join(where, " AND ")+`
|
||||
ORDER BY created_ts DESC`,
|
||||
args...,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -169,33 +212,33 @@ func findShortcutList(db *DB, find *api.ShortcutFind) ([]*api.Shortcut, error) {
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
list := make([]*api.Shortcut, 0)
|
||||
shortcutRawList := make([]*shortcutRaw, 0)
|
||||
for rows.Next() {
|
||||
var shortcut api.Shortcut
|
||||
var shortcutRaw shortcutRaw
|
||||
if err := rows.Scan(
|
||||
&shortcut.ID,
|
||||
&shortcut.Title,
|
||||
&shortcut.Payload,
|
||||
&shortcut.CreatorID,
|
||||
&shortcut.CreatedTs,
|
||||
&shortcut.UpdatedTs,
|
||||
&shortcut.RowStatus,
|
||||
&shortcutRaw.ID,
|
||||
&shortcutRaw.Title,
|
||||
&shortcutRaw.Payload,
|
||||
&shortcutRaw.CreatorID,
|
||||
&shortcutRaw.CreatedTs,
|
||||
&shortcutRaw.UpdatedTs,
|
||||
&shortcutRaw.RowStatus,
|
||||
); err != nil {
|
||||
return nil, FormatError(err)
|
||||
}
|
||||
|
||||
list = append(list, &shortcut)
|
||||
shortcutRawList = append(shortcutRawList, &shortcutRaw)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, FormatError(err)
|
||||
}
|
||||
|
||||
return list, nil
|
||||
return shortcutRawList, nil
|
||||
}
|
||||
|
||||
func deleteShortcut(db *DB, delete *api.ShortcutDelete) error {
|
||||
result, err := db.Db.Exec(`DELETE FROM shortcut WHERE id = ?`, delete.ID)
|
||||
func deleteShortcut(db *sql.DB, delete *api.ShortcutDelete) error {
|
||||
result, err := db.Exec(`DELETE FROM shortcut WHERE id = ?`, delete.ID)
|
||||
if err != nil {
|
||||
return FormatError(err)
|
||||
}
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"memos/common"
|
||||
"os"
|
||||
"sort"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
//go:embed migration
|
||||
var migrationFS embed.FS
|
||||
|
||||
//go:embed seed
|
||||
var seedFS embed.FS
|
||||
|
||||
type DB struct {
|
||||
Db *sql.DB
|
||||
// datasource name
|
||||
DSN string
|
||||
// mode should be prod or dev
|
||||
mode string
|
||||
}
|
||||
|
||||
// NewDB returns a new instance of DB associated with the given datasource name.
|
||||
func NewDB(profile *common.Profile) *DB {
|
||||
db := &DB{
|
||||
DSN: profile.DSN,
|
||||
mode: profile.Mode,
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func (db *DB) Open() (err error) {
|
||||
// Ensure a DSN is set before attempting to open the database.
|
||||
if db.DSN == "" {
|
||||
return fmt.Errorf("dsn required")
|
||||
}
|
||||
|
||||
// Connect to the database.
|
||||
sqlDB, err := sql.Open("sqlite3", db.DSN)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open db with dsn: %s, err: %w", db.DSN, err)
|
||||
}
|
||||
|
||||
db.Db = sqlDB
|
||||
// If db file not exists, we should migrate and seed the database.
|
||||
if _, err := os.Stat(db.DSN); errors.Is(err, os.ErrNotExist) {
|
||||
if err := db.migrate(); err != nil {
|
||||
return fmt.Errorf("failed to migrate: %w", err)
|
||||
}
|
||||
if err := db.seed(); err != nil {
|
||||
return fmt.Errorf("failed to seed: %w", err)
|
||||
}
|
||||
} else {
|
||||
// If db file exists and mode is dev, we should migrate and seed the database.
|
||||
if db.mode == "dev" {
|
||||
if err := db.migrate(); err != nil {
|
||||
return fmt.Errorf("failed to migrate: %w", err)
|
||||
}
|
||||
if err := db.seed(); err != nil {
|
||||
return fmt.Errorf("failed to seed: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (db *DB) migrate() error {
|
||||
filenames, err := fs.Glob(migrationFS, fmt.Sprintf("%s/*.sql", "migration"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sort.Strings(filenames)
|
||||
|
||||
// Loop over all migration files and execute them in order.
|
||||
for _, filename := range filenames {
|
||||
if err := db.executeFile(migrationFS, filename); err != nil {
|
||||
return fmt.Errorf("migrate error: name=%q err=%w", filename, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *DB) seed() error {
|
||||
filenames, err := fs.Glob(seedFS, fmt.Sprintf("%s/*.sql", "seed"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sort.Strings(filenames)
|
||||
|
||||
// Loop over all seed files and execute them in order.
|
||||
for _, filename := range filenames {
|
||||
if err := db.executeFile(seedFS, filename); err != nil {
|
||||
return fmt.Errorf("seed error: name=%q err=%w", filename, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// executeFile runs a single seed file within a transaction.
|
||||
func (db *DB) executeFile(FS embed.FS, name string) error {
|
||||
tx, err := db.Db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Read and execute SQL file.
|
||||
if buf, err := fs.ReadFile(FS, name); err != nil {
|
||||
return err
|
||||
} else if _, err := tx.Exec(string(buf)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func FormatError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch err {
|
||||
case sql.ErrNoRows:
|
||||
return errors.New("data not found")
|
||||
default:
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,21 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/usememos/memos/server/profile"
|
||||
)
|
||||
|
||||
// Store provides database access to all raw objects
|
||||
type Store struct {
|
||||
db *DB
|
||||
db *sql.DB
|
||||
profile *profile.Profile
|
||||
}
|
||||
|
||||
// New creates a new instance of Store
|
||||
func New(db *DB) *Store {
|
||||
func New(db *sql.DB, profile *profile.Profile) *Store {
|
||||
return &Store{
|
||||
db: db,
|
||||
db: db,
|
||||
profile: profile,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,36 +1,81 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"memos/api"
|
||||
"memos/common"
|
||||
"strings"
|
||||
|
||||
"github.com/usememos/memos/api"
|
||||
"github.com/usememos/memos/common"
|
||||
)
|
||||
|
||||
// userRaw is the store model for an User.
|
||||
// Fields have exactly the same meanings as User.
|
||||
type userRaw struct {
|
||||
ID int
|
||||
|
||||
// Standard fields
|
||||
RowStatus api.RowStatus
|
||||
CreatedTs int64
|
||||
UpdatedTs int64
|
||||
|
||||
// Domain specific fields
|
||||
Email string
|
||||
Role api.Role
|
||||
Name string
|
||||
PasswordHash string
|
||||
OpenID string
|
||||
}
|
||||
|
||||
func (raw *userRaw) toUser() *api.User {
|
||||
return &api.User{
|
||||
ID: raw.ID,
|
||||
|
||||
RowStatus: raw.RowStatus,
|
||||
CreatedTs: raw.CreatedTs,
|
||||
UpdatedTs: raw.UpdatedTs,
|
||||
|
||||
Email: raw.Email,
|
||||
Role: raw.Role,
|
||||
Name: raw.Name,
|
||||
PasswordHash: raw.PasswordHash,
|
||||
OpenID: raw.OpenID,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Store) CreateUser(create *api.UserCreate) (*api.User, error) {
|
||||
user, err := createUser(s.db, create)
|
||||
userRaw, err := createUser(s.db, create)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user := userRaw.toUser()
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (s *Store) PatchUser(patch *api.UserPatch) (*api.User, error) {
|
||||
user, err := patchUser(s.db, patch)
|
||||
userRaw, err := patchUser(s.db, patch)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user := userRaw.toUser()
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (s *Store) FindUserList(find *api.UserFind) ([]*api.User, error) {
|
||||
list, err := findUserList(s.db, find)
|
||||
userRawList, err := findUserList(s.db, find)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
list := []*api.User{}
|
||||
for _, raw := range userRawList {
|
||||
list = append(list, raw.toUser())
|
||||
}
|
||||
|
||||
return list, nil
|
||||
}
|
||||
|
||||
@@ -46,11 +91,13 @@ func (s *Store) FindUser(find *api.UserFind) (*api.User, error) {
|
||||
return nil, &common.Error{Code: common.Conflict, Err: fmt.Errorf("found %d users with filter %+v, expect 1. ", len(list), find)}
|
||||
}
|
||||
|
||||
return list[0], nil
|
||||
user := list[0].toUser()
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func createUser(db *DB, create *api.UserCreate) (*api.User, error) {
|
||||
row, err := db.Db.Query(`
|
||||
func createUser(db *sql.DB, create *api.UserCreate) (*userRaw, error) {
|
||||
row, err := db.Query(`
|
||||
INSERT INTO user (
|
||||
email,
|
||||
role,
|
||||
@@ -59,7 +106,7 @@ func createUser(db *DB, create *api.UserCreate) (*api.User, error) {
|
||||
open_id
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
RETURNING id, email, role, name, password_hash, open_id, created_ts, updated_ts
|
||||
RETURNING id, email, role, name, password_hash, open_id, created_ts, updated_ts, row_status
|
||||
`,
|
||||
create.Email,
|
||||
create.Role,
|
||||
@@ -73,46 +120,50 @@ func createUser(db *DB, create *api.UserCreate) (*api.User, error) {
|
||||
defer row.Close()
|
||||
|
||||
row.Next()
|
||||
var user api.User
|
||||
var userRaw userRaw
|
||||
if err := row.Scan(
|
||||
&user.ID,
|
||||
&user.Email,
|
||||
&user.Role,
|
||||
&user.Name,
|
||||
&user.PasswordHash,
|
||||
&user.OpenID,
|
||||
&user.CreatedTs,
|
||||
&user.UpdatedTs,
|
||||
&userRaw.ID,
|
||||
&userRaw.Email,
|
||||
&userRaw.Role,
|
||||
&userRaw.Name,
|
||||
&userRaw.PasswordHash,
|
||||
&userRaw.OpenID,
|
||||
&userRaw.CreatedTs,
|
||||
&userRaw.UpdatedTs,
|
||||
&userRaw.RowStatus,
|
||||
); err != nil {
|
||||
return nil, FormatError(err)
|
||||
}
|
||||
|
||||
return &user, nil
|
||||
return &userRaw, nil
|
||||
}
|
||||
|
||||
func patchUser(db *DB, patch *api.UserPatch) (*api.User, error) {
|
||||
func patchUser(db *sql.DB, patch *api.UserPatch) (*userRaw, error) {
|
||||
set, args := []string{}, []interface{}{}
|
||||
|
||||
if v := patch.RowStatus; v != nil {
|
||||
set, args = append(set, "row_status = ?"), append(args, *v)
|
||||
}
|
||||
if v := patch.Email; v != nil {
|
||||
set, args = append(set, "email = ?"), append(args, v)
|
||||
set, args = append(set, "email = ?"), append(args, *v)
|
||||
}
|
||||
if v := patch.Name; v != nil {
|
||||
set, args = append(set, "name = ?"), append(args, v)
|
||||
set, args = append(set, "name = ?"), append(args, *v)
|
||||
}
|
||||
if v := patch.PasswordHash; v != nil {
|
||||
set, args = append(set, "password_hash = ?"), append(args, v)
|
||||
set, args = append(set, "password_hash = ?"), append(args, *v)
|
||||
}
|
||||
if v := patch.OpenID; v != nil {
|
||||
set, args = append(set, "open_id = ?"), append(args, v)
|
||||
set, args = append(set, "open_id = ?"), append(args, *v)
|
||||
}
|
||||
|
||||
args = append(args, patch.ID)
|
||||
|
||||
row, err := db.Db.Query(`
|
||||
row, err := db.Query(`
|
||||
UPDATE user
|
||||
SET `+strings.Join(set, ", ")+`
|
||||
WHERE id = ?
|
||||
RETURNING id, email, role, name, password_hash, open_id, created_ts, updated_ts
|
||||
RETURNING id, email, role, name, password_hash, open_id, created_ts, updated_ts, row_status
|
||||
`, args...)
|
||||
if err != nil {
|
||||
return nil, FormatError(err)
|
||||
@@ -120,27 +171,28 @@ func patchUser(db *DB, patch *api.UserPatch) (*api.User, error) {
|
||||
defer row.Close()
|
||||
|
||||
if row.Next() {
|
||||
var user api.User
|
||||
var userRaw userRaw
|
||||
if err := row.Scan(
|
||||
&user.ID,
|
||||
&user.Email,
|
||||
&user.Role,
|
||||
&user.Name,
|
||||
&user.PasswordHash,
|
||||
&user.OpenID,
|
||||
&user.CreatedTs,
|
||||
&user.UpdatedTs,
|
||||
&userRaw.ID,
|
||||
&userRaw.Email,
|
||||
&userRaw.Role,
|
||||
&userRaw.Name,
|
||||
&userRaw.PasswordHash,
|
||||
&userRaw.OpenID,
|
||||
&userRaw.CreatedTs,
|
||||
&userRaw.UpdatedTs,
|
||||
&userRaw.RowStatus,
|
||||
); err != nil {
|
||||
return nil, FormatError(err)
|
||||
}
|
||||
|
||||
return &user, nil
|
||||
return &userRaw, nil
|
||||
}
|
||||
|
||||
return nil, &common.Error{Code: common.NotFound, Err: fmt.Errorf("user ID not found: %d", patch.ID)}
|
||||
}
|
||||
|
||||
func findUserList(db *DB, find *api.UserFind) ([]*api.User, error) {
|
||||
func findUserList(db *sql.DB, find *api.UserFind) ([]*userRaw, error) {
|
||||
where, args := []string{"1 = 1"}, []interface{}{}
|
||||
|
||||
if v := find.ID; v != nil {
|
||||
@@ -159,7 +211,7 @@ func findUserList(db *DB, find *api.UserFind) ([]*api.User, error) {
|
||||
where, args = append(where, "open_id = ?"), append(args, *v)
|
||||
}
|
||||
|
||||
rows, err := db.Db.Query(`
|
||||
rows, err := db.Query(`
|
||||
SELECT
|
||||
id,
|
||||
email,
|
||||
@@ -168,9 +220,11 @@ func findUserList(db *DB, find *api.UserFind) ([]*api.User, error) {
|
||||
password_hash,
|
||||
open_id,
|
||||
created_ts,
|
||||
updated_ts
|
||||
updated_ts,
|
||||
row_status
|
||||
FROM user
|
||||
WHERE `+strings.Join(where, " AND "),
|
||||
WHERE `+strings.Join(where, " AND ")+`
|
||||
ORDER BY created_ts DESC`,
|
||||
args...,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -178,29 +232,30 @@ func findUserList(db *DB, find *api.UserFind) ([]*api.User, error) {
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
list := make([]*api.User, 0)
|
||||
userRawList := make([]*userRaw, 0)
|
||||
for rows.Next() {
|
||||
var user api.User
|
||||
var userRaw userRaw
|
||||
if err := rows.Scan(
|
||||
&user.ID,
|
||||
&user.Email,
|
||||
&user.Role,
|
||||
&user.Name,
|
||||
&user.PasswordHash,
|
||||
&user.OpenID,
|
||||
&user.CreatedTs,
|
||||
&user.UpdatedTs,
|
||||
&userRaw.ID,
|
||||
&userRaw.Email,
|
||||
&userRaw.Role,
|
||||
&userRaw.Name,
|
||||
&userRaw.PasswordHash,
|
||||
&userRaw.OpenID,
|
||||
&userRaw.CreatedTs,
|
||||
&userRaw.UpdatedTs,
|
||||
&userRaw.RowStatus,
|
||||
); err != nil {
|
||||
fmt.Println(err)
|
||||
return nil, FormatError(err)
|
||||
}
|
||||
|
||||
list = append(list, &user)
|
||||
userRawList = append(userRawList, &userRaw)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, FormatError(err)
|
||||
}
|
||||
|
||||
return list, nil
|
||||
return userRawList, nil
|
||||
}
|
||||
|
||||
@@ -23,7 +23,11 @@
|
||||
],
|
||||
"@typescript-eslint/no-empty-interface": ["off"],
|
||||
"@typescript-eslint/no-explicit-any": ["off"],
|
||||
"react/react-in-jsx-scope": "off",
|
||||
"@typescript-eslint/no-namespace": "off"
|
||||
"react/react-in-jsx-scope": "off"
|
||||
},
|
||||
"settings": {
|
||||
"react": {
|
||||
"version": "detect"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/logo.svg" sizes="64x64" type="image/*" />
|
||||
<link rel="icon" href="/favicon.svg" sizes="64x64" type="image/*" />
|
||||
<meta name="theme-color" content="#f6f5f4" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, minimum-scale=1, user-scalable=no" />
|
||||
<title>Memos</title>
|
||||
<script src="https://kit.fontawesome.com/41e3aaa6af.js" crossorigin="anonymous"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "memos",
|
||||
"version": "2.0.6",
|
||||
"version": "0.2.2",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
@@ -8,17 +8,24 @@
|
||||
"lint": "eslint --ext .js,.ts,.tsx, src"
|
||||
},
|
||||
"dependencies": {
|
||||
"@reduxjs/toolkit": "^1.8.1",
|
||||
"axios": "^0.27.2",
|
||||
"dayjs": "^1.11.3",
|
||||
"lodash-es": "^4.17.21",
|
||||
"qs": "^6.11.0",
|
||||
"react": "^18.1.0",
|
||||
"react-dom": "^18.1.0"
|
||||
"react-dom": "^18.1.0",
|
||||
"react-redux": "^8.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/lodash-es": "^4.17.5",
|
||||
"@types/react": "^18.0.8",
|
||||
"@types/react-dom": "^18.0.3",
|
||||
"@types/node": "^18.0.3",
|
||||
"@types/qs": "^6.9.7",
|
||||
"@types/react": "^18.0.9",
|
||||
"@types/react-dom": "^18.0.4",
|
||||
"@typescript-eslint/eslint-plugin": "^5.6.0",
|
||||
"@typescript-eslint/parser": "^5.6.0",
|
||||
"@vitejs/plugin-react": "^1.0.0",
|
||||
"@vitejs/plugin-react": "^2.0.0",
|
||||
"autoprefixer": "^10.4.2",
|
||||
"eslint": "^8.4.1",
|
||||
"eslint-config-prettier": "^8.3.0",
|
||||
@@ -29,7 +36,6 @@
|
||||
"prettier": "2.5.1",
|
||||
"tailwindcss": "^3.0.18",
|
||||
"typescript": "^4.3.2",
|
||||
"vite": "^2.9.0"
|
||||
},
|
||||
"license": "MIT"
|
||||
"vite": "^3.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 121 B After Width: | Height: | Size: 121 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48"><path d="M22.5 38V25.5H10V22.5H22.5V10H25.5V22.5H38V25.5H25.5V38Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 137 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#37352f"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12l4.58-4.59z"/></svg>
|
||||
|
Before Width: | Height: | Size: 214 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#FFFFFF"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6-6-6z"/></svg>
|
||||
|
Before Width: | Height: | Size: 209 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#37352f"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6-6-6z"/></svg>
|
||||
|
Before Width: | Height: | Size: 209 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#37352f"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V5h14v14zM17.99 9l-1.41-1.42-6.59 6.59-2.58-2.57-1.42 1.41 4 3.99z"/></svg>
|
||||
|
Before Width: | Height: | Size: 308 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#37352f"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M19 5v14H5V5h14m0-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2z"/></svg>
|
||||
|
Before Width: | Height: | Size: 249 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#37352f"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z"/></svg>
|
||||
|
Before Width: | Height: | Size: 268 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#FFFFFF"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M14.06 9.02l.92.92L5.92 19H5v-.92l9.06-9.06M17.66 3c-.25 0-.51.1-.7.29l-1.83 1.83 3.75 3.75 1.83-1.83c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.2-.2-.45-.29-.71-.29zm-3.6 3.19L3 17.25V21h3.75L17.81 9.94l-3.75-3.75z"/></svg>
|
||||
|
Before Width: | Height: | Size: 367 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#37352f"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M14.06 9.02l.92.92L5.92 19H5v-.92l9.06-9.06M17.66 3c-.25 0-.51.1-.7.29l-1.83 1.83 3.75 3.75 1.83-1.83c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.2-.2-.45-.29-.71-.29zm-3.6 3.19L3 17.25V21h3.75L17.81 9.94l-3.75-3.75z"/></svg>
|
||||
|
Before Width: | Height: | Size: 367 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M19 5v14H5V5h14m0-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-4.86 8.86l-3 3.87L9 13.14 6 17h12l-3.86-5.14z"/></svg>
|
||||
|
Before Width: | Height: | Size: 296 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#37352f"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M3 18h18v-2H3v2zm0-5h18v-2H3v2zm0-7v2h18V6H3z"/></svg>
|
||||
|
Before Width: | Height: | Size: 204 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#FFFFFF"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M6 10c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm12 0c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm-6 0c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"/></svg>
|
||||
|
Before Width: | Height: | Size: 306 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#37352f"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M6 10c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm12 0c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm-6 0c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"/></svg>
|
||||
|
Before Width: | Height: | Size: 306 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#37352f"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/></svg>
|
||||
|
Before Width: | Height: | Size: 393 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" enable-background="new 0 0 24 24" height="24px" viewBox="0 0 24 24" width="24px" fill="#37352f"><g><rect fill="none" height="24" width="24"/></g><g><path d="M16,5l-1.42,1.42l-1.59-1.59V16h-1.98V4.83L9.42,6.42L8,5l4-4L16,5z M20,10v11c0,1.1-0.9,2-2,2H6c-1.11,0-2-0.9-2-2V10 c0-1.11,0.89-2,2-2h3v2H6v11h12V10h-3V8h3C19.1,8,20,8.89,20,10z"/></g></svg>
|
||||
|
Before Width: | Height: | Size: 387 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" enable-background="new 0 0 24 24" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000"><g><rect fill="none" height="24" width="24"/></g><g><path d="M20,10V8h-4V4h-2v4h-4V4H8v4H4v2h4v4H4v2h4v4h2v-4h4v4h2v-4h4v-2h-4v-4H20z M14,14h-4v-4h4V14z"/></g></svg>
|
||||
|
Before Width: | Height: | Size: 301 B |
@@ -1,12 +1,8 @@
|
||||
import { useContext } from "react";
|
||||
import appContext from "./stores/appContext";
|
||||
import { appRouterSwitch } from "./routers";
|
||||
import "./less/app.less";
|
||||
import { useAppSelector } from "./store";
|
||||
|
||||
function App() {
|
||||
const {
|
||||
locationState: { pathname },
|
||||
} = useContext(appContext);
|
||||
const pathname = useAppSelector((state) => state.location.pathname);
|
||||
|
||||
return <>{appRouterSwitch(pathname)}</>;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import api from "../helpers/api";
|
||||
import * as api from "../helpers/api";
|
||||
import Only from "./common/OnlyWhen";
|
||||
import { showDialog } from "./Dialog";
|
||||
import GitHubBadge from "./GitHubBadge";
|
||||
import "../less/about-site-dialog.less";
|
||||
|
||||
interface Props extends DialogProps {}
|
||||
@@ -11,7 +12,10 @@ const AboutSiteDialog: React.FC<Props> = ({ destroy }: Props) => {
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
api.getSystemStatus().then(({ profile }) => {
|
||||
api.getSystemStatus().then(({ data }) => {
|
||||
const {
|
||||
data: { profile },
|
||||
} = data;
|
||||
setProfile(profile);
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -33,22 +37,26 @@ const AboutSiteDialog: React.FC<Props> = ({ destroy }: Props) => {
|
||||
<span className="icon-text">🤠</span>About <b>Memos</b>
|
||||
</p>
|
||||
<button className="btn close-btn" onClick={handleCloseBtnClick}>
|
||||
<img className="icon-img" src="/icons/close.svg" />
|
||||
<i className="fa-solid fa-xmark fa-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div className="dialog-content-container">
|
||||
<p>
|
||||
Memos is an open source, quickly self-hosted alternative to <a href="https://flomoapp.com">flomo</a>.
|
||||
Memos is an <i>open source</i>, <i>self-hosted</i> knowledge base that works with a SQLite db file.
|
||||
</p>
|
||||
<br />
|
||||
<p>
|
||||
<a href="https://github.com/justmemos/memos">🏗 Source code</a>, and built by <a href="https://github.com/boojack">Steven 🐯</a>.
|
||||
</p>
|
||||
<Only when={profile !== undefined}>
|
||||
<p className="updated-time-text">
|
||||
version: <span className="pre-text">{profile?.version}</span> 🎉
|
||||
</p>
|
||||
</Only>
|
||||
<div className="addtion-info-container">
|
||||
<GitHubBadge />
|
||||
<Only when={profile !== undefined}>
|
||||
<>
|
||||
version:
|
||||
<span className="pre-text">
|
||||
{profile?.version}-{profile?.mode}
|
||||
</span>
|
||||
🎉
|
||||
</>
|
||||
</Only>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { IMAGE_URL_REG } from "../helpers/consts";
|
||||
import utils from "../helpers/utils";
|
||||
import * as utils from "../helpers/utils";
|
||||
import useToggle from "../hooks/useToggle";
|
||||
import { memoService } from "../services";
|
||||
import Only from "./common/OnlyWhen";
|
||||
@@ -9,25 +9,24 @@ import { formatMemoContent } from "./Memo";
|
||||
import "../less/memo.less";
|
||||
|
||||
interface Props {
|
||||
memo: Model.Memo;
|
||||
handleDeletedMemoAction: (memoId: string) => void;
|
||||
memo: Memo;
|
||||
}
|
||||
|
||||
const DeletedMemo: React.FC<Props> = (props: Props) => {
|
||||
const { memo: propsMemo, handleDeletedMemoAction } = props;
|
||||
const memo: FormattedMemo = {
|
||||
const ArchivedMemo: React.FC<Props> = (props: Props) => {
|
||||
const { memo: propsMemo } = props;
|
||||
const memo = {
|
||||
...propsMemo,
|
||||
createdAtStr: utils.getDateTimeString(propsMemo.createdAt),
|
||||
deletedAtStr: utils.getDateTimeString(propsMemo.updatedAt ?? Date.now()),
|
||||
createdAtStr: utils.getDateTimeString(propsMemo.createdTs),
|
||||
archivedAtStr: utils.getDateTimeString(propsMemo.updatedTs ?? Date.now()),
|
||||
};
|
||||
const [showConfirmDeleteBtn, toggleConfirmDeleteBtn] = useToggle(false);
|
||||
const imageUrls = Array.from(memo.content.match(IMAGE_URL_REG) ?? []);
|
||||
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);
|
||||
handleDeletedMemoAction(memo.id);
|
||||
await memoService.fetchAllMemos();
|
||||
} catch (error: any) {
|
||||
toastHelper.error(error.message);
|
||||
}
|
||||
@@ -38,8 +37,11 @@ const DeletedMemo: React.FC<Props> = (props: Props) => {
|
||||
|
||||
const handleRestoreMemoClick = async () => {
|
||||
try {
|
||||
await memoService.restoreMemoById(memo.id);
|
||||
handleDeletedMemoAction(memo.id);
|
||||
await memoService.patchMemo({
|
||||
id: memo.id,
|
||||
rowStatus: "NORMAL",
|
||||
});
|
||||
await memoService.fetchAllMemos();
|
||||
toastHelper.info("Restored successfully");
|
||||
} catch (error: any) {
|
||||
toastHelper.error(error.message);
|
||||
@@ -53,9 +55,9 @@ const DeletedMemo: React.FC<Props> = (props: Props) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`memo-wrapper deleted-memo ${"memos-" + memo.id}`} onMouseLeave={handleMouseLeaveMemoWrapper}>
|
||||
<div className={`memo-wrapper archived-memo ${"memos-" + memo.id}`} onMouseLeave={handleMouseLeaveMemoWrapper}>
|
||||
<div className="memo-top-wrapper">
|
||||
<span className="time-text">Deleted at {memo.deletedAtStr}</span>
|
||||
<span className="time-text">Archived at {memo.archivedAtStr}</span>
|
||||
<div className="btns-container">
|
||||
<span className="btn restore-btn" onClick={handleRestoreMemoClick}>
|
||||
Restore
|
||||
@@ -77,4 +79,4 @@ const DeletedMemo: React.FC<Props> = (props: Props) => {
|
||||
);
|
||||
};
|
||||
|
||||
export default DeletedMemo;
|
||||
export default ArchivedMemo;
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import useLoading from "../hooks/useLoading";
|
||||
import { memoService } from "../services";
|
||||
import { useAppSelector } from "../store";
|
||||
import { showDialog } from "./Dialog";
|
||||
import toastHelper from "./Toast";
|
||||
import ArchivedMemo from "./ArchivedMemo";
|
||||
import "../less/archived-memo-dialog.less";
|
||||
|
||||
interface Props extends DialogProps {}
|
||||
|
||||
const ArchivedMemoDialog: React.FC<Props> = (props: Props) => {
|
||||
const { destroy } = props;
|
||||
const memos = useAppSelector((state) => state.memo.memos);
|
||||
const loadingState = useLoading();
|
||||
const [archivedMemos, setArchivedMemos] = useState<Memo[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
memoService
|
||||
.fetchArchivedMemos()
|
||||
.then((result) => {
|
||||
setArchivedMemos(result);
|
||||
})
|
||||
.catch((error) => {
|
||||
toastHelper.error("Failed to fetch archived memos: ", error);
|
||||
})
|
||||
.finally(() => {
|
||||
loadingState.setFinish();
|
||||
});
|
||||
}, [memos]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="dialog-header-container">
|
||||
<p className="title-text">
|
||||
<span className="icon-text">🗂</span>
|
||||
Archived Memos
|
||||
</p>
|
||||
<button className="btn close-btn" onClick={destroy}>
|
||||
<i className="fa-solid fa-xmark fa-lg icon-img"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div className="dialog-content-container">
|
||||
{loadingState.isLoading ? (
|
||||
<div className="tip-text-container">
|
||||
<p className="tip-text">fetching data...</p>
|
||||
</div>
|
||||
) : archivedMemos.length === 0 ? (
|
||||
<div className="tip-text-container">
|
||||
<p className="tip-text">Here is No Zettels.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="archived-memos-container">
|
||||
{archivedMemos.map((memo) => (
|
||||
<ArchivedMemo key={`${memo.id}-${memo.updatedTs}`} memo={memo} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default function showArchivedMemo(): void {
|
||||
showDialog(
|
||||
{
|
||||
className: "archived-memo-dialog",
|
||||
useAppContext: true,
|
||||
},
|
||||
ArchivedMemoDialog,
|
||||
{}
|
||||
);
|
||||
}
|
||||
@@ -55,7 +55,9 @@ const ChangePasswordDialog: React.FC<Props> = ({ destroy }: Props) => {
|
||||
}
|
||||
|
||||
try {
|
||||
await userService.updatePassword(newPassword);
|
||||
await userService.patchUser({
|
||||
password: newPassword,
|
||||
});
|
||||
toastHelper.info("Password changed.");
|
||||
handleCloseBtnClick();
|
||||
} catch (error: any) {
|
||||
@@ -68,7 +70,7 @@ const ChangePasswordDialog: React.FC<Props> = ({ destroy }: Props) => {
|
||||
<div className="dialog-header-container">
|
||||
<p className="title-text">Change Password</p>
|
||||
<button className="btn close-btn" onClick={handleCloseBtnClick}>
|
||||
<img className="icon-img" src="/icons/close.svg" />
|
||||
<i className="fa-solid fa-xmark fa-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div className="dialog-content-container">
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useEffect } from "react";
|
||||
import { showDialog } from "./Dialog";
|
||||
import useLoading from "../hooks/useLoading";
|
||||
import toastHelper from "./Toast";
|
||||
import { userService } from "../services";
|
||||
import useLoading from "../hooks/useLoading";
|
||||
import { showDialog } from "./Dialog";
|
||||
import toastHelper from "./Toast";
|
||||
import "../less/confirm-reset-openid-dialog.less";
|
||||
|
||||
interface Props extends DialogProps {}
|
||||
@@ -25,7 +25,9 @@ const ConfirmResetOpenIdDialog: React.FC<Props> = ({ destroy }: Props) => {
|
||||
|
||||
resetBtnClickLoadingState.setLoading();
|
||||
try {
|
||||
await userService.resetOpenId();
|
||||
await userService.patchUser({
|
||||
resetOpenId: true,
|
||||
});
|
||||
} catch (error) {
|
||||
toastHelper.error("Request reset open API failed.");
|
||||
return;
|
||||
@@ -39,12 +41,12 @@ const ConfirmResetOpenIdDialog: React.FC<Props> = ({ destroy }: Props) => {
|
||||
<div className="dialog-header-container">
|
||||
<p className="title-text">Reset Open API</p>
|
||||
<button className="btn close-btn" onClick={handleCloseBtnClick}>
|
||||
<img className="icon-img" src="/icons/close.svg" />
|
||||
<i className="fa-solid fa-xmark fa-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div className="dialog-content-container">
|
||||
<p className="warn-text">
|
||||
⚠️ The existing API will be invalidated and a new one will be generated, are you sure you want to reset?
|
||||
❗️The existing API will be invalidated and a new one will be generated, are you sure you want to reset?
|
||||
</p>
|
||||
<div className="btns-container">
|
||||
<span className="btn cancel-btn" onClick={handleCloseBtnClick}>
|
||||
|
||||
@@ -8,7 +8,7 @@ import Selector from "./common/Selector";
|
||||
import "../less/create-shortcut-dialog.less";
|
||||
|
||||
interface Props extends DialogProps {
|
||||
shortcutId?: string;
|
||||
shortcutId?: ShortcutId;
|
||||
}
|
||||
|
||||
const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
|
||||
@@ -23,12 +23,14 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
|
||||
}).length;
|
||||
|
||||
useEffect(() => {
|
||||
const shortcutTemp = shortcutService.getShortcutById(shortcutId ?? "");
|
||||
if (shortcutTemp) {
|
||||
setTitle(shortcutTemp.title);
|
||||
const temp = JSON.parse(shortcutTemp.payload);
|
||||
if (Array.isArray(temp)) {
|
||||
setFilters(temp);
|
||||
if (shortcutId) {
|
||||
const shortcutTemp = shortcutService.getShortcutById(shortcutId);
|
||||
if (shortcutTemp) {
|
||||
setTitle(shortcutTemp.title);
|
||||
const temp = JSON.parse(shortcutTemp.payload);
|
||||
if (Array.isArray(temp)) {
|
||||
setFilters(temp);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [shortcutId]);
|
||||
@@ -46,11 +48,16 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
|
||||
|
||||
try {
|
||||
if (shortcutId) {
|
||||
const editedShortcut = await shortcutService.updateShortcut(shortcutId, title, JSON.stringify(filters));
|
||||
shortcutService.editShortcut(shortcutService.convertResponseModelShortcut(editedShortcut));
|
||||
await shortcutService.patchShortcut({
|
||||
id: shortcutId,
|
||||
title,
|
||||
payload: JSON.stringify(filters),
|
||||
});
|
||||
} else {
|
||||
const shortcut = await shortcutService.createShortcut(title, JSON.stringify(filters));
|
||||
shortcutService.pushShortcut(shortcutService.convertResponseModelShortcut(shortcut));
|
||||
await shortcutService.createShortcut({
|
||||
title,
|
||||
payload: JSON.stringify(filters),
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
toastHelper.error(error.message);
|
||||
@@ -93,7 +100,7 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
|
||||
{shortcutId ? "Edit Shortcut" : "Create Shortcut"}
|
||||
</p>
|
||||
<button className="btn close-btn" onClick={destroy}>
|
||||
<img className="icon-img" src="/icons/close.svg" />
|
||||
<i className="fa-solid fa-xmark fa-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div className="dialog-content-container">
|
||||
@@ -145,6 +152,7 @@ interface MemoFilterInputerProps {
|
||||
|
||||
const FilterInputer: React.FC<MemoFilterInputerProps> = (props: MemoFilterInputerProps) => {
|
||||
const { index, filter, handleFilterChange, handleFilterRemove } = props;
|
||||
const tags = Array.from(memoService.getState().tags);
|
||||
const { type } = filter;
|
||||
const [inputElements, setInputElements] = useState<JSX.Element>(<></>);
|
||||
|
||||
@@ -178,12 +186,9 @@ const FilterInputer: React.FC<MemoFilterInputerProps> = (props: MemoFilterInpute
|
||||
valueElement = (
|
||||
<Selector
|
||||
className="value-selector"
|
||||
dataSource={memoService
|
||||
.getState()
|
||||
.tags.sort()
|
||||
.map((t) => {
|
||||
return { text: t, value: t };
|
||||
})}
|
||||
dataSource={tags.sort().map((t) => {
|
||||
return { text: t, value: t };
|
||||
})}
|
||||
value={filter.value.value}
|
||||
handleValueChanged={handleValueChange}
|
||||
/>
|
||||
@@ -291,14 +296,14 @@ const FilterInputer: React.FC<MemoFilterInputerProps> = (props: MemoFilterInpute
|
||||
/>
|
||||
|
||||
{inputElements}
|
||||
<img className="remove-btn" src="/icons/close.svg" onClick={handleRemoveBtnClick} />
|
||||
<i className="fa-solid fa-xmark remove-btn" onClick={handleRemoveBtnClick}></i>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const MemoFilterInputer: React.FC<MemoFilterInputerProps> = memo(FilterInputer);
|
||||
|
||||
export default function showCreateShortcutDialog(shortcutId?: string): void {
|
||||
export default function showCreateShortcutDialog(shortcutId?: ShortcutId): void {
|
||||
showDialog(
|
||||
{
|
||||
className: "create-shortcut-dialog",
|
||||
|
||||
@@ -1,25 +1,26 @@
|
||||
import { IMAGE_URL_REG } from "../helpers/consts";
|
||||
import utils from "../helpers/utils";
|
||||
import { formatMemoContent } from "./Memo";
|
||||
import * as utils from "../helpers/utils";
|
||||
import Only from "./common/OnlyWhen";
|
||||
import { formatMemoContent } from "./Memo";
|
||||
import "../less/daily-memo.less";
|
||||
|
||||
interface DailyMemo extends FormattedMemo {
|
||||
interface DailyMemo extends Memo {
|
||||
createdAtStr: string;
|
||||
timeStr: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
memo: Model.Memo;
|
||||
memo: Memo;
|
||||
}
|
||||
|
||||
const DailyMemo: React.FC<Props> = (props: Props) => {
|
||||
const { memo: propsMemo } = props;
|
||||
const memo: DailyMemo = {
|
||||
...propsMemo,
|
||||
createdAtStr: utils.getDateTimeString(propsMemo.createdAt),
|
||||
timeStr: utils.getTimeString(propsMemo.createdAt),
|
||||
createdAtStr: utils.getDateTimeString(propsMemo.createdTs),
|
||||
timeStr: utils.getTimeString(propsMemo.createdTs),
|
||||
};
|
||||
const imageUrls = Array.from(memo.content.match(IMAGE_URL_REG) ?? []);
|
||||
const imageUrls = Array.from(memo.content.match(IMAGE_URL_REG) ?? []).map((s) => s.replace(IMAGE_URL_REG, "$1"));
|
||||
|
||||
return (
|
||||
<div className="daily-memo-wrapper">
|
||||
@@ -31,11 +32,12 @@ const DailyMemo: React.FC<Props> = (props: Props) => {
|
||||
<Only when={imageUrls.length > 0}>
|
||||
<div className="images-container">
|
||||
{imageUrls.map((imgUrl, idx) => (
|
||||
<img key={idx} crossOrigin="anonymous" src={imgUrl} decoding="async" />
|
||||
<img key={idx} src={imgUrl} decoding="async" />
|
||||
))}
|
||||
</div>
|
||||
</Only>
|
||||
</div>
|
||||
<div className="split-line"></div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { memoService } from "../services";
|
||||
import toImage from "../labs/html2image";
|
||||
import useToggle from "../hooks/useToggle";
|
||||
import useLoading from "../hooks/useLoading";
|
||||
import { DAILY_TIMESTAMP } from "../helpers/consts";
|
||||
import utils from "../helpers/utils";
|
||||
import { showDialog } from "./Dialog";
|
||||
import showPreviewImageDialog from "./PreviewImageDialog";
|
||||
import DailyMemo from "./DailyMemo";
|
||||
import DatePicker from "./common/DatePicker";
|
||||
import "../less/daily-memo-diary-dialog.less";
|
||||
|
||||
interface Props extends DialogProps {
|
||||
currentDateStamp: DateStamp;
|
||||
}
|
||||
|
||||
const monthChineseStrArray = ["一月", "二月", "三月", "四月", "五月", "六月", "七月", "八月", "九月", "十月", "十一月", "十二月"];
|
||||
const weekdayChineseStrArray = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"];
|
||||
|
||||
const DailyMemoDiaryDialog: React.FC<Props> = (props: Props) => {
|
||||
const loadingState = useLoading();
|
||||
const [memos, setMemos] = useState<Model.Memo[]>([]);
|
||||
const [currentDateStamp, setCurrentDateStamp] = useState(utils.getDateStampByDate(utils.getDateString(props.currentDateStamp)));
|
||||
const [showDatePicker, toggleShowDatePicker] = useToggle(false);
|
||||
const memosElRef = useRef<HTMLDivElement>(null);
|
||||
const currentDate = new Date(currentDateStamp);
|
||||
|
||||
useEffect(() => {
|
||||
const setDailyMemos = () => {
|
||||
const dailyMemos = memoService
|
||||
.getState()
|
||||
.memos.filter(
|
||||
(a) =>
|
||||
utils.getTimeStampByDate(a.createdAt) >= currentDateStamp &&
|
||||
utils.getTimeStampByDate(a.createdAt) < currentDateStamp + DAILY_TIMESTAMP
|
||||
)
|
||||
.sort((a, b) => utils.getTimeStampByDate(a.createdAt) - utils.getTimeStampByDate(b.createdAt));
|
||||
setMemos(dailyMemos);
|
||||
loadingState.setFinish();
|
||||
};
|
||||
|
||||
setDailyMemos();
|
||||
}, [currentDateStamp]);
|
||||
|
||||
const handleShareBtnClick = () => {
|
||||
toggleShowDatePicker(false);
|
||||
|
||||
setTimeout(() => {
|
||||
if (!memosElRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
toImage(memosElRef.current, {
|
||||
backgroundColor: "#ffffff",
|
||||
pixelRatio: window.devicePixelRatio * 2,
|
||||
})
|
||||
.then((url) => {
|
||||
showPreviewImageDialog(url);
|
||||
})
|
||||
.catch(() => {
|
||||
// do nth
|
||||
});
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const handleDataPickerChange = (datestamp: DateStamp): void => {
|
||||
setCurrentDateStamp(datestamp);
|
||||
toggleShowDatePicker(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="dialog-header-container">
|
||||
<div className="header-wrapper">
|
||||
<p className="title-text">Daily Memos</p>
|
||||
<div className="btns-container">
|
||||
<span className="btn-text" onClick={() => setCurrentDateStamp(currentDateStamp - DAILY_TIMESTAMP)}>
|
||||
<img className="icon-img" src="/icons/arrow-left.svg" />
|
||||
</span>
|
||||
<span className="btn-text" onClick={() => setCurrentDateStamp(currentDateStamp + DAILY_TIMESTAMP)}>
|
||||
<img className="icon-img" src="/icons/arrow-right.svg" />
|
||||
</span>
|
||||
<span className="btn-text share-btn" onClick={handleShareBtnClick}>
|
||||
<img className="icon-img" src="/icons/share.svg" />
|
||||
</span>
|
||||
<span className="btn-text" onClick={() => props.destroy()}>
|
||||
<img className="icon-img" src="/icons/close.svg" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="dialog-content-container" ref={memosElRef}>
|
||||
<div className="date-card-container" onClick={() => toggleShowDatePicker()}>
|
||||
<div className="year-text">{currentDate.getFullYear()}</div>
|
||||
<div className="date-container">
|
||||
<div className="month-text">{monthChineseStrArray[currentDate.getMonth()]}</div>
|
||||
<div className="date-text">{currentDate.getDate()}</div>
|
||||
<div className="day-text">{weekdayChineseStrArray[currentDate.getDay()]}</div>
|
||||
</div>
|
||||
</div>
|
||||
<DatePicker
|
||||
className={`date-picker ${showDatePicker ? "" : "hidden"}`}
|
||||
datestamp={currentDateStamp}
|
||||
handleDateStampChange={handleDataPickerChange}
|
||||
/>
|
||||
{loadingState.isLoading ? (
|
||||
<div className="tip-container">
|
||||
<p className="tip-text">Loading...</p>
|
||||
</div>
|
||||
) : memos.length === 0 ? (
|
||||
<div className="tip-container">
|
||||
<p className="tip-text">Oops, there is nothing.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="dailymemos-wrapper">
|
||||
{memos.map((memo) => (
|
||||
<DailyMemo key={`${memo.id}-${memo.updatedAt}`} memo={memo} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default function showDailyMemoDiaryDialog(datestamp: DateStamp = Date.now()): void {
|
||||
showDialog(
|
||||
{
|
||||
className: "daily-memo-diary-dialog",
|
||||
},
|
||||
DailyMemoDiaryDialog,
|
||||
{ currentDateStamp: datestamp }
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { useAppSelector } from "../store";
|
||||
import toImage from "../labs/html2image";
|
||||
import useToggle from "../hooks/useToggle";
|
||||
import { DAILY_TIMESTAMP } from "../helpers/consts";
|
||||
import * as utils from "../helpers/utils";
|
||||
import { showDialog } from "./Dialog";
|
||||
import DatePicker from "./common/DatePicker";
|
||||
import showPreviewImageDialog from "./PreviewImageDialog";
|
||||
import DailyMemo from "./DailyMemo";
|
||||
import "../less/daily-review-dialog.less";
|
||||
|
||||
interface Props extends DialogProps {
|
||||
currentDateStamp: DateStamp;
|
||||
}
|
||||
|
||||
const monthChineseStrArray = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sept", "Oct", "Nov", "Dev"];
|
||||
const weekdayChineseStrArray = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||
|
||||
const DailyReviewDialog: React.FC<Props> = (props: Props) => {
|
||||
const memos = useAppSelector((state) => state.memo.memos);
|
||||
const [currentDateStamp, setCurrentDateStamp] = useState(utils.getDateStampByDate(utils.getDateString(props.currentDateStamp)));
|
||||
const [showDatePicker, toggleShowDatePicker] = useToggle(false);
|
||||
const memosElRef = useRef<HTMLDivElement>(null);
|
||||
const currentDate = new Date(currentDateStamp);
|
||||
const dailyMemos = memos
|
||||
.filter(
|
||||
(m) =>
|
||||
m.rowStatus === "NORMAL" &&
|
||||
utils.getTimeStampByDate(m.createdTs) >= currentDateStamp &&
|
||||
utils.getTimeStampByDate(m.createdTs) < currentDateStamp + DAILY_TIMESTAMP
|
||||
)
|
||||
.sort((a, b) => utils.getTimeStampByDate(a.createdTs) - utils.getTimeStampByDate(b.createdTs));
|
||||
|
||||
const handleShareBtnClick = () => {
|
||||
if (!memosElRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
toggleShowDatePicker(false);
|
||||
|
||||
toImage(memosElRef.current, {
|
||||
backgroundColor: "#ffffff",
|
||||
pixelRatio: window.devicePixelRatio * 2,
|
||||
})
|
||||
.then((url) => {
|
||||
showPreviewImageDialog(url);
|
||||
})
|
||||
.catch(() => {
|
||||
// do nth
|
||||
});
|
||||
};
|
||||
|
||||
const handleDataPickerChange = (datestamp: DateStamp): void => {
|
||||
setCurrentDateStamp(datestamp);
|
||||
toggleShowDatePicker(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="dialog-header-container">
|
||||
<p className="title-text" onClick={() => toggleShowDatePicker()}>
|
||||
<span className="icon-text">📅</span> Daily Review
|
||||
</p>
|
||||
<div className="btns-container">
|
||||
<button className="btn-text" onClick={() => setCurrentDateStamp(currentDateStamp - DAILY_TIMESTAMP)}>
|
||||
<i className="fa-solid fa-chevron-left icon-img"></i>
|
||||
</button>
|
||||
<button className="btn-text" onClick={() => setCurrentDateStamp(currentDateStamp + DAILY_TIMESTAMP)}>
|
||||
<i className="fa-solid fa-chevron-right icon-img"></i>
|
||||
</button>
|
||||
<button className="btn-text" onClick={handleShareBtnClick}>
|
||||
<i className="fa-solid fa-share-nodes icon-img"></i>
|
||||
</button>
|
||||
<button className="btn-text" onClick={() => props.destroy()}>
|
||||
<i className="fa-solid fa-xmark fa-lg icon-img"></i>
|
||||
</button>
|
||||
</div>
|
||||
<DatePicker
|
||||
className={`date-picker ${showDatePicker ? "" : "!hidden"}`}
|
||||
datestamp={currentDateStamp}
|
||||
handleDateStampChange={handleDataPickerChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="dialog-content-container" ref={memosElRef}>
|
||||
<div className="date-card-container">
|
||||
<div className="year-text">{currentDate.getFullYear()}</div>
|
||||
<div className="date-container">
|
||||
<div className="month-text">{monthChineseStrArray[currentDate.getMonth()]}</div>
|
||||
<div className="date-text">{currentDate.getDate()}</div>
|
||||
<div className="day-text">{weekdayChineseStrArray[currentDate.getDay()]}</div>
|
||||
</div>
|
||||
</div>
|
||||
{dailyMemos.length === 0 ? (
|
||||
<div className="tip-container">
|
||||
<p className="tip-text">Oops, there is nothing.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="dailymemos-wrapper">
|
||||
{dailyMemos.map((memo) => (
|
||||
<DailyMemo key={`${memo.id}-${memo.updatedTs}`} memo={memo} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default function showDailyReviewDialog(datestamp: DateStamp = Date.now()): void {
|
||||
showDialog(
|
||||
{
|
||||
className: "daily-review-dialog",
|
||||
useAppContext: true,
|
||||
},
|
||||
DailyReviewDialog,
|
||||
{ currentDateStamp: datestamp }
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
import { createRoot } from "react-dom/client";
|
||||
import appContext from "../stores/appContext";
|
||||
import Provider from "../labs/Provider";
|
||||
import appStore from "../stores/appStore";
|
||||
import { ANIMATION_DURATION } from "../helpers/consts";
|
||||
import { Provider } from "react-redux";
|
||||
import store from "../store";
|
||||
import "../less/dialog.less";
|
||||
|
||||
interface DialogConfig {
|
||||
@@ -69,11 +68,7 @@ export function showDialog<T extends DialogProps>(
|
||||
);
|
||||
|
||||
if (config.useAppContext) {
|
||||
Fragment = (
|
||||
<Provider store={appStore} context={appContext}>
|
||||
{Fragment}
|
||||
</Provider>
|
||||
);
|
||||
Fragment = <Provider store={store}>{Fragment}</Provider>;
|
||||
}
|
||||
|
||||
dialog.render(Fragment);
|
||||
|
||||
@@ -15,6 +15,7 @@ interface EditorProps {
|
||||
className: string;
|
||||
initialContent: string;
|
||||
placeholder: string;
|
||||
fullscreen: boolean;
|
||||
showConfirmBtn: boolean;
|
||||
showCancelBtn: boolean;
|
||||
tools?: ReactNode;
|
||||
@@ -29,6 +30,7 @@ const Editor = forwardRef((props: EditorProps, ref: React.ForwardedRef<EditorRef
|
||||
className,
|
||||
initialContent,
|
||||
placeholder,
|
||||
fullscreen,
|
||||
showConfirmBtn,
|
||||
showCancelBtn,
|
||||
onConfirmBtnClick: handleConfirmBtnClickCallback,
|
||||
@@ -45,11 +47,11 @@ const Editor = forwardRef((props: EditorProps, ref: React.ForwardedRef<EditorRef
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (editorRef.current) {
|
||||
if (editorRef.current && !fullscreen) {
|
||||
editorRef.current.style.height = "auto";
|
||||
editorRef.current.style.height = (editorRef.current.scrollHeight ?? 0) + "px";
|
||||
}
|
||||
}, [editorRef.current?.value]);
|
||||
}, [editorRef.current?.value, fullscreen]);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import * as api from "../helpers/api";
|
||||
import "../less/github-badge.less";
|
||||
|
||||
interface Props {}
|
||||
|
||||
const GitHubBadge: React.FC<Props> = () => {
|
||||
const [starCount, setStarCount] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
api.getRepoStarCount().then((data) => {
|
||||
setStarCount(data);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<a className="github-badge-container" href="https://github.com/usememos/memos">
|
||||
<div className="github-icon">
|
||||
<i className="fa-brands fa-github fa-lg icon-img"></i>
|
||||
Star
|
||||
</div>
|
||||
<div className="count-text">
|
||||
{starCount || ""}
|
||||
<span className="icon-text">🌟</span>
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
export default GitHubBadge;
|
||||
@@ -1,29 +1,67 @@
|
||||
import { memo } from "react";
|
||||
import { escape } from "lodash-es";
|
||||
import { IMAGE_URL_REG, LINK_REG, MEMO_LINK_REG, TAG_REG } from "../helpers/consts";
|
||||
import { parseMarkedToHtml, parseRawTextToHtml } from "../helpers/marked";
|
||||
import utils from "../helpers/utils";
|
||||
import useToggle from "../hooks/useToggle";
|
||||
import { globalStateService, memoService } from "../services";
|
||||
import { memo, useEffect, useRef, useState } from "react";
|
||||
import { escape, indexOf } from "lodash-es";
|
||||
import dayjs from "dayjs";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
import { IMAGE_URL_REG, LINK_URL_REG, MEMO_LINK_REG, TAG_REG, UNKNOWN_ID } from "../helpers/consts";
|
||||
import { DONE_BLOCK_REG, parseMarkedToHtml, TODO_BLOCK_REG } from "../helpers/marked";
|
||||
import { editorStateService, locationService, memoService, userService } from "../services";
|
||||
import Only from "./common/OnlyWhen";
|
||||
import toastHelper from "./Toast";
|
||||
import Image from "./Image";
|
||||
import showMemoCardDialog from "./MemoCardDialog";
|
||||
import showShareMemoImageDialog from "./ShareMemoImageDialog";
|
||||
import toastHelper from "./Toast";
|
||||
import "../less/memo.less";
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
const MAX_MEMO_CONTAINER_HEIGHT = 384;
|
||||
|
||||
type ExpandButtonStatus = -1 | 0 | 1;
|
||||
|
||||
interface Props {
|
||||
memo: Model.Memo;
|
||||
memo: Memo;
|
||||
}
|
||||
|
||||
interface State {
|
||||
expandButtonStatus: ExpandButtonStatus;
|
||||
}
|
||||
|
||||
export const getFormatedMemoCreatedAtStr = (createdTs: number): string => {
|
||||
if (Date.now() - createdTs < 1000 * 60 * 60 * 24) {
|
||||
return dayjs(createdTs).fromNow();
|
||||
} else {
|
||||
return dayjs(createdTs).format("YYYY/MM/DD HH:mm:ss");
|
||||
}
|
||||
};
|
||||
|
||||
const Memo: React.FC<Props> = (props: Props) => {
|
||||
const { memo: propsMemo } = props;
|
||||
const memo: FormattedMemo = {
|
||||
...propsMemo,
|
||||
createdAtStr: utils.getDateTimeString(propsMemo.createdAt),
|
||||
};
|
||||
const [showConfirmDeleteBtn, toggleConfirmDeleteBtn] = useToggle(false);
|
||||
const imageUrls = Array.from(memo.content.match(IMAGE_URL_REG) ?? []);
|
||||
const memo = props.memo;
|
||||
const [state, setState] = useState<State>({
|
||||
expandButtonStatus: -1,
|
||||
});
|
||||
const [createdAtStr, setCreatedAtStr] = useState<string>(getFormatedMemoCreatedAtStr(memo.createdTs));
|
||||
const memoContainerRef = useRef<HTMLDivElement>(null);
|
||||
const imageUrls = Array.from(memo.content.match(IMAGE_URL_REG) ?? []).map((s) => s.replace(IMAGE_URL_REG, "$1"));
|
||||
const isVisitorMode = userService.isVisitorMode();
|
||||
|
||||
useEffect(() => {
|
||||
if (!memoContainerRef) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Number(memoContainerRef.current?.clientHeight) > MAX_MEMO_CONTAINER_HEIGHT) {
|
||||
setState({
|
||||
...state,
|
||||
expandButtonStatus: 0,
|
||||
});
|
||||
}
|
||||
|
||||
if (Date.now() - memo.createdTs < 1000 * 60 * 60 * 24) {
|
||||
setInterval(() => {
|
||||
setCreatedAtStr(dayjs(memo.createdTs).fromNow());
|
||||
}, 1000 * 1);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleShowMemoStoryDialog = () => {
|
||||
showMemoCardDialog(memo);
|
||||
@@ -31,18 +69,10 @@ const Memo: React.FC<Props> = (props: Props) => {
|
||||
|
||||
const handleTogglePinMemoBtnClick = async () => {
|
||||
try {
|
||||
if (memo.rowStatus === "ARCHIVED") {
|
||||
if (memo.pinned) {
|
||||
await memoService.unpinMemo(memo.id);
|
||||
memoService.editMemo({
|
||||
...memo,
|
||||
rowStatus: "NORMAL",
|
||||
});
|
||||
} else {
|
||||
await memoService.pinMemo(memo.id);
|
||||
memoService.editMemo({
|
||||
...memo,
|
||||
rowStatus: "ARCHIVED",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// do nth
|
||||
@@ -50,32 +80,25 @@ const Memo: React.FC<Props> = (props: Props) => {
|
||||
};
|
||||
|
||||
const handleMarkMemoClick = () => {
|
||||
globalStateService.setMarkMemoId(memo.id);
|
||||
editorStateService.setMarkMemoWithId(memo.id);
|
||||
};
|
||||
|
||||
const handleEditMemoClick = () => {
|
||||
globalStateService.setEditMemoId(memo.id);
|
||||
editorStateService.setEditMemoWithId(memo.id);
|
||||
};
|
||||
|
||||
const handleDeleteMemoClick = async () => {
|
||||
if (showConfirmDeleteBtn) {
|
||||
try {
|
||||
await memoService.hideMemoById(memo.id);
|
||||
} catch (error: any) {
|
||||
toastHelper.error(error.message);
|
||||
}
|
||||
|
||||
if (globalStateService.getState().editMemoId === memo.id) {
|
||||
globalStateService.setEditMemoId("");
|
||||
}
|
||||
} else {
|
||||
toggleConfirmDeleteBtn();
|
||||
const handleArchiveMemoClick = async () => {
|
||||
try {
|
||||
await memoService.patchMemo({
|
||||
id: memo.id,
|
||||
rowStatus: "ARCHIVED",
|
||||
});
|
||||
} catch (error: any) {
|
||||
toastHelper.error(error.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseLeaveMemoWrapper = () => {
|
||||
if (showConfirmDeleteBtn) {
|
||||
toggleConfirmDeleteBtn(false);
|
||||
if (editorStateService.getState().editMemoId === memo.id) {
|
||||
editorStateService.clearEditMemo();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -88,7 +111,7 @@ const Memo: React.FC<Props> = (props: Props) => {
|
||||
|
||||
if (targetEl.className === "memo-link-text") {
|
||||
const memoId = targetEl.dataset?.value;
|
||||
const memoTemp = memoService.getMemoById(memoId ?? "");
|
||||
const memoTemp = memoService.getMemoById(Number(memoId) ?? UNKNOWN_ID);
|
||||
|
||||
if (memoTemp) {
|
||||
showMemoCardDialog(memoTemp);
|
||||
@@ -96,53 +119,113 @@ const Memo: React.FC<Props> = (props: Props) => {
|
||||
toastHelper.error("MEMO Not Found");
|
||||
targetEl.classList.remove("memo-link-text");
|
||||
}
|
||||
} else if (targetEl.className === "todo-block") {
|
||||
// do nth
|
||||
} else if (targetEl.className === "tag-span") {
|
||||
const tagName = targetEl.innerText.slice(1);
|
||||
const currTagQuery = locationService.getState().query?.tag;
|
||||
if (currTagQuery === tagName) {
|
||||
locationService.setTagQuery(undefined);
|
||||
} else {
|
||||
locationService.setTagQuery(tagName);
|
||||
}
|
||||
} else if (targetEl.classList.contains("todo-block")) {
|
||||
if (userService.isVisitorMode()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const status = targetEl.dataset?.value;
|
||||
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);
|
||||
let finalContent = "";
|
||||
|
||||
for (let i = 0; i < tempList.length; i++) {
|
||||
if (i === 0) {
|
||||
finalContent += `${tempList[i]}`;
|
||||
} else {
|
||||
if (i === index + 1) {
|
||||
finalContent += status === "DONE" ? "- [ ] " : "- [x] ";
|
||||
} else {
|
||||
finalContent += status === "DONE" ? "- [x] " : "- [ ] ";
|
||||
}
|
||||
finalContent += `${tempList[i]}`;
|
||||
}
|
||||
}
|
||||
await memoService.patchMemo({
|
||||
id: memo.id,
|
||||
content: finalContent,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleExpandBtnClick = () => {
|
||||
setState({
|
||||
expandButtonStatus: Number(Boolean(!state.expandButtonStatus)) as ExpandButtonStatus,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`memo-wrapper ${"memos-" + memo.id} ${memo.rowStatus}`} onMouseLeave={handleMouseLeaveMemoWrapper}>
|
||||
<div className={`memo-wrapper ${"memos-" + memo.id} ${memo.pinned ? "pinned" : ""}`}>
|
||||
<div className="memo-top-wrapper">
|
||||
<span className="time-text" onClick={handleShowMemoStoryDialog}>
|
||||
{memo.createdAtStr}
|
||||
<Only when={memo.rowStatus === "ARCHIVED"}>
|
||||
<span className="ml-2">PINNED</span>
|
||||
<div className="status-text-container" onClick={handleShowMemoStoryDialog}>
|
||||
<span className="time-text">{createdAtStr}</span>
|
||||
<Only when={memo.pinned}>
|
||||
<span className="status-text">PINNED</span>
|
||||
</Only>
|
||||
</span>
|
||||
<div className="btns-container">
|
||||
<Only when={memo.visibility === "PUBLIC" && !isVisitorMode}>
|
||||
<span className="status-text">PUBLIC</span>
|
||||
</Only>
|
||||
</div>
|
||||
<div className={`btns-container ${userService.isVisitorMode() ? "!hidden" : ""}`}>
|
||||
<span className="btn more-action-btn">
|
||||
<img className="icon-img" src="/icons/more.svg" />
|
||||
<i className="fa-solid fa-ellipsis icon-img"></i>
|
||||
</span>
|
||||
<div className="more-action-btns-wrapper">
|
||||
<div className="more-action-btns-container">
|
||||
<span className="btn" onClick={handleShowMemoStoryDialog}>
|
||||
View Story
|
||||
</span>
|
||||
<span className="btn" onClick={handleTogglePinMemoBtnClick}>
|
||||
{memo.rowStatus === "NORMAL" ? "Pin" : "Unpin"}
|
||||
</span>
|
||||
<div className="btns-container">
|
||||
<div className="btn" onClick={handleTogglePinMemoBtnClick}>
|
||||
<i className={`fa-solid fa-thumbtack icon-img ${memo.pinned ? "" : "opacity-20"}`}></i>
|
||||
<span className="tip-text">{memo.pinned ? "Unpin" : "Pin"}</span>
|
||||
</div>
|
||||
<div className="btn" onClick={handleEditMemoClick}>
|
||||
<i className="fa-solid fa-pen-to-square icon-img"></i>
|
||||
<span className="tip-text">Edit</span>
|
||||
</div>
|
||||
<div className="btn" onClick={handleGenMemoImageBtnClick}>
|
||||
<i className="fa-solid fa-share-nodes icon-img"></i>
|
||||
<span className="tip-text">Share</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="btn" onClick={handleMarkMemoClick}>
|
||||
Mark
|
||||
</span>
|
||||
<span className="btn" onClick={handleGenMemoImageBtnClick}>
|
||||
Share
|
||||
<span className="btn" onClick={handleShowMemoStoryDialog}>
|
||||
View Story
|
||||
</span>
|
||||
<span className="btn" onClick={handleEditMemoClick}>
|
||||
Edit
|
||||
</span>
|
||||
<span className={`btn delete-btn ${showConfirmDeleteBtn ? "final-confirm" : ""}`} onClick={handleDeleteMemoClick}>
|
||||
{showConfirmDeleteBtn ? "Delete!" : "Delete"}
|
||||
<span className="btn archive-btn" onClick={handleArchiveMemoClick}>
|
||||
Archive
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="memo-content-text"
|
||||
ref={memoContainerRef}
|
||||
className={`memo-content-text ${state.expandButtonStatus === 0 ? "expanded" : ""}`}
|
||||
onClick={handleMemoContentClick}
|
||||
dangerouslySetInnerHTML={{ __html: formatMemoContent(memo.content) }}
|
||||
></div>
|
||||
{state.expandButtonStatus !== -1 && (
|
||||
<div className="expand-btn-container">
|
||||
<span className={`btn ${state.expandButtonStatus === 0 ? "expand-btn" : "fold-btn"}`} onClick={handleExpandBtnClick}>
|
||||
{state.expandButtonStatus === 0 ? "Expand" : "Fold"}
|
||||
<i className="fa-solid fa-chevron-right icon-img"></i>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<Only when={imageUrls.length > 0}>
|
||||
<div className="images-wrapper">
|
||||
{imageUrls.map((imgUrl, idx) => (
|
||||
@@ -155,49 +238,14 @@ const Memo: React.FC<Props> = (props: Props) => {
|
||||
};
|
||||
|
||||
export function formatMemoContent(content: string) {
|
||||
content = escape(content);
|
||||
content = parseRawTextToHtml(content)
|
||||
.split("<br>")
|
||||
.map((t) => {
|
||||
return `<p>${t !== "" ? t : "<br>"}</p>`;
|
||||
})
|
||||
.join("");
|
||||
const tempElement = document.createElement("div");
|
||||
tempElement.innerHTML = parseMarkedToHtml(escape(content));
|
||||
|
||||
const { shouldUseMarkdownParser, shouldSplitMemoWord, shouldHideImageUrl } = globalStateService.getState();
|
||||
|
||||
if (shouldUseMarkdownParser) {
|
||||
content = parseMarkedToHtml(content);
|
||||
}
|
||||
|
||||
if (shouldHideImageUrl) {
|
||||
content = content.replace(IMAGE_URL_REG, "");
|
||||
}
|
||||
|
||||
content = content
|
||||
.replace(TAG_REG, "<span class='tag-span'>#$1</span>")
|
||||
.replace(LINK_REG, "<a class='link' target='_blank' rel='noreferrer' href='$1'>$1</a>")
|
||||
.replace(MEMO_LINK_REG, "<span class='memo-link-text' data-value='$2'>$1</span>");
|
||||
|
||||
// Add space in english and chinese
|
||||
if (shouldSplitMemoWord) {
|
||||
content = content
|
||||
.replace(/([\u4e00-\u9fa5])([A-Za-z0-9?.,;[\]]+)/g, "$1 $2")
|
||||
.replace(/([A-Za-z0-9?.,;[\]]+)([\u4e00-\u9fa5])/g, "$1 $2");
|
||||
}
|
||||
|
||||
const tempDivContainer = document.createElement("div");
|
||||
tempDivContainer.innerHTML = content;
|
||||
for (let i = 0; i < tempDivContainer.children.length; i++) {
|
||||
const c = tempDivContainer.children[i];
|
||||
|
||||
if (c.tagName === "P" && c.textContent === "" && c.firstElementChild?.tagName !== "BR") {
|
||||
c.remove();
|
||||
i--;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return tempDivContainer.innerHTML;
|
||||
return tempElement.innerHTML
|
||||
.replace(IMAGE_URL_REG, "")
|
||||
.replace(MEMO_LINK_REG, "<span class='memo-link-text' data-value='$2'>$1</span>")
|
||||
.replace(LINK_URL_REG, "<a class='link' target='_blank' rel='noreferrer' href='$2'>$1</a>")
|
||||
.replace(TAG_REG, "<span class='tag-span'>#$1</span> ");
|
||||
}
|
||||
|
||||
export default memo(Memo);
|
||||
|
||||
@@ -1,32 +1,31 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { IMAGE_URL_REG, MEMO_LINK_REG } from "../helpers/consts";
|
||||
import utils from "../helpers/utils";
|
||||
import { globalStateService, memoService } from "../services";
|
||||
import { editorStateService, memoService, userService } from "../services";
|
||||
import { IMAGE_URL_REG, MEMO_LINK_REG, UNKNOWN_ID } from "../helpers/consts";
|
||||
import * as utils from "../helpers/utils";
|
||||
import { parseHtmlToRawText } from "../helpers/marked";
|
||||
import { formatMemoContent } from "./Memo";
|
||||
import Only from "./common/OnlyWhen";
|
||||
import toastHelper from "./Toast";
|
||||
import { showDialog } from "./Dialog";
|
||||
import Only from "./common/OnlyWhen";
|
||||
import Image from "./Image";
|
||||
import { formatMemoContent } from "./Memo";
|
||||
import "../less/memo-card-dialog.less";
|
||||
import "../less/memo-content.less";
|
||||
|
||||
interface LinkedMemo extends FormattedMemo {
|
||||
interface LinkedMemo extends Memo {
|
||||
createdAtStr: string;
|
||||
dateStr: string;
|
||||
}
|
||||
|
||||
interface Props extends DialogProps {
|
||||
memo: Model.Memo;
|
||||
memo: Memo;
|
||||
}
|
||||
|
||||
const MemoCardDialog: React.FC<Props> = (props: Props) => {
|
||||
const [memo, setMemo] = useState<FormattedMemo>({
|
||||
const [memo, setMemo] = useState<Memo>({
|
||||
...props.memo,
|
||||
createdAtStr: utils.getDateTimeString(props.memo.createdAt),
|
||||
});
|
||||
const [linkMemos, setLinkMemos] = useState<LinkedMemo[]>([]);
|
||||
const [linkedMemos, setLinkedMemos] = useState<LinkedMemo[]>([]);
|
||||
const imageUrls = Array.from(memo.content.match(IMAGE_URL_REG) ?? []);
|
||||
const imageUrls = Array.from(memo.content.match(IMAGE_URL_REG) ?? []).map((s) => s.replace(IMAGE_URL_REG, "$1"));
|
||||
|
||||
useEffect(() => {
|
||||
const fetchLinkedMemos = async () => {
|
||||
@@ -35,13 +34,17 @@ const MemoCardDialog: React.FC<Props> = (props: Props) => {
|
||||
const matchedArr = [...memo.content.matchAll(MEMO_LINK_REG)];
|
||||
for (const matchRes of matchedArr) {
|
||||
if (matchRes && matchRes.length === 3) {
|
||||
const id = matchRes[2];
|
||||
const id = Number(matchRes[2]);
|
||||
if (id === memo.id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const memoTemp = memoService.getMemoById(id);
|
||||
if (memoTemp) {
|
||||
linkMemos.push({
|
||||
...memoTemp,
|
||||
createdAtStr: utils.getDateTimeString(memoTemp.createdAt),
|
||||
dateStr: utils.getDateString(memoTemp.createdAt),
|
||||
createdAtStr: utils.getDateTimeString(memoTemp.createdTs),
|
||||
dateStr: utils.getDateString(memoTemp.createdTs),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -51,11 +54,12 @@ const MemoCardDialog: React.FC<Props> = (props: Props) => {
|
||||
const linkedMemos = await memoService.getLinkedMemos(memo.id);
|
||||
setLinkedMemos(
|
||||
linkedMemos
|
||||
.sort((a, b) => utils.getTimeStampByDate(b.createdAt) - utils.getTimeStampByDate(a.createdAt))
|
||||
.filter((m) => m.rowStatus === "NORMAL" && m.id !== memo.id)
|
||||
.sort((a, b) => utils.getTimeStampByDate(b.createdTs) - utils.getTimeStampByDate(a.createdTs))
|
||||
.map((m) => ({
|
||||
...m,
|
||||
createdAtStr: utils.getDateTimeString(m.createdAt),
|
||||
dateStr: utils.getDateString(m.createdAt),
|
||||
createdAtStr: utils.getDateTimeString(m.createdTs),
|
||||
dateStr: utils.getDateString(m.createdTs),
|
||||
}))
|
||||
);
|
||||
} catch (error) {
|
||||
@@ -71,12 +75,12 @@ const MemoCardDialog: React.FC<Props> = (props: Props) => {
|
||||
|
||||
if (targetEl.className === "memo-link-text") {
|
||||
const nextMemoId = targetEl.dataset?.value;
|
||||
const memoTemp = memoService.getMemoById(nextMemoId ?? "");
|
||||
const memoTemp = memoService.getMemoById(Number(nextMemoId) ?? UNKNOWN_ID);
|
||||
|
||||
if (memoTemp) {
|
||||
const nextMemo = {
|
||||
...memoTemp,
|
||||
createdAtStr: utils.getDateTimeString(memoTemp.createdAt),
|
||||
createdAtStr: utils.getDateTimeString(memoTemp.createdTs),
|
||||
};
|
||||
setLinkMemos([]);
|
||||
setLinkedMemos([]);
|
||||
@@ -88,7 +92,7 @@ const MemoCardDialog: React.FC<Props> = (props: Props) => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleLinkedMemoClick = useCallback((memo: FormattedMemo) => {
|
||||
const handleLinkedMemoClick = useCallback((memo: Memo) => {
|
||||
setLinkMemos([]);
|
||||
setLinkedMemos([]);
|
||||
setMemo(memo);
|
||||
@@ -96,20 +100,54 @@ const MemoCardDialog: React.FC<Props> = (props: Props) => {
|
||||
|
||||
const handleEditMemoBtnClick = useCallback(() => {
|
||||
props.destroy();
|
||||
globalStateService.setEditMemoId(memo.id);
|
||||
editorStateService.setEditMemoWithId(memo.id);
|
||||
}, [memo.id]);
|
||||
|
||||
const handlePinClick = async () => {
|
||||
if (memo.pinned) {
|
||||
await memoService.unpinMemo(memo.id);
|
||||
} else {
|
||||
await memoService.pinMemo(memo.id);
|
||||
}
|
||||
setMemo({
|
||||
...memo,
|
||||
pinned: !memo.pinned,
|
||||
});
|
||||
};
|
||||
|
||||
const handleVisibilityClick = async () => {
|
||||
const visibility = memo.visibility === "PRIVATE" ? "PUBLIC" : "PRIVATE";
|
||||
await memoService.patchMemo({
|
||||
id: memo.id,
|
||||
visibility: visibility,
|
||||
});
|
||||
setMemo({
|
||||
...memo,
|
||||
visibility: visibility,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="memo-card-container">
|
||||
<div className="header-container">
|
||||
<p className="time-text">{memo.createdAtStr}</p>
|
||||
<p className="time-text">{utils.getDateTimeString(memo.createdTs)}</p>
|
||||
<div className="btns-container">
|
||||
<button className="btn edit-btn" onClick={handleEditMemoBtnClick}>
|
||||
<img className="icon-img" src="/icons/edit.svg" />
|
||||
</button>
|
||||
<Only when={!userService.isVisitorMode()}>
|
||||
<>
|
||||
<button className="btn" onClick={handlePinClick}>
|
||||
<i className={`fa-solid fa-thumbtack icon-img ${memo.pinned ? "" : "opacity-20"}`}></i>
|
||||
</button>
|
||||
<button className="btn" onClick={handleVisibilityClick}>
|
||||
<i className={`fa-solid fa-eye icon-img ${memo.visibility === "PUBLIC" ? "" : "opacity-20"}`}></i>
|
||||
</button>
|
||||
<button className="btn edit-btn" onClick={handleEditMemoBtnClick}>
|
||||
<i className="fa-solid fa-pen-to-square icon-img"></i>
|
||||
</button>
|
||||
</>
|
||||
</Only>
|
||||
<button className="btn close-btn" onClick={props.destroy}>
|
||||
<img className="icon-img" src="/icons/close.svg" />
|
||||
<i className="fa-solid fa-xmark fa-lg icon-img"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -150,11 +188,11 @@ const MemoCardDialog: React.FC<Props> = (props: Props) => {
|
||||
{linkMemos.length > 0 ? (
|
||||
<div className="linked-memos-wrapper">
|
||||
<p className="normal-text">{linkMemos.length} related MEMO</p>
|
||||
{linkMemos.map((m) => {
|
||||
const rawtext = parseHtmlToRawText(formatMemoContent(m.content)).replaceAll("\n", " ");
|
||||
{linkMemos.map((memo, index) => {
|
||||
const rawtext = parseHtmlToRawText(formatMemoContent(memo.content)).replaceAll("\n", " ");
|
||||
return (
|
||||
<div className="linked-memo-container" key={m.id} onClick={() => handleLinkedMemoClick(m)}>
|
||||
<span className="time-text">{m.dateStr} </span>
|
||||
<div className="linked-memo-container" key={`${index}-${memo.id}`} onClick={() => handleLinkedMemoClick(memo)}>
|
||||
<span className="time-text">{memo.dateStr} </span>
|
||||
{rawtext}
|
||||
</div>
|
||||
);
|
||||
@@ -164,11 +202,11 @@ const MemoCardDialog: React.FC<Props> = (props: Props) => {
|
||||
{linkedMemos.length > 0 ? (
|
||||
<div className="linked-memos-wrapper">
|
||||
<p className="normal-text">{linkedMemos.length} linked MEMO</p>
|
||||
{linkedMemos.map((m) => {
|
||||
const rawtext = parseHtmlToRawText(formatMemoContent(m.content)).replaceAll("\n", " ");
|
||||
{linkedMemos.map((memo, index) => {
|
||||
const rawtext = parseHtmlToRawText(formatMemoContent(memo.content)).replaceAll("\n", " ");
|
||||
return (
|
||||
<div className="linked-memo-container" key={m.id} onClick={() => handleLinkedMemoClick(m)}>
|
||||
<span className="time-text">{m.dateStr} </span>
|
||||
<div className="linked-memo-container" key={`${index}-${memo.id}`} onClick={() => handleLinkedMemoClick(memo)}>
|
||||
<span className="time-text">{memo.dateStr} </span>
|
||||
{rawtext}
|
||||
</div>
|
||||
);
|
||||
@@ -179,7 +217,7 @@ const MemoCardDialog: React.FC<Props> = (props: Props) => {
|
||||
);
|
||||
};
|
||||
|
||||
export default function showMemoCardDialog(memo: Model.Memo): void {
|
||||
export default function showMemoCardDialog(memo: Memo): void {
|
||||
showDialog(
|
||||
{
|
||||
className: "memo-card-dialog",
|
||||
|
||||
@@ -1,88 +1,61 @@
|
||||
import React, { useCallback, useContext, useEffect, useMemo, useRef } from "react";
|
||||
import appContext from "../stores/appContext";
|
||||
import { globalStateService, locationService, memoService, resourceService } from "../services";
|
||||
import utils from "../helpers/utils";
|
||||
import { storage } from "../helpers/storage";
|
||||
import useToggle from "../hooks/useToggle";
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { UNKNOWN_ID } from "../helpers/consts";
|
||||
import { editorStateService, locationService, memoService, resourceService } from "../services";
|
||||
import { useAppSelector } from "../store";
|
||||
import * as storage from "../helpers/storage";
|
||||
import toastHelper from "./Toast";
|
||||
import Editor, { EditorRefActions } from "./Editor/Editor";
|
||||
import "../less/memo-editor.less";
|
||||
|
||||
const getCursorPostion = (input: HTMLTextAreaElement) => {
|
||||
const { offsetLeft: inputX, offsetTop: inputY, selectionEnd: selectionPoint } = input;
|
||||
const div = document.createElement("div");
|
||||
|
||||
const copyStyle = window.getComputedStyle(input);
|
||||
for (const item of copyStyle) {
|
||||
div.style.setProperty(item, copyStyle.getPropertyValue(item));
|
||||
}
|
||||
div.style.position = "fixed";
|
||||
div.style.visibility = "hidden";
|
||||
div.style.whiteSpace = "pre-wrap";
|
||||
|
||||
const swap = ".";
|
||||
const inputValue = input.tagName === "INPUT" ? input.value.replace(/ /g, swap) : input.value;
|
||||
const textContent = inputValue.substring(0, selectionPoint || 0);
|
||||
div.textContent = textContent;
|
||||
if (input.tagName === "TEXTAREA") {
|
||||
div.style.height = "auto";
|
||||
}
|
||||
|
||||
const span = document.createElement("span");
|
||||
span.textContent = inputValue.substring(selectionPoint || 0) || ".";
|
||||
div.appendChild(span);
|
||||
document.body.appendChild(div);
|
||||
const { offsetLeft: spanX, offsetTop: spanY } = span;
|
||||
document.body.removeChild(div);
|
||||
return {
|
||||
x: inputX + spanX,
|
||||
y: inputY + spanY,
|
||||
};
|
||||
};
|
||||
|
||||
interface Props {}
|
||||
|
||||
interface State {
|
||||
isUploadingResource: boolean;
|
||||
fullscreen: boolean;
|
||||
}
|
||||
|
||||
const MemoEditor: React.FC<Props> = () => {
|
||||
const {
|
||||
globalState,
|
||||
memoState: { tags },
|
||||
} = useContext(appContext);
|
||||
const [isTagSeletorShown, toggleTagSeletor] = useToggle(false);
|
||||
const editorState = useAppSelector((state) => state.editor);
|
||||
const tags = useAppSelector((state) => state.memo.tags);
|
||||
const [state, setState] = useState<State>({
|
||||
isUploadingResource: false,
|
||||
fullscreen: false,
|
||||
});
|
||||
const editorRef = useRef<EditorRefActions>(null);
|
||||
const prevGlobalStateRef = useRef(globalState);
|
||||
const prevGlobalStateRef = useRef(editorState);
|
||||
const tagSeletorRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (globalState.markMemoId) {
|
||||
if (editorState.markMemoId && editorState.markMemoId !== UNKNOWN_ID) {
|
||||
const editorCurrentValue = editorRef.current?.getContent();
|
||||
const memoLinkText = `${editorCurrentValue ? "\n" : ""}Mark: [@MEMO](${globalState.markMemoId})`;
|
||||
const memoLinkText = `${editorCurrentValue ? "\n" : ""}Mark: @[MEMO](${editorState.markMemoId})`;
|
||||
editorRef.current?.insertText(memoLinkText);
|
||||
globalStateService.setMarkMemoId("");
|
||||
editorStateService.clearMarkMemo();
|
||||
}
|
||||
|
||||
if (globalState.editMemoId && globalState.editMemoId !== prevGlobalStateRef.current.editMemoId) {
|
||||
const editMemo = memoService.getMemoById(globalState.editMemoId);
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
prevGlobalStateRef.current = globalState;
|
||||
}, [globalState.markMemoId, globalState.editMemoId]);
|
||||
prevGlobalStateRef.current = editorState;
|
||||
}, [editorState.markMemoId, editorState.editMemoId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editorRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
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(url);
|
||||
editorRef.current?.insertText(``);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -93,7 +66,7 @@ const MemoEditor: React.FC<Props> = () => {
|
||||
const file = event.dataTransfer.files[0];
|
||||
const url = await handleUploadFile(file);
|
||||
if (url) {
|
||||
editorRef.current?.insertText(url);
|
||||
editorRef.current?.insertText(``);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -108,10 +81,10 @@ const MemoEditor: React.FC<Props> = () => {
|
||||
});
|
||||
};
|
||||
|
||||
editorRef.current.element.addEventListener("paste", handlePasteEvent);
|
||||
editorRef.current.element.addEventListener("drop", handleDropEvent);
|
||||
editorRef.current.element.addEventListener("click", handleClickEvent);
|
||||
editorRef.current.element.addEventListener("keydown", handleKeyDownEvent);
|
||||
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);
|
||||
@@ -121,116 +94,82 @@ const MemoEditor: React.FC<Props> = () => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleUploadFile = useCallback(async (file: File) => {
|
||||
const { type } = file;
|
||||
const handleUploadFile = useCallback(
|
||||
async (file: File) => {
|
||||
if (state.isUploadingResource) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!type.startsWith("image")) {
|
||||
return;
|
||||
}
|
||||
setState({
|
||||
...state,
|
||||
isUploadingResource: true,
|
||||
});
|
||||
const { type } = file;
|
||||
|
||||
try {
|
||||
const image = await resourceService.upload(file);
|
||||
const url = `/h/r/${image.id}/${image.filename}`;
|
||||
if (!type.startsWith("image")) {
|
||||
return;
|
||||
}
|
||||
|
||||
return url;
|
||||
} catch (error: any) {
|
||||
toastHelper.error(error);
|
||||
}
|
||||
}, []);
|
||||
try {
|
||||
const image = await resourceService.upload(file);
|
||||
const url = `/h/r/${image.id}/${image.filename}`;
|
||||
|
||||
const handleSaveBtnClick = useCallback(async (content: string) => {
|
||||
return url;
|
||||
} catch (error: any) {
|
||||
toastHelper.error(error);
|
||||
} finally {
|
||||
setState({
|
||||
...state,
|
||||
isUploadingResource: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
[state]
|
||||
);
|
||||
|
||||
const handleSaveBtnClick = async (content: string) => {
|
||||
if (content === "") {
|
||||
toastHelper.error("Content can't be empty");
|
||||
return;
|
||||
}
|
||||
|
||||
const { editMemoId } = globalStateService.getState();
|
||||
|
||||
try {
|
||||
if (editMemoId) {
|
||||
const prevMemo = memoService.getMemoById(editMemoId);
|
||||
const { editMemoId } = editorStateService.getState();
|
||||
if (editMemoId && editMemoId !== UNKNOWN_ID) {
|
||||
const prevMemo = memoService.getMemoById(editMemoId ?? UNKNOWN_ID);
|
||||
|
||||
if (prevMemo && prevMemo.content !== content) {
|
||||
const editedMemo = await memoService.updateMemo(prevMemo.id, content);
|
||||
editedMemo.updatedAt = utils.getDateTimeString(Date.now());
|
||||
memoService.editMemo(editedMemo);
|
||||
await memoService.patchMemo({
|
||||
id: prevMemo.id,
|
||||
content,
|
||||
});
|
||||
}
|
||||
globalStateService.setEditMemoId("");
|
||||
editorStateService.clearEditMemo();
|
||||
} else {
|
||||
const newMemo = await memoService.createMemo(content);
|
||||
memoService.pushMemo(newMemo);
|
||||
await memoService.createMemo({
|
||||
content,
|
||||
});
|
||||
locationService.clearQuery();
|
||||
}
|
||||
} catch (error: any) {
|
||||
toastHelper.error(error.message);
|
||||
}
|
||||
|
||||
setState({
|
||||
...state,
|
||||
fullscreen: false,
|
||||
});
|
||||
setEditorContentCache("");
|
||||
}, []);
|
||||
};
|
||||
|
||||
const handleCancelBtnClick = useCallback(() => {
|
||||
globalStateService.setEditMemoId("");
|
||||
editorStateService.clearEditMemo();
|
||||
editorRef.current?.setContent("");
|
||||
setEditorContentCache("");
|
||||
}, []);
|
||||
|
||||
const handleContentChange = useCallback((content: string) => {
|
||||
setEditorContentCache(content);
|
||||
|
||||
if (editorRef.current) {
|
||||
const selectionStart = editorRef.current.element.selectionStart;
|
||||
const prevString = content.slice(0, selectionStart);
|
||||
const nextString = content.slice(selectionStart);
|
||||
|
||||
if (prevString.endsWith("#") && (nextString.startsWith(" ") || nextString === "")) {
|
||||
toggleTagSeletor(true);
|
||||
updateTagSelectorPopupPosition();
|
||||
} else {
|
||||
toggleTagSeletor(false);
|
||||
}
|
||||
|
||||
editorRef.current?.focus();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleTagTextBtnClick = useCallback(() => {
|
||||
if (!editorRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentValue = editorRef.current.getContent();
|
||||
const selectionStart = editorRef.current.element.selectionStart;
|
||||
const prevString = currentValue.slice(0, selectionStart);
|
||||
const nextString = currentValue.slice(selectionStart);
|
||||
|
||||
let nextValue = prevString + "# " + nextString;
|
||||
let cursorIndex = prevString.length + 1;
|
||||
|
||||
if (prevString.endsWith("#") && nextString.startsWith(" ")) {
|
||||
nextValue = prevString.slice(0, prevString.length - 1) + nextString.slice(1);
|
||||
cursorIndex = prevString.length - 1;
|
||||
}
|
||||
|
||||
editorRef.current.element.value = nextValue;
|
||||
editorRef.current.element.setSelectionRange(cursorIndex, cursorIndex);
|
||||
editorRef.current.focus();
|
||||
handleContentChange(editorRef.current.element.value);
|
||||
}, []);
|
||||
|
||||
const updateTagSelectorPopupPosition = useCallback(() => {
|
||||
if (!editorRef.current || !tagSeletorRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const seletorPopupWidth = 128;
|
||||
const editorWidth = editorRef.current.element.clientWidth;
|
||||
const { x, y } = getCursorPostion(editorRef.current.element);
|
||||
const left = x + seletorPopupWidth + 16 > editorWidth ? editorWidth + 20 - seletorPopupWidth : x + 2;
|
||||
const top = y + 32 + 6;
|
||||
|
||||
tagSeletorRef.current.scroll(0, 0);
|
||||
tagSeletorRef.current.style.left = `${left}px`;
|
||||
tagSeletorRef.current.style.top = `${top}px`;
|
||||
}, []);
|
||||
|
||||
const handleUploadFileBtnClick = useCallback(() => {
|
||||
@@ -246,57 +185,69 @@ const MemoEditor: React.FC<Props> = () => {
|
||||
const file = inputEl.files[0];
|
||||
const url = await handleUploadFile(file);
|
||||
if (url) {
|
||||
editorRef.current?.insertText(url + " ");
|
||||
editorRef.current?.insertText(``);
|
||||
}
|
||||
};
|
||||
inputEl.click();
|
||||
}, []);
|
||||
|
||||
const handleFullscreenBtnClick = () => {
|
||||
setState({
|
||||
...state,
|
||||
fullscreen: !state.fullscreen,
|
||||
});
|
||||
};
|
||||
|
||||
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 + " " ?? "");
|
||||
toggleTagSeletor(false);
|
||||
editorRef.current?.insertText(`#${(event.target as HTMLElement).textContent} ` ?? "");
|
||||
editorRef.current?.focus();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const isEditing = Boolean(globalState.editMemoId);
|
||||
const isEditing = Boolean(editorState.editMemoId && editorState.editMemoId !== UNKNOWN_ID);
|
||||
|
||||
const editorConfig = useMemo(
|
||||
() => ({
|
||||
className: "memo-editor",
|
||||
initialContent: getEditorContentCache(),
|
||||
placeholder: "Any thoughts...",
|
||||
fullscreen: state.fullscreen,
|
||||
showConfirmBtn: true,
|
||||
showCancelBtn: isEditing,
|
||||
onConfirmBtnClick: handleSaveBtnClick,
|
||||
onCancelBtnClick: handleCancelBtnClick,
|
||||
onContentChange: handleContentChange,
|
||||
}),
|
||||
[isEditing]
|
||||
[isEditing, state.fullscreen]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={"memo-editor-container " + (isEditing ? "edit-ing" : "")}>
|
||||
<div className={`memo-editor-container ${isEditing ? "edit-ing" : ""} ${state.fullscreen ? "fullscreen" : ""}`}>
|
||||
<p className={"tip-text " + (isEditing ? "" : "hidden")}>Editting...</p>
|
||||
<Editor
|
||||
ref={editorRef}
|
||||
{...editorConfig}
|
||||
tools={
|
||||
<>
|
||||
<img className="action-btn file-upload" src="/icons/tag.svg" onClick={handleTagTextBtnClick} />
|
||||
<img className="action-btn file-upload" src="/icons/image.svg" onClick={handleUploadFileBtnClick} />
|
||||
<div className="action-btn tag-action">
|
||||
<i className="fa-solid fa-hashtag icon-img"></i>
|
||||
<div ref={tagSeletorRef} className="tag-list" onClick={handleTagSeletorClick}>
|
||||
{tags.map((t) => {
|
||||
return <span key={t}>{t}</span>;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<button className="action-btn">
|
||||
<i className="fa-solid fa-image icon-img" onClick={handleUploadFileBtnClick}></i>
|
||||
<span className={`tip-text ${state.isUploadingResource ? "!block" : ""}`}>Uploading</span>
|
||||
</button>
|
||||
<button className="action-btn" onClick={handleFullscreenBtnClick}>
|
||||
<i className={`fa-solid fa-${state.fullscreen ? "compress" : "expand"} icon-img`}></i>
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<div
|
||||
ref={tagSeletorRef}
|
||||
className={`tag-list ${isTagSeletorShown && tags.length > 0 ? "" : "hidden"}`}
|
||||
onClick={handleTagSeletorClick}
|
||||
>
|
||||
{tags.map((t) => {
|
||||
return <span key={t}>{t}</span>;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,36 +1,33 @@
|
||||
import { useContext } from "react";
|
||||
import appContext from "../stores/appContext";
|
||||
import { useAppSelector } from "../store";
|
||||
import { locationService, shortcutService } from "../services";
|
||||
import utils from "../helpers/utils";
|
||||
import * as utils from "../helpers/utils";
|
||||
import { getTextWithMemoType } from "../helpers/filter";
|
||||
import "../less/memo-filter.less";
|
||||
|
||||
interface FilterProps {}
|
||||
|
||||
const MemoFilter: React.FC<FilterProps> = () => {
|
||||
const {
|
||||
locationState: { query },
|
||||
} = useContext(appContext);
|
||||
|
||||
const query = useAppSelector((state) => state.location.query);
|
||||
useAppSelector((state) => state.shortcut.shortcuts);
|
||||
const { tag: tagQuery, duration, type: memoType, text: textQuery, shortcutId } = query;
|
||||
const queryFilter = shortcutService.getShortcutById(shortcutId);
|
||||
const showFilter = Boolean(tagQuery || (duration && duration.from < duration.to) || memoType || textQuery || queryFilter);
|
||||
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"}`}>
|
||||
<div className={`filter-query-container ${showFilter ? "" : "!hidden"}`}>
|
||||
<span className="tip-text">Filter:</span>
|
||||
<div
|
||||
className={"filter-item-container " + (queryFilter ? "" : "hidden")}
|
||||
className={"filter-item-container " + (shortcut ? "" : "hidden")}
|
||||
onClick={() => {
|
||||
locationService.setMemoShortcut("");
|
||||
locationService.setMemoShortcut(undefined);
|
||||
}}
|
||||
>
|
||||
<span className="icon-text">🎯</span> {queryFilter?.title}
|
||||
<span className="icon-text">🎯</span> {shortcut?.title}
|
||||
</div>
|
||||
<div
|
||||
className={"filter-item-container " + (tagQuery ? "" : "hidden")}
|
||||
onClick={() => {
|
||||
locationService.setTagQuery("");
|
||||
locationService.setTagQuery(undefined);
|
||||
}}
|
||||
>
|
||||
<span className="icon-text">🏷️</span> {tagQuery}
|
||||
@@ -38,7 +35,7 @@ const MemoFilter: React.FC<FilterProps> = () => {
|
||||
<div
|
||||
className={"filter-item-container " + (memoType ? "" : "hidden")}
|
||||
onClick={() => {
|
||||
locationService.setMemoTypeQuery("");
|
||||
locationService.setMemoTypeQuery(undefined);
|
||||
}}
|
||||
>
|
||||
<span className="icon-text">📦</span> {getTextWithMemoType(memoType as MemoSpecType)}
|
||||
@@ -47,7 +44,7 @@ const MemoFilter: React.FC<FilterProps> = () => {
|
||||
<div
|
||||
className="filter-item-container"
|
||||
onClick={() => {
|
||||
locationService.setFromAndToQuery(0, 0);
|
||||
locationService.setFromAndToQuery();
|
||||
}}
|
||||
>
|
||||
<span className="icon-text">🗓️</span> {utils.getDateString(duration.from)} to {utils.getDateString(duration.to)}
|
||||
|
||||
@@ -1,34 +1,32 @@
|
||||
import { useCallback, useContext, useEffect, useRef, useState } from "react";
|
||||
import appContext from "../stores/appContext";
|
||||
import { locationService, memoService, shortcutService } from "../services";
|
||||
import { IMAGE_URL_REG, LINK_REG, MEMO_LINK_REG, TAG_REG } from "../helpers/consts";
|
||||
import utils from "../helpers/utils";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { memoService, shortcutService } from "../services";
|
||||
import { useAppSelector } from "../store";
|
||||
import { IMAGE_URL_REG, LINK_URL_REG, MEMO_LINK_REG, TAG_REG } from "../helpers/consts";
|
||||
import * as utils from "../helpers/utils";
|
||||
import { checkShouldShowMemoWithFilters } from "../helpers/filter";
|
||||
import Memo from "./Memo";
|
||||
import toastHelper from "./Toast";
|
||||
import Memo from "./Memo";
|
||||
import "../less/memo-list.less";
|
||||
|
||||
interface Props {}
|
||||
|
||||
const MemoList: React.FC<Props> = () => {
|
||||
const {
|
||||
locationState: { query },
|
||||
memoState: { memos },
|
||||
} = useContext(appContext);
|
||||
const query = useAppSelector((state) => state.location.query);
|
||||
const memos = useAppSelector((state) => state.memo.memos);
|
||||
const [isFetching, setFetchStatus] = useState(true);
|
||||
const wrapperElement = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { tag: tagQuery, duration, type: memoType, text: textQuery, shortcutId } = query;
|
||||
const queryFilter = shortcutService.getShortcutById(shortcutId);
|
||||
const showMemoFilter = Boolean(tagQuery || (duration && duration.from < duration.to) || memoType || textQuery || queryFilter);
|
||||
const { tag: tagQuery, duration, type: memoType, text: textQuery, shortcutId } = query ?? {};
|
||||
const shortcut = shortcutId ? shortcutService.getShortcutById(shortcutId) : null;
|
||||
const showMemoFilter = Boolean(tagQuery || (duration && duration.from < duration.to) || memoType || textQuery || shortcut);
|
||||
|
||||
const shownMemos =
|
||||
showMemoFilter || queryFilter
|
||||
showMemoFilter || shortcut
|
||||
? memos.filter((memo) => {
|
||||
let shouldShow = true;
|
||||
|
||||
if (queryFilter) {
|
||||
const filters = JSON.parse(queryFilter.payload) as Filter[];
|
||||
if (shortcut) {
|
||||
const filters = JSON.parse(shortcut.payload) as Filter[];
|
||||
if (Array.isArray(filters)) {
|
||||
shouldShow = checkShouldShowMemoWithFilters(memo, filters);
|
||||
}
|
||||
@@ -53,14 +51,14 @@ const MemoList: React.FC<Props> = () => {
|
||||
if (
|
||||
duration &&
|
||||
duration.from < duration.to &&
|
||||
(utils.getTimeStampByDate(memo.createdAt) < duration.from || utils.getTimeStampByDate(memo.createdAt) > duration.to)
|
||||
(utils.getTimeStampByDate(memo.createdTs) < duration.from || utils.getTimeStampByDate(memo.createdTs) > 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_REG) === null) {
|
||||
} 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;
|
||||
@@ -76,9 +74,9 @@ const MemoList: React.FC<Props> = () => {
|
||||
})
|
||||
: memos;
|
||||
|
||||
const pinnedMemos = shownMemos.filter((m) => m.rowStatus === "ARCHIVED");
|
||||
const unpinnedMemos = shownMemos.filter((m) => m.rowStatus === "NORMAL");
|
||||
const sortedMemos = pinnedMemos.concat(unpinnedMemos);
|
||||
const pinnedMemos = shownMemos.filter((m) => m.pinned);
|
||||
const unpinnedMemos = shownMemos.filter((m) => !m.pinned);
|
||||
const sortedMemos = pinnedMemos.concat(unpinnedMemos).filter((m) => m.rowStatus === "NORMAL");
|
||||
|
||||
useEffect(() => {
|
||||
memoService
|
||||
@@ -96,33 +94,14 @@ const MemoList: React.FC<Props> = () => {
|
||||
wrapperElement.current?.scrollTo({ top: 0 });
|
||||
}, [query]);
|
||||
|
||||
const handleMemoListClick = useCallback((event: React.MouseEvent) => {
|
||||
const targetEl = event.target as HTMLElement;
|
||||
if (targetEl.tagName === "SPAN" && targetEl.className === "tag-span") {
|
||||
const tagName = targetEl.innerText.slice(1);
|
||||
const currTagQuery = locationService.getState().query.tag;
|
||||
if (currTagQuery === tagName) {
|
||||
locationService.setTagQuery("");
|
||||
} else {
|
||||
locationService.setTagQuery(tagName);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={`memo-list-container ${isFetching ? "" : "completed"}`} onClick={handleMemoListClick} ref={wrapperElement}>
|
||||
<div className={`memo-list-container ${isFetching ? "" : "completed"}`} ref={wrapperElement}>
|
||||
{sortedMemos.map((memo) => (
|
||||
<Memo key={`${memo.id}-${memo.updatedAt}`} memo={memo} />
|
||||
<Memo key={`${memo.id}-${memo.updatedTs}`} memo={memo} />
|
||||
))}
|
||||
<div className="status-text-container">
|
||||
<p className="status-text">
|
||||
{isFetching
|
||||
? "Fetching data..."
|
||||
: sortedMemos.length === 0
|
||||
? "Oops, there is nothing"
|
||||
: showMemoFilter
|
||||
? ""
|
||||
: "Fetching completed 🎉"}
|
||||
{isFetching ? "Fetching data..." : sortedMemos.length === 0 ? "No memos 🌃" : showMemoFilter ? "" : "All memos are ready 🎉"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import useLoading from "../hooks/useLoading";
|
||||
import { locationService, memoService } from "../services";
|
||||
import { showDialog } from "./Dialog";
|
||||
import toastHelper from "./Toast";
|
||||
import DeletedMemo from "./DeletedMemo";
|
||||
import "../less/memo-trash-dialog.less";
|
||||
|
||||
interface Props extends DialogProps {}
|
||||
|
||||
const MemoTrashDialog: React.FC<Props> = (props: Props) => {
|
||||
const { destroy } = props;
|
||||
const loadingState = useLoading();
|
||||
const [deletedMemos, setDeletedMemos] = useState<Model.Memo[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
memoService.fetchAllMemos();
|
||||
memoService
|
||||
.fetchDeletedMemos()
|
||||
.then((result) => {
|
||||
if (result !== false) {
|
||||
setDeletedMemos(result);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
toastHelper.error("Failed to fetch deleted memos: ", error);
|
||||
})
|
||||
.finally(() => {
|
||||
loadingState.setFinish();
|
||||
});
|
||||
locationService.clearQuery();
|
||||
}, []);
|
||||
|
||||
const handleDeletedMemoAction = useCallback((memoId: string) => {
|
||||
setDeletedMemos((deletedMemos) => deletedMemos.filter((memo) => memo.id !== memoId));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="dialog-header-container">
|
||||
<p className="title-text">
|
||||
<span className="icon-text">🗑️</span>
|
||||
Trash Bin
|
||||
</p>
|
||||
<button className="btn close-btn" onClick={destroy}>
|
||||
<img className="icon-img" src="/icons/close.svg" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="dialog-content-container">
|
||||
{loadingState.isLoading ? (
|
||||
<div className="tip-text-container">
|
||||
<p className="tip-text">fetching data...</p>
|
||||
</div>
|
||||
) : deletedMemos.length === 0 ? (
|
||||
<div className="tip-text-container">
|
||||
<p className="tip-text">Here is No Zettels.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="deleted-memos-container">
|
||||
{deletedMemos.map((memo) => (
|
||||
<DeletedMemo key={`${memo.id}-${memo.updatedAt}`} memo={memo} handleDeletedMemoAction={handleDeletedMemoAction} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default function showMemoTrashDialog(): void {
|
||||
showDialog(
|
||||
{
|
||||
className: "memo-trash-dialog",
|
||||
useAppContext: true,
|
||||
},
|
||||
MemoTrashDialog,
|
||||
{}
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useCallback, useContext, useEffect, useState } from "react";
|
||||
import appContext from "../stores/appContext";
|
||||
import SearchBar from "./SearchBar";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { memoService, shortcutService } from "../services";
|
||||
import { useAppSelector } from "../store";
|
||||
import SearchBar from "./SearchBar";
|
||||
import { toggleSiderbar } from "./Sidebar";
|
||||
import "../less/memos-header.less";
|
||||
|
||||
let prevRequestTimestamp = Date.now();
|
||||
@@ -9,25 +10,23 @@ let prevRequestTimestamp = Date.now();
|
||||
interface Props {}
|
||||
|
||||
const MemosHeader: React.FC<Props> = () => {
|
||||
const {
|
||||
locationState: {
|
||||
query: { shortcutId },
|
||||
},
|
||||
shortcutState: { shortcuts },
|
||||
} = useContext(appContext);
|
||||
|
||||
const query = useAppSelector((state) => state.location.query);
|
||||
const shortcuts = useAppSelector((state) => state.shortcut.shortcuts);
|
||||
const [titleText, setTitleText] = useState("MEMOS");
|
||||
|
||||
useEffect(() => {
|
||||
const query = shortcutService.getShortcutById(shortcutId);
|
||||
if (query) {
|
||||
setTitleText(query.title);
|
||||
} else {
|
||||
if (!query?.shortcutId) {
|
||||
setTitleText("MEMOS");
|
||||
return;
|
||||
}
|
||||
}, [shortcutId, shortcuts]);
|
||||
|
||||
const handleMemoTextClick = useCallback(() => {
|
||||
const shortcut = shortcutService.getShortcutById(query?.shortcutId);
|
||||
if (shortcut) {
|
||||
setTitleText(shortcut.title);
|
||||
}
|
||||
}, [query, shortcuts]);
|
||||
|
||||
const handleTitleTextClick = useCallback(() => {
|
||||
const now = Date.now();
|
||||
if (now - prevRequestTimestamp > 10 * 1000) {
|
||||
prevRequestTimestamp = now;
|
||||
@@ -39,8 +38,13 @@ const MemosHeader: React.FC<Props> = () => {
|
||||
|
||||
return (
|
||||
<div className="section-header-container memos-header-container">
|
||||
<div className="title-text" onClick={handleMemoTextClick}>
|
||||
<span className="normal-text">{titleText}</span>
|
||||
<div className="title-container">
|
||||
<div className="action-btn" onClick={toggleSiderbar}>
|
||||
<i className="fa-solid fa-bars icon-img"></i>
|
||||
</div>
|
||||
<span className="title-text" onClick={handleTitleTextClick}>
|
||||
{titleText}
|
||||
</span>
|
||||
</div>
|
||||
<SearchBar />
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import * as api from "../helpers/api";
|
||||
import { locationService, userService } from "../services";
|
||||
import toastHelper from "./Toast";
|
||||
import Only from "./common/OnlyWhen";
|
||||
import showAboutSiteDialog from "./AboutSiteDialog";
|
||||
import "../less/menu-btns-popup.less";
|
||||
|
||||
@@ -27,12 +30,28 @@ const MenuBtnsPopup: React.FC<Props> = (props: Props) => {
|
||||
}
|
||||
}, [shownStatus]);
|
||||
|
||||
const handlePingBtnClick = () => {
|
||||
api
|
||||
.getSystemStatus()
|
||||
.then(({ data }) => {
|
||||
const {
|
||||
data: { profile },
|
||||
} = data;
|
||||
toastHelper.info(JSON.stringify(profile, null, 4));
|
||||
})
|
||||
.catch((error) => {
|
||||
toastHelper.error("Failed to ping\n" + JSON.stringify(error, null, 4));
|
||||
});
|
||||
};
|
||||
|
||||
const handleAboutBtnClick = () => {
|
||||
showAboutSiteDialog();
|
||||
};
|
||||
|
||||
const handleSignOutBtnClick = async () => {
|
||||
await userService.doSignOut();
|
||||
userService.doSignOut().catch(() => {
|
||||
// do nth
|
||||
});
|
||||
locationService.replaceHistory("/signin");
|
||||
window.location.reload();
|
||||
};
|
||||
@@ -42,9 +61,14 @@ const MenuBtnsPopup: React.FC<Props> = (props: Props) => {
|
||||
<button className="btn action-btn" onClick={handleAboutBtnClick}>
|
||||
<span className="icon">🤠</span> About
|
||||
</button>
|
||||
<button className="btn action-btn" onClick={handleSignOutBtnClick}>
|
||||
<span className="icon">👋</span> Sign out
|
||||
<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> Sign out
|
||||
</button>
|
||||
</Only>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import utils from "../helpers/utils";
|
||||
import { showDialog } from "./Dialog";
|
||||
import * as utils from "../helpers/utils";
|
||||
import "../less/preview-image-dialog.less";
|
||||
|
||||
interface Props extends DialogProps {
|
||||
@@ -8,55 +7,29 @@ interface Props extends DialogProps {
|
||||
}
|
||||
|
||||
const PreviewImageDialog: React.FC<Props> = ({ destroy, imgUrl }: Props) => {
|
||||
const imgRef = useRef<HTMLImageElement>(null);
|
||||
const [imgWidth, setImgWidth] = useState<number>(-1);
|
||||
|
||||
useEffect(() => {
|
||||
utils.getImageSize(imgUrl).then(({ width }) => {
|
||||
if (width !== 0) {
|
||||
setImgWidth(80);
|
||||
} else {
|
||||
setImgWidth(0);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleCloseBtnClick = () => {
|
||||
destroy();
|
||||
};
|
||||
|
||||
const handleDecreaseImageSize = () => {
|
||||
if (imgWidth > 30) {
|
||||
setImgWidth(imgWidth - 10);
|
||||
}
|
||||
};
|
||||
|
||||
const handleIncreaseImageSize = () => {
|
||||
setImgWidth(imgWidth + 10);
|
||||
const handleDownloadBtnClick = () => {
|
||||
const a = document.createElement("a");
|
||||
a.href = imgUrl;
|
||||
a.download = `memos-${utils.getDateTimeString(Date.now())}.png`;
|
||||
a.click();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button className="btn close-btn" onClick={handleCloseBtnClick}>
|
||||
<img className="icon-img" src="/icons/close.svg" />
|
||||
</button>
|
||||
|
||||
<div className="img-container">
|
||||
<img className={imgWidth <= 0 ? "hidden" : ""} ref={imgRef} width={imgWidth + "%"} src={imgUrl} />
|
||||
<span className={"loading-text " + (imgWidth === -1 ? "" : "hidden")}>Loading image...</span>
|
||||
<span className={"loading-text " + (imgWidth === 0 ? "" : "hidden")}>😟 Failed to load image</span>
|
||||
<div className="btns-container">
|
||||
<button className="btn" onClick={handleCloseBtnClick}>
|
||||
<i className="fa-solid fa-xmark fa-lg icon-img"></i>
|
||||
</button>
|
||||
<button className="btn" onClick={handleDownloadBtnClick}>
|
||||
<i className="fa-solid fa-download icon-img"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="action-btns-container">
|
||||
<button className="btn" onClick={handleDecreaseImageSize}>
|
||||
➖
|
||||
</button>
|
||||
<button className="btn" onClick={handleIncreaseImageSize}>
|
||||
➕
|
||||
</button>
|
||||
<button className="btn" onClick={() => setImgWidth(80)}>
|
||||
⭕
|
||||
</button>
|
||||
<div className="img-container">
|
||||
<img src={imgUrl} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,22 +1,17 @@
|
||||
import { useContext } from "react";
|
||||
import appContext from "../stores/appContext";
|
||||
import { locationService } from "../services";
|
||||
import { useAppSelector } from "../store";
|
||||
import { memoSpecialTypes } from "../helpers/filter";
|
||||
import "../less/search-bar.less";
|
||||
|
||||
interface Props {}
|
||||
|
||||
const SearchBar: React.FC<Props> = () => {
|
||||
const {
|
||||
locationState: {
|
||||
query: { type: memoType },
|
||||
},
|
||||
} = useContext(appContext);
|
||||
const memoType = useAppSelector((state) => state.location.query?.type);
|
||||
|
||||
const handleMemoTypeItemClick = (type: MemoSpecType | "") => {
|
||||
const { type: prevType } = locationService.getState().query;
|
||||
const handleMemoTypeItemClick = (type: MemoSpecType | undefined) => {
|
||||
const { type: prevType } = locationService.getState().query ?? {};
|
||||
if (type === prevType) {
|
||||
type = "";
|
||||
type = undefined;
|
||||
}
|
||||
locationService.setMemoTypeQuery(type);
|
||||
};
|
||||
@@ -29,7 +24,7 @@ const SearchBar: React.FC<Props> = () => {
|
||||
return (
|
||||
<div className="search-bar-container">
|
||||
<div className="search-bar-inputer">
|
||||
<img className="icon-img" src="/icons/search.svg" />
|
||||
<i className="fa-solid fa-magnifying-glass fa-sm icon-img"></i>
|
||||
<input className="text-input" type="text" placeholder="" onChange={handleTextQueryInput} />
|
||||
</div>
|
||||
<div className="quickly-action-wrapper">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useContext, useState } from "react";
|
||||
import appContext from "../stores/appContext";
|
||||
import { useState } from "react";
|
||||
import { useAppSelector } from "../store";
|
||||
import { showDialog } from "./Dialog";
|
||||
import MyAccountSection from "./Settings/MyAccountSection";
|
||||
import PreferencesSection from "./Settings/PreferencesSection";
|
||||
@@ -15,10 +15,8 @@ interface State {
|
||||
}
|
||||
|
||||
const SettingDialog: React.FC<Props> = (props: Props) => {
|
||||
const {
|
||||
userState: { user },
|
||||
} = useContext(appContext);
|
||||
const { destroy } = props;
|
||||
const user = useAppSelector((state) => state.user.user);
|
||||
const [state, setState] = useState<State>({
|
||||
selectedSection: "my-account",
|
||||
});
|
||||
@@ -32,31 +30,35 @@ const SettingDialog: React.FC<Props> = (props: Props) => {
|
||||
return (
|
||||
<div className="dialog-content-container">
|
||||
<button className="btn close-btn" onClick={destroy}>
|
||||
<img className="icon-img" src="/icons/close.svg" />
|
||||
<i className="fa-solid fa-xmark fa-lg icon-img"></i>
|
||||
</button>
|
||||
<div className="section-selector-container">
|
||||
<span className="section-title">Basic</span>
|
||||
<span
|
||||
onClick={() => handleSectionSelectorItemClick("my-account")}
|
||||
className={`section-item ${state.selectedSection === "my-account" ? "selected" : ""}`}
|
||||
>
|
||||
My account
|
||||
</span>
|
||||
<span
|
||||
onClick={() => handleSectionSelectorItemClick("preferences")}
|
||||
className={`section-item ${state.selectedSection === "preferences" ? "selected" : ""}`}
|
||||
>
|
||||
Preferences
|
||||
</span>
|
||||
{user?.role === "OWNER" ? (
|
||||
<div className="section-items-container">
|
||||
<span
|
||||
onClick={() => handleSectionSelectorItemClick("my-account")}
|
||||
className={`section-item ${state.selectedSection === "my-account" ? "selected" : ""}`}
|
||||
>
|
||||
<span className="icon-text">🤠</span> My account
|
||||
</span>
|
||||
<span
|
||||
onClick={() => handleSectionSelectorItemClick("preferences")}
|
||||
className={`section-item ${state.selectedSection === "preferences" ? "selected" : ""}`}
|
||||
>
|
||||
<span className="icon-text">🏟</span> Preferences
|
||||
</span>
|
||||
</div>
|
||||
{user?.role === "HOST" ? (
|
||||
<>
|
||||
<span className="section-title">Admin</span>
|
||||
<span
|
||||
onClick={() => handleSectionSelectorItemClick("member")}
|
||||
className={`section-item ${state.selectedSection === "member" ? "selected" : ""}`}
|
||||
>
|
||||
Member
|
||||
</span>
|
||||
<div className="section-items-container">
|
||||
<span
|
||||
onClick={() => handleSectionSelectorItemClick("member")}
|
||||
className={`section-item ${state.selectedSection === "member" ? "selected" : ""}`}
|
||||
>
|
||||
<span className="icon-text">👤</span> Member
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||