From ea5a872d23118234357f6bfdb30f4ece660423a0 Mon Sep 17 00:00:00 2001 From: Harald Schilly Date: Thu, 17 Oct 2024 12:26:05 +0200 Subject: [PATCH] bookmarks: for starred files, first idea --- src/packages/frontend/project/context.tsx | 16 +++ .../frontend/project/page/flyouts/active.tsx | 21 +-- .../frontend/project/page/flyouts/store.ts | 113 +++++++++++++++ src/packages/frontend/projects/actions.ts | 9 +- src/packages/next/lib/api/schema/bookmarks.ts | 76 ++++++++++ .../next/pages/api/v2/bookmarks.test.ts | 20 +++ .../next/pages/api/v2/bookmarks/add.ts | 88 ++++++++++++ .../next/pages/api/v2/bookmarks/get.ts | 90 ++++++++++++ .../next/pages/api/v2/bookmarks/remove.ts | 88 ++++++++++++ .../next/pages/api/v2/bookmarks/set.ts | 82 +++++++++++ src/packages/next/pages/api/v2/exec.ts | 5 + src/packages/server/bookmarks/starred.ts | 130 ++++++++++++++++++ src/packages/server/package.json | 1 + src/packages/util/consts/bookmarks.ts | 1 + src/packages/util/db-schema/bookmarks.ts | 45 ++++++ src/packages/util/db-schema/crm.ts | 36 ++--- src/packages/util/db-schema/index.ts | 1 + src/packages/util/types/bookmarks.ts | 17 +++ 18 files changed, 801 insertions(+), 38 deletions(-) create mode 100644 src/packages/frontend/project/page/flyouts/store.ts create mode 100644 src/packages/next/lib/api/schema/bookmarks.ts create mode 100644 src/packages/next/pages/api/v2/bookmarks.test.ts create mode 100644 src/packages/next/pages/api/v2/bookmarks/add.ts create mode 100644 src/packages/next/pages/api/v2/bookmarks/get.ts create mode 100644 src/packages/next/pages/api/v2/bookmarks/remove.ts create mode 100644 src/packages/next/pages/api/v2/bookmarks/set.ts create mode 100644 src/packages/server/bookmarks/starred.ts create mode 100644 src/packages/util/consts/bookmarks.ts create mode 100644 src/packages/util/db-schema/bookmarks.ts create mode 100644 src/packages/util/types/bookmarks.ts diff --git a/src/packages/frontend/project/context.tsx b/src/packages/frontend/project/context.tsx index 799eb696e2..d70226250c 100644 --- a/src/packages/frontend/project/context.tsx +++ b/src/packages/frontend/project/context.tsx @@ -26,6 +26,8 @@ import { import { useProjectStatus } from "./page/project-status-hook"; import { useProjectHasInternetAccess } from "./settings/has-internet-access-hook"; import { Project } from "./settings/types"; +import { useStarredFilesManager } from "./page/flyouts/store"; +import { FlyoutActiveStarred } from "./page/flyouts/state"; export interface ProjectContextState { actions?: ProjectActions; @@ -42,6 +44,10 @@ export interface ProjectContextState { onCoCalcDocker: boolean; enabledLLMs: LLMServicesAvailable; mainWidthPx: number; + manageStarredFiles: { + starred: FlyoutActiveStarred; + setStarredPath: (path: string, starState: boolean) => void; + }; } export const ProjectContext: Context = @@ -68,6 +74,10 @@ export const ProjectContext: Context = user: false, }, mainWidthPx: 0, + manageStarredFiles: { + starred: [], + setStarredPath: () => {}, + }, }); export function useProjectContext() { @@ -105,6 +115,11 @@ export function useProjectContextProvider({ // shared data: used to flip through the open tabs in the active files flyout const flipTabs = useState(0); + // manage starred files (active tabs) + // This is put here, to only sync the starred files when the project is opened, + // not each time the active tab is opened! + const manageStarredFiles = useStarredFilesManager(project_id); + const kucalc = useTypedRedux("customize", "kucalc"); const onCoCalcCom = kucalc === KUCALC_COCALC_COM; const onCoCalcDocker = kucalc === KUCALC_DISABLED; @@ -145,5 +160,6 @@ export function useProjectContextProvider({ onCoCalcDocker, enabledLLMs, mainWidthPx, + manageStarredFiles, }; } diff --git a/src/packages/frontend/project/page/flyouts/active.tsx b/src/packages/frontend/project/page/flyouts/active.tsx index 610419d8f8..99fba2a1f0 100644 --- a/src/packages/frontend/project/page/flyouts/active.tsx +++ b/src/packages/frontend/project/page/flyouts/active.tsx @@ -51,11 +51,9 @@ import { FileListItem } from "./file-list-item"; import { FlyoutFilterWarning } from "./filter-warning"; import { FlyoutActiveMode, - FlyoutActiveStarred, FlyoutActiveTabSort, getFlyoutActiveMode, getFlyoutActiveShowStarred, - getFlyoutActiveStarred, getFlyoutActiveTabSort, isFlyoutActiveMode, storeFlyoutState, @@ -119,7 +117,7 @@ interface Props { export function ActiveFlyout(props: Readonly): JSX.Element { const { wrap, flyoutWidth } = props; const { formatIntl } = useAppContext(); - const { project_id, flipTabs } = useProjectContext(); + const { project_id, flipTabs, manageStarredFiles } = useProjectContext(); const flipTab = flipTabs[0]; const flipTabPrevious = usePrevious(flipTab); const actions = useActions({ project_id }); @@ -127,9 +125,6 @@ export function ActiveFlyout(props: Readonly): JSX.Element { const [mode, setActiveMode] = useState( getFlyoutActiveMode(project_id), ); - const [starred, setStarred] = useState( - getFlyoutActiveStarred(project_id), - ); const [sortTabs, setSortTabsState] = useState( getFlyoutActiveTabSort(project_id), @@ -144,6 +139,8 @@ export function ActiveFlyout(props: Readonly): JSX.Element { ); const [showStarredTabs, setShowStarredTabs] = useState(true); + const { starred, setStarredPath } = manageStarredFiles; + function setMode(mode: FlyoutActiveMode) { if (isFlyoutActiveMode(mode)) { setActiveMode(mode); @@ -153,14 +150,6 @@ export function ActiveFlyout(props: Readonly): JSX.Element { } } - function setStarredPath(path: string, next: boolean) { - const newStarred = next - ? [...starred, path] - : starred.filter((p) => p !== path); - setStarred(newStarred); - storeFlyoutState(project_id, "active", { starred: newStarred }); - } - function setSortTabs(sort: FlyoutActiveTabSort) { setSortTabsState(sort); storeFlyoutState(project_id, "active", { activeTabSort: sort }); @@ -297,12 +286,12 @@ export function ActiveFlyout(props: Readonly): JSX.Element { } }} isStarred={showStarred ? starred.includes(path) : undefined} - onStar={(next: boolean) => { + onStar={(starState: boolean) => { // we only toggle star, if it is currently opeend! // otherwise, when closed and accidentally clicking on the star // the file unstarred and just vanishes if (isopen) { - setStarredPath(path, next); + setStarredPath(path, starState); } else { handleFileClick(undefined, path, "star"); } diff --git a/src/packages/frontend/project/page/flyouts/store.ts b/src/packages/frontend/project/page/flyouts/store.ts new file mode 100644 index 0000000000..7f6b1dc524 --- /dev/null +++ b/src/packages/frontend/project/page/flyouts/store.ts @@ -0,0 +1,113 @@ +/* + * This file is part of CoCalc: Copyright © 2024 Sagemath, Inc. + * License: MS-RSL – see LICENSE.md for details + */ + +import { merge, sortBy, throttle, uniq, xor } from "lodash"; +import { useState } from "react"; +import useAsyncEffect from "use-async-effect"; + +import api from "@cocalc/frontend/client/api"; +import { STARRED } from "@cocalc/util/consts/bookmarks"; +import { GetStarredBookmarks } from "@cocalc/util/types/bookmarks"; +import { + FlyoutActiveStarred, + getFlyoutActiveStarred, + storeFlyoutState, +} from "./state"; + +// Additionally to local storage, we back the state of the starred files in the database. +// Errors with the API are ignored, because we primarily rely on local storage. +// The only really important situation to think of are when there is nothing in local storage but in the database, +// or when there is +export function useStarredFilesManager(project_id: string) { + const [starred, setStarred] = useState( + getFlyoutActiveStarred(project_id), + ); + + // once after mounting this, we update the starred bookmarks (which merges with what we have) and then stores it + useAsyncEffect(async () => { + await updateStarred(); + }, []); + + function setStarredLS(starred: string[]) { + setStarred(starred); + storeFlyoutState(project_id, "active", { starred: starred }); + } + + // TODO: there are also add/remove API endpoints, but for now we stick with set. Hardly worth optimizing. + function setStarredPath(path: string, starState: boolean) { + const next = starState + ? [...starred, path] + : starred.filter((p) => p !== path); + setStarredLS(next); + storeStarred(next); + } + + async function storeStarred(starred: string[]) { + try { + await api("bookmarks/set", { + type: STARRED, + project_id, + payload: starred, + }); + } catch (err) { + console.error("api error", err); + } + } + + // this is called once, when the flyout/tabs component is mounted + // throtteld, to usually take 1 sec from opening the panel to loading the stars + const updateStarred = throttle( + async () => { + try { + const data: GetStarredBookmarks = await api("bookmarks/get", { + type: STARRED, + project_id, + }); + + const { type, status } = data; + + if (type !== STARRED) { + console.error( + `flyout/store/starred type must be ${STARRED} but we got`, + type, + ); + return; + } + + if (status === "success") { + const { payload } = data; + if ( + Array.isArray(payload) && + payload.every((x) => typeof x === "string") + ) { + payload.sort(); // sorted for the xor check below + const next = sortBy(uniq(merge(starred, payload))); + setStarredLS(next); + if (xor(payload, next).length > 0) { + // if there is a change (e.g. nothing in the database stored yet), store the stars + await storeStarred(next); + } + } else { + console.error("flyout/store/starred invalid payload", payload); + } + } else if (status === "error") { + const { error } = data; + console.error("flyout/store/starred error", error); + } else { + console.error("flyout/store/starred error: unknown status", status); + } + } catch (err) { + console.error("api error", err); + } + }, + 1000, + { trailing: true, leading: false }, + ); + + return { + starred, + setStarredPath, + }; +} diff --git a/src/packages/frontend/projects/actions.ts b/src/packages/frontend/projects/actions.ts index 1618a2b0da..67ca5f7611 100644 --- a/src/packages/frontend/projects/actions.ts +++ b/src/packages/frontend/projects/actions.ts @@ -5,16 +5,21 @@ import { Set } from "immutable"; import { isEqual } from "lodash"; + import { alert_message } from "@cocalc/frontend/alerts"; import { Actions, redux } from "@cocalc/frontend/app-framework"; import { set_window_title } from "@cocalc/frontend/browser"; +import api from "@cocalc/frontend/client/api"; import { COCALC_MINIMAL } from "@cocalc/frontend/fullscreen"; import { markdown_to_html } from "@cocalc/frontend/markdown"; import type { FragmentId } from "@cocalc/frontend/misc/fragment-id"; import { allow_project_to_run } from "@cocalc/frontend/project/client-side-throttle"; +import startProjectPayg from "@cocalc/frontend/purchases/pay-as-you-go/start-project"; import { site_license_public_info } from "@cocalc/frontend/site-licenses/util"; import { webapp_client } from "@cocalc/frontend/webapp-client"; import { once } from "@cocalc/util/async-utils"; +import type { StudentProjectFunctionality } from "@cocalc/util/db-schema/projects"; +import type { PurchaseInfo } from "@cocalc/util/licenses/purchase/types"; import { assert_uuid, copy, @@ -28,10 +33,6 @@ import { SiteLicenseQuota } from "@cocalc/util/types/site-licenses"; import { Upgrades } from "@cocalc/util/upgrades/types"; import { ProjectsState, store } from "./store"; import { load_all_projects, switch_to_project } from "./table"; -import type { PurchaseInfo } from "@cocalc/util/licenses/purchase/types"; -import api from "@cocalc/frontend/client/api"; -import type { StudentProjectFunctionality } from "@cocalc/util/db-schema/projects"; -import startProjectPayg from "@cocalc/frontend/purchases/pay-as-you-go/start-project"; import type { CourseInfo, diff --git a/src/packages/next/lib/api/schema/bookmarks.ts b/src/packages/next/lib/api/schema/bookmarks.ts new file mode 100644 index 0000000000..9ff09d41c3 --- /dev/null +++ b/src/packages/next/lib/api/schema/bookmarks.ts @@ -0,0 +1,76 @@ +/* + * This file is part of CoCalc: Copyright © 2024 Sagemath, Inc. + * License: MS-RSL – see LICENSE.md for details + */ + +import { z } from "../framework"; + +import { + LoadStarredFilesBookmarksProps, + SaveStarredFilesBoookmarksProps, +} from "@cocalc/server/bookmarks/starred"; +import { STARRED } from "@cocalc/util/consts/bookmarks"; +import { GetStarredBookmarks } from "@cocalc/util/types/bookmarks"; +import { ProjectIdSchema } from "./projects/common"; + +const ERROR = z.object({ + status: z.literal("error"), + error: z.string(), +}); + +const COMMON_STARRED = z.object({ + project_id: ProjectIdSchema, + type: z.literal(STARRED), +}); + +export const BookmarkSetInputSchema = COMMON_STARRED.extend({ + payload: z.string().array(), +}); + +export const BookmarkSetOutputSchema = z.union([ + COMMON_STARRED.merge(z.object({ status: z.literal("success") })), + ERROR, +]); + +export const BookmarkAddInputSchema = BookmarkSetInputSchema; +export const BookmarkAddOutputSchema = BookmarkSetOutputSchema; + +export const BookmarkRemoveInputSchema = BookmarkSetInputSchema; +export const BookmarkRemoveOutputSchema = BookmarkSetOutputSchema; + +export const BookmarkGetInputSchema = COMMON_STARRED; +export const BookmarkGetOutputSchema = z.union([ + z + .object({ + status: z.literal("success"), + payload: z + .array(z.string()) + .describe( + "Array of file path strings, as they are in the starred tabs flyout", + ), + last_edited: z + .number() + .optional() + .describe("UNIX epoch timestamp, when bookmark was last edited"), + }) + .merge(COMMON_STARRED), + ERROR.merge(COMMON_STARRED), +]); + +export type BookmarkSetInputType = z.infer; +export type BookmarkSetOutputType = z.infer; +export type BookmarkAddInputType = z.infer; +export type BookmarkAddOutputType = z.infer; +export type BookmarkRemoveInputType = z.infer; +export type BookmarkRemoveOutputType = z.infer; +export type BookmarkGetInputType = z.infer; +export type BookmarkGetOutputType = z.infer; + +// consistency checks +export const _1: Omit = + {} as Omit; + +export const _2: Omit = + {} as Omit; + +export const _3: BookmarkGetOutputType = {} as GetStarredBookmarks; diff --git a/src/packages/next/pages/api/v2/bookmarks.test.ts b/src/packages/next/pages/api/v2/bookmarks.test.ts new file mode 100644 index 0000000000..2148cc6dbb --- /dev/null +++ b/src/packages/next/pages/api/v2/bookmarks.test.ts @@ -0,0 +1,20 @@ +import { createMocks } from "lib/api/test-framework"; +// import get from "./bookmarks/get"; +import set from "./bookmarks/set"; + +describe("/api/v2/bookmarks", () => { + test("set then get", async () => { + const { req, res } = createMocks({ + method: "POST", + url: "/api/v2/bookmarks/set", + body: { + type: "starred-files", + payload: { stars: ["foo.md", "bar.ipynb"] }, + }, + }); + + await set(req, res); + expect(res.statusCode).toBe(200); + console.log(res._getJSONData()); + }); +}); diff --git a/src/packages/next/pages/api/v2/bookmarks/add.ts b/src/packages/next/pages/api/v2/bookmarks/add.ts new file mode 100644 index 0000000000..af1b4e2774 --- /dev/null +++ b/src/packages/next/pages/api/v2/bookmarks/add.ts @@ -0,0 +1,88 @@ +/* + * This file is part of CoCalc: Copyright © 2024 Sagemath, Inc. + * License: MS-RSL – see LICENSE.md for details + */ + +/* +Run code in a project. +*/ + +import { Request } from "express"; + +import isCollaborator from "@cocalc/server/projects/is-collaborator"; +import getAccountId from "lib/account/get-account"; +import getParams from "lib/api/get-params"; + +import { getLogger } from "@cocalc/backend/logger"; +import { saveStarredFilesBookmarks } from "@cocalc/server/bookmarks/starred"; +import { STARRED } from "@cocalc/util/consts/bookmarks"; +import { apiRoute, apiRouteOperation } from "lib/api"; +import { + BookmarkAddInputSchema, + BookmarkAddOutputSchema, + BookmarkSetInputSchema, + BookmarkSetOutputType, +} from "lib/api/schema/bookmarks"; + +const L = getLogger("api:v2:bookmark:set"); + +async function handle(req, res) { + try { + res.json(await add(req)); + } catch (err) { + res.json({ error: `${err.message}` }); + return; + } +} + +async function add(req: Request): Promise { + const account_id = await getAccountId(req); + if (!account_id) { + throw Error("must be signed in"); + } + const { project_id, type, payload } = BookmarkSetInputSchema.parse( + getParams(req), + ); + + switch (type) { + case STARRED: { + if (!(await isCollaborator({ account_id, project_id }))) { + throw Error("user must be a collaborator on the project"); + } + + L.debug("set", { project_id, payload }); + await saveStarredFilesBookmarks({ + project_id, + payload, + account_id, + mode: "add", + }); + + return { status: "success", project_id, type }; + } + + default: + return { status: "error", error: `cannot handle type '${type}'` }; + } +} + +export default apiRoute({ + addBookmarks: apiRouteOperation({ + method: "POST", + openApiOperation: { + tags: ["Projects"], + }, + }) + .input({ + contentType: "application/json", + body: BookmarkAddInputSchema, + }) + .outputs([ + { + status: 200, + contentType: "application/json", + body: BookmarkAddOutputSchema, + }, + ]) + .handler(handle), +}); diff --git a/src/packages/next/pages/api/v2/bookmarks/get.ts b/src/packages/next/pages/api/v2/bookmarks/get.ts new file mode 100644 index 0000000000..445d468f58 --- /dev/null +++ b/src/packages/next/pages/api/v2/bookmarks/get.ts @@ -0,0 +1,90 @@ +/* + * This file is part of CoCalc: Copyright © 2024 Sagemath, Inc. + * License: MS-RSL – see LICENSE.md for details + */ + +/* +Run code in a project. +*/ + +import { Request } from "express"; + +import isCollaborator from "@cocalc/server/projects/is-collaborator"; +import getAccountId from "lib/account/get-account"; +import getParams from "lib/api/get-params"; + +import { apiRoute, apiRouteOperation } from "lib/api"; +import { + BookmarkGetOutputSchema, + BookmarkGetInputSchema, + BookmarkGetOutputType, +} from "lib/api/schema/bookmarks"; +import { loadStarredFilesBookmarks } from "@cocalc/server/bookmarks/starred"; +import { STARRED } from "@cocalc/util/consts/bookmarks"; + +async function handle(req, res) { + try { + res.json(await get(req)); + } catch (err) { + res.json({ error: `${err.message}` }); + return; + } +} + +async function get(req: Request): Promise { + const account_id = await getAccountId(req); + if (!account_id) { + throw Error("must be signed in"); + } + const { project_id, type } = BookmarkGetInputSchema.parse(getParams(req)); + + switch (type) { + case STARRED: { + if (!(await isCollaborator({ account_id, project_id }))) { + throw Error("user must be a collaborator on the project"); + } + + const { payload, last_edited } = await loadStarredFilesBookmarks({ + project_id, + account_id, + }); + + return { + type, + project_id, + payload, + last_edited, + status: "success", + }; + } + + default: + return { + type, + project_id, + status: "error", + error: `cannot handle type '${type}'`, + }; + } +} + +export default apiRoute({ + getBookmarks: apiRouteOperation({ + method: "POST", + openApiOperation: { + tags: ["Projects"], + }, + }) + .input({ + contentType: "application/json", + body: BookmarkGetInputSchema, + }) + .outputs([ + { + status: 200, + contentType: "application/json", + body: BookmarkGetOutputSchema, + }, + ]) + .handler(handle), +}); diff --git a/src/packages/next/pages/api/v2/bookmarks/remove.ts b/src/packages/next/pages/api/v2/bookmarks/remove.ts new file mode 100644 index 0000000000..1aa4995caa --- /dev/null +++ b/src/packages/next/pages/api/v2/bookmarks/remove.ts @@ -0,0 +1,88 @@ +/* + * This file is part of CoCalc: Copyright © 2024 Sagemath, Inc. + * License: MS-RSL – see LICENSE.md for details + */ + +/* +Run code in a project. +*/ + +import { Request } from "express"; + +import isCollaborator from "@cocalc/server/projects/is-collaborator"; +import getAccountId from "lib/account/get-account"; +import getParams from "lib/api/get-params"; + +import { getLogger } from "@cocalc/backend/logger"; +import { saveStarredFilesBookmarks } from "@cocalc/server/bookmarks/starred"; +import { STARRED } from "@cocalc/util/consts/bookmarks"; +import { apiRoute, apiRouteOperation } from "lib/api"; +import { + BookmarkRemoveInputSchema, + BookmarkRemoveOutputSchema, + BookmarkSetInputSchema, + BookmarkSetOutputType, +} from "lib/api/schema/bookmarks"; + +const L = getLogger("api:v2:bookmark:set"); + +async function handle(req, res) { + try { + res.json(await add(req)); + } catch (err) { + res.json({ error: `${err.message}` }); + return; + } +} + +async function add(req: Request): Promise { + const account_id = await getAccountId(req); + if (!account_id) { + throw Error("must be signed in"); + } + const { project_id, type, payload } = BookmarkSetInputSchema.parse( + getParams(req), + ); + + switch (type) { + case STARRED: { + if (!(await isCollaborator({ account_id, project_id }))) { + throw Error("user must be a collaborator on the project"); + } + + L.debug("set", { project_id, payload }); + await saveStarredFilesBookmarks({ + project_id, + account_id, + payload, + mode: "remove", + }); + + return { status: "success", project_id, type }; + } + + default: + return { status: "error", error: `cannot handle type '${type}'` }; + } +} + +export default apiRoute({ + removeBookmarks: apiRouteOperation({ + method: "POST", + openApiOperation: { + tags: ["Projects"], + }, + }) + .input({ + contentType: "application/json", + body: BookmarkRemoveInputSchema, + }) + .outputs([ + { + status: 200, + contentType: "application/json", + body: BookmarkRemoveOutputSchema, + }, + ]) + .handler(handle), +}); diff --git a/src/packages/next/pages/api/v2/bookmarks/set.ts b/src/packages/next/pages/api/v2/bookmarks/set.ts new file mode 100644 index 0000000000..d0fa0c473b --- /dev/null +++ b/src/packages/next/pages/api/v2/bookmarks/set.ts @@ -0,0 +1,82 @@ +/* + * This file is part of CoCalc: Copyright © 2024 Sagemath, Inc. + * License: MS-RSL – see LICENSE.md for details + */ + +/* +Run code in a project. +*/ + +import { Request } from "express"; + +import isCollaborator from "@cocalc/server/projects/is-collaborator"; +import getAccountId from "lib/account/get-account"; +import getParams from "lib/api/get-params"; + +import { apiRoute, apiRouteOperation } from "lib/api"; +import { + BookmarkSetOutputSchema, + BookmarkSetInputSchema, + BookmarkSetOutputType, +} from "lib/api/schema/bookmarks"; +import { getLogger } from "@cocalc/backend/logger"; +import { saveStarredFilesBookmarks } from "@cocalc/server/bookmarks/starred"; +import { STARRED } from "@cocalc/util/consts/bookmarks"; + +const L = getLogger("api:v2:bookmark:set"); + +async function handle(req, res) { + try { + res.json(await set(req)); + } catch (err) { + res.json({ error: `${err.message}` }); + return; + } +} + +async function set(req: Request): Promise { + const account_id = await getAccountId(req); + if (!account_id) { + throw Error("must be signed in"); + } + const { project_id, type, payload } = BookmarkSetInputSchema.parse( + getParams(req), + ); + + switch (type) { + case STARRED: { + if (!(await isCollaborator({ account_id, project_id }))) { + throw Error("user must be a collaborator on the project"); + } + + L.debug("set", { project_id, payload }); + await saveStarredFilesBookmarks({ project_id, account_id, payload, mode: "set" }); + + return { status: "success", project_id, type }; + } + + default: + return { status: "error", error: `cannot handle type '${type}'` }; + } +} + +export default apiRoute({ + setBookmarks: apiRouteOperation({ + method: "POST", + openApiOperation: { + tags: ["Projects"], + }, + }) + .input({ + contentType: "application/json", + body: BookmarkSetInputSchema, + }) + .outputs([ + { + status: 200, + contentType: "application/json", + body: BookmarkSetOutputSchema, + }, + ]) + .handler(handle), +}); diff --git a/src/packages/next/pages/api/v2/exec.ts b/src/packages/next/pages/api/v2/exec.ts index b28530ada2..88a4279b94 100644 --- a/src/packages/next/pages/api/v2/exec.ts +++ b/src/packages/next/pages/api/v2/exec.ts @@ -1,3 +1,8 @@ +/* + * This file is part of CoCalc: Copyright © 2024 Sagemath, Inc. + * License: MS-RSL – see LICENSE.md for details + */ + /* Run code in a project. */ diff --git a/src/packages/server/bookmarks/starred.ts b/src/packages/server/bookmarks/starred.ts new file mode 100644 index 0000000000..31f68f2a65 --- /dev/null +++ b/src/packages/server/bookmarks/starred.ts @@ -0,0 +1,130 @@ +/* + * This file is part of CoCalc: Copyright © 2024 Sagemath, Inc. + * License: MS-RSL – see LICENSE.md for details + */ + +import getPool from "@cocalc/database/pool"; +import { STARRED } from "@cocalc/util/consts/bookmarks"; + +const MAX_LENGTH = 2048; + +export type SaveStarredFilesBoookmarksProps = { + project_id: string; + account_id: string; + payload: string[]; + mode: "set" | "add" | "remove"; +}; + +export async function saveStarredFilesBookmarks({ + project_id, + account_id, + payload: stars, + mode, +}: SaveStarredFilesBoookmarksProps): Promise { + const pool = getPool(); + + // test if a row with the given project_id exists in the table projects + const { rows: project } = await pool.query( + `SELECT * FROM projects WHERE project_id = $1`, + [project_id], + ); + + if (project.length === 0) { + throw new Error(`Project '${project_id} does not exist`); + } + + // test that all strings in starred are strings and have a maximum length of $MAX_LENGTH characters + for (const path of stars) { + if (typeof path !== "string" || path.length > MAX_LENGTH) { + throw new Error( + `Invalid starred file path: '${path}' must be a string with a maximum length of ${MAX_LENGTH} characters`, + ); + } + } + + // in-place sort. neat to keep them in a canonical ordering. + stars.sort(); + + const { rows } = await pool.query( + `SELECT id FROM bookmarks WHERE project_id=$1 AND type=$2 AND account_id=$3`, + [project_id, STARRED, account_id], + ); + + const query = async (q, v): Promise => { + const { rows } = await pool.query<{ payload: string[] }>(q, v); + return rows[0].payload; + }; + + if (rows.length > 0) { + switch (mode) { + case "add": { + // Add the new starred items to the list + return query( + `UPDATE bookmarks + SET payload = jsonb_set(payload, '{stars}', (payload->'stars') || array_to_json($1::TEXT[])::JSONB), + last_edited=$2 + WHERE id = $3 AND account_id = $4 + RETURNING payload;`, + [stars, new Date(), rows[0].id, account_id], + ); + } + case "remove": { + // Remove any stars that may exist + return query( + `UPDATE bookmarks + SET payload = jsonb_set(payload, '{stars}', + to_jsonb(array( + SELECT jsonb_array_elements_text(payload -> 'stars') + EXCEPT SELECT unnest($1::TEXT[]) + ))), + last_edited=$2 + WHERE id = $3 AND account_id=$4 + RETURNING payload;`, + [stars, new Date(), rows[0].id, account_id], + ); + } + case "set": { + // Instead of appending the new stars, we replace them with a set operation + return query( + `UPDATE bookmarks SET payload=$1, last_edited=$2 WHERE id = $3 AND account_id=$4 RETURNING payload`, + [{ stars }, new Date(), rows[0].id, account_id], + ); + } + } + } else { + return query( + `INSERT INTO bookmarks (type, project_id, payload, account_id, last_edited) + VALUES ($1, $2, $3, $4, $5) RETURNING payload`, + [STARRED, project_id, { stars }, account_id, new Date()], + ); + } +} + +export type LoadStarredFilesBookmarksProps = { + project_id: string; + account_id: string; +}; + +export async function loadStarredFilesBookmarks({ + project_id, + account_id, +}: LoadStarredFilesBookmarksProps): Promise<{ + payload: string[]; + last_edited?: number; +}> { + const pool = getPool(); + const { rows } = await pool.query( + `SELECT payload, last_edited FROM bookmarks WHERE project_id=$1 AND type=$2 AND account_id=$3`, + [project_id, STARRED, account_id], + ); + + if (rows.length > 0) { + const row = rows[0]; + return { + payload: row.payload?.stars ?? [], + last_edited: row.last_edited?.getTime(), + }; + } else { + return { payload: [] }; + } +} diff --git a/src/packages/server/package.json b/src/packages/server/package.json index 92069aa601..9638bda0a4 100644 --- a/src/packages/server/package.json +++ b/src/packages/server/package.json @@ -7,6 +7,7 @@ "./auth/*": "./dist/auth/*.js", "./api/*": "./dist/api/*.js", "./accounts/*": "./dist/accounts/*.js", + "./bookmarks/*": "./dist/bookmarks/*.js", "./compute/*": "./dist/compute/*.js", "./compute/maintenance": "./dist/compute/maintenance/index.js", "./database/*": "./dist/database/*.js", diff --git a/src/packages/util/consts/bookmarks.ts b/src/packages/util/consts/bookmarks.ts new file mode 100644 index 0000000000..5b4b59ee51 --- /dev/null +++ b/src/packages/util/consts/bookmarks.ts @@ -0,0 +1 @@ +export const STARRED = "starred-files"; diff --git a/src/packages/util/db-schema/bookmarks.ts b/src/packages/util/db-schema/bookmarks.ts new file mode 100644 index 0000000000..9ca83081e9 --- /dev/null +++ b/src/packages/util/db-schema/bookmarks.ts @@ -0,0 +1,45 @@ +/* + * This file is part of CoCalc: Copyright © 2024 Sagemath, Inc. + * License: MS-RSL – see LICENSE.md for details + */ + +import { Table } from "./types"; +import { ID } from "./crm"; + +// This table stores various types of bookmarks. This started with backing up starred tabs for a user in a project. +Table({ + name: "bookmarks", + fields: { + id: ID, + type: { + type: "string", + desc: "Type of bookmark as defined in @cocalc/util/consts/bookmarks", + }, + project_id: { + type: "uuid", + desc: "The Project ID where this bookmark belongs to", + }, + account_id: { + type: "uuid", + desc: "(optional) if not set, this bookmark is project wide, for all collaborators", + }, + path: { + type: "string", + desc: "(optional) path to a specific file in the project", + }, + payload: { + type: "map", + desc: "a JSON-type data for this bookmark, e.g. a list of strings for IDs", + }, + last_edited: { + type: "timestamp", + desc: "When the bookmark last changed", + }, + }, + rules: { + desc: "Table for various types of bookmarks.", + primary_key: "id", + pg_indexes: ["type", "project_id", "account_id"], + user_query: {}, + }, +}); diff --git a/src/packages/util/db-schema/crm.ts b/src/packages/util/db-schema/crm.ts index e140483ec9..03ea8a7af9 100644 --- a/src/packages/util/db-schema/crm.ts +++ b/src/packages/util/db-schema/crm.ts @@ -12,32 +12,32 @@ extra things for all crm_ tables to ensure safety, e.g., ensuring admin. import { FieldSpec, Table } from "./types"; import { blue, green, red, yellow } from "@ant-design/colors"; -export const NOTES = { +export const NOTES: FieldSpec = { type: "string", desc: "Open ended text in markdown about this item.", render: { type: "markdown", editable: true, }, -} as FieldSpec; +} as const; -export const ID = { +export const ID: FieldSpec = { type: "integer", desc: "Automatically generated sequential id that uniquely determines this row.", pg_type: "SERIAL UNIQUE", noCoerce: true, -} as FieldSpec; +} as const; const TAG_TYPE = `INTEGER[]`; -const TAGS_FIELD = { +const TAGS_FIELD: FieldSpec = { type: "array", pg_type: TAG_TYPE, desc: "Tags applied to this record.", render: { type: "tags", editable: true }, -} as FieldSpec; +} as const; -const PRORITIES_FIELD = { +const PRORITIES_FIELD: FieldSpec = { type: "string", pg_type: "VARCHAR(30)", desc: "Priority of this record", @@ -48,9 +48,9 @@ const PRORITIES_FIELD = { colors: [yellow[5], blue[5], green[5], red[5]], priority: true, }, -} as FieldSpec; +} as const; -const STATUS_FIELD = { +const STATUS_FIELD: FieldSpec = { type: "string", pg_type: "VARCHAR(30)", desc: "Status of this record", @@ -60,32 +60,32 @@ const STATUS_FIELD = { options: ["new", "open", "pending", "active", "solved"], colors: [yellow[5], red[5], green[5], blue[5], "#888"], }, -} as FieldSpec; +} as const; -export const CREATED = { +export const CREATED: FieldSpec = { type: "timestamp", desc: "When the record was created.", -} as FieldSpec; +} as const; -export const LAST_EDITED = { +export const LAST_EDITED: FieldSpec = { type: "timestamp", desc: "When this record was last edited.", -} as FieldSpec; +} as const; -const LAST_MODIFIED_BY = { +const LAST_MODIFIED_BY: FieldSpec = { type: "uuid", desc: "Account that last modified this task.", render: { type: "account" }, -} as FieldSpec; +} as const; -const ASSIGNEE = { +const ASSIGNEE: FieldSpec = { type: "uuid", desc: "Account that is responsible for resolving this.", render: { type: "assignee", editable: true, }, -} as FieldSpec; +} as const; Table({ name: "crm_people", diff --git a/src/packages/util/db-schema/index.ts b/src/packages/util/db-schema/index.ts index ff58b59ba9..0431a9f56c 100644 --- a/src/packages/util/db-schema/index.ts +++ b/src/packages/util/db-schema/index.ts @@ -21,6 +21,7 @@ import "./accounts"; import "./api-keys"; import "./auth"; import "./blobs"; +import "./bookmarks"; import "./central-log"; import "./client-error-log"; import "./cloud-filesystems"; diff --git a/src/packages/util/types/bookmarks.ts b/src/packages/util/types/bookmarks.ts new file mode 100644 index 0000000000..45a1c8d3dd --- /dev/null +++ b/src/packages/util/types/bookmarks.ts @@ -0,0 +1,17 @@ +type GetStarredBookmarksCommon = { + type: "starred-files"; + project_id: string; +}; + +export type GetStarredBookmarks = GetStarredBookmarksCommon & + ( + | { + status: "success"; + payload: string[]; + last_edited?: number ; + } + | { + status: "error"; + error: string; + } + );