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>
pull/364/head
Zeng1998 2 years ago committed by GitHub
parent 95376f78f6
commit e85d368f87
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -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: &currentTs,
}
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{}{}

@ -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`);
}

@ -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,
},
});

@ -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;

@ -12,3 +12,8 @@ interface Resource {
linkedMemoAmount: number;
}
interface ResourcePatch {
id: ResourceId;
filename?: string;
}

Loading…
Cancel
Save