From cb7d5a3eedc61c7ca3908fa2069793d184af3a2d Mon Sep 17 00:00:00 2001 From: zkldi <20380519+zkldi@users.noreply.github.com> Date: Sun, 25 Sep 2022 16:41:53 +0100 Subject: [PATCH] feat(seeds): diffing, reading remotes, everything else --- client/example/.env | 1 + client/package.json | 1 + .../app/pages/dashboard/utils/SeedsViewer.tsx | 646 ++++-------------- client/src/components/seeds/SeedsPicker.tsx | 482 +++++++++++++ .../src/components/seeds/SeedsStateViewer.tsx | 264 ++++++- .../src/components/tables/cells/ObjCell.tsx | 14 +- .../components/tables/cells/SeedsDiffCell.tsx | 49 ++ .../tables/cells/SeedsIndicatorCell.tsx | 43 ++ .../components/FilterDirectivesIndicator.tsx | 4 +- .../tables/components/TachiTable.tsx | 5 +- .../tables/seeds/MakeSeedsDiffTable.tsx | 71 ++ .../tables/seeds/MakeSeedsTable.tsx | 46 ++ .../seeds/SeedsBMSCourseLookupTable.tsx | 150 ++-- .../tables/seeds/SeedsChartsTable.tsx | 107 ++- .../tables/seeds/SeedsFolderTable.tsx | 126 ++-- .../tables/seeds/SeedsSongsTable.tsx | 82 +-- .../components/tables/seeds/SeedsTable.tsx | 263 ++++++- .../tables/seeds/SeedsTableTable.tsx | 129 ++-- client/src/components/util/SelectButton.tsx | 11 +- client/src/types/git.ts | 3 + client/src/types/seeds.ts | 11 + client/src/util/misc.ts | 106 +++ client/src/util/seeds.ts | 277 +++++++- client/src/util/ztable/search.ts | 2 + .../collections/bms-course-lookup.json | 4 +- pnpm-lock.yaml | 2 + 26 files changed, 1997 insertions(+), 902 deletions(-) create mode 100644 client/src/components/seeds/SeedsPicker.tsx create mode 100644 client/src/components/tables/cells/SeedsDiffCell.tsx create mode 100644 client/src/components/tables/cells/SeedsIndicatorCell.tsx create mode 100644 client/src/components/tables/seeds/MakeSeedsDiffTable.tsx create mode 100644 client/src/components/tables/seeds/MakeSeedsTable.tsx diff --git a/client/example/.env b/client/example/.env index 7d0cc037c..79dece187 100644 --- a/client/example/.env +++ b/client/example/.env @@ -2,6 +2,7 @@ BROWSER="none" REACT_APP_TCHIC_MODE="omni" REACT_APP_SERVER_URL="https://127.0.0.1:8080" REACT_APP_CDN_URL="https://127.0.0.1:8080/cdn" +REACT_APP_GIT_REPO="GitHub:TNG-dev/Tachi" REACT_APP_IMGSCORE_URL="" REACT_APP_EAG_CLIENT_ID="" REACT_APP_MIN_CLIENT_ID="" diff --git a/client/package.json b/client/package.json index 1d51c68f6..a6a37359e 100644 --- a/client/package.json +++ b/client/package.json @@ -40,6 +40,7 @@ "deepmerge": "4.2.2", "dotenv": "6.2.0", "dotenv-expand": "5.1.0", + "fast-json-stable-hash": "^1.0.3", "file-loader": "3.0.1", "formik": "2.2.9", "fs-extra": "7.0.1", diff --git a/client/src/app/pages/dashboard/utils/SeedsViewer.tsx b/client/src/app/pages/dashboard/utils/SeedsViewer.tsx index 9c892b35c..2c31b51ed 100644 --- a/client/src/app/pages/dashboard/utils/SeedsViewer.tsx +++ b/client/src/app/pages/dashboard/utils/SeedsViewer.tsx @@ -1,24 +1,17 @@ -import { FormatTime } from "util/time"; import { APIFetchV1 } from "util/api"; -import { LoadSeeds } from "util/seeds"; -import { StrSOV } from "util/sorts"; import useSetSubheader from "components/layout/header/useSetSubheader"; +import SeedsPicker from "components/seeds/SeedsPicker"; +import SeedsStateViewer from "components/seeds/SeedsStateViewer"; +import Divider from "components/util/Divider"; +import ExternalLink from "components/util/ExternalLink"; import Loading from "components/util/Loading"; import useApiQuery from "components/util/query/useApiQuery"; -import Select from "components/util/Select"; -import React, { useEffect, useMemo, useState } from "react"; -import { Badge, Button, Col, Modal, Row } from "react-bootstrap"; -import Divider from "components/util/Divider"; -import Icon from "components/util/Icon"; -import { SetState } from "types/react"; -import { TachiConfig } from "lib/config"; -import ExternalLink from "components/util/ExternalLink"; -import Card from "components/layout/page/Card"; -import { GitCommit, Revision } from "types/git"; -import { AllDatabaseSeeds, DatabaseSeedNames } from "tachi-common"; import SelectButton from "components/util/SelectButton"; -import FormInput from "components/util/FormInput"; -import SeedsTable from "components/tables/seeds/SeedsTable"; +import { TachiConfig } from "lib/config"; +import React, { useEffect, useState } from "react"; +import { Col, Row } from "react-bootstrap"; +import { useParams } from "react-router-dom"; +import { GitCommit, Revision } from "types/git"; export default function SeedsViewer() { useSetSubheader(["Developer Utils", "Database Seeds Management"]); @@ -70,6 +63,93 @@ function InnerSeedsViewer({ hasLocalAPI }: { hasLocalAPI: boolean }) { // the base, but no order is enforced. const [baseRev, setBaseRev] = useState(null); const [headRev, setHeadRev] = useState(null); + const [urlLoading, setURLLoading] = useState(false); + const [isFirstLoad, setIsFirstLoad] = useState(true); + + const params = useParams<{ repo: string | undefined; sha: string | undefined }>(); + + console.log(params); + + const { repo, sha } = params; + + const [view, setView] = useState<"DIFF" | "FULL">("DIFF"); + + useEffect(() => { + if (!isFirstLoad) { + return; + } + + // if we've been given stuff in the URL and don't already have a baseRev + if (sha && repo && !baseRev) { + setURLLoading(true); + + (async () => { + if (repo === "local") { + const params = new URLSearchParams({ sha }); + + const res = await APIFetchV1(`/seeds/commit?${params.toString()}`); + + if (!res.success) { + throw new Error( + `Failed to fetch info about commit ${sha}: ${res.description}.` + ); + } + + setBaseRev({ + c: res.body, + repo, + }); + + setURLLoading(false); + + setIsFirstLoad(false); + } else if (repo.startsWith("GitHub:")) { + const repoName = repo.slice("GitHub:".length); + + const res: GitCommit = await fetch( + `https://github.com/${repoName}/seeds/commit/${sha}}` + ).then((r) => r.json()); + + setBaseRev({ + c: res, + repo, + }); + + setURLLoading(false); + + setIsFirstLoad(false); + } + })(); + } + }, [isFirstLoad]); + + useEffect(() => { + if (baseRev) { + const params = new URLSearchParams({ + repo: baseRev.repo, + sha: baseRev.c.sha, + }); + + window.history.replaceState(null, "", `?${params.toString()}`); + } else { + window.history.replaceState(null, "", ""); + } + }, [baseRev]); + + // if sha XOR repo, this should not be allowed. + if (!!sha !== !!repo) { + return
Invalid reference, both a repo and a sha must be provided in the URL.
; + } + + if (urlLoading) { + return ( +
+ +
+ Fetching information from the provided URL... +
+ ); + } return ( <> @@ -87,7 +167,8 @@ function InnerSeedsViewer({ hasLocalAPI }: { hasLocalAPI: boolean }) { @@ -98,522 +179,27 @@ function InnerSeedsViewer({ hasLocalAPI }: { hasLocalAPI: boolean }) { + {!headRev && ( +
+ + View Changes + + + View Full State + +
+ )} + + - +
)} ); } - -function SeedsLoaderViewer({ baseRev, headRev }: { baseRev: Revision; headRev: Revision | null }) { - const [baseData, setBaseData] = useState | null>(null); - const [headData, setHeadData] = useState | null>(null); - - useEffect(() => { - (async () => { - // enter loading... - setBaseData(null); - const data = await LoadSeeds(baseRev.repo, baseRev.c.sha); - - setBaseData(data); - })(); - }, [baseRev]); - - useEffect(() => { - (async () => { - // enter loading... - setHeadData(null); - - if (headRev === null) { - return; - } - - const data = await LoadSeeds(headRev.repo, headRev.c.sha); - - setHeadData(data); - })(); - }, [headRev]); - - if (baseData === null) { - return ( - -
-
- -
- Reading data... -
-
- - ); - } - - if (headData !== null) { - return <>{Object.keys(headData)}; - } - - return ; -} - -function SeedsAbsoluteState({ seedsData }: { seedsData: Partial }) { - const files = Object.keys(seedsData).sort(StrSOV((k) => k)) as Array; - - const [file, setFile] = useState(files[0]); - - return ( - <> - - -
- {files.map((e) => ( -
- - {e} ({seedsData[e]?.length ?? 0}) - -
- ))} -
-
- - - -

{file}

- - - - - ); -} - -type Branch = { name: string; sha: string }; - -function SeedsPicker({ - hasLocalAPI, - header, - rev, - setRev, - message, -}: { - hasLocalAPI: boolean; - header: string; - rev: Revision | null; - setRev: SetState; - message?: string; -}) { - // a repo is one of the following: - // null - nothing has been selected yet - // "local" - we're referring to the files on the local development disk - // "GitHub:NAME/REPO" - we're referring to a repository on github, like GitHub:TNG-Dev/Tachi - const [repo, setRepo] = useState(null); - - // to list commits, we need to know what branch we're looking at. - // we also need to know the set of all available branches - const [branch, setBranch] = useState(null); - const [branches, setBranches] = useState(null); - - const [shaBranchLookup, setSHABranchLookup] = useState>({}); - - // When repo changes, refetch the available branches. - useEffect(() => { - if (repo === null) { - return setBranches(null); - } - - (async () => { - if (repo === "local") { - const res = await APIFetchV1<{ branches: Branch[]; current: Branch | null }>( - `/seeds/branches` - ); - - if (!res.success) { - throw new Error( - `Failed to fetch branches for your local repo. ${res.description}.` - ); - } - - setBranches(res.body.branches.map((e) => e.name)); - - // can't be bothered getting this to work - // if (res.body.current) { - // setBranch(res.body.current.name); - // } - - // to get pretty UI stuff, we keep track of the HEADs of all branches - // so we can render what branch a commit is the HEAD of in the UI. - const lookup: Record = {}; - - for (const branch of res.body.branches) { - if (branch.sha in lookup) { - lookup[branch.sha].push(branch.name); - } else { - lookup[branch.sha] = [branch.name]; - } - } - - setSHABranchLookup(lookup); - } else if (repo.startsWith("GitHub:")) { - throw new Error("TODO"); - } - })(); - }, [repo]); - - // if branches change, re-prompt for users to select a branch - useEffect(() => { - setBranch(null); - }, [branches]); - - const [show, setShow] = useState(false); - - const [subject, body] = useMemo(() => { - if (rev === null) { - return [null, null]; - } - - const [subject, _gap, ...maybeBodies] = rev.c.commit.message.split("\n"); - - return [subject, maybeBodies?.join("\n") ?? null]; - }, [rev]); - - const [showBody, setShowBody] = useState(false); - - const authorNotCommitter = rev?.c.commit.author.email !== rev?.c.commit.committer.email; - - return ( - <> - - {rev ? ( -
- - - {rev.repo}/{rev.c.sha} - - : {subject} - - {body && ( - setShowBody(!showBody)} - > - {showBody ? ( - - ) : ( - - )} - - )} - {showBody &&
{body}
} - -
-
- {authorNotCommitter ? ( - <> - Authored by {rev.c.commit.author.name}, Committed by{" "} - {rev.c.commit.committer.name} - - ) : ( - <> - Authored by {rev.c.commit.author.name} - - )} -
-
- {FormatTime(Date.parse(rev.c.commit.author.date))} -
-
- -
- setShow(true)} - > - Change Commit... - -
-
- ) : ( - <> - {message && ( - <> - {message} - - - )} - - - - )} -
- setShow(false)}> - - Pick {header} - - - - -
- Repository: - -
- {branches && ( -
- Branch: - -
- )} - - - {repo && branch && ( - <> - - { - setRev({ c: commit, repo }); - setShow(false); - }} - /> - - )} - -
-
-
- - ); -} - -function RevSelector({ - repo, - onSelect, - branch, - shaBranchLookup, -}: { - repo: string; - onSelect: (g: GitCommit) => void; - branch: string; - shaBranchLookup: Record; -}) { - const [revs, setRevs] = useState>([]); - const [filteredRevs, setFilteredRevs] = useState>([]); - - // We have the option of filtering returned revisions according to only those that - // affect a specific file. Sounds useful. - const [collection, setCollection] = useState(null); - - useEffect(() => { - (async () => { - if (repo.startsWith("GitHub:")) { - throw new Error("Unsupported..."); - } else { - // startLoading - setRevs([]); - - const params = new URLSearchParams(); - params.set("branch", branch); - - if (collection) { - params.set("file", collection); - } - - // local - const res = await APIFetchV1>( - `/seeds/commits?${params.toString()}` - ); - - if (!res.success) { - throw new Error(`Failed to fetch commits? ${res.description}`); - } - - const hasUncommittedRes = await APIFetchV1( - "/seeds/has-uncommitted-changes" - ); - - if (hasUncommittedRes.success && hasUncommittedRes.body) { - const LOCAL_COMMIT: GitCommit = { - sha: "WORKING_DIRECTORY", - commit: { - author: { - name: "Not Committed Yet", - date: "1970-01-01", - email: "null@example.com", - }, - committer: { - name: "Not Committed Yet", - date: "1970-01-01", - email: "null@example.com", - }, - message: "Uncommitted changes on your local disk.", - }, - }; - - setRevs([LOCAL_COMMIT, ...res.body]); - } else { - setRevs(res.body); - } - } - })(); - }, [repo, branch, collection]); - - useEffect(() => { - setFilteredRevs(revs); - setSearch(""); - }, [revs]); - - const [search, setSearch] = useState(""); - - useEffect(() => { - if (search === "") { - return setFilteredRevs(revs); - } - - // remarkably lazy search implementation, but convenient. - return setFilteredRevs( - revs.filter( - (r) => - r.sha.includes(search) || - r.commit.message.includes(search) || - r.commit.author.name.includes(search) || - r.commit.committer.name.includes(search) - ) - ); - }, [search]); - - return ( - <> -
- Show changes that affect: - -
- - - -
-
- {filteredRevs.map((r) => ( - - ))} -
- - ); -} - -function Revision({ - rev, - tags = [], - onSelect: onSelect, -}: { - rev: GitCommit; - tags?: string[]; - onSelect: (g: GitCommit) => void; -}) { - const authorNotCommitter = rev.commit.author.email !== rev.commit.committer.email; - - // if there's a body then there's two newlines. - const [subject, _gap, ...maybeBodies] = rev.commit.message.split("\n"); - - const [showBody, setShowBody] = useState(false); - - const body: string | undefined = maybeBodies?.join("\n"); - - return ( -
- -
-
- - {rev.sha}:{" "} - onSelect(rev)}> - {subject} - - - {body && ( - setShowBody(!showBody)} - > - {showBody ? ( - - ) : ( - - )} - - )} - {tags.length !== 0 && ( - - {tags.map((e) => ( - - {e} - - ))} - - )} - {showBody &&
{body}
} -
- {authorNotCommitter ? ( - <> - Authored by {rev.commit.author.name}, Committed by{" "} - {rev.commit.committer.name} - - ) : ( - <> - Authored by {rev.commit.author.name} - - )} -
-
- - - {FormatTime(Date.parse(rev.commit.author.date))} - -
-
- ); -} diff --git a/client/src/components/seeds/SeedsPicker.tsx b/client/src/components/seeds/SeedsPicker.tsx new file mode 100644 index 000000000..37ddd232b --- /dev/null +++ b/client/src/components/seeds/SeedsPicker.tsx @@ -0,0 +1,482 @@ +import { APIFetchV1 } from "util/api"; +import { FormatTime } from "util/time"; +import { EscapeStringRegexp, TruncateString } from "util/misc"; +import Divider from "components/util/Divider"; +import FormInput from "components/util/FormInput"; +import Icon from "components/util/Icon"; +import Select from "components/util/Select"; +import React, { useState, useEffect, useMemo } from "react"; +import { Button, Modal, Row, Col, Badge } from "react-bootstrap"; +import { DatabaseSeedNames } from "tachi-common"; +import { Branch, GitCommit, Revision } from "types/git"; +import { SetState } from "types/react"; +import Card from "components/layout/page/Card"; + +export default function SeedsPicker({ + hasLocalAPI, + header, + rev, + setRev, + message, +}: { + hasLocalAPI: boolean; + header: string; + rev: Revision | null; + setRev: SetState; + message?: string; +}) { + // a repo is one of the following: + // null - nothing has been selected yet + // "local" - we're referring to the files on the local development disk + // "GitHub:NAME/REPO" - we're referring to a repository on github, like GitHub:TNG-Dev/Tachi + const [repo, setRepo] = useState(null); + + // to list commits, we need to know what branch we're looking at. + // we also need to know the set of all available branches + const [branch, setBranch] = useState(null); + const [branches, setBranches] = useState(null); + + const [shaBranchLookup, setSHABranchLookup] = useState>({}); + + // When repo changes, refetch the available branches. + useEffect(() => { + if (repo === null) { + return setBranches(null); + } + + (async () => { + if (repo === "local") { + const res = await APIFetchV1<{ branches: Branch[]; current: Branch | null }>( + `/seeds/branches` + ); + + if (!res.success) { + throw new Error( + `Failed to fetch branches for your local repo. ${res.description}.` + ); + } + + setBranches(res.body.branches.map((e) => e.name)); + + // can't be bothered getting this to work + // if (res.body.current) { + // setBranch(res.body.current.name); + // } + + // to get pretty UI stuff, we keep track of the HEADs of all branches + // so we can render what branch a commit is the HEAD of in the UI. + const lookup: Record = {}; + + for (const branch of res.body.branches) { + if (branch.sha in lookup) { + lookup[branch.sha].push(branch.name); + } else { + lookup[branch.sha] = [branch.name]; + } + } + + setSHABranchLookup(lookup); + } else if (repo.startsWith("GitHub:")) { + const repoName = repo.slice("GitHub:".length); + + console.log(repoName); + + const res: Array<{ name: string; commit: { sha: string } }> = await fetch( + `https://api.github.com/repos/${repoName}/branches` + ).then((r) => r.json()); + + setBranches(res.map((e) => e.name)); + + const lookup: Record = {}; + + for (const branch of res) { + if (branch.commit.sha in lookup) { + lookup[branch.commit.sha].push(branch.name); + } else { + lookup[branch.commit.sha] = [branch.name]; + } + } + + setSHABranchLookup(lookup); + } + })(); + }, [repo]); + + // if branches change, re-prompt for users to select a branch + useEffect(() => { + setBranch(null); + }, [branches]); + + const [show, setShow] = useState(false); + + const [subject, body] = useMemo(() => { + if (rev === null) { + return [null, null]; + } + + const [subject, _gap, ...maybeBodies] = rev.c.commit.message.split("\n"); + + return [subject, maybeBodies?.join("\n") ?? null]; + }, [rev]); + + const [showBody, setShowBody] = useState(false); + + const authorNotCommitter = rev?.c.commit.author.email !== rev?.c.commit.committer.email; + + return ( + <> + + {rev ? ( +
+ + + {rev.repo}/{rev.c.sha} + + : {subject} + + {body && ( + setShowBody(!showBody)} + > + {showBody ? ( + + ) : ( + + )} + + )} + {showBody &&
{body}
} + +
+
+ {authorNotCommitter ? ( + <> + Authored by {rev.c.commit.author.name}, Committed by{" "} + {rev.c.commit.committer.name} + + ) : ( + <> + Authored by {rev.c.commit.author.name} + + )} +
+
+ {FormatTime(Date.parse(rev.c.commit.author.date))} +
+
+ +
+ +
+
+ ) : ( + <> + {message && ( + <> + {message} + + + )} + + + + )} +
+ setShow(false)}> + + Pick {header} + + + + +
+ Repository: + +
+ {branches && ( +
+ Branch: + +
+ )} + + + {repo && branch && ( + <> + + { + setRev({ c: commit, repo }); + setShow(false); + }} + /> + + )} + +
+
+
+ + ); +} + +function RevSelector({ + repo, + onSelect, + branch, + shaBranchLookup, +}: { + repo: string; + onSelect: (g: GitCommit) => void; + branch: string; + shaBranchLookup: Record; +}) { + const [revs, setRevs] = useState>([]); + const [filteredRevs, setFilteredRevs] = useState>([]); + + // We have the option of filtering returned revisions according to only those that + // affect a specific file. Sounds useful. + const [collection, setCollection] = useState(null); + + useEffect(() => { + (async () => { + if (repo.startsWith("GitHub:")) { + setRevs([]); + const repoName = repo.slice("GitHub:".length); + + const params = new URLSearchParams(); + + if (collection) { + params.set("path", `database-seeds/collections/${collection}`); + } else { + params.set("path", "database-seeds/collections"); + } + + const res = await fetch( + `https://api.github.com/repos/${repoName}/commits?${params.toString()}` + ).then((r) => r.json()); + + setRevs(res); + } else { + setRevs([]); + + const params = new URLSearchParams(); + params.set("branch", branch); + + if (collection) { + params.set("file", collection); + } + + // local + const res = await APIFetchV1>( + `/seeds/commits?${params.toString()}` + ); + + if (!res.success) { + throw new Error(`Failed to fetch commits? ${res.description}`); + } + + const hasUncommittedRes = await APIFetchV1( + "/seeds/has-uncommitted-changes" + ); + + if (hasUncommittedRes.success && hasUncommittedRes.body) { + const LOCAL_COMMIT: GitCommit = { + sha: "WORKING_DIRECTORY", + commit: { + author: { + name: "Not Committed Yet", + date: "1970-01-01", + email: "null@example.com", + }, + committer: { + name: "Not Committed Yet", + date: "1970-01-01", + email: "null@example.com", + }, + message: "Uncommitted changes on your local disk.", + }, + parents: res.body[0] ? [{ sha: res.body[0].sha }] : [], + }; + + setRevs([LOCAL_COMMIT, ...res.body]); + } else { + setRevs(res.body); + } + } + })(); + }, [repo, branch, collection]); + + useEffect(() => { + setFilteredRevs(revs); + setSearch(""); + }, [revs]); + + const [search, setSearch] = useState(""); + + useEffect(() => { + if (search === "") { + return setFilteredRevs(revs); + } + + const regexpSearch = new RegExp(EscapeStringRegexp(search), "ui"); + + // remarkably lazy search implementation, but convenient. + return setFilteredRevs( + revs.filter( + (r) => + r.sha.match(regexpSearch) || + r.commit.message.match(regexpSearch) || + r.commit.author.name.match(regexpSearch) || + r.commit.committer.name.match(regexpSearch) + ) + ); + }, [search]); + + return ( + <> +
+ Show changes that affect: + +
+ + + +
+
+ {filteredRevs.map((r) => ( + + ))} +
+ + ); +} + +function Revision({ + rev, + tags = [], + onSelect: onSelect, +}: { + rev: GitCommit; + tags?: string[]; + onSelect: (g: GitCommit) => void; +}) { + const authorNotCommitter = rev.commit.author.email !== rev.commit.committer.email; + + // if there's a body then there's two newlines. + const [subject, _gap, ...maybeBodies] = rev.commit.message.split("\n"); + + const [showBody, setShowBody] = useState(false); + + const body: string | undefined = maybeBodies?.join("\n"); + + return ( +
+ +
+
+ + {TruncateString(rev.sha, 9)}:{" "} + onSelect(rev)}> + {subject} + + + {body && ( + setShowBody(!showBody)} + > + {showBody ? ( + + ) : ( + + )} + + )} + {tags.length !== 0 && ( + + {tags.map((e) => ( + + {e} + + ))} + + )} + {showBody &&
{body}
} +
+ {authorNotCommitter ? ( + <> + Authored by {rev.commit.author.name}, Committed by{" "} + {rev.commit.committer.name} + + ) : ( + <> + Authored by {rev.commit.author.name} + + )} +
+
+ + + {FormatTime(Date.parse(rev.commit.author.date))} + +
+
+ ); +} diff --git a/client/src/components/seeds/SeedsStateViewer.tsx b/client/src/components/seeds/SeedsStateViewer.tsx index 4e20b2c8d..ec30f891e 100644 --- a/client/src/components/seeds/SeedsStateViewer.tsx +++ b/client/src/components/seeds/SeedsStateViewer.tsx @@ -1,5 +1,263 @@ -import React from "react"; +import { ChangeOpacity } from "util/color-opacity"; +import { JoinJSX } from "util/misc"; +import { LoadSeeds, DiffSeeds } from "util/seeds"; +import { StrSOV } from "util/sorts"; +import SeedsTable, { SeedsDiffTable } from "components/tables/seeds/SeedsTable"; +import Loading from "components/util/Loading"; +import SelectButton from "components/util/SelectButton"; +import { ColourConfig } from "lib/config"; +import React, { useEffect, useMemo, useState } from "react"; +import { Col, Button } from "react-bootstrap"; +import Card from "components/layout/page/Card"; +import { AllDatabaseSeeds } from "tachi-common"; +import { Revision } from "types/git"; +import Divider from "components/util/Divider"; -export default function SeedsStateViewer() { - return
SeedsStateViewer
; +export default function SeedsStateViewer({ + baseRev, + headRev, + view, +}: { + baseRev: Revision; + headRev: Revision | null; + view: "DIFF" | "FULL"; +}) { + const [baseData, setBaseData] = useState | null>(null); + + // typescript lets me get away with insane enums like this, so damnit i will. + // "LOADING" is different to headData being null (not picked), unlike baseRev, which cannot be null. + const [headData, setHeadData] = useState | null | "LOADING">(null); + + useEffect(() => { + (async () => { + // enter loading... + setBaseData(null); + const data = await LoadSeeds(baseRev.repo, baseRev.c.sha); + + setBaseData(data); + })(); + }, [baseRev]); + + useEffect(() => { + (async () => { + // enter loading... + setHeadData("LOADING"); + + if (headRev === null) { + setHeadData(null); + return; + } + + const data = await LoadSeeds(headRev.repo, headRev.c.sha); + + setHeadData(data); + })(); + }, [headRev]); + + if (baseData === null || headData === "LOADING") { + return ( + +
+
+ +
+ Reading data... +
+
+ + ); + } + + if (headData !== null) { + return ; + } + + if (view === "DIFF") { + if (baseRev.c.parents.length === 0) { + // no parents, diff against the empty set. + return ; + } else if (baseRev.c.parents.length > 1) { + // more than one parent, i have no idea what to do? + return ( +
+ This commit has multiple parents. Diffing against this isn't supported yet, + sorry. + {baseRev.c.parents.join(", ")} +
+ ); + } + + return ; + } + + return ; +} + +function SeedsDiffParentState({ + seedsData, + baseRev, +}: { + seedsData: Partial; + baseRev: Revision; +}) { + const [parentData, setParentData] = useState | null>(null); + + useEffect(() => { + (async () => { + // loading... + setParentData(null); + + const data = await LoadSeeds(baseRev.repo, baseRev.c.parents[0].sha); + + setParentData(data); + })(); + }, [baseRev]); + + if (parentData === null) { + return ( + +
+
+ +
+ Comparing against previous state... (This takes a bit.) +
+
+ + ); + } + + return ; +} + +function SeedsAbsoluteState({ seedsData }: { seedsData: Partial }) { + const files = Object.keys(seedsData).sort(StrSOV((k) => k)) as Array; + + const [file, setFile] = useState(files[0]); + + return ( + <> + + +
+ {files.map((e) => ( +
+ + {e} ({seedsData[e]?.length ?? 0}) + +
+ ))} +
+
+ + + +

{file}

+ + + + + ); +} + +function SeedsDiffState({ + baseData, + headData, +}: { + baseData: Partial; + headData: Partial; +}) { + const seedsDiff = useMemo(() => DiffSeeds(baseData, headData), [baseData, headData]); + + const files = Object.keys(seedsDiff).sort(StrSOV((k) => k)) as Array; + + // don't display anything with 0 diffs. + const filesWithDiffs = files.filter((e) => seedsDiff[e]?.length !== 0); + + const [file, setFile] = useState(filesWithDiffs[0] ?? null); + + return ( + + +
+ {filesWithDiffs.length > 0 ? ( + filesWithDiffs.map((e) => { + let added = 0; + let modified = 0; + let deleted = 0; + + for (const diff of seedsDiff[e]!) { + switch (diff.type) { + case "ADDED": + added++; + break; + case "DELETED": + deleted++; + break; + case "MODIFIED": + modified++; + break; + } + } + + const display = []; + + if (added) { + display.push(+{added}); + } + + if (modified) { + display.push(~{modified}); + } + + if (deleted) { + display.push(-{deleted}); + } + + return ( +
+ +
+ ); + }) + ) : ( +
There are no changes between these two commits.
+ )} +
+
+ {file && ( + + +

{file}

+ + + + )} + + ); } diff --git a/client/src/components/tables/cells/ObjCell.tsx b/client/src/components/tables/cells/ObjCell.tsx index 61a3e9a58..196528553 100644 --- a/client/src/components/tables/cells/ObjCell.tsx +++ b/client/src/components/tables/cells/ObjCell.tsx @@ -5,12 +5,14 @@ export default function ObjCell({ data }: { data: unknown }) { return ( {/* this kinda sucks. have we got a better way to do this? */} - {FlattenValue(data).map((e) => ( - <> - {StringifyKeyChain(e.keychain)}: {String(e.value)} -
- - ))} + {FlattenValue(data) + .filter((e) => e.value !== null) + .map((e) => ( + <> + {StringifyKeyChain(e.keychain)}: {JSON.stringify(e.value)} +
+ + ))} ); } diff --git a/client/src/components/tables/cells/SeedsDiffCell.tsx b/client/src/components/tables/cells/SeedsDiffCell.tsx new file mode 100644 index 000000000..f383434b0 --- /dev/null +++ b/client/src/components/tables/cells/SeedsDiffCell.tsx @@ -0,0 +1,49 @@ +import { JSONAttributeDiff, StringifyKeyChain } from "util/misc"; +import React from "react"; + +export default function SeedsDiffCell({ diffs }: { diffs: JSONAttributeDiff[] }) { + return ( + +
+ {diffs.map((e) => ( +
+ {StringifyKeyChain(e.keychain)} +
+ +
+ ))} +
+ + ); +} + +function DiffRow({ diff }: { diff: JSONAttributeDiff }) { + // added + if (diff.beforeVal === undefined) { + return +{JSON.stringify(diff.afterVal)}; + } + + // deleted + if (diff.afterVal === undefined) { + return -{JSON.stringify(diff.beforeVal)}; + } + + // modified + + return ( + +
+ -{JSON.stringify(diff.beforeVal)} +
+ +{JSON.stringify(diff.afterVal)} +
+
+ ); +} diff --git a/client/src/components/tables/cells/SeedsIndicatorCell.tsx b/client/src/components/tables/cells/SeedsIndicatorCell.tsx new file mode 100644 index 000000000..aea261599 --- /dev/null +++ b/client/src/components/tables/cells/SeedsIndicatorCell.tsx @@ -0,0 +1,43 @@ +import { ChangeOpacity } from "util/color-opacity"; +import React from "react"; +import { COLOUR_SET } from "tachi-common"; +import { ChangeIndicator } from "types/seeds"; + +export default function SeedsIndicatorCell({ indicate }: { indicate: ChangeIndicator }) { + if (indicate === "ADDED") { + return ( + + + + + ); + } else if (indicate === "REMOVED") { + return ( + + - + + ); + } else if (indicate === "MODIFIED") { + return ( + + ~ + + ); + } + + return ; +} diff --git a/client/src/components/tables/components/FilterDirectivesIndicator.tsx b/client/src/components/tables/components/FilterDirectivesIndicator.tsx index f2c8722b2..f4b64ee21 100644 --- a/client/src/components/tables/components/FilterDirectivesIndicator.tsx +++ b/client/src/components/tables/components/FilterDirectivesIndicator.tsx @@ -1,4 +1,4 @@ -import { GetValueGetter, ValueGetterOrHybrid } from "util/ztable/search"; +import { GetValueGetter, SearchFunctions } from "util/ztable/search"; import Divider from "components/util/Divider"; import ExternalLink from "components/util/ExternalLink"; import HoverText from "components/util/HoverText"; @@ -45,7 +45,7 @@ export default function FilterDirectivesIndicator({ searchFunctions, doc, }: { - searchFunctions: Record>; + searchFunctions: SearchFunctions; doc?: D; }) { const [modalShow, setModalShow] = useState(false); diff --git a/client/src/components/tables/components/TachiTable.tsx b/client/src/components/tables/components/TachiTable.tsx index 6790ed074..9535fdc64 100644 --- a/client/src/components/tables/components/TachiTable.tsx +++ b/client/src/components/tables/components/TachiTable.tsx @@ -1,10 +1,9 @@ import { CopyToClipboard } from "util/misc"; -import { ComposeSearchFunction, ValueGetterOrHybrid } from "util/ztable/search"; +import { ComposeSearchFunction, SearchFunctions } from "util/ztable/search"; import Icon from "components/util/Icon"; import SmallText from "components/util/SmallText"; import { useZTable, ZTableSortFn } from "components/util/table/useZTable"; import { UserSettingsContext } from "context/UserSettingsContext"; -import { json2csvAsync } from "json-2-csv"; import React, { useContext, useState } from "react"; import { Button } from "react-bootstrap"; import { integer } from "tachi-common"; @@ -91,7 +90,7 @@ export default function TachiTable({ noTopDisplayStr?: boolean; defaultSortMode?: string; defaultReverseSort?: boolean; - searchFunctions?: Record>; + searchFunctions?: SearchFunctions; }) { const [search, setSearch] = useState(""); diff --git a/client/src/components/tables/seeds/MakeSeedsDiffTable.tsx b/client/src/components/tables/seeds/MakeSeedsDiffTable.tsx new file mode 100644 index 000000000..c4acca981 --- /dev/null +++ b/client/src/components/tables/seeds/MakeSeedsDiffTable.tsx @@ -0,0 +1,71 @@ +import { HeadersToDiffHeaders, SearchFnsToDiffSearchFns } from "util/seeds"; +import { SearchFunctions } from "util/ztable/search"; +import Muted from "components/util/Muted"; +import React from "react"; +import { Col } from "react-bootstrap"; +import { CellsRenderFN, DiffSeedsCollection } from "types/seeds"; +import SeedsDiffCell from "../cells/SeedsDiffCell"; +import SeedsIndicatorCell from "../cells/SeedsIndicatorCell"; +import DropdownRow from "../components/DropdownRow"; +import TachiTable, { Header } from "../components/TachiTable"; + +/** + * Given all the necessary parts of a normal seeds table, render a table showing + * its diffs instead. + */ +export default function MakeSeedsDiffTable({ + headers, + dataset, + searchFns, + entryName, + Cells, +}: { + headers: Header[]; + dataset: DiffSeedsCollection[]; + searchFns: SearchFunctions; + entryName: string; + Cells: CellsRenderFN; +}) { + return ( + } + /> + ); +} + +function DiffRow({ data, Cells }: { data: DiffSeedsCollection; Cells: CellsRenderFN }) { + return ( + + + + + + + + + + + + + +
Old Entry
+
+ + (This is what the row looked like before changes were applied.) + + + + } + > + + + +
+ ); +} diff --git a/client/src/components/tables/seeds/MakeSeedsTable.tsx b/client/src/components/tables/seeds/MakeSeedsTable.tsx new file mode 100644 index 000000000..f5868e965 --- /dev/null +++ b/client/src/components/tables/seeds/MakeSeedsTable.tsx @@ -0,0 +1,46 @@ +import { SearchFunctions } from "util/ztable/search"; +import DebugContent from "components/util/DebugContent"; +import React from "react"; +import { CellsRenderFN, ChangeIndicator } from "types/seeds"; +import SeedsIndicatorCell from "../cells/SeedsIndicatorCell"; +import DropdownRow from "../components/DropdownRow"; +import TachiTable, { Header } from "../components/TachiTable"; +import { EmptyHeader } from "../headers/IndicatorHeader"; + +export default function MakeSeedsTable({ + dataset, + headers: _headers, + Cells, + indicate = null, + searchFns, + entryName, +}: { + headers: Header[]; + dataset: T[]; + indicate?: ChangeIndicator; + searchFns: SearchFunctions; + Cells: CellsRenderFN; + entryName: string; +}) { + // clone headers so as to not mutate global state + const headers = _headers.slice(0); + + if (indicate) { + headers.unshift(EmptyHeader); + } + + return ( + ( + }> + {indicate && } + + + )} + /> + ); +} diff --git a/client/src/components/tables/seeds/SeedsBMSCourseLookupTable.tsx b/client/src/components/tables/seeds/SeedsBMSCourseLookupTable.tsx index b4c804344..7165c6262 100644 --- a/client/src/components/tables/seeds/SeedsBMSCourseLookupTable.tsx +++ b/client/src/components/tables/seeds/SeedsBMSCourseLookupTable.tsx @@ -1,92 +1,76 @@ import { StrSOV } from "util/sorts"; +import { SearchFunctions } from "util/ztable/search"; import React from "react"; import { FormatChart } from "tachi-common"; -import { BMSCourseWithRelated } from "types/seeds"; -import DebugContent from "components/util/DebugContent"; -import TachiTable, { Header } from "../components/TachiTable"; -import DropdownRow from "../components/DropdownRow"; +import { BMSCourseWithRelated, CellsRenderFN } from "types/seeds"; +import { Header } from "../components/TachiTable"; -export default function SeedsBMSCourseLookupTable({ - dataset, -}: { - dataset: BMSCourseWithRelated[]; -}) { - const headers: Header[] = [ - ["Title", "Title", StrSOV((x) => x.title)], - ["Charts", "Charts", StrSOV((x) => x.title)], - ["MD5s", "MD5s", StrSOV((x) => x.title)], - [ - "Set (Index)", - "Set (Idx)", - (a, b) => { - const a2 = `${a.playtype} ${a.set}`; - const b2 = `${b.playtype} ${b.set}`; +export const SeedsBMSCourseLookupHeaders: Header[] = [ + ["Title", "Title", StrSOV((x) => x.title)], + ["Charts", "Charts", StrSOV((x) => x.title)], + [ + "Set (Index)", + "Set (Idx)", + (a, b) => { + const a2 = `${a.playtype} ${a.set}`; + const b2 = `${b.playtype} ${b.set}`; - if (a2 === b2) { - return a.value - b.value; + if (a2 === b2) { + return a.value - b.value; + } + + return a2.localeCompare(b2); + }, + ], +]; + +export const SeedsBMSCourseLookupSearchFns: SearchFunctions = { + title: (x) => x.title, + set: (x) => `${x.playtype} ${x.set}`, + md5: (x) => x.md5sums, + value: (x) => x.value, + playtype: (x) => x.playtype, + // don't ask + // it gets all the songtitles and joins them + song: (x) => + x.__related.entries + .map((e) => { + if (typeof e === "string") { + return `UNKNOWN CHART (${e})`; } - return a2.localeCompare(b2); - }, - ], - ]; + return FormatChart("bms", e.song, e.chart); + }) + .join("\n"), +}; - return ( - x.title, - set: (x) => `${x.playtype} ${x.set}`, - md5: (x) => x.md5sums, - value: (x) => x.value, - playtype: (x) => x.playtype, - // don't ask - // it gets all the songtitles and joins them - song: (x) => - x.__related.entries - .map((e) => { - if (typeof e === "string") { - return `UNKNOWN CHART (${e})`; - } +export const SeedsBMSCourseLookupCells: CellsRenderFN = ({ + data, + compress, +}) => ( + <> + + {data.title} + + + {data.__related.entries.map((e, i) => ( +
+ {typeof e === "string" ? ( + UNKNOWN CHART + ) : ( + {FormatChart("bms", e.song, e.chart)} + )} - return FormatChart("bms", e.song, e.chart); - }) - .join("\n"), - }} - rowFunction={(x) => } - /> - ); -} - -function Row({ data }: { data: BMSCourseWithRelated }) { - return ( - }> - - {data.title} - - - {data.__related.entries.map((e, i) => ( -
- {typeof e === "string" ? ( - UNKNOWN CHART - ) : ( - {FormatChart("bms", e.song, e.chart)} - )} -
- ))} - - - {data.__related.entries.map((e, i) => ( -
- {typeof e === "string" ? e : e.chart.data.hashMD5} -
- ))} - - - {data.playtype} {data.set} ({data.value}) - -
- ); -} + {!compress && ( +
+ {typeof e === "string" ? e : e.chart.data.hashMD5} +
+ )} +
+ ))} + + + {data.playtype} {data.set} ({data.value}) + + +); diff --git a/client/src/components/tables/seeds/SeedsChartsTable.tsx b/client/src/components/tables/seeds/SeedsChartsTable.tsx index d417e98b9..d12303cbc 100644 --- a/client/src/components/tables/seeds/SeedsChartsTable.tsx +++ b/client/src/components/tables/seeds/SeedsChartsTable.tsx @@ -1,75 +1,62 @@ import { ChangeOpacity } from "util/color-opacity"; import { FlattenValue, StringifyKeyChain } from "util/misc"; import { StrSOV } from "util/sorts"; +import { SearchFunctions } from "util/ztable/search"; import QuickTooltip from "components/layout/misc/QuickTooltip"; -import DebugContent from "components/util/DebugContent"; import Icon from "components/util/Icon"; import React from "react"; import { COLOUR_SET, Game } from "tachi-common"; -import { ChartWithRelated } from "types/seeds"; +import { CellsRenderFN, ChartWithRelated } from "types/seeds"; import DifficultyCell from "../cells/DifficultyCell"; import ObjCell from "../cells/ObjCell"; import TitleCell from "../cells/TitleCell"; -import DropdownRow from "../components/DropdownRow"; -import TachiTable, { Header } from "../components/TachiTable"; import ChartHeader from "../headers/ChartHeader"; +import { Header } from "../components/TachiTable"; -export default function SeedsChartsTable({ - dataset, - game, -}: { - dataset: ChartWithRelated[]; - game: Game; -}) { - const headers: Header[] = [ - ChartHeader(game, (k) => k), - ["Title", "Title", StrSOV((x) => x.__related.song?.title ?? "")], - ["Data", "Data"], - ]; +export function MakeSeedsChartsControls(game: Game): { + headers: Header[]; + searchFns: SearchFunctions; + Cells: CellsRenderFN; +} { + return { + headers: [ + ChartHeader(game, (k) => k), + ["Title", "Title", StrSOV((x) => x.__related.song?.title ?? "")], + ["Data", "Data"], + ], + searchFns: { + artist: (x) => x.__related.song?.artist ?? null, + title: (x) => x.__related.song?.title ?? null, + songID: (x) => x.__related.song?.id ?? null, + searchTerms: (x) => x.__related.song?.searchTerms.join(", ") ?? null, + altTitles: (x) => x.__related.song?.altTitles.join(", ") ?? null, + difficulty: (x) => x.difficulty, + level: (x) => x.levelNum, + data: (x) => + FlattenValue(x.data) + .map((e) => `${StringifyKeyChain(e.keychain)} ${e.value}`) + .join("\n"), + }, + Cells: ({ data }) => ( + <> + - return ( - x.__related.song?.artist ?? null, - title: (x) => x.__related.song?.title ?? null, - songID: (x) => x.__related.song?.id ?? null, - searchTerms: (x) => x.__related.song?.searchTerms.join(", ") ?? null, - altTitles: (x) => x.__related.song?.altTitles.join(", ") ?? null, - difficulty: (x) => x.difficulty, - level: (x) => x.levelNum, - data: (x) => - FlattenValue(x.data) - .map((e) => `${StringifyKeyChain(e.keychain)} ${e.value}`) - .join("\n"), - }} - rowFunction={(x) => } - /> - ); -} + {/* if we have a song, render the title cell. Otherwise, this chart has no parent song, and is invalid. */} + {data.__related.song ? ( + + ) : ( + + INVALID SONG ({data.songID}) + +
+ +
+
+ + )} -function Row({ data, game }: { data: ChartWithRelated; game: Game }) { - return ( - }> - - - {/* if we have a song, render the title cell. Otherwise, this chart has no parent song, and is invalid. */} - {data.__related.song ? ( - - ) : ( - - INVALID SONG ({data.songID} - -
- -
-
- - )} - - -
- ); + + + ), + }; } diff --git a/client/src/components/tables/seeds/SeedsFolderTable.tsx b/client/src/components/tables/seeds/SeedsFolderTable.tsx index b7ad76836..9715d86db 100644 --- a/client/src/components/tables/seeds/SeedsFolderTable.tsx +++ b/client/src/components/tables/seeds/SeedsFolderTable.tsx @@ -1,79 +1,71 @@ import { FlattenValue, StringifyKeyChain } from "util/misc"; import { StrSOV } from "util/sorts"; -import DebugContent from "components/util/DebugContent"; +import { SearchFunctions } from "util/ztable/search"; +import Divider from "components/util/Divider"; import Muted from "components/util/Muted"; import React from "react"; import { Badge } from "react-bootstrap"; import { FolderDocument, FormatGame } from "tachi-common"; -import Divider from "components/util/Divider"; -import DropdownRow from "../components/DropdownRow"; -import TachiTable, { Header } from "../components/TachiTable"; +import { CellsRenderFN } from "types/seeds"; +import { Header } from "../components/TachiTable"; -export default function SeedsFolderTable({ dataset }: { dataset: FolderDocument[] }) { - const headers: Header[] = [ - ["ID", "ID", StrSOV((x) => x.folderID)], - ["Name", "Name", StrSOV((x) => x.title)], - ["GPT", "GPT", StrSOV((x) => `${x.game} ${x.playtype}`)], - ["Query", "Query", StrSOV((x) => x.title)], - ]; +export const SeedsFolderHeaders: Header[] = [ + ["ID", "ID", StrSOV((x) => x.folderID)], + ["Name", "Name", StrSOV((x) => x.title)], + ["GPT", "GPT", StrSOV((x) => `${x.game} ${x.playtype}`)], + ["Query", "Query", StrSOV((x) => x.title)], +]; - return ( - x.title, - folderID: (x) => x.folderID, - inactive: (x) => x.inactive, - game: (x) => x.game, - playtype: (x) => x.playtype, - gpt: (x) => FormatGame(x.game, x.playtype), - type: (x) => x.type, - query: (x) => - FlattenValue(x.data) - .map((e) => `${StringifyKeyChain(e.keychain)} ${e.value}`) - .join("\n"), - }} - rowFunction={(x) => } - /> - ); -} +export const SeedsFolderSearchFns: SearchFunctions = { + title: (x) => x.title, + folderID: (x) => x.folderID, + inactive: (x) => x.inactive, + game: (x) => x.game, + playtype: (x) => x.playtype, + gpt: (x) => FormatGame(x.game, x.playtype), + type: (x) => x.type, + query: (x) => + FlattenValue(x.data) + .map((e) => `${StringifyKeyChain(e.keychain)} ${e.value}`) + .join("\n"), +}; -function Row({ data }: { data: FolderDocument }) { - return ( - }> - - {data.folderID} - - - {data.title} - {data.searchTerms.length !== 0 && ( +export const SeedsFolderCells: CellsRenderFN = ({ + data, +}: { + data: FolderDocument; +}) => ( + <> + + {data.folderID} + + + {data.title} + {data.searchTerms && data.searchTerms.length !== 0 && ( + <> +
+ {data.searchTerms.join(", ")} + + )} + {data.inactive && ( + <> +
+ INACTIVE + + )} + + {FormatGame(data.game, data.playtype)} + + TYPE: {data.type} + +
+ {FlattenValue(data.data).map((e) => ( <> + {StringifyKeyChain(e.keychain)} = {String(e.value)}
- {data.searchTerms.join(", ")} - )} - {data.inactive && ( - <> -
- INACTIVE - - )} - - {FormatGame(data.game, data.playtype)} - - TYPE: {data.type} - -
- {FlattenValue(data.data).map((e) => ( - <> - {StringifyKeyChain(e.keychain)} = {String(e.value)} -
- - ))} -
- - - ); -} + ))} +
+ + +); diff --git a/client/src/components/tables/seeds/SeedsSongsTable.tsx b/client/src/components/tables/seeds/SeedsSongsTable.tsx index cc57e3a80..7ee2131a3 100644 --- a/client/src/components/tables/seeds/SeedsSongsTable.tsx +++ b/client/src/components/tables/seeds/SeedsSongsTable.tsx @@ -1,53 +1,41 @@ -import { NumericSOV, StrSOV } from "util/sorts"; import { FlattenValue, StringifyKeyChain } from "util/misc"; +import { NumericSOV, StrSOV } from "util/sorts"; +import { SearchFunctions } from "util/ztable/search"; import React from "react"; import { Game, SongDocument } from "tachi-common"; -import DebugContent from "components/util/DebugContent"; -import TachiTable, { Header } from "../components/TachiTable"; -import DropdownRow from "../components/DropdownRow"; -import TitleCell from "../cells/TitleCell"; +import { CellsRenderFN } from "types/seeds"; import ObjCell from "../cells/ObjCell"; +import TitleCell from "../cells/TitleCell"; +import { Header } from "../components/TachiTable"; -export default function SeedsSongsTable({ - dataset, - game, -}: { - dataset: SongDocument[]; - game: Game; -}) { - const headers: Header[] = [ - ["ID", "ID", NumericSOV((x) => x.id)], - ["Title", "Title", StrSOV((x) => x.title)], - ["Data", "Data"], - ]; - - return ( - x.artist, - title: (x) => x.title, - songID: (x) => x.id, - searchTerms: (x) => x.searchTerms.join(", "), - altTitles: (x) => x.altTitles.join(", "), - data: (x) => - FlattenValue(x.data) - .map((e) => `${StringifyKeyChain(e.keychain)} ${e.value}`) - .join("\n"), - }} - rowFunction={(x) => } - /> - ); -} - -function Row({ data, game }: { data: SongDocument; game: Game }) { - return ( - }> - {data.id} - - - - ); +export function MakeSeedsSongsControls(game: Game): { + headers: Header[]; + searchFns: SearchFunctions; + Cells: CellsRenderFN; +} { + return { + headers: [ + ["ID", "ID", NumericSOV((x) => x.id)], + ["Title", "Title", StrSOV((x) => x.title)], + ["Data", "Data"], + ], + searchFns: { + artist: (x) => x.artist, + title: (x) => x.title, + songID: (x) => x.id, + searchTerms: (x) => x.searchTerms.join(", "), + altTitles: (x) => x.altTitles.join(", "), + data: (x) => + FlattenValue(x.data) + .map((e) => `${StringifyKeyChain(e.keychain)} ${e.value}`) + .join("\n"), + }, + Cells: ({ data }) => ( + <> + {data.id} + + + + ), + }; } diff --git a/client/src/components/tables/seeds/SeedsTable.tsx b/client/src/components/tables/seeds/SeedsTable.tsx index 17fd4321c..83dd5d5af 100644 --- a/client/src/components/tables/seeds/SeedsTable.tsx +++ b/client/src/components/tables/seeds/SeedsTable.tsx @@ -1,50 +1,263 @@ +import { ChangeOpacity } from "util/color-opacity"; import { ExtractGameFromFile } from "util/misc"; -import { MakeDataset } from "util/seeds"; -import React, { useMemo } from "react"; -import { AllDatabaseSeeds, Game } from "tachi-common"; -import SeedsBMSCourseLookupTable from "./SeedsBMSCourseLookupTable"; -import SeedsChartsTable from "./SeedsChartsTable"; -import SeedsFolderTable from "./SeedsFolderTable"; -import SeedsSongsTable from "./SeedsSongsTable"; -import SeedsTableTable from "./SeedsTableTable"; +import { + DBSeedsDiff, + DBSeedsDiffDeleted, + DBSeedsDiffModified, + DBSeedsDiffNew, + MakeDataset, +} from "util/seeds"; +import Divider from "components/util/Divider"; +import SelectButton from "components/util/SelectButton"; +import { ColourConfig } from "lib/config"; +import React, { useEffect, useMemo, useState } from "react"; +import { Col, Row } from "react-bootstrap"; +import { AllDatabaseSeeds } from "tachi-common"; +import { ChangeIndicator, DiffSeedsCollection } from "types/seeds"; +import MakeSeedsDiffTable from "./MakeSeedsDiffTable"; +import MakeSeedsTable from "./MakeSeedsTable"; +import { + SeedsBMSCourseLookupCells, + SeedsBMSCourseLookupHeaders, + SeedsBMSCourseLookupSearchFns, +} from "./SeedsBMSCourseLookupTable"; +import { SeedsTableCells, SeedsTableHeaders, SeedsTableSearchFns } from "./SeedsTableTable"; +import { SeedsFolderCells, SeedsFolderHeaders, SeedsFolderSearchFns } from "./SeedsFolderTable"; +import { MakeSeedsChartsControls } from "./SeedsChartsTable"; +import { MakeSeedsSongsControls } from "./SeedsSongsTable"; export default function SeedsTable({ data, file, + indicate = null, }: { data: Partial; file: keyof AllDatabaseSeeds; + indicate?: ChangeIndicator; }) { const dataset: any = useMemo(() => MakeDataset(file, data), [file, data]); + const tableControls = useMemo(() => GetTableControls(file), [file]); + + if (!tableControls) { + return <>This collection does not have render controls.; + } + + return ; +} + +export function SeedsDiffTable({ + baseData, + headData, + file, + diffs, +}: { + baseData: Partial; + headData: Partial; + file: T; + + // disgusting types + diffs: DBSeedsDiff[]; +}) { + // filter all these into their separate camps + disgusting type assertions + const addedDiffs = diffs.filter((e) => e.type === "ADDED") as DBSeedsDiffNew< + AllDatabaseSeeds[T][0] + >[]; + const deletedDiffs = diffs.filter((e) => e.type === "DELETED") as DBSeedsDiffDeleted< + AllDatabaseSeeds[T][0] + >[]; + const modifiedDiffs = diffs.filter((e) => e.type === "MODIFIED") as DBSeedsDiffModified< + AllDatabaseSeeds[T][0] + >[]; + + // Ok, this is kind of some insane hackery, but follow with me on this one. + // We "make" a dataset, but with the collection replaced with the documents + // affected in the diff, in this way, we only create a dataset of - say - + // three modified folder documents, instead of the whole collection. + const addedData = useMemo( + () => ({ + ...headData, + [file]: addedDiffs.map((e) => e.head), + }), + [addedDiffs, file, headData] + ); + + // see above + const deletedData = useMemo( + () => ({ + ...baseData, + [file]: deletedDiffs.map((e) => e.base), + }), + [deletedDiffs, file, baseData] + ); + + // Overwhelmingly complex. + // We split our diffs into separate arrays HEAD_DATA[] and BASE_DATA[] + // then we make datasets from those. + // we then rejoin the datasets in the same position, such that + // B, DIFF, H + // becomes + // DATASET(B), DIFF, DATASET(H) + const modifiedData = useMemo(() => { + const head = []; + const base = []; + + for (const diff of modifiedDiffs) { + head.push(diff.head); + base.push(diff.base); + } + + const headDSet = MakeDataset(file, { + ...headData, + [file]: head, + }); + const baseDSet = MakeDataset(file, { + ...baseData, + [file]: base, + }); + + const dataset: DiffSeedsCollection[] = []; + + for (let i = 0; i < modifiedDiffs.length; i++) { + const diff = modifiedDiffs[i]!; + const head = headDSet[i]!; + const base = baseDSet[i]; + + dataset.push({ + head, + base, + diff: diff.diff, + }); + } + + return dataset; + }, [baseData, headData, file, modifiedDiffs]); + + // not handling the case where there's no diffs because that ain't possible + const defaultView = modifiedDiffs.length ? "MODIFIED" : addedDiffs.length ? "ADDED" : "DELETED"; + + const [view, setView] = useState<"ADDED" | "DELETED" | "MODIFIED">(defaultView); + + useEffect(() => { + setView(modifiedDiffs.length ? "MODIFIED" : addedDiffs.length ? "ADDED" : "DELETED"); + }, [modifiedDiffs.length, addedDiffs.length]); + + return ( + <> + + +
+ {addedDiffs.length > 0 && ( + + Added (+{addedDiffs.length}) + + )} + {modifiedDiffs.length > 0 && ( + + Modified{" "} + (~{modifiedDiffs.length}) + + )} + {deletedDiffs.length > 0 && ( + + Deleted{" "} + (-{deletedDiffs.length}) + + )} +
+ + + + {view === "ADDED" ? ( + + ) : view === "DELETED" ? ( + + ) : ( + + )} +
+ + ); +} + +function InnerSeedsDifftable({ + dataset, + file, +}: { + // any here is a funny hack because TS doesn't really have nice match statements + // ah well. + dataset: DiffSeedsCollection[]; + file: keyof AllDatabaseSeeds; +}) { + const tableControls = useMemo(() => GetTableControls(file), [file]); + + if (!tableControls) { + return <>This collection does not have render controls.; + } + + return ; +} + +function GetTableControls(file: keyof AllDatabaseSeeds) { if (file.startsWith("songs-")) { const game = ExtractGameFromFile(file); - return ; + return { + ...MakeSeedsSongsControls(game), + entryName: "Songs", + }; } else if (file.startsWith("charts-")) { const game = ExtractGameFromFile(file); - if (file === "charts-iidx.json") { - return ( - <> - - Tip: Use a filter of level:!0 to filter out 2DXTra charts. - - ; - - ); - } - - return ; + return { + ...MakeSeedsChartsControls(game), + entryName: "Charts", + }; } if (file === "bms-course-lookup.json") { - return ; + return { + Cells: SeedsBMSCourseLookupCells, + headers: SeedsBMSCourseLookupHeaders, + searchFns: SeedsBMSCourseLookupSearchFns, + entryName: "BMS Courses", + }; } else if (file === "tables.json") { - return ; + return { + Cells: SeedsTableCells, + headers: SeedsTableHeaders, + searchFns: SeedsTableSearchFns, + entryName: "Tables", + }; } else if (file === "folders.json") { - return ; + return { + Cells: SeedsFolderCells, + headers: SeedsFolderHeaders, + searchFns: SeedsFolderSearchFns, + entryName: "Folders", + }; } - return <>; + return null; } diff --git a/client/src/components/tables/seeds/SeedsTableTable.tsx b/client/src/components/tables/seeds/SeedsTableTable.tsx index bcb73d950..102ae53c3 100644 --- a/client/src/components/tables/seeds/SeedsTableTable.tsx +++ b/client/src/components/tables/seeds/SeedsTableTable.tsx @@ -1,80 +1,67 @@ import { StrSOV } from "util/sorts"; -import DebugContent from "components/util/DebugContent"; +import { SearchFunctions } from "util/ztable/search"; import Muted from "components/util/Muted"; import React from "react"; import { Badge } from "react-bootstrap"; import { FormatGame } from "tachi-common"; -import { TableWithRelated } from "types/seeds"; -import DropdownRow from "../components/DropdownRow"; -import TachiTable, { Header } from "../components/TachiTable"; +import { CellsRenderFN, TableWithRelated } from "types/seeds"; +import { Header } from "../components/TachiTable"; -export default function SeedsTableTable({ dataset }: { dataset: TableWithRelated[] }) { - const headers: Header[] = [ - ["ID", "ID", StrSOV((x) => x.tableID)], - ["Name", "Name", StrSOV((x) => x.title)], - ["GPT", "GPT", StrSOV((x) => `${x.game} ${x.playtype}`)], - ["Folders", "Folders", StrSOV((x) => x.title)], - ]; +export const SeedsTableHeaders: Header[] = [ + ["ID", "ID", StrSOV((x) => x.tableID)], + ["Name", "Name", StrSOV((x) => x.title)], + ["GPT", "GPT", StrSOV((x) => `${x.game} ${x.playtype}`)], + ["Folders", "Folders", StrSOV((x) => x.title)], +]; - return ( - x.title, - tableID: (x) => x.tableID, - inactive: (x) => x.inactive, - default: (x) => x.default, - description: (x) => x.description, - game: (x) => x.game, - playtype: (x) => x.playtype, - gpt: (x) => FormatGame(x.game, x.playtype), - folder: (x) => - Object.values(x.__related.folders) - .filter((e) => e !== undefined) - .map((e) => e!.title) - .join("\n"), - }} - rowFunction={(x) => } - /> - ); -} +export const SeedsTableSearchFns: SearchFunctions = { + title: (x) => x.title, + tableID: (x) => x.tableID, + inactive: (x) => x.inactive, + default: (x) => x.default, + description: (x) => x.description, + game: (x) => x.game, + playtype: (x) => x.playtype, + gpt: (x) => FormatGame(x.game, x.playtype), + folder: (x) => + Object.values(x.__related.folders) + .filter((e) => e !== undefined) + .map((e) => e!.title) + .join("\n"), +}; -function Row({ data }: { data: TableWithRelated }) { - return ( - }> - - {data.tableID} - - - {data.title} -
- {data.description} - {(data.inactive || data.default) &&
} - {data.default && DEFAULT} - {data.inactive && INACTIVE} - - {FormatGame(data.game, data.playtype)} - -
- {data.folders.map((e) => { - const folder = data.__related.folders[e]; +export const SeedsTableCells: CellsRenderFN = ({ data }) => ( + <> + + {data.tableID} + + + {data.title} +
+ {data.description} + {(data.inactive || data.default) &&
} + {data.default && DEFAULT} + {data.inactive && INACTIVE} + + {FormatGame(data.game, data.playtype)} + +
+ {data.folders.map((e) => { + const folder = data.__related.folders[e]; - return ( -
- {folder ? ( - - {folder.title} ({folder.playtype}) - - ) : ( - UNKNOWN FOLDER {e} - )} -
- ); - })} -
- - - ); -} + return ( +
+ {folder ? ( + + {folder.title} ({folder.playtype}) + + ) : ( + UNKNOWN FOLDER {e} + )} +
+ ); + })} +
+ + +); diff --git a/client/src/components/util/SelectButton.tsx b/client/src/components/util/SelectButton.tsx index 4010a9383..abc157b86 100644 --- a/client/src/components/util/SelectButton.tsx +++ b/client/src/components/util/SelectButton.tsx @@ -11,7 +11,10 @@ export default function SelectButton({ onVariant = "primary", offVariant = "outline-secondary", disabled = false, - style, + onStyle = {}, + offStyle = {}, + style = {}, + className = "", }: { id: T; value: T; @@ -20,13 +23,17 @@ export default function SelectButton({ offVariant?: ButtonVariant; disabled?: boolean; style?: CSSProperties; + onStyle?: CSSProperties; + offStyle?: CSSProperties; + className?: string; } & JustChildren) { return ( diff --git a/client/src/types/git.ts b/client/src/types/git.ts index 908e48f53..e2d3fbb4c 100644 --- a/client/src/types/git.ts +++ b/client/src/types/git.ts @@ -14,6 +14,9 @@ export interface GitCommit { }; message: string; }; + parents: Array<{ sha: string }>; } export type Revision = { c: GitCommit; repo: string }; + +export type Branch = { name: string; sha: string }; diff --git a/client/src/types/seeds.ts b/client/src/types/seeds.ts index dc6b956f5..db34bff57 100644 --- a/client/src/types/seeds.ts +++ b/client/src/types/seeds.ts @@ -1,3 +1,4 @@ +import { JSONAttributeDiff } from "util/misc"; import { BMSCourseDocument, ChartDocument, @@ -106,3 +107,13 @@ export type DatabaseSeedsWithRelated = { "milestones.json": Array; } & SongSeedsWithRelated & ChartSeedsWithRelated; + +export type ChangeIndicator = "ADDED" | "REMOVED" | "MODIFIED" | null; + +export type DiffSeedsCollection = { + head: T; + diff: JSONAttributeDiff[]; + base: T; +}; + +export type CellsRenderFN = (d: { data: T; compress?: boolean }) => JSX.Element; diff --git a/client/src/util/misc.ts b/client/src/util/misc.ts index 4c726748a..7234f1660 100644 --- a/client/src/util/misc.ts +++ b/client/src/util/misc.ts @@ -14,6 +14,7 @@ import { SessionDocument, } from "tachi-common"; import { Playtype } from "types/tachi"; +import fjsh from "fast-json-stable-hash"; export function RFA(arr: T[]): T { return arr[Math.floor(Math.random() * arr.length)]; @@ -426,3 +427,108 @@ export function ExtractGameFromFile(file: string): Game { return game as Game; } + +/** + * Given an array, remove all duplicates in it. + */ +export function Dedupe(arr: Array): Array { + return [...new Set(arr)]; +} + +export function IsRecord(maybeRec: unknown): maybeRec is Record { + return typeof maybeRec === "object" && maybeRec !== null && !Array.isArray(maybeRec); +} + +export type JSONAttributeDiff = { + keychain: string[]; + beforeVal: unknown; + afterVal: unknown; +}; + +/** + * Given two JSON objects, diff them. + * + * This returns an array of keychains (stringpaths to the value) + * and their state before and after. `undefined` implies that the property does not + * exist anymore (or didn't exist). + * + * @note A and B **must** both be json OBJECTS or ARRAYS. They cannot be primitives. + */ +export function JSONCompare(before: object, after: object, keychain: string[] = []) { + const diffs: JSONAttributeDiff[] = []; + + const bKeys = Object.keys(before); + const aKeys = Object.keys(after); + const allKeys = Dedupe([...bKeys, ...aKeys]); + + for (const key of allKeys) { + const newKeychain = [...keychain, key]; + + // @ts-expect-error JS ABUSE WARNING + const bef = before[key]; + // @ts-expect-error JS ABUSE here + // you can index arrays with strings as if they were numbers + // this is because arrays are secretly objects. + // lol! + const aft = after[key]; + + // -- OBJECT CASES + // if both sides are objects, recurse. + if (IsRecord(bef) && IsRecord(aft)) { + diffs.push(...JSONCompare(bef, aft, newKeychain)); + } else if (IsRecord(bef) && !IsRecord(aft)) { + // if one is an object and the other isn't + // compare against the empty object so we still get nice diffs + // instead of blubber like key went from 1 to -> {foo: "bar"} + + diffs.push(...JSONCompare(bef, {}, newKeychain)); + } else if (!IsRecord(bef) && IsRecord(aft)) { + // converse of previous case + diffs.push(...JSONCompare({}, aft, newKeychain)); + } + // -- ARRAY CASES + // we only need to handle the case of ARRAY->ARRAY. + // otherwise, it's a guaranteed diff. + else if (Array.isArray(bef) && Array.isArray(aft)) { + if (fjsh.stringify(bef) !== fjsh.stringify(aft)) { + diffs.push({ + afterVal: aft, + beforeVal: bef, + keychain: newKeychain, + }); + } + } + + // -- PRIMITIVE CASES + else if (Object.is(bef, aft)) { + // are these two things the same? if so, don't do anything. + continue; + } else { + // these things are different, it's a diff. + diffs.push({ + keychain: newKeychain, + beforeVal: bef, + afterVal: aft, + }); + } + } + + return diffs; +} + +export function JoinJSX(elements: Array, joiner: JSX.Element): Array { + const newArray: Array = []; + + for (let i = 0; i < elements.length; i++) { + const element = elements[i]!; + + // if this is the last element, don't suffix with a joiner. + if (i === elements.length - 1) { + newArray.push(element); + } else { + newArray.push(element, joiner); + } + } + + return newArray; +} diff --git a/client/src/util/seeds.ts b/client/src/util/seeds.ts index 0aac15ce0..516000711 100644 --- a/client/src/util/seeds.ts +++ b/client/src/util/seeds.ts @@ -1,19 +1,29 @@ +import { Header } from "components/tables/components/TachiTable"; +import { EmptyHeader } from "components/tables/headers/IndicatorHeader"; +import { ZTableSortFn } from "components/util/table/useZTable"; import { AllDatabaseSeeds, + BMSCourseDocument, ChartDocument, CreateSongMap, DatabaseSeedNames, FolderDocument, Game, + GoalDocument, + MilestoneDocument, + MilestoneSetDocument, SongDocument, + TableDocument, } from "tachi-common"; import { BMSCourseWithRelated, ChartWithRelated, - DatabaseSeedsWithRelated, + DiffSeedsCollection as SeedsDiff, TableWithRelated, } from "types/seeds"; import { APIFetchV1 } from "./api"; +import { Dedupe, JSONAttributeDiff, JSONCompare } from "./misc"; +import { ValueGetterOrHybrid } from "./ztable/search"; /** * Given a repo and a reference return the status of database-seeds/collections @@ -208,3 +218,268 @@ function RelateCharts(data: Partial, file: string): ChartWithR }, })); } + +export type DBSeedsCollection = AllDatabaseSeeds[keyof AllDatabaseSeeds][0]; + +export interface DBSeedsDiffModified { + type: "MODIFIED"; + base: T; + diff: JSONAttributeDiff[]; + head: T; +} + +export interface DBSeedsDiffNew { + type: "ADDED"; + head: T; +} + +export interface DBSeedsDiffDeleted { + type: "DELETED"; + base: T; +} + +/** + * A diff may be any of three states: + * + * ADDED -> This did not exist in base, but now exists in HEAD + * DELETED -> This existed in base, but does not exist in HEAD + * MODIFIED -> This exists in both base and HEAD, but does not have equivalent checksums. + */ +export type DBSeedsDiff = + | DBSeedsDiffDeleted + | DBSeedsDiffNew + | DBSeedsDiffModified; + +export type DBSeedsDiffs = Partial<{ + [K in keyof AllDatabaseSeeds]: Array>; +}>; + +/** + * Given two full states of seeds, Return all the changes in each collection. + * + * + */ +export function DiffSeeds( + base: Partial, + head: Partial +): DBSeedsDiffs { + const diffs: DBSeedsDiffs = {}; + + const baseCollections = Object.keys(base) as (keyof AllDatabaseSeeds)[]; + const headCollections = Object.keys(head) as (keyof AllDatabaseSeeds)[]; + + // Base and HEAD might add new/remove collections. This allows us to iterate + // over the union of both sets + const allCollections = Dedupe([...baseCollections, ...headCollections]); + + for (const collection of allCollections) { + const baseState = base[collection] as DBSeedsCollection[]; + const headState = head[collection] as DBSeedsCollection[]; + + const colDiff = DiffCollection(collection, baseState, headState); + + diffs[collection] = colDiff; + } + + return diffs as DBSeedsDiffs; +} + +function DiffCollection( + collection: keyof AllDatabaseSeeds, + baseState: T[] | undefined, + headState: T[] | undefined +): Array> { + if (!headState && !baseState) { + // this is a collection that exists, + // but, neither head or base reports that it exists? + // this should **never** happen, but we can recover. + console.warn( + `Collection ${collection} exists, but neither HEAD nor base have it as existing?` + ); + + // return early with 0 diffs I guess? null to null is no diffs. + return []; + } else if (baseState && !headState) { + // if this collection was deleted in head, then everything is a removal. + + // so, typescript has a bit of a moment here where it thinks that an + // Array (A) of type T mapped to A' means that T and T' + // - although being the same union type - + // might be different members of that union via the mapping. + // it's right. I know it's right, but god damnit it's wrong. + return baseState.map((e) => ({ + type: "DELETED", + base: e as any, + })); + } else if (!baseState && headState) { + // if this collection didn't exist in base, but exists in head + // then everything is an addition. + + return headState.map((e) => ({ + type: "ADDED", + head: e as any, + })); + } + + // the above fail cases mean that both headState and baseState must be defined + // however, TS cannot infer this. + if (!headState || !baseState) { + throw new Error( + `INVARIANT FAILED: headState or baseState was undefined. This is not possible at this point in the program due to basic boolean logic, and only exists to prevent a TS error. How did this *ever* happen?` + ); + } + + // otherwise. lets get diffing... + const diffs: Array> = []; + + // create lookup tables on UNIQ(T) -> T for the entire set. + const headMap = new Map(); + for (const d of headState) { + headMap.set(GetUniqID(collection, d), d); + } + + const baseMap = new Map(); + for (const d of baseState) { + baseMap.set(GetUniqID(collection, d), d); + } + + const allIds = Dedupe([...headMap.keys(), ...baseMap.keys()]); + + for (const id of allIds) { + const base = baseMap.get(id); + const head = headMap.get(id); + + // not possible. we check it anyway. + if (!head && !base) { + console.warn(`ID ${id} exists, but neither HEAD nor base have it as existing?`); + continue; + } else if (base && !head) { + // not in head, but was in base. + diffs.push({ + type: "DELETED", + base, + }); + } else if (head && !base) { + // wasn't in base, but was in head + diffs.push({ + type: "ADDED", + head, + }); + } else if (head && base) { + // existed in both. We need to compare these two deeply. + + // these things *are* different. time to find out what. + const objDiffs = JSONCompare(base, head); + + // shouldn't be possible, but we mightaswell handle it. + if (objDiffs.length === 0) { + continue; + } + + diffs.push({ + type: "MODIFIED", + diff: objDiffs, + base, + head, + }); + } + } + + return diffs; +} + +/** + * Given a value and what collection it's from, return it's unique identifier -- + * so songID, chartID, etc. + * + * We do this to detect when things have been modified. + */ +function GetUniqID(collection: K, value: AllDatabaseSeeds[K][0]) { + if (collection.startsWith("songs-")) { + return (value as SongDocument).id.toString(); + } else if (collection.startsWith("charts-")) { + return (value as ChartDocument).chartID; + } + + const c = collection as NotSongsChartsSeeds; + + switch (c) { + case "bms-course-lookup.json": { + const v = value as BMSCourseDocument; + return `${v.set}-${v.playtype}-${v.value}`; + } + case "folders.json": { + const v = value as FolderDocument; + return v.folderID; + } + case "tables.json": { + const v = value as TableDocument; + return v.tableID; + } + case "goals.json": { + const v = value as GoalDocument; + return v.goalID; + } + case "milestones.json": { + const v = value as MilestoneDocument; + return v.milestoneID; + } + case "milestone-sets.json": { + const v = value as MilestoneSetDocument; + return v.setID; + } + } +} + +/** + * Given an array of headers upon T, return the headers for Diff. + */ +export function HeadersToDiffHeaders(headers: Header[]): Header>[] { + const headHeaders = headers.map((e) => { + const sortFn = e[2]; + + if (sortFn) { + // turn the sort function into sort(a.head, b.head); + const newSortFn: ZTableSortFn> = (a, b) => sortFn(a.head, b.head); + + return [e[0], e[1], newSortFn, e[3]]; + } + + return e; + }) as Header>[]; + + const newHeaders: Header>[] = [ + EmptyHeader, // diff indicator goes here + ...headHeaders, // new state + ["Diff", "Diff"], // raw diff showing + ]; + + return newHeaders; +} + +/** + * Given a record of search functions, convert them into ${x}_new and ${x}_old functions. + */ +export function SearchFnsToDiffSearchFns( + searchFns: Record> +): Record>> { + const newSearches: Record>> = {}; + + for (const [key, fn] of Object.entries(searchFns)) { + if (typeof fn === "function") { + newSearches[`${key}_old`] = (x) => fn(x.base); + newSearches[key] = (x) => fn(x.head); + } else { + newSearches[`${key}_old`] = { + strToNum: fn.strToNum, + valueGetter: (x) => fn.valueGetter(x.base), + }; + newSearches[key] = { + strToNum: fn.strToNum, + valueGetter: (x) => fn.valueGetter(x.head), + }; + } + } + + return newSearches; +} diff --git a/client/src/util/ztable/search.ts b/client/src/util/ztable/search.ts index 83d545030..3ce3caf65 100644 --- a/client/src/util/ztable/search.ts +++ b/client/src/util/ztable/search.ts @@ -194,6 +194,8 @@ export type ValueGetterOrHybrid = } | ValueGetter; +export type SearchFunctions = Record>; + export function GetValueGetter(v: ValueGetterOrHybrid) { if (typeof v === "function") { return v; diff --git a/database-seeds/collections/bms-course-lookup.json b/database-seeds/collections/bms-course-lookup.json index ca0513988..11f4abc78 100644 --- a/database-seeds/collections/bms-course-lookup.json +++ b/database-seeds/collections/bms-course-lookup.json @@ -1,9 +1,9 @@ [ { - "md5sums": "98fa5d9e8e9ddca1dc78ba15d231bd279a25380cede7740b2f61d03dc5480daf50a21694c6d36650104f07e5ecde5a316c77ca5053489a4857ed4f6d53ff0bda", + "md5sums": "98fa5d9e8e9ddca1dc78ba15d231bd279a25380cede7740b2f61d03dc5480daf50a21694c6d36650104f07e5ecde5a016c77ca5053489a4857ed4f6d53ff0bda", "playtype": "14K", "set": "genocideDan", - "title": "GENOSIDE 2018 DP段位認定 初段", + "title": "GENOSIDE 2018 DP段位認定 初段 AMONG US", "value": 0 }, { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f9f38f9b1..0bb5d7675 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -111,6 +111,7 @@ importers: dotenv-expand: 5.1.0 esbuild-loader: 2.13.1 eslint-plugin-react: ^7.30.1 + fast-json-stable-hash: ^1.0.3 file-loader: 3.0.1 formik: 2.2.9 fs-extra: 7.0.1 @@ -171,6 +172,7 @@ importers: deepmerge: 4.2.2 dotenv: 6.2.0 dotenv-expand: 5.1.0 + fast-json-stable-hash: 1.0.3 file-loader: 3.0.1_webpack@4.41.0 formik: 2.2.9_react@17.0.2 fs-extra: 7.0.1