diff --git a/server/tag.go b/server/tag.go index 3cd7e17c..5b139db0 100644 --- a/server/tag.go +++ b/server/tag.go @@ -2,20 +2,85 @@ package server import ( "encoding/json" + "fmt" "net/http" "regexp" "sort" "strconv" "github.com/usememos/memos/api" + "github.com/usememos/memos/common" + metric "github.com/usememos/memos/plugin/metrics" "github.com/labstack/echo/v4" ) -var tagRegexpList = []*regexp.Regexp{regexp.MustCompile(`^#([^\s#]+?) `), regexp.MustCompile(`^#([^\s#]+?)\s`), regexp.MustCompile(`[^\S]#([^\s#]+?)$`), regexp.MustCompile(`[^\S]#([^\s#]+?) `), regexp.MustCompile(` #([^\s#]+?) `)} - func (s *Server) registerTagRoutes(g *echo.Group) { + g.POST("/tag", func(c echo.Context) error { + ctx := c.Request().Context() + userID, ok := c.Get(getUserIDContextKey()).(int) + if !ok { + return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session") + } + + tagUpsert := &api.TagUpsert{ + CreatorID: userID, + } + if err := json.NewDecoder(c.Request().Body).Decode(tagUpsert); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post tag request").SetInternal(err) + } + if tagUpsert.Name == "" { + return echo.NewHTTPError(http.StatusBadRequest, "Tag name shouldn't be empty") + } + + tag, err := s.Store.UpsertTag(ctx, tagUpsert) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert tag").SetInternal(err) + } + s.Collector.Collect(ctx, &metric.Metric{ + Name: "tag created", + }) + + c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8) + if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(tag.Name)); err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode tag response").SetInternal(err) + } + return nil + }) + g.GET("/tag", func(c echo.Context) error { + ctx := c.Request().Context() + tagFind := &api.TagFind{} + if userID, err := strconv.Atoi(c.QueryParam("creatorId")); err == nil { + tagFind.CreatorID = userID + } + + if tagFind.CreatorID == 0 { + currentUserID, ok := c.Get(getUserIDContextKey()).(int) + if !ok { + return echo.NewHTTPError(http.StatusBadRequest, "Missing user id to find tag") + } + tagFind.CreatorID = currentUserID + } + + tagList, err := s.Store.FindTagList(ctx, tagFind) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find tag list").SetInternal(err) + } + + tagNameList := []string{} + for _, tag := range tagList { + tagNameList = append(tagNameList, tag.Name) + } + + c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8) + if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(tagNameList)); err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode tags response").SetInternal(err) + } + return nil + }) + + g.GET("/tag/suggestion", func(c echo.Context) error { ctx := c.Request().Context() contentSearch := "#" normalRowStatus := api.Normal @@ -65,15 +130,42 @@ func (s *Server) registerTagRoutes(g *echo.Group) { } return nil }) + + g.DELETE("/tag/:tagName", func(c echo.Context) error { + ctx := c.Request().Context() + userID, ok := c.Get(getUserIDContextKey()).(int) + if !ok { + return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session") + } + + tagName := c.Param("tagName") + if tagName == "" { + return echo.NewHTTPError(http.StatusBadRequest, "Tag name cannot be empty") + } + + tagDelete := &api.TagDelete{ + Name: tagName, + CreatorID: userID, + } + if err := s.Store.DeleteTag(ctx, tagDelete); err != nil { + if common.ErrorCode(err) == common.NotFound { + return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Tag name not found: %s", tagName)) + } + return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to delete tag name: %v", tagName)).SetInternal(err) + } + + return c.JSON(http.StatusOK, true) + }) } +var tagRegexp = regexp.MustCompile(`#([^\s#]+)`) + func findTagListFromMemoContent(memoContent string) []string { tagMapSet := make(map[string]bool) - for _, tagRegexp := range tagRegexpList { - for _, rawTag := range tagRegexp.FindAllString(memoContent, -1) { - tag := tagRegexp.ReplaceAllString(rawTag, "$1") - tagMapSet[tag] = true - } + matches := tagRegexp.FindAllStringSubmatch(memoContent, -1) + for _, v := range matches { + tagName := v[1] + tagMapSet[tagName] = true } tagList := []string{} diff --git a/server/tag_test.go b/server/tag_test.go index 8762356f..a05edb83 100644 --- a/server/tag_test.go +++ b/server/tag_test.go @@ -31,11 +31,11 @@ func TestFindTagListFromMemoContent(t *testing.T) { }, { memoContent: "#tag1 123123#tag2 \n#tag3 #tag4 ", - want: []string{"tag1", "tag3", "tag4"}, + want: []string{"tag1", "tag2", "tag3", "tag4"}, }, { memoContent: "#tag1 http://123123.com?123123#tag2 \n#tag3 #tag4 http://123123.com?123123#tag2) ", - want: []string{"tag1", "tag3", "tag4"}, + want: []string{"tag1", "tag2", "tag2)", "tag3", "tag4"}, }, } for _, test := range tests { diff --git a/store/tag.go b/store/tag.go index 4a44ab39..2f8fd09d 100644 --- a/store/tag.go +++ b/store/tag.go @@ -87,7 +87,9 @@ func upsertTag(ctx context.Context, tx *sql.Tx, upsert *api.TagUpsert) (*tagRaw, name, creator_id ) VALUES (?, ?) - ON CONFLICT(name, creator_id) DO NOTHING + ON CONFLICT(name, creator_id) DO UPDATE + SET + name = EXCLUDED.name RETURNING name, creator_id ` var tagRaw tagRaw diff --git a/web/src/components/CreateShortcutDialog.tsx b/web/src/components/CreateShortcutDialog.tsx index eb663622..9d519840 100644 --- a/web/src/components/CreateShortcutDialog.tsx +++ b/web/src/components/CreateShortcutDialog.tsx @@ -1,7 +1,7 @@ import dayjs from "dayjs"; import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import { useMemoStore, useShortcutStore } from "../store/module"; +import { useShortcutStore, useTagStore } from "../store/module"; import { filterConsts, getDefaultFilter, relationConsts } from "../helpers/filter"; import useLoading from "../hooks/useLoading"; import Icon from "./Icon"; @@ -162,9 +162,9 @@ interface MemoFilterInputerProps { const MemoFilterInputer: React.FC = (props: MemoFilterInputerProps) => { const { index, filter, handleFilterChange, handleFilterRemove } = props; const { t } = useTranslation(); - const memoStore = useMemoStore(); + const tagStore = useTagStore(); const [value, setValue] = useState(filter.value.value); - const tags = Array.from(memoStore.getState().tags); + const tags = Array.from(tagStore.getState().tags); const { type } = filter; const typeDataSource = Object.values(filterConsts).map(({ text, value }) => ({ text: t(text), value })); diff --git a/web/src/components/CreateTagDialog.tsx b/web/src/components/CreateTagDialog.tsx new file mode 100644 index 00000000..81c050c5 --- /dev/null +++ b/web/src/components/CreateTagDialog.tsx @@ -0,0 +1,140 @@ +import { TextField } from "@mui/joy"; +import { useEffect, useState } from "react"; +import { useTagStore } from "../store/module"; +import { getTagSuggestionList } from "../helpers/api"; +import Tag from "../labs/marked/parser/Tag"; +import Icon from "./Icon"; +import toastHelper from "./Toast"; +import { generateDialog } from "./Dialog"; + +type Props = DialogProps; + +const validateTagName = (tagName: string): boolean => { + const matchResult = Tag.matcher(`#${tagName}`); + if (!matchResult || matchResult[1] !== tagName) { + return false; + } + return true; +}; + +const CreateTagDialog: React.FC = (props: Props) => { + const { destroy } = props; + const tagStore = useTagStore(); + const [tagName, setTagName] = useState(""); + const [suggestTagNameList, setSuggestTagNameList] = useState([]); + const tagNameList = tagStore.state.tags; + + useEffect(() => { + getTagSuggestionList().then(({ data }) => { + setSuggestTagNameList(data.data.filter((tag) => !tagNameList.includes(tag) && validateTagName(tag))); + }); + }, [tagNameList]); + + const handleTagNameChanged = (e: React.ChangeEvent) => { + const tagName = e.target.value as string; + setTagName(tagName.trim()); + }; + + const handleRemoveSuggestTag = (tag: string) => { + setSuggestTagNameList(suggestTagNameList.filter((item) => item !== tag)); + }; + + const handleSaveBtnClick = async () => { + if (!validateTagName(tagName)) { + toastHelper.error("Invalid tag name"); + return; + } + + try { + await tagStore.upsertTag(tagName); + } catch (error: any) { + console.error(error); + toastHelper.error(error.response.data.message); + } + }; + + const handleDeleteTag = async (tag: string) => { + await tagStore.deleteTag(tag); + }; + + const handleSaveSuggestTagList = async () => { + for (const tagName of suggestTagNameList) { + if (validateTagName(tagName)) { + await tagStore.upsertTag(tagName); + } + } + }; + + return ( + <> +
+

