Commit 68c2bd38 authored by Steven's avatar Steven

chore: update memo relations

parent 82da20e1
......@@ -2,16 +2,18 @@ syntax = "proto3";
package memos.api.v1;
import "google/api/field_behavior.proto";
option go_package = "gen/api/v1";
message MemoRelation {
// The name of memo.
// Format: "memos/{uid}"
string memo = 1;
Memo memo = 1;
// The name of related memo.
// Format: "memos/{uid}"
string related_memo = 2;
Memo related_memo = 2;
enum Type {
TYPE_UNSPECIFIED = 0;
......@@ -19,4 +21,13 @@ message MemoRelation {
COMMENT = 2;
}
Type type = 3;
message Memo {
// The name of the memo.
// Format: memos/{id}
string name = 1;
string uid = 2;
// The snippet of the memo content. Plain text only.
string snippet = 3 [(google.api.field_behavior) = OUTPUT_ONLY];
}
}
......@@ -172,7 +172,7 @@ message Memo {
optional string parent = 18 [(google.api.field_behavior) = OUTPUT_ONLY];
// The snippet of the memo content. Plain text only.
string snippet = 19;
string snippet = 19 [(google.api.field_behavior) = OUTPUT_ONLY];
// The location of the memo.
optional Location location = 20;
......
This diff is collapsed.
......@@ -347,7 +347,7 @@ paths:
"200":
description: A successful response.
schema:
$ref: '#/definitions/v1Memo'
$ref: '#/definitions/apiv1Memo'
default:
description: An unexpected error response.
schema:
......@@ -368,7 +368,7 @@ paths:
"200":
description: A successful response.
schema:
$ref: '#/definitions/v1Memo'
$ref: '#/definitions/apiv1Memo'
default:
description: An unexpected error response.
schema:
......@@ -813,7 +813,7 @@ paths:
"200":
description: A successful response.
schema:
$ref: '#/definitions/v1Memo'
$ref: '#/definitions/apiv1Memo'
default:
description: An unexpected error response.
schema:
......@@ -897,6 +897,7 @@ paths:
snippet:
type: string
description: The snippet of the memo content. Plain text only.
readOnly: true
location:
$ref: '#/definitions/apiv1Location'
description: The location of the memo.
......@@ -1056,7 +1057,7 @@ paths:
"200":
description: A successful response.
schema:
$ref: '#/definitions/v1Memo'
$ref: '#/definitions/apiv1Memo'
default:
description: An unexpected error response.
schema:
......@@ -1257,7 +1258,7 @@ paths:
"200":
description: A successful response.
schema:
$ref: '#/definitions/v1Memo'
$ref: '#/definitions/apiv1Memo'
default:
description: An unexpected error response.
schema:
......@@ -1952,6 +1953,82 @@ definitions:
longitude:
type: number
format: double
apiv1Memo:
type: object
properties:
name:
type: string
description: |-
The name of the memo.
Format: memos/{id}
id is the system generated id.
uid:
type: string
description: The user defined id of the memo.
rowStatus:
$ref: '#/definitions/v1RowStatus'
creator:
type: string
title: |-
The name of the creator.
Format: users/{id}
createTime:
type: string
format: date-time
updateTime:
type: string
format: date-time
displayTime:
type: string
format: date-time
content:
type: string
nodes:
type: array
items:
type: object
$ref: '#/definitions/v1Node'
readOnly: true
visibility:
$ref: '#/definitions/v1Visibility'
tags:
type: array
items:
type: string
pinned:
type: boolean
resources:
type: array
items:
type: object
$ref: '#/definitions/v1Resource'
relations:
type: array
items:
type: object
$ref: '#/definitions/v1MemoRelation'
reactions:
type: array
items:
type: object
$ref: '#/definitions/v1Reaction'
readOnly: true
property:
$ref: '#/definitions/v1MemoProperty'
readOnly: true
parent:
type: string
title: |-
The name of the parent memo.
Format: memos/{id}
readOnly: true
snippet:
type: string
description: The snippet of the memo content. Plain text only.
readOnly: true
location:
$ref: '#/definitions/apiv1Location'
description: The location of the memo.
apiv1OAuth2Config:
type: object
properties:
......@@ -2482,7 +2559,7 @@ definitions:
type: array
items:
type: object
$ref: '#/definitions/v1Memo'
$ref: '#/definitions/apiv1Memo'
v1ListMemoReactionsResponse:
type: object
properties:
......@@ -2514,7 +2591,7 @@ definitions:
type: array
items:
type: object
$ref: '#/definitions/v1Memo'
$ref: '#/definitions/apiv1Memo'
nextPageToken:
type: string
description: |-
......@@ -2575,81 +2652,6 @@ definitions:
properties:
content:
type: string
v1Memo:
type: object
properties:
name:
type: string
description: |-
The name of the memo.
Format: memos/{id}
id is the system generated id.
uid:
type: string
description: The user defined id of the memo.
rowStatus:
$ref: '#/definitions/v1RowStatus'
creator:
type: string
title: |-
The name of the creator.
Format: users/{id}
createTime:
type: string
format: date-time
updateTime:
type: string
format: date-time
displayTime:
type: string
format: date-time
content:
type: string
nodes:
type: array
items:
type: object
$ref: '#/definitions/v1Node'
readOnly: true
visibility:
$ref: '#/definitions/v1Visibility'
tags:
type: array
items:
type: string
pinned:
type: boolean
resources:
type: array
items:
type: object
$ref: '#/definitions/v1Resource'
relations:
type: array
items:
type: object
$ref: '#/definitions/v1MemoRelation'
reactions:
type: array
items:
type: object
$ref: '#/definitions/v1Reaction'
readOnly: true
property:
$ref: '#/definitions/v1MemoProperty'
readOnly: true
parent:
type: string
title: |-
The name of the parent memo.
Format: memos/{id}
readOnly: true
snippet:
type: string
description: The snippet of the memo content. Plain text only.
location:
$ref: '#/definitions/apiv1Location'
description: The location of the memo.
v1MemoProperty:
type: object
properties:
......@@ -2669,17 +2671,31 @@ definitions:
type: object
properties:
memo:
type: string
$ref: '#/definitions/v1MemoRelationMemo'
title: |-
The name of memo.
Format: "memos/{uid}"
relatedMemo:
type: string
$ref: '#/definitions/v1MemoRelationMemo'
title: |-
The name of related memo.
Format: "memos/{uid}"
type:
$ref: '#/definitions/v1MemoRelationType'
v1MemoRelationMemo:
type: object
properties:
name:
type: string
title: |-
The name of the memo.
Format: memos/{id}
uid:
type: string
snippet:
type: string
description: The snippet of the memo content. Plain text only.
readOnly: true
v1MemoRelationType:
type: string
enum:
......
......@@ -8,6 +8,7 @@ import (
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/emptypb"
"github.com/pkg/errors"
v1pb "github.com/usememos/memos/proto/gen/api/v1"
"github.com/usememos/memos/store"
)
......@@ -28,7 +29,7 @@ func (s *APIV1Service) SetMemoRelations(ctx context.Context, request *v1pb.SetMe
for _, relation := range request.Relations {
// Ignore reflexive relations.
if request.Name == relation.RelatedMemo {
if request.Name == relation.RelatedMemo.Name {
continue
}
// Ignore comment relations as there's no need to update a comment's relation.
......@@ -36,7 +37,7 @@ func (s *APIV1Service) SetMemoRelations(ctx context.Context, request *v1pb.SetMe
if relation.Type == v1pb.MemoRelation_COMMENT {
continue
}
relatedMemoID, err := ExtractMemoIDFromName(relation.RelatedMemo)
relatedMemoID, err := ExtractMemoIDFromName(relation.RelatedMemo.Name)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid related memo name: %v", err)
}
......@@ -64,8 +65,12 @@ func (s *APIV1Service) ListMemoRelations(ctx context.Context, request *v1pb.List
if err != nil {
return nil, err
}
for _, relation := range tempList {
relationList = append(relationList, convertMemoRelationFromStore(relation))
for _, raw := range tempList {
relation, err := s.convertMemoRelationFromStore(ctx, raw)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to convert memo relation")
}
relationList = append(relationList, relation)
}
tempList, err = s.Store.ListMemoRelations(ctx, &store.FindMemoRelation{
RelatedMemoID: &id,
......@@ -73,8 +78,12 @@ func (s *APIV1Service) ListMemoRelations(ctx context.Context, request *v1pb.List
if err != nil {
return nil, err
}
for _, relation := range tempList {
relationList = append(relationList, convertMemoRelationFromStore(relation))
for _, raw := range tempList {
relation, err := s.convertMemoRelationFromStore(ctx, raw)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to convert memo relation")
}
relationList = append(relationList, relation)
}
response := &v1pb.ListMemoRelationsResponse{
......@@ -83,12 +92,36 @@ func (s *APIV1Service) ListMemoRelations(ctx context.Context, request *v1pb.List
return response, nil
}
func convertMemoRelationFromStore(memoRelation *store.MemoRelation) *v1pb.MemoRelation {
return &v1pb.MemoRelation{
Memo: fmt.Sprintf("%s%d", MemoNamePrefix, memoRelation.MemoID),
RelatedMemo: fmt.Sprintf("%s%d", MemoNamePrefix, memoRelation.RelatedMemoID),
Type: convertMemoRelationTypeFromStore(memoRelation.Type),
func (s *APIV1Service) convertMemoRelationFromStore(ctx context.Context, memoRelation *store.MemoRelation) (*v1pb.MemoRelation, error) {
memo, err := s.Store.GetMemo(ctx, &store.FindMemo{ID: &memoRelation.MemoID})
if err != nil {
return nil, err
}
memoSnippet, err := getMemoContentSnippet(memo.Content)
if err != nil {
return nil, errors.Wrap(err, "failed to get memo content snippet")
}
relatedMemo, err := s.Store.GetMemo(ctx, &store.FindMemo{ID: &memoRelation.RelatedMemoID})
if err != nil {
return nil, err
}
relatedMemoSnippet, err := getMemoContentSnippet(relatedMemo.Content)
if err != nil {
return nil, errors.Wrap(err, "failed to get related memo content snippet")
}
return &v1pb.MemoRelation{
Memo: &v1pb.MemoRelation_Memo{
Name: fmt.Sprintf("%s%d", MemoNamePrefix, memo.ID),
Uid: memo.UID,
Snippet: memoSnippet,
},
RelatedMemo: &v1pb.MemoRelation_Memo{
Name: fmt.Sprintf("%s%d", MemoNamePrefix, relatedMemo.ID),
Uid: relatedMemo.UID,
Snippet: relatedMemoSnippet,
},
Type: convertMemoRelationTypeFromStore(memoRelation.Type),
}, nil
}
func convertMemoRelationTypeFromStore(relationType store.MemoRelationType) v1pb.MemoRelation_Type {
......
......@@ -73,7 +73,7 @@ const EmbeddedMemo = ({ resourceId: uid, params: paramsStr }: Props) => {
</div>
<div className="flex justify-end items-center gap-1">
<span className="text-xs opacity-60 leading-5 cursor-pointer hover:opacity-80" onClick={() => copyMemoUid(memo.uid)}>
{memo.uid.slice(0, 8)}
{memo.uid.slice(0, 6)}
</span>
<Link className="opacity-60 hover:opacity-80" to={`/m/${memo.uid}`} unstable_viewTransition>
<ArrowUpRightIcon className="w-5 h-auto" />
......
......@@ -8,7 +8,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/Popover
import { memoServiceClient } from "@/grpcweb";
import { DEFAULT_LIST_MEMOS_PAGE_SIZE } from "@/helpers/consts";
import useCurrentUser from "@/hooks/useCurrentUser";
import { MemoRelation_Type } from "@/types/proto/api/v1/memo_relation_service";
import { MemoRelation_Memo, MemoRelation_Type } from "@/types/proto/api/v1/memo_relation_service";
import { Memo, MemoView } from "@/types/proto/api/v1/memo_service";
import { useTranslate } from "@/utils/i18n";
import { EditorRefActions } from "../Editor";
......@@ -34,7 +34,7 @@ const AddMemoRelationPopover = (props: Props) => {
(memo) =>
!selectedMemos.includes(memo) &&
memo.name !== context.memoName &&
!context.relationList.some((relation) => relation.relatedMemo === memo.name),
!context.relationList.some((relation) => relation.relatedMemo?.name === memo.name),
);
useDebounce(
......@@ -112,8 +112,8 @@ const AddMemoRelationPopover = (props: Props) => {
uniqBy(
[
...selectedMemos.map((memo) => ({
memo: context.memoName || "",
relatedMemo: memo.name,
memo: MemoRelation_Memo.fromPartial({ name: memo.name }),
relatedMemo: MemoRelation_Memo.fromPartial({ name: memo.name }),
type: MemoRelation_Type.REFERENCE,
})),
...context.relationList,
......
......@@ -19,7 +19,7 @@ const RelationListView = (props: Props) => {
const requests = relationList
.filter((relation) => relation.type === MemoRelation_Type.REFERENCE)
.map(async (relation) => {
return await memoStore.getOrFetchMemoByName(relation.relatedMemo, { skipStore: true });
return await memoStore.getOrFetchMemoByName(relation.relatedMemo!.name, { skipStore: true });
});
const list = await Promise.all(requests);
setReferencingMemoList(list);
......@@ -27,7 +27,7 @@ const RelationListView = (props: Props) => {
}, [relationList]);
const handleDeleteRelation = async (memo: Memo) => {
setRelationList(relationList.filter((relation) => relation.relatedMemo !== memo.name));
setRelationList(relationList.filter((relation) => relation.relatedMemo?.name !== memo.name));
};
return (
......
......@@ -80,7 +80,8 @@ const MemoEditor = (props: Props) => {
const [contentCache, setContentCache] = useLocalStorage<string>(contentCacheKey, "");
const referenceRelations = memoName
? state.relationList.filter(
(relation) => relation.memo === memoName && relation.relatedMemo !== memoName && relation.type === MemoRelation_Type.REFERENCE,
(relation) =>
relation.memo?.name === memoName && relation.relatedMemo?.name !== memoName && relation.type === MemoRelation_Type.REFERENCE,
)
: state.relationList.filter((relation) => relation.type === MemoRelation_Type.REFERENCE);
const workspaceMemoRelatedSetting =
......
import { useColorScheme } from "@mui/joy";
import clsx from "clsx";
import { useEffect, useRef, useState } from "react";
import ForceGraph2D from "react-force-graph-2d";
import ForceGraph2D, { ForceGraphMethods, LinkObject, NodeObject } from "react-force-graph-2d";
import useNavigateTo from "@/hooks/useNavigateTo";
import { Memo } from "@/types/proto/api/v1/memo_service";
import { FGMethods } from "./types";
import { LinkType, NodeType } from "./types";
import { convertMemoRelationsToGraphData } from "./utils";
interface Props {
......@@ -15,9 +16,10 @@ const MAIN_NODE_COLOR = "#14b8a6";
const DEFAULT_NODE_COLOR = "#a1a1aa";
const MemoRelationForceGraph = ({ className, memo }: Props) => {
const navigateTo = useNavigateTo();
const { mode } = useColorScheme();
const containerRef = useRef<HTMLDivElement>(null);
const graphRef = useRef<FGMethods | undefined>(undefined);
const graphRef = useRef<ForceGraphMethods<NodeObject<NodeType>, LinkObject<NodeType, LinkType>> | undefined>(undefined);
const [graphSize, setGraphSize] = useState({ width: 0, height: 0 });
useEffect(() => {
......@@ -25,8 +27,9 @@ const MemoRelationForceGraph = ({ className, memo }: Props) => {
setGraphSize(containerRef.current.getBoundingClientRect());
}, []);
const onNodeClick = () => {
// TODO: Handle node click event
const onNodeClick = (node: NodeObject<NodeType>) => {
if (node.memo.uid === memo.uid) return;
navigateTo(`/m/${node.memo.uid}`);
};
return (
......@@ -37,11 +40,15 @@ const MemoRelationForceGraph = ({ className, memo }: Props) => {
height={graphSize.height}
enableZoomInteraction
cooldownTicks={0}
nodeColor={(node) => (node.name === memo.name ? MAIN_NODE_COLOR : DEFAULT_NODE_COLOR)}
nodeColor={(node) => (node.id === memo.name ? MAIN_NODE_COLOR : DEFAULT_NODE_COLOR)}
nodeRelSize={3}
linkColor={() => (mode === "light" ? "" : "#525252")}
nodeLabel={(node) => node.memo.uid.slice(0, 6).toLowerCase()}
linkColor={() => (mode === "light" ? "#e4e4e7" : "#3f3f46")}
graphData={convertMemoRelationsToGraphData(memo.relations)}
onNodeClick={onNodeClick}
linkDirectionalArrowLength={3}
linkDirectionalArrowRelPos={1}
linkCurvature={0.25}
/>
</div>
);
......
import { ForceGraphMethods, LinkObject, NodeObject } from "react-force-graph-2d";
import { MemoRelation_Memo } from "@/types/proto/api/v1/memo_relation_service";
export interface NodeType {
name: string;
memo: MemoRelation_Memo;
}
export interface LinkType {
// ...add more additional properties relevant to the link here.
}
export interface FGMethods extends ForceGraphMethods<NodeObject<NodeType>, LinkObject<NodeType, LinkType>> {}
import { GraphData, LinkObject, NodeObject } from "react-force-graph-2d";
import { MemoRelation } from "@/types/proto/api/v1/memo_relation_service";
import { MemoRelation, MemoRelation_Memo } from "@/types/proto/api/v1/memo_relation_service";
import { LinkType, NodeType } from "./types";
export const convertMemoRelationsToGraphData = (memoRelations: MemoRelation[]): GraphData<NodeType, LinkType> => {
......@@ -8,23 +8,24 @@ export const convertMemoRelationsToGraphData = (memoRelations: MemoRelation[]):
// Iterate through memoRelations to populate nodes and links.
memoRelations.forEach((relation) => {
const { memo, relatedMemo, type } = relation;
const memo = relation.memo as MemoRelation_Memo;
const relatedMemo = relation.relatedMemo as MemoRelation_Memo;
// Add memo node if not already present.
if (!nodesMap.has(memo)) {
nodesMap.set(memo, { id: memo, name: memo });
if (!nodesMap.has(memo.name)) {
nodesMap.set(memo.name, { id: memo.name, memo });
}
// Add related_memo node if not already present.
if (!nodesMap.has(relatedMemo)) {
nodesMap.set(relatedMemo, { id: relatedMemo, name: relatedMemo });
if (!nodesMap.has(relatedMemo.name)) {
nodesMap.set(relatedMemo.name, { id: relatedMemo.name, memo: relatedMemo });
}
// Create link between memo and relatedMemo.
links.push({
source: memo,
target: relatedMemo,
type, // Include the type of relation as a property of the link.
source: memo.name,
target: relatedMemo.name,
type: relation.type, // Include the type of relation as a property of the link.
});
});
......
import clsx from "clsx";
import { DotIcon, LinkIcon, MilestoneIcon } from "lucide-react";
import { LinkIcon, MilestoneIcon } from "lucide-react";
import { memo, useState } from "react";
import { Link } from "react-router-dom";
import useAsyncEffect from "@/hooks/useAsyncEffect";
import { useMemoStore } from "@/store/v1";
import { MemoRelation } from "@/types/proto/api/v1/memo_relation_service";
import { Memo } from "@/types/proto/api/v1/memo_service";
......@@ -14,30 +12,15 @@ interface Props {
const MemoRelationListView = (props: Props) => {
const { memo, relations: relationList } = props;
const memoStore = useMemoStore();
const [referencingMemoList, setReferencingMemoList] = useState<Memo[]>([]);
const [referencedMemoList, setReferencedMemoList] = useState<Memo[]>([]);
const [selectedTab, setSelectedTab] = useState<"referencing" | "referenced">("referencing");
useAsyncEffect(async () => {
const referencingMemoList = await Promise.all(
relationList
.filter((relation) => relation.memo === memo.name && relation.relatedMemo !== memo.name)
.map((relation) => memoStore.getOrFetchMemoByName(relation.relatedMemo, { skipStore: true })),
);
setReferencingMemoList(referencingMemoList);
const referencedMemoList = await Promise.all(
relationList
.filter((relation) => relation.memo !== memo.name && relation.relatedMemo === memo.name)
.map((relation) => memoStore.getOrFetchMemoByName(relation.memo, { skipStore: true })),
);
setReferencedMemoList(referencedMemoList);
if (referencingMemoList.length === 0) {
setSelectedTab("referenced");
} else {
setSelectedTab("referencing");
}
}, [memo.name, relationList]);
const referencingMemoList = relationList
.filter((relation) => relation.memo?.name === memo.name && relation.relatedMemo?.name !== memo.name)
.map((relation) => relation.relatedMemo!);
const referencedMemoList = relationList
.filter((relation) => relation.memo?.name !== memo.name && relation.relatedMemo?.name === memo.name)
.map((relation) => relation.memo!);
const [selectedTab, setSelectedTab] = useState<"referencing" | "referenced">(
referencingMemoList.length === 0 ? "referenced" : "referencing",
);
if (referencingMemoList.length + referencedMemoList.length === 0) {
return null;
......@@ -83,7 +66,9 @@ const MemoRelationListView = (props: Props) => {
to={`/m/${memo.uid}`}
unstable_viewTransition
>
<DotIcon className="shrink-0 w-4 h-auto opacity-40" />
<span className="text-xs opacity-60 leading-4 border font-mono px-1 rounded-full mr-1 dark:border-zinc-700">
{memo.uid.slice(0, 6)}
</span>
<span className="truncate">{memo.snippet}</span>
</Link>
);
......@@ -100,7 +85,9 @@ const MemoRelationListView = (props: Props) => {
to={`/m/${memo.uid}`}
unstable_viewTransition
>
<DotIcon className="shrink-0 w-4 h-auto opacity-40" />
<span className="text-xs opacity-60 leading-4 border font-mono px-1 rounded-full mr-1 dark:border-zinc-700">
{memo.uid.slice(0, 6)}
</span>
<span className="truncate">{memo.snippet}</span>
</Link>
);
......
......@@ -52,7 +52,7 @@ const MemoView: React.FC<Props> = (props: Props) => {
const memoContainerRef = useRef<HTMLDivElement>(null);
const referencedMemos = memo.relations.filter((relation) => relation.type === MemoRelation_Type.REFERENCE);
const commentAmount = memo.relations.filter(
(relation) => relation.type === MemoRelation_Type.COMMENT && relation.relatedMemo === memo.name,
(relation) => relation.type === MemoRelation_Type.COMMENT && relation.relatedMemo?.name === memo.name,
).length;
const relativeTimeFormat = Date.now() - memo.displayTime!.getTime() > 1000 * 60 * 60 * 24 ? "datetime" : "auto";
const readonly = memo.creator !== user?.name && !isSuperUser(user);
......
......@@ -34,8 +34,8 @@ const MemoDetail = () => {
const [parentMemo, setParentMemo] = useState<Memo | undefined>(undefined);
const [showCommentEditor, setShowCommentEditor] = useState(false);
const commentRelations =
memo?.relations.filter((relation) => relation.relatedMemo === memo.name && relation.type === MemoRelation_Type.COMMENT) || [];
const comments = commentRelations.map((relation) => memoStore.getMemoByName(relation.memo)).filter((memo) => memo) as any as Memo[];
memo?.relations.filter((relation) => relation.relatedMemo?.name === memo.name && relation.type === MemoRelation_Type.COMMENT) || [];
const comments = commentRelations.map((relation) => memoStore.getMemoByName(relation.memo!.name)).filter((memo) => memo) as any as Memo[];
const showCreateCommentButton = workspaceMemoRelatedSetting.enableComment && currentUser;
// Prepare memo.
......@@ -64,7 +64,7 @@ const MemoDetail = () => {
} else {
setParentMemo(undefined);
}
await Promise.all(commentRelations.map((relation) => memoStore.getOrFetchMemoByName(relation.memo)));
await Promise.all(commentRelations.map((relation) => memoStore.getOrFetchMemoByName(relation.memo!.name)));
})();
}, [memo]);
......
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