feat(seeds): diffing, reading remotes, everything else

This commit is contained in:
zkldi
2022-09-25 16:41:53 +01:00
parent 7ef6b2d890
commit cb7d5a3eed
26 changed files with 1997 additions and 902 deletions
+1
View File
@@ -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=""
+1
View File
@@ -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",
@@ -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<Revision | null>(null);
const [headRev, setHeadRev] = useState<Revision | null>(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<GitCommit>(`/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 <div>Invalid reference, both a repo and a sha must be provided in the URL.</div>;
}
if (urlLoading) {
return (
<div className="w-100 d-flex justify-content-center">
<Loading />
<br />
<span>Fetching information from the provided URL...</span>
</div>
);
}
return (
<>
@@ -87,7 +167,8 @@ function InnerSeedsViewer({ hasLocalAPI }: { hasLocalAPI: boolean }) {
<SeedsPicker
hasLocalAPI={hasLocalAPI}
header="Compare Commit"
message="Optionally, pick another commit to compare against. This commit should be newer than the base commit."
message="Optionally, pick another arbitary commit to show all the changes between.
This commit should be newer than the base commit!"
setRev={setHeadRev}
rev={headRev}
/>
@@ -98,522 +179,27 @@ function InnerSeedsViewer({ hasLocalAPI }: { hasLocalAPI: boolean }) {
<Row>
<Col xs={12}>
<Divider />
{!headRev && (
<div className="w-100 d-flex justify-content-center">
<SelectButton
className="mx-2"
value={view}
setValue={setView}
id="DIFF"
>
View Changes
</SelectButton>
<SelectButton value={view} setValue={setView} id="FULL">
View Full State
</SelectButton>
</div>
)}
<Divider />
</Col>
<SeedsLoaderViewer baseRev={baseRev} headRev={headRev} />
<SeedsStateViewer baseRev={baseRev} headRev={headRev} view={view} />
</Row>
)}
</>
);
}
function SeedsLoaderViewer({ baseRev, headRev }: { baseRev: Revision; headRev: Revision | null }) {
const [baseData, setBaseData] = useState<Partial<AllDatabaseSeeds> | null>(null);
const [headData, setHeadData] = useState<Partial<AllDatabaseSeeds> | 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 (
<Col xs={12}>
<div className="d-flex justify-content-center">
<div>
<Loading />
<br />
Reading data...
</div>
</div>
</Col>
);
}
if (headData !== null) {
return <>{Object.keys(headData)}</>;
}
return <SeedsAbsoluteState seedsData={baseData} />;
}
function SeedsAbsoluteState({ seedsData }: { seedsData: Partial<AllDatabaseSeeds> }) {
const files = Object.keys(seedsData).sort(StrSOV((k) => k)) as Array<keyof AllDatabaseSeeds>;
const [file, setFile] = useState(files[0]);
return (
<>
<Col xs={12}>
<Card header="Collections">
<div className="d-flex flex-wrap" style={{ justifyContent: "space-around" }}>
{files.map((e) => (
<div key={e} className="my-2">
<SelectButton id={e} setValue={setFile} value={file}>
{e} ({seedsData[e]?.length ?? 0})
</SelectButton>
</div>
))}
</div>
</Card>
</Col>
<Col xs={12}>
<Divider />
<h1 className="text-center">{file}</h1>
<Divider />
<SeedsTable data={seedsData} file={file} />
</Col>
</>
);
}
type Branch = { name: string; sha: string };
function SeedsPicker({
hasLocalAPI,
header,
rev,
setRev,
message,
}: {
hasLocalAPI: boolean;
header: string;
rev: Revision | null;
setRev: SetState<Revision | null>;
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<string | null>(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<string | null>(null);
const [branches, setBranches] = useState<string[] | null>(null);
const [shaBranchLookup, setSHABranchLookup] = useState<Record<string, string[]>>({});
// 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<string, string[]> = {};
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 (
<>
<Card header={header}>
{rev ? (
<div className="mr-3" style={{ textAlign: "left" }}>
<span style={{ fontSize: "1.15rem" }}>
<code>
{rev.repo}/{rev.c.sha}
</code>
: {subject}
</span>
{body && (
<span
className="ml-4 badge badge-secondary"
onClick={() => setShowBody(!showBody)}
>
{showBody ? (
<Icon style={{ fontSize: "0.7rem" }} type="chevron-down" />
) : (
<Icon style={{ fontSize: "0.7rem" }} type="chevron-left" />
)}
</span>
)}
{showBody && <div className="ml-6 mt-1">{body}</div>}
<Divider />
<div style={{ width: "100%", display: "flex" }}>
<div style={{ flex: 1 }}>
{authorNotCommitter ? (
<>
Authored by <b>{rev.c.commit.author.name}</b>, Committed by{" "}
<b>{rev.c.commit.committer.name}</b>
</>
) : (
<>
Authored by <b>{rev.c.commit.author.name}</b>
</>
)}
</div>
<div className="text-right" style={{ flex: 1 }}>
{FormatTime(Date.parse(rev.c.commit.author.date))}
</div>
</div>
<Divider />
<div className="text-center">
<span
className="text-muted underline-on-hover"
onClick={() => setShow(true)}
>
Change Commit...
</span>
</div>
</div>
) : (
<>
{message && (
<>
<span>{message}</span>
<Divider />
</>
)}
<Button variant="secondary" onClick={() => setShow(true)}>
Pick Commit...
</Button>
</>
)}
</Card>
<Modal show={show} size="xl" onHide={() => setShow(false)}>
<Modal.Header closeButton>
<Modal.Title>Pick {header}</Modal.Title>
</Modal.Header>
<Modal.Body>
<Row>
<Col xs={12} className="text-center">
<div>
<span style={{ fontSize: "large" }}>Repository:</span>
<Select
style={{ display: "inline", width: "unset" }}
className="mx-2"
value={repo}
setValue={setRepo}
allowNull
>
{hasLocalAPI && <option value="local">Your Local Repo</option>}
</Select>
</div>
{branches && (
<div className="mt-2">
<span style={{ fontSize: "large" }}>Branch:</span>
<Select
style={{ display: "inline", width: "unset" }}
className="mx-2"
value={branch}
setValue={setBranch}
allowNull
>
{branches.map((e) => (
<option key={e} value={e}>
{e}
</option>
))}
</Select>
</div>
)}
</Col>
<Col xs={12} lg={10} className="offset-lg-1 text-center">
{repo && branch && (
<>
<Divider />
<RevSelector
shaBranchLookup={shaBranchLookup}
repo={repo}
branch={branch}
onSelect={(commit) => {
setRev({ c: commit, repo });
setShow(false);
}}
/>
</>
)}
</Col>
</Row>
</Modal.Body>
</Modal>
</>
);
}
function RevSelector({
repo,
onSelect,
branch,
shaBranchLookup,
}: {
repo: string;
onSelect: (g: GitCommit) => void;
branch: string;
shaBranchLookup: Record<string, string[]>;
}) {
const [revs, setRevs] = useState<Array<GitCommit>>([]);
const [filteredRevs, setFilteredRevs] = useState<Array<GitCommit>>([]);
// We have the option of filtering returned revisions according to only those that
// affect a specific file. Sounds useful.
const [collection, setCollection] = useState<string | null>(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<Array<GitCommit>>(
`/seeds/commits?${params.toString()}`
);
if (!res.success) {
throw new Error(`Failed to fetch commits? ${res.description}`);
}
const hasUncommittedRes = await APIFetchV1<boolean>(
"/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 (
<>
<div>
<span>Show changes that affect: </span>
<Select
value={collection}
setValue={setCollection}
unselectedName="Any Collection"
allowNull
className="mx-2"
style={{ width: "unset", display: "inline" }}
>
{DatabaseSeedNames.map((e) => (
<option key={e} value={e}>
{e}
</option>
))}
</Select>
</div>
<Divider />
<FormInput
fieldName="Search"
placeholder="Search commits..."
value={search}
setValue={setSearch}
/>
<Divider />
<div className="timeline timeline-2">
<div className="timeline-bar"></div>
{filteredRevs.map((r) => (
<Revision
tags={shaBranchLookup[r.sha] ?? []}
key={r.sha}
rev={r}
onSelect={onSelect}
/>
))}
</div>
</>
);
}
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 (
<div className="timeline-item">
<span className="timeline-badge bg-primary"></span>
<div className="timeline-content d-flex align-items-center justify-content-between">
<div className="mr-3" style={{ width: "70%", textAlign: "left" }}>
<span style={{ fontSize: "1.15rem" }}>
<code>{rev.sha}</code>:{" "}
<a className="gentle-link" onClick={() => onSelect(rev)}>
{subject}
</a>
</span>
{body && (
<span
className="ml-4 badge badge-secondary"
onClick={() => setShowBody(!showBody)}
>
{showBody ? (
<Icon style={{ fontSize: "0.7rem" }} type="chevron-down" />
) : (
<Icon style={{ fontSize: "0.7rem" }} type="chevron-left" />
)}
</span>
)}
{tags.length !== 0 && (
<span className="ml-2">
{tags.map((e) => (
<Badge className="ml-2" variant="primary" key={e}>
{e}
</Badge>
))}
</span>
)}
{showBody && <div className="ml-6 mt-1">{body}</div>}
<div>
{authorNotCommitter ? (
<>
Authored by <b>{rev.commit.author.name}</b>, Committed by{" "}
<b>{rev.commit.committer.name}</b>
</>
) : (
<>
Authored by <b>{rev.commit.author.name}</b>
</>
)}
</div>
</div>
<span className="text-muted font-italic text-right">
{FormatTime(Date.parse(rev.commit.author.date))}
</span>
</div>
</div>
);
}
+482
View File
@@ -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<Revision | null>;
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<string | null>(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<string | null>(null);
const [branches, setBranches] = useState<string[] | null>(null);
const [shaBranchLookup, setSHABranchLookup] = useState<Record<string, string[]>>({});
// 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<string, string[]> = {};
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<string, string[]> = {};
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 (
<>
<Card header={header}>
{rev ? (
<div className="mr-3" style={{ textAlign: "left" }}>
<span style={{ fontSize: "1.15rem" }}>
<code>
{rev.repo}/{rev.c.sha}
</code>
: {subject}
</span>
{body && (
<span
className="ml-4 badge badge-secondary"
onClick={() => setShowBody(!showBody)}
>
{showBody ? (
<Icon style={{ fontSize: "0.7rem" }} type="chevron-down" />
) : (
<Icon style={{ fontSize: "0.7rem" }} type="chevron-left" />
)}
</span>
)}
{showBody && <div className="ml-6 mt-1">{body}</div>}
<Divider />
<div style={{ width: "100%", display: "flex" }}>
<div style={{ flex: 1 }}>
{authorNotCommitter ? (
<>
Authored by <b>{rev.c.commit.author.name}</b>, Committed by{" "}
<b>{rev.c.commit.committer.name}</b>
</>
) : (
<>
Authored by <b>{rev.c.commit.author.name}</b>
</>
)}
</div>
<div className="text-right" style={{ flex: 1 }}>
{FormatTime(Date.parse(rev.c.commit.author.date))}
</div>
</div>
<Divider />
<div className="w-100 d-flex">
<Button variant="secondary" onClick={() => setShow(true)}>
Change Commit...
</Button>
</div>
</div>
) : (
<>
{message && (
<>
<span>{message}</span>
<Divider />
</>
)}
<Button variant="secondary" onClick={() => setShow(true)}>
Pick Commit...
</Button>
</>
)}
</Card>
<Modal show={show} size="xl" onHide={() => setShow(false)}>
<Modal.Header closeButton>
<Modal.Title>Pick {header}</Modal.Title>
</Modal.Header>
<Modal.Body>
<Row>
<Col xs={12} className="text-center">
<div>
<span style={{ fontSize: "large" }}>Repository:</span>
<Select
style={{ display: "inline", width: "unset" }}
className="mx-2"
value={repo}
setValue={setRepo}
allowNull
>
{hasLocalAPI && <option value="local">Your Local Repo</option>}
{process.env.REACT_APP_GIT_REPO && (
<option value={process.env.REACT_APP_GIT_REPO}>
{process.env.REACT_APP_GIT_REPO}
</option>
)}
</Select>
</div>
{branches && (
<div className="mt-2">
<span style={{ fontSize: "large" }}>Branch:</span>
<Select
style={{ display: "inline", width: "unset" }}
className="mx-2"
value={branch}
setValue={setBranch}
allowNull
>
{branches.map((e) => (
<option key={e} value={e}>
{e}
</option>
))}
</Select>
</div>
)}
</Col>
<Col xs={12} lg={10} className="offset-lg-1 text-center">
{repo && branch && (
<>
<Divider />
<RevSelector
shaBranchLookup={shaBranchLookup}
repo={repo}
branch={branch}
onSelect={(commit) => {
setRev({ c: commit, repo });
setShow(false);
}}
/>
</>
)}
</Col>
</Row>
</Modal.Body>
</Modal>
</>
);
}
function RevSelector({
repo,
onSelect,
branch,
shaBranchLookup,
}: {
repo: string;
onSelect: (g: GitCommit) => void;
branch: string;
shaBranchLookup: Record<string, string[]>;
}) {
const [revs, setRevs] = useState<Array<GitCommit>>([]);
const [filteredRevs, setFilteredRevs] = useState<Array<GitCommit>>([]);
// We have the option of filtering returned revisions according to only those that
// affect a specific file. Sounds useful.
const [collection, setCollection] = useState<string | null>(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<Array<GitCommit>>(
`/seeds/commits?${params.toString()}`
);
if (!res.success) {
throw new Error(`Failed to fetch commits? ${res.description}`);
}
const hasUncommittedRes = await APIFetchV1<boolean>(
"/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 (
<>
<div>
<span>Show changes that affect: </span>
<Select
value={collection}
setValue={setCollection}
unselectedName="Any Collection"
allowNull
className="mx-2"
style={{ width: "unset", display: "inline" }}
>
{DatabaseSeedNames.map((e) => (
<option key={e} value={e}>
{e}
</option>
))}
</Select>
</div>
<Divider />
<FormInput
fieldName="Search"
placeholder="Search commits..."
value={search}
setValue={setSearch}
/>
<Divider />
<div className="timeline timeline-2">
<div className="timeline-bar"></div>
{filteredRevs.map((r) => (
<Revision
tags={shaBranchLookup[r.sha] ?? []}
key={r.sha}
rev={r}
onSelect={onSelect}
/>
))}
</div>
</>
);
}
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 (
<div className="timeline-item">
<span className="timeline-badge bg-primary"></span>
<div className="timeline-content d-flex align-items-center justify-content-between">
<div className="mr-3" style={{ width: "70%", textAlign: "left" }}>
<span style={{ fontSize: "1.15rem" }}>
<code>{TruncateString(rev.sha, 9)}</code>:{" "}
<a className="gentle-link" onClick={() => onSelect(rev)}>
{subject}
</a>
</span>
{body && (
<span
className="ml-4 badge badge-secondary"
onClick={() => setShowBody(!showBody)}
>
{showBody ? (
<Icon style={{ fontSize: "0.7rem" }} type="chevron-down" />
) : (
<Icon style={{ fontSize: "0.7rem" }} type="chevron-left" />
)}
</span>
)}
{tags.length !== 0 && (
<span className="ml-2">
{tags.map((e) => (
<Badge className="ml-2" variant="primary" key={e}>
{e}
</Badge>
))}
</span>
)}
{showBody && <div className="ml-6 mt-1">{body}</div>}
<div>
{authorNotCommitter ? (
<>
Authored by <b>{rev.commit.author.name}</b>, Committed by{" "}
<b>{rev.commit.committer.name}</b>
</>
) : (
<>
Authored by <b>{rev.commit.author.name}</b>
</>
)}
</div>
</div>
<span className="text-muted font-italic text-right">
{FormatTime(Date.parse(rev.commit.author.date))}
</span>
</div>
</div>
);
}
@@ -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 <div>SeedsStateViewer</div>;
export default function SeedsStateViewer({
baseRev,
headRev,
view,
}: {
baseRev: Revision;
headRev: Revision | null;
view: "DIFF" | "FULL";
}) {
const [baseData, setBaseData] = useState<Partial<AllDatabaseSeeds> | 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<Partial<AllDatabaseSeeds> | 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 (
<Col xs={12}>
<div className="d-flex justify-content-center">
<div>
<Loading />
<br />
Reading data...
</div>
</div>
</Col>
);
}
if (headData !== null) {
return <SeedsDiffState baseData={baseData} headData={headData} />;
}
if (view === "DIFF") {
if (baseRev.c.parents.length === 0) {
// no parents, diff against the empty set.
return <SeedsDiffState baseData={baseData} headData={{}} />;
} else if (baseRev.c.parents.length > 1) {
// more than one parent, i have no idea what to do?
return (
<div>
This commit has multiple parents. Diffing against this isn't supported yet,
sorry.
<code>{baseRev.c.parents.join(", ")}</code>
</div>
);
}
return <SeedsDiffParentState seedsData={baseData} baseRev={baseRev} />;
}
return <SeedsAbsoluteState seedsData={baseData} />;
}
function SeedsDiffParentState({
seedsData,
baseRev,
}: {
seedsData: Partial<AllDatabaseSeeds>;
baseRev: Revision;
}) {
const [parentData, setParentData] = useState<Partial<AllDatabaseSeeds> | 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 (
<Col xs={12}>
<div className="d-flex justify-content-center">
<div>
<Loading />
<br />
Comparing against previous state... (This takes a bit.)
</div>
</div>
</Col>
);
}
return <SeedsDiffState baseData={parentData} headData={seedsData} />;
}
function SeedsAbsoluteState({ seedsData }: { seedsData: Partial<AllDatabaseSeeds> }) {
const files = Object.keys(seedsData).sort(StrSOV((k) => k)) as Array<keyof AllDatabaseSeeds>;
const [file, setFile] = useState(files[0]);
return (
<>
<Col xs={12}>
<Card header="Collections">
<div className="d-flex flex-wrap" style={{ justifyContent: "space-around" }}>
{files.map((e) => (
<div key={e} className="my-2">
<SelectButton id={e} setValue={setFile} value={file}>
{e} ({seedsData[e]?.length ?? 0})
</SelectButton>
</div>
))}
</div>
</Card>
</Col>
<Col xs={12}>
<Divider />
<h1 className="text-center">{file}</h1>
<Divider />
<SeedsTable data={seedsData} file={file} />
</Col>
</>
);
}
function SeedsDiffState({
baseData,
headData,
}: {
baseData: Partial<AllDatabaseSeeds>;
headData: Partial<AllDatabaseSeeds>;
}) {
const seedsDiff = useMemo(() => DiffSeeds(baseData, headData), [baseData, headData]);
const files = Object.keys(seedsDiff).sort(StrSOV((k) => k)) as Array<keyof AllDatabaseSeeds>;
// don't display anything with 0 diffs.
const filesWithDiffs = files.filter((e) => seedsDiff[e]?.length !== 0);
const [file, setFile] = useState<keyof AllDatabaseSeeds | null>(filesWithDiffs[0] ?? null);
return (
<Col xs={12}>
<Card header="Changes">
<div className="d-flex flex-wrap" style={{ justifyContent: "space-around" }}>
{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(<span className="text-success">+{added}</span>);
}
if (modified) {
display.push(<span className="text-warning">~{modified}</span>);
}
if (deleted) {
display.push(<span className="text-danger">-{deleted}</span>);
}
return (
<div key={e} className="my-2">
<Button
variant={file === e ? "primary" : "secondary"}
onClick={() => setFile(e)}
style={
file === e
? {
backgroundColor: ChangeOpacity(
ColourConfig.primary,
0.6
),
}
: {}
}
>
{e}
{display.length > 0 && (
<>
<br />({JoinJSX(display, <span> | </span>)})
</>
)}
</Button>
</div>
);
})
) : (
<div>There are no changes between these two commits.</div>
)}
</div>
</Card>
{file && (
<Col xs={12}>
<Divider />
<h1 className="text-center">{file}</h1>
<Divider />
<SeedsDiffTable
file={file}
baseData={baseData}
headData={headData}
diffs={seedsDiff[file] ?? []}
/>
</Col>
)}
</Col>
);
}
@@ -5,12 +5,14 @@ export default function ObjCell({ data }: { data: unknown }) {
return (
<td className="text-left">
{/* this kinda sucks. have we got a better way to do this? */}
{FlattenValue(data).map((e) => (
<>
<code>{StringifyKeyChain(e.keychain)}</code>: {String(e.value)}
<br />
</>
))}
{FlattenValue(data)
.filter((e) => e.value !== null)
.map((e) => (
<>
<code>{StringifyKeyChain(e.keychain)}</code>: {JSON.stringify(e.value)}
<br />
</>
))}
</td>
);
}
@@ -0,0 +1,49 @@
import { JSONAttributeDiff, StringifyKeyChain } from "util/misc";
import React from "react";
export default function SeedsDiffCell({ diffs }: { diffs: JSONAttributeDiff[] }) {
return (
<td>
<div
style={{
maxHeight: "200px",
maxWidth: "600px",
overflow: "scroll",
textAlign: "left",
}}
>
{diffs.map((e) => (
<div key={StringifyKeyChain(e.keychain)}>
<code>{StringifyKeyChain(e.keychain)}</code>
<br />
<DiffRow diff={e} />
</div>
))}
</div>
</td>
);
}
function DiffRow({ diff }: { diff: JSONAttributeDiff }) {
// added
if (diff.beforeVal === undefined) {
return <span className="text-success">+{JSON.stringify(diff.afterVal)}</span>;
}
// deleted
if (diff.afterVal === undefined) {
return <span className="text-danger">-{JSON.stringify(diff.beforeVal)}</span>;
}
// modified
return (
<span>
<div>
<span className="text-danger">-{JSON.stringify(diff.beforeVal)}</span>
<br />
<span className="text-success">+{JSON.stringify(diff.afterVal)}</span>
</div>
</span>
);
}
@@ -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 (
<td
style={{
backgroundColor: ChangeOpacity(COLOUR_SET.green, 0.2),
borderRight: "1px black solid",
}}
>
<strong>+</strong>
</td>
);
} else if (indicate === "REMOVED") {
return (
<td
style={{
backgroundColor: ChangeOpacity(COLOUR_SET.red, 0.2),
borderRight: "1px black solid",
}}
>
<strong>-</strong>
</td>
);
} else if (indicate === "MODIFIED") {
return (
<td
style={{
backgroundColor: ChangeOpacity(COLOUR_SET.orange, 0.2),
borderRight: "1px black solid",
}}
>
<strong>~</strong>
</td>
);
}
return <td />;
}
@@ -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<D>({
searchFunctions,
doc,
}: {
searchFunctions: Record<string, ValueGetterOrHybrid<D>>;
searchFunctions: SearchFunctions<D>;
doc?: D;
}) {
const [modalShow, setModalShow] = useState(false);
@@ -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<D>({
noTopDisplayStr?: boolean;
defaultSortMode?: string;
defaultReverseSort?: boolean;
searchFunctions?: Record<string, ValueGetterOrHybrid<D>>;
searchFunctions?: SearchFunctions<D>;
}) {
const [search, setSearch] = useState("");
@@ -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<T>({
headers,
dataset,
searchFns,
entryName,
Cells,
}: {
headers: Header<T>[];
dataset: DiffSeedsCollection<T>[];
searchFns: SearchFunctions<T>;
entryName: string;
Cells: CellsRenderFN<T>;
}) {
return (
<TachiTable
dataset={dataset}
headers={HeadersToDiffHeaders(headers)}
entryName={entryName}
searchFunctions={SearchFnsToDiffSearchFns(searchFns)}
rowFunction={(x) => <DiffRow data={x} Cells={Cells} />}
/>
);
}
function DiffRow<T>({ data, Cells }: { data: DiffSeedsCollection<T>; Cells: CellsRenderFN<T> }) {
return (
<DropdownRow
dropdown={
<div className="row">
<Col xs={12} className="mt-2">
<table className="table">
<thead>
<tr>
<td colSpan={100}>Old Entry</td>
</tr>
</thead>
<tbody>
<tr>
<Cells data={data.base} />
</tr>
</tbody>
</table>
<br />
<Muted>
(This is what the row looked like before changes were applied.)
</Muted>
</Col>
</div>
}
>
<SeedsIndicatorCell indicate="MODIFIED" />
<Cells data={data.head} compress />
<SeedsDiffCell diffs={data.diff} />
</DropdownRow>
);
}
@@ -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<T>({
dataset,
headers: _headers,
Cells,
indicate = null,
searchFns,
entryName,
}: {
headers: Header<T>[];
dataset: T[];
indicate?: ChangeIndicator;
searchFns: SearchFunctions<T>;
Cells: CellsRenderFN<T>;
entryName: string;
}) {
// clone headers so as to not mutate global state
const headers = _headers.slice(0);
if (indicate) {
headers.unshift(EmptyHeader);
}
return (
<TachiTable
dataset={dataset}
headers={headers}
entryName={entryName}
searchFunctions={searchFns}
rowFunction={(data) => (
<DropdownRow dropdown={<DebugContent data={{ ...data, __related: undefined }} />}>
{indicate && <SeedsIndicatorCell indicate={indicate} />}
<Cells data={data} />
</DropdownRow>
)}
/>
);
}
@@ -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<BMSCourseWithRelated>[] = [
["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<BMSCourseWithRelated>[] = [
["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<BMSCourseWithRelated> = {
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 (
<TachiTable
dataset={dataset}
headers={headers}
entryName="BMS Courses"
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})`;
}
export const SeedsBMSCourseLookupCells: CellsRenderFN<BMSCourseWithRelated> = ({
data,
compress,
}) => (
<>
<td>
<strong>{data.title}</strong>
</td>
<td className="text-left">
{data.__related.entries.map((e, i) => (
<div key={i} className="d-flex w-100">
{typeof e === "string" ? (
<span className="text-danger">UNKNOWN CHART</span>
) : (
<span>{FormatChart("bms", e.song, e.chart)} </span>
)}
return FormatChart("bms", e.song, e.chart);
})
.join("\n"),
}}
rowFunction={(x) => <Row data={x} />}
/>
);
}
function Row({ data }: { data: BMSCourseWithRelated }) {
return (
<DropdownRow dropdown={<DebugContent data={{ ...data, __related: undefined }} />}>
<td>
<strong>{data.title}</strong>
</td>
<td className="text-left">
{data.__related.entries.map((e, i) => (
<div key={i} className="d-flex w-100">
{typeof e === "string" ? (
<span className="text-danger">UNKNOWN CHART</span>
) : (
<span>{FormatChart("bms", e.song, e.chart)} </span>
)}
</div>
))}
</td>
<td>
{data.__related.entries.map((e, i) => (
<div key={i}>
<code>{typeof e === "string" ? e : e.chart.data.hashMD5}</code>
</div>
))}
</td>
<td>
{data.playtype} {data.set} ({data.value})
</td>
</DropdownRow>
);
}
{!compress && (
<div className="ml-auto" key={i}>
<code>{typeof e === "string" ? e : e.chart.data.hashMD5}</code>
</div>
)}
</div>
))}
</td>
<td>
{data.playtype} {data.set} ({data.value})
</td>
</>
);
@@ -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<ChartWithRelated>[] = [
ChartHeader(game, (k) => k),
["Title", "Title", StrSOV((x) => x.__related.song?.title ?? "")],
["Data", "Data"],
];
export function MakeSeedsChartsControls(game: Game): {
headers: Header<ChartWithRelated>[];
searchFns: SearchFunctions<ChartWithRelated>;
Cells: CellsRenderFN<ChartWithRelated>;
} {
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 }) => (
<>
<DifficultyCell chart={data} game={game} />
return (
<TachiTable
dataset={dataset}
headers={headers}
entryName="Charts"
searchFunctions={{
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"),
}}
rowFunction={(x) => <Row data={x} game={game} />}
/>
);
}
{/* if we have a song, render the title cell. Otherwise, this chart has no parent song, and is invalid. */}
{data.__related.song ? (
<TitleCell game={game} song={data.__related.song} chart={data} />
) : (
<td style={{ backgroundColor: ChangeOpacity(COLOUR_SET.red, 0.3) }}>
INVALID SONG ({data.songID})
<QuickTooltip tooltipContent="This chart points to a songID that does not exist at the time of this commit. This is a fatal error, and this commit doesn't pass tests.">
<div>
<Icon type="info" />
</div>
</QuickTooltip>
</td>
)}
function Row({ data, game }: { data: ChartWithRelated; game: Game }) {
return (
<DropdownRow dropdown={<DebugContent data={{ ...data, __related: undefined }} />}>
<DifficultyCell chart={data} game={game} />
{/* if we have a song, render the title cell. Otherwise, this chart has no parent song, and is invalid. */}
{data.__related.song ? (
<TitleCell game={game} song={data.__related.song} chart={data} />
) : (
<td style={{ backgroundColor: ChangeOpacity(COLOUR_SET.red, 0.3) }}>
INVALID SONG ({data.songID}
<QuickTooltip tooltipContent="This chart points to a songID that does not exist at the time of this commit. This is a fatal error, and this commit doesn't pass tests.">
<div>
<Icon type="info" />
</div>
</QuickTooltip>
</td>
)}
<ObjCell data={data.data} />
</DropdownRow>
);
<ObjCell data={data.data} />
</>
),
};
}
@@ -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<FolderDocument>[] = [
["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<FolderDocument>[] = [
["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 (
<TachiTable
dataset={dataset}
headers={headers}
entryName="Folders"
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"),
}}
rowFunction={(x) => <Row data={x} />}
/>
);
}
export const SeedsFolderSearchFns: SearchFunctions<FolderDocument> = {
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 (
<DropdownRow dropdown={<DebugContent data={{ ...data, __related: undefined }} />}>
<td>
<code>{data.folderID}</code>
</td>
<td>
<strong>{data.title}</strong>
{data.searchTerms.length !== 0 && (
export const SeedsFolderCells: CellsRenderFN<FolderDocument> = ({
data,
}: {
data: FolderDocument;
}) => (
<>
<td>
<code>{data.folderID}</code>
</td>
<td>
<strong>{data.title}</strong>
{data.searchTerms && data.searchTerms.length !== 0 && (
<>
<br />
<Muted>{data.searchTerms.join(", ")}</Muted>
</>
)}
{data.inactive && (
<>
<br />
<Badge variant="warning">INACTIVE</Badge>
</>
)}
</td>
<td>{FormatGame(data.game, data.playtype)}</td>
<td>
TYPE: <b>{data.type}</b>
<Divider />
<div className="text-left">
{FlattenValue(data.data).map((e) => (
<>
{StringifyKeyChain(e.keychain)} = {String(e.value)}
<br />
<Muted>{data.searchTerms.join(", ")}</Muted>
</>
)}
{data.inactive && (
<>
<br />
<Badge variant="warning">INACTIVE</Badge>
</>
)}
</td>
<td>{FormatGame(data.game, data.playtype)}</td>
<td>
TYPE: <b>{data.type}</b>
<Divider />
<div className="text-left">
{FlattenValue(data.data).map((e) => (
<>
{StringifyKeyChain(e.keychain)} = {String(e.value)}
<br />
</>
))}
</div>
</td>
</DropdownRow>
);
}
))}
</div>
</td>
</>
);
@@ -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<SongDocument>[] = [
["ID", "ID", NumericSOV((x) => x.id)],
["Title", "Title", StrSOV((x) => x.title)],
["Data", "Data"],
];
return (
<TachiTable
dataset={dataset}
headers={headers}
entryName="Songs"
searchFunctions={{
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"),
}}
rowFunction={(x) => <Row data={x} game={game} />}
/>
);
}
function Row({ data, game }: { data: SongDocument; game: Game }) {
return (
<DropdownRow dropdown={<DebugContent data={{ ...data, __related: undefined }} />}>
<td>{data.id}</td>
<TitleCell game={game} song={data} showAltTitles showSearchTerms />
<ObjCell data={data.data} />
</DropdownRow>
);
export function MakeSeedsSongsControls(game: Game): {
headers: Header<SongDocument>[];
searchFns: SearchFunctions<SongDocument>;
Cells: CellsRenderFN<SongDocument>;
} {
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 }) => (
<>
<td>{data.id}</td>
<TitleCell game={game} song={data} showAltTitles showSearchTerms />
<ObjCell data={data.data} />
</>
),
};
}
+238 -25
View File
@@ -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<AllDatabaseSeeds>;
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 <MakeSeedsTable {...(tableControls as any)} dataset={dataset} indicate={indicate} />;
}
export function SeedsDiffTable<T extends keyof AllDatabaseSeeds>({
baseData,
headData,
file,
diffs,
}: {
baseData: Partial<AllDatabaseSeeds>;
headData: Partial<AllDatabaseSeeds>;
file: T;
// disgusting types
diffs: DBSeedsDiff<AllDatabaseSeeds[T][0]>[];
}) {
// 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<any>[] = [];
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 (
<>
<Row>
<Col xs={12}>
<div className="d-flex flex-wrap" style={{ justifyContent: "space-evenly" }}>
{addedDiffs.length > 0 && (
<SelectButton
id="ADDED"
value={view}
setValue={setView}
onStyle={{
backgroundColor: ChangeOpacity(ColourConfig.primary, 0.6),
}}
>
Added <span className="text-success">(+{addedDiffs.length})</span>
</SelectButton>
)}
{modifiedDiffs.length > 0 && (
<SelectButton
id="MODIFIED"
value={view}
setValue={setView}
onStyle={{
backgroundColor: ChangeOpacity(ColourConfig.primary, 0.6),
}}
>
Modified{" "}
<span className="text-warning">(~{modifiedDiffs.length})</span>
</SelectButton>
)}
{deletedDiffs.length > 0 && (
<SelectButton
id="DELETED"
value={view}
setValue={setView}
onStyle={{
backgroundColor: ChangeOpacity(ColourConfig.primary, 0.6),
}}
>
Deleted{" "}
<span className="text-danger">(-{deletedDiffs.length})</span>
</SelectButton>
)}
</div>
<Divider />
</Col>
{view === "ADDED" ? (
<SeedsTable data={addedData} file={file} indicate="ADDED" />
) : view === "DELETED" ? (
<SeedsTable data={deletedData} file={file} indicate="REMOVED" />
) : (
<InnerSeedsDifftable dataset={modifiedData} file={file} />
)}
</Row>
</>
);
}
function InnerSeedsDifftable({
dataset,
file,
}: {
// any here is a funny hack because TS doesn't really have nice match statements
// ah well.
dataset: DiffSeedsCollection<any>[];
file: keyof AllDatabaseSeeds;
}) {
const tableControls = useMemo(() => GetTableControls(file), [file]);
if (!tableControls) {
return <>This collection does not have render controls.</>;
}
return <MakeSeedsDiffTable {...(tableControls as any)} dataset={dataset} />;
}
function GetTableControls(file: keyof AllDatabaseSeeds) {
if (file.startsWith("songs-")) {
const game = ExtractGameFromFile(file);
return <SeedsSongsTable dataset={dataset} game={game as Game} />;
return {
...MakeSeedsSongsControls(game),
entryName: "Songs",
};
} else if (file.startsWith("charts-")) {
const game = ExtractGameFromFile(file);
if (file === "charts-iidx.json") {
return (
<>
<span>
Tip: Use a filter of <code>level:!0</code> to filter out 2DXTra charts.
</span>
<SeedsChartsTable dataset={dataset} game={game as Game} />;
</>
);
}
return <SeedsChartsTable dataset={dataset} game={game as Game} />;
return {
...MakeSeedsChartsControls(game),
entryName: "Charts",
};
}
if (file === "bms-course-lookup.json") {
return <SeedsBMSCourseLookupTable dataset={dataset} />;
return {
Cells: SeedsBMSCourseLookupCells,
headers: SeedsBMSCourseLookupHeaders,
searchFns: SeedsBMSCourseLookupSearchFns,
entryName: "BMS Courses",
};
} else if (file === "tables.json") {
return <SeedsTableTable dataset={dataset} />;
return {
Cells: SeedsTableCells,
headers: SeedsTableHeaders,
searchFns: SeedsTableSearchFns,
entryName: "Tables",
};
} else if (file === "folders.json") {
return <SeedsFolderTable dataset={dataset} />;
return {
Cells: SeedsFolderCells,
headers: SeedsFolderHeaders,
searchFns: SeedsFolderSearchFns,
entryName: "Folders",
};
}
return <></>;
return null;
}
@@ -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<TableWithRelated>[] = [
["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<TableWithRelated>[] = [
["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 (
<TachiTable
dataset={dataset}
headers={headers}
entryName="Tables"
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"),
}}
rowFunction={(x) => <Row data={x} />}
/>
);
}
export const SeedsTableSearchFns: SearchFunctions<TableWithRelated> = {
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 (
<DropdownRow dropdown={<DebugContent data={{ ...data, __related: undefined }} />}>
<td>
<code>{data.tableID}</code>
</td>
<td>
<strong>{data.title}</strong>
<br />
<Muted>{data.description}</Muted>
{(data.inactive || data.default) && <br />}
{data.default && <Badge variant="success">DEFAULT</Badge>}
{data.inactive && <Badge variant="warning">INACTIVE</Badge>}
</td>
<td>{FormatGame(data.game, data.playtype)}</td>
<td className="text-left">
<div style={{ maxHeight: "200px", overflowY: "scroll" }}>
{data.folders.map((e) => {
const folder = data.__related.folders[e];
export const SeedsTableCells: CellsRenderFN<TableWithRelated> = ({ data }) => (
<>
<td>
<code>{data.tableID}</code>
</td>
<td>
<strong>{data.title}</strong>
<br />
<Muted>{data.description}</Muted>
{(data.inactive || data.default) && <br />}
{data.default && <Badge variant="success">DEFAULT</Badge>}
{data.inactive && <Badge variant="warning">INACTIVE</Badge>}
</td>
<td>{FormatGame(data.game, data.playtype)}</td>
<td className="text-left">
<div style={{ maxHeight: "200px", overflowY: "scroll" }}>
{data.folders.map((e) => {
const folder = data.__related.folders[e];
return (
<div key={e}>
{folder ? (
<span>
{folder.title} ({folder.playtype})
</span>
) : (
<span className="text-danger">UNKNOWN FOLDER {e}</span>
)}
</div>
);
})}
</div>
</td>
</DropdownRow>
);
}
return (
<div key={e}>
{folder ? (
<span>
{folder.title} ({folder.playtype})
</span>
) : (
<span className="text-danger">UNKNOWN FOLDER {e}</span>
)}
</div>
);
})}
</div>
</td>
</>
);
+9 -2
View File
@@ -11,7 +11,10 @@ export default function SelectButton<T>({
onVariant = "primary",
offVariant = "outline-secondary",
disabled = false,
style,
onStyle = {},
offStyle = {},
style = {},
className = "",
}: {
id: T;
value: T;
@@ -20,13 +23,17 @@ export default function SelectButton<T>({
offVariant?: ButtonVariant;
disabled?: boolean;
style?: CSSProperties;
onStyle?: CSSProperties;
offStyle?: CSSProperties;
className?: string;
} & JustChildren) {
return (
<Button
disabled={disabled}
variant={id === value ? onVariant : offVariant}
onClick={() => setValue(id)}
style={style}
style={id === value ? Object.assign(style, onStyle) : Object.assign(style, offStyle)}
className={className}
>
{children}
</Button>
+3
View File
@@ -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 };
+11
View File
@@ -1,3 +1,4 @@
import { JSONAttributeDiff } from "util/misc";
import {
BMSCourseDocument,
ChartDocument,
@@ -106,3 +107,13 @@ export type DatabaseSeedsWithRelated = {
"milestones.json": Array<MilestoneWithRelated>;
} & SongSeedsWithRelated &
ChartSeedsWithRelated;
export type ChangeIndicator = "ADDED" | "REMOVED" | "MODIFIED" | null;
export type DiffSeedsCollection<T> = {
head: T;
diff: JSONAttributeDiff[];
base: T;
};
export type CellsRenderFN<T> = (d: { data: T; compress?: boolean }) => JSX.Element;
+106
View File
@@ -14,6 +14,7 @@ import {
SessionDocument,
} from "tachi-common";
import { Playtype } from "types/tachi";
import fjsh from "fast-json-stable-hash";
export function RFA<T>(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<T>(arr: Array<T>): Array<T> {
return [...new Set(arr)];
}
export function IsRecord(maybeRec: unknown): maybeRec is Record<string, unknown> {
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<JSX.Element>, joiner: JSX.Element): Array<JSX.Element> {
const newArray: Array<JSX.Element> = [];
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;
}
+276 -1
View File
@@ -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<AllDatabaseSeeds>, file: string): ChartWithR
},
}));
}
export type DBSeedsCollection = AllDatabaseSeeds[keyof AllDatabaseSeeds][0];
export interface DBSeedsDiffModified<T extends DBSeedsCollection> {
type: "MODIFIED";
base: T;
diff: JSONAttributeDiff[];
head: T;
}
export interface DBSeedsDiffNew<T extends DBSeedsCollection> {
type: "ADDED";
head: T;
}
export interface DBSeedsDiffDeleted<T extends DBSeedsCollection> {
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<T extends DBSeedsCollection> =
| DBSeedsDiffDeleted<T>
| DBSeedsDiffNew<T>
| DBSeedsDiffModified<T>;
export type DBSeedsDiffs = Partial<{
[K in keyof AllDatabaseSeeds]: Array<DBSeedsDiff<DBSeedsCollection>>;
}>;
/**
* Given two full states of seeds, Return all the changes in each collection.
*
*
*/
export function DiffSeeds(
base: Partial<AllDatabaseSeeds>,
head: Partial<AllDatabaseSeeds>
): 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<T extends DBSeedsCollection>(
collection: keyof AllDatabaseSeeds,
baseState: T[] | undefined,
headState: T[] | undefined
): Array<DBSeedsDiff<T>> {
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<DBSeedsDiff<T>> = [];
// create lookup tables on UNIQ(T) -> T for the entire set.
const headMap = new Map<string, T>();
for (const d of headState) {
headMap.set(GetUniqID(collection, d), d);
}
const baseMap = new Map<string, T>();
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<K extends keyof AllDatabaseSeeds>(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<T>.
*/
export function HeadersToDiffHeaders<T>(headers: Header<T>[]): Header<SeedsDiff<T>>[] {
const headHeaders = headers.map((e) => {
const sortFn = e[2];
if (sortFn) {
// turn the sort function into sort(a.head, b.head);
const newSortFn: ZTableSortFn<SeedsDiff<T>> = (a, b) => sortFn(a.head, b.head);
return [e[0], e[1], newSortFn, e[3]];
}
return e;
}) as Header<SeedsDiff<T>>[];
const newHeaders: Header<SeedsDiff<T>>[] = [
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<T>(
searchFns: Record<string, ValueGetterOrHybrid<T>>
): Record<string, ValueGetterOrHybrid<SeedsDiff<T>>> {
const newSearches: Record<string, ValueGetterOrHybrid<SeedsDiff<T>>> = {};
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;
}
+2
View File
@@ -194,6 +194,8 @@ export type ValueGetterOrHybrid<D> =
}
| ValueGetter<D>;
export type SearchFunctions<T> = Record<string, ValueGetterOrHybrid<T>>;
export function GetValueGetter<D>(v: ValueGetterOrHybrid<D>) {
if (typeof v === "function") {
return v;
@@ -1,9 +1,9 @@
[
{
"md5sums": "98fa5d9e8e9ddca1dc78ba15d231bd279a25380cede7740b2f61d03dc5480daf50a21694c6d36650104f07e5ecde5a316c77ca5053489a4857ed4f6d53ff0bda",
"md5sums": "98fa5d9e8e9ddca1dc78ba15d231bd279a25380cede7740b2f61d03dc5480daf50a21694c6d36650104f07e5ecde5a016c77ca5053489a4857ed4f6d53ff0bda",
"playtype": "14K",
"set": "genocideDan",
"title": "GENOSIDE 2018 DP段位認定 初段",
"title": "GENOSIDE 2018 DP段位認定 初段 AMONG US",
"value": 0
},
{
+2
View File
@@ -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