Create Tag

+ +
+
+ } + endDecorator={} + /> + {tagNameList.length > 0 && ( + <> +

All tags

+
+ {tagNameList.map((tag) => ( + handleDeleteTag(tag)} + > + #{tag} + + ))} +
+ + )} + + {suggestTagNameList.length > 0 && ( + <> +

Tag suggestions

+
+ {suggestTagNameList.map((tag) => ( + handleRemoveSuggestTag(tag)} + > + #{tag} + + ))} +
+ + + )} +
+ + ); +}; + +function showCreateTagDialog() { + generateDialog( + { + className: "create-tag-dialog", + dialogName: "create-tag-dialog", + }, + CreateTagDialog + ); +} + +export default showCreateTagDialog; diff --git a/web/src/components/MemoEditor.tsx b/web/src/components/MemoEditor.tsx index de4fb3f3..968b9b0e 100644 --- a/web/src/components/MemoEditor.tsx +++ b/web/src/components/MemoEditor.tsx @@ -3,7 +3,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react" import { useTranslation } from "react-i18next"; import { deleteMemoResource, upsertMemoResource } from "../helpers/api"; import { TAB_SPACE_WIDTH, UNKNOWN_ID, VISIBILITY_SELECTOR_ITEMS } from "../helpers/consts"; -import { useEditorStore, useLocationStore, useMemoStore, useResourceStore, useUserStore } from "../store/module"; +import { useEditorStore, useLocationStore, useMemoStore, useResourceStore, useTagStore, useUserStore } from "../store/module"; import * as storage from "../helpers/storage"; import Icon from "./Icon"; import toastHelper from "./Toast"; @@ -44,6 +44,7 @@ const MemoEditor = () => { const editorStore = useEditorStore(); const locationStore = useLocationStore(); const memoStore = useMemoStore(); + const tagStore = useTagStore(); const resourceStore = useResourceStore(); const [state, setState] = useState({ @@ -57,7 +58,7 @@ const MemoEditor = () => { const tagSelectorRef = useRef(null); const user = userStore.state.user as User; const setting = user.setting; - const tags = memoStore.state.tags; + const tags = tagStore.state.tags; const memoVisibilityOptionSelectorItems = VISIBILITY_SELECTOR_ITEMS.map((item) => { return { value: item.value, diff --git a/web/src/components/TagList.tsx b/web/src/components/TagList.tsx index 0613ed93..5e19db09 100644 --- a/web/src/components/TagList.tsx +++ b/web/src/components/TagList.tsx @@ -1,8 +1,9 @@ import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import { useLocationStore, useMemoStore, useUserStore } from "../store/module"; +import { useLocationStore, useTagStore, useUserStore } from "../store/module"; import useToggle from "../hooks/useToggle"; import Icon from "./Icon"; +import showCreateTagDialog from "./CreateTagDialog"; import "../less/tag-list.less"; interface Tag { @@ -15,16 +16,14 @@ const TagList = () => { const { t } = useTranslation(); const locationStore = useLocationStore(); const userStore = useUserStore(); - const memoStore = useMemoStore(); - const { memos, tags: tagsText } = memoStore.state; + const tagStore = useTagStore(); + const tagsText = tagStore.state.tags; const query = locationStore.state.query; const [tags, setTags] = useState([]); useEffect(() => { - if (memos.length > 0) { - memoStore.updateTagsState(); - } - }, [memos]); + tagStore.fetchTags(); + }, []); useEffect(() => { const sortedTags = Array.from(tagsText).sort(); @@ -72,7 +71,15 @@ const TagList = () => { return (
-

{t("common.tags")}

+
+ {t("common.tags")} + +
{tags.map((t, idx) => ( diff --git a/web/src/components/UserBanner.tsx b/web/src/components/UserBanner.tsx index 616826c7..cd4176aa 100644 --- a/web/src/components/UserBanner.tsx +++ b/web/src/components/UserBanner.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; -import { useLocationStore, useMemoStore, useUserStore } from "../store/module"; +import { useLocationStore, useMemoStore, useTagStore, useUserStore } from "../store/module"; import { getMemoStats } from "../helpers/api"; import * as utils from "../helpers/utils"; import Icon from "./Icon"; @@ -17,8 +17,10 @@ const UserBanner = () => { const locationStore = useLocationStore(); const userStore = useUserStore(); const memoStore = useMemoStore(); + const tagStore = useTagStore(); const { user, owner } = userStore.state; - const { memos, tags } = memoStore.state; + const { memos } = memoStore.state; + const tags = tagStore.state.tags; const [username, setUsername] = useState("Memos"); const [memoAmount, setMemoAmount] = useState(0); const [createdDays, setCreatedDays] = useState(0); diff --git a/web/src/helpers/api.ts b/web/src/helpers/api.ts index 9fd11f03..d6529ee7 100644 --- a/web/src/helpers/api.ts +++ b/web/src/helpers/api.ts @@ -187,6 +187,20 @@ export function getTagList(tagFind?: TagFind) { return axios.get>(`/api/tag?${queryList.join("&")}`); } +export function getTagSuggestionList() { + return axios.get>(`/api/tag/suggestion`); +} + +export function upsertTag(tagName: string) { + return axios.post>(`/api/tag`, { + name: tagName, + }); +} + +export function deleteTag(tagName: string) { + return axios.delete>(`/api/tag/${tagName}`); +} + export async function getRepoStarCount() { const { data } = await axios.get(`https://api.github.com/repos/usememos/memos`, { headers: { diff --git a/web/src/less/tag-list.less b/web/src/less/tag-list.less index 57c8be41..dd0f233f 100644 --- a/web/src/less/tag-list.less +++ b/web/src/less/tag-list.less @@ -1,10 +1,6 @@ .tags-wrapper { @apply flex flex-col justify-start items-start px-2 w-full h-auto flex-nowrap pb-4 mt-2 grow hide-scrollbar; - > .title-text { - @apply w-full px-4 text-sm leading-6 font-mono text-gray-400; - } - > .tags-container { @apply flex flex-col justify-start items-start relative w-full h-auto flex-nowrap mb-2 mt-1; diff --git a/web/src/store/index.ts b/web/src/store/index.ts index e08ecfae..f19af158 100644 --- a/web/src/store/index.ts +++ b/web/src/store/index.ts @@ -8,12 +8,14 @@ import shortcutReducer from "./reducer/shortcut"; import locationReducer from "./reducer/location"; import resourceReducer from "./reducer/resource"; import dialogReducer from "./reducer/dialog"; +import tagReducer from "./reducer/tag"; const store = configureStore({ reducer: { global: globalReducer, user: userReducer, memo: memoReducer, + tag: tagReducer, editor: editorReducer, shortcut: shortcutReducer, location: locationReducer, diff --git a/web/src/store/module/index.ts b/web/src/store/module/index.ts index 72614a90..803eb374 100644 --- a/web/src/store/module/index.ts +++ b/web/src/store/module/index.ts @@ -2,6 +2,7 @@ export * from "./editor"; export * from "./global"; export * from "./location"; export * from "./memo"; +export * from "./tag"; export * from "./resource"; export * from "./shortcut"; export * from "./user"; diff --git a/web/src/store/module/memo.ts b/web/src/store/module/memo.ts index 4da129ce..f4cd3086 100644 --- a/web/src/store/module/memo.ts +++ b/web/src/store/module/memo.ts @@ -3,7 +3,7 @@ import * as api from "../../helpers/api"; import { DEFAULT_MEMO_LIMIT } from "../../helpers/consts"; import { useUserStore } from "./"; import store, { useAppSelector } from "../"; -import { createMemo, deleteMemo, patchMemo, setIsFetching, setMemos, setTags } from "../reducer/memo"; +import { createMemo, deleteMemo, patchMemo, setIsFetching, setMemos } from "../reducer/memo"; const convertResponseModelMemo = (memo: Memo): Memo => { return { @@ -85,14 +85,6 @@ export const useMemoStore = () => { return await fetchMemoById(memoId); }, - updateTagsState: async () => { - const tagFind: TagFind = {}; - if (userStore.isVisitorMode()) { - tagFind.creatorId = userStore.getUserIdFromPath(); - } - const { data } = (await api.getTagList(tagFind)).data; - store.dispatch(setTags(data)); - }, getLinkedMemos: async (memoId: MemoId): Promise => { const regex = new RegExp(`[@(.+?)](${memoId})`); return state.memos.filter((m) => m.content.match(regex)); diff --git a/web/src/store/module/tag.ts b/web/src/store/module/tag.ts new file mode 100644 index 00000000..661305c3 --- /dev/null +++ b/web/src/store/module/tag.ts @@ -0,0 +1,31 @@ +import store, { useAppSelector } from ".."; +import * as api from "../../helpers/api"; +import { deleteTag, setTags, upsertTag } from "../reducer/tag"; +import { useUserStore } from "./"; + +export const useTagStore = () => { + const state = useAppSelector((state) => state.tag); + const userStore = useUserStore(); + return { + state, + getState: () => { + return store.getState().tag; + }, + fetchTags: async () => { + const tagFind: TagFind = {}; + if (userStore.isVisitorMode()) { + tagFind.creatorId = userStore.getUserIdFromPath(); + } + const { data } = (await api.getTagList(tagFind)).data; + store.dispatch(setTags(data)); + }, + upsertTag: async (tagName: string) => { + await api.upsertTag(tagName); + store.dispatch(upsertTag(tagName)); + }, + deleteTag: async (tagName: string) => { + await api.deleteTag(tagName); + store.dispatch(deleteTag(tagName)); + }, + }; +}; diff --git a/web/src/store/reducer/memo.ts b/web/src/store/reducer/memo.ts index 82473a22..c558cc17 100644 --- a/web/src/store/reducer/memo.ts +++ b/web/src/store/reducer/memo.ts @@ -2,7 +2,6 @@ import { createSlice, PayloadAction } from "@reduxjs/toolkit"; interface State { memos: Memo[]; - tags: string[]; isFetching: boolean; } @@ -10,7 +9,6 @@ const memoSlice = createSlice({ name: "memo", initialState: { memos: [], - tags: [], // isFetching flag should starts with true. isFetching: true, } as State, @@ -52,12 +50,6 @@ const memoSlice = createSlice({ }), }; }, - setTags: (state, action: PayloadAction) => { - return { - ...state, - tags: action.payload, - }; - }, setIsFetching: (state, action: PayloadAction) => { return { ...state, @@ -67,6 +59,6 @@ const memoSlice = createSlice({ }, }); -export const { setMemos, createMemo, patchMemo, deleteMemo, setTags, setIsFetching } = memoSlice.actions; +export const { setMemos, createMemo, patchMemo, deleteMemo, setIsFetching } = memoSlice.actions; export default memoSlice.reducer; diff --git a/web/src/store/reducer/tag.ts b/web/src/store/reducer/tag.ts new file mode 100644 index 00000000..db8d94c1 --- /dev/null +++ b/web/src/store/reducer/tag.ts @@ -0,0 +1,42 @@ +import { createSlice, PayloadAction } from "@reduxjs/toolkit"; + +interface State { + tags: string[]; +} + +const tagSlice = createSlice({ + name: "tag", + initialState: { + tags: [], + } as State, + reducers: { + setTags: (state, action: PayloadAction) => { + return { + ...state, + tags: action.payload, + }; + }, + upsertTag: (state, action: PayloadAction) => { + if (state.tags.includes(action.payload)) { + return state; + } + + return { + ...state, + tags: state.tags.concat(action.payload), + }; + }, + deleteTag: (state, action: PayloadAction) => { + return { + ...state, + tags: state.tags.filter((tag) => { + return tag !== action.payload; + }), + }; + }, + }, +}); + +export const { setTags, upsertTag, deleteTag } = tagSlice.actions; + +export default tagSlice.reducer;