From 8085fd8171d74bce451007e24477fa1e4070b7e3 Mon Sep 17 00:00:00 2001 From: Christophe Bach Date: Thu, 25 May 2023 16:39:28 +0200 Subject: [PATCH 1/6] backend changes for autosaving --- client/src/util/api-client.ts | 14 ++--- client/src/views/PostFormView.vue | 31 +++++++--- common/src/entity/Post.ts | 6 +- ...tResponseDto.ts => SavePostResponseDto.ts} | 2 +- common/src/index.ts | 2 +- server/src/entity/Post.entity.ts | 8 +++ .../src/migration/1684930338348-Autosave.ts | 62 +++++++++++++++++++ server/src/routes/posts.ts | 29 +++++++-- server/src/service/testdata-generator.ts | 17 +++++ 9 files changed, 148 insertions(+), 23 deletions(-) rename common/src/entity/{DraftResponseDto.ts => SavePostResponseDto.ts} (73%) create mode 100644 server/src/migration/1684930338348-Autosave.ts diff --git a/client/src/util/api-client.ts b/client/src/util/api-client.ts index 6f8d423..abb325e 100644 --- a/client/src/util/api-client.ts +++ b/client/src/util/api-client.ts @@ -2,7 +2,7 @@ import { loadIdToken, updateIdToken } from "@client/util/storage.js"; import type { AiSummaryData, DataUrl, - DraftResponseDto, + SavePostResponseDto, EditPostRequestDto, JsonMimeType, NewPostRequestDto, @@ -113,19 +113,19 @@ export class OpenAiEndpoints { } export class PostEndpoints { - static async createPostWithoutFiles(json: NewPostRequestDto): Promise { - return callServer("/api/posts/new", "POST", "application/json", { json: json }); + static async createPostWithoutFiles(json: NewPostRequestDto): Promise { + return callServer("/api/posts/new", "POST", "application/json", { json: json }); } - static async createPost(json: NewPostRequestDto, files: File[]): Promise { - return callServer( + static async createPost(json: NewPostRequestDto, files: File[]): Promise { + return callServer( "/api/posts/new", "POST", "application/json", new ApiRequestJsonPayloadWithFiles(json, files), ); } - static async editPost(json: EditPostRequestDto, files: File[]): Promise { - return callServer( + static async editPost(json: EditPostRequestDto, files: File[]): Promise { + return callServer( `/api/posts/${json.id}`, "POST", "application/json", diff --git a/client/src/views/PostFormView.vue b/client/src/views/PostFormView.vue index 8e8dfa7..088d442 100644 --- a/client/src/views/PostFormView.vue +++ b/client/src/views/PostFormView.vue @@ -4,7 +4,7 @@
-
+
@@ -284,7 +284,14 @@ import { debounce } from "@client/debounce.js"; import { PostEndpoints } from "@client/util/api-client.js"; import { faUpload } from "@fortawesome/free-solid-svg-icons"; import { t, tc } from "@client/plugins/i18n.js"; -import type { DraftResponseDto, NewPostRequestDto, Post, Tag, SupportedInsertPositionType } from "@fumix/fu-blog-common"; +import type { + SavePostResponseDto, + NewPostRequestDto, + Post, + Tag, + SupportedInsertPositionType, + EditPostRequestDto, +} from "@fumix/fu-blog-common"; import { bytesToBase64URL, convertToHumanReadableFileSize } from "@fumix/fu-blog-common"; import { computed, onMounted, reactive, ref, watch } from "vue"; import { useRoute, useRouter } from "vue-router"; @@ -307,6 +314,7 @@ const form = reactive({ markdown: "", draft: false, stringTags: [], + autosave: false, }); const props = defineProps({ @@ -449,6 +457,11 @@ const setDescription = (description: string) => { form.description = description; }; +const handleAutoSave = () => + debounce(() => { + send(props.postId, true); + }, 1000); + const setKeyvisual = (base64Str: string) => { fetch(base64Str) .then((res) => res.blob()) @@ -472,9 +485,8 @@ const addFile = (file: File) => { .catch((it) => console.error("Failed to calculate SHA-256 hash!")); }; -const submitForm = (e: Event) => { - e.preventDefault(); - send(props.postId); +const submitForm = () => { + send(props.postId, false); }; const insertIntoTextarea = ( @@ -491,16 +503,19 @@ const insertIntoTextarea = ( return before + insertedText + after; }; -const send = async (id: number | undefined) => { - const successAction = (r: DraftResponseDto) => { +const send = async (id: number | undefined, shouldAutosave: boolean) => { + const successAction = (r: SavePostResponseDto) => { router.push(`/posts/post/${r.postId}`); }; if (!id) { + form.autosave = shouldAutosave; await PostEndpoints.createPost(form, Object.values(files)) .then(successAction) .catch((reason) => console.log("Create post request failed", reason)); } else { - await PostEndpoints.editPost(Object.assign(form, { id }), Object.values(files)) + let editRequest = Object.assign(form, { id }) as EditPostRequestDto; + editRequest.autosave = shouldAutosave; + await PostEndpoints.editPost(editRequest, Object.values(files)) .then(successAction) .catch((reason) => console.log("Edit post request failed", reason)); } diff --git a/common/src/entity/Post.ts b/common/src/entity/Post.ts index cac153b..5e8dd63 100644 --- a/common/src/entity/Post.ts +++ b/common/src/entity/Post.ts @@ -1,6 +1,6 @@ import { Attachment } from "./Attachment.js"; import type { Tag } from "./Tag.js"; -import type { PublicUserInfo } from "./User.js"; +import type { PublicUserInfo, User } from "./User.js"; export type Post = { id?: number; @@ -15,9 +15,11 @@ export type Post = { attachments?: Attachment[]; draft: boolean; tags?: Tag[]; + autosaveRefPost?: Post; + autosaveRefUser?: User; }; -export type NewPostRequestDto = Pick & { stringTags: string[] }; +export type NewPostRequestDto = Pick & { stringTags: string[]; autosave: boolean }; export type EditPostRequestDto = NewPostRequestDto & { id: number }; export type PostRequestDto = NewPostRequestDto | EditPostRequestDto; diff --git a/common/src/entity/DraftResponseDto.ts b/common/src/entity/SavePostResponseDto.ts similarity index 73% rename from common/src/entity/DraftResponseDto.ts rename to common/src/entity/SavePostResponseDto.ts index 11c9cdd..696f92d 100644 --- a/common/src/entity/DraftResponseDto.ts +++ b/common/src/entity/SavePostResponseDto.ts @@ -1,6 +1,6 @@ import { Attachment } from "./Attachment.js"; -export type DraftResponseDto = { +export type SavePostResponseDto = { postId?: number; attachments: Attachment[]; }; diff --git a/common/src/index.ts b/common/src/index.ts index 96b0ea4..0103183 100644 --- a/common/src/index.ts +++ b/common/src/index.ts @@ -12,7 +12,7 @@ export * from "./dto/oauth/OAuthUserInfoDto.js"; export * from "./dto/oauth/SavedOAuthToken.js"; export * from "./dto/word.js"; export * from "./entity/Attachment.js"; -export * from "./entity/DraftResponseDto.js"; +export * from "./entity/SavePostResponseDto.js"; export * from "./entity/File.js"; export * from "./entity/OAuthAccount.js"; export * from "./entity/OAuthProvider.js"; diff --git a/server/src/entity/Post.entity.ts b/server/src/entity/Post.entity.ts index 1b9c6e1..1c73c7f 100644 --- a/server/src/entity/Post.entity.ts +++ b/server/src/entity/Post.entity.ts @@ -53,4 +53,12 @@ export class PostEntity implements Post { }) @JoinTable({ name: "post_tag", joinColumn: { name: "post_id" }, inverseJoinColumn: { name: "tag_id" } }) tags: TagEntity[]; + + @JoinColumn({ name: "autosave_ref_post", referencedColumnName: "id", foreignKeyConstraintName: "post_autosave_ref_post_fk" }) + @ManyToOne(() => PostEntity, (post) => post.id, { nullable: true }) + autosaveRefPost?: PostEntity; + + @JoinColumn({ name: "autosave_ref_user", referencedColumnName: "id", foreignKeyConstraintName: "post_autosave_ref_user_fk" }) + @ManyToOne(() => UserEntity, (user) => user.id, { nullable: true }) + autosaveRefUser?: UserEntity; } diff --git a/server/src/migration/1684930338348-Autosave.ts b/server/src/migration/1684930338348-Autosave.ts new file mode 100644 index 0000000..fde6694 --- /dev/null +++ b/server/src/migration/1684930338348-Autosave.ts @@ -0,0 +1,62 @@ +import { MigrationInterface, QueryRunner, TableColumn, TableForeignKey } from "typeorm"; + +export class Autosave1684930338348 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.addColumn( + "post", + new TableColumn({ + name: "autosave_ref_post", + type: "bigint", + isNullable: true, + isUnique: true, + }), + ); + await queryRunner.createForeignKey( + "post", + new TableForeignKey({ + name: "post_autosave_ref_post_fk", + columnNames: ["autosave_ref_post"], + referencedColumnNames: ["id"], + referencedTableName: "post", + }), + ); + await queryRunner.addColumn( + "post", + new TableColumn({ + name: "autosave_ref_user", + type: "bigint", + isNullable: true, + isUnique: true, + }), + ); + await queryRunner.createForeignKey( + "post", + new TableForeignKey({ + name: "post_autosave_ref_user_fk", + columnNames: ["autosave_ref_user"], + referencedColumnNames: ["id"], + referencedTableName: "user", + }), + ); + await queryRunner.query("drop view search_posts"); + await queryRunner.query(` + CREATE VIEW search_posts AS + ( + select id as post_id, + setweight(to_tsvector(coalesce(title, '')), 'A') || + setweight(to_tsvector(coalesce(description, '')), 'B') || + setweight(to_tsvector(coalesce(markdown, '')), 'C') || + setweight(to_tsvector(coalesce((select string_agg(name, ' ') + from tag t + where t.id in (select tag_id from post_tag where post_id = post.id)), '')), 'A') + as post_tsv + from post + where post.autosave_ref_post is null and post.autosave_ref_user is null) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropColumn("post", "autosave_ref"); + await queryRunner.query("drop view search_posts"); + } +} diff --git a/server/src/routes/posts.ts b/server/src/routes/posts.ts index 3c8f3e0..aef79c7 100644 --- a/server/src/routes/posts.ts +++ b/server/src/routes/posts.ts @@ -1,4 +1,4 @@ -import { DraftResponseDto, EditPostRequestDto, NewPostRequestDto, permissionsForUser, PostRequestDto } from "@fumix/fu-blog-common"; +import { EditPostRequestDto, NewPostRequestDto, permissionsForUser, PostRequestDto, SavePostResponseDto } from "@fumix/fu-blog-common"; import { GoneError } from "../errors/GoneError.js"; import express, { NextFunction, Request, Response, Router } from "express"; import { In } from "typeorm"; @@ -16,6 +16,7 @@ import { MarkdownConverterServer } from "../markdown-converter-server.js"; import { authMiddleware } from "../service/middleware/auth.js"; import { extractJsonBody, extractUploadFiles, multipleFilesUpload } from "../service/middleware/files-upload.js"; import { generateShareImage } from "../service/opengraph.js"; +import logger from "../logger.js"; const router: Router = express.Router(); @@ -116,7 +117,7 @@ router.post( "/new", authMiddleware, multipleFilesUpload, - async (req: Request, res: Response, next: NextFunction): Promise => { + async (req: Request, res: Response, next: NextFunction): Promise => { const body: NewPostRequestDto | undefined = JSON.parse(req.body?.json) as NewPostRequestDto; const loggedInUser = await req.loggedInUser?.(); @@ -137,7 +138,7 @@ router.post( .values(tags) .onConflict('("name") DO UPDATE SET "name" = EXCLUDED."name"') .execute() - .then(async (it): Promise => { + .then(async (it): Promise => { const post: PostEntity = { title: body.title, description: body.description, @@ -232,6 +233,16 @@ router.get("/tags/:search", async (req: Request, res: Response, next) => { .catch(next); }); +// deletes autosaves referencing a post, if any +async function deleteAnyAutosaveForPost(postId: number) { + return await AppDataSource.manager.getRepository("post").delete({ autosaveRefPost: postId }); +} + +// deletes autosaves referencing a user, if any +async function deleteAnyAutosaveForUser(userId: number) { + return await AppDataSource.manager.getRepository("post").delete({ autosaveRefUser: userId }); +} + // EDIT EXISTING POST router.post("/:id(\\d+$)", authMiddleware, multipleFilesUpload, async (req: Request, res: Response, next) => { const postId = +req.params.id; @@ -253,6 +264,11 @@ router.post("/:id(\\d+$)", authMiddleware, multipleFilesUpload, async (req: Requ return next(new ForbiddenError()); } + if (body.autosave) { + // TODO + logger.info("autosaving"); + } + const tagsToUseInPost: TagEntity[] = await getPersistedTagsForPost(post, body).catch((err) => { throw new InternalServerError(true, "Error getting tags" + err); }); @@ -291,7 +307,12 @@ router.post("/:id(\\d+$)", authMiddleware, multipleFilesUpload, async (req: Requ .then((updatedPost) => manager.getRepository(PostEntity).save(updatedPost)) .catch(next); }) - .then((it) => res.status(200).json({ postId: post.id } as DraftResponseDto)) + .then((it) => { + if (post.id) { + deleteAnyAutosaveForPost(post.id); + } + return res.status(200).json({ postId: post.id } as SavePostResponseDto); + }) .catch(next); }); diff --git a/server/src/service/testdata-generator.ts b/server/src/service/testdata-generator.ts index 9009cd1..485ca51 100644 --- a/server/src/service/testdata-generator.ts +++ b/server/src/service/testdata-generator.ts @@ -35,6 +35,22 @@ export async function initDatabase(): Promise { logger.info("Database initialized"); } +export async function createRandomAutosave(post: PostEntity): Promise { + try { + // save some autosaves + if (faker.datatype.boolean({ probability: 0.25 })) { + const clone = structuredClone(post) as PostEntity; + clone.autosaveRefPost = post; + clone.id = undefined; + clone.title = "Autosave"; + return await AppDataSource.manager.getRepository(PostEntity).save(clone); + } + } catch (e) { + logger.error("Error creating autosave", e); + } + return new Promise(() => null); +} + /** * Generate some test data for the blog. */ @@ -50,6 +66,7 @@ async function generate(): Promise { createRandomFakeOauthAccount(user, faker.datatype.number()); Array.from({ length: postsPerUser }).forEach(() => { createRandomPost(user, faker.datatype.number()).then((post: PostEntity) => { + createRandomAutosave(post); Array.from({ length: attachmentsPerPost }).forEach(() => { createRandomAttachment(post, faker.datatype.number()); }); From 57812273ccea23d18a612efc7c320f8ec0ef1eb0 Mon Sep 17 00:00:00 2001 From: Christophe Bach Date: Thu, 1 Jun 2023 12:25:13 +0200 Subject: [PATCH 2/6] frontend view, fix deletion --- client/src/i18n/de.json | 11 +++-- client/src/i18n/en.json | 11 +++-- client/src/util/api-client.ts | 7 ++- client/src/views/PostFormView.vue | 72 ++++++++++++++++++++++++---- client/src/views/PostView.vue | 11 ++--- client/src/views/PostsView.vue | 29 ++++++------ server/src/routes/posts.ts | 78 ++++++++++++++++++++++++++++--- 7 files changed, 177 insertions(+), 42 deletions(-) diff --git a/client/src/i18n/de.json b/client/src/i18n/de.json index 2338ebb..fe2859f 100644 --- a/client/src/i18n/de.json +++ b/client/src/i18n/de.json @@ -21,6 +21,8 @@ "back": "Zurück", "close": "Schließen", "save": "Speichern", + "publish": "Veröffentlichen", + "savedraft": "Als Entwurf speichern", "cancel": "Abbrechen", "edit": "Bearbeiten", "delete": "Löschen", @@ -29,7 +31,9 @@ "roles": "Rollen", "login": "Anmelden", "githubLinkText": "Die Blog-Software auf GitHub", - "startpage": "Startseite" + "startpage": "Startseite", + "restore": "Wiederherstellen", + "discard": "Verwerfen" } }, "nav": { @@ -59,7 +63,8 @@ "tags": "Schlagworte", "enter": "Geben Sie Schlagworte ein..." }, - "imageupload": "keine angehängten Bilder | ein angehängtes Bild | {count} angehängte Bilder" + "imageupload": "keine angehängten Bilder | ein angehängtes Bild | {count} angehängte Bilder", + "restore": "Unveröffentlichte Version gefunden" }, "confirm": { "title": "Löschen bestätigen", @@ -69,7 +74,7 @@ "not-available": { "title": "Post nicht vorhanden", "message": "Der Post ist nicht verfügbar oder wurde gelöscht. Sie können mit dem Button zur Startseite zurück, oder nutzen sie die Suchfunktion um einen bestimmten Post zu finden." - } + } } }, "admin": { diff --git a/client/src/i18n/en.json b/client/src/i18n/en.json index 0a73092..cf765b1 100644 --- a/client/src/i18n/en.json +++ b/client/src/i18n/en.json @@ -21,6 +21,8 @@ "back": "Back", "close": "Close", "save": "Save", + "publish": "Publish", + "savedraft": "Save draft", "cancel": "Cancel", "edit": "Edit", "delete": "Delete", @@ -29,7 +31,9 @@ "roles": "Roles", "login": "Login", "githubLinkText": "The blog software on GitHub", - "startpage": "Startpage" + "startpage": "Startpage", + "restore": "Restore", + "discard": "Discard" } }, "nav": { @@ -56,7 +60,8 @@ "title": "Preview" }, "imageupload": "Upload image", - "tags": "Tags" + "tags": "Tags", + "restore": "Unpublished version found" }, "confirm": { "title": "Confirm deletion", @@ -66,7 +71,7 @@ "not-available": { "title": "Post not available", "message": "This post is not available or has been deleted. You can find all posts in the overview, or use the search function to find a specific post." - } + } } }, "admin": { diff --git a/client/src/util/api-client.ts b/client/src/util/api-client.ts index abb325e..6e9221b 100644 --- a/client/src/util/api-client.ts +++ b/client/src/util/api-client.ts @@ -19,7 +19,7 @@ async function callServer< ResponseType = ResponseMimeType extends JsonMimeType ? any : ResponseMimeType extends SupportedImageMimeType ? ArrayBuffer : any, >( url: ApiUrl, - method: "GET" | "POST", + method: "GET" | "POST" | "DELETE" | "PUT", responseType: ResponseMimeType, payload: ApiRequestJsonPayload | null = null, authenticated = true, @@ -133,6 +133,9 @@ export class PostEndpoints { ); } static async deletePost(id: number): Promise<{ affected: number }> { - return callServer(`/api/posts/delete/${id}`, "POST", "application/json"); + return callServer(`/api/posts/${id}`, "DELETE", "application/json"); + } + static async deleteAutosave(id: number): Promise<{ affected: number }> { + return callServer(`/api/posts/autosave/${id}`, "DELETE", "application/json"); } } diff --git a/client/src/views/PostFormView.vue b/client/src/views/PostFormView.vue index 088d442..f568138 100644 --- a/client/src/views/PostFormView.vue +++ b/client/src/views/PostFormView.vue @@ -1,5 +1,24 @@