diff --git a/api/resource.go b/api/resource.go index 11dc157a..b00136cf 100644 --- a/api/resource.go +++ b/api/resource.go @@ -46,3 +46,12 @@ type ResourceDelete struct { // Standard fields CreatorID int } + +type ResourcePatch struct { + ID int + + // Standard fields + UpdatedTs *int64 + + Filename *string `json:"filename"` +} diff --git a/server/resource.go b/server/resource.go index 2baa7892..25cff86a 100644 --- a/server/resource.go +++ b/server/resource.go @@ -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) { diff --git a/store/resource.go b/store/resource.go index a1174c8f..1bb3a356 100644 --- a/store/resource.go +++ b/store/resource.go @@ -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{}{} diff --git a/web/src/components/ChangeResourceFilenameDialog.tsx b/web/src/components/ChangeResourceFilenameDialog.tsx new file mode 100644 index 00000000..06f82900 --- /dev/null +++ b/web/src/components/ChangeResourceFilenameDialog.tsx @@ -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) => { + const { t } = useTranslation(); + const { destroy, resourceId, resourceFilename } = props; + const [filename, setFilename] = useState(resourceFilename); + + const handleFilenameChanged = (e: React.ChangeEvent) => { + 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 ( + <> +
+

{t("message.change-resource-filename")}

+ +
+
+ +
+ + {t("common.cancel")} + + + {t("common.save")} + +
+
+ + ); +}; + +function showChangeResourceFilenameDialog(resourceId: ResourceId, resourceFilename: string) { + generateDialog( + { + className: "change-resource-filename-dialog", + }, + ChangeResourceFilenameDialog, + { + resourceId, + resourceFilename, + } + ); +} + +export default showChangeResourceFilenameDialog; diff --git a/web/src/components/ResourcesDialog.tsx b/web/src/components/ResourcesDialog.tsx index 52b46834..40f05a75 100644 --- a/web/src/components/ResourcesDialog.tsx +++ b/web/src/components/ResourcesDialog.tsx @@ -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) => { const { destroy } = props; const { t } = useTranslation(); const loadingState = useLoading(); + const { resources } = useAppSelector((state) => state.resource); const [state, setState] = useState({ - resources: [], isUploadingResource: false, }); - useEffect(() => { fetchResources() .catch((error) => { @@ -41,10 +41,6 @@ const ResourcesDialog: React.FC = (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) => { } }; + 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) => { TYPE - {state.resources.length === 0 ? ( + {resources.length === 0 ? (

{t("resources.no-resources")}

) : ( - state.resources.map((resource) => ( + resources.map((resource) => (
{resource.id} @@ -198,6 +198,12 @@ const ResourcesDialog: React.FC = (props: Props) => { > {t("resources.preview")} +