mirror of
https://github.com/usememos/memos.git
synced 2025-03-24 14:40:16 +01:00
feat: patch resource filename (#360)
* feat: resource filename rename * update: resource filename rename * update: resource filename rename * update: validation about the filename Co-authored-by: boojack <stevenlgtm@gmail.com>
This commit is contained in:
parent
95376f78f6
commit
e85d368f87
@ -46,3 +46,12 @@ type ResourceDelete struct {
|
||||
// Standard fields
|
||||
CreatorID int
|
||||
}
|
||||
|
||||
type ResourcePatch struct {
|
||||
ID int
|
||||
|
||||
// Standard fields
|
||||
UpdatedTs *int64
|
||||
|
||||
Filename *string `json:"filename"`
|
||||
}
|
||||
|
@ -7,6 +7,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/usememos/memos/api"
|
||||
"github.com/usememos/memos/common"
|
||||
@ -182,6 +183,47 @@ func (s *Server) registerResourceRoutes(g *echo.Group) {
|
||||
|
||||
return c.JSON(http.StatusOK, true)
|
||||
})
|
||||
|
||||
g.PATCH("/resource/:resourceId", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
|
||||
}
|
||||
|
||||
resourceID, err := strconv.Atoi(c.Param("resourceId"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("resourceId"))).SetInternal(err)
|
||||
}
|
||||
|
||||
resourceFind := &api.ResourceFind{
|
||||
ID: &resourceID,
|
||||
CreatorID: &userID,
|
||||
}
|
||||
if _, err := s.Store.FindResource(ctx, resourceFind); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find resource").SetInternal(err)
|
||||
}
|
||||
|
||||
currentTs := time.Now().Unix()
|
||||
resourcePatch := &api.ResourcePatch{
|
||||
ID: resourceID,
|
||||
UpdatedTs: ¤tTs,
|
||||
}
|
||||
if err := json.NewDecoder(c.Request().Body).Decode(resourcePatch); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted patch resource request").SetInternal(err)
|
||||
}
|
||||
|
||||
resource, err := s.Store.PatchResource(ctx, resourcePatch)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to patch resource").SetInternal(err)
|
||||
}
|
||||
|
||||
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
|
||||
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(resource)); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode resource response").SetInternal(err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) registerResourcePublicRoutes(g *echo.Group) {
|
||||
|
@ -188,6 +188,31 @@ func (s *Store) DeleteResource(ctx context.Context, delete *api.ResourceDelete)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) PatchResource(ctx context.Context, patch *api.ResourcePatch) (*api.Resource, error) {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, FormatError(err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
resourceRaw, err := patchResource(ctx, tx, patch)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, FormatError(err)
|
||||
}
|
||||
|
||||
if err := s.cache.UpsertCache(api.ResourceCache, resourceRaw.ID, resourceRaw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resource := resourceRaw.toResource()
|
||||
|
||||
return resource, nil
|
||||
}
|
||||
|
||||
func createResource(ctx context.Context, tx *sql.Tx, create *api.ResourceCreate) (*resourceRaw, error) {
|
||||
query := `
|
||||
INSERT INTO resource (
|
||||
@ -217,6 +242,41 @@ func createResource(ctx context.Context, tx *sql.Tx, create *api.ResourceCreate)
|
||||
return &resourceRaw, nil
|
||||
}
|
||||
|
||||
func patchResource(ctx context.Context, tx *sql.Tx, patch *api.ResourcePatch) (*resourceRaw, error) {
|
||||
set, args := []string{}, []interface{}{}
|
||||
|
||||
if v := patch.UpdatedTs; v != nil {
|
||||
set, args = append(set, "updated_ts = ?"), append(args, *v)
|
||||
}
|
||||
if v := patch.Filename; v != nil {
|
||||
set, args = append(set, "filename = ?"), append(args, *v)
|
||||
}
|
||||
|
||||
args = append(args, patch.ID)
|
||||
|
||||
query := `
|
||||
UPDATE resource
|
||||
SET ` + strings.Join(set, ", ") + `
|
||||
WHERE id = ?
|
||||
RETURNING id, filename, blob, type, size, creator_id, created_ts, updated_ts
|
||||
`
|
||||
var resourceRaw resourceRaw
|
||||
if err := tx.QueryRowContext(ctx, query, args...).Scan(
|
||||
&resourceRaw.ID,
|
||||
&resourceRaw.Filename,
|
||||
&resourceRaw.Blob,
|
||||
&resourceRaw.Type,
|
||||
&resourceRaw.Size,
|
||||
&resourceRaw.CreatorID,
|
||||
&resourceRaw.CreatedTs,
|
||||
&resourceRaw.UpdatedTs,
|
||||
); err != nil {
|
||||
return nil, FormatError(err)
|
||||
}
|
||||
|
||||
return &resourceRaw, nil
|
||||
}
|
||||
|
||||
func findResourceList(ctx context.Context, tx *sql.Tx, find *api.ResourceFind) ([]*resourceRaw, error) {
|
||||
where, args := []string{"1 = 1"}, []interface{}{}
|
||||
|
||||
|
100
web/src/components/ChangeResourceFilenameDialog.tsx
Normal file
100
web/src/components/ChangeResourceFilenameDialog.tsx
Normal file
@ -0,0 +1,100 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { resourceService } from "../services";
|
||||
import Icon from "./Icon";
|
||||
import { generateDialog } from "./Dialog";
|
||||
import toastHelper from "./Toast";
|
||||
import "../less/change-resource-filename-dialog.less";
|
||||
|
||||
interface Props extends DialogProps {
|
||||
resourceId: ResourceId;
|
||||
resourceFilename: string;
|
||||
}
|
||||
|
||||
const validateFilename = (filename: string): boolean => {
|
||||
if (filename.length === 0 || filename.length >= 128) {
|
||||
return false;
|
||||
}
|
||||
const startReg = /^([+\-.]).*/;
|
||||
const illegalReg = /[/@#$%^&*()[\]]/;
|
||||
if (startReg.test(filename) || illegalReg.test(filename)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const ChangeResourceFilenameDialog: React.FC<Props> = (props: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { destroy, resourceId, resourceFilename } = props;
|
||||
const [filename, setFilename] = useState<string>(resourceFilename);
|
||||
|
||||
const handleFilenameChanged = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const nextUsername = e.target.value as string;
|
||||
setFilename(nextUsername);
|
||||
};
|
||||
|
||||
const handleCloseBtnClick = () => {
|
||||
destroy();
|
||||
};
|
||||
|
||||
const handleSaveBtnClick = async () => {
|
||||
if (filename === resourceFilename) {
|
||||
handleCloseBtnClick();
|
||||
return;
|
||||
}
|
||||
if (!validateFilename(filename)) {
|
||||
toastHelper.error(t("message.invalid-resource-filename"));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await resourceService.patchResource({
|
||||
id: resourceId,
|
||||
filename: filename,
|
||||
});
|
||||
toastHelper.info(t("message.resource-filename-updated"));
|
||||
handleCloseBtnClick();
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
toastHelper.error(error.response.data.message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="dialog-header-container">
|
||||
<p className="title-text">{t("message.change-resource-filename")}</p>
|
||||
<button className="btn close-btn" onClick={handleCloseBtnClick}>
|
||||
<Icon.X />
|
||||
</button>
|
||||
</div>
|
||||
<div className="dialog-content-container">
|
||||
<label className="form-label input-form-label">
|
||||
<input type="text" value={filename} onChange={handleFilenameChanged} />
|
||||
</label>
|
||||
<div className="btns-container">
|
||||
<span className="btn cancel-btn" onClick={handleCloseBtnClick}>
|
||||
{t("common.cancel")}
|
||||
</span>
|
||||
<span className="btn confirm-btn" onClick={handleSaveBtnClick}>
|
||||
{t("common.save")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
function showChangeResourceFilenameDialog(resourceId: ResourceId, resourceFilename: string) {
|
||||
generateDialog(
|
||||
{
|
||||
className: "change-resource-filename-dialog",
|
||||
},
|
||||
ChangeResourceFilenameDialog,
|
||||
{
|
||||
resourceId,
|
||||
resourceFilename,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export default showChangeResourceFilenameDialog;
|
@ -11,11 +11,12 @@ import Icon from "./Icon";
|
||||
import toastHelper from "./Toast";
|
||||
import "../less/resources-dialog.less";
|
||||
import * as utils from "../helpers/utils";
|
||||
import showChangeResourceFilenameDialog from "./ChangeResourceFilenameDialog";
|
||||
import { useAppSelector } from "../store";
|
||||
|
||||
type Props = DialogProps;
|
||||
|
||||
interface State {
|
||||
resources: Resource[];
|
||||
isUploadingResource: boolean;
|
||||
}
|
||||
|
||||
@ -23,11 +24,10 @@ const ResourcesDialog: React.FC<Props> = (props: Props) => {
|
||||
const { destroy } = props;
|
||||
const { t } = useTranslation();
|
||||
const loadingState = useLoading();
|
||||
const { resources } = useAppSelector((state) => state.resource);
|
||||
const [state, setState] = useState<State>({
|
||||
resources: [],
|
||||
isUploadingResource: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchResources()
|
||||
.catch((error) => {
|
||||
@ -41,10 +41,6 @@ const ResourcesDialog: React.FC<Props> = (props: Props) => {
|
||||
|
||||
const fetchResources = async () => {
|
||||
const data = await resourceService.getResourceList();
|
||||
setState({
|
||||
...state,
|
||||
resources: data,
|
||||
});
|
||||
};
|
||||
|
||||
const handleUploadFileBtnClick = async () => {
|
||||
@ -99,6 +95,10 @@ const ResourcesDialog: React.FC<Props> = (props: Props) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleRenameBtnClick = (resource: Resource) => {
|
||||
showChangeResourceFilenameDialog(resource.id, resource.filename);
|
||||
};
|
||||
|
||||
const handleCopyResourceLinkBtnClick = (resource: Resource) => {
|
||||
copy(`${window.location.origin}/o/r/${resource.id}/${resource.filename}`);
|
||||
toastHelper.success("Succeed to copy resource link to clipboard");
|
||||
@ -165,10 +165,10 @@ const ResourcesDialog: React.FC<Props> = (props: Props) => {
|
||||
<span className="field-text type-text">TYPE</span>
|
||||
<span></span>
|
||||
</div>
|
||||
{state.resources.length === 0 ? (
|
||||
{resources.length === 0 ? (
|
||||
<p className="tip-text">{t("resources.no-resources")}</p>
|
||||
) : (
|
||||
state.resources.map((resource) => (
|
||||
resources.map((resource) => (
|
||||
<div key={resource.id} className="resource-container">
|
||||
<span className="field-text id-text">{resource.id}</span>
|
||||
<span className="field-text name-text">
|
||||
@ -198,6 +198,12 @@ const ResourcesDialog: React.FC<Props> = (props: Props) => {
|
||||
>
|
||||
{t("resources.preview")}
|
||||
</button>
|
||||
<button
|
||||
className="w-full text-left text-sm leading-6 py-1 px-3 cursor-pointer rounded hover:bg-gray-100"
|
||||
onClick={() => handleRenameBtnClick(resource)}
|
||||
>
|
||||
{t("resources.rename")}
|
||||
</button>
|
||||
<button
|
||||
className="w-full text-left text-sm leading-6 py-1 px-3 cursor-pointer rounded hover:bg-gray-100"
|
||||
onClick={() => handleCopyResourceLinkBtnClick(resource)}
|
||||
|
@ -154,6 +154,10 @@ export function deleteResourceById(id: ResourceId) {
|
||||
return axios.delete(`/api/resource/${id}`);
|
||||
}
|
||||
|
||||
export function patchResource(resourcePatch: ResourcePatch) {
|
||||
return axios.patch<ResponseObject<Resource>>(`/api/resource/${resourcePatch.id}`, resourcePatch);
|
||||
}
|
||||
|
||||
export function getMemoResourceList(memoId: MemoId) {
|
||||
return axios.get<ResponseObject<Resource[]>>(`/api/memo/${memoId}/resource`);
|
||||
}
|
||||
|
47
web/src/less/change-resource-filename-dialog.less
Normal file
47
web/src/less/change-resource-filename-dialog.less
Normal file
@ -0,0 +1,47 @@
|
||||
@import "./mixin.less";
|
||||
|
||||
.change-resource-filename-dialog {
|
||||
> .dialog-container {
|
||||
@apply w-72;
|
||||
|
||||
> .dialog-content-container {
|
||||
.flex(column, flex-start, flex-start);
|
||||
|
||||
> .tip-text {
|
||||
@apply bg-gray-400 text-xs p-2 rounded-lg;
|
||||
}
|
||||
|
||||
> .form-label {
|
||||
@apply flex flex-col justify-start items-start relative w-full leading-relaxed;
|
||||
|
||||
&.input-form-label {
|
||||
@apply py-3 pb-1;
|
||||
|
||||
> input {
|
||||
@apply w-full p-2 text-sm leading-6 rounded border border-gray-400 bg-transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .btns-container {
|
||||
@apply flex flex-row justify-end items-center mt-2 w-full;
|
||||
|
||||
> .btn {
|
||||
@apply text-sm px-4 py-2 rounded ml-2 bg-gray-400;
|
||||
|
||||
&:hover {
|
||||
@apply opacity-80;
|
||||
}
|
||||
|
||||
&.confirm-btn {
|
||||
@apply bg-green-600 text-white shadow-inner;
|
||||
}
|
||||
|
||||
&.cancel-btn {
|
||||
background-color: unset;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -62,6 +62,34 @@
|
||||
&.type-text {
|
||||
@apply col-span-3;
|
||||
}
|
||||
|
||||
> .form-label {
|
||||
min-height: 28px;
|
||||
|
||||
&.filename-label {
|
||||
@apply w-full flex flex-row justify-between;
|
||||
|
||||
> input {
|
||||
@apply grow-0 w-40 shadow-inner px-2 mr-2 border rounded leading-7 bg-transparent focus:border-black;
|
||||
}
|
||||
|
||||
> .btns-container {
|
||||
@apply shrink-0 grow flex flex-row justify-end items-center;
|
||||
|
||||
> .btn {
|
||||
@apply text-sm shadow px-2 leading-7 rounded border hover:opacity-80 bg-gray-50;
|
||||
|
||||
&.cancel-btn {
|
||||
@apply shadow-none border-none bg-transparent;
|
||||
}
|
||||
|
||||
&.confirm-btn {
|
||||
@apply bg-green-600 border-green-600 text-white;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -64,7 +64,8 @@
|
||||
"copy-link": "Copy Link",
|
||||
"delete-resource": "Delete Resource",
|
||||
"warning-text": "Are you sure to delete this resource? THIS ACTION IS IRREVERSIABLE❗️",
|
||||
"linked-amount": "Linked memo amount"
|
||||
"linked-amount": "Linked memo amount",
|
||||
"rename": "Rename"
|
||||
},
|
||||
"archived": {
|
||||
"archived-memos": "Archived Memos",
|
||||
@ -162,6 +163,9 @@
|
||||
"password-changed": "Password Changed",
|
||||
"private-only": "This memo is private only.",
|
||||
"copied": "Copied",
|
||||
"succeed-copy-content": "Succeed to copy content to clipboard."
|
||||
"succeed-copy-content": "Succeed to copy content to clipboard.",
|
||||
"change-resource-filename": "Change resource filename",
|
||||
"resource-filename-updated": "Resource filename changed.",
|
||||
"invalid-resource-filename": "Invalid filename."
|
||||
}
|
||||
}
|
||||
|
@ -64,7 +64,8 @@
|
||||
"copy-link": "Sao chép",
|
||||
"delete-resource": "Xóa tài nguyên",
|
||||
"warning-text": "Bạn có chắc chắn xóa tài nguyên này không? HÀNH ĐỘNG KHÔNG THỂ KHÔI PHỤC❗️",
|
||||
"linked-amount": "Số memo đã liên kết"
|
||||
"linked-amount": "Số memo đã liên kết",
|
||||
"rename": "đổi tên"
|
||||
},
|
||||
"archived": {
|
||||
"archived-memos": "Memo đã lưu trữ",
|
||||
@ -162,6 +163,9 @@
|
||||
"password-changed": "Mật khẩu đã được thay đổi",
|
||||
"private-only": "Memo này có trạng thái riêng tư.",
|
||||
"copied": "Đã sao chép",
|
||||
"succeed-copy-content": "Đã sao chép nội dung memo thành công."
|
||||
"succeed-copy-content": "Đã sao chép nội dung memo thành công.",
|
||||
"change-resource-filename": "Thay đổi tên tệp tài nguyên",
|
||||
"resource-filename-updated": "Tên tệp tài nguyên đã thay đổi.",
|
||||
"invalid-resource-filename": "Tên tệp không hợp lệ."
|
||||
}
|
||||
}
|
||||
|
@ -64,7 +64,8 @@
|
||||
"copy-link": "拷贝链接",
|
||||
"delete-resource": "删除资源",
|
||||
"warning-text": "确定删除这个资源么?此操作不可逆❗️",
|
||||
"linked-amount": "链接的 Memo 数量"
|
||||
"linked-amount": "链接的 Memo 数量",
|
||||
"rename": "重命名"
|
||||
},
|
||||
"archived": {
|
||||
"archived-memos": "已归档的 Memo",
|
||||
@ -162,6 +163,9 @@
|
||||
"password-changed": "密码已修改",
|
||||
"private-only": "此 Memo 仅自己可见",
|
||||
"copied": "已复制",
|
||||
"succeed-copy-content": "复制内容到剪贴板成功。"
|
||||
"succeed-copy-content": "复制内容到剪贴板成功。",
|
||||
"change-resource-filename": "更改资源文件名",
|
||||
"resource-filename-updated": "资源文件名更改成功。",
|
||||
"invalid-resource-filename": "无效的资源文件名"
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,6 @@
|
||||
import * as api from "../helpers/api";
|
||||
import store from "../store";
|
||||
import { patchResource, setResources } from "../store/modules/resource";
|
||||
|
||||
const convertResponseModelResource = (resource: Resource): Resource => {
|
||||
return {
|
||||
@ -12,6 +14,7 @@ const resourceService = {
|
||||
async getResourceList(): Promise<Resource[]> {
|
||||
const { data } = (await api.getResourceList()).data;
|
||||
const resourceList = data.map((m) => convertResponseModelResource(m));
|
||||
store.dispatch(setResources(resourceList));
|
||||
return resourceList;
|
||||
},
|
||||
async upload(file: File): Promise<Resource> {
|
||||
@ -30,6 +33,13 @@ const resourceService = {
|
||||
async deleteResourceById(id: ResourceId) {
|
||||
return api.deleteResourceById(id);
|
||||
},
|
||||
|
||||
async patchResource(resourcePatch: ResourcePatch): Promise<Resource> {
|
||||
const { data } = (await api.patchResource(resourcePatch)).data;
|
||||
const resource = convertResponseModelResource(data);
|
||||
store.dispatch(patchResource(resource));
|
||||
return resource;
|
||||
},
|
||||
};
|
||||
|
||||
export default resourceService;
|
||||
|
@ -6,6 +6,7 @@ import memoReducer from "./modules/memo";
|
||||
import editorReducer from "./modules/editor";
|
||||
import shortcutReducer from "./modules/shortcut";
|
||||
import locationReducer from "./modules/location";
|
||||
import resourceReducer from "./modules/resource";
|
||||
|
||||
const store = configureStore({
|
||||
reducer: {
|
||||
@ -15,6 +16,7 @@ const store = configureStore({
|
||||
editor: editorReducer,
|
||||
shortcut: shortcutReducer,
|
||||
location: locationReducer,
|
||||
resource: resourceReducer,
|
||||
},
|
||||
});
|
||||
|
||||
|
39
web/src/store/modules/resource.ts
Normal file
39
web/src/store/modules/resource.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
|
||||
|
||||
interface State {
|
||||
resources: Resource[];
|
||||
}
|
||||
|
||||
const resourceSlice = createSlice({
|
||||
name: "resource",
|
||||
initialState: {
|
||||
resources: [],
|
||||
} as State,
|
||||
reducers: {
|
||||
setResources: (state, action: PayloadAction<Resource[]>) => {
|
||||
return {
|
||||
...state,
|
||||
resources: action.payload,
|
||||
};
|
||||
},
|
||||
patchResource: (state, action: PayloadAction<Partial<Resource>>) => {
|
||||
return {
|
||||
...state,
|
||||
resources: state.resources.map((resource) => {
|
||||
if (resource.id === action.payload.id) {
|
||||
return {
|
||||
...resource,
|
||||
...action.payload,
|
||||
};
|
||||
} else {
|
||||
return resource;
|
||||
}
|
||||
}),
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const { setResources, patchResource } = resourceSlice.actions;
|
||||
|
||||
export default resourceSlice.reducer;
|
5
web/src/types/modules/resource.d.ts
vendored
5
web/src/types/modules/resource.d.ts
vendored
@ -12,3 +12,8 @@ interface Resource {
|
||||
|
||||
linkedMemoAmount: number;
|
||||
}
|
||||
|
||||
interface ResourcePatch {
|
||||
id: ResourceId;
|
||||
filename?: string;
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user