Unverified Commit 475f258f authored by XQ's avatar XQ Committed by GitHub

Merge pull request #26 from justmemos/refactor/go

🔥🔥🔥Big refactoring🔥🔥🔥
parents 3d8997a4 925773db
...@@ -28,4 +28,4 @@ jobs: ...@@ -28,4 +28,4 @@ jobs:
context: ./ context: ./
file: ./Dockerfile file: ./Dockerfile
push: true push: true
tags: ${{ secrets.DOCKER_NEOSMEMO_USERNAME }}/memos:latest tags: ${{ secrets.DOCKER_NEOSMEMO_USERNAME }}/memos:next
...@@ -17,18 +17,13 @@ COPY . . ...@@ -17,18 +17,13 @@ COPY . .
RUN go build \ RUN go build \
-o memos \ -o memos \
./server/main.go ./bin/server/main.go
# Make workspace with above generated files. # Make workspace with above generated files.
FROM alpine:3.14.3 AS monolithic FROM alpine:3.14.3 AS monolithic
WORKDIR /usr/local/memos WORKDIR /usr/local/memos
RUN apk add --no-cache tzdata
ENV TZ="Asia/Shanghai"
COPY --from=backend /backend-build/memos /usr/local/memos/ COPY --from=backend /backend-build/memos /usr/local/memos/
# Copy default resources, like db file.
COPY --from=backend /backend-build/resources /usr/local/memos/resources
COPY --from=frontend /frontend-build/dist /usr/local/memos/web/dist COPY --from=frontend /frontend-build/dist /usr/local/memos/web/dist
CMD ["./memos"] CMD ["./memos"]
......
...@@ -2,8 +2,7 @@ ...@@ -2,8 +2,7 @@
<p align="center"> <p align="center">
<a href="https://memos.onrender.com/">Live Demo</a> <a href="https://memos.onrender.com/">Live Demo</a>
<a href="https://github.com/justmemos/memos/discussions">Discussions</a> <a href="https://github.com/justmemos/memos/discussions">Discussions</a>
<a href="https://t.me/+M-AqruZmJBhkYWQ1">Telegram</a>
</p> </p>
<p align="center"> <p align="center">
...@@ -13,23 +12,30 @@ ...@@ -13,23 +12,30 @@
<img alt="GitHub license" src="https://img.shields.io/github/license/justmemos/memos" /> <img alt="GitHub license" src="https://img.shields.io/github/license/justmemos/memos" />
</p> </p>
Memos 是一款开源的 [flomo](https://flomoapp.com/) 替代工具,为了快速方便的部署属于自己的碎片化知识管理工具。 Memos is an open source, self-hosted alternative to [flomo](https://flomoapp.com/). Built with `Golang` and `React`.
## 🎯 产品意图 Making sure that you are in charge of your data and more customizations.
- 📅 用于记录:每日/周计划、💡 突发奇想、📕 读后感... ## 🎯 Intentions
- 🏗️ 代替了微信“文件传输助手”;
- 📒 打造一个属于自己的轻量化“卡片”笔记簿;
## ✨ 特色亮点 - ✍️ For noting 📅 daily/weekly plans, 💡 fantastic ideas, 📕 reading thoughts...
- 📒 Write down the lightweight card memos easily;
- 🏗️ Build your own fragmented knowledge management tools;
- 🦄 开源项目; ## ✨ Features
- 😋 精美且细节的视觉样式;
- 📑 体验优良的交互逻辑;
- ⚡️ 快速地私有化部署;
## 📕 文档 - 🦄 Open source project;
- 😋 Beautiful and detailed visual styles;
- 📑 Experience excellent interaction logic;
- ⚡️ Quick privatization deployment;
- [使用 Docker 部署](https://github.com/justmemos/memos/tree/main/docs/deploy) <!--
WIP
## 📕 Docs
Enjoy it and welcome your contributions - [Guide to self host with Docker](https://github.com/justmemos/memos/tree/main/docs/deploy)
-->
---
Just enjoy it.
package api package api
import ( type Login struct {
"encoding/json" Name string `json:"name"`
"memos/api/e"
"memos/store"
"net/http"
"github.com/gorilla/mux"
)
func handleUserSignUp(w http.ResponseWriter, r *http.Request) {
type UserSignUpDataBody struct {
Username string `json:"username"`
Password string `json:"password"` Password string `json:"password"`
}
userSignup := UserSignUpDataBody{}
err := json.NewDecoder(r.Body).Decode(&userSignup)
if err != nil {
e.ErrorHandler(w, "REQUEST_BODY_ERROR", "Bad request")
return
}
usernameUsable, _ := store.CheckUsernameUsable(userSignup.Username)
if !usernameUsable {
json.NewEncoder(w).Encode(Response{
Succeed: false,
Message: "Username is existed",
Data: nil,
})
return
}
user, err := store.CreateNewUser(userSignup.Username, userSignup.Password)
if err != nil {
e.ErrorHandler(w, "DATABASE_ERROR", err.Error())
return
}
session, _ := SessionStore.Get(r, "session")
session.Values["user_id"] = user.Id
session.Save(r, w)
json.NewEncoder(w).Encode(Response{
Succeed: true,
Message: "",
Data: user,
})
} }
func handleUserSignIn(w http.ResponseWriter, r *http.Request) { type Signup struct {
type UserSigninDataBody struct { Name string `json:"name"`
Username string `json:"username"`
Password string `json:"password"` Password string `json:"password"`
}
userSignin := UserSigninDataBody{}
err := json.NewDecoder(r.Body).Decode(&userSignin)
if err != nil {
e.ErrorHandler(w, "REQUEST_BODY_ERROR", "Bad request")
return
}
user, err := store.GetUserByUsernameAndPassword(userSignin.Username, userSignin.Password)
if err != nil {
json.NewEncoder(w).Encode(Response{
Succeed: false,
Message: "Username and password not allowed",
Data: nil,
})
return
}
session, _ := SessionStore.Get(r, "session")
session.Values["user_id"] = user.Id
session.Save(r, w)
json.NewEncoder(w).Encode(Response{
Succeed: true,
Message: "",
Data: user,
})
}
func handleUserSignOut(w http.ResponseWriter, r *http.Request) {
session, _ := SessionStore.Get(r, "session")
session.Values["user_id"] = ""
session.Save(r, w)
json.NewEncoder(w).Encode(Response{
Succeed: true,
Message: "",
Data: nil,
})
}
func RegisterAuthRoutes(r *mux.Router) {
authRouter := r.PathPrefix("/api/auth").Subrouter()
authRouter.Use(JSONResponseMiddleWare)
authRouter.HandleFunc("/signup", handleUserSignUp).Methods("POST")
authRouter.HandleFunc("/signin", handleUserSignIn).Methods("POST")
authRouter.HandleFunc("/signout", handleUserSignOut).Methods("POST")
} }
package e
var Codes = map[string]int{
"NOT_AUTH": 20001,
"REQUEST_BODY_ERROR": 40001,
"UPLOAD_FILE_ERROR": 40002,
"OVERLOAD_MAX_SIZE": 40003,
"NOT_FOUND": 40400,
"USER_NOT_FOUND": 40401,
"RESOURCE_NOT_FOUND": 40402,
"DATABASE_ERROR": 50001,
}
package e
import (
"encoding/json"
"net/http"
)
type ServerError struct {
Code int
Message string
}
type ErrorResponse struct {
Succeed bool `json:"succeed"`
Message string `json:"message"`
StatusCode int `json:"statusCode"`
Data interface{} `json:"data"`
}
func getServerError(err string) ServerError {
code, exists := Codes[err]
println(err)
if !exists {
err = "BAD_REQUEST"
code = 40000
}
return ServerError{
Code: code,
Message: err,
}
}
func ErrorHandler(w http.ResponseWriter, err string, message string) {
serverError := getServerError(err)
res := ErrorResponse{
Succeed: false,
Message: message,
StatusCode: serverError.Code,
Data: nil,
}
statusCode := int(serverError.Code / 100)
w.WriteHeader(statusCode)
json.NewEncoder(w).Encode(res)
}
package api package api
import ( type Memo struct {
"encoding/json" Id int `json:"id"`
"memos/api/e" CreatedTs int64 `json:"createdTs"`
"memos/store" UpdatedTs int64 `json:"updatedTs"`
"net/http" RowStatus string `json:"rowStatus"`
"github.com/gorilla/mux" Content string `json:"content"`
) CreatorId int `json:"creatorId"`
func handleGetMyMemos(w http.ResponseWriter, r *http.Request) {
userId, _ := GetUserIdInSession(r)
urlParams := r.URL.Query()
deleted := urlParams.Get("deleted")
onlyDeletedFlag := deleted == "true"
memos, err := store.GetMemosByUserId(userId, onlyDeletedFlag)
if err != nil {
e.ErrorHandler(w, "DATABASE_ERROR", err.Error())
return
}
json.NewEncoder(w).Encode(Response{
Succeed: true,
Message: "",
Data: memos,
})
} }
func handleCreateMemo(w http.ResponseWriter, r *http.Request) { type MemoCreate struct {
userId, _ := GetUserIdInSession(r)
type CreateMemoDataBody struct {
Content string `json:"content"` Content string `json:"content"`
} CreatorId int
createMemo := CreateMemoDataBody{}
err := json.NewDecoder(r.Body).Decode(&createMemo)
if err != nil {
e.ErrorHandler(w, "REQUEST_BODY_ERROR", "Bad request")
return
}
memo, err := store.CreateNewMemo(createMemo.Content, userId)
if err != nil {
e.ErrorHandler(w, "DATABASE_ERROR", err.Error())
return
}
json.NewEncoder(w).Encode(Response{
Succeed: true,
Message: "",
Data: memo,
})
} }
func handleUpdateMemo(w http.ResponseWriter, r *http.Request) { type MemoPatch struct {
vars := mux.Vars(r) Id int
memoId := vars["id"]
memoPatch := store.MemoPatch{}
err := json.NewDecoder(r.Body).Decode(&memoPatch)
if err != nil {
e.ErrorHandler(w, "REQUEST_BODY_ERROR", "Bad request")
return
}
memo, err := store.UpdateMemo(memoId, &memoPatch)
if err != nil {
e.ErrorHandler(w, "DATABASE_ERROR", err.Error())
return
}
json.NewEncoder(w).Encode(Response{ Content *string `json:"content"`
Succeed: true, RowStatus *string `json:"rowStatus"`
Message: "",
Data: memo,
})
} }
func handleDeleteMemo(w http.ResponseWriter, r *http.Request) { type MemoFind struct {
vars := mux.Vars(r) Id *int `json:"id"`
memoId := vars["id"] CreatorId *int `json:"creatorId"`
RowStatus *string `json:"rowStatus"`
err := store.DeleteMemo(memoId)
if err != nil {
e.ErrorHandler(w, "DATABASE_ERROR", err.Error())
return
}
json.NewEncoder(w).Encode(Response{
Succeed: true,
Message: "",
Data: nil,
})
} }
func RegisterMemoRoutes(r *mux.Router) { type MemoDelete struct {
memoRouter := r.PathPrefix("/api/memo").Subrouter() Id *int `json:"id"`
CreatorId *int
memoRouter.Use(JSONResponseMiddleWare) }
memoRouter.Use(AuthCheckerMiddleWare)
memoRouter.HandleFunc("/all", handleGetMyMemos).Methods("GET") type MemoService interface {
memoRouter.HandleFunc("/", handleCreateMemo).Methods("PUT") CreateMemo(create *MemoCreate) (*Memo, error)
memoRouter.HandleFunc("/{id}", handleUpdateMemo).Methods("PATCH") PatchMemo(patch *MemoPatch) (*Memo, error)
memoRouter.HandleFunc("/{id}", handleDeleteMemo).Methods("DELETE") FindMemoList(find *MemoFind) ([]*Memo, error)
FindMemo(find *MemoFind) (*Memo, error)
DeleteMemo(delete *MemoDelete) error
} }
package api
import (
"memos/api/e"
"net/http"
)
func AuthCheckerMiddleWare(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session, _ := SessionStore.Get(r, "session")
if userId, ok := session.Values["user_id"].(string); !ok || userId == "" {
e.ErrorHandler(w, "NOT_AUTH", "Need authorize")
return
}
next.ServeHTTP(w, r)
})
}
func JSONResponseMiddleWare(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
next.ServeHTTP(w, r)
})
}
func CorsMiddleWare(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
next.ServeHTTP(w, r)
})
}
package api
import (
"encoding/json"
"memos/api/e"
"memos/store"
"net/http"
"github.com/gorilla/mux"
)
func handleGetMyQueries(w http.ResponseWriter, r *http.Request) {
userId, _ := GetUserIdInSession(r)
queries, err := store.GetQueriesByUserId(userId)
if err != nil {
e.ErrorHandler(w, "DATABASE_ERROR", err.Error())
return
}
json.NewEncoder(w).Encode(Response{
Succeed: true,
Message: "",
Data: queries,
})
}
func handleCreateQuery(w http.ResponseWriter, r *http.Request) {
userId, _ := GetUserIdInSession(r)
type CreateQueryDataBody struct {
Title string `json:"title"`
Querystring string `json:"querystring"`
}
queryData := CreateQueryDataBody{}
err := json.NewDecoder(r.Body).Decode(&queryData)
if err != nil {
e.ErrorHandler(w, "REQUEST_BODY_ERROR", "Bad request")
return
}
query, err := store.CreateNewQuery(queryData.Title, queryData.Querystring, userId)
if err != nil {
e.ErrorHandler(w, "DATABASE_ERROR", err.Error())
return
}
json.NewEncoder(w).Encode(Response{
Succeed: true,
Message: "",
Data: query,
})
}
func handleUpdateQuery(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
queryId := vars["id"]
queryPatch := store.QueryPatch{}
err := json.NewDecoder(r.Body).Decode(&queryPatch)
if err != nil {
e.ErrorHandler(w, "REQUEST_BODY_ERROR", "Bad request")
return
}
query, err := store.UpdateQuery(queryId, &queryPatch)
if err != nil {
e.ErrorHandler(w, "DATABASE_ERROR", err.Error())
return
}
json.NewEncoder(w).Encode(Response{
Succeed: true,
Message: "",
Data: query,
})
}
func handleDeleteQuery(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
queryId := vars["id"]
err := store.DeleteQuery(queryId)
if err != nil {
e.ErrorHandler(w, "DATABASE_ERROR", err.Error())
return
}
json.NewEncoder(w).Encode(Response{
Succeed: true,
Message: "",
Data: nil,
})
}
func RegisterQueryRoutes(r *mux.Router) {
queryRouter := r.PathPrefix("/api/query").Subrouter()
queryRouter.Use(JSONResponseMiddleWare)
queryRouter.Use(AuthCheckerMiddleWare)
queryRouter.HandleFunc("/all", handleGetMyQueries).Methods("GET")
queryRouter.HandleFunc("/", handleCreateQuery).Methods("PUT")
queryRouter.HandleFunc("/{id}", handleUpdateQuery).Methods("PATCH")
queryRouter.HandleFunc("/{id}", handleDeleteQuery).Methods("DELETE")
}
package api package api
import ( type Resource struct {
"encoding/json" Id int `json:"id"`
"io/ioutil" CreatedTs int64 `json:"createdTs"`
"memos/api/e" UpdatedTs int64 `json:"updatedTs"`
"memos/store"
"net/http"
"strings"
"github.com/gorilla/mux" Filename string `json:"filename"`
) Blob []byte `json:"blob"`
Type string `json:"type"`
Size int64 `json:"size"`
func handleGetMyResources(w http.ResponseWriter, r *http.Request) { CreatorId int `json:"creatorId"`
userId, _ := GetUserIdInSession(r)
resources, err := store.GetResourcesByUserId(userId)
if err != nil {
e.ErrorHandler(w, "DATABASE_ERROR", err.Error())
return
}
json.NewEncoder(w).Encode(Response{
Succeed: true,
Message: "",
Data: resources,
})
} }
func handleUploadResource(w http.ResponseWriter, r *http.Request) { type ResourceCreate struct {
userId, _ := GetUserIdInSession(r) Filename string `json:"filename"`
Blob []byte `json:"blob"`
err := r.ParseMultipartForm(5 << 20) Type string `json:"type"`
Size int64 `json:"size"`
if err != nil {
e.ErrorHandler(w, "OVERLOAD_MAX_SIZE", "The max size of resource is 5Mb.")
return
}
file, handler, err := r.FormFile("file")
if err != nil {
e.ErrorHandler(w, "REQUEST_BODY_ERROR", "Bad request")
return
}
defer file.Close()
filename := handler.Filename
filetype := handler.Header.Get("Content-Type")
size := handler.Size
fileBytes, err := ioutil.ReadAll(file)
if err != nil {
e.ErrorHandler(w, "UPLOAD_FILE_ERROR", "Read file error")
return
}
resource, err := store.CreateResource(userId, filename, fileBytes, filetype, size)
if err != nil { CreatorId int
e.ErrorHandler(w, "DATABASE_ERROR", err.Error())
return
}
json.NewEncoder(w).Encode(Response{
Succeed: true,
Message: "Upload file succeed",
Data: resource,
})
} }
func handleDeleteResource(w http.ResponseWriter, r *http.Request) { type ResourceFind struct {
vars := mux.Vars(r) Id *int `json:"id"`
resourceId := vars["id"] CreatorId *int `json:"creatorId"`
Filename *string `json:"filename"`
err := store.DeleteResourceById(resourceId)
if err != nil {
e.ErrorHandler(w, "DATABASE_ERROR", err.Error())
return
}
json.NewEncoder(w).Encode(Response{
Succeed: true,
Message: "",
Data: nil,
})
} }
func handleGetResource(w http.ResponseWriter, r *http.Request) { type ResourceDelete struct {
vars := mux.Vars(r) Id int
resourceId := vars["id"]
filename := vars["filename"]
etag := `"` + resourceId + "/" + filename + `"`
w.Header().Set("Etag", etag)
w.Header().Set("Cache-Control", "max-age=2592000")
if match := r.Header.Get("If-None-Match"); match != "" {
if strings.Contains(match, etag) {
w.WriteHeader(http.StatusNotModified)
return
}
}
resource, err := store.GetResourceByIdAndFilename(resourceId, filename)
if err != nil {
e.ErrorHandler(w, "RESOURCE_NOT_FOUND", err.Error())
return
}
w.WriteHeader(http.StatusOK)
w.Header().Set("Content-Type", "application/octet-stream")
w.Write(resource.Blob)
} }
func RegisterResourceRoutes(r *mux.Router) { type ResourceService interface {
resourceRouter := r.PathPrefix("/").Subrouter() CreateResource(create *ResourceCreate) (*Resource, error)
FindResourceList(find *ResourceFind) ([]*Resource, error)
resourceRouter.Use(AuthCheckerMiddleWare) FindResource(find *ResourceFind) (*Resource, error)
DeleteResource(delete *ResourceDelete) error
resourceRouter.HandleFunc("/api/resource/all", handleGetMyResources).Methods("GET")
resourceRouter.HandleFunc("/api/resource/", handleUploadResource).Methods("PUT")
resourceRouter.HandleFunc("/api/resource/{id}", handleDeleteResource).Methods("DELETE")
resourceRouter.HandleFunc("/r/{id}/{filename}", handleGetResource).Methods("GET")
} }
package api
import (
"memos/utils"
"github.com/gorilla/sessions"
)
var SessionStore = sessions.NewCookieStore([]byte(utils.GenUUID()))
package api
type Shortcut struct {
Id int `json:"id"`
CreatedTs int64 `json:"createdTs"`
UpdatedTs int64 `json:"updatedTs"`
Title string `json:"title"`
Payload string `json:"payload"`
RowStatus string `json:"rowStatus"`
CreatorId int
}
type ShortcutCreate struct {
// Standard fields
CreatorId int
// Domain specific fields
Title string `json:"title"`
Payload string `json:"payload"`
}
type ShortcutPatch struct {
Id int
Title *string `json:"title"`
Payload *string `json:"payload"`
RowStatus *string `json:"rowStatus"`
}
type ShortcutFind struct {
Id *int
// Standard fields
CreatorId *int
// Domain specific fields
Title *string `json:"title"`
}
type ShortcutDelete struct {
Id int
}
type ShortcutService interface {
CreateShortcut(create *ShortcutCreate) (*Shortcut, error)
PatchShortcut(patch *ShortcutPatch) (*Shortcut, error)
FindShortcutList(find *ShortcutFind) ([]*Shortcut, error)
FindShortcut(find *ShortcutFind) (*Shortcut, error)
DeleteShortcut(delete *ShortcutDelete) error
}
package api
import (
"net/http"
"os"
"path/filepath"
)
type SPAHandler struct {
StaticPath string
IndexPath string
}
func (h SPAHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
path, err := filepath.Abs(r.URL.Path)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
path = filepath.Join(h.StaticPath, path)
_, err = os.Stat(path)
if os.IsNotExist(err) {
// file does not exist, serve index.html
http.ServeFile(w, r, filepath.Join(h.StaticPath, h.IndexPath))
return
} else if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
http.FileServer(http.Dir(h.StaticPath)).ServeHTTP(w, r)
}
package api package api
import ( type User struct {
"encoding/json" Id int `json:"id"`
"memos/api/e" CreatedTs int64 `json:"createdTs"`
"memos/store" UpdatedTs int64 `json:"updatedTs"`
"net/http"
OpenId string `json:"openId"`
"github.com/gorilla/mux" Name string `json:"name"`
) Password string `json:"-"`
func handleGetMyUserInfo(w http.ResponseWriter, r *http.Request) {
userId, _ := GetUserIdInSession(r)
user, err := store.GetUserById(userId)
if err != nil {
e.ErrorHandler(w, "USER_NOT_FOUND", err.Error())
return
}
json.NewEncoder(w).Encode(Response{
Succeed: true,
Message: "",
Data: user,
})
} }
func handleUpdateMyUserInfo(w http.ResponseWriter, r *http.Request) { type UserCreate struct {
userId, _ := GetUserIdInSession(r) OpenId string `json:"openId"`
Name string `json:"name"`
updateUserPatch := store.UpdateUserPatch{} Password string `json:"password"`
err := json.NewDecoder(r.Body).Decode(&updateUserPatch)
if err != nil {
e.ErrorHandler(w, "REQUEST_BODY_ERROR", "Bad request")
return
}
if updateUserPatch.Username != nil {
usernameUsable, _ := store.CheckUsernameUsable(*updateUserPatch.Username)
if !usernameUsable {
json.NewEncoder(w).Encode(Response{
Succeed: false,
Message: "Username is existed",
Data: nil,
})
return
}
}
user, err := store.UpdateUser(userId, &updateUserPatch)
if err != nil {
e.ErrorHandler(w, "DATABASE_ERROR", err.Error())
return
}
json.NewEncoder(w).Encode(Response{
Succeed: true,
Message: "",
Data: user,
})
} }
func handleResetUserOpenId(w http.ResponseWriter, r *http.Request) { type UserPatch struct {
userId, _ := GetUserIdInSession(r) Id int
openId, err := store.ResetUserOpenId(userId)
if err != nil { OpenId *string
e.ErrorHandler(w, "DATABASE_ERROR", err.Error())
return
}
json.NewEncoder(w).Encode(Response{ Name *string `json:"name"`
Succeed: true, Password *string `json:"password"`
Message: "", ResetOpenId *bool `json:"resetOpenId"`
Data: openId,
})
} }
func handleCheckUsername(w http.ResponseWriter, r *http.Request) { type UserFind struct {
type CheckUsernameDataBody struct { Id *int `json:"id"`
Username string
}
checkUsername := CheckUsernameDataBody{} Name *string `json:"name"`
err := json.NewDecoder(r.Body).Decode(&checkUsername) Password *string
OpenId *string
if err != nil {
e.ErrorHandler(w, "REQUEST_BODY_ERROR", "Bad request")
return
}
usable, err := store.CheckUsernameUsable(checkUsername.Username)
if err != nil {
e.ErrorHandler(w, "DATABASE_ERROR", err.Error())
return
}
json.NewEncoder(w).Encode(Response{
Succeed: true,
Message: "",
Data: usable,
})
} }
func handleValidPassword(w http.ResponseWriter, r *http.Request) { type UserRenameCheck struct {
type ValidPasswordDataBody struct { Name string `json:"name"`
Password string
}
userId, _ := GetUserIdInSession(r)
validPassword := ValidPasswordDataBody{}
err := json.NewDecoder(r.Body).Decode(&validPassword)
if err != nil {
e.ErrorHandler(w, "REQUEST_BODY_ERROR", "Bad request")
return
}
valid, err := store.CheckPasswordValid(userId, validPassword.Password)
if err != nil {
e.ErrorHandler(w, "DATABASE_ERROR", err.Error())
return
}
json.NewEncoder(w).Encode(Response{
Succeed: true,
Message: "",
Data: valid,
})
} }
func RegisterUserRoutes(r *mux.Router) { type UserPasswordCheck struct {
userRouter := r.PathPrefix("/api/user").Subrouter() Password string `json:"password"`
}
userRouter.Use(JSONResponseMiddleWare)
userRouter.Use(AuthCheckerMiddleWare)
userRouter.HandleFunc("/me", handleGetMyUserInfo).Methods("GET") type UserService interface {
userRouter.HandleFunc("/me", handleUpdateMyUserInfo).Methods("PATCH") CreateUser(create *UserCreate) (*User, error)
userRouter.HandleFunc("/open_id/new", handleResetUserOpenId).Methods("POST") PatchUser(patch *UserPatch) (*User, error)
userRouter.HandleFunc("/checkusername", handleCheckUsername).Methods("POST") FindUser(find *UserFind) (*User, error)
userRouter.HandleFunc("/validpassword", handleValidPassword).Methods("POST")
} }
package api
import (
"net/http"
)
type Response struct {
Succeed bool `json:"succeed"`
Message string `json:"message"`
Data interface{} `json:"data"`
}
func GetUserIdInSession(r *http.Request) (string, error) {
session, _ := SessionStore.Get(r, "session")
userId, ok := session.Values["user_id"].(string)
if !ok {
return "", http.ErrNoCookie
}
return userId, nil
}
package api
import (
"encoding/json"
"memos/api/e"
"memos/store"
"net/http"
"github.com/gorilla/mux"
)
func handleCreateMemoByWH(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
openId := vars["openId"]
type CreateMemoDataBody struct {
Content string `json:"content"`
}
createMemo := CreateMemoDataBody{}
err := json.NewDecoder(r.Body).Decode(&createMemo)
if err != nil {
e.ErrorHandler(w, "REQUEST_BODY_ERROR", "Bad request")
return
}
user, err := store.GetUserByOpenId(openId)
if err != nil {
e.ErrorHandler(w, "DATABASE_ERROR", err.Error())
return
}
memo, err := store.CreateNewMemo(createMemo.Content, user.Id)
if err != nil {
e.ErrorHandler(w, "DATABASE_ERROR", err.Error())
return
}
json.NewEncoder(w).Encode(Response{
Succeed: true,
Message: "",
Data: memo,
})
}
func RegisterWebHooksRoutes(r *mux.Router) {
memoRouter := r.PathPrefix("/api/whs").Subrouter()
memoRouter.Use(JSONResponseMiddleWare)
memoRouter.HandleFunc("/memo/{openId}", handleCreateMemoByWH).Methods("POST")
}
//go:build !release
// +build !release
package cmd
import (
"fmt"
)
// GetDevProfile will return a profile for dev.
func GetDevProfile(dataDir string) Profile {
return Profile{
mode: "8080",
port: 8080,
dsn: fmt.Sprintf("file:%s/memos_dev.db", dataDir),
}
}
package cmd
import (
"fmt"
"os"
"path/filepath"
"strings"
"memos/server"
"memos/store"
)
var (
dataDir string
)
type Profile struct {
// mode can be "release" or "dev"
mode string
// port is the binding port for server.
port int
// dsn points to where Memos stores its own data
dsn string
}
func checkDataDir() error {
// Convert to absolute path if relative path is supplied.
if !filepath.IsAbs(dataDir) {
absDir, err := filepath.Abs(filepath.Dir(os.Args[0]) + "/" + dataDir)
if err != nil {
return err
}
dataDir = absDir
}
// Trim trailing / in case user supplies
dataDir = strings.TrimRight(dataDir, "/")
if _, err := os.Stat(dataDir); err != nil {
error := fmt.Errorf("unable to access --data %s, %w", dataDir, err)
return error
}
return nil
}
type Main struct {
profile *Profile
server *server.Server
db *store.DB
}
func Execute() {
err := checkDataDir()
if err != nil {
fmt.Printf("%+v\n", err)
os.Exit(1)
}
m := Main{}
profile := GetDevProfile(dataDir)
m.profile = &profile
err = m.Run()
if err != nil {
fmt.Printf("%+v\n", err)
os.Exit(1)
}
}
func (m *Main) Run() error {
db := store.NewDB(m.profile.dsn)
if err := db.Open(); err != nil {
return fmt.Errorf("cannot open db: %w", err)
}
m.db = db
s := server.NewServer(m.profile.port)
s.ShortcutService = store.NewShortcutService(db)
s.MemoService = store.NewMemoService(db)
s.UserService = store.NewUserService(db)
s.ShortcutService = store.NewShortcutService(db)
s.ResourceService = store.NewResourceService(db)
m.server = s
if err := s.Run(); err != nil {
return err
}
return nil
}
package main
import "memos/bin/server/cmd"
func main() {
cmd.Execute()
}
package common
import (
"errors"
)
// Code is the error code.
type Code int
// Application error codes.
const (
// 0 ~ 99 general error
Ok Code = 0
Internal Code = 1
NotAuthorized Code = 2
Invalid Code = 3
NotFound Code = 4
Conflict Code = 5
NotImplemented Code = 6
// 101 ~ 199 db error
DbConnectionFailure Code = 101
DbStatementSyntaxError Code = 102
DbExecutionError Code = 103
// 201 db migration error
// Db migration is a core feature, so we separate it from the db error
MigrationSchemaMissing Code = 201
MigrationAlreadyApplied Code = 202
MigrationOutOfOrder Code = 203
MigrationBaselineMissing Code = 204
// 301 task error
TaskTimingNotAllowed Code = 301
// 10001 advisor error code
CompatibilityDropDatabase Code = 10001
CompatibilityRenameTable Code = 10002
CompatibilityDropTable Code = 10003
CompatibilityRenameColumn Code = 10004
CompatibilityDropColumn Code = 10005
CompatibilityAddPrimaryKey Code = 10006
CompatibilityAddUniqueKey Code = 10007
CompatibilityAddForeignKey Code = 10008
CompatibilityAddCheck Code = 10009
CompatibilityAlterCheck Code = 10010
CompatibilityAlterColumn Code = 10011
)
// Error represents an application-specific error. Application errors can be
// unwrapped by the caller to extract out the code & message.
//
// Any non-application error (such as a disk error) should be reported as an
// Internal error and the human user should only see "Internal error" as the
// message. These low-level internal error details should only be logged and
// reported to the operator of the application (not the end user).
type Error struct {
// Machine-readable error code.
Code Code
// Embedded error.
Err error
}
// Error implements the error interface. Not used by the application otherwise.
func (e *Error) Error() string {
return e.Err.Error()
}
// ErrorCode unwraps an application error and returns its code.
// Non-application errors always return EINTERNAL.
func ErrorCode(err error) Code {
var e *Error
if err == nil {
return Ok
} else if errors.As(err, &e) {
return e.Code
}
return Internal
}
// ErrorMessage unwraps an application error and returns its message.
// Non-application errors always return "Internal error".
func ErrorMessage(err error) string {
var e *Error
if err == nil {
return ""
} else if errors.As(err, &e) {
return e.Err.Error()
}
return "Internal error."
}
// Errorf is a helper function to return an Error with a given code and error.
func Errorf(code Code, err error) *Error {
return &Error{
Code: code,
Err: err,
}
}
package common
import (
"strings"
"github.com/google/uuid"
)
// HasPrefixes returns true if the string s has any of the given prefixes.
func HasPrefixes(src string, prefixes ...string) bool {
for _, prefix := range prefixes {
if strings.HasPrefix(src, prefix) {
return true
}
}
return false
}
func GenUUID() string {
return uuid.New().String()
}
...@@ -2,13 +2,31 @@ module memos ...@@ -2,13 +2,31 @@ module memos
go 1.17 go 1.17
require github.com/gorilla/mux v1.8.0
require github.com/mattn/go-sqlite3 v1.14.9 require github.com/mattn/go-sqlite3 v1.14.9
require github.com/google/uuid v1.3.0 require github.com/google/uuid v1.3.0
require (
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
github.com/mattn/go-colorable v0.1.11 // indirect
github.com/mattn/go-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/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
golang.org/x/time v0.0.0-20201208040808-7e3f01d25324 // indirect
)
require (
github.com/gorilla/context v1.1.1 // indirect
github.com/labstack/echo/v4 v4.6.3
github.com/labstack/gommon v0.3.1 // indirect
)
require ( require (
github.com/gorilla/securecookie v1.1.1 // indirect github.com/gorilla/securecookie v1.1.1 // indirect
github.com/gorilla/sessions v1.2.1 github.com/gorilla/sessions v1.2.1
github.com/labstack/echo-contrib v0.12.0
) )
This diff is collapsed.
DROP TABLE IF EXISTS `memos`;
DROP TABLE IF EXISTS `queries`;
DROP TABLE IF EXISTS `resources`;
DROP TABLE IF EXISTS `users`;
CREATE TABLE `users` (
`id` TEXT NOT NULL PRIMARY KEY,
`username` TEXT NOT NULL,
`password` TEXT NOT NULL,
`open_id` TEXT NOT NULL DEFAULT '',
`created_at` TEXT NOT NULL DEFAULT (DATETIME('now', 'localtime')),
`updated_at` TEXT NOT NULL DEFAULT (DATETIME('now', 'localtime')),
UNIQUE(`username`, `open_id`)
);
CREATE TABLE `queries` (
`id` TEXT NOT NULL PRIMARY KEY,
`user_id` TEXT NOT NULL,
`title` TEXT NOT NULL,
`querystring` TEXT NOT NULL,
`created_at` TEXT NOT NULL DEFAULT (DATETIME('now', 'localtime')),
`updated_at` TEXT NOT NULL DEFAULT (DATETIME('now', 'localtime')),
`pinned_at` TEXT NOT NULL DEFAULT '',
FOREIGN KEY(`user_id`) REFERENCES `users`(`id`)
);
CREATE TABLE `memos` (
`id` TEXT NOT NULL PRIMARY KEY,
`content` TEXT NOT NULL,
`user_id` TEXT NOT NULL,
`created_at` TEXT NOT NULL DEFAULT (DATETIME('now', 'localtime')),
`updated_at` TEXT NOT NULL DEFAULT (DATETIME('now', 'localtime')),
`deleted_at` TEXT NOT NULL DEFAULT '',
FOREIGN KEY(`user_id`) REFERENCES `users`(`id`)
);
CREATE TABLE `resources` (
`id` TEXT NOT NULL PRIMARY KEY,
`user_id` TEXT NOT NULL,
`filename` TEXT NOT NULL,
`blob` BLOB NOT NULL,
`type` TEXT NOT NULL,
`size` INTEGER NOT NULL DEFAULT 0,
`created_at` TEXT NOT NULL DEFAULT (DATETIME('now', 'localtime')),
FOREIGN KEY(`user_id`) REFERENCES `users`(`id`)
);
INSERT INTO `users`
(`id`, `username`, `password`, `open_id`)
VALUES
('1', 'guest', '123456', 'guest_open_id'),
('2', 'mine', '123456', 'mine_open_id');
INSERT INTO `memos`
(`id`, `content`, `user_id`)
VALUES
('1', '👋 Welcome to memos', '1'),
('2', '👋 Welcome to memos', '2');
...@@ -3,7 +3,7 @@ tmp_dir = ".air" ...@@ -3,7 +3,7 @@ tmp_dir = ".air"
[build] [build]
bin = "./.air/memos" bin = "./.air/memos"
cmd = "go build -o ./.air/memos ./server/main.go" cmd = "go build -o ./.air/memos ./bin/server/main.go"
delay = 1000 delay = 1000
exclude_dir = [".air", "web"] exclude_dir = [".air", "web"]
exclude_file = [] exclude_file = []
......
package server
import (
"encoding/json"
"fmt"
"memos/api"
"memos/common"
"net/http"
"github.com/labstack/echo/v4"
)
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)
}
userFind := &api.UserFind{
Name: &login.Name,
}
user, err := s.UserService.FindUser(userFind)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to authenticate user").SetInternal(err)
}
if user == nil {
return echo.NewHTTPError(http.StatusUnauthorized, fmt.Sprintf("User not found: %s", login.Name))
}
// Compare the stored password
if login.Password != user.Password {
// If the two passwords don't match, return a 401 status.
return echo.NewHTTPError(http.StatusUnauthorized, "Incorrect password").SetInternal(err)
}
err = setUserSession(c, user)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to set login 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
})
g.POST("/auth/logout", func(c echo.Context) error {
err := removeUserSession(c)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to set logout session").SetInternal(err)
}
c.Response().WriteHeader(http.StatusOK)
return nil
})
g.POST("/auth/signup", func(c echo.Context) error {
signup := &api.Signup{}
if err := json.NewDecoder(c.Request().Body).Decode(signup); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted signup request").SetInternal(err)
}
userFind := &api.UserFind{
Name: &signup.Name,
}
user, err := s.UserService.FindUser(userFind)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to authenticate user").SetInternal(err)
}
if user != nil {
return echo.NewHTTPError(http.StatusUnauthorized, fmt.Sprintf("Existed user found: %s", signup.Name))
}
userCreate := &api.UserCreate{
Name: signup.Name,
Password: signup.Password,
OpenId: common.GenUUID(),
}
user, err = s.UserService.CreateUser(userCreate)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create user").SetInternal(err)
}
err = setUserSession(c, user)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to set signup 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 created user response").SetInternal(err)
}
return nil
})
}
package server
import (
"fmt"
"memos/api"
"memos/common"
"net/http"
"strconv"
"github.com/gorilla/sessions"
"github.com/labstack/echo-contrib/session"
"github.com/labstack/echo/v4"
)
var (
userIdContextKey = "user-id"
)
func getUserIdContextKey() string {
return userIdContextKey
}
// Purpose of this cookie is to store the user's id.
func setUserSession(c echo.Context, user *api.User) error {
sess, err := session.Get("session", c)
if err != nil {
return fmt.Errorf("failed to get session, err: %w", err)
}
sess.Options = &sessions.Options{
Path: "/",
MaxAge: 1000 * 3600 * 24 * 30,
HttpOnly: true,
}
sess.Values[userIdContextKey] = user.Id
err = sess.Save(c.Request(), c.Response())
if err != nil {
return fmt.Errorf("failed to set session, err: %w", err)
}
return nil
}
func removeUserSession(c echo.Context) error {
sess, err := session.Get("session", c)
if err != nil {
return fmt.Errorf("failed to get session, err: %w", err)
}
sess.Options = &sessions.Options{
Path: "/",
MaxAge: 0,
HttpOnly: true,
}
sess.Values[userIdContextKey] = nil
err = sess.Save(c.Request(), c.Response())
if err != nil {
return fmt.Errorf("failed to set session, err: %w", err)
}
return nil
}
// Use session in the initial version
func BasicAuthMiddleware(us api.UserService, next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
// Skips auth
if common.HasPrefixes(c.Path(), "/api/auth") {
return next(c)
}
sess, err := session.Get("session", c)
if err != nil {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing session").SetInternal(err)
}
userIdValue := sess.Values[userIdContextKey]
if userIdValue == nil {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing userId in session")
}
userId, err := strconv.Atoi(fmt.Sprintf("%v", userIdValue))
if err != nil {
return echo.NewHTTPError(http.StatusUnauthorized, "Failed to malformatted user id in the session.")
}
// Even if there is no error, we still need to make sure the user still exists.
principalFind := &api.UserFind{
Id: &userId,
}
user, err := us.FindUser(principalFind)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to find user by ID: %d", userId)).SetInternal(err)
}
if user == nil {
return echo.NewHTTPError(http.StatusUnauthorized, fmt.Sprintf("Not found user ID: %d", userId))
}
// Stores userId into context.
c.Set(getUserIdContextKey(), userId)
return next(c)
}
}
package server
func composeResponse(data interface{}) interface{} {
type R struct {
Data interface{} `json:"data"`
}
return R{
Data: data,
}
}
package main
import (
"memos/api"
"memos/store"
"net/http"
"github.com/gorilla/mux"
)
func main() {
store.InitDBConn()
r := mux.NewRouter().StrictSlash(true)
api.RegisterAuthRoutes(r)
api.RegisterUserRoutes(r)
api.RegisterMemoRoutes(r)
api.RegisterQueryRoutes(r)
api.RegisterResourceRoutes(r)
api.RegisterWebHooksRoutes(r)
webServe := api.SPAHandler{
StaticPath: "./web/dist",
IndexPath: "index.html",
}
r.PathPrefix("/").Handler(webServe)
http.ListenAndServe(":8080", r)
}
package server
import (
"encoding/json"
"fmt"
"memos/api"
"memos/common"
"net/http"
"strconv"
"github.com/labstack/echo/v4"
)
func (s *Server) registerMemoRoutes(g *echo.Group) {
g.POST("/memo", func(c echo.Context) error {
userId := c.Get(getUserIdContextKey()).(int)
memoCreate := &api.MemoCreate{
CreatorId: userId,
}
if err := json.NewDecoder(c.Request().Body).Decode(memoCreate); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post memo request").SetInternal(err)
}
memo, err := s.MemoService.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.PATCH("/memo/:memoId", func(c echo.Context) error {
memoId, err := strconv.Atoi(c.Param("memoId"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("memoId"))).SetInternal(err)
}
memoPatch := &api.MemoPatch{
Id: memoId,
}
if err := json.NewDecoder(c.Request().Body).Decode(memoPatch); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted patch memo request").SetInternal(err)
}
memo, err := s.MemoService.PatchMemo(memoPatch)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to patch 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("/memo", func(c echo.Context) error {
userId := c.Get(getUserIdContextKey()).(int)
memoFind := &api.MemoFind{
CreatorId: &userId,
}
showHiddenMemo, err := strconv.ParseBool(c.QueryParam("hidden"))
if err != nil {
showHiddenMemo = false
}
rowStatus := "NORMAL"
if showHiddenMemo {
rowStatus = "HIDDEN"
}
memoFind.RowStatus = &rowStatus
list, err := s.MemoService.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.GET("/memo/:memoId", func(c echo.Context) error {
memoId, err := strconv.Atoi(c.Param("memoId"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("memoId"))).SetInternal(err)
}
memoFind := &api.MemoFind{
Id: &memoId,
}
memo, err := s.MemoService.FindMemo(memoFind)
if err != nil {
if common.ErrorCode(err) == common.NotFound {
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Memo ID not found: %d", memoId))
}
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to delete memo ID: %v", memoId)).SetInternal(err)
}
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(memo)); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode memo response").SetInternal(err)
}
return nil
})
g.DELETE("/memo/:memoId", func(c echo.Context) error {
memoId, err := strconv.Atoi(c.Param("memoId"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("memoId"))).SetInternal(err)
}
memoDelete := &api.MemoDelete{
Id: &memoId,
}
err = s.MemoService.DeleteMemo(memoDelete)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to delete memo ID: %v", memoId)).SetInternal(err)
}
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
c.Response().WriteHeader(http.StatusOK)
return nil
})
}
package server
import (
"encoding/json"
"fmt"
"io/ioutil"
"memos/api"
"net/http"
"strconv"
"github.com/labstack/echo/v4"
)
func (s *Server) registerResourceRoutes(g *echo.Group) {
g.POST("/resource", func(c echo.Context) error {
userId := c.Get(getUserIdContextKey()).(int)
err := c.Request().ParseMultipartForm(5 << 20)
if 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: userId,
}
resource, err := s.ResourceService.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 shortcut response").SetInternal(err)
}
return nil
})
g.GET("/resource", func(c echo.Context) error {
userId := c.Get(getUserIdContextKey()).(int)
resourceFind := &api.ResourceFind{
CreatorId: &userId,
}
list, err := s.ResourceService.FindResourceList(resourceFind)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch resource list").SetInternal(err)
}
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(list)); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode resource list response").SetInternal(err)
}
return nil
})
g.DELETE("/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)
}
resourceDelete := &api.ResourceDelete{
Id: resourceId,
}
if err := s.ResourceService.DeleteResource(resourceDelete); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to delete resource").SetInternal(err)
}
return nil
})
}
package server
import (
"fmt"
"memos/api"
"time"
"github.com/gorilla/sessions"
"github.com/labstack/echo-contrib/session"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
)
type Server struct {
e *echo.Echo
UserService api.UserService
MemoService api.MemoService
ShortcutService api.ShortcutService
ResourceService api.ResourceService
port int
}
func NewServer(port int) *Server {
e := echo.New()
e.Debug = true
e.HideBanner = true
e.HidePort = false
e.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{
Format: "${method} ${uri} ${status}\n",
}))
e.Use(middleware.TimeoutWithConfig(middleware.TimeoutConfig{
Skipper: middleware.DefaultSkipper,
ErrorMessage: "Request timeout",
Timeout: 30 * time.Second,
}))
e.Use(middleware.StaticWithConfig(middleware.StaticConfig{
Skipper: middleware.DefaultSkipper,
Root: "web/dist",
Browse: false,
HTML5: true,
}))
e.Use(session.Middleware(sessions.NewCookieStore([]byte("just_memos"))))
s := &Server{
e: e,
port: port,
}
webhookGroup := e.Group("/h")
s.registerWebhookRoutes(webhookGroup)
apiGroup := e.Group("/api")
apiGroup.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
return BasicAuthMiddleware(s.UserService, next)
})
s.registerAuthRoutes(apiGroup)
s.registerUserRoutes(apiGroup)
s.registerMemoRoutes(apiGroup)
s.registerShortcutRoutes(apiGroup)
s.registerResourceRoutes(apiGroup)
return s
}
func (server *Server) Run() error {
return server.e.Start(fmt.Sprintf(":%d", server.port))
}
package server
import (
"encoding/json"
"fmt"
"memos/api"
"net/http"
"strconv"
"github.com/labstack/echo/v4"
)
func (s *Server) registerShortcutRoutes(g *echo.Group) {
g.POST("/shortcut", func(c echo.Context) error {
userId := c.Get(getUserIdContextKey()).(int)
shortcutCreate := &api.ShortcutCreate{
CreatorId: userId,
}
if err := json.NewDecoder(c.Request().Body).Decode(shortcutCreate); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post shortcut request").SetInternal(err)
}
shortcut, err := s.ShortcutService.CreateShortcut(shortcutCreate)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create shortcut").SetInternal(err)
}
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
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.PATCH("/shortcut/:shortcutId", func(c echo.Context) error {
shortcutId, err := strconv.Atoi(c.Param("shortcutId"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("shortcutId"))).SetInternal(err)
}
shortcutPatch := &api.ShortcutPatch{
Id: shortcutId,
}
if err := json.NewDecoder(c.Request().Body).Decode(shortcutPatch); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted patch shortcut request").SetInternal(err)
}
shortcut, err := s.ShortcutService.PatchShortcut(shortcutPatch)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to patch shortcut").SetInternal(err)
}
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
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,
}
list, err := s.ShortcutService.FindShortcutList(shortcutFind)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch shortcut 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 shortcut list response").SetInternal(err)
}
return nil
})
g.GET("/shortcut/:shortcutId", func(c echo.Context) error {
shortcutId, err := strconv.Atoi(c.Param("shortcutId"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("shortcutId"))).SetInternal(err)
}
shortcutFind := &api.ShortcutFind{
Id: &shortcutId,
}
shortcut, err := s.ShortcutService.FindShortcut(shortcutFind)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch shortcut").SetInternal(err)
}
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
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.DELETE("/shortcut/:shortcutId", func(c echo.Context) error {
shortcutId, err := strconv.Atoi(c.Param("shortcutId"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("shortcutId"))).SetInternal(err)
}
shortcutDelete := &api.ShortcutDelete{
Id: shortcutId,
}
if err := s.ShortcutService.DeleteShortcut(shortcutDelete); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to delete shortcut").SetInternal(err)
}
return nil
})
}
package server
import (
"encoding/json"
"memos/api"
"memos/common"
"net/http"
"github.com/labstack/echo/v4"
)
func (s *Server) registerUserRoutes(g *echo.Group) {
g.GET("/user/me", func(c echo.Context) error {
// /api/user/me is used to check if the user is logged in,
userSessionId := c.Get(getUserIdContextKey())
if userSessionId == nil {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing session")
}
userId := userSessionId.(int)
userFind := &api.UserFind{
Id: &userId,
}
user, err := s.UserService.FindUser(userFind)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch user").SetInternal(err)
}
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(user)); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode user response").SetInternal(err)
}
return nil
})
g.POST("/user/rename_check", func(c echo.Context) error {
userRenameCheck := &api.UserRenameCheck{}
if err := json.NewDecoder(c.Request().Body).Decode(userRenameCheck); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post user rename check request").SetInternal(err)
}
if userRenameCheck.Name == "" {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post user rename check request")
}
userFind := &api.UserFind{
Name: &userRenameCheck.Name,
}
user, err := s.UserService.FindUser(userFind)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err)
}
isUsable := true
if user != nil {
isUsable = false
}
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(isUsable)); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode rename check response").SetInternal(err)
}
return nil
})
g.POST("/user/password_check", func(c echo.Context) error {
userId := c.Get(getUserIdContextKey()).(int)
userPasswordCheck := &api.UserPasswordCheck{}
if err := json.NewDecoder(c.Request().Body).Decode(userPasswordCheck); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post user password check request").SetInternal(err)
}
if userPasswordCheck.Password == "" {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post user password check request")
}
userFind := &api.UserFind{
Id: &userId,
Password: &userPasswordCheck.Password,
}
user, err := s.UserService.FindUser(userFind)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err)
}
isValid := false
if user != nil {
isValid = true
}
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(isValid)); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode password check response").SetInternal(err)
}
return nil
})
g.PATCH("/user/me", func(c echo.Context) error {
userId := c.Get(getUserIdContextKey()).(int)
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.ResetOpenId != nil && *userPatch.ResetOpenId {
openId := common.GenUUID()
userPatch.OpenId = &openId
}
user, err := s.UserService.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
})
}
package server
import (
"encoding/json"
"fmt"
"memos/api"
"net/http"
"strconv"
"github.com/labstack/echo/v4"
)
func (s *Server) registerWebhookRoutes(g *echo.Group) {
g.GET("/test", func(c echo.Context) error {
return c.HTML(http.StatusOK, "<strong>Hello, World!</strong>")
})
g.POST("/:openId/memo", func(c echo.Context) error {
openId := c.Param("openId")
userFind := &api.UserFind{
OpenId: &openId,
}
user, err := s.UserService.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.MemoService.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("/r/:resourceId/:filename", func(c echo.Context) error {
resourceId, err := strconv.Atoi(c.Param("resourceId"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("resourceId"))).SetInternal(err)
}
filename := c.Param("filename")
resourceFind := &api.ResourceFind{
Id: &resourceId,
Filename: &filename,
}
resource, err := s.ResourceService.FindResource(resourceFind)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to fetch resource ID: %v", resourceId)).SetInternal(err)
}
c.Response().Writer.WriteHeader(http.StatusOK)
c.Response().Writer.Header().Set("Content-Type", "application/octet-stream")
c.Response().Writer.Write(resource.Blob)
return nil
})
}
package store
import (
"database/sql"
"errors"
"io/ioutil"
"os"
"path/filepath"
_ "github.com/mattn/go-sqlite3"
)
/*
* Use a global variable to save the db connection: Quick and easy to setup.
* Reference: https://techinscribed.com/different-approaches-to-pass-database-connection-into-controllers-in-golang/
*/
var DB *sql.DB
func InitDBConn() {
// mounting point in docker is "/usr/local/memos/data"
dbFilePath := "./data/memos.db"
if _, err := os.Stat(dbFilePath); err != nil {
dbFilePath = "./resources/memos.db"
println("use the default database")
} else {
println("use the custom database")
}
db, err := sql.Open("sqlite3", dbFilePath)
if err != nil {
panic("db connect failed")
} else {
DB = db
println("connect to sqlite succeed")
}
if dbFilePath == "./resources/memos.db" {
resetDataInDefaultDatabase()
}
}
func FormatDBError(err error) error {
if err == nil {
return nil
}
switch err {
case sql.ErrNoRows:
return errors.New("data not found")
default:
return err
}
}
func resetDataInDefaultDatabase() {
initialSQLFilePath := filepath.Join("resources", "initial_db.sql")
c, err := ioutil.ReadFile(initialSQLFilePath)
if err != nil {
// do nth
return
}
sql := string(c)
DB.Exec(sql)
println("Initial data succeed")
}
package store package store
import ( import (
"memos/utils" "fmt"
"memos/api"
"memos/common"
"strings" "strings"
) )
type Memo struct { type MemoService struct {
Id string `json:"id"` db *DB
Content string `json:"content"`
UserId string `json:"userId"`
DeletedAt string `json:"deletedAt"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
} }
func CreateNewMemo(content string, userId string) (Memo, error) { func NewMemoService(db *DB) *MemoService {
nowDateTimeStr := utils.GetNowDateTimeStr() return &MemoService{db: db}
newMemo := Memo{ }
Id: utils.GenUUID(),
Content: content,
UserId: userId,
DeletedAt: "",
CreatedAt: nowDateTimeStr,
UpdatedAt: nowDateTimeStr,
}
query := `INSERT INTO memos (id, content, user_id, deleted_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)` func (s *MemoService) CreateMemo(create *api.MemoCreate) (*api.Memo, error) {
_, err := DB.Exec(query, newMemo.Id, newMemo.Content, newMemo.UserId, newMemo.DeletedAt, newMemo.CreatedAt, newMemo.UpdatedAt) memo, err := createMemo(s.db, create)
if err != nil {
return nil, err
}
return newMemo, FormatDBError(err) return memo, nil
} }
type MemoPatch struct { func (s *MemoService) PatchMemo(patch *api.MemoPatch) (*api.Memo, error) {
Content *string memo, err := patchMemo(s.db, patch)
DeletedAt *string if err != nil {
return nil, err
}
return memo, nil
} }
func UpdateMemo(id string, memoPatch *MemoPatch) (Memo, error) { func (s *MemoService) FindMemoList(find *api.MemoFind) ([]*api.Memo, error) {
memo, _ := GetMemoById(id) list, err := findMemoList(s.db, find)
set, args := []string{}, []interface{}{} if err != nil {
return nil, err
}
return list, nil
}
if v := memoPatch.Content; v != nil { func (s *MemoService) FindMemo(find *api.MemoFind) (*api.Memo, error) {
memo.Content = *v list, err := findMemoList(s.db, find)
set, args = append(set, "content=?"), append(args, *v) if err != nil {
return nil, err
} }
if v := memoPatch.DeletedAt; v != nil {
memo.DeletedAt = *v if len(list) == 0 {
set, args = append(set, "deleted_at=?"), append(args, *v) return nil, &common.Error{Code: common.NotFound, Err: fmt.Errorf("not found")}
} }
set, args = append(set, "updated_at=?"), append(args, utils.GetNowDateTimeStr())
args = append(args, id)
sqlQuery := `UPDATE memos SET ` + strings.Join(set, ",") + ` WHERE id=?` return list[0], nil
_, err := DB.Exec(sqlQuery, args...) }
return memo, FormatDBError(err) func (s *MemoService) DeleteMemo(delete *api.MemoDelete) error {
err := deleteMemo(s.db, delete)
if err != nil {
return FormatError(err)
}
return nil
} }
func DeleteMemo(memoId string) error { func createMemo(db *DB, create *api.MemoCreate) (*api.Memo, error) {
query := `DELETE FROM memos WHERE id=?` row, err := db.Db.Query(`
_, err := DB.Exec(query, memoId) INSERT INTO memo (
return FormatDBError(err) creator_id,
content
)
VALUES (?, ?)
RETURNING id, creator_id, created_ts, updated_ts, content, row_status
`,
create.CreatorId,
create.Content,
)
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 memo api.Memo
if err := row.Scan(
&memo.Id,
&memo.CreatorId,
&memo.CreatedTs,
&memo.UpdatedTs,
&memo.Content,
&memo.RowStatus,
); err != nil {
return nil, FormatError(err)
}
return &memo, nil
} }
func GetMemoById(id string) (Memo, error) { func patchMemo(db *DB, patch *api.MemoPatch) (*api.Memo, error) {
query := `SELECT id, content, deleted_at, created_at, updated_at FROM memos WHERE id=?` set, args := []string{}, []interface{}{}
memo := Memo{}
err := DB.QueryRow(query, id).Scan(&memo.Id, &memo.Content, &memo.DeletedAt, &memo.CreatedAt, &memo.UpdatedAt) if v := patch.Content; v != nil {
return memo, FormatDBError(err) set, args = append(set, "content = ?"), append(args, *v)
}
if v := patch.RowStatus; v != nil {
set, args = append(set, "row_status = ?"), append(args, *v)
}
args = append(args, patch.Id)
row, err := db.Db.Query(`
UPDATE memo
SET `+strings.Join(set, ", ")+`
WHERE id = ?
RETURNING id, created_ts, updated_ts, content, row_status
`, args...)
if err != nil {
return nil, FormatError(err)
}
defer row.Close()
if !row.Next() {
return nil, &common.Error{Code: common.NotFound, Err: fmt.Errorf("not found")}
}
var memo api.Memo
if err := row.Scan(
&memo.Id,
&memo.CreatedTs,
&memo.UpdatedTs,
&memo.Content,
&memo.RowStatus,
); err != nil {
return nil, FormatError(err)
}
return &memo, nil
} }
func GetMemosByUserId(userId string, onlyDeleted bool) ([]Memo, error) { func findMemoList(db *DB, find *api.MemoFind) ([]*api.Memo, error) {
sqlQuery := `SELECT id, content, deleted_at, created_at, updated_at FROM memos WHERE user_id=?` where, args := []string{"1 = 1"}, []interface{}{}
if onlyDeleted { if v := find.Id; v != nil {
sqlQuery = sqlQuery + ` AND deleted_at!=""` where, args = append(where, "id = ?"), append(args, *v)
} else { }
sqlQuery = sqlQuery + ` AND deleted_at=""` if v := find.CreatorId; v != nil {
where, args = append(where, "creator_id = ?"), append(args, *v)
}
if v := find.RowStatus; v != nil {
where, args = append(where, "row_status = ?"), append(args, *v)
} }
rows, _ := DB.Query(sqlQuery, userId) rows, err := db.Db.Query(`
SELECT
id,
creator_id,
created_ts,
updated_ts,
content,
row_status
FROM memo
WHERE `+strings.Join(where, " AND "),
args...,
)
if err != nil {
return nil, FormatError(err)
}
defer rows.Close() defer rows.Close()
memos := []Memo{} list := make([]*api.Memo, 0)
for rows.Next() { for rows.Next() {
memo := Memo{} var memo api.Memo
rows.Scan(&memo.Id, &memo.Content, &memo.DeletedAt, &memo.CreatedAt, &memo.UpdatedAt) if err := rows.Scan(
&memo.Id,
&memo.CreatorId,
&memo.CreatedTs,
&memo.UpdatedTs,
&memo.Content,
&memo.RowStatus,
); err != nil {
return nil, FormatError(err)
}
memos = append(memos, memo) list = append(list, &memo)
} }
if err := rows.Err(); err != nil { if err := rows.Err(); err != nil {
return nil, FormatDBError(err) return nil, FormatError(err)
}
return list, nil
}
func deleteMemo(db *DB, delete *api.MemoDelete) error {
result, err := db.Db.Exec(`DELETE FROM memo WHERE id = ?`, delete.Id)
if err != nil {
return FormatError(err)
}
rows, _ := result.RowsAffected()
if rows == 0 {
return &common.Error{Code: common.NotFound, Err: fmt.Errorf("memo ID not found: %d", delete.Id)}
} }
return memos, nil return nil
} }
package store
import (
"memos/utils"
"strings"
)
type Query struct {
Id string `json:"id"`
UserId string `json:"userId"`
Title string `json:"title"`
Querystring string `json:"querystring"`
PinnedAt string `json:"pinnedAt"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
func CreateNewQuery(title string, querystring string, userId string) (Query, error) {
nowDateTimeStr := utils.GetNowDateTimeStr()
newQuery := Query{
Id: utils.GenUUID(),
Title: title,
Querystring: querystring,
UserId: userId,
PinnedAt: "",
CreatedAt: nowDateTimeStr,
UpdatedAt: nowDateTimeStr,
}
sqlQuery := `INSERT INTO queries (id, title, querystring, user_id, pinned_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)`
_, err := DB.Exec(sqlQuery, newQuery.Id, newQuery.Title, newQuery.Querystring, newQuery.UserId, newQuery.PinnedAt, newQuery.CreatedAt, newQuery.UpdatedAt)
return newQuery, FormatDBError(err)
}
type QueryPatch struct {
Title *string
Querystring *string
PinnedAt *string
}
func UpdateQuery(id string, queryPatch *QueryPatch) (Query, error) {
query, _ := GetQueryById(id)
set, args := []string{}, []interface{}{}
if v := queryPatch.Title; v != nil {
query.Title = *v
set, args = append(set, "title=?"), append(args, *v)
}
if v := queryPatch.Querystring; v != nil {
query.Querystring = *v
set, args = append(set, "querystring=?"), append(args, *v)
}
if v := queryPatch.PinnedAt; v != nil {
query.PinnedAt = *v
set, args = append(set, "pinned_at=?"), append(args, *v)
}
set, args = append(set, "updated_at=?"), append(args, utils.GetNowDateTimeStr())
args = append(args, id)
sqlQuery := `UPDATE queries SET ` + strings.Join(set, ",") + ` WHERE id=?`
_, err := DB.Exec(sqlQuery, args...)
return query, FormatDBError(err)
}
func DeleteQuery(queryId string) error {
query := `DELETE FROM queries WHERE id=?`
_, err := DB.Exec(query, queryId)
return FormatDBError(err)
}
func GetQueryById(queryId string) (Query, error) {
sqlQuery := `SELECT id, title, querystring, pinned_at, created_at, updated_at FROM queries WHERE id=?`
query := Query{}
err := DB.QueryRow(sqlQuery, queryId).Scan(&query.Id, &query.Title, &query.Querystring, &query.PinnedAt, &query.CreatedAt, &query.UpdatedAt)
return query, FormatDBError(err)
}
func GetQueriesByUserId(userId string) ([]Query, error) {
query := `SELECT id, title, querystring, pinned_at, created_at, updated_at FROM queries WHERE user_id=?`
rows, _ := DB.Query(query, userId)
defer rows.Close()
queries := []Query{}
for rows.Next() {
query := Query{}
rows.Scan(&query.Id, &query.Title, &query.Querystring, &query.PinnedAt, &query.CreatedAt, &query.UpdatedAt)
queries = append(queries, query)
}
if err := rows.Err(); err != nil {
return nil, FormatDBError(err)
}
return queries, nil
}
package store package store
import "memos/utils" import (
"fmt"
"memos/api"
"memos/common"
"strings"
)
type Resource struct { type ResourceService struct {
Id string `json:"id"` db *DB
UserId string `json:"userId"`
Filename string `json:"filename"`
Blob []byte `json:"blob"`
Type string `json:"type"`
Size int64 `json:"size"`
CreatedAt string `json:"createdAt"`
} }
func CreateResource(userId string, filename string, blob []byte, filetype string, size int64) (Resource, error) { func NewResourceService(db *DB) *ResourceService {
newResource := Resource{ return &ResourceService{db: db}
Id: utils.GenUUID(), }
UserId: userId,
Filename: filename, func (s *ResourceService) CreateResource(create *api.ResourceCreate) (*api.Resource, error) {
Blob: blob, resource, err := createResource(s.db, create)
Type: filetype, if err != nil {
Size: size, return nil, err
CreatedAt: utils.GetNowDateTimeStr(),
} }
query := `INSERT INTO resources (id, user_id, filename, blob, type, size, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)` return resource, nil
_, err := DB.Exec(query, newResource.Id, newResource.UserId, newResource.Filename, newResource.Blob, newResource.Type, newResource.Size, newResource.CreatedAt) }
func (s *ResourceService) FindResourceList(find *api.ResourceFind) ([]*api.Resource, error) {
list, err := findResourceList(s.db, find)
if err != nil {
return nil, err
}
return newResource, FormatDBError(err) return list, nil
} }
func GetResourcesByUserId(userId string) ([]Resource, error) { func (s *ResourceService) FindResource(find *api.ResourceFind) (*api.Resource, error) {
query := `SELECT id, filename, type, size, created_at FROM resources WHERE user_id=?` list, err := findResourceList(s.db, find)
rows, _ := DB.Query(query, userId) if err != nil {
defer rows.Close() return nil, err
}
if len(list) == 0 {
return nil, &common.Error{Code: common.NotFound, Err: fmt.Errorf("not found")}
}
resources := []Resource{} return list[0], nil
}
func (s *ResourceService) DeleteResource(delete *api.ResourceDelete) error {
err := deleteResource(s.db, delete)
if err != nil {
return err
}
return nil
}
func createResource(db *DB, create *api.ResourceCreate) (*api.Resource, error) {
row, err := db.Db.Query(`
INSERT INTO resource (
filename,
blob,
type,
size,
creator_id
)
VALUES (?, ?, ?, ?, ?)
RETURNING id, filename, blob, type, size, created_ts, updated_ts
`,
create.Filename,
create.Blob,
create.Type,
create.Size,
create.CreatorId,
)
if err != nil {
return nil, FormatError(err)
}
defer row.Close()
if !row.Next() {
return nil, &common.Error{Code: common.NotFound, Err: fmt.Errorf("not found")}
}
var resource api.Resource
if err := row.Scan(
&resource.Id,
&resource.Filename,
&resource.Blob,
&resource.Type,
&resource.Size,
&resource.CreatedTs,
&resource.UpdatedTs,
); err != nil {
return nil, FormatError(err)
}
return &resource, nil
}
func findResourceList(db *DB, find *api.ResourceFind) ([]*api.Resource, error) {
where, args := []string{"1 = 1"}, []interface{}{}
if v := find.Id; v != nil {
where, args = append(where, "id = ?"), append(args, *v)
}
if v := find.CreatorId; v != nil {
where, args = append(where, "creator_id = ?"), append(args, *v)
}
if v := find.Filename; v != nil {
where, args = append(where, "filename = ?"), append(args, *v)
}
rows, err := db.Db.Query(`
SELECT
id,
filename,
blob,
type,
size,
created_ts,
updated_ts
FROM resource
WHERE `+strings.Join(where, " AND "),
args...,
)
if err != nil {
return nil, FormatError(err)
}
defer rows.Close()
list := make([]*api.Resource, 0)
for rows.Next() { for rows.Next() {
resource := Resource{} var resource api.Resource
rows.Scan(&resource.Id, &resource.Filename, &resource.Type, &resource.Size, &resource.CreatedAt) if err := rows.Scan(
resources = append(resources, resource) &resource.Id,
&resource.Filename,
&resource.Blob,
&resource.Type,
&resource.Size,
&resource.CreatedTs,
&resource.UpdatedTs,
); err != nil {
return nil, FormatError(err)
}
list = append(list, &resource)
} }
if err := rows.Err(); err != nil { if err := rows.Err(); err != nil {
return nil, FormatDBError(err) return nil, FormatError(err)
} }
return resources, nil return list, nil
} }
func GetResourceByIdAndFilename(id string, filename string) (Resource, error) { func deleteResource(db *DB, delete *api.ResourceDelete) error {
query := `SELECT id, filename, blob, type, size FROM resources WHERE id=? AND filename=?` result, err := db.Db.Exec(`DELETE FROM resource WHERE id = ?`, delete.Id)
resource := Resource{} if err != nil {
err := DB.QueryRow(query, id, filename).Scan(&resource.Id, &resource.Filename, &resource.Blob, &resource.Type, &resource.Size) return FormatError(err)
return resource, FormatDBError(err) }
}
rows, _ := result.RowsAffected()
if rows == 0 {
return &common.Error{Code: common.NotFound, Err: fmt.Errorf("resource ID not found: %d", delete.Id)}
}
func DeleteResourceById(id string) error { return nil
query := `DELETE FROM resources WHERE id=?`
_, err := DB.Exec(query, id)
return FormatDBError(err)
} }
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,
name TEXT NOT NULL,
password TEXT NOT NULL,
open_id TEXT NOT NULL,
created_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
updated_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
UNIQUE(`name`, `open_id`)
);
INSERT INTO
sqlite_sequence (name, seq)
VALUES
('user', 100);
CREATE TRIGGER IF NOT EXISTS `trigger_update_user_modification_time`
AFTER
UPDATE
ON `user` FOR EACH ROW BEGIN
UPDATE
`user`
SET
updated_ts = (strftime('%s', 'now'))
WHERE
rowid = old.rowid;
END;
-- memo
CREATE TABLE memo (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
updated_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
-- allowed row status are 'NORMAL', 'HIDDEN'.
row_status TEXT NOT NULL DEFAULT 'NORMAL',
content TEXT NOT NULL DEFAULT '',
creator_id INTEGER NOT NULL,
FOREIGN KEY(creator_id) REFERENCES users(id)
);
INSERT INTO
sqlite_sequence (name, seq)
VALUES
('memo', 100);
CREATE TRIGGER IF NOT EXISTS `trigger_update_memo_modification_time`
AFTER
UPDATE
ON `memo` FOR EACH ROW BEGIN
UPDATE
`memo`
SET
updated_ts = (strftime('%s', 'now'))
WHERE
rowid = old.rowid;
END;
-- shortcut
CREATE TABLE shortcut (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
updated_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
title TEXT NOT NULL DEFAULT '',
payload TEXT NOT NULL DEFAULT '',
creator_id INTEGER NOT NULL,
-- allowed row status are 'NORMAL', 'ARCHIVED'.
row_status TEXT NOT NULL DEFAULT 'NORMAL',
FOREIGN KEY(creator_id) REFERENCES users(id)
);
INSERT INTO
sqlite_sequence (name, seq)
VALUES
('shortcut', 100);
CREATE TRIGGER IF NOT EXISTS `trigger_update_shortcut_modification_time`
AFTER
UPDATE
ON `shortcut` FOR EACH ROW BEGIN
UPDATE
`shortcut`
SET
updated_ts = (strftime('%s', 'now'))
WHERE
rowid = old.rowid;
END;
-- resource
CREATE TABLE resource (
id INTEGER PRIMARY KEY AUTOINCREMENT,
filename TEXT NOT NULL DEFAULT '',
blob BLOB NOT NULL,
type TEXT NOT NULL DEFAULT '',
size INTEGER NOT NULL DEFAULT 0,
created_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
updated_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
creator_id INTEGER NOT NULL,
FOREIGN KEY(creator_id) REFERENCES users(id)
);
INSERT INTO
sqlite_sequence (name, seq)
VALUES
('resource', 100);
CREATE TRIGGER IF NOT EXISTS `trigger_update_resource_modification_time`
AFTER
UPDATE
ON `resource` FOR EACH ROW BEGIN
UPDATE
`resource`
SET
updated_ts = (strftime('%s', 'now'))
WHERE
rowid = old.rowid;
END;
INSERT INTO user
(`id`, `name`, `password`, `open_id`)
VALUES
(1, 'guest', '123456', 'guest_open_id'),
INSERT INTO memo
(`content`, `creator_id`)
VALUES
('👋 Welcome to memos', 1),
package store
import (
"fmt"
"memos/api"
"memos/common"
"strings"
)
type ShortcutService struct {
db *DB
}
func NewShortcutService(db *DB) *ShortcutService {
return &ShortcutService{db: db}
}
func (s *ShortcutService) CreateShortcut(create *api.ShortcutCreate) (*api.Shortcut, error) {
shortcut, err := createShortcut(s.db, create)
if err != nil {
return nil, err
}
return shortcut, nil
}
func (s *ShortcutService) PatchShortcut(patch *api.ShortcutPatch) (*api.Shortcut, error) {
shortcut, err := patchShortcut(s.db, patch)
if err != nil {
return nil, err
}
return shortcut, nil
}
func (s *ShortcutService) FindShortcutList(find *api.ShortcutFind) ([]*api.Shortcut, error) {
list, err := findShortcutList(s.db, find)
if err != nil {
return nil, err
}
return list, nil
}
func (s *ShortcutService) FindShortcut(find *api.ShortcutFind) (*api.Shortcut, error) {
list, err := findShortcutList(s.db, find)
if err != nil {
return nil, err
}
if len(list) == 0 {
return nil, &common.Error{Code: common.NotFound, Err: fmt.Errorf("not found")}
}
return list[0], nil
}
func (s *ShortcutService) DeleteShortcut(delete *api.ShortcutDelete) error {
err := deleteShortcut(s.db, delete)
if err != nil {
return FormatError(err)
}
return nil
}
func createShortcut(db *DB, create *api.ShortcutCreate) (*api.Shortcut, error) {
row, err := db.Db.Query(`
INSERT INTO shortcut (
title,
payload,
creator_id
)
VALUES (?, ?, ?)
RETURNING id, title, payload, creator_id, created_ts, updated_ts, row_status
`,
create.Title,
create.Payload,
create.CreatorId,
)
if err != nil {
return nil, FormatError(err)
}
defer row.Close()
row.Next()
var shortcut api.Shortcut
if err := row.Scan(
&shortcut.Id,
&shortcut.Title,
&shortcut.Payload,
&shortcut.CreatorId,
&shortcut.CreatedTs,
&shortcut.UpdatedTs,
&shortcut.RowStatus,
); err != nil {
return nil, FormatError(err)
}
return &shortcut, nil
}
func patchShortcut(db *DB, patch *api.ShortcutPatch) (*api.Shortcut, error) {
set, args := []string{}, []interface{}{}
if v := patch.Title; v != nil {
set, args = append(set, "title = ?"), append(args, *v)
}
if v := patch.Payload; v != nil {
set, args = append(set, "payload = ?"), append(args, *v)
}
if v := patch.RowStatus; v != nil {
set, args = append(set, "row_status = ?"), append(args, *v)
}
args = append(args, patch.Id)
row, err := db.Db.Query(`
UPDATE shortcut
SET `+strings.Join(set, ", ")+`
WHERE id = ?
RETURNING id, title, payload, created_ts, updated_ts, row_status
`, args...)
if err != nil {
return nil, FormatError(err)
}
defer row.Close()
if !row.Next() {
return nil, &common.Error{Code: common.NotFound, Err: fmt.Errorf("not found")}
}
var shortcut api.Shortcut
if err := row.Scan(
&shortcut.Id,
&shortcut.Title,
&shortcut.Payload,
&shortcut.CreatedTs,
&shortcut.UpdatedTs,
&shortcut.RowStatus,
); err != nil {
return nil, FormatError(err)
}
return &shortcut, nil
}
func findShortcutList(db *DB, find *api.ShortcutFind) ([]*api.Shortcut, error) {
where, args := []string{"1 = 1"}, []interface{}{}
if v := find.Id; v != nil {
where, args = append(where, "id = ?"), append(args, *v)
}
if v := find.CreatorId; v != nil {
where, args = append(where, "creator_id = ?"), append(args, *v)
}
if v := find.Title; v != nil {
where, args = append(where, "title = ?"), append(args, *v)
}
rows, err := db.Db.Query(`
SELECT
id,
title,
payload,
creator_id,
created_ts,
updated_ts,
row_status
FROM shortcut
WHERE `+strings.Join(where, " AND "),
args...,
)
if err != nil {
return nil, FormatError(err)
}
defer rows.Close()
list := make([]*api.Shortcut, 0)
for rows.Next() {
var shortcut api.Shortcut
if err := rows.Scan(
&shortcut.Id,
&shortcut.Title,
&shortcut.Payload,
&shortcut.CreatorId,
&shortcut.CreatedTs,
&shortcut.UpdatedTs,
&shortcut.RowStatus,
); err != nil {
return nil, FormatError(err)
}
list = append(list, &shortcut)
}
if err := rows.Err(); err != nil {
return nil, FormatError(err)
}
return list, nil
}
func deleteShortcut(db *DB, delete *api.ShortcutDelete) error {
result, err := db.Db.Exec(`DELETE FROM saved_query WHERE id = ?`, delete.Id)
if err != nil {
return FormatError(err)
}
rows, _ := result.RowsAffected()
if rows == 0 {
return &common.Error{Code: common.NotFound, Err: fmt.Errorf("memo ID not found: %d", delete.Id)}
}
return nil
}
package store
import (
"database/sql"
"embed"
"errors"
"fmt"
"io/fs"
"sort"
_ "github.com/mattn/go-sqlite3"
)
//go:embed seed
var seedFS embed.FS
type DB struct {
Db *sql.DB
// Datasource name.
DSN string
}
// NewDB returns a new instance of DB associated with the given datasource name.
func NewDB(dsn string) *DB {
db := &DB{
DSN: dsn,
}
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.
if db.Db, err = sql.Open("sqlite3", db.DSN); err != nil {
return err
}
if err := db.seed(); err != nil {
return fmt.Errorf("failed to seed: %w", err)
}
return err
}
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.seedFile(filename); err != nil {
return fmt.Errorf("seed error: name=%q err=%w", filename, err)
}
}
return nil
}
// seedFile runs a single seed file within a transaction.
func (db *DB) seedFile(name string) error {
tx, err := db.Db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
// Read and execute migration file.
if buf, err := fs.ReadFile(seedFS, 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
}
}
package store package store
import ( import (
"database/sql"
"fmt" "fmt"
"memos/utils" "memos/api"
"memos/common"
"strings" "strings"
) )
type User struct { type UserService struct {
Id string `json:"id"` db *DB
Username string `json:"username"`
Password string `json:"password"`
OpenId string `json:"openId"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
} }
func CreateNewUser(username string, password string) (User, error) { func NewUserService(db *DB) *UserService {
nowDateTimeStr := utils.GetNowDateTimeStr() return &UserService{db: db}
newUser := User{
Id: utils.GenUUID(),
Username: username,
Password: password,
OpenId: utils.GenUUID(),
CreatedAt: nowDateTimeStr,
UpdatedAt: nowDateTimeStr,
}
query := `INSERT INTO users (id, username, password, open_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)`
_, err := DB.Exec(query, newUser.Id, newUser.Username, newUser.Password, newUser.OpenId, newUser.CreatedAt, newUser.UpdatedAt)
return newUser, FormatDBError(err)
} }
type UpdateUserPatch struct { func (s *UserService) CreateUser(create *api.UserCreate) (*api.User, error) {
Username *string user, err := createUser(s.db, create)
Password *string if err != nil {
} return nil, err
}
func UpdateUser(id string, updateUserPatch *UpdateUserPatch) (User, error) { return user, nil
user := User{} }
user, err := GetUserById(id)
func (s *UserService) PatchUser(patch *api.UserPatch) (*api.User, error) {
user, err := patchUser(s.db, patch)
if err != nil { if err != nil {
return user, FormatDBError(err) return nil, err
} }
set, args := []string{}, []interface{}{} return user, nil
}
if v := updateUserPatch.Username; v != nil { func (s *UserService) FindUser(find *api.UserFind) (*api.User, error) {
user.Username = *v list, err := findUserList(s.db, find)
set, args = append(set, "username=?"), append(args, *v) if err != nil {
} return nil, err
if v := updateUserPatch.Password; v != nil {
user.Password = *v
set, args = append(set, "password=?"), append(args, *v)
} }
set, args = append(set, "updated_at=?"), append(args, utils.GetNowDateTimeStr())
args = append(args, id)
sqlQuery := `UPDATE users SET ` + strings.Join(set, ",") + ` WHERE id=?` if len(list) == 0 {
_, err = DB.Exec(sqlQuery, args...) return nil, nil
} else if len(list) > 1 {
return nil, &common.Error{Code: common.Conflict, Err: fmt.Errorf("found %d users with filter %+v, expect 1. ", len(list), find)}
}
return user, FormatDBError(err) return list[0], nil
} }
func ResetUserOpenId(userId string) (string, error) { func createUser(db *DB, create *api.UserCreate) (*api.User, error) {
openId := utils.GenUUID() row, err := db.Db.Query(`
query := `UPDATE users SET open_id=? WHERE id=?` INSERT INTO user (
_, err := DB.Exec(query, openId, userId) name,
return openId, FormatDBError(err) password,
} open_id
)
VALUES (?, ?, ?)
RETURNING id, name, password, open_id, created_ts, updated_ts
`,
create.Name,
create.Password,
create.OpenId,
)
if err != nil {
return nil, FormatError(err)
}
defer row.Close()
row.Next()
var user api.User
if err := row.Scan(
&user.Id,
&user.Name,
&user.Password,
&user.OpenId,
&user.CreatedTs,
&user.UpdatedTs,
); err != nil {
return nil, FormatError(err)
}
func GetUserById(id string) (User, error) { return &user, nil
query := `SELECT id, username, password, open_id, created_at, updated_at FROM users WHERE id=?`
user := User{}
err := DB.QueryRow(query, id).Scan(&user.Id, &user.Username, &user.Password, &user.OpenId, &user.CreatedAt, &user.UpdatedAt)
return user, FormatDBError(err)
} }
func GetUserByOpenId(openId string) (User, error) { func patchUser(db *DB, patch *api.UserPatch) (*api.User, error) {
query := `SELECT id, username, password, open_id, created_at, updated_at FROM users WHERE open_id=?` set, args := []string{}, []interface{}{}
user := User{}
err := DB.QueryRow(query, openId).Scan(&user.Id, &user.Username, &user.Password, &user.OpenId, &user.CreatedAt, &user.UpdatedAt)
return user, FormatDBError(err)
}
func GetUserByUsernameAndPassword(username string, password string) (User, error) { if v := patch.Name; v != nil {
query := `SELECT id, username, password, open_id, created_at, updated_at FROM users WHERE username=? AND password=?` set, args = append(set, "name = ?"), append(args, v)
user := User{} }
err := DB.QueryRow(query, username, password).Scan(&user.Id, &user.Username, &user.Password, &user.OpenId, &user.CreatedAt, &user.UpdatedAt) if v := patch.Password; v != nil {
return user, FormatDBError(err) set, args = append(set, "password = ?"), append(args, v)
} }
if v := patch.OpenId; v != nil {
set, args = append(set, "open_id = ?"), append(args, v)
}
func CheckUsernameUsable(username string) (bool, error) { args = append(args, patch.Id)
query := `SELECT * FROM users WHERE username=?`
query = fmt.Sprintf("SELECT COUNT(*) FROM (%s)", query)
var count uint row, err := db.Db.Query(`
err := DB.QueryRow(query, username).Scan(&count) UPDATE user
if err != nil && err != sql.ErrNoRows { SET `+strings.Join(set, ", ")+`
return false, FormatDBError(err) WHERE id = ?
RETURNING id, name, password, open_id, created_ts, updated_ts
`, args...)
if err != nil {
return nil, FormatError(err)
}
defer row.Close()
if row.Next() {
var user api.User
if err := row.Scan(
&user.Id,
&user.Name,
&user.Password,
&user.OpenId,
&user.CreatedTs,
&user.UpdatedTs,
); err != nil {
return nil, FormatError(err)
} }
usable := true return &user, nil
if count > 0 {
usable = false
} }
return usable, nil return nil, &common.Error{Code: common.NotFound, Err: fmt.Errorf("user ID not found: %d", patch.Id)}
} }
func CheckPasswordValid(id string, password string) (bool, error) { func findUserList(db *DB, find *api.UserFind) ([]*api.User, error) {
query := `SELECT * FROM users WHERE id=? AND password=?` where, args := []string{"1 = 1"}, []interface{}{}
query = fmt.Sprintf("SELECT COUNT(*) FROM (%s)", query)
if v := find.Id; v != nil {
where, args = append(where, "id = ?"), append(args, *v)
}
if v := find.Name; v != nil {
where, args = append(where, "name = ?"), append(args, *v)
}
if v := find.OpenId; v != nil {
where, args = append(where, "open_id = ?"), append(args, *v)
}
rows, err := db.Db.Query(`
SELECT
id,
name,
password,
open_id,
created_ts,
updated_ts
FROM user
WHERE `+strings.Join(where, " AND "),
args...,
)
if err != nil {
return nil, FormatError(err)
}
defer rows.Close()
list := make([]*api.User, 0)
for rows.Next() {
var user api.User
if err := rows.Scan(
&user.Id,
&user.Name,
&user.Password,
&user.OpenId,
&user.CreatedTs,
&user.UpdatedTs,
); err != nil {
fmt.Println(err)
return nil, FormatError(err)
}
var count uint list = append(list, &user)
err := DB.QueryRow(query, id, password).Scan(&count)
if err != nil && err != sql.ErrNoRows {
return false, FormatDBError(err)
} }
if count > 0 { if err := rows.Err(); err != nil {
return true, nil return nil, FormatError(err)
} else {
return false, nil
} }
return list, nil
} }
package utils
import (
"time"
"github.com/google/uuid"
)
func GenUUID() string {
return uuid.New().String()
}
func GetNowDateTimeStr() string {
return time.Now().Local().Format("2006/01/02 15:04:05")
}
...@@ -13,7 +13,7 @@ const AboutSiteDialog: React.FC<Props> = ({ destroy }: Props) => { ...@@ -13,7 +13,7 @@ const AboutSiteDialog: React.FC<Props> = ({ destroy }: Props) => {
<> <>
<div className="dialog-header-container"> <div className="dialog-header-container">
<p className="title-text"> <p className="title-text">
<span className="icon-text">🤠</span>关于 <b>Memos</b> <span className="icon-text">🤠</span>About <b>Memos</b>
</p> </p>
<button className="btn close-btn" onClick={handleCloseBtnClick}> <button className="btn close-btn" onClick={handleCloseBtnClick}>
<img className="icon-img" src="/icons/close.svg" /> <img className="icon-img" src="/icons/close.svg" />
...@@ -21,9 +21,9 @@ const AboutSiteDialog: React.FC<Props> = ({ destroy }: Props) => { ...@@ -21,9 +21,9 @@ const AboutSiteDialog: React.FC<Props> = ({ destroy }: Props) => {
</div> </div>
<div className="dialog-content-container"> <div className="dialog-content-container">
<p> <p>
把玩 <a href="https://flomoapp.com">flomo</a> 后有感而作的开源项目 Memos is an open source, self-hosted alternative to <a href="https://flomoapp.com">flomo</a>.
</p> </p>
<p>特点:精美且细节的视觉样式、体验优良的交互逻辑</p> <p>Built with `Golang` and `React`.</p>
<br /> <br />
<p> <p>
🏗 This project is working in progress, <br /> and very pleasure to welcome your{" "} 🏗 This project is working in progress, <br /> and very pleasure to welcome your{" "}
......
...@@ -44,19 +44,19 @@ const ChangePasswordDialog: React.FC<Props> = ({ destroy }: Props) => { ...@@ -44,19 +44,19 @@ const ChangePasswordDialog: React.FC<Props> = ({ destroy }: Props) => {
const handleSaveBtnClick = async () => { const handleSaveBtnClick = async () => {
if (oldPassword === "" || newPassword === "" || newPasswordAgain === "") { if (oldPassword === "" || newPassword === "" || newPasswordAgain === "") {
toastHelper.error("密码不能为空"); toastHelper.error("Please fill in all fields.");
return; return;
} }
if (newPassword !== newPasswordAgain) { if (newPassword !== newPasswordAgain) {
toastHelper.error("新密码两次输入不一致"); toastHelper.error("New passwords do not match.");
setNewPasswordAgain(""); setNewPasswordAgain("");
return; return;
} }
const passwordValidResult = validate(newPassword, validateConfig); const passwordValidResult = validate(newPassword, validateConfig);
if (!passwordValidResult.result) { if (!passwordValidResult.result) {
toastHelper.error("密码 " + passwordValidResult.reason); toastHelper.error("Password " + passwordValidResult.reason);
return; return;
} }
...@@ -64,13 +64,13 @@ const ChangePasswordDialog: React.FC<Props> = ({ destroy }: Props) => { ...@@ -64,13 +64,13 @@ const ChangePasswordDialog: React.FC<Props> = ({ destroy }: Props) => {
const isValid = await userService.checkPasswordValid(oldPassword); const isValid = await userService.checkPasswordValid(oldPassword);
if (!isValid) { if (!isValid) {
toastHelper.error("旧密码不匹配"); toastHelper.error("Old password is invalid.");
setOldPassword(""); setOldPassword("");
return; return;
} }
await userService.updatePassword(newPassword); await userService.updatePassword(newPassword);
toastHelper.info("密码修改成功!"); toastHelper.info("Password changed.");
handleCloseBtnClick(); handleCloseBtnClick();
} catch (error: any) { } catch (error: any) {
toastHelper.error(error); toastHelper.error(error);
...@@ -80,30 +80,30 @@ const ChangePasswordDialog: React.FC<Props> = ({ destroy }: Props) => { ...@@ -80,30 +80,30 @@ const ChangePasswordDialog: React.FC<Props> = ({ destroy }: Props) => {
return ( return (
<> <>
<div className="dialog-header-container"> <div className="dialog-header-container">
<p className="title-text">修改密码</p> <p className="title-text">Change Password</p>
<button className="btn close-btn" onClick={handleCloseBtnClick}> <button className="btn close-btn" onClick={handleCloseBtnClick}>
<img className="icon-img" src="/icons/close.svg" /> <img className="icon-img" src="/icons/close.svg" />
</button> </button>
</div> </div>
<div className="dialog-content-container"> <div className="dialog-content-container">
<label className="form-label input-form-label"> <label className="form-label input-form-label">
<span className={"normal-text " + (oldPassword === "" ? "" : "not-null")}>旧密码</span> <span className={"normal-text " + (oldPassword === "" ? "" : "not-null")}>Old password</span>
<input type="password" value={oldPassword} onChange={handleOldPasswordChanged} /> <input type="password" value={oldPassword} onChange={handleOldPasswordChanged} />
</label> </label>
<label className="form-label input-form-label"> <label className="form-label input-form-label">
<span className={"normal-text " + (newPassword === "" ? "" : "not-null")}>新密码</span> <span className={"normal-text " + (newPassword === "" ? "" : "not-null")}>New passworld</span>
<input type="password" value={newPassword} onChange={handleNewPasswordChanged} /> <input type="password" value={newPassword} onChange={handleNewPasswordChanged} />
</label> </label>
<label className="form-label input-form-label"> <label className="form-label input-form-label">
<span className={"normal-text " + (newPasswordAgain === "" ? "" : "not-null")}>再次输入新密码</span> <span className={"normal-text " + (newPasswordAgain === "" ? "" : "not-null")}>New password again</span>
<input type="password" value={newPasswordAgain} onChange={handleNewPasswordAgainChanged} /> <input type="password" value={newPasswordAgain} onChange={handleNewPasswordAgainChanged} />
</label> </label>
<div className="btns-container"> <div className="btns-container">
<span className="btn cancel-btn" onClick={handleCloseBtnClick}> <span className="btn cancel-btn" onClick={handleCloseBtnClick}>
取消 Cancel
</span> </span>
<span className="btn confirm-btn" onClick={handleSaveBtnClick}> <span className="btn confirm-btn" onClick={handleSaveBtnClick}>
保存 Save
</span> </span>
</div> </div>
</div> </div>
......
...@@ -27,29 +27,31 @@ const ConfirmResetOpenIdDialog: React.FC<Props> = ({ destroy }: Props) => { ...@@ -27,29 +27,31 @@ const ConfirmResetOpenIdDialog: React.FC<Props> = ({ destroy }: Props) => {
try { try {
await userService.resetOpenId(); await userService.resetOpenId();
} catch (error) { } catch (error) {
toastHelper.error("请求重置 Open API 失败"); toastHelper.error("Request reset open API failed.");
return; return;
} }
toastHelper.success("重置成功!"); toastHelper.success("Reset open API succeeded.");
handleCloseBtnClick(); handleCloseBtnClick();
}; };
return ( return (
<> <>
<div className="dialog-header-container"> <div className="dialog-header-container">
<p className="title-text">重置 Open API</p> <p className="title-text">Reset Open API</p>
<button className="btn close-btn" onClick={handleCloseBtnClick}> <button className="btn close-btn" onClick={handleCloseBtnClick}>
<img className="icon-img" src="/icons/close.svg" /> <img className="icon-img" src="/icons/close.svg" />
</button> </button>
</div> </div>
<div className="dialog-content-container"> <div className="dialog-content-container">
<p className="warn-text">⚠️ 现有 API 将失效,并生成新的 API,确定要重置吗?</p> <p className="warn-text">
⚠️ 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"> <div className="btns-container">
<span className="btn cancel-btn" onClick={handleCloseBtnClick}> <span className="btn cancel-btn" onClick={handleCloseBtnClick}>
取消 Cancel
</span> </span>
<span className={`btn confirm-btn ${resetBtnClickLoadingState.isLoading ? "loading" : ""}`} onClick={handleConfirmBtnClick}> <span className={`btn confirm-btn ${resetBtnClickLoadingState.isLoading ? "loading" : ""}`} onClick={handleConfirmBtnClick}>
确定重置! Reset!
</span> </span>
</div> </div>
</div> </div>
......
import { memo, useCallback, useEffect, useState } from "react"; import { memo, useCallback, useEffect, useState } from "react";
import { memoService, queryService } from "../services"; import { memoService, shortcutService } from "../services";
import { checkShouldShowMemoWithFilters, filterConsts, getDefaultFilter, relationConsts } from "../helpers/filter"; import { checkShouldShowMemoWithFilters, filterConsts, getDefaultFilter, relationConsts } from "../helpers/filter";
import useLoading from "../hooks/useLoading"; import useLoading from "../hooks/useLoading";
import { showDialog } from "./Dialog"; import { showDialog } from "./Dialog";
import toastHelper from "./Toast"; import toastHelper from "./Toast";
import Selector from "./common/Selector"; import Selector from "./common/Selector";
import "../less/create-query-dialog.less"; import "../less/create-shortcut-dialog.less";
interface Props extends DialogProps { interface Props extends DialogProps {
queryId?: string; shortcutId?: string;
} }
const CreateQueryDialog: React.FC<Props> = (props: Props) => { const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
const { destroy, queryId } = props; const { destroy, shortcutId } = props;
const [title, setTitle] = useState<string>(""); const [title, setTitle] = useState<string>("");
const [filters, setFilters] = useState<Filter[]>([]); const [filters, setFilters] = useState<Filter[]>([]);
...@@ -23,15 +23,15 @@ const CreateQueryDialog: React.FC<Props> = (props: Props) => { ...@@ -23,15 +23,15 @@ const CreateQueryDialog: React.FC<Props> = (props: Props) => {
}).length; }).length;
useEffect(() => { useEffect(() => {
const queryTemp = queryService.getQueryById(queryId ?? ""); const shortcutTemp = shortcutService.getShortcutById(shortcutId ?? "");
if (queryTemp) { if (shortcutTemp) {
setTitle(queryTemp.title); setTitle(shortcutTemp.title);
const temp = JSON.parse(queryTemp.querystring); const temp = JSON.parse(shortcutTemp.payload);
if (Array.isArray(temp)) { if (Array.isArray(temp)) {
setFilters(temp); setFilters(temp);
} }
} }
}, [queryId]); }, [shortcutId]);
const handleTitleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleTitleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const text = e.target.value as string; const text = e.target.value as string;
...@@ -40,17 +40,17 @@ const CreateQueryDialog: React.FC<Props> = (props: Props) => { ...@@ -40,17 +40,17 @@ const CreateQueryDialog: React.FC<Props> = (props: Props) => {
const handleSaveBtnClick = async () => { const handleSaveBtnClick = async () => {
if (!title) { if (!title) {
toastHelper.error("标题不能为空!"); toastHelper.error("Title is required");
return; return;
} }
try { try {
if (queryId) { if (shortcutId) {
const editedQuery = await queryService.updateQuery(queryId, title, JSON.stringify(filters)); const editedShortcut = await shortcutService.updateShortcut(shortcutId, title, JSON.stringify(filters));
queryService.editQuery(editedQuery); shortcutService.editShortcut(shortcutService.convertResponseModelShortcut(editedShortcut));
} else { } else {
const query = await queryService.createQuery(title, JSON.stringify(filters)); const shortcut = await shortcutService.createShortcut(title, JSON.stringify(filters));
queryService.pushQuery(query); shortcutService.pushShortcut(shortcutService.convertResponseModelShortcut(shortcut));
} }
} catch (error: any) { } catch (error: any) {
toastHelper.error(error.message); toastHelper.error(error.message);
...@@ -62,7 +62,7 @@ const CreateQueryDialog: React.FC<Props> = (props: Props) => { ...@@ -62,7 +62,7 @@ const CreateQueryDialog: React.FC<Props> = (props: Props) => {
if (filters.length > 0) { if (filters.length > 0) {
const lastFilter = filters[filters.length - 1]; const lastFilter = filters[filters.length - 1];
if (lastFilter.value.value === "") { if (lastFilter.value.value === "") {
toastHelper.info("先完善上一个过滤器吧"); toastHelper.info("Please fill in previous filter value");
return; return;
} }
} }
...@@ -90,7 +90,7 @@ const CreateQueryDialog: React.FC<Props> = (props: Props) => { ...@@ -90,7 +90,7 @@ const CreateQueryDialog: React.FC<Props> = (props: Props) => {
<div className="dialog-header-container"> <div className="dialog-header-container">
<p className="title-text"> <p className="title-text">
<span className="icon-text">🔖</span> <span className="icon-text">🔖</span>
{queryId ? "编辑检索" : "创建检索"} {shortcutId ? "Edit Shortcut" : "Create Shortcut"}
</p> </p>
<button className="btn close-btn" onClick={destroy}> <button className="btn close-btn" onClick={destroy}>
<img className="icon-img" src="/icons/close.svg" /> <img className="icon-img" src="/icons/close.svg" />
...@@ -98,11 +98,11 @@ const CreateQueryDialog: React.FC<Props> = (props: Props) => { ...@@ -98,11 +98,11 @@ const CreateQueryDialog: React.FC<Props> = (props: Props) => {
</div> </div>
<div className="dialog-content-container"> <div className="dialog-content-container">
<div className="form-item-container input-form-container"> <div className="form-item-container input-form-container">
<span className="normal-text">标题</span> <span className="normal-text">Title</span>
<input className="title-input" type="text" value={title} onChange={handleTitleInputChange} /> <input className="title-input" type="text" value={title} onChange={handleTitleInputChange} />
</div> </div>
<div className="form-item-container filter-form-container"> <div className="form-item-container filter-form-container">
<span className="normal-text">过滤器</span> <span className="normal-text">Filter</span>
<div className="filters-wrapper"> <div className="filters-wrapper">
{filters.map((f, index) => { {filters.map((f, index) => {
return ( return (
...@@ -116,7 +116,7 @@ const CreateQueryDialog: React.FC<Props> = (props: Props) => { ...@@ -116,7 +116,7 @@ const CreateQueryDialog: React.FC<Props> = (props: Props) => {
); );
})} })}
<div className="create-filter-btn" onClick={handleAddFilterBenClick}> <div className="create-filter-btn" onClick={handleAddFilterBenClick}>
添加筛选条件 New Filter
</div> </div>
</div> </div>
</div> </div>
...@@ -125,10 +125,10 @@ const CreateQueryDialog: React.FC<Props> = (props: Props) => { ...@@ -125,10 +125,10 @@ const CreateQueryDialog: React.FC<Props> = (props: Props) => {
<div></div> <div></div>
<div className="btns-container"> <div className="btns-container">
<span className={`tip-text ${filters.length === 0 && "hidden"}`}> <span className={`tip-text ${filters.length === 0 && "hidden"}`}>
符合条件的 Memo 有 <strong>{shownMemoLength}</strong> <strong>{shownMemoLength}</strong> eligible memo
</span> </span>
<button className={`btn save-btn ${requestState.isLoading ? "requesting" : ""}`} onClick={handleSaveBtnClick}> <button className={`btn save-btn ${requestState.isLoading ? "requesting" : ""}`} onClick={handleSaveBtnClick}>
保存 Save
</button> </button>
</div> </div>
</div> </div>
...@@ -298,12 +298,12 @@ const FilterInputer: React.FC<MemoFilterInputerProps> = (props: MemoFilterInpute ...@@ -298,12 +298,12 @@ const FilterInputer: React.FC<MemoFilterInputerProps> = (props: MemoFilterInpute
const MemoFilterInputer: React.FC<MemoFilterInputerProps> = memo(FilterInputer); const MemoFilterInputer: React.FC<MemoFilterInputerProps> = memo(FilterInputer);
export default function showCreateQueryDialog(queryId?: string): void { export default function showCreateShortcutDialog(shortcutId?: string): void {
showDialog( showDialog(
{ {
className: "create-query-dialog", className: "create-shortcut-dialog",
}, },
CreateQueryDialog, CreateShortcutDialog,
{ queryId } { shortcutId }
); );
} }
...@@ -106,11 +106,11 @@ const DailyMemoDiaryDialog: React.FC<Props> = (props: Props) => { ...@@ -106,11 +106,11 @@ const DailyMemoDiaryDialog: React.FC<Props> = (props: Props) => {
/> />
{loadingState.isLoading ? ( {loadingState.isLoading ? (
<div className="tip-container"> <div className="tip-container">
<p className="tip-text">努力加载中...</p> <p className="tip-text">Loading...</p>
</div> </div>
) : memos.length === 0 ? ( ) : memos.length === 0 ? (
<div className="tip-container"> <div className="tip-container">
<p className="tip-text">空空如也</p> <p className="tip-text">Oops, there is nothing.</p>
</div> </div>
) : ( ) : (
<div className="dailymemos-wrapper"> <div className="dailymemos-wrapper">
......
...@@ -18,7 +18,7 @@ const DeletedMemo: React.FC<Props> = (props: Props) => { ...@@ -18,7 +18,7 @@ const DeletedMemo: React.FC<Props> = (props: Props) => {
const memo: FormattedMemo = { const memo: FormattedMemo = {
...propsMemo, ...propsMemo,
createdAtStr: utils.getDateTimeString(propsMemo.createdAt), createdAtStr: utils.getDateTimeString(propsMemo.createdAt),
deletedAtStr: utils.getDateTimeString(propsMemo.deletedAt ?? Date.now()), deletedAtStr: utils.getDateTimeString(propsMemo.updatedAt ?? Date.now()),
}; };
const [showConfirmDeleteBtn, toggleConfirmDeleteBtn] = useToggle(false); 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) ?? []);
...@@ -40,7 +40,7 @@ const DeletedMemo: React.FC<Props> = (props: Props) => { ...@@ -40,7 +40,7 @@ const DeletedMemo: React.FC<Props> = (props: Props) => {
try { try {
await memoService.restoreMemoById(memo.id); await memoService.restoreMemoById(memo.id);
handleDeletedMemoAction(memo.id); handleDeletedMemoAction(memo.id);
toastHelper.info("恢复成功"); toastHelper.info("Restored successfully");
} catch (error: any) { } catch (error: any) {
toastHelper.error(error.message); toastHelper.error(error.message);
} }
...@@ -55,7 +55,7 @@ const DeletedMemo: React.FC<Props> = (props: Props) => { ...@@ -55,7 +55,7 @@ const DeletedMemo: React.FC<Props> = (props: Props) => {
return ( return (
<div className={`memo-wrapper ${"memos-" + memo.id}`} onMouseLeave={handleMouseLeaveMemoWrapper}> <div className={`memo-wrapper ${"memos-" + memo.id}`} onMouseLeave={handleMouseLeaveMemoWrapper}>
<div className="memo-top-wrapper"> <div className="memo-top-wrapper">
<span className="time-text">删除于 {memo.deletedAtStr}</span> <span className="time-text">Deleted at {memo.deletedAtStr}</span>
<div className="btns-container"> <div className="btns-container">
<span className="btn more-action-btn"> <span className="btn more-action-btn">
<img className="icon-img" src="/icons/more.svg" /> <img className="icon-img" src="/icons/more.svg" />
...@@ -63,10 +63,10 @@ const DeletedMemo: React.FC<Props> = (props: Props) => { ...@@ -63,10 +63,10 @@ const DeletedMemo: React.FC<Props> = (props: Props) => {
<div className="more-action-btns-wrapper"> <div className="more-action-btns-wrapper">
<div className="more-action-btns-container"> <div className="more-action-btns-container">
<span className="btn restore-btn" onClick={handleRestoreMemoClick}> <span className="btn restore-btn" onClick={handleRestoreMemoClick}>
恢复 Restore
</span> </span>
<span className={`btn delete-btn ${showConfirmDeleteBtn ? "final-confirm" : ""}`} onClick={handleDeleteMemoClick}> <span className={`btn delete-btn ${showConfirmDeleteBtn ? "final-confirm" : ""}`} onClick={handleDeleteMemoClick}>
{showConfirmDeleteBtn ? "确定删除!" : "完全删除"} {showConfirmDeleteBtn ? "Delete!" : "Delete"}
</span> </span>
</div> </div>
</div> </div>
......
...@@ -176,12 +176,12 @@ const Editor = forwardRef((props: EditorProps, ref: React.ForwardedRef<EditorRef ...@@ -176,12 +176,12 @@ const Editor = forwardRef((props: EditorProps, ref: React.ForwardedRef<EditorRef
<div className="btns-container"> <div className="btns-container">
<Only when={showCancelBtn}> <Only when={showCancelBtn}>
<button className="action-btn cancel-btn" onClick={handleCommonCancelBtnClick}> <button className="action-btn cancel-btn" onClick={handleCommonCancelBtnClick}>
撤销修改 Cancel editting
</button> </button>
</Only> </Only>
<Only when={showConfirmBtn}> <Only when={showConfirmBtn}>
<button className="action-btn confirm-btn" disabled={!editorRef.current?.value} onClick={handleCommonConfirmBtnClick}> <button className="action-btn confirm-btn" disabled={!editorRef.current?.value} onClick={handleCommonConfirmBtnClick}>
记下<span className="icon-text">✍️</span> Save <span className="icon-text">✍️</span>
</button> </button>
</Only> </Only>
</div> </div>
......
...@@ -93,19 +93,19 @@ const Memo: React.FC<Props> = (props: Props) => { ...@@ -93,19 +93,19 @@ const Memo: React.FC<Props> = (props: Props) => {
<div className="more-action-btns-wrapper"> <div className="more-action-btns-wrapper">
<div className="more-action-btns-container"> <div className="more-action-btns-container">
<span className="btn" onClick={handleShowMemoStoryDialog}> <span className="btn" onClick={handleShowMemoStoryDialog}>
查看详情 View Story
</span> </span>
<span className="btn" onClick={handleMarkMemoClick}> <span className="btn" onClick={handleMarkMemoClick}>
Mark Mark
</span> </span>
<span className="btn" onClick={handleGenMemoImageBtnClick}> <span className="btn" onClick={handleGenMemoImageBtnClick}>
分享 Share
</span> </span>
<span className="btn" onClick={handleEditMemoClick}> <span className="btn" onClick={handleEditMemoClick}>
编辑 Edit
</span> </span>
<span className={`btn delete-btn ${showConfirmDeleteBtn ? "final-confirm" : ""}`} onClick={handleDeleteMemoClick}> <span className={`btn delete-btn ${showConfirmDeleteBtn ? "final-confirm" : ""}`} onClick={handleDeleteMemoClick}>
{showConfirmDeleteBtn ? "确定删除!" : "删除"} {showConfirmDeleteBtn ? "Delete!" : "Delete"}
</span> </span>
</div> </div>
</div> </div>
...@@ -151,7 +151,7 @@ export function formatMemoContent(content: string) { ...@@ -151,7 +151,7 @@ export function formatMemoContent(content: string) {
.replace(LINK_REG, "<a class='link' target='_blank' rel='noreferrer' href='$1'>$1</a>") .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>"); .replace(MEMO_LINK_REG, "<span class='memo-link-text' data-value='$2'>$1</span>");
// 中英文之间加空格 // Add space in english and chinese
if (shouldSplitMemoWord) { if (shouldSplitMemoWord) {
content = content content = content
.replace(/([\u4e00-\u9fa5])([A-Za-z0-9?.,;[\]]+)/g, "$1 $2") .replace(/([\u4e00-\u9fa5])([A-Za-z0-9?.,;[\]]+)/g, "$1 $2")
......
...@@ -148,7 +148,7 @@ const MemoCardDialog: React.FC<Props> = (props: Props) => { ...@@ -148,7 +148,7 @@ const MemoCardDialog: React.FC<Props> = (props: Props) => {
</div> </div>
{linkMemos.length > 0 ? ( {linkMemos.length > 0 ? (
<div className="linked-memos-wrapper"> <div className="linked-memos-wrapper">
<p className="normal-text">关联了 {linkMemos.length} MEMO</p> <p className="normal-text">{linkMemos.length} related MEMO</p>
{linkMemos.map((m) => { {linkMemos.map((m) => {
const rawtext = parseHtmlToRawText(formatMemoContent(m.content)).replaceAll("\n", " "); const rawtext = parseHtmlToRawText(formatMemoContent(m.content)).replaceAll("\n", " ");
return ( return (
...@@ -162,7 +162,7 @@ const MemoCardDialog: React.FC<Props> = (props: Props) => { ...@@ -162,7 +162,7 @@ const MemoCardDialog: React.FC<Props> = (props: Props) => {
) : null} ) : null}
{linkedMemos.length > 0 ? ( {linkedMemos.length > 0 ? (
<div className="linked-memos-wrapper"> <div className="linked-memos-wrapper">
<p className="normal-text">{linkedMemos.length} 个链接至此的 MEMO</p> <p className="normal-text">{linkedMemos.length} linked MEMO</p>
{linkedMemos.map((m) => { {linkedMemos.map((m) => {
const rawtext = parseHtmlToRawText(formatMemoContent(m.content)).replaceAll("\n", " "); const rawtext = parseHtmlToRawText(formatMemoContent(m.content)).replaceAll("\n", " ");
return ( return (
......
...@@ -130,7 +130,7 @@ const MemoEditor: React.FC<Props> = () => { ...@@ -130,7 +130,7 @@ const MemoEditor: React.FC<Props> = () => {
try { try {
const image = await resourceService.upload(file); const image = await resourceService.upload(file);
const url = `/r/${image.id}/${image.filename}`; const url = `/h/r/${image.id}/${image.filename}`;
return url; return url;
} catch (error: any) { } catch (error: any) {
...@@ -140,7 +140,7 @@ const MemoEditor: React.FC<Props> = () => { ...@@ -140,7 +140,7 @@ const MemoEditor: React.FC<Props> = () => {
const handleSaveBtnClick = useCallback(async (content: string) => { const handleSaveBtnClick = useCallback(async (content: string) => {
if (content === "") { if (content === "") {
toastHelper.error("内容不能为空呀"); toastHelper.error("Content can't be empty");
return; return;
} }
...@@ -270,7 +270,7 @@ const MemoEditor: React.FC<Props> = () => { ...@@ -270,7 +270,7 @@ const MemoEditor: React.FC<Props> = () => {
() => ({ () => ({
className: "memo-editor", className: "memo-editor",
initialContent: getEditorContentCache(), initialContent: getEditorContentCache(),
placeholder: "现在的想法是...", placeholder: "Any thoughts...",
showConfirmBtn: true, showConfirmBtn: true,
showCancelBtn: showEditStatus, showCancelBtn: showEditStatus,
onConfirmBtnClick: handleSaveBtnClick, onConfirmBtnClick: handleSaveBtnClick,
...@@ -282,7 +282,7 @@ const MemoEditor: React.FC<Props> = () => { ...@@ -282,7 +282,7 @@ const MemoEditor: React.FC<Props> = () => {
return ( return (
<div className={"memo-editor-wrapper " + (showEditStatus ? "edit-ing" : "")}> <div className={"memo-editor-wrapper " + (showEditStatus ? "edit-ing" : "")}>
<p className={"tip-text " + (showEditStatus ? "" : "hidden")}>正在修改中...</p> <p className={"tip-text " + (showEditStatus ? "" : "hidden")}>Editting...</p>
<Editor <Editor
ref={editorRef} ref={editorRef}
{...editorConfig} {...editorConfig}
......
import { useContext } from "react"; import { useContext } from "react";
import appContext from "../stores/appContext"; import appContext from "../stores/appContext";
import { locationService, queryService } from "../services"; import { locationService, shortcutService } from "../services";
import utils from "../helpers/utils"; import utils from "../helpers/utils";
import { getTextWithMemoType } from "../helpers/filter"; import { getTextWithMemoType } from "../helpers/filter";
import "../less/memo-filter.less"; import "../less/memo-filter.less";
...@@ -12,17 +12,17 @@ const MemoFilter: React.FC<FilterProps> = () => { ...@@ -12,17 +12,17 @@ const MemoFilter: React.FC<FilterProps> = () => {
locationState: { query }, locationState: { query },
} = useContext(appContext); } = useContext(appContext);
const { tag: tagQuery, duration, type: memoType, text: textQuery, filter } = query; const { tag: tagQuery, duration, type: memoType, text: textQuery, shortcutId } = query;
const queryFilter = queryService.getQueryById(filter); const queryFilter = shortcutService.getShortcutById(shortcutId);
const showFilter = Boolean(tagQuery || (duration && duration.from < duration.to) || memoType || textQuery || queryFilter); const showFilter = Boolean(tagQuery || (duration && duration.from < duration.to) || memoType || textQuery || queryFilter);
return ( return (
<div className={`filter-query-container ${showFilter ? "" : "hidden"}`}> <div className={`filter-query-container ${showFilter ? "" : "hidden"}`}>
<span className="tip-text">筛选:</span> <span className="tip-text">Filter:</span>
<div <div
className={"filter-item-container " + (queryFilter ? "" : "hidden")} className={"filter-item-container " + (queryFilter ? "" : "hidden")}
onClick={() => { onClick={() => {
locationService.setMemoFilter(""); locationService.setMemoShortcut("");
}} }}
> >
<span className="icon-text">🔖</span> {queryFilter?.title} <span className="icon-text">🔖</span> {queryFilter?.title}
......
import { useCallback, useContext, useEffect, useRef, useState } from "react"; import { useCallback, useContext, useEffect, useRef, useState } from "react";
import appContext from "../stores/appContext"; import appContext from "../stores/appContext";
import { locationService, memoService, queryService } from "../services"; import { locationService, memoService, shortcutService } from "../services";
import { IMAGE_URL_REG, LINK_REG, MEMO_LINK_REG, TAG_REG } from "../helpers/consts"; import { IMAGE_URL_REG, LINK_REG, MEMO_LINK_REG, TAG_REG } from "../helpers/consts";
import utils from "../helpers/utils"; import utils from "../helpers/utils";
import { checkShouldShowMemoWithFilters } from "../helpers/filter"; import { checkShouldShowMemoWithFilters } from "../helpers/filter";
...@@ -18,8 +18,8 @@ const MemoList: React.FC<Props> = () => { ...@@ -18,8 +18,8 @@ const MemoList: React.FC<Props> = () => {
const [isFetching, setFetchStatus] = useState(true); const [isFetching, setFetchStatus] = useState(true);
const wrapperElement = useRef<HTMLDivElement>(null); const wrapperElement = useRef<HTMLDivElement>(null);
const { tag: tagQuery, duration, type: memoType, text: textQuery, filter: queryId } = query; const { tag: tagQuery, duration, type: memoType, text: textQuery, shortcutId } = query;
const queryFilter = queryService.getQueryById(queryId); const queryFilter = shortcutService.getShortcutById(shortcutId);
const showMemoFilter = Boolean(tagQuery || (duration && duration.from < duration.to) || memoType || textQuery || queryFilter); const showMemoFilter = Boolean(tagQuery || (duration && duration.from < duration.to) || memoType || textQuery || queryFilter);
const shownMemos = const shownMemos =
...@@ -28,7 +28,7 @@ const MemoList: React.FC<Props> = () => { ...@@ -28,7 +28,7 @@ const MemoList: React.FC<Props> = () => {
let shouldShow = true; let shouldShow = true;
if (queryFilter) { if (queryFilter) {
const filters = JSON.parse(queryFilter.querystring) as Filter[]; const filters = JSON.parse(queryFilter.payload) as Filter[];
if (Array.isArray(filters)) { if (Array.isArray(filters)) {
shouldShow = checkShouldShowMemoWithFilters(memo, filters); shouldShow = checkShouldShowMemoWithFilters(memo, filters);
} }
...@@ -83,7 +83,7 @@ const MemoList: React.FC<Props> = () => { ...@@ -83,7 +83,7 @@ const MemoList: React.FC<Props> = () => {
setFetchStatus(false); setFetchStatus(false);
}) })
.catch(() => { .catch(() => {
toastHelper.error("😭 请求数据失败了"); toastHelper.error("😭 Refresh failed, please try again later.");
}); });
}, []); }, []);
...@@ -111,7 +111,13 @@ const MemoList: React.FC<Props> = () => { ...@@ -111,7 +111,13 @@ const MemoList: React.FC<Props> = () => {
))} ))}
<div className="status-text-container"> <div className="status-text-container">
<p className="status-text"> <p className="status-text">
{isFetching ? "努力请求数据中..." : shownMemos.length === 0 ? "空空如也" : showMemoFilter ? "" : "所有数据加载完啦 🎉"} {isFetching
? "Fetching data..."
: shownMemos.length === 0
? "Oops, there is nothing"
: showMemoFilter
? ""
: "Fetching completed 🎉"}
</p> </p>
</div> </div>
</div> </div>
......
import { useCallback, useContext, useEffect, useState } from "react"; import { useCallback, useContext, useEffect, useState } from "react";
import appContext from "../stores/appContext"; import appContext from "../stores/appContext";
import SearchBar from "./SearchBar"; import SearchBar from "./SearchBar";
import { globalStateService, memoService, queryService } from "../services"; import { globalStateService, memoService, shortcutService } from "../services";
import Only from "./common/OnlyWhen"; import Only from "./common/OnlyWhen";
import "../less/memos-header.less"; import "../less/memos-header.less";
...@@ -12,22 +12,22 @@ interface Props {} ...@@ -12,22 +12,22 @@ interface Props {}
const MemosHeader: React.FC<Props> = () => { const MemosHeader: React.FC<Props> = () => {
const { const {
locationState: { locationState: {
query: { filter }, query: { shortcutId },
}, },
globalState: { isMobileView }, globalState: { isMobileView },
queryState: { queries }, shortcutState: { shortcuts },
} = useContext(appContext); } = useContext(appContext);
const [titleText, setTitleText] = useState("MEMOS"); const [titleText, setTitleText] = useState("MEMOS");
useEffect(() => { useEffect(() => {
const query = queryService.getQueryById(filter); const query = shortcutService.getShortcutById(shortcutId);
if (query) { if (query) {
setTitleText(query.title); setTitleText(query.title);
} else { } else {
setTitleText("MEMOS"); setTitleText("MEMOS");
} }
}, [filter, queries]); }, [shortcutId, shortcuts]);
const handleMemoTextClick = useCallback(() => { const handleMemoTextClick = useCallback(() => {
const now = Date.now(); const now = Date.now();
......
...@@ -49,16 +49,16 @@ const MenuBtnsPopup: React.FC<Props> = (props: Props) => { ...@@ -49,16 +49,16 @@ const MenuBtnsPopup: React.FC<Props> = (props: Props) => {
return ( return (
<div className={`menu-btns-popup ${shownStatus ? "" : "hidden"}`} ref={popupElRef}> <div className={`menu-btns-popup ${shownStatus ? "" : "hidden"}`} ref={popupElRef}>
<button className="btn action-btn" onClick={handleMyAccountBtnClick}> <button className="btn action-btn" onClick={handleMyAccountBtnClick}>
<span className="icon">👤</span> 账号与设置 <span className="icon">👤</span> Settings
</button> </button>
<button className="btn action-btn" onClick={handleMemosTrashBtnClick}> <button className="btn action-btn" onClick={handleMemosTrashBtnClick}>
<span className="icon">🗑️</span> 回收站 <span className="icon">🗑️</span> Recycle Bin
</button> </button>
<button className="btn action-btn" onClick={handleAboutBtnClick}> <button className="btn action-btn" onClick={handleAboutBtnClick}>
<span className="icon">🤠</span> 关于 <span className="icon">🤠</span> About
</button> </button>
<button className="btn action-btn" onClick={handleSignOutBtnClick}> <button className="btn action-btn" onClick={handleSignOutBtnClick}>
<span className="icon">👋</span> 退出 <span className="icon">👋</span> Sign out
</button> </button>
</div> </div>
); );
......
...@@ -20,8 +20,8 @@ interface Props {} ...@@ -20,8 +20,8 @@ interface Props {}
const MyAccountSection: React.FC<Props> = () => { const MyAccountSection: React.FC<Props> = () => {
const { userState } = useContext(appContext); const { userState } = useContext(appContext);
const user = userState.user as Model.User; const user = userState.user as Model.User;
const [username, setUsername] = useState<string>(user.username); const [username, setUsername] = useState<string>(user.name);
const openAPIRoute = `${window.location.origin}/api/whs/memo/${user.openId}`; const openAPIRoute = `${window.location.origin}/h/${user.openId}/memo`;
const handleUsernameChanged = (e: React.ChangeEvent<HTMLInputElement>) => { const handleUsernameChanged = (e: React.ChangeEvent<HTMLInputElement>) => {
const nextUsername = e.target.value as string; const nextUsername = e.target.value as string;
...@@ -29,18 +29,18 @@ const MyAccountSection: React.FC<Props> = () => { ...@@ -29,18 +29,18 @@ const MyAccountSection: React.FC<Props> = () => {
}; };
const handleConfirmEditUsernameBtnClick = async () => { const handleConfirmEditUsernameBtnClick = async () => {
if (user.username === "guest") { if (user.name === "guest") {
toastHelper.info("🈲 不要修改我的用户名"); toastHelper.info("Do not change my username");
return; return;
} }
if (username === user.username) { if (username === user.name) {
return; return;
} }
const usernameValidResult = validate(username, validateConfig); const usernameValidResult = validate(username, validateConfig);
if (!usernameValidResult.result) { if (!usernameValidResult.result) {
toastHelper.error("用户名 " + usernameValidResult.reason); toastHelper.error("Username " + usernameValidResult.reason);
return; return;
} }
...@@ -48,21 +48,21 @@ const MyAccountSection: React.FC<Props> = () => { ...@@ -48,21 +48,21 @@ const MyAccountSection: React.FC<Props> = () => {
const isUsable = await userService.checkUsernameUsable(username); const isUsable = await userService.checkUsernameUsable(username);
if (!isUsable) { if (!isUsable) {
toastHelper.error("用户名无法使用"); toastHelper.error("Username is not available");
return; return;
} }
await userService.updateUsername(username); await userService.updateUsername(username);
await userService.doSignIn(); await userService.doSignIn();
toastHelper.info("修改成功~"); toastHelper.info("Username changed");
} catch (error: any) { } catch (error: any) {
toastHelper.error(error.message); toastHelper.error(error.message);
} }
}; };
const handleChangePasswordBtnClick = () => { const handleChangePasswordBtnClick = () => {
if (user.username === "guest") { if (user.name === "guest") {
toastHelper.info("🈲 不要修改我的密码"); toastHelper.info("Do not change my password");
return; return;
} }
...@@ -81,47 +81,47 @@ const MyAccountSection: React.FC<Props> = () => { ...@@ -81,47 +81,47 @@ const MyAccountSection: React.FC<Props> = () => {
return ( return (
<> <>
<div className="section-container account-section-container"> <div className="section-container account-section-container">
<p className="title-text">基本信息</p> <p className="title-text">Account Information</p>
<label className="form-label input-form-label"> <label className="form-label input-form-label">
<span className="normal-text">ID</span> <span className="normal-text">ID:</span>
<span className="normal-text">{user.id}</span> <span className="normal-text">{user.id}</span>
</label> </label>
<label className="form-label input-form-label"> <label className="form-label input-form-label">
<span className="normal-text">创建时间:</span> <span className="normal-text">Created at:</span>
<span className="normal-text">{utils.getDateString(user.createdAt)}</span> <span className="normal-text">{utils.getDateString(user.createdAt)}</span>
</label> </label>
<label className="form-label input-form-label username-label"> <label className="form-label input-form-label username-label">
<span className="normal-text">账号:</span> <span className="normal-text">Username:</span>
<input type="text" value={username} onChange={handleUsernameChanged} /> <input type="text" value={username} onChange={handleUsernameChanged} />
<div className={`btns-container ${username === user.username ? "hidden" : ""}`} onClick={handlePreventDefault}> <div className={`btns-container ${username === user.name ? "hidden" : ""}`} onClick={handlePreventDefault}>
<span className="btn confirm-btn" onClick={handleConfirmEditUsernameBtnClick}> <span className="btn confirm-btn" onClick={handleConfirmEditUsernameBtnClick}>
保存 Save
</span> </span>
<span <span
className="btn cancel-btn" className="btn cancel-btn"
onClick={() => { onClick={() => {
setUsername(user.username); setUsername(user.name);
}} }}
> >
撤销 Cancel
</span> </span>
</div> </div>
</label> </label>
<label className="form-label password-label"> <label className="form-label password-label">
<span className="normal-text">密码:</span> <span className="normal-text">Password:</span>
<span className="btn" onClick={handleChangePasswordBtnClick}> <span className="btn" onClick={handleChangePasswordBtnClick}>
修改密码 Change It
</span> </span>
</label> </label>
</div> </div>
<div className="section-container openapi-section-container"> <div className="section-container openapi-section-container">
<p className="title-text">Open API(实验性功能)</p> <p className="title-text">Open API (Experimental feature)</p>
<p className="value-text">{openAPIRoute}</p> <p className="value-text">{openAPIRoute}</p>
<span className="reset-btn" onClick={handleResetOpenIdBtnClick}> <span className="reset-btn" onClick={handleResetOpenIdBtnClick}>
重置 API Reset API
</span> </span>
<div className="usage-guide-container"> <div className="usage-guide-container">
<p className="title-text">使用方法:</p> <p className="title-text">Usage guide:</p>
<pre>{`POST ${openAPIRoute}\nContent-type: application/json\n{\n "content": "Hello, #memos ${window.location.origin}"\n}`}</pre> <pre>{`POST ${openAPIRoute}\nContent-type: application/json\n{\n "content": "Hello, #memos ${window.location.origin}"\n}`}</pre>
</div> </div>
</div> </div>
......
...@@ -11,7 +11,7 @@ const PreferencesSection: React.FC<Props> = () => { ...@@ -11,7 +11,7 @@ const PreferencesSection: React.FC<Props> = () => {
const { globalState } = useContext(appContext); const { globalState } = useContext(appContext);
const { useTinyUndoHistoryCache, shouldHideImageUrl, shouldSplitMemoWord, shouldUseMarkdownParser } = globalState; const { useTinyUndoHistoryCache, shouldHideImageUrl, shouldSplitMemoWord, shouldUseMarkdownParser } = globalState;
const demoMemoContent = "👋 你好呀~欢迎使用memos!\n* ✨ **开源项目**;\n* 😋 精美且细节的视觉样式;\n* 📑 体验优良的交互逻辑;"; const demoMemoContent = "👋 Hiya, welcome to memos!\n* ✨ **Open source project**;\n* 😋 What do you think;\n* 📑 Tell me something plz;";
const handleOpenTinyUndoChanged = () => { const handleOpenTinyUndoChanged = () => {
globalStateService.setAppSetting({ globalStateService.setAppSetting({
...@@ -65,29 +65,29 @@ const PreferencesSection: React.FC<Props> = () => { ...@@ -65,29 +65,29 @@ const PreferencesSection: React.FC<Props> = () => {
return ( return (
<> <>
<div className="section-container preferences-section-container"> <div className="section-container preferences-section-container">
<p className="title-text">Memo 显示相关</p> <p className="title-text">Memo Display</p>
<div <div
className="demo-content-container memo-content-text" className="demo-content-container memo-content-text"
dangerouslySetInnerHTML={{ __html: formatMemoContent(demoMemoContent) }} dangerouslySetInnerHTML={{ __html: formatMemoContent(demoMemoContent) }}
></div> ></div>
<label className="form-label checkbox-form-label" onClick={handleSplitWordsValueChanged}> <label className="form-label checkbox-form-label hidden" onClick={handleSplitWordsValueChanged}>
<span className="normal-text">中英文内容自动间隔</span> <span className="normal-text">Auto-space in English and Chinese</span>
<img className="icon-img" src={shouldSplitMemoWord ? "/icons/checkbox-active.svg" : "/icons/checkbox.svg"} /> <img className="icon-img" src={shouldSplitMemoWord ? "/icons/checkbox-active.svg" : "/icons/checkbox.svg"} />
</label> </label>
<label className="form-label checkbox-form-label" onClick={handleUseMarkdownParserChanged}> <label className="form-label checkbox-form-label" onClick={handleUseMarkdownParserChanged}>
<span className="normal-text">部分 markdown 格式解析</span> <span className="normal-text">Partial markdown format parsing</span>
<img className="icon-img" src={shouldUseMarkdownParser ? "/icons/checkbox-active.svg" : "/icons/checkbox.svg"} /> <img className="icon-img" src={shouldUseMarkdownParser ? "/icons/checkbox-active.svg" : "/icons/checkbox.svg"} />
</label> </label>
<label className="form-label checkbox-form-label" onClick={handleHideImageUrlValueChanged}> <label className="form-label checkbox-form-label" onClick={handleHideImageUrlValueChanged}>
<span className="normal-text">隐藏图片链接地址</span> <span className="normal-text">Hide image url</span>
<img className="icon-img" src={shouldHideImageUrl ? "/icons/checkbox-active.svg" : "/icons/checkbox.svg"} /> <img className="icon-img" src={shouldHideImageUrl ? "/icons/checkbox-active.svg" : "/icons/checkbox.svg"} />
</label> </label>
</div> </div>
<div className="section-container preferences-section-container"> <div className="section-container preferences-section-container">
<p className="title-text">编辑器</p> <p className="title-text">Editor Extensions</p>
<label className="form-label checkbox-form-label" onClick={handleOpenTinyUndoChanged}> <label className="form-label checkbox-form-label" onClick={handleOpenTinyUndoChanged}>
<span className="normal-text"> <span className="normal-text">
启用{" "} Use{" "}
<a target="_blank" href="https://github.com/boojack/tiny-undo" onClick={(e) => e.stopPropagation()} rel="noreferrer"> <a target="_blank" href="https://github.com/boojack/tiny-undo" onClick={(e) => e.stopPropagation()} rel="noreferrer">
tiny-undo tiny-undo
</a> </a>
...@@ -96,13 +96,13 @@ const PreferencesSection: React.FC<Props> = () => { ...@@ -96,13 +96,13 @@ const PreferencesSection: React.FC<Props> = () => {
</label> </label>
</div> </div>
<div className="section-container"> <div className="section-container">
<p className="title-text">其他</p> <p className="title-text">Others</p>
<div className="w-full flex flex-row justify-start items-center"> <div className="w-full flex flex-row justify-start items-center">
<button className="px-2 py-1 border rounded text-base hover:opacity-80" onClick={handleExportBtnClick}> <button className="px-2 py-1 border rounded text-base hover:opacity-80" onClick={handleExportBtnClick}>
导出数据(JSON) Export data as JSON
</button> </button>
<button className="btn format-btn hidden" onClick={handleFormatMemosBtnClick}> <button className="btn format-btn hidden" onClick={handleFormatMemosBtnClick}>
格式化数据 Format Data
</button> </button>
</div> </div>
</div> </div>
......
...@@ -43,8 +43,8 @@ const PreviewImageDialog: React.FC<Props> = ({ destroy, imgUrl }: Props) => { ...@@ -43,8 +43,8 @@ const PreviewImageDialog: React.FC<Props> = ({ destroy, imgUrl }: Props) => {
<div className="img-container"> <div className="img-container">
<img className={imgWidth <= 0 ? "hidden" : ""} ref={imgRef} width={imgWidth + "%"} src={imgUrl} /> <img className={imgWidth <= 0 ? "hidden" : ""} ref={imgRef} width={imgWidth + "%"} src={imgUrl} />
<span className={"loading-text " + (imgWidth === -1 ? "" : "hidden")}>图片加载中...</span> <span className={"loading-text " + (imgWidth === -1 ? "" : "hidden")}>Loading image...</span>
<span className={"loading-text " + (imgWidth === 0 ? "" : "hidden")}>😟 图片加载失败,可能是无效的链接</span> <span className={"loading-text " + (imgWidth === 0 ? "" : "hidden")}>😟 Failed to load image</span>
</div> </div>
<div className="action-btns-container"> <div className="action-btns-container">
......
...@@ -36,7 +36,7 @@ const SearchBar: React.FC<Props> = () => { ...@@ -36,7 +36,7 @@ const SearchBar: React.FC<Props> = () => {
<div className="quickly-action-container"> <div className="quickly-action-container">
<p className="title-text">QUICKLY FILTER</p> <p className="title-text">QUICKLY FILTER</p>
<div className="section-container types-container"> <div className="section-container types-container">
<span className="section-text">类型:</span> <span className="section-text">Type:</span>
<div className="values-container"> <div className="values-container">
{memoSpecialTypes.map((t, idx) => { {memoSpecialTypes.map((t, idx) => {
return ( return (
......
...@@ -65,7 +65,7 @@ const ShareMemoImageDialog: React.FC<Props> = (props: Props) => { ...@@ -65,7 +65,7 @@ const ShareMemoImageDialog: React.FC<Props> = (props: Props) => {
<> <>
<div className="dialog-header-container"> <div className="dialog-header-container">
<p className="title-text"> <p className="title-text">
<span className="icon-text">🥰</span>分享 Memo 图片 <span className="icon-text">🥰</span>Share Memo
</p> </p>
<button className="btn close-btn" onClick={handleCloseBtnClick}> <button className="btn close-btn" onClick={handleCloseBtnClick}>
<img className="icon-img" src="/icons/close.svg" /> <img className="icon-img" src="/icons/close.svg" />
...@@ -73,7 +73,7 @@ const ShareMemoImageDialog: React.FC<Props> = (props: Props) => { ...@@ -73,7 +73,7 @@ const ShareMemoImageDialog: React.FC<Props> = (props: Props) => {
</div> </div>
<div className="dialog-content-container"> <div className="dialog-content-container">
<div className={`tip-words-container ${shortcutImgUrl ? "finish" : "loading"}`}> <div className={`tip-words-container ${shortcutImgUrl ? "finish" : "loading"}`}>
<p className="tip-text">{shortcutImgUrl ? "右键或长按即可保存图片 👇" : "图片生成中..."}</p> <p className="tip-text">{shortcutImgUrl ? "Right click or long press to save image 👇" : "Generating the screenshot..."}</p>
</div> </div>
<div className="memo-container" ref={memoElRef}> <div className="memo-container" ref={memoElRef}>
<Only when={shortcutImgUrl !== ""}> <Only when={shortcutImgUrl !== ""}>
...@@ -97,7 +97,7 @@ const ShareMemoImageDialog: React.FC<Props> = (props: Props) => { ...@@ -97,7 +97,7 @@ const ShareMemoImageDialog: React.FC<Props> = (props: Props) => {
</Only> </Only>
<div className="watermark-container"> <div className="watermark-container">
<span className="normal-text"> <span className="normal-text">
<span className="icon-text">✍️</span> by <span className="name-text">{userinfo?.username}</span> <span className="icon-text">✍️</span> by <span className="name-text">{userinfo?.name}</span>
</span> </span>
</div> </div>
</div> </div>
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment