last radzathan push

main
Mari 4 months ago
parent ae1c05270f
commit b54a67a5cd
  1. 3
      .idea/codeStyles/Project.xml
  2. 1
      .idea/sqldialects.xml
  3. 29
      migrations/0003_split_responses_table_to_allow_for_custom_lists.sql
  4. 37
      migrations/0006_add_audit_log_table.sql
  5. 4309
      package-lock.json
  6. 19
      package.json
  7. BIN
      slot-gray.jpg
  8. BIN
      slot.jpg
  9. 13
      src/build/bundle-client-with-source-map.ts
  10. 58
      src/build/bundler.ts
  11. 13
      src/build/check-source-map-and-bundle-client.ts
  12. 13
      src/build/tsconfig.json
  13. 25
      src/client/combined-generator-responses-entrypoint-old.ts
  14. 9
      src/client/combined-generator-responses-entrypoint.css
  15. 12
      src/client/combined-generator-responses-entrypoint.less
  16. 15
      src/client/combined-generator-responses-entrypoint.ts
  17. 294
      src/client/generator-entrypoint-old.ts
  18. 2
      src/client/generator-entrypoint.css
  19. 3
      src/client/generator-entrypoint.less
  20. 16
      src/client/generator-entrypoint.ts
  21. 0
      src/client/noscript-entrypoint.css
  22. 82
      src/client/responses-entrypoint-old.ts
  23. 1
      src/client/responses-entrypoint.css
  24. 3
      src/client/responses-entrypoint.less
  25. 8
      src/client/responses-entrypoint.ts
  26. 7
      src/common/client/Attribution.css
  27. 6
      src/common/client/Attribution.tsx
  28. 0
      src/common/client/AttributionAuthor.css
  29. 2
      src/common/client/AttributionAuthor.tsx
  30. 0
      src/common/client/AttributionSet.css
  31. 2
      src/common/client/AttributionSet.tsx
  32. 18
      src/common/client/Button.css
  33. 13
      src/common/client/GeneratedElement.css
  34. 41
      src/common/client/GeneratedElement.tsx
  35. 5
      src/common/client/GeneratedResult.css
  36. 18
      src/common/client/GeneratedResult.tsx
  37. 15
      src/common/client/GeneratorPage.css
  38. 168
      src/common/client/GeneratorPage.tsx
  39. 0
      src/common/client/Main.css
  40. 13
      src/common/client/MainGeneratorAndResponses.css
  41. 364
      src/common/client/MainGeneratorAndResponses.tsx
  42. 3
      src/common/client/MainGeneratorOnly.css
  43. 3
      src/common/client/MainGeneratorOnly.less
  44. 300
      src/common/client/MainGeneratorOnly.tsx
  45. 3
      src/common/client/MainResponsesOnly.css
  46. 86
      src/common/client/MainResponsesOnly.tsx
  47. 0
      src/common/client/Page.css
  48. 0
      src/common/client/PageFooter.css
  49. 3
      src/common/client/PageFooter.tsx
  50. 36
      src/common/client/ResponseElement.css
  51. 36
      src/common/client/ResponseElement.less
  52. 38
      src/common/client/ResponseElement.tsx
  53. 11
      src/common/client/ResponseType.css
  54. 31
      src/common/client/ResponseType.tsx
  55. 12
      src/common/client/ResponsesPage.css
  56. 100
      src/common/client/ResponsesPage.tsx
  57. 18
      src/common/client/ResultText.css
  58. 37
      src/common/client/ResultText.tsx
  59. 0
      src/common/client/TableHeader.css
  60. 5
      src/common/client/contexts.ts
  61. 0
      src/common/client/pulseElement.css
  62. 3
      src/common/client/pulseElement.ts
  63. 19
      src/common/client/responsesForm.ts
  64. 8
      src/common/client/useClipboard.ts
  65. 73
      src/common/client/useHistory.ts
  66. 0
      src/common/client/usePopup.css
  67. 44
      src/common/client/useSubmitCallback.ts
  68. 1
      src/common/client/util.ts
  69. 226
      src/common/rolltable.ts
  70. 0
      src/common/template.ts
  71. 18
      src/common/tsconfig.json
  72. 55
      src/server/db/database.ts
  73. 188
      src/server/db/queries.ts
  74. 33
      src/server/db/querytypes.ts
  75. 219
      src/server/db/router.ts
  76. 8
      src/server/db/validators.ts
  77. 28
      src/server/discord/commands.ts
  78. 5
      src/server/discord/embed.ts
  79. 6
      src/server/discord/router.ts
  80. 21
      src/server/entrypoint.ts
  81. 2
      src/server/tsconfig.json
  82. 2
      src/server/web/bundles/sourcemaps.ts
  83. 49
      src/server/web/router.ts
  84. 15
      src/tsconfig.json
  85. 2
      wrangler.toml

@ -9,7 +9,6 @@
<option name="USE_DOUBLE_QUOTES" value="false" />
<option name="FORCE_QUOTE_STYlE" value="true" />
<option name="ENFORCE_TRAILING_COMMA" value="Remove" />
<option name="USE_EXPLICIT_JS_EXTENSION" value="FALSE" />
<option name="SPACES_WITHIN_OBJECT_LITERAL_BRACES" value="true" />
<option name="SPACES_WITHIN_IMPORTS" value="true" />
</JSCodeStyleSettings>
@ -19,7 +18,7 @@
<option name="USE_DOUBLE_QUOTES" value="false" />
<option name="FORCE_QUOTE_STYlE" value="true" />
<option name="ENFORCE_TRAILING_COMMA" value="Remove" />
<option name="USE_EXPLICIT_JS_EXTENSION" value="FALSE" />
<option name="USE_EXPLICIT_JS_EXTENSION" value="ALWAYS_JS" />
<option name="USE_IMPORT_TYPE" value="ALWAYS" />
<option name="SPACES_WITHIN_OBJECT_LITERAL_BRACES" value="true" />
<option name="SPACES_WITHIN_IMPORTS" value="true" />

@ -2,6 +2,7 @@
<project version="4">
<component name="SqlDialectMappings">
<file url="file://$PROJECT_DIR$/migrations/0000_initialize_responses_table.sql" dialect="SQLite" />
<file url="file://$PROJECT_DIR$/migrations/0006_add_audit_log_table.sql" dialect="SQLite" />
<file url="PROJECT" dialect="SQLite" />
</component>
</project>

@ -97,7 +97,7 @@ CREATE TRIGGER IF NOT EXISTS authorshipTypeDeleted
FROM authors
WHERE authorshipTypeId = OLD.id)
BEGIN
SELECT RAISE(ABORT, 'authorship type is still used1');
SELECT RAISE(ABORT, 'authorship type is still used!');
END;
INSERT OR IGNORE INTO authors (discordSnowflake, authorshipTypeId)
@ -109,7 +109,6 @@ CREATE TABLE IF NOT EXISTS resultSets
id INTEGER PRIMARY KEY,
name TEXT,
description TEXT,
creatorId INTEGER,
discordSnowflake TEXT,
global INTEGER NOT NULL DEFAULT FALSE CHECK (global IN (TRUE, FALSE))
) STRICT;
@ -126,15 +125,13 @@ CREATE TABLE IF NOT EXISTS resultMappings
id INTEGER PRIMARY KEY,
resultId INTEGER NOT NULL, -- FOREIGN KEY REFERENCES rollableResults (id) ON DELETE CASCADE ON UPDATE CASCADE
setId INTEGER NOT NULL, -- FOREIGN KEY REFERENCES resultSets (id) ON DELETE CASCADE ON UPDATE CASCADE
authorId INTEGER, -- FOREIGN KEY REFERENCES authors (id) ON DELETE SET NULL ON UPDATE CASCADE
created INTEGER NOT NULL,
updated INTEGER NOT NULL
authorId INTEGER -- FOREIGN KEY REFERENCES authors (id) ON DELETE SET NULL ON UPDATE CASCADE
) STRICT;
CREATE UNIQUE INDEX IF NOT EXISTS idx_resultMapping_setId_resultId_resultOncePerSet ON resultMappings (setId, resultId);
INSERT OR IGNORE INTO resultMappings (resultId, setId, authorId, created, updated)
SELECT rollableResults.id, resultSets.id, authors.id, responses.id, responses.timestamp
INSERT OR IGNORE INTO resultMappings (resultId, setId, authorId)
SELECT rollableResults.id, resultSets.id, authors.id
FROM responses
LEFT JOIN rollableResults
ON rollableResults.tableId = responses.tableId
@ -284,10 +281,8 @@ BEGIN
END;
CREATE VIEW IF NOT EXISTS responses AS
SELECT resultMappings.created AS id,
rollableResults.tableId AS tableId,
SELECT rollableResults.tableId AS tableId,
rollableResults.text AS text,
resultMappings.updated AS timestamp,
authors.discordSnowflake AS userSnowflake,
(CASE
WHEN resultSets.discordSnowflake = authors.discordSnowflake THEN NULL
@ -312,10 +307,10 @@ CREATE TABLE intentionallyCrash
INSERT INTO intentionallyCrash (differences)
SELECT 'uh oh'
FROM (SELECT *
FROM (SELECT * FROM responsesOriginal EXCEPT SELECT * FROM responses)
FROM (SELECT tableId, text, userSnowflake, serverSnowflake, access FROM responsesOriginal EXCEPT SELECT tableId, text, userSnowflake, serverSnowflake, access FROM responses)
UNION ALL
SELECT *
FROM (SELECT * FROM responses EXCEPT SELECT * FROM responsesOriginal));
FROM (SELECT tableId, text, userSnowflake, serverSnowflake, access FROM responses EXCEPT SELECT tableId, text, userSnowflake, serverSnowflake, access FROM responsesOriginal));
DROP TABLE intentionallyCrash;
DROP TABLE responsesOriginal;
@ -340,7 +335,7 @@ BEGIN
INSERT OR IGNORE INTO authors (discordSnowflake, authorshipTypeId)
VALUES (NEW.userSnowflake,
(SELECT authorshipTypes.id FROM authorshipTypes WHERE authorshipTypes.name = 'Discord contributor'));
INSERT OR ABORT INTO resultMappings (resultId, setId, authorId, created, updated)
INSERT OR ABORT INTO resultMappings (resultId, setId, authorId)
VALUES ((SELECT id FROM rollableResults WHERE tableId = NEW.tableId AND text = NEW.text),
(SELECT id
FROM resultSets
@ -350,9 +345,7 @@ BEGIN
WHEN NEW.access = 2 THEN NEW.userSnowflake
ELSE RAISE(ABORT, 'access must be in 0, 1, 2')
END),
(SELECT id FROM authors WHERE discordSnowflake = NEW.userSnowflake),
NEW.id,
NEW.timestamp);
(SELECT id FROM authors WHERE discordSnowflake = NEW.userSnowflake));
END;
CREATE TRIGGER IF NOT EXISTS responsesUpdated
@ -388,9 +381,7 @@ BEGIN
WHEN NEW.access = 2 THEN 0
ELSE RAISE(ABORT, 'access must be in 0, 1, 2')
END),
authorId = (SELECT id FROM authors WHERE discordSnowflake = NEW.userSnowflake),
created = NEW.id,
updated = NEW.timestamp
authorId = (SELECT id FROM authors WHERE discordSnowflake = NEW.userSnowflake)
WHERE resultId = (SELECT id FROM rollableResults WHERE tableId = OLD.tableId AND text = OLD.text)
AND setId = (SELECT id
FROM resultSets

@ -0,0 +1,37 @@
-- Migration number: 0006 2024-07-01T08:24:03.736Z
CREATE TABLE IF NOT EXISTS userAuditLog (
changeId INTEGER PRIMARY KEY,
userId INTEGER NOT NULL,
newName TEXT NULL,
newUrl TEXT NULL,
timestamp INTEGER NOT NULL
) STRICT;
CREATE INDEX IF NOT EXISTS userUpdateTimestamp ON userAuditLog (userId, timestamp DESC);
CREATE TABLE IF NOT EXISTS setAuditLog (
changeId INTEGER PRIMARY KEY,
userId INTEGER NOT NULL,
setId INTEGER NOT NULL,
newName TEXT NULL,
newDescription TEXT NULL,
updateType INTEGER NOT NULL,
timestamp INTEGER NOT NULL
) STRICT;
CREATE INDEX IF NOT EXISTS setUpdateTimestamp ON setAuditLog (setId, timestamp DESC);
CREATE INDEX IF NOT EXISTS setUpdateTimestampByUser ON setAuditLog (userId, timestamp DESC);
CREATE TABLE IF NOT EXISTS resultAuditLog (
changeId INTEGER PRIMARY KEY,
mappingId INTEGER NOT NULL,
userId INTEGER NOT NULL,
setId INTEGER NOT NULL,
newResultId INTEGER NULL,
updateType INTEGER NOT NULL,
timestamp INTEGER NOT NULL
) STRICT;
CREATE INDEX IF NOT EXISTS resultUpdateTimestampByMapping ON resultAuditLog (mappingId, timestamp DESC);
CREATE INDEX IF NOT EXISTS resultUpdateTimestampBySet ON resultAuditLog (setId, timestamp DESC);
CREATE INDEX IF NOT EXISTS resultUpdateTimestampByUser ON resultAuditLog (userId, timestamp DESC);

4309
package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -9,37 +9,40 @@
"start": "wrangler dev",
"generate": "tsx src/build/bundle-client.ts"
},
"type": "module",
"devDependencies": {
"@babel/core": "^7.23.7",
"@babel/plugin-transform-runtime": "^7.23.7",
"@babel/preset-env": "^7.23.8",
"@babel/preset-react": "^7.24.7",
"@babel/preset-typescript": "^7.23.3",
"@babel/runtime": "^7.23.8",
"@cloudflare/workers-types": "^4.20231218.0",
"@rollup/plugin-babel": "^6.0.4",
"@rollup/plugin-commonjs": "^25.0.7",
"@rollup/plugin-commonjs": "^26.0.1",
"@rollup/plugin-node-resolve": "^15.2.3",
"@rollup/plugin-terser": "^0.4.4",
"@types/clean-css": "^4.2.11",
"@types/escape-html": "^1.0.4",
"@types/less": "^3.0.6",
"@types/markdown-escape": "^1.1.3",
"@types/slug": "^5.0.7",
"change-case": "^5.4.2",
"clean-css": "^5.3.3",
"fast-deep-equal": "^3.1.3",
"less": "^4.2.0",
"rollup": "^4.9.5",
"rollup": "^4.18.0",
"rollup-plugin-ts": "^3.4.5",
"tsx": "^4.7.0",
"typescript": "^5.0.4",
"rollup-plugin-typescript2": "^0.36.0",
"ts-loader": "^9.5.1",
"tsx": "^4.16.0",
"typescript": "^5.5.2",
"wrangler": "^3.0.0"
},
"dependencies": {
"collapse-white-space": "^2.1.0",
"discord-snowflake": "^2.0.0",
"escape-html": "^1.0.3",
"itty-router": "^4.0.26",
"itty-router": "^5.0.17",
"jsoncrush": "^1.1.8",
"locale-includes": "^1.0.6",
"markdown-escape": "^2.0.0",
"preact": "^10.19.6",
"preact-iso": "^2.4.0",

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

@ -1,15 +1,20 @@
import { DEFAULT_IN_PATH, DEFAULT_OUT_PATH, getBundle, writeBundle } from './bundler';
import { getBundle, writeBundle } from './bundler.js';
async function main(inPath: string = DEFAULT_IN_PATH, outPath: string = DEFAULT_OUT_PATH) {
async function main(inPath: string, outPath: string) {
const bundle = await getBundle(inPath)
await writeBundle(bundle, outPath, true)
}
main(...process.argv.slice(2)).then(() => {
const args = process.argv.slice(2)
if (args.length !== 2) {
console.error('need exactly two arguments: inPath and outPath')
throw Error("invalid arguments")
}
main(args[0], args[1]).then(() => {
console.info('generated client helpers');
}).catch((err) => {
console.error('could not generate client helpers');
console.error(err && 'stack' in err ? err.stack : err);
console.error(err)
throw err;
}).catch(() => {
process.exit(1);

@ -9,22 +9,20 @@ import {
type VariableDeclaration,
type VariableStatement
} from 'typescript';
import typescriptModule from 'typescript';
import { readFile, writeFile, readdir } from 'node:fs/promises';
import { basename, dirname, join, normalize } from 'node:path';
import { writeFile, readdir } from 'node:fs/promises';
import { basename, join } from 'node:path';
import {createHash} from 'node:crypto';
import {camelCase} from 'change-case';
import { render as renderLess } from 'less';
import CleanCSS from 'clean-css';
import { default as CleanCSS } from 'clean-css';
import type {
HashedBundled,
SourceMappedHashedBundled,
SourceMappedBundled,
Bundled,
MaybeSourceMappedHashedBundled, SourceMap
} from '../common/bundle';
} from '../common/bundle.js';
import { rollup, type RollupCache } from 'rollup';
import typescript from 'rollup-plugin-ts';
import typescript from 'rollup-plugin-typescript2';
import terser from '@rollup/plugin-terser';
import nodeResolve from '@rollup/plugin-node-resolve';
import commonJs from '@rollup/plugin-commonjs';
@ -74,17 +72,7 @@ function exportObjectLiteral(identifier: string, pairs: Iterable<[string, MaybeS
}
async function processLess(atPath: string): Promise<SourceMappedBundled> {
const fileBase = basename(atPath.substring(0, atPath.length - LESS_SUFFIX.length));
const { css: lessCss, map: lessMap } = await renderLess(await readFile(atPath, { encoding: 'utf-8' }), {
paths: [dirname(atPath)],
math: 'strict',
strictUnits: true,
filename: fileBase + '.less',
strictImports: true,
sourceMap: {
outputSourceFiles: true,
}
});
const fileBase = basename(atPath.substring(0, atPath.length - CSS_SUFFIX.length));
const { styles, sourceMap } = await new CleanCSS({
sourceMap: true,
sourceMapInlineSources: true,
@ -94,15 +82,10 @@ async function processLess(atPath: string): Promise<SourceMappedBundled> {
inline: ['all'],
rebase: false,
compatibility: '*',
fetch(uri): never {
throw Error(`external files are unexpected after less compilation, but found ${uri}`)
fetch(url: string) {
throw Error(`external files are unexpected, but found ${url}`)
},
}).minify({
[fileBase + '.css']: {
styles: lessCss,
sourceMap: lessMap
}
})
}).minify([atPath])
return { bundled: styles, sourceMap: {...JSON.parse(sourceMap!.toString()), file: fileBase + ".css"} as SourceMap };
}
@ -111,16 +94,12 @@ async function processTypescript(atPath: string, inDir: string, cache?: RollupCa
cache: cache ?? true,
input: atPath,
plugins: [
nodeResolve({
}),
commonJs({
}),
nodeResolve({}),
commonJs({}),
typescript({
transpiler: "babel",
typescript: typescriptModule,
tsconfig: join(inDir, 'tsconfig.json')
tsconfig: join(inDir, '..', 'tsconfig.json'),
}),
terser({})
terser({}),
]
})
const {output: [chunk]} = await build.generate({
@ -139,7 +118,7 @@ async function processTypescript(atPath: string, inDir: string, cache?: RollupCa
}
}
const LESS_SUFFIX = '-entrypoint.less';
const CSS_SUFFIX = '-entrypoint.css';
const TS_SUFFIX = '-entrypoint.ts';
function hashBundled<T extends Bundled>(value: T & {readonly hash?: never}): T & HashedBundled {
@ -159,8 +138,8 @@ export async function getBundle(inDir: string): Promise<{ css: Map<string, Sourc
if (!ent.isFile()) {
continue;
}
if (ent.name.endsWith(LESS_SUFFIX)) {
css.set(camelCase(ent.name.substring(0, ent.name.length - LESS_SUFFIX.length)), hashBundled(await processLess(join(inDir, ent.name))));
if (ent.name.endsWith(CSS_SUFFIX)) {
css.set(camelCase(ent.name.substring(0, ent.name.length - CSS_SUFFIX.length)), hashBundled(await processLess(join(inDir, ent.name))));
} else if (ent.name.endsWith(TS_SUFFIX)) {
const {cache: newCache, bundle} = await processTypescript(join(inDir, ent.name), inDir, cache)
cache = newCache
@ -172,9 +151,6 @@ export async function getBundle(inDir: string): Promise<{ css: Map<string, Sourc
return { css, js };
}
export const DEFAULT_IN_PATH = normalize(join(__dirname, '../../src/client/'))
export const DEFAULT_OUT_PATH = normalize(join(__dirname, '../../src/server/web/bundles/client.generated.ts'))
export async function writeBundle({ css, js }: {css: Map<string, SourceMappedHashedBundled>, js: Map<string, SourceMappedHashedBundled>}, outFile: string, includeSourceMap: true): Promise<void>
export async function writeBundle({ css, js }: {css: Map<string, HashedBundled>, js: Map<string, HashedBundled>}, outFile: string, includeSourceMap: false): Promise<void>
export async function writeBundle({ css, js }: {css: Map<string, MaybeSourceMappedHashedBundled>, js: Map<string, MaybeSourceMappedHashedBundled>}, outFile: string, includeSourceMap: boolean): Promise<void>
@ -196,7 +172,7 @@ export async function writeBundle({ css, js }: {css: Map<string, MaybeSourceMapp
factory.createIdentifier( "MaybeSourceMappedHashedBundled")),
])
),
factory.createStringLiteral("../../common/bundle.js")),
factory.createStringLiteral("../../../common/bundle.js")),
exportObjectLiteral('CSS', css, includeSourceMap),
exportObjectLiteral('JS', js, includeSourceMap)
], factory.createToken(SyntaxKind.EndOfFileToken), NodeFlags.None)), {

@ -1,8 +1,8 @@
import { DEFAULT_IN_PATH, DEFAULT_OUT_PATH, getBundle, writeBundle } from './bundler';
import { getSourceMapFileName, SourceMapExtension, SourceMaps } from '../server/web/bundles/sourcemaps';
import { getBundle, writeBundle } from './bundler.js';
import {getSourceMapFileName, SourceMapExtension, SourceMaps} from '../server/web/bundles/sourcemaps.js';
import deepEqual from 'fast-deep-equal';
async function main(inPath: string = DEFAULT_IN_PATH, outPath: string = DEFAULT_OUT_PATH) {
async function main(inPath: string, outPath: string) {
const bundle = await getBundle(inPath)
const errors: string[] = []
for (const [name, {hash, sourceMap}] of bundle.css) {
@ -20,7 +20,12 @@ async function main(inPath: string = DEFAULT_IN_PATH, outPath: string = DEFAULT_
await writeBundle(bundle, outPath, false)
}
main(...process.argv.slice(2)).then(() => {
const args = process.argv.slice(2)
if (args.length !== 2) {
console.error('need exactly two arguments: inPath and outPath')
throw Error("invalid arguments")
}
main(args[0], args[1]).then(() => {
console.info('generated client helpers and confirmed sourcemaps are present');
}).catch((err) => {
console.error('could not generate client helpers or confirm sourcemaps are present');

@ -1,16 +1,19 @@
{
"compilerOptions": {
"module": "commonjs",
"module": "ESNext",
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"target": "ESNext",
"strict": true,
"noEmit": true,
"noImplicitAny": true,
"moduleResolution": "node",
"allowSyntheticDefaultImports": true,
"allowJs": true,
"moduleResolution": "Bundler",
"sourceMap": true,
"baseUrl": "./"
"baseUrl": "./",
"importsNotUsedAsValues": "remove",
},
"include": [
"*"
"./*"
]
}

@ -1,25 +0,0 @@
import {responseLists, db} from './responses-entrypoint-old'
import {prepareGenerator} from './generator-entrypoint-old'
Promise.all([prepareGenerator(db), responseLists]).then(([gen, res]) => {
res.addSelectionListener((ev) => {
gen.setActiveResult(ev.detail, true)
})
gen.addRerollListener((ev) => {
for (const result of ev.detail.changedResults) {
res.setActiveElementForTable(result)
}
})
console.info("connected generator and response list")
}).catch((e) => {
console.error(e)
})
function updateHash(): void {
if (location.hash === "" || location.hash === "#" || !location.hash) {
location.replace("#generator")
}
}
window.addEventListener("hashchange", updateHash)
updateHash()

@ -0,0 +1,9 @@
@import "../common/client/MainGeneratorAndResponses.css";
#generator:not(:target) {
display: none;
}
#generator:target ~ #responses {
display: none;
}

@ -1,12 +0,0 @@
@import "../common/client/GeneratorPage";
@import "../common/client/ResponsesPage";
@import "../common/client/Page";
@import "../common/client/PageFooter";
#generator:not(:target) {
display: none;
}
#generator:target ~ #responses {
display: none;
}

@ -0,0 +1,15 @@
import { DOMLoaded } from './onload.js';
import { createElement, hydrate } from 'preact';
import {
MainGeneratorAndResponses,
type MainGeneratorAndResponsesProps, reconstituteMainGeneratorAndResponses
} from '../common/client/MainGeneratorAndResponses.js';
DOMLoaded.then(() => {
const props: MainGeneratorAndResponsesProps = {
...reconstituteMainGeneratorAndResponses(document.querySelector("div#mainGeneratorAndResponses")!),
}
hydrate(createElement(MainGeneratorAndResponses, props), document.body)
}).catch((ex) => {
console.error(ex)
})

@ -1,294 +0,0 @@
import {
ExportFormat,
exportScenario,
type GeneratedState,
generatedStateToString,
getResultFrom,
RolledValues,
rollOn,
RollSelections,
type RollTable,
RollTableDatabase,
type RollTableDetailsAndResults,
type RollTableResult,
type RollTableResultFull
} from '../common/rolltable';
import { buildGenerated, htmlTableIdentifier } from '../common/template';
import { DOMLoaded } from './onload';
import { scrapeGeneratedScenario } from './scraper';
import { showPopup } from './Popup';
import { pulseElement } from './pulse';
import { DOMTemplateBuilder } from './template';
import escapeHTML from 'escape-html';
export interface RerollEventDetail {
rerolledAll: boolean
changedResults: Iterable<RollTableResultFull<RollTableDetailsAndResults>>
fullResults: ReadonlyMap<RollTable, RollTableResult>
selections: ReadonlySet<RollTable>
}
export class Generator {
readonly generator: HTMLElement;
readonly scenario: HTMLUListElement;
readonly copyButtons: HTMLElement;
readonly rollButtons: HTMLElement;
readonly db: RollTableDatabase | undefined;
private readonly rolled = new RolledValues();
private readonly selected = new RollSelections();
get state(): GeneratedState {
return {
final: false,
rolled: this.rolled,
selected: this.selected,
}
}
getTableWithHtmlId(id: string, prefix?: string): RollTable | undefined {
return Array.from(this.rolled.keys()).find(t => id === ((prefix ?? '') + htmlTableIdentifier(t)));
}
selectAll(): this {
for (const check of this.scenario.querySelectorAll('input[type=checkbox]:not(:checked)') as Iterable<HTMLInputElement>) {
check.checked = true;
check.dispatchEvent<"change">(new Event("change", {cancelable: true, bubbles: true, composed: false}))
const table = this.getTableWithHtmlId(check.id, 'selected-');
if (table) {
this.selected.add(table);
}
}
return this
}
selectNone(): this {
this.selected.clear();
for (const check of this.scenario.querySelectorAll('input[type=checkbox]:checked') as Iterable<HTMLInputElement>) {
check.checked = false;
check.dispatchEvent<"change">(new Event("change", {cancelable: true, bubbles: true, composed: false}))
}
return this
}
reroll(all: boolean): this {
if (!this.db) {
return this
}
const changes: RollTableResultFull<RollTableDetailsAndResults>[] = []
for (const row of this.scenario.querySelectorAll(`.generatedElement`)) {
const check = row.querySelector<HTMLInputElement>(`input.generatedSelect:checked`)
const text = row.querySelector<HTMLElement>(`.resultText`)
if ((all || check) && text) {
let result = this.db.mappings.get(parseInt(text.dataset["mappingid"] ?? '-1'))
if (!result || result.table.resultsById.size === 1) {
continue
}
const origResult = result
const table = result.table
while (result === origResult) {
result = rollOn(table)
}
this.setActiveResult(result, all)
changes.push(result)
pulseElement(text)
}
}
this.generator.dispatchEvent(new CustomEvent<RerollEventDetail>("reroll", {
composed: false,
bubbles: true,
cancelable: false,
detail: {
rerolledAll: all,
changedResults: changes,
fullResults: this.rolled,
selections: this.selected,
}
}))
return this
}
loadValuesFromDOM(): this {
this.rolled.clear()
this.selected.clear()
const scenario = scrapeGeneratedScenario(this.scenario)
if (!scenario) {
throw Error("Failed to load generated values from DOM")
}
for (const [scrapedTable, scrapedResult] of scenario.rolled) {
const table = this.db?.getTableMatching(scrapedTable) ?? scrapedTable
const result = getResultFrom(table, scrapedResult)
if (scenario.selected.has(scrapedTable)) {
this.selected.add(table)
}
this.rolled.add(result)
}
return this
}
private getGeneratedElementForTable(table: RollTable): HTMLElement|null {
return this.scenario.querySelector(`#generated-${escapeHTML(htmlTableIdentifier(table))}`)
}
private replaceResultInElement(result: RollTableResult, generatedElement: HTMLElement) {
// sister function is buildGeneratedElement
const generatedDiv = generatedElement.querySelector(`.generated`)
if (!generatedDiv) {
throw Error(`couldn't find .generated in replaceResultInElement`)
}
generatedDiv.replaceWith(buildGenerated({result, includesResponses: !!this.db, builder: DOMTemplateBuilder}))
const button = generatedElement.querySelector<HTMLButtonElement>(`.resultText`)!
pulseElement(button)
this.rolled.add(result)
}
private changeSelection(generatedElement: HTMLElement, selected: boolean) {
const check = generatedElement.querySelector<HTMLInputElement>(`.generatedSelect`)
if (!check) {
return
}
if (check.checked !== selected) {
check.checked = selected
check.dispatchEvent<"change">(new Event("change", {cancelable: true, bubbles: true, composed: false}))
}
}
async copy(format: ExportFormat): Promise<void> {
const exported = exportScenario(Array.from(this.rolled.values()), format)
return navigator.clipboard.writeText(exported)
}
attachHandlers(): this {
this.generator.addEventListener('click', (e) => this.clickHandler(e));
this.generator.addEventListener('change', (e) => this.changeHandler(e));
this.generator.querySelector<HTMLButtonElement>(`#reroll`)!.disabled = (this.selected.size === 0)
return this;
}
private clickHandler(e: Event): void {
if (e.target instanceof HTMLButtonElement || e.target instanceof HTMLAnchorElement) {
switch (e.target.id) {
case "selectNone":
this.selectNone()
break
case "selectAll":
this.selectAll()
break
case "copyMD":
this.copy(ExportFormat.Markdown)
.then(() => showPopup(this.copyButtons, `Copied Markdown to clipboard!`, 'success'))
.catch((e) => {
console.error("Failed while copying Markdown:", e)
showPopup(this.copyButtons, `Failed to copy Markdown to clipboard`, 'error')
})
break
case "copyBB":
this.copy(ExportFormat.BBCode)
.then(() => showPopup(this.copyButtons, `Copied BBCode to clipboard!`, 'success'))
.catch((e) => {
console.error("Failed while copying BBCode:", e)
showPopup(this.copyButtons, `Failed to copy BBCode to clipboard`, 'error')
})
break
case "copyEmojiText":
this.copy(ExportFormat.TextEmoji)
.then(() => showPopup(this.copyButtons, `Copied text (with emojis) to clipboard!`, 'success'))
.catch((e) => {
console.error("Failed while copying text (with emojis):", e)
showPopup(this.copyButtons, `Failed to copy text (with emojis) to clipboard`, 'error')
})
break
case "copyText":
this.copy(ExportFormat.TextOnly)
.then(() => showPopup(this.copyButtons, `Copied text to clipboard!`, 'success'))
.catch((e) => {
console.error("Failed while copying text:", e)
showPopup(this.copyButtons, `Failed to copy text to clipboard`, 'error')
})
break
case "reroll":
this.reroll(false)
break
case "rerollAll":
this.reroll(true)
break
default:
return
}
e.preventDefault()
}
}
private changeHandler(e: Event): void {
if (e.target instanceof HTMLInputElement && e.target.type === 'checkbox' && e.target.id.startsWith('selected-')) {
const check = e.target
const table = this.getTableWithHtmlId(check.id, 'selected-');
if (table) {
if (check.checked) {
this.selected.add(table);
} else {
this.selected.delete(table);
}
this.generator.querySelector<HTMLButtonElement>(`#reroll`)!.disabled = (this.selected.size === 0)
pulseElement(check)
}
}
}
constructor(generator: HTMLElement, generatorForm: HTMLUListElement, copyButtons: HTMLElement, rollButtons: HTMLElement, db?: RollTableDatabase) {
this.generator = generator;
this.scenario = generatorForm;
this.copyButtons = copyButtons;
this.rollButtons = rollButtons;
this.db = db;
}
setActiveResult(result: RollTableResultFull<RollTableDetailsAndResults>, clearSelection?: boolean) {
const tableElement = this.getGeneratedElementForTable(result.table)
if (!tableElement) {
return
}
this.replaceResultInElement(result, tableElement)
if (clearSelection) {
this.changeSelection(tableElement, false)
}
}
addRerollListener(listener: (ev: CustomEvent<RerollEventDetail>) => void, options?: boolean|EventListenerOptions): void {
this.generator.addEventListener('reroll', listener, options)
}
}
function initGenerator(db?: RollTableDatabase): Generator {
const generatorFound = document.getElementById('generator');
if (!generatorFound) {
throw Error('generator was not found');
}
const generatedScenarioFound = document.getElementById('generatedScenario');
if (!generatedScenarioFound || !(generatedScenarioFound instanceof HTMLUListElement)) {
throw Error('generated scenario was not found');
}
const copyButtons = document.getElementById("copyButtons")
if (!copyButtons) {
throw Error('copy buttons were not found')
}
const rollButtons = document.getElementById("rollButtons")
if (!rollButtons) {
throw Error('roll buttons were not found')
}
return new Generator(generatorFound, generatedScenarioFound, copyButtons, rollButtons, db).loadValuesFromDOM().attachHandlers();
}
let pendingGenerator: Promise<Generator>|undefined = undefined
export async function prepareGenerator(db?: Promise<RollTableDatabase>): Promise<Generator> {
if (pendingGenerator) {
throw Error(`prepareGenerator should only be called once`)
}
pendingGenerator = DOMLoaded.then(() => db)
.then((promisedDb) => initGenerator(promisedDb))
return pendingGenerator
}
DOMLoaded.then(() => pendingGenerator ?? prepareGenerator())
.then(g => console.info(`loaded generator:\n${generatedStateToString(g.state)}`))
.catch(e => console.error('failed to load generator', e))

@ -0,0 +1,2 @@
@import "../common/client/MainGeneratorOnly.css";
/* empty file, just for redirecting the build process to point into the common files */

@ -1,3 +0,0 @@
@import "../common/client/GeneratorPage";
@import "../common/client/Page";
@import "../common/client/PageFooter";

@ -0,0 +1,16 @@
import { DOMLoaded } from './onload.js';
import {
type MainGeneratorProps,
MainGeneratorOnly,
reconstituteMainGeneratorOnly
} from '../common/client/MainGeneratorOnly.js';
import { createElement, hydrate } from 'preact';
DOMLoaded.then(() => {
const props: MainGeneratorProps = {
...reconstituteMainGeneratorOnly(document.querySelector("div#mainGeneratorOnly")!),
}
hydrate(createElement(MainGeneratorOnly, props), document.body)
}).catch((ex) => {
console.error(ex)
})

@ -1,82 +0,0 @@
import type { RollTableDatabase, RollTableDetailsAndResults, RollTableResultFull } from '../common/rolltable';
import { DOMLoaded } from './onload';
import { scrapeResponseLists } from './scraper';
import { htmlTableIdentifier } from '../common/template';
import escapeHTML from 'escape-html';
class ResponseLists {
readonly db: RollTableDatabase
readonly listsElement: HTMLElement
constructor(db: RollTableDatabase, listsElement: HTMLElement) {
this.db = db
this.listsElement = listsElement
}
addSelectionListener(listener: (e: CustomEvent<RollTableResultFull<RollTableDetailsAndResults>>) => void, options?: boolean|EventListenerOptions): void {
this.listsElement.addEventListener("resultselected", listener, options)
}
configureHandlers(): this {
this.listsElement.addEventListener("click", (e) => {
if (e.target instanceof HTMLElement && e.target.classList.contains("makeResponseActive")) {
const response = e.target.closest(`.response`)
if (!response) {
console.log("no response")
return
}
const mappingId = response.id && response.id.startsWith("response-") ? parseInt(response.id.substring("response-".length), 10) : NaN
if (isNaN(mappingId)) {
console.log("no mapping ID")
return
}
const result = this.db.mappings.get(mappingId)
if (!result) {
console.log("no result")
return
}
const ev = new CustomEvent<RollTableResultFull<RollTableDetailsAndResults>>("resultselected", {
bubbles: true,
cancelable: true,
detail: result
})
if (e.target.dispatchEvent(ev)) {
this.setActiveElementForTable(result)
const button = response.querySelector(`.resultText`) as HTMLElement
if (button) {
button.focus()
}
}
}
})
return this
}
setActiveElementForTable(result: RollTableResultFull<RollTableDetailsAndResults>) {
const oldActive = this.listsElement.querySelector(`#responses-${escapeHTML(htmlTableIdentifier(result.table))} .response.active`)
const newActive = this.listsElement.querySelector(`#response-${escapeHTML(`${result.mappingId}`)}`)
if (!newActive || oldActive === newActive) {
return
}
newActive.classList.add("active")
if (!oldActive) {
return
}
oldActive.classList.remove("active")
}
}
function initResponseList(): ResponseLists {
const listsElement = document.querySelector<HTMLElement>(`#responseLists`)
if (!listsElement) {
throw Error(`can't find #responseLists`)
}
const lists = scrapeResponseLists(listsElement)
if (!lists) {
throw Error(`can't parse #responseLists`)
}
const {db} = lists
return new ResponseLists(db, listsElement).configureHandlers()
}
export const responseLists: Promise<ResponseLists> = DOMLoaded.then(() => initResponseList())
export const db: Promise<RollTableDatabase> = responseLists.then(r => r.db)

@ -0,0 +1 @@
@import "../common/client/MainResponsesOnly.css";

@ -1,3 +0,0 @@
@import "../common/client/ResponsesPage";
@import "../common/client/Page";
@import "../common/client/PageFooter";

@ -0,0 +1,8 @@
import { DOMLoaded } from './onload.js';
import { MainResponsesOnly, reconstituteMainResponsesOnly } from '../common/client/MainResponsesOnly.js';
import { createElement, hydrate } from 'preact';
DOMLoaded.then(() => {
const props = reconstituteMainResponsesOnly(document.querySelector("div#mainResponsesOnly")!)
hydrate(createElement(MainResponsesOnly, props), document.body)
})

@ -1,5 +1,5 @@
@import "AttributionAuthor";
@import "AttributionSet";
@import "AttributionAuthor.css";
@import "AttributionSet.css";
.attributed {
position: relative;
@ -41,6 +41,9 @@
.attribution .attributionBubble * {
user-select: none;
}
.attribution .attributionBubble > * {
margin: 0.1rem;
}
.attribution .button {
margin-top: 0.5rem;

@ -1,16 +1,16 @@
import type {
RollTableAuthor,
} from '../rolltable';
} from '../rolltable.js';
import {
AttributionAuthor,
reconstituteAttributionAuthorIfExists
} from './AttributionAuthor';
} from './AttributionAuthor.js';
import { Fragment } from 'preact';
import {
AttributionSet,
type AttributionSetProps,
reconstituteAttributionSetIfExists
} from './AttributionSet';
} from './AttributionSet.js';
import type { PropsWithChildren } from 'preact/compat';
export interface AttributionPropsFull {

@ -1,4 +1,4 @@
import type { RollTableAuthor } from '../rolltable';
import type { RollTableAuthor } from '../rolltable.js';
export function reconstituteAttributionAuthorIfExists(element: HTMLParagraphElement | null, partial?: Partial<RollTableAuthor>|null): RollTableAuthor|null {
if (!element || partial === null) {

@ -1,4 +1,4 @@
import type { RollTableResultSet } from '../rolltable';
import type { RollTableResultSet } from '../rolltable.js';
import { Fragment } from 'preact';
export type AttributionSetProps = Pick<RollTableResultSet, "name"|"id"|"global">

@ -1,4 +1,5 @@
.button {
flex: 1 0 auto;
border: none;
padding: 0.5rem;
font-size: 1rem;
@ -13,23 +14,23 @@
box-shadow: 0 0 black;
transform: none;
transition: background-color 0.1s ease, transform 0.1s ease, box-shadow 0.1s ease;
}
&:disabled {
.button:disabled {
background-color: slategray;
color: #333;
cursor: inherit;
}
}
&:not(:disabled):hover, &:not(:disabled):focus {
.button:not(:disabled):hover, .button:not(:disabled):focus {
background-color: darkgray;
box-shadow: -0.2rem 0.2rem black;
transform: translate(0.2rem, -0.2rem);
}
}
&:not(:disabled):active {
.button:not(:disabled):active {
box-shadow: 0 0 black;
transform: none;
}
}
.buttons {
@ -37,8 +38,9 @@
flex-wrap: wrap;
align-items: center;
justify-content: stretch;
& > * {
}
.buttons > * {
flex: 1 0 auto;
margin: 0.2rem 0 0 0.3rem
}
}

@ -1,5 +1,6 @@
@import "TableHeader";
@import "GeneratedResult";
@import "TableHeader.css";
@import "GeneratedResult.css";
@import "pulseElement.css";
.generatedElement {
list-style: none;
@ -52,12 +53,12 @@
text-align: center;
line-height: 2rem;
border-radius: 1rem;
}
&::after {
.generatedSelect::after {
content: '🔒'
}
}
&:checked::after {
.generatedSelect:checked::after {
content: '🎲';
}
}

@ -5,15 +5,18 @@ import {
tableIdentifier,
type TableProps,
TableTitle
} from './TableHeader';
} from './TableHeader.js';
import {
GeneratedResult,
type GeneratedResultProps, type GeneratedResultPropsFull,
type PartialGeneratedResultProps, type PartialGeneratedResultPropsFull,
reconstituteGeneratedResult
} from './GeneratedResult';
} from './GeneratedResult.js';
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
import { pulseElement } from './pulseElement';
import { pulseElement } from './pulseElement.js';
import JSONCrush from 'jsoncrush';
const {crush, uncrush} = JSONCrush
export type GeneratedElementPropsBase = {
table: TableProps
@ -41,7 +44,7 @@ export function reconstituteGeneratedElement(element: HTMLLIElement, partial?: P
const result = reconstituteGeneratedResult(element.querySelector(".generatedResult")!, partial)
const selected = typeof partial?.selected !== 'undefined'
? partial.selected
: (element.querySelector<HTMLInputElement>('.generatedSelect')?.checked) ?? null
: (element.querySelector<HTMLInputElement>('.generatedSelect')?.defaultChecked) ?? null
const table = reconstituteTable(element.querySelector(".tableHeader")!, partial?.table)
if (result.set) {
return {
@ -62,6 +65,28 @@ export interface GeneratedElementEvents {
onSelectionChange?: (tableId: number, selected: boolean) => void
}
export function serializeLimitedText(table: TableProps, text: string): string {
if (table.id) {
return crush(JSON.stringify({id: table.id, text}))
} else {
return crush(JSON.stringify({emoji: table.emoji, title: table.title, ordinal: table.ordinal, text}))
}
}
export function deserializeLimitedText(serialized: string): {id: number, text: string}|{id: null, ordinal: number, emoji: string, title: string, text: string} {
const json = JSON.parse(uncrush(serialized))
if (typeof json !== 'object' || json === null || !('text' in json) || typeof json.text !== 'string') {
throw Error("invalid json data")
}
if ('id' in json && typeof json.id === 'number') {
return json
}
if ('ordinal' in json && typeof json.ordinal === 'number' && 'emoji' in json && typeof json.emoji === 'number' && 'title' in json && typeof json.title === 'string') {
return {...json, id: null}
}
throw Error("invalid json data")
}
export function GeneratedElement({ onSelectionChange, ...props }: GeneratedElementProps & GeneratedElementEvents) {
const ref = useRef<HTMLInputElement>(null);
const selected = props.selected
@ -83,6 +108,7 @@ export function GeneratedElement({ onSelectionChange, ...props }: GeneratedEleme
}, [ref, selected, lastSelected, setLastSelected])
return <li class="generatedElement" id={`generated-${tableIdentifier(props.table)}`}>
<h2 class="generatedHead">
<input type="hidden" name="id" value={props.textId ?? serializeLimitedText(props.table, props.text)} />
<label
class="generatedLabel tableHeader"
{...(props.selected !== null ? {"for": checkId} : {})}
@ -91,12 +117,13 @@ export function GeneratedElement({ onSelectionChange, ...props }: GeneratedEleme
{' '}
<TableTitle {...props.table} />
</label>
<input type="checkbox" class={`generatedSelect${props.selected === null ? " unselectable" : ""}`}
{ selected !== null ? <input type="checkbox" class={`generatedSelect${props.selected === null ? " unselectable" : ""}`}
id={checkId}
name={checkId}
name="sel"
value={tableId}
checked={props.selected ?? false}
onChange={changeCallback}
ref={ref} />
ref={ref} /> : null}
</h2>
<GeneratedResult {...props} />
</li>

@ -1,5 +1,6 @@
@import "Attribution";
@import "ResultText";
@import "Attribution.css";
@import "pulseElement.css";
@import "ResultText.css";
.generatedResult {
margin: 0;

@ -3,17 +3,17 @@ import {
type AttributionPropsEmpty, type AttributionPropsFull,
type PartialAttributionProps, type PartialAttributionPropsEmpty,
reconstituteAttribution,
} from './Attribution';
} from './Attribution.js';
import {
reconstituteResultText,
ResultText,
type ResultTextPropsFull,
type ResultTextPropsLimited
} from './ResultText';
import { LinkButton } from './Button';
import { responseIdPrefix } from './ResponseElement';
import { IncludesResponses } from './ResponsesPage';
} from './ResultText.js';
import { LinkButton } from './Button.js';
import { responseIdPrefix } from './ResponseElement.js';
import { useContext } from 'preact/hooks';
import { IncludesResponses } from './contexts.js';
export type GeneratedResultPropsFull = AttributionPropsFull & ResultTextPropsFull
@ -33,15 +33,15 @@ export function reconstituteGeneratedResult(div: HTMLDivElement, partial?: Parti
reconstituteResultText(div.querySelector(".resultText")!, partial)
const attribution =
reconstituteAttribution(div.querySelector(".attribution")!, partial)
if (result.updated && attribution.set) {
if (attribution.set) {
return {
...attribution,
...result,
}
} as GeneratedResultPropsFull
} else {
return {
...result as ResultTextPropsLimited,
}
} as GeneratedResultPropsLimited
}
}
@ -51,7 +51,7 @@ export function GeneratedResult(props: GeneratedResultProps) {
<ResultText {...props} />
<Attribution {...props}>
{includesResponses && props.set
? <p><LinkButton class={"jumpToResponse"} href={`#${responseIdPrefix}${props.mappingId}`}>Jump to Result in List</LinkButton></p>
? <p class="buttons"><LinkButton class={"jumpToResponse"} href={`#${responseIdPrefix}${props.textId}`}>Jump to Result in List</LinkButton></p>
: null}
</Attribution>
</div>

@ -1,13 +1,9 @@
@import "Page";
@import "Button";
@import "usePopup";
@import "Page.css";
@import "Button.css";
@import "GeneratedElement.css";
@import "usePopup.css";
#generator {
position: absolute;
top: 0;
min-height: 100dvh;
left: 0;
right: 0;
margin: 0;
padding: 2rem;
display: flex;
@ -24,14 +20,15 @@
#generatedScenario {
padding: 0;
list-style: none;
}
#generator .buttons {
margin-left: -0.3rem;
}
#copyButtons::before {
content: "Copy as:";
padding-right: 0.2rem;
margin: 0.2rem 0 0 0.3rem
}

@ -1,18 +1,16 @@
import { FormButton, LinkButton } from './Button';
import { FormButton, LinkButton } from './Button.js';
import { createContext, Fragment } from 'preact';
import { usePopup } from './usePopup';
import { useCallback, useContext } from 'preact/hooks';
import { ExportFormat, exportFormatToString } from '../rolltable';
import { usePopup } from './usePopup.js';
import { useCallback, useContext, useEffect, useMemo, useState } from 'preact/hooks';
import { ExportFormat, exportFormatToString } from '../rolltable.js';
import {
GeneratedElement,
type GeneratedElementProps,
reconstituteGeneratedElement,
} from './GeneratedElement';
import { IncludesResponses } from './ResponsesPage';
import { tableIdentifier } from './TableHeader';
export const IncludesGenerator = createContext(false)
} from './GeneratedElement.js';
import { tableIdentifier } from './TableHeader.js';
import { type FormInfo, type FormReturnType, useSubmitCallback } from './useSubmitCallback.js';
import { IncludesResponses } from './contexts.js';
export interface GeneratorProps {
generatorTargetUrl: string
@ -26,11 +24,6 @@ export enum GeneratorSelect {
None = "none",
}
export enum GeneratorReroll {
All = "all",
Selected = "selected",
}
export function reconstituteGenerator(element: HTMLDivElement, partial?: Partial<GeneratorProps>): GeneratorProps {
const addToDiscordUrl = partial?.addToDiscordUrl ?? element.querySelector<HTMLAnchorElement>("#addToDiscord")?.href ?? null
const editable = partial?.editable ?? !!element.querySelector("#rollButtons")
@ -43,46 +36,68 @@ export function reconstituteGenerator(element: HTMLDivElement, partial?: Partial
addToDiscordUrl,
editable,
generatorTargetUrl,
elements: elements,
elements,
}
}
export enum GeneratorFormAction {
Reroll = 'reroll selected',
RerollAll = 'reroll all',
OpenInGenerator = 'open in generator',
SaveScenario = 'get scenario link',
GoToResponses = 'go to responses',
GoToOffline = 'go to offline version',
}
export interface GeneratorFormInfo extends FormInfo {
action: GeneratorFormAction,
}
export interface GeneratorSubmitResult extends FormReturnType {
status?: Promise<string|void>
}
export interface GeneratorEvents {
onCopy?: (format: ExportFormat) => Promise<void>
onReroll?: (which: GeneratorReroll) => Promise<void>
onSelect?: (which: GeneratorSelect) => void
onSelectionChange?: (tableId: number, selected: boolean) => void
onSubmit?: (action: GeneratorFormInfo) => GeneratorSubmitResult
}
enum GeneratorSelectionState {
All = "All",
Partial = "Some",
None = "None"
None = "None",
Unselectable = "Unselectable",
}
export function GeneratorPage({ editable, generatorTargetUrl, addToDiscordUrl, onSelectionChange, onSelect, onReroll, onCopy, elements }: GeneratorProps & GeneratorEvents) {
export function GeneratorPage({ editable, generatorTargetUrl, addToDiscordUrl, onSelectionChange, onSelect, onSubmit, onCopy, elements }: GeneratorProps & GeneratorEvents) {
const includesResponses = useContext(IncludesResponses);
const [copyPopupHost, showCopyPopup] = usePopup<HTMLDivElement>()
const [rerollPopupHost, showRerollPopup] = usePopup<HTMLDivElement>()
const [popupHost, showPopup] = usePopup<HTMLDivElement>()
const [firstRender, setFirstRender] = useState(true)
const copyWrapper = useCallback((format: ExportFormat) => {
if (!onCopy) {
console.error("No copy handler")
return showCopyPopup(`Failed to copy ${exportFormatToString(format)} to clipboard`, 'error')
return showPopup(`Failed to copy ${exportFormatToString(format)} to clipboard`, 'error')
}
onCopy(format).then(() => {
return showCopyPopup(`Copied ${exportFormatToString(format)} to clipboard`)
return showPopup(`Copied ${exportFormatToString(format)} to clipboard`)
}).catch((ex: unknown) => {
console.error(ex)
return showCopyPopup(`Failed to copy ${exportFormatToString(format)} to clipboard`, 'error')
return showPopup(`Failed to copy ${exportFormatToString(format)} to clipboard`, 'error')
}).catch((ex: unknown) => {
console.error(ex)
})
}, [showCopyPopup, onCopy])
}, [showPopup, onCopy])
const md = useCallback(() => copyWrapper(ExportFormat.Markdown), [copyWrapper])
const bb = useCallback(() => copyWrapper(ExportFormat.BBCode), [copyWrapper])
const emoji = useCallback(() => copyWrapper(ExportFormat.TextEmoji), [copyWrapper])
const text = useCallback(() => copyWrapper(ExportFormat.TextOnly), [copyWrapper])
const selected = elements.reduce<null|GeneratorSelectionState>((current, next) => {
const selected = useMemo(() => {
if (firstRender) {
return null;
}
return elements.reduce<GeneratorSelectionState>((current, next) => {
if (next.selected === null) {
return current
}
@ -93,79 +108,102 @@ export function GeneratorPage({ editable, generatorTargetUrl, addToDiscordUrl, o
return next.selected ? GeneratorSelectionState.Partial : GeneratorSelectionState.None
case GeneratorSelectionState.All:
return next.selected ? GeneratorSelectionState.All : GeneratorSelectionState.Partial
case null:
case GeneratorSelectionState.Unselectable:
return next.selected ? GeneratorSelectionState.All : GeneratorSelectionState.None
}
}, null)
}, GeneratorSelectionState.Unselectable);
}, [elements])
const selectAll = useCallback((ev: Event) => {
if (!onSelect || (ev.currentTarget instanceof HTMLButtonElement && ev.currentTarget.disabled)) {
return
}
onSelect(GeneratorSelect.All)
}, [onSelect, showRerollPopup])
}, [onSelect])
const selectNone = useCallback((ev: Event) => {
if (!onSelect || (ev.currentTarget instanceof HTMLButtonElement && ev.currentTarget.disabled)) {
return
}
onSelect(GeneratorSelect.None)
}, [onSelect, showRerollPopup])
const rerollSelected = useCallback((ev: Event) => {
if (!onReroll || (ev.currentTarget instanceof HTMLButtonElement && ev.currentTarget.disabled)) {
return
}, [onSelect])
const submitHandler = useCallback((d: FormInfo) => {
if (!onSubmit) {
return {allowSubmit: true}
}
onReroll(GeneratorReroll.Selected).then(() => {}).catch((ex: unknown) => {
console.error(ex)
return showRerollPopup(`Failed to reroll`, 'error')
}).catch((ex: unknown) => {
console.error(ex)
const action = d.data.get("action")
switch (action) {
case GeneratorFormAction.Reroll:
case GeneratorFormAction.RerollAll:
case GeneratorFormAction.GoToOffline:
case GeneratorFormAction.GoToResponses:
case GeneratorFormAction.OpenInGenerator:
case GeneratorFormAction.SaveScenario:
const {allowSubmit, status} = onSubmit({
...d,
action
})
}, [onReroll, showRerollPopup])
const rerollAll = useCallback((ev: Event) => {
if (!onReroll || (ev.currentTarget instanceof HTMLButtonElement && ev.currentTarget.disabled)) {
return
if (status) {
status.then((text) => {
if (text) {
return showPopup(text, "success")
}
}).catch((ex) => {
if (ex instanceof Error) {
return showPopup(`Failed to ${action}: ${ex.message}`, 'error')
} else if (ex) {
return showPopup(`Failed to ${action}: ${ex}`, 'error')
} else {
return showPopup(`Failed to ${action}`, 'error')
}
onReroll(GeneratorReroll.All).then(() => {}).catch((ex: unknown) => {
console.error(ex)
return showRerollPopup(`Failed to reroll all`, 'error')
}).catch((ex: unknown) => {
console.error(ex)
})
}, [onReroll, showRerollPopup])
}
return {allowSubmit}
default:
return {allowSubmit: true}
}
}, [onSubmit, showPopup])
useEffect(() => {
setFirstRender(false)
}, [])
const submitCallback = useSubmitCallback(submitHandler)
return <div id="generator" class="page">
<form method="post" action={generatorTargetUrl} id="generatorWindow" class="window readable">
<form method="post" action={generatorTargetUrl} enctype="multipart/form-data" id="generatorWindow" class="window readable" onSubmit={submitCallback}>
<h2 id="generatorHead">Your generated scenario</h2>
<ul id="generatedScenario">
{elements.map(i => <GeneratedElement key={tableIdentifier(i.table)} {...i} onSelectionChange={onSelectionChange} />)}
{elements.map(i =>
<GeneratedElement key={tableIdentifier(i.table)} {...i} onSelectionChange={onSelectionChange} />)}
</ul>
<div id="generatorControls">
<div ref={copyPopupHost} id="copyButtons" className="buttons requiresJs jsPopupHost">
<div ref={popupHost} id="generatorControls">
<div id="copyButtons" className="buttons requiresJs jsPopupHost">
<FormButton id="copyMD" type="button" onClick={onCopy && md} disabled={!onCopy}>Markdown</FormButton>
<FormButton id="copyBB" type="button" onClick={onCopy && bb} disabled={!onCopy}>BBCode</FormButton>
<FormButton id="copyEmojiText" type="button" onClick={onCopy && emoji} disabled={!onCopy}>Text + Emoji</FormButton>
<FormButton id="copyText" type="button" onClick={onCopy && text} disabled={!onCopy}>Text Only</FormButton>
</div>
{editable ? <div ref={rerollPopupHost} id="rollButtons" class="buttons jsPopupHost">
<FormButton type="submit" id="reroll" name="submit"
value="reroll" disabled={onReroll && (!selected || selected === GeneratorSelectionState.None)} onClick={onReroll && rerollSelected}>
Reroll {selected === GeneratorSelectionState.All ? 'All' : 'Selected'}
{editable ? <div id="rollButtons" class="buttons jsPopupHost">
<FormButton type="submit" id="reroll" name="action"
value={GeneratorFormAction.Reroll} disabled={selected === GeneratorSelectionState.None || selected === GeneratorSelectionState.Unselectable}>
Reroll Selected
</FormButton>
<FormButton type="button" id="selectAll" class="requiresJs" onClick={selectAll} disabled={!onSelect || selected === GeneratorSelectionState.All}>Select All</FormButton>
<FormButton type="button" id="selectNone" class="requiresJs" onClick={selectNone} disabled={!onSelect || !selected || selected === GeneratorSelectionState.None}>Select None</FormButton>
<FormButton type="button" id="selectAll" class="requiresJs" onClick={selectAll} disabled={!onSelect || selected === GeneratorSelectionState.All || selected === GeneratorSelectionState.Unselectable}>Select All</FormButton>
<FormButton type="button" id="selectNone" class="requiresJs" onClick={selectNone} disabled={!onSelect || selected === GeneratorSelectionState.None || selected === GeneratorSelectionState.Unselectable}>Select None</FormButton>
</div> : null}
<div id="scenarioButtons" class="buttons">
{editable ? <Fragment>
<LinkButton id="rerollAll" href={generatorTargetUrl} external={false} onClick={onReroll && rerollAll}>New Scenario</LinkButton>
<FormButton id="saveScenario" name="submit" value="saveScenario" type="submit">Get Scenario Link</FormButton>
<FormButton key={"newScenario"} id="rerollAll" name="action" value={GeneratorFormAction.RerollAll} type="submit">New Scenario</FormButton>
<FormButton key={"saveScenario"} id="saveScenario" name="action" value={GeneratorFormAction.SaveScenario} type="submit">Get Scenario Link</FormButton>
</Fragment> : <Fragment>
<LinkButton id="openInGenerator" href={generatorTargetUrl} external={false}>Open in Generator</LinkButton>
<FormButton key={"openInGenerator"} id="openInGenerator" name="action" value={GeneratorFormAction.OpenInGenerator} type="submit">Open in Generator</FormButton>
</Fragment>}
</div>
{ addToDiscordUrl || includesResponses ?
<div id="generatorLinks" class="buttons">
{addToDiscordUrl && <LinkButton external={true} id="addToDiscord" href={addToDiscordUrl}>Add to Discord</LinkButton>}
{includesResponses ? <LinkButton external={false} id="goToResponses" href="#responses">View Possible Responses</LinkButton> : null}
</div> : null}
{addToDiscordUrl && <LinkButton key={"addToDiscord"} external={true} id="addToDiscord" href={addToDiscordUrl}>Add to Discord</LinkButton>}
{includesResponses ? <LinkButton key={"viewResponses"} external={false} id="jumpToResponses" href="#responses">{ editable ? "View/Select" : "View Possible" } Responses</LinkButton> : null}
{!includesResponses ? <FormButton key={"viewResponses"} id="goToResponses" name="action" value={GeneratorFormAction.GoToResponses}>Switch to Responses</FormButton> : null}
{!includesResponses ? <FormButton key={"viewOffline"} id="goToOfflineVer" name="action" value={GeneratorFormAction.GoToOffline}>Switch to Offline Version</FormButton> : null}
</div>
</div>
</form>
</div>

@ -0,0 +1,13 @@
@import "Main.css";
@import "GeneratorPage.css";
@import "ResponsesPage.css";
@import "PageFooter.css";
#mainGeneratorAndResponses {
display: flex;
flex-flow: column;
justify-content: center;
align-content: center;
min-height: 100dvh;
min-width: 100dvw;
}

@ -0,0 +1,364 @@
import {
GeneratorFormAction,
type GeneratorFormInfo,
GeneratorPage,
GeneratorSelect,
type GeneratorSubmitResult,
reconstituteGenerator
} from './GeneratorPage.js';
import { PageFooter, reconstituteFooterProps } from './PageFooter.js';
import { deserializeLimitedText, type GeneratedElementProps, serializeLimitedText } from './GeneratedElement.js';
import { useCallback, useMemo, useState } from 'preact/hooks';
import {
ExportFormat,
exportScenario,
RolledValues,
rollOnAll,
RollSelections,
type RollTable,
RollTableDatabase,
type RollTableDetailsAndResults,
type RollTableResult
} from '../rolltable.js';
import { useHistoryState } from './useHistory.js';
import { reconstituteResponses, ResponsesPage, ResponsesTarget } from './ResponsesPage.js';
import type { ResponseTypeProps } from './ResponseType.js';
import { copyText } from './useClipboard.js';
import { IncludesGenerator, IncludesResponses } from './contexts.js';
import { type ResponsesFormInfo, type ResponsesFormResult, ResponsesSubmitType } from './responsesForm.js';
export interface MainGeneratorAndResponsesProps {
targetUrl: string
addToDiscordUrl: string|null
creditsUrl: string
initialEditable: boolean
database: RollTableDatabase
initialResults: ReadonlyMap<RollTable, RollTableResult>
initialSelected: ReadonlySet<RollTableDetailsAndResults>|null
}
export function parseDatabaseFrom({responseTypes, generatorElements, editable}: {
responseTypes: ResponseTypeProps[],
generatorElements: GeneratedElementProps[],
editable: boolean,
}): {
database: RollTableDatabase,
values: ReadonlyMap<RollTable, RollTableResult>,
selections: ReadonlySet<RollTableDetailsAndResults>|null
} {
const database = new RollTableDatabase()
for (const responseType of responseTypes) {
const table = database.addTable({
full: 'details',
id: responseType.table.id,
identifier: responseType.table.identifier,
emoji: responseType.table.emoji,
title: responseType.table.title,
ordinal: responseType.table.ordinal,
header: `${responseType.table.emoji} ${responseType.table.title}`,
name: responseType.table.name,
})
for (const response of responseType.contents) {
database.addResult({
full: true,
text: response.result.text,
textId: response.result.textId,
mappingId: response.result.mappingId,
author: response.attribution.author,
set: (response.attribution.set && {
name: response.attribution.set.name,
id: response.attribution.set.id,
description: null,
global: response.attribution.set.global,
}),
table: table,
})
}
}
const values = new RolledValues()
const selections = editable ? new RollSelections<RollTableDetailsAndResults>() : null
for (const element of generatorElements) {
const table: RollTable = (typeof element.table.id === 'number' ? database.tables.get(element.table.id) : null) ?? {
full: false,
header: `${element.table.emoji} ${element.table.title}`,
emoji: element.table.emoji,
title: element.table.title,
ordinal: element.table.ordinal,
}
const result: RollTableResult = (typeof element.textId === 'number' && table.full === "results"
? table.resultsById.get(element.textId)
: null) ?? {
full: false,
table,
text: element.text,
textId: element.textId,
}
values.add(result)
if (selections && element.selected && table.full === "results") {
selections.add(table)
}
}
return {
database,
values,
selections,
}
}
export function reconstituteMainGeneratorAndResponses(
element: HTMLDivElement, partial?: Partial<MainGeneratorAndResponsesProps>): MainGeneratorAndResponsesProps {
const {creditsUrl} = reconstituteFooterProps(element.querySelector("footer")!, partial)
const {generatorTargetUrl, addToDiscordUrl, editable, elements: generatorElements} =
reconstituteGenerator(element.querySelector<HTMLDivElement>("#generator")!)
const {types: responseTypes} =
reconstituteResponses(element.querySelector<HTMLFormElement>("#responses")!)
const {database, values, selections} = parseDatabaseFrom({responseTypes, generatorElements, editable})
return {
targetUrl: generatorTargetUrl,
addToDiscordUrl,
creditsUrl,
database,
initialEditable: editable,
initialResults: values,
initialSelected: selections,
}
}
export function MainGeneratorAndResponses({
initialEditable, targetUrl, addToDiscordUrl, database,
creditsUrl, initialResults, initialSelected,
}: MainGeneratorAndResponsesProps) {
const [results, setResults] =
useState<ReadonlyMap<RollTable, RollTableResult>>(initialResults)
const [selected, setSelected] =
useState<ReadonlySet<RollTableDetailsAndResults>|null>(initialSelected)
const [editable, setEditable] = useState(initialEditable)
const [currentUrl, setCurrentUrl] = useState<URL|null>(null)
const responsesBase: Omit<ResponseTypeProps, "selectedTextId">[] = useMemo(() =>
[...database.tables.values()].map((table) => ({
table: {
name: table.name,
emoji: table.emoji,
ordinal: table.ordinal,
id: table.id,
title: table.title,
identifier: table.identifier,
},
contents: [...table.resultsById.values()].map(result => ({
attribution: {
set: result.set,
author: result.author
},
result: {
text: result.text,
textId: result.textId,
mappingId: result.mappingId,
}
}))
})), [database])
const responsesWithSelections: ResponseTypeProps[] = useMemo(() =>
responsesBase.map(table => ({
...table,
selectedTextId: results.get(database.tables.get(table.table.id)!)?.textId ?? null
})), [responsesBase, database, results])
const generatedElements: GeneratedElementProps[] = useMemo(() =>
[...results.values()].map(result =>
result.full
? {
text: result.text,
textId: result.textId,
mappingId: result.mappingId,
set: result.set,
author: result.author,
selected: editable && result.table.full === "results" ? selected?.has(result.table) ?? null : null,
table: result.table,
} : {
text: result.text,
selected: null,
table: result.table,
set: null,
author: null,
textId: result.textId,
mappingId: null,
}), [results, selected])
const historyKey = useMemo(() =>
[...results.values()].map(result => result.textId ?? serializeLimitedText(result.table, result.text))
, [results])
const historyState = useMemo(() => {
return {selected: selected && [...selected].map(t => t.id), values: historyKey}
}, [historyKey, selected])
const onHistoryState = useCallback((state: unknown, url: URL) => {
// TODO: validate that this is in fact one of the states that this version of the page created,
// or at least is parseable by it
const {values, selected} = state as {selected: number[]|null, values: (string|number)[]}
const results = new RolledValues()
for (const value of values) {
if (typeof value === 'string') {
const serialized = deserializeLimitedText(value)
const table: RollTable|undefined = serialized.id === null
? {
full: false,
title: serialized.title,
ordinal: serialized.ordinal,
emoji: serialized.emoji,
header: `${serialized.emoji} ${serialized.title}`,
} : database.tables.get(serialized.id)
if (!table) {
console.error(`invalid table id in history ${serialized.id}`)
continue
}
results.add({
full: false,
table,
text: serialized.text,
textId: null,
})
} else {
const result = database.results.get(value)
if (!result) {
console.error(`invalid text id in history ${value}`)
continue
}
results.add(result)
}
}
setResults(results)
if (selected) {
const selections = new RollSelections<RollTableDetailsAndResults>()
for (const selection of selected) {
const table = database.tables.get(selection)
if (!table) {
console.error(`invalid table id in history ${selection}`)
continue
}
selections.add(table)
}
setSelected(selections)
}
setCurrentUrl(url)
}, [setResults, setSelected, setCurrentUrl, database])
useHistoryState({
state: historyState,
key: historyKey,
url: currentUrl,
onState: onHistoryState,
})
const onCopy = useCallback(async (format: ExportFormat) => {
return copyText(exportScenario(Array.from(results.values()), format))
}, [results])
const onSelectionChange = useCallback((tableId: number, state: boolean) => {
const table = database.tables.get(tableId)
if (!table) {
return
}
const newSelection = new RollSelections(selected)
if (state) {
newSelection.add(table)
} else {
newSelection.delete(table)
}
setSelected(newSelection)
}, [database, selected, setSelected])
const onSelect = useCallback((select: GeneratorSelect) => {
switch (select) {
case GeneratorSelect.All:
setSelected(new RollSelections(database.tables.values()));
break;
case GeneratorSelect.None:
setSelected(new RollSelections());
break;
}
}, [database, setSelected])
const onGeneratorSubmitted = useCallback((formData: GeneratorFormInfo): GeneratorSubmitResult => {
switch (formData.action) {
case GeneratorFormAction.GoToOffline:
return {
allowSubmit: false,
status: Promise.reject(Error("Already in the offline version!"))
}
case GeneratorFormAction.GoToResponses:
return {
allowSubmit: false,
status: Promise.reject(Error("Already in a version with responses!"))
}
case GeneratorFormAction.OpenInGenerator:
// TODO: make the call to the server to get the URL
setEditable(true)
setSelected(new RollSelections())
return {
allowSubmit: false,
}
case GeneratorFormAction.SaveScenario:
// TODO: make the call to the server to get the URL, and copy it to the clipboard
setEditable(false)
setSelected(null)
return {
allowSubmit: false,
status: copyText && copyText(window.location.href).then(() => Promise.resolve(`URL copied to clipboard!`))
}
case GeneratorFormAction.Reroll:
case GeneratorFormAction.RerollAll:
if (formData.action === GeneratorFormAction.RerollAll) {
setResults(rollOnAll(database.tables.values()))
setSelected(new RollSelections())
} else if (selected !== null && selected.size > 0) {
setResults(new RolledValues([...results, ...rollOnAll(selected)]))
}
return {
allowSubmit: false
};
default:
return {
allowSubmit: true,
}
}
}, [database, results, selected, setResults, setSelected, setEditable])
const onResponsesSubmitted = useCallback((data: ResponsesFormInfo): ResponsesFormResult => {
switch (data.action) {
case ResponsesSubmitType.ReturnToGenerator:
return {
allowSubmit: false,
status: Promise.reject(Error("Already in a version with a generator!"))
}
case ResponsesSubmitType.ChangeSelected:
const result = database.results.get(data.newSelectedTextId)
if (!result) {
return {
allowSubmit: false,
status: Promise.reject(Error(`no such text ID ${data.newSelectedTextId}`))
}
}
setResults(new RolledValues([...results.values(), result]))
return {
allowSubmit: false,
}
default:
return {
allowSubmit: true
}
}
}, [database, results, setResults])
return (
<div id="mainGeneratorAndResponses">
<IncludesGenerator.Provider value={true}>
<IncludesResponses.Provider value={true}>
<GeneratorPage
generatorTargetUrl={targetUrl}
elements={generatedElements}
addToDiscordUrl={addToDiscordUrl}
editable={editable}
onCopy={onCopy}
onSelect={selected ? onSelect : undefined}
onSelectionChange={selected ? onSelectionChange : undefined}
onSubmit={onGeneratorSubmitted} />
<ResponsesPage types={responsesWithSelections} targetUrl={targetUrl} target={editable ? ResponsesTarget.API : ResponsesTarget.Scenario} onSubmit={onResponsesSubmitted} />
<PageFooter creditsUrl={creditsUrl} />
</IncludesResponses.Provider>
</IncludesGenerator.Provider>
</div>)
}

@ -0,0 +1,3 @@
@import "Main.css";
@import "GeneratorPage.css";
@import "PageFooter.css";

@ -1,3 +0,0 @@
@import "./Main";
@import "./GeneratorPage";
@import "./PageFooter";

@ -1,41 +1,162 @@
import { GeneratorPage, GeneratorSelect, IncludesGenerator } from './GeneratorPage';
import { PageFooter } from './PageFooter';
import type { GeneratedElementProps } from './GeneratedElement';
import { useCallback, useMemo, useState } from 'preact/hooks';
import { ExportFormat, exportScenario, RollSelections, type RollTable, type RollTableResult } from '../rolltable';
import {
GeneratorFormAction,
type GeneratorFormInfo,
GeneratorPage,
GeneratorSelect,
type GeneratorSubmitResult,
reconstituteGenerator
} from './GeneratorPage.js';
import { PageFooter, reconstituteFooterProps } from './PageFooter.js';
import type { GeneratedElementProps } from './GeneratedElement.js';
import { useCallback, useMemo, useRef, useState } from 'preact/hooks';
import {
ExportFormat,
exportScenario,
isRollTableResultArray,
RolledValues,
RollSelections,
type RollTable,
type RollTableDetailsNoResults,
type RollTableLimited,
type RollTableResult
} from '../rolltable.js';
import { copyText } from './useClipboard.js';
import { useHistoryState } from './useHistory.js';
import { IncludesGenerator } from './contexts.js';
export interface GeneratorMainProps {
editable: boolean
export function elementsToValuesAndSelections(elements: GeneratedElementProps[]):
{values: ReadonlyMap<RollTable, RollTableResult>, selections: ReadonlySet<RollTable>|null} {
const values = new RolledValues()
const selections = new RollSelections()
let anyNonNullSelection = false
for (const x of elements) {
const table: RollTableLimited|RollTableDetailsNoResults = typeof x.table.id === 'number' ? {
full: 'details',
header: `${x.table.emoji} ${x.table.title}`,
id: x.table.id,
identifier: x.table.identifier,
name: x.table.name,
title: x.table.title,
ordinal: x.table.ordinal,
emoji: x.table.emoji
} : {
full: false,
header: `${x.table.emoji} ${x.table.title}`,
title: x.table.title,
emoji: x.table.emoji,
ordinal: x.table.ordinal
}
const result: RollTableResult<RollTableLimited|RollTableDetailsNoResults> = x.set ? {
full: true,
table,
set: {
name: x.set.name,
id: x.set.id,
global: x.set.global,
description: null,
},
author: x.author && {
name: x.author.name,
id: x.author.id,
url: x.author.url,
relation: x.author.relation
},
mappingId: x.mappingId,
textId: x.textId,
text: x.text,
} : {
full: false,
table,
textId: x.textId,
text: x.text,
}
values.add(result)
if (x.selected !== null) {
anyNonNullSelection = true
if (x.selected) {
selections.add(table)
}
}
}
return {values: values, selections: anyNonNullSelection ? selections : null}
}
export interface MainGeneratorProps {
generatorTargetUrl: string
addToDiscordUrl: string
addToDiscordUrl: string|null
creditsUrl: string
initialEditable: boolean
initialResults: ReadonlyMap<RollTable, RollTableResult>
initialSelected?: ReadonlySet<RollTable>
initialSelected: ReadonlySet<RollTable>|null
}
export interface GeneratorMainEvents {
copyText?: (text: string) => Promise<void>
}
export function reconstituteMainGeneratorOnly(
element: HTMLDivElement, partial?: Partial<MainGeneratorProps>): MainGeneratorProps {
const {creditsUrl} = reconstituteFooterProps(element.querySelector("footer")!, partial)
const {generatorTargetUrl, addToDiscordUrl, editable, elements} =
reconstituteGenerator(element.querySelector<HTMLDivElement>("#generator")!)
const {values, selections} = elementsToValuesAndSelections(elements)
// TODO: add a "reconstitute" function for MainGeneratorOnly
// TODO: add the other two top-level pages (MainResponsesOnly, MainGeneratorResponses) with "reconstitute" functions
// TODO: add the entry points that reconstitute and hydrate each of the respective top-level pages
return {
generatorTargetUrl,
addToDiscordUrl,
creditsUrl,
initialEditable: editable,
initialResults: values,
initialSelected: selections,
}
}
function MainGeneratorOnly({
editable, generatorTargetUrl, addToDiscordUrl,
creditsUrl, initialResults, initialSelected, copyText}: GeneratorMainProps & GeneratorMainEvents) {
const [results, ] =
export function MainGeneratorOnly({
initialEditable, generatorTargetUrl, addToDiscordUrl,
creditsUrl, initialResults, initialSelected,
}: MainGeneratorProps) {
const [results, setResults] =
useState<ReadonlyMap<RollTable, RollTableResult>>(initialResults)
const [selected, setSelected] =
useState<ReadonlySet<RollTable>|null>(initialSelected ?? null)
const onCopy = useCallback(async (format: ExportFormat) => {
if (!copyText) {
return Promise.reject(Error("Copy functionality is not implemented"))
useState<ReadonlySet<RollTable>|null>(initialSelected)
const [editable, setEditable] = useState(initialEditable)
const [currentUrl, setCurrentUrl] = useState<URL|null>(null)
const abortController = useRef<AbortController|null>(null)
const elements = useMemo(() => {
const output: GeneratedElementProps[] = []
for (const result of results.values()) {
if (result.full) {
output.push({
...result,
selected: selected === null ? null : selected.has(result.table)
})
} else {
output.push({
...result,
selected: null,
})
}
}
return output
}, [results, selected])
const onHistoryState = useCallback((state: unknown, url: URL) => {
// TODO: validate that this is in fact one of the states that this version of the page created,
// or at least is parseable by it
const {values, selections} = elementsToValuesAndSelections(state as GeneratedElementProps[])
setResults(values)
setSelected(selections)
setCurrentUrl(url)
}, [selected, setResults, setSelected, setCurrentUrl])
useHistoryState({
state: elements,
key: results,
url: currentUrl,
onState: onHistoryState,
})
const onCopy = useCallback(async (format: ExportFormat) => {
return copyText(exportScenario(Array.from(results.values()), format))
}, [copyText, results])
}, [results])
const onSelectionChange = useCallback((tableId: number, state: boolean) => {
const table = Array.from(initialResults.keys()).find(table => table.full && table.id === tableId)
if (!table) {
@ -59,35 +180,128 @@ function MainGeneratorOnly({
break;
}
}, [initialResults, setSelected])
const elements = useMemo(() => {
const output: GeneratedElementProps[] = []
for (const result of results.values()) {
if (result.full) {
output.push({
...result,
selected: selected === null ? null : selected.has(result.table)
})
} else {
output.push({
...result,
selected: null,
const onSubmit = useCallback(
({url: href, data, enctype, method, target, action}: GeneratorFormInfo): GeneratorSubmitResult => {
const oldCtrl = abortController.current
if (oldCtrl) {
oldCtrl.abort()
abortController.current = null
}
switch (action) {
case GeneratorFormAction.Reroll:
case GeneratorFormAction.RerollAll:
case GeneratorFormAction.SaveScenario:
case GeneratorFormAction.OpenInGenerator:
// continue
break
case GeneratorFormAction.GoToResponses:
case GeneratorFormAction.GoToOffline:
default:
return {
allowSubmit: true
}
}
if (target) {
return {
allowSubmit: true
}
}
if (method.toUpperCase() !== "POST") {
return {
allowSubmit: true
}
}
let body: FormData|URLSearchParams
switch (enctype) {
case "application/x-www-form-urlencoded":
body = new URLSearchParams()
data.forEach((value, key) => {
if (typeof value !== 'string') {
return
}
body.append(key, value)
})
break
case "multipart/form-data":
body = data
break
default:
return {
allowSubmit: true
}
}
return output
}, [results, selected])
return <IncludesGenerator.Provider value={true}>
const url = new URL(href)
const newCtrl = new AbortController()
abortController.current = newCtrl
const fetched = fetch(new Request(url, {
body: data,
method: "POST",
headers: [["Content-Type", enctype], ["Accept", "application/json"]],
redirect: 'follow',
window: null,
signal: newCtrl.signal,
})).then(async (response) => {
if (!response.ok) {
throw Error(
(response.body ? await response.text() : "")
|| response.status > 0
? `${response.status} ${response.statusText}`
: "Network error")
}
const finalUrl = new URL(response.url)
switch (action) {
case GeneratorFormAction.OpenInGenerator:
setEditable(true)
setCurrentUrl(finalUrl)
break
case GeneratorFormAction.SaveScenario:
setEditable(false)
setCurrentUrl(finalUrl)
return copyText(finalUrl.toString()).then(() => Promise.resolve(`URL copied to clipboard!`))
case GeneratorFormAction.Reroll:
case GeneratorFormAction.RerollAll:
const jsonText = await response.text()
const json = JSON.parse(jsonText)
if (!isRollTableResultArray(json)) {
throw Error("invalid JSON data")
}
const newResults = new RolledValues(action === GeneratorFormAction.RerollAll ? [] : results)
const newSelected = new RollSelections()
const tableLookup = [...results.keys()]
for (const result of json) {
const table = result.table
const oldTable = tableLookup.find((t) => (table.full && t.full && t.id === table.id) || (!table.full && !t.full && t.ordinal === table.ordinal))
if (oldTable) {
newResults.delete(oldTable)
}
newResults.add(result)
newSelected.add(table)
}
setCurrentUrl(finalUrl)
setResults(newResults)
setSelected(newSelected)
break
}
})
return {
allowSubmit: false,
status: fetched,
}
}, [setResults, setCurrentUrl, setSelected, results, abortController])
return (
<div id="mainGeneratorOnly">
<IncludesGenerator.Provider value={true}>
<GeneratorPage
generatorTargetUrl={generatorTargetUrl}
elements={elements}
addToDiscordUrl={addToDiscordUrl}
editable={editable}
onCopy={onCopy}
// TODO: implement onReroll using JSON fetch
// specifically: POST to the target URL as if you're submitting the form,
// _but_ add Accept: text/json to indicate you want it for API purposes and not as a page
onSelect={selected ? onSelect : undefined}
onSelectionChange={selected ? onSelectionChange : undefined} />
onSelectionChange={selected ? onSelectionChange : undefined}
onSubmit={onSubmit} />
<PageFooter creditsUrl={creditsUrl} />
</IncludesGenerator.Provider>
</div>)
}

@ -0,0 +1,3 @@
@import "Main.css";
@import "ResponsesPage.css";
@import "PageFooter.css";

@ -0,0 +1,86 @@
import { PageFooter, reconstituteFooterProps } from './PageFooter.js';
import {
reconstituteResponses,
ResponsesPage, ResponsesTarget
} from './ResponsesPage.js';
import type { ResponseTypeProps } from './ResponseType.js';
import { useCallback, useMemo, useState } from 'preact/hooks';
import { useHistoryState } from './useHistory.js';
import { IncludesResponses } from './contexts.js';
import { type ResponsesFormInfo, type ResponsesFormResult, ResponsesSubmitType } from './responsesForm.js';
export interface MainResponsesProps {
creditsUrl: string
targetUrl: string
target: ResponsesTarget
oldState?: string
startingTypes: ResponseTypeProps[]
}
export function reconstituteMainResponsesOnly(
element: HTMLDivElement, partial?: Partial<MainResponsesProps>): MainResponsesProps {
const {creditsUrl} = reconstituteFooterProps(element.querySelector("footer")!, partial)
const {types, targetUrl, target, oldState} =
reconstituteResponses(element.querySelector<HTMLFormElement>("#responses")!)
return {
creditsUrl,
targetUrl,
target,
oldState,
startingTypes: types,
}
}
export function MainResponsesOnly({
creditsUrl, startingTypes, targetUrl, target, oldState}: MainResponsesProps) {
const [types, setTypes] = useState<ResponseTypeProps[]>(startingTypes)
const selectedIDs = useMemo((): number[] => {
return types.map(type => {
return type.selectedTextId
}).filter((v): v is number => typeof v === 'number')
}, [types])
useHistoryState({
state: selectedIDs,
onState(state: unknown) {
// TODO: validate that this is in fact one of the states that this version of the page created
const ids = new Set(state as number[])
setTypes(types.map(type => {
return {
...type,
selectedTextId: type.contents.map(({result}) => result.textId).find(id => ids.has(id)) ?? null
}
}))
}
})
const onSubmit = useCallback((d: ResponsesFormInfo): ResponsesFormResult => {
switch (d.action) {
case ResponsesSubmitType.ChangeSelected:
setTypes(types.map((props) => {
if (!props.contents.some((match) => match.result.textId === d.newSelectedTextId)) {
return props
}
return {
...props,
selectedTextId: d.newSelectedTextId,
}
}))
return {
allowSubmit: false
}
case ResponsesSubmitType.ReturnToGenerator:
default:
return {
allowSubmit: true
}
}
}, [types, setTypes])
return (
<div id="mainResponsesOnly">
<IncludesResponses.Provider value={true}>
<ResponsesPage types={startingTypes} oldState={oldState} targetUrl={targetUrl} target={target} onSubmit={onSubmit} />
<PageFooter creditsUrl={creditsUrl} />
</IncludesResponses.Provider>
</div>)
}

@ -1,7 +1,6 @@
import { Fragment } from 'preact';
import { IncludesResponses } from './ResponsesPage';
import { IncludesGenerator } from './GeneratorPage';
import { useContext } from 'preact/hooks';
import { IncludesGenerator, IncludesResponses } from './contexts.js';
export interface PageFooterProps {
creditsUrl: string

@ -0,0 +1,36 @@
@import "ResultText.css";
@import "Attribution.css";
.response {
margin-top: 0.3rem;
display: flex;
align-items: stretch;
flex-flow: row nowrap;
scroll-margin-top: 12rem;
list-style: none;
}
.response.active {
position: relative;
min-height: 1.5rem;
}
.response.active::before {
width: 1rem;
margin: 0.2rem 0.2rem 0.2rem 0.5rem;
content: "";
flex: 0 0 auto;
background-image:
linear-gradient(to bottom left, transparent 50%, currentColor 0),
linear-gradient(to bottom right, currentColor 50%, transparent 0);
background-size: 100% 50%;
background-repeat: no-repeat;
background-position: top, bottom;
}
.response.active .resultText {
font-weight: bold;
}
.response.active .attribution .button {
display: none;
}

@ -1,36 +0,0 @@
@import "ResultText";
@import "Attribution";
.response {
margin-top: 0.3rem;
display: flex;
align-items: stretch;
flex-flow: row nowrap;
scroll-margin-top: 12rem;
list-style: none;
}
.response.active {
position: relative;
min-height: 1.5rem;
&::before {
width: 1rem;
margin: 0.2rem 0.2rem 0.2rem 0.5rem;
content: "";
flex: 0 0 auto;
background-image:
linear-gradient(to bottom left, transparent 50%, currentColor 0),
linear-gradient(to bottom right, currentColor 50%, transparent 0);
background-size: 100% 50%;
background-repeat: no-repeat;
background-position: top, bottom;
}
& .resultText {
font-weight: bold;
}
& .attribution .button {
display: none;
}
}

@ -1,16 +1,15 @@
import { reconstituteResultText, ResultText, type ResultTextPropsFull } from './ResultText';
import { reconstituteResultText, ResultText, type ResultTextPropsFull } from './ResultText.js';
import {
Attribution,
type AttributionPropsFull,
type PartialAttributionPropsFull, reconstituteAttribution
} from './Attribution';
import { FormButton } from './Button';
import { IncludesGenerator } from './GeneratorPage';
import { useCallback, useContext, useEffect, useState } from 'preact/hooks';
import { type Context, createContext, createRef } from 'preact';
import { pulseElement } from './pulseElement';
export const CurrentSelectedResponse: Context<number|null> = createContext<number|null>(null)
} from './Attribution.js';
import { FormButton } from './Button.js';
import { useContext, useEffect, useState } from 'preact/hooks';
import { createRef } from 'preact';
import { pulseElement } from './pulseElement.js';
import { IncludesGenerator, EditableResponses } from './contexts.js';
import { ResponsesSubmitType } from './responsesForm.js';
export interface ResponseElementProps {
attribution: AttributionPropsFull
@ -18,10 +17,6 @@ export interface ResponseElementProps {
selected: boolean
}
export interface ResponseElementEvents {
onSelected?: (mappingId: number) => void
}
export interface PartialResponseElementProps {
attribution?: PartialAttributionPropsFull
result?: Partial<ResultTextPropsFull>
@ -40,14 +35,9 @@ export function reconstituteResponseElement(element: HTMLLIElement, partial?: Pa
export const responseIdPrefix="response-"
export function ResponseElement({attribution, result, selected, onSelected}: ResponseElementProps & ResponseElementEvents) {
const includesGenerator = useContext(IncludesGenerator);
export function ResponseElement({attribution, result, selected}: ResponseElementProps) {
const editable = useContext(EditableResponses)
const [lastSelected, setLastSelected] = useState(selected)
const onSelect = useCallback(() => {
if (onSelected) {
onSelected(result.mappingId)
}
}, [attribution, result, onSelected])
const ref = createRef<HTMLLIElement>()
useEffect(() => {
if (lastSelected !== selected) {
@ -57,11 +47,13 @@ export function ResponseElement({attribution, result, selected, onSelected}: Res
}
}
}, [selected, lastSelected, setLastSelected, ref]);
return <li ref={ref} id={responseIdPrefix + result.mappingId} class={`response attributed${selected ? " active" : ""}`}>
return <li ref={ref} id={responseIdPrefix + result.textId} class={`response attributed${selected ? " active" : ""}`}>
{(editable && selected) ? <input type="hidden" name="sel" value={result.textId} /> : null}
<ResultText {...result} />
<Attribution {...attribution}>
{includesGenerator
? <FormButton type={"button"} class="makeResponseActive requiresJs" onClick={onSelect}>Set in Generated Scenario</FormButton>
{!selected && editable
? <p class="buttons"><FormButton type={"submit"} class="makeResponseActive"
name={ResponsesSubmitType.ChangeSelected} value={`${result.textId}`}>Set in Generated Scenario</FormButton></p>
: null}
</Attribution>
</li>

@ -1,6 +1,6 @@
@import "ResponseElement";
@import "Page";
@import "TableHeader";
@import "ResponseElement.css";
@import "Page.css";
@import "TableHeader.css";
.responseType {
list-style: none;
@ -30,3 +30,8 @@
.responseTypeList {
padding: 0;
}
.responseType .tableEmoji {
padding-right: 0.5rem;
user-select: text;
}

@ -5,36 +5,35 @@ import {
TableHeaderDataset,
tableIdentifier,
TableTitle
} from './TableHeader';
} from './TableHeader.js';
import {
reconstituteResponseElement,
ResponseElement,
type ResponseElementProps, responseIdPrefix
} from './ResponseElement';
import { useCallback } from 'preact/hooks';
} from './ResponseElement.js';
export interface ResponseTypeProps {
table: TableFullProps,
selectedMappingId: number | null,
selectedTextId: number | null,
contents: Omit<ResponseElementProps, 'selected'>[],
}
export interface PartialResponseTypeProps {
table?: Partial<TableFullProps>;
selectedMappingId?: number | null;
selectedTextId?: number | null;
contents?: Omit<ResponseElementProps, 'selected'>[];
}
export function reconstituteResponseType(element: HTMLLIElement, partial?: PartialResponseTypeProps): ResponseTypeProps {
const table = reconstituteTable(element.querySelector('.tableHeader')!, partial?.table) as TableFullProps;
let selected: number | null | undefined = partial?.selectedMappingId ?? null,
let selected: number | null | undefined = partial?.selectedTextId ?? null,
contents: Omit<ResponseElementProps, 'selected'>[] | undefined = partial?.contents;
if (!contents) {
contents = [];
for (const child of Array.from(element.querySelector('.responseTypeList')!.children) as HTMLLIElement[]) {
const childContents = reconstituteResponseElement(child);
if (typeof selected === 'undefined' && childContents.selected) {
selected = childContents.result.mappingId;
selected = childContents.result.textId;
}
contents.push(childContents);
}
@ -47,32 +46,22 @@ export function reconstituteResponseType(element: HTMLLIElement, partial?: Parti
}
return {
table,
selectedMappingId: selected,
selectedTextId: selected,
contents
};
}
export interface ResponseTypeEvents {
onSelectResponse?: (tableId: number, mappingId: number) => void
}
export const responseListIdPrefix = 'responses-';
export function ResponseType({ table, selectedMappingId, contents, onSelectResponse }: ResponseTypeProps & ResponseTypeEvents) {
const onSelectChild = useCallback((mappingId: number) => {
if (onSelectResponse) {
onSelectResponse(table.id, mappingId)
}
}, [onSelectResponse]);
export function ResponseType({ table, selectedTextId, contents }: ResponseTypeProps) {
return <li id={responseListIdPrefix + tableIdentifier(table)} class="responseType window readable">
<h2 class="responseTypeHead tableHeader" {...TableHeaderDataset(table)}>
<TableEmoji emoji={table.emoji} />{' '}<TableTitle title={table.title} />
</h2>
<ul class="responseTypeList">
{contents.map(result =>
<ResponseElement key={result.result.mappingId}
selected={result.result.mappingId === selectedMappingId}
onSelected={onSelectChild}
<ResponseElement key={result.result.textId}
selected={result.result.textId === selectedTextId}
{...result} />)}
</ul>
</li>;

@ -1,5 +1,5 @@
@import "Page";
@import "ResponseType";
@import "Page.css";
@import "ResponseType.css";
#responsesHeader {
position: sticky;
@ -29,12 +29,14 @@
overflow-x: visible;
}
#returnToGenerator {
#returnToGenerator, #goToGenerator {
flex-basis: 50%;
}
.responseNavEmoji {
margin-right: 0.2rem;
#responsesHeader .tableEmoji {
display: inline-block;
font-size: 100%;
transform: scale(140%);
}
#responsesHead {

@ -1,42 +1,106 @@
import { createContext } from 'preact';
import { responseListIdPrefix, ResponseType, type ResponseTypeProps } from './ResponseType';
import { IncludesGenerator } from './GeneratorPage';
import { LinkButton } from './Button';
import { TableEmoji, tableIdentifier, TableName } from './TableHeader';
import { reconstituteResponseType, responseListIdPrefix, ResponseType, type ResponseTypeProps } from './ResponseType.js';
import { FormButton, LinkButton } from './Button.js';
import { TableEmoji, tableIdentifier, TableName } from './TableHeader.js';
import { useContext } from 'preact/hooks';
export const IncludesResponses = createContext(false)
import { type FormInfo, useSubmitCallback } from './useSubmitCallback.js';
import { usePopup } from './usePopup.js';
import { useCallback } from 'preact/hooks';
import { EditableResponses, IncludesGenerator } from './contexts.js';
import { type ResponsesFormInfo, type ResponsesFormResult, ResponsesSubmitType } from './responsesForm.js';
export interface ResponsesProps {
targetUrl: string
target: ResponsesTarget
oldState?: string
types: ResponseTypeProps[]
}
export interface ResponsesEvents {
onSelectResponse: (tableId: number, mappingId: number) => void
export enum ResponsesTarget {
Scenario = "Scenario",
Generator = "Generator",
API = "API",
}
// TODO: add a "reconstitute" function for ResponsesPage
export function reconstituteResponses(element: HTMLFormElement): ResponsesProps {
return {
targetUrl: element.action,
target: element.dataset.target as ResponsesTarget,
oldState: element.querySelector<HTMLInputElement>('input#oldState')?.value,
types: Array.from(element.querySelectorAll<HTMLLIElement>("#responseLists li.responseType"))
.map(el => reconstituteResponseType(el))
}
}
export interface ResponsesEvents {
onSubmit?(i: ResponsesFormInfo): ResponsesFormResult
}
export function ResponsesPage({ types, onSelectResponse }: ResponsesProps & ResponsesEvents) {
export function ResponsesPage({ targetUrl, target, oldState, types, onSubmit }: ResponsesProps & ResponsesEvents) {
const includesGenerator = useContext(IncludesGenerator);
return <div id="responses" class="page">
<header id="responsesHeader" class="window">
const [headerPopupRef, showHeaderPopup] = usePopup()
const submitHandler = useCallback((d: FormInfo) => {
if (!onSubmit) {
return {allowSubmit: true}
}
const action = d.button?.name
let info: ResponsesFormInfo
switch (action) {
case ResponsesSubmitType.ChangeSelected:
info = { ...d, action: ResponsesSubmitType.ChangeSelected, newSelectedTextId: parseInt(d.button!.value) }
break
case ResponsesSubmitType.ReturnToGenerator:
info = { ...d, action: ResponsesSubmitType.ReturnToGenerator }
break
default:
return {allowSubmit: true}
}
const {allowSubmit, status} = onSubmit(info)
if (status) {
status.then((text) => {
if (text) {
return showHeaderPopup(text, "success")
}
}).catch((ex) => {
if (ex instanceof Error) {
return showHeaderPopup(`Failed to ${action}: ${ex.message}`, 'error')
} else if (ex) {
return showHeaderPopup(`Failed to ${action}: ${ex}`, 'error')
} else {
return showHeaderPopup(`Failed to ${action}`, 'error')
}
})
}
return {allowSubmit}
}, [onSubmit, showHeaderPopup])
const submitCallback = useSubmitCallback(submitHandler)
return <form id="responses" class="page" enctype="multipart/form-data" method={target === ResponsesTarget.API ? "POST" : "GET"} action={targetUrl} data-target={target} onSubmit={submitCallback}>
{(oldState && target === ResponsesTarget.API) ? <input id="oldState" name="oldState" value={oldState} type="hidden" /> : null}
<header id="responsesHeader" class="window" ref={headerPopupRef}>
<h1 id="responsesHead">Possible Responses</h1>
<nav id="responsesHeaderNav" class="buttons">
{types.map(type =>
<LinkButton key={tableIdentifier(type.table)} href={`#${responseListIdPrefix}${tableIdentifier(type.table)}`} external={false}>
<TableEmoji emoji={type.table.emoji} />{' '}<TableName name={type.table.name} />
<TableEmoji emoji={type.table.emoji} /><TableName name={type.table.name} />
</LinkButton>)}
{includesGenerator
? <LinkButton href={"#generator"} external={false} id="returnToGenerator">Return to Generator</LinkButton>
: null}
? <LinkButton href="#generator" external={false} id="returnToGenerator">Return to {target === ResponsesTarget.API ? "Generator" : "Scenario"}</LinkButton>
: target === ResponsesTarget.API
? <FormButton type="submit" name={ResponsesSubmitType.ReturnToGenerator} value={"OK"} id="returnToGenerator">Return to Generator</FormButton>
: target === ResponsesTarget.Scenario
? <LinkButton href={targetUrl} external={false} id="returnToGenerator">Return to Scenario</LinkButton>
: <LinkButton href={targetUrl} external={false} id="returnToGenerator">Go to Generator</LinkButton>}
</nav>
</header>
<EditableResponses.Provider value={target === ResponsesTarget.API}>
<ul id="responseLists">
{types.map(type =>
<ResponseType key={tableIdentifier(type.table)}
onSelectResponse={onSelectResponse}
{...type} />)}
</ul>
</div>
</EditableResponses.Provider>
</form>
}

@ -1,3 +1,5 @@
@import "pulseElement.css";
.resultText {
flex: 1 1 auto;
appearance: none;
@ -18,20 +20,20 @@
user-select: text;
transition: background-color 0.2s ease;
border-radius: 0.3rem;
}
&:hover:not(:active) {
.resultText:hover:not(:active) {
background-color: #BFBFBF60;
}
}
&:focus:not(:active) {
.resultText:focus:not(:active) {
background-color: #9F9FFF90;
}
}
&:focus:hover:not(:active) {
.resultText:focus:hover:not(:active) {
background-color: #8F8FDF90;
}
}
&:active {
.resultText:active {
background-color: #3F3FFFA0;
}
}

@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from 'preact/hooks';
import { pulseElement } from './pulseElement';
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
import { pulseElement } from './pulseElement.js';
import { createRef } from 'preact';
export interface ResultTextPropsBase {
text: string
@ -8,33 +9,33 @@ export interface ResultTextPropsBase {
export interface ResultTextPropsFull extends ResultTextPropsBase {
mappingId: number
textId: number
updated: Date
}
export interface ResultTextPropsLimited extends ResultTextPropsBase {
mappingId?: null
textId?: null
updated?: null
textId: number|null
}
export type ResultTextProps = ResultTextPropsFull|ResultTextPropsLimited
export function reconstituteResultText(button: HTMLButtonElement, partial: Partial<ResultTextProps> = {}): ResultTextProps {
const text = button.innerText
if (typeof partial.mappingId ?? button.dataset["mappingId"] === "undefined") {
return {text}
const textId = typeof (partial.textId ?? button.dataset["textId"]) === "undefined"
? null
: (partial.textId ?? parseInt(button.dataset["textId"]!))
if (textId === null || typeof (partial.mappingId ?? button.dataset["mappingId"]) === "undefined") {
return {text, textId}
} else {
return {
text,
mappingId: partial.mappingId ?? parseInt(button.dataset["mappingId"]!),
textId: partial.textId ?? parseInt(button.dataset["textId"]!),
updated: partial.updated ?? new Date(parseInt(button.dataset["updated"]!))
textId: textId,
}
}
}
export function ResultText({text, mappingId, textId, updated}: ResultTextProps) {
const ref = useRef<HTMLButtonElement>(null)
export function ResultText({text, mappingId, textId}: ResultTextProps) {
const ref = createRef<HTMLButtonElement>()
const [lastText, setLastText] = useState<string>(text)
useEffect(() => {
if (text !== lastText) {
@ -44,8 +45,16 @@ export function ResultText({text, mappingId, textId, updated}: ResultTextProps)
}
}
}, [ref, text, lastText, setLastText]);
return <button className="resultText" ref={ref}
{...(updated
? { "data-mapping-id": mappingId, "data-text-id": textId, "data-updated": updated.getTime() }
const onClick = useCallback(() => {
if (ref.current) {
ref.current.focus()
}
}, [ref])
return <button type="button" className="resultText" ref={ref} onClick={onClick}
{...((typeof textId === 'number')
? {"data-text-id": textId}
: {})}
{...(mappingId !== null
? { "data-mapping-id": mappingId }
: {})}>{text}</button>
}

@ -0,0 +1,5 @@
import { createContext } from 'preact';
export const IncludesGenerator = createContext(false)
export const IncludesResponses = createContext(false)
export const EditableResponses = createContext(false)

@ -7,6 +7,9 @@ function onPulseEnd(e: AnimationEvent): void {
}
export function pulseElement(element: HTMLElement) {
if (!element.offsetParent) {
return
}
element.removeEventListener("animationend", onPulseEnd)
element.removeEventListener("animationcancel", onPulseEnd)
if (element.classList.contains("pulse")) {

@ -0,0 +1,19 @@
import type { FormInfo, FormReturnType } from './useSubmitCallback.js';
export enum ResponsesSubmitType {
ChangeSelected = "change selected",
ReturnToGenerator = "return to generator",
}
export interface ResponsesReturnEvent extends FormInfo {
action: ResponsesSubmitType.ReturnToGenerator
}
export interface ResponsesSelectEvent extends FormInfo {
action: ResponsesSubmitType.ChangeSelected
newSelectedTextId: number
}
export type ResponsesFormInfo = ResponsesReturnEvent|ResponsesSelectEvent
export interface ResponsesFormResult extends FormReturnType {
status?: Promise<string|void>
}

@ -0,0 +1,8 @@
const clipboard = typeof window !== 'undefined' && window.navigator && window.navigator.clipboard
export async function copyText(text: string): Promise<void> {
if (!clipboard) {
throw Error("Clipboard functionality not supported here")
}
return clipboard.writeText(text)
}

@ -0,0 +1,73 @@
import { useCallback, useEffect, useRef } from 'preact/hooks';
export interface HistoryProps {
state: {}|null,
key?: {}|null
url?: URL|null
onState(state: unknown, url: URL): void
}
const history = typeof window !== 'undefined' && window.history
export function getSameOriginURL(target: URL|null|undefined, base: URL): URL {
if (!target) {
return base
}
const isFile = base.protocol === "file"
if (isFile) {
return new URL(target?.hash, base)
}
const sameOrigin = !!target && base.origin === target.origin
if (sameOrigin) {
return target
} else {
return new URL(target?.search ?? "?" + target?.hash ?? "", base)
}
}
export function useHistoryState({state: userState, key: givenKey = userState, url: givenUrl, onState}: HistoryProps): void {
const key = useRef(givenKey),
url = useRef<URL|null>(null),
firstRender = useRef(true),
popRender = useRef(false);
useEffect(() => {
if (history) {
const currentUrl = new URL(window.location.href)
const effectiveUrl = getSameOriginURL(givenUrl, currentUrl)
if (firstRender.current) {
firstRender.current = false
if (history.state !== null) {
popRender.current = true
onState(history.state, currentUrl)
} else {
history.replaceState(userState, "", effectiveUrl)
}
} else if (popRender) {
popRender.current = false
key.current = givenKey
url.current = effectiveUrl
history.replaceState(userState, "", effectiveUrl)
} else {
if (givenKey !== key.current || effectiveUrl !== url.current) {
key.current = givenKey
url.current = effectiveUrl
history.pushState(userState, "", effectiveUrl)
} else {
history.replaceState(userState, "", effectiveUrl)
}
}
}
}, [url, key, userState, givenKey, firstRender, popRender, givenUrl])
const onPopState = useCallback(() => {
if (history) {
popRender.current = true
onState(history.state, new URL(window.location.href))
}
}, [popRender, onState])
useEffect(() => {
if (history) {
window.addEventListener("popstate", onPopState)
return () => window.removeEventListener("popstate", onPopState)
}
}, [onPopState])
}

@ -0,0 +1,44 @@
import { useCallback } from 'preact/hooks';
export interface FormInfo {
data: FormData
method: string
url: string
enctype: string
target: string
button: HTMLButtonElement|HTMLInputElement|null
}
export interface FormReturnType {
allowSubmit?: boolean
}
export function useSubmitCallback(onSubmit?: (data: FormInfo) => FormReturnType|undefined): (e: SubmitEvent) => void {
return useCallback((e: SubmitEvent) => {
if (!onSubmit) {
return
}
const button =
e.submitter instanceof HTMLButtonElement || e.submitter instanceof HTMLInputElement ? e.submitter : null
const form = e.target instanceof HTMLFormElement ? e.target : null
if (!form) {
return
}
const data = new FormData(form, e.submitter)
const method = button?.formMethod ?? form.method
const url = button?.formAction ?? form.action
const enctype = button?.formEnctype ?? form.enctype
const target = button?.formTarget ?? form.target
const {allowSubmit} = onSubmit({
data,
method,
url,
enctype,
target,
button,
}) ?? {allowSubmit: true}
if (!allowSubmit) {
e.preventDefault()
}
}, [onSubmit])
}

@ -1,26 +1,92 @@
import markdownEscape from 'markdown-escape';
import { bbcodeEscape } from './bbcode';
import { bbcodeEscape } from './bbcode.js';
export interface RollTableLimited {
readonly full: false,
export interface RollTableBase {
readonly full: false|'details'|'results'|'input'
readonly emoji: string,
readonly title: string,
readonly header: string,
readonly ordinal: number,
}
export interface RollTableDetailsBase {
export interface RollTableLimited extends RollTableBase {
readonly full: false,
}
export interface RollTableDetailsBase extends RollTableBase {
readonly full: 'details'|'results'|'input'
readonly id: number,
readonly identifier: string,
readonly emoji: string,
readonly name: string,
readonly title: string,
readonly header: string,
readonly ordinal: number,
}
export interface RollTableDetailsNoResults extends RollTableDetailsBase {
readonly full: 'details'
}
export interface RollTableDetailsAndResultsTruncated extends RollTableDetailsBase {
readonly full: 'results'
}
export interface RollTableDetailsAndResults extends RollTableDetailsAndResultsTruncated {
readonly resultsById: ReadonlyMap<number, RollTableResultFull<this>>
readonly resultsByText: ReadonlyMap<string, RollTableResultFull<this>>
}
interface RollTableDetailsAndResultsInternal extends RollTableDetailsAndResultsTruncated {
readonly resultsById: Map<number, RollTableResultFull<this>>
readonly resultsByText: Map<string, RollTableResultFull<this>>
}
function isRollTableDetailsResultMap(map: ReadonlyMap<unknown, unknown>, table: RollTableDetailsAndResults, keytype: 'number'): map is ReadonlyMap<number, RollTableResultFull<RollTableDetailsAndResults>>
function isRollTableDetailsResultMap(map: ReadonlyMap<unknown, unknown>, table: RollTableDetailsAndResults, keytype: 'string'): map is ReadonlyMap<string, RollTableResultFull<RollTableDetailsAndResults>>
function isRollTableDetailsResultMap(map: ReadonlyMap<unknown, unknown>, table: RollTableDetailsAndResults, keytype: 'number'|'string'): map is ReadonlyMap<number|string, RollTableResultFull<RollTableDetailsAndResults>> {
for (const [key, value] of map.entries()) {
if (typeof key !== keytype) {
return false
}
if (!isRollTableResult(value, {expectTable: table}) || value.table !== table) {
return false
}
}
return true
}
function hasRollTableResultMaps(table: RollTableDetailsBase & {readonly full: 'results'}): table is RollTableDetailsAndResults {
return ("resultsById" in table && table.resultsById instanceof Map && isRollTableDetailsResultMap(table.resultsById, table as RollTableDetailsAndResults, 'number')
&& "resultsByText" in table && table.resultsByText instanceof Map && isRollTableDetailsResultMap(table.resultsByText, table as RollTableDetailsAndResults, 'string'))
}
export type RollTableDetails = RollTableDetailsNoResults|RollTableDetailsAndResults
export type RollTableDetailsTruncated = RollTableDetailsNoResults|RollTableDetailsAndResultsTruncated
function isRollTableDetails(table: RollTableBase & {readonly full: 'details'|'results'}, options: {skipResultsCheck: true}): table is RollTableDetailsTruncated
function isRollTableDetails(table: RollTableBase & {readonly full: 'details'|'results'}, options?: {skipResultsCheck?: false}): table is RollTableDetails
function isRollTableDetails(table: RollTableBase & {readonly full: 'details'|'results'}, options?: {skipResultsCheck?: boolean}): table is RollTableDetails|RollTableDetailsTruncated
function isRollTableDetails(table: RollTableBase & {readonly full: 'details'|'results'}, {skipResultsCheck}: {skipResultsCheck?: boolean} = {}): table is RollTableDetails|RollTableDetailsTruncated {
return ("id" in table && typeof table.id === "number"
&& "identifier" in table && typeof table.identifier === "string"
&& "name" in table && typeof table.name === "string"
&& (table.full === 'details' || skipResultsCheck || hasRollTableResultMaps(table as RollTableDetailsBase & {readonly full: 'results'})))
}
export type RollTable = RollTableLimited | RollTableDetails
export type RollTableOrInput = RollTable | RollTableDetailsInputResults
export type RollTableTruncated = RollTableLimited | RollTableDetailsTruncated
export function isRollTable(table: unknown, options: {skipResultsCheck: true}): table is RollTableTruncated
export function isRollTable(table: unknown, options?: {skipResultsCheck?: false}): table is RollTable
export function isRollTable(table: unknown, options?: {skipResultsCheck?: boolean}): table is RollTableTruncated
export function isRollTable(table: unknown, options: {skipResultsCheck?: boolean} = {}): table is RollTableTruncated {
return (typeof table === "object" && table !== null
&& "emoji" in table && typeof table.emoji === 'string'
&& "title" in table && typeof table.title === 'string'
&& "header" in table && typeof table.header === 'string'
&& "ordinal" in table && typeof table.ordinal === 'number'
&& "full" in table && (table.full === false
|| ((table.full === 'details' || table.full === 'results')
&& isRollTableDetails(table as RollTableBase & {readonly full: 'details'|'results'}, options))))
}
export function rollTableToString(v: RollTable) {
if (v.full) {
@ -38,6 +104,18 @@ export function rollTableToStringShort(v: RollTable) {
}
}
export interface RollTableDetailsInputResults extends RollTableDetailsBase {
readonly full: 'input'
readonly resultsById: Iterable<RollTableResultOrLookup<RollTableDetailsInputResults>|readonly [number, RollTableResultOrLookup<RollTableDetailsInputResults>]>;
}
export type RollTableDetailsOrInput = RollTableDetails | RollTableDetailsInputResults
export type RollTableOrInput = RollTableTruncated | RollTableDetailsInputResults
function isRollTableDetailsInputPair(v: unknown): v is readonly [number, RollTableResultOrLookup<RollTableDetailsOrInput>] {
return Array.isArray(v) && v.length === 2 && typeof v[0] === 'number' && isRollTableResult(v[1])
}
export const MAX_RESULT_LENGTH = 150;
export const MAX_IDENTIFIER_LENGTH = 20;
export const MAX_NAME_LENGTH = 50;
@ -50,6 +128,18 @@ export interface RollTableAuthor {
readonly relation: string;
}
export function authorToString(v: RollTableAuthor): string {
return `${v.relation} ${v.name} (${v.id})`
}
export function isRollTableAuthor(author: unknown): author is RollTableAuthor {
return (typeof author === "object" && author !== null
&& 'id' in author && typeof author.id === 'number'
&& 'name' in author && typeof author.name === 'string'
&& 'url' in author && (author.url === null || typeof author.url === 'string')
&& 'relation' in author && typeof author.relation === 'string');
}
export interface RollTableResultSet {
readonly id: number;
readonly name: string | null;
@ -57,9 +147,23 @@ export interface RollTableResultSet {
readonly global: boolean;
}
export function setToString(v: RollTableResultSet): string {
return `${v.global ? 'global' : 'local'} ${v.name ?? 'set'}`
}
export function isRollTableResultSet(set: unknown): set is RollTableResultSet {
return (typeof set === 'object' && set !== null
&& 'id' in set && typeof set.id === 'number'
&& 'name' in set && (set.name === null || typeof set.name === 'string')
&& 'description' in set && (set.description === null || typeof set.description === 'string')
&& 'global' in set && typeof set.global === 'boolean')
}
export interface RollTableResultLimited<T extends RollTableOrInput = RollTable> {
readonly full: false,
readonly text: string,
readonly textId: number|null
readonly tableId?: never
readonly table: T,
}
@ -72,27 +176,38 @@ export interface RollTableResultFull<T extends RollTableOrInput = RollTableDetai
readonly text: string,
readonly set: RollTableResultSet,
readonly author: RollTableAuthor | null,
readonly updated: Date,
}
export type RollTableResult<T extends RollTableOrInput = RollTable> = RollTableResultLimited<T> | RollTableResultFull<T>
export type RollTableResultOrLookup<T extends RollTableOrInput = RollTable> = RollTableResultFull<T>|RollTableResultLookup
export function setToString(v: RollTableResultSet): string {
return `${v.global ? 'global' : 'local'} ${v.name ?? 'set'}`
}
export function authorToString(v: RollTableAuthor): string {
return `${v.relation} ${v.name} (${v.id})`
export function isRollTableResult<TableT extends RollTableTruncated>(result: unknown, options: {expectTable: TableT, tableCheckOptions?: {skipResultsCheck?: boolean}}): result is RollTableResult<TableT>
export function isRollTableResult(result: unknown, options: {expectTable?: never, tableCheckOptions: {skipResultsCheck: true}}): result is RollTableResult<RollTableTruncated>
export function isRollTableResult(result: unknown, options?: {expectTable?: never, tableCheckOptions: {skipResultsCheck: false}}): result is RollTableResult
export function isRollTableResult(result: unknown, options?: {expectTable?: RollTableTruncated, tableCheckOptions?: {skipResultsCheck?: boolean}}): result is RollTableResult<RollTableTruncated>
export function isRollTableResult(result: unknown, {expectTable, tableCheckOptions}: {expectTable?: RollTableTruncated, tableCheckOptions?: {skipResultsCheck?: boolean}} = {}): result is RollTableResult<RollTableTruncated> {
return (typeof result === "object" && result !== null
&& 'table' in result && (expectTable ? result.table === expectTable : isRollTable(result.table, tableCheckOptions))
&& !('tableId' in result && typeof result.tableId !== 'undefined')
&& 'textId' in result && (result.textId === null || typeof result.textId === 'number')
&& 'text' in result && typeof result.text === 'string'
&& 'full' in result && (
result.full === false
|| (result.full === true
&& typeof result.textId === 'number'
&& 'mappingId' in result && typeof result.mappingId === 'number'
&& 'set' in result && isRollTableResultSet(result.set)
&& 'author' in result && (result.author === null || isRollTableAuthor(result.author))
)));
}
export function isRollTableResultArray<TableT extends RollTableTruncated>(array: unknown, resultOptions: {expectTable: TableT, tableCheckOptions?: {skipResultsCheck?: boolean}}): array is RollTableResult<TableT>[]
export function isRollTableResultArray(array: unknown, resultOptions: {expectTable?: never, tableCheckOptions: {skipResultsCheck: true}}): array is RollTableResult<RollTableTruncated>[]
export function isRollTableResultArray(array: unknown, resultOptions?: {expectTable?: never, tableCheckOptions?: {skipResultsCheck?: false}}): array is RollTableResult[]
export function isRollTableResultArray(array: unknown, resultOptions?: {expectTable?: RollTable, tableCheckOptions?: {skipResultsCheck?: boolean}}): array is RollTableResult<RollTableTruncated>[]
export function isRollTableResultArray(array: unknown, resultOptions: {expectTable?: RollTable, tableCheckOptions?: {skipResultsCheck?: boolean}} = {}): array is RollTableResult<RollTableTruncated>[] {
return Array.isArray(array) && array.every(item => isRollTableResult(item, resultOptions))
}
export function rollResultToString(v: RollTableResult) {
if (v.full) {
return `${v.text} (${v.mappingId}: ${v.textId}/${rollTableToStringShort(v.table)}/${setToString(v.set)}/${v.author ? authorToString(v.author) : 'no author'})`
} else {
return `${v.text} (???: ${rollTableToStringShort(v.table)})`
}
}
export interface RollTableResultLookup {
readonly textId: number,
readonly mappingId: number,
@ -101,39 +216,19 @@ export interface RollTableResultLookup {
readonly text: string,
readonly setId: number,
readonly authorId: number | null,
readonly updated: Date,
}
export interface RollTableDetailsInputResults extends RollTableDetailsBase {
readonly full: 'input'
readonly resultsById: Iterable<RollTableResultOrLookup<RollTableDetailsInputResults>|readonly [number, RollTableResultOrLookup<RollTableDetailsInputResults>]>;
}
function isResultArray(v: unknown): v is readonly [unknown, RollTableResultOrLookup<RollTableDetailsOrInput>] {
return Array.isArray(v) && isRollTableResult(v[1])
}
export type RollTableDetailsOrInput = RollTableDetails | RollTableDetailsInputResults
export interface RollTableDetailsNoResults extends RollTableDetailsBase {
readonly full: 'details'
}
export interface RollTableDetailsAndResults extends RollTableDetailsBase {
readonly full: 'results'
readonly resultsById: ReadonlyMap<number, RollTableResultFull<this>>
readonly resultsByText: ReadonlyMap<string, RollTableResultFull<this>>
}
export type RollTableResultOrLookup<T extends RollTableOrInput = RollTable> = RollTableResultFull<T>|RollTableResultLookup
interface RollTableDetailsAndResultsInternal extends RollTableDetailsBase {
readonly full: 'results'
readonly resultsById: Map<number, RollTableResultFull<this>>
readonly resultsByText: Map<string, RollTableResultFull<this>>
export function rollResultToString(v: RollTableResult) {
if (v.full) {
return `${v.text} (${v.mappingId}: ${v.textId}/${rollTableToStringShort(v.table)}/${setToString(v.set)}/${v.author ? authorToString(v.author) : 'no author'})`
} else {
return `${v.text} (???: ${rollTableToStringShort(v.table)})`
}
}
export type RollTableDetails = RollTableDetailsNoResults|RollTableDetailsAndResults
function compareRollTables(a: RollTableOrInput, b: RollTableOrInput): number {
export function compareRollTables(a: RollTableOrInput, b: RollTableOrInput): number {
return (a.ordinal - b.ordinal) ||
("id" in a !== "id" in b ? "id" in a ? -1 : 1 : 0) ||
("id" in a && "id" in b ? a.id - b.id : 0) ||
@ -150,9 +245,9 @@ function compareRollTableResults(a: RollTableResult|null|undefined, b: RollTable
if (a && a.full) {
if (b && b.full) {
if (a.set.global === b.set.global) {
return a.updated.getDate() < b.updated.getDate() ? preferA : preferB
return equalPreference
} else {
return !a.set.global ? preferA : preferB
return !b.set.global ? preferB : preferA
}
} else {
return preferA
@ -166,16 +261,12 @@ function compareRollTableResults(a: RollTableResult|null|undefined, b: RollTable
}
}
function isRollTableResult(result: unknown): result is RollTableResult<RollTableDetailsOrInput> {
return (typeof result === "object" && result !== null && 'table' in result
&& !('tableId' in result && typeof result.tableId !== 'undefined') && 'full' in result);
}
export function getResultFrom(table: RollTable, originalResult: RollTableResult): RollTableResult {
const dbResult = table.full === "results" ? table.resultsByText.get(originalResult.text) : null
return dbResult ?? {
full: false,
table,
textId: originalResult.textId,
text: originalResult.text
}
}
@ -327,10 +418,10 @@ export class RollTableDatabase implements Iterable<RollTableDetailsAndResults> {
}
addResult(result: RollTableResultOrLookup<RollTableDetailsOrInput>|readonly [number, RollTableResultOrLookup<RollTableDetailsOrInput>]): RollTableResultFull<RollTableDetailsAndResults> {
if (isResultArray(result)) {
if (isRollTableDetailsInputPair(result)) {
const [, innerResult] = result as [number, RollTableResultOrLookup<RollTableDetailsOrInput>];
return this.addResult(innerResult);
} else if (isRollTableResult(result)) {
} else if ("table" in result && result.table) {
if (!this.tables.has(result.table.id)) {
this.addTableInternal({... result.table, full: 'details'})
}
@ -346,8 +437,7 @@ export class RollTableDatabase implements Iterable<RollTableDetailsAndResults> {
setId: result.set.id,
textId: result.textId,
text: result.text,
mappingId: result.mappingId,
updated: result.updated
mappingId: result.mappingId
})
} else {
const internalTable = this.tablesById.get(result.tableId);
@ -370,8 +460,7 @@ export class RollTableDatabase implements Iterable<RollTableDetailsAndResults> {
text: result.text,
table: internalTable,
author: internalAuthor,
set: internalSet,
updated: result.updated
set: internalSet
};
if (compareRollTableResults(oldText, out) > 0) {
internalTable.resultsByText.set(out.text, out);
@ -496,6 +585,13 @@ export function generatedContentsToString(contents: GeneratedContents): string {
}
export class RolledValues<T extends RollTable = RollTable, U extends RollTableResult<T> = RollTableResult<T>> extends Map<T, U> {
constructor()
constructor(values: Iterable<U>|null)
constructor(keyValues: Iterable<[T, U]>|null)
constructor(values?: Iterable<U>|Iterable<[T, U]>|null) {
super(values && [...values].map(v => Array.isArray(v) ? v : [v.table, v]));
}
[Symbol.iterator](): IterableIterator<[T, U]> {
return this.entries();
}

@ -1,18 +0,0 @@
{
"compilerOptions": {
"target": "es2015",
"module": "ES2015",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"moduleResolution": "NodeNext",
"strict": true,
"skipLibCheck": true,
"lib": ["DOM"],
"jsx": "react-jsx",
"jsxImportSource": "preact",
"paths": {
"react": ["./node_modules/preact/compat/"],
"react-dom": ["./node_modules/preact/compat/"]
}
}
}

@ -5,20 +5,18 @@ import {
type GeneratedState,
type InProgressGeneratedContents,
type InProgressGeneratedState,
RolledValues, rollOn, rollResultToString,
RolledValues, rollOn,
RollSelections,
type RollTable,
type RollTableAuthor,
RollTableDatabase,
type RollTableDetailsNoResults,
type RollTableResultFull,
} from '../../common/rolltable';
import { type PreparedQueries, type QueryOutput, TypedDBWrapper } from './querytypes';
import { DatabaseQueries } from './queries';
} from '../../common/rolltable.js';
import { type PreparedQueries, type QueryOutput, TypedDBWrapper } from './querytypes.js';
import { DatabaseQueries } from './queries.js';
function processOperationResult(result: QueryOutput<(typeof DatabaseQueries)['getResultMappingsForDiscordSet']>[number] | undefined): (RollTableResultFull<RollTableDetailsNoResults> & {
status: 'updated' | 'existing'
}) | undefined {
function processOperationResult(result: QueryOutput<(typeof DatabaseQueries)['getResultMappingsForDiscordSet']>[number] | undefined): (RollTableResultFull<RollTableDetailsNoResults> | undefined) {
if (!result) {
return result;
}
@ -49,18 +47,17 @@ function processOperationResult(result: QueryOutput<(typeof DatabaseQueries)['ge
description: result.setDescription,
global: !!(result.setGlobal)
},
updated: new Date(result.updated),
status: result.status
};
}
// TODO: add some caching for read-response-database style actions
export class Database {
private readonly db: TypedDBWrapper;
private readonly queries: PreparedQueries<typeof DatabaseQueries>;
constructor(db: D1Database) {
this.db = new TypedDBWrapper(db);
this.queries = this.db.prepareAll(DatabaseQueries);
this.queries = this.db.prepareOrRetrieveAll(DatabaseQueries);
}
async autocompleteTable(tableSoFar: string) {
@ -91,7 +88,6 @@ export class Database {
setSnowflake: setId
}),
this.queries.getResultMappingsForDiscordSet({
timestamp,
tableIdentifier: table,
text,
setSnowflake: setId,
@ -102,30 +98,32 @@ export class Database {
if (!result) {
throw Error('failed adding the new response');
}
return {
...result,
status: result.status === 'updated' ? 'added' : 'existed'
};
return result;
}
async editResponseFromDiscord(timestamp: number, table: number | string, oldText: string, newText: string, userId: string, username: string, setId: string): Promise<{
async editResponseFromDiscord(table: number | string, oldText: string, newText: string, userId: string, username: string, setId: string): Promise<{
status: 'nonexistent'
} | {
status: 'noneditable',
old: RollTableResultFull<RollTableDetailsNoResults>
} | {
status: 'conflict' | 'updated',
status: 'edited'|'conflicting',
old: RollTableResultFull<RollTableDetailsNoResults>,
new: RollTableResultFull<RollTableDetailsNoResults>,
}> {
const [oldResults, , , , newResults] = await this.db.batch(
const [oldResultsBefore, newResultsBefore, , , , oldResultsAfter, newResultsAfter] = await this.db.batch(
this.queries.getResultMappingsForDiscordSet({
timestamp,
tableIdentifier: table,
text: oldText,
setSnowflake: setId,
includeGlobal: true
}),
this.queries.getResultMappingsForDiscordSet({
tableIdentifier: table,
text: newText,
setSnowflake: setId,
includeGlobal: true
}),
this.queries.addResultForEditMapping({
tableIdentifier: table,
oldText,
@ -141,7 +139,6 @@ export class Database {
setSnowflake: setId
}),
this.queries.editMappingForDiscord({
timestamp,
tableIdentifier: table,
oldText,
newText,
@ -149,26 +146,31 @@ export class Database {
setSnowflake: setId
}),
this.queries.getResultMappingsForDiscordSet({
timestamp,
tableIdentifier: table,
text: oldText,
setSnowflake: setId,
includeGlobal: false
}),
this.queries.getResultMappingsForDiscordSet({
tableIdentifier: table,
text: newText,
setSnowflake: setId,
includeGlobal: false
})
);
const oldResult = processOperationResult(oldResults[0]);
const oldResult = processOperationResult(oldResultsBefore[0]);
if (!oldResult) {
return { status: 'nonexistent' };
}
if (oldResult.set?.global) {
return { status: 'noneditable', old: oldResult };
}
const newResult = processOperationResult(newResults[0]);
const newResult = processOperationResult(newResultsAfter[0]);
if (!newResult) {
throw Error('failed to update response');
}
return {
status: newResult.status === 'updated' ? 'updated' : 'conflict',
status: 'edited',
old: oldResult,
new: newResult
};
@ -182,7 +184,6 @@ export class Database {
}> {
const [oldResults, deleted] = await this.db.batch(
this.queries.getResultMappingsForDiscordSet({
timestamp: null,
tableIdentifier: table,
text,
setSnowflake: setId,
@ -218,7 +219,6 @@ export class Database {
status: 'existent',
} & RollTableResultFull<RollTableDetailsNoResults>)> {
const results = await this.db.run(this.queries.getResultMappingsForDiscordSet({
timestamp: null,
tableIdentifier: table,
text,
setSnowflake: setId,
@ -294,7 +294,7 @@ export class Database {
tables: tables.map(v => ({ ...v, full: 'details' })),
authors,
sets,
results: mappings.map(v => ({ ...v, updated: new Date(v.updated) }))
results: mappings.map(v => ({ ...v }))
});
if (!oldResults && !reroll) {
return db;
@ -332,6 +332,7 @@ export class Database {
const result = lookupResult ?? {
full: false,
text: text,
textId: null,
table
}
rolled.add(result);

@ -1,7 +1,7 @@
import { type QueryDefinitions, validatedDefinitions } from './querytypes';
import { type QueryDefinitions, validatedDefinitions } from './querytypes.js';
import {
boolean,
discordSnowflake,
discordSnowflake, integer,
jsonArray,
nullable,
string,
@ -10,8 +10,8 @@ import {
tableIdentifierSubstring,
timestamp,
URL
} from './validators';
import { extract, guaranteedSingleton, jsonParser, nothing, rows, singleton, writeCount } from './transformers';
} from './validators.js';
import { extract, guaranteedSingleton, jsonParser, nothing, rows, singleton, writeCount } from './transformers.js';
export const DatabaseQueries = validatedDefinitions({
autocompleteTable: {
@ -116,7 +116,7 @@ export const DatabaseQueries = validatedDefinitions({
output: nothing()
},
addDiscordSetForAddMapping: {
query: `INSERT OR IGNORE INTO resultSets (name, description, discordSnowflake, creatorId, global)
query: `INSERT OR IGNORE INTO resultSets (name, description, discordSnowflake, global)
VALUES (NULL, NULL, ?1, (SELECT authors.id FROM authors WHERE authors.discordSnowflake = ?2), FALSE)`,
parameters: {
'setSnowflake': {
@ -153,12 +153,10 @@ export const DatabaseQueries = validatedDefinitions({
INSERT
OR
IGNORE
INTO resultMappings (resultId, setId, authorId, created, updated)
INTO resultMappings (resultId, setId, authorId)
VALUES ((SELECT rollableResult.id FROM rollableResult),
(SELECT resultSet.id FROM resultSet),
(SELECT author.id FROM author),
?1,
?1);`,
(SELECT author.id FROM author));`,
parameters: {
'timestamp': {
validator: timestamp,
@ -186,12 +184,12 @@ export const DatabaseQueries = validatedDefinitions({
getResultMappingsForDiscordSet: {
query: `WITH rollableTable (id) AS (SELECT rollableTableIdentifiers.tableId
FROM rollableTableIdentifiers
WHERE rollableTableIdentifiers.identifier = ?2
OR rollableTableIdentifiers.tableId = ?2
WHERE rollableTableIdentifiers.identifier = ?1
OR rollableTableIdentifiers.tableId = ?1
LIMIT 1),
visibleSets (id) AS (SELECT resultSets.id
FROM resultSets
WHERE ((?5 AND resultSets.global) OR resultSets.discordSnowflake = ?4))
WHERE ((?4 AND resultSets.global) OR resultSets.discordSnowflake = ?3))
SELECT resultMappings.id AS mappingId,
rollableResults.id AS resultId,
rollableResults.text AS resultText,
@ -209,9 +207,7 @@ export const DatabaseQueries = validatedDefinitions({
rollableTables.title AS tableTitle,
rollableTables.emoji AS tableEmoji,
rollableTables.header AS tableHeader,
rollableTables.ordinal AS tableOrdinal,
resultMappings.updated AS updated,
(CASE WHEN resultMappings.updated = ?1 THEN 'updated' ELSE 'existing' END) AS status
rollableTables.ordinal AS tableOrdinal
FROM resultMappings
INNER JOIN rollableResults ON rollableResults.id = resultMappings.resultId
LEFT JOIN authors ON authors.id = resultMappings.authorId
@ -219,29 +215,25 @@ export const DatabaseQueries = validatedDefinitions({
INNER JOIN resultSets ON resultSets.id = resultMappings.setId
INNER JOIN rollableTables ON rollableTables.id = rollableResults.tableId
WHERE rollableResults.tableId = (SELECT id FROM rollableTable)
AND rollableResults.text = ?3
AND rollableResults.text = ?2
AND resultMappings.setId IN visibleSets
ORDER BY (NOT setGlobal) DESC, (authorId IS NOT NULL) DESC, updated, mappingId;`,
ORDER BY (NOT setGlobal) DESC, (authorId IS NOT NULL) DESC, mappingId;`,
parameters: {
'timestamp': {
validator: nullable(timestamp),
index: 1
},
'tableIdentifier': {
validator: tableIdentifierOrId,
index: 2
index: 1
},
'text': {
validator: string,
index: 3
index: 2
},
'setSnowflake': {
validator: discordSnowflake,
index: 4
index: 3
},
'includeGlobal': {
validator: boolean,
index: 5
index: 4
}
},
output: rows<{
@ -262,9 +254,7 @@ export const DatabaseQueries = validatedDefinitions({
tableTitle: string,
tableEmoji: string,
tableHeader: string,
tableOrdinal: number,
updated: number,
status: 'updated' | 'existing'
tableOrdinal: number
}>()
},
addResultForEditMapping: {
@ -380,56 +370,51 @@ export const DatabaseQueries = validatedDefinitions({
editMappingForDiscord: {
query: `WITH rollableTable (id) AS (SELECT rollableTableIdentifiers.tableId
FROM rollableTableIdentifiers
WHERE rollableTableIdentifiers.identifier = ?2
OR rollableTableIdentifiers.tableId = ?2
WHERE rollableTableIdentifiers.identifier = ?1
OR rollableTableIdentifiers.tableId = ?1
LIMIT 1),
oldResult (id) AS (SELECT rollableResults.id
FROM rollableResults
WHERE rollableResults.tableId = (SELECT rollableTable.id FROM rollableTable)
AND rollableResults.text = ?3
AND rollableResults.text = ?2
LIMIT 1),
newResult(id) AS (SELECT rollableResults.id
FROM rollableResults
WHERE rollableResults.tableId = (SELECT rollableTable.id FROM rollableTable)
AND rollableResults.text = ?4
AND rollableResults.text = ?3
LIMIT 1),
author(id) AS (SELECT authors.id FROM authors WHERE authors.discordSnowflake = ?5 LIMIT 1),
author(id) AS (SELECT authors.id FROM authors WHERE authors.discordSnowflake = ?4 LIMIT 1),
targetSet(id) AS (SELECT resultSets.id
FROM resultSets
WHERE resultSets.discordSnowflake = ?6
WHERE resultSets.discordSnowflake = ?5
AND NOT resultSets.global
LIMIT 1)
UPDATE OR IGNORE resultMappings
SET resultId = (SELECT id FROM newResult),
authorId = (SELECT id FROM author),
updated = ?1
WHERE ?3 != ?4
authorId = (SELECT id FROM author)
WHERE ?2 != ?3
AND resultMappings.resultId = (SELECT id FROM oldResult)
AND resultMappings.setId = (SELECT id FROM targetSet);`,
parameters: {
'timestamp': {
validator: timestamp,
index: 1
},
'tableIdentifier': {
validator: tableIdentifierOrId,
index: 2
index: 1
},
'oldText': {
validator: string,
index: 3
index: 2
},
'newText': {
validator: string,
index: 4
index: 3
},
'userSnowflake': {
validator: discordSnowflake,
index: 5
index: 4
},
'setSnowflake': {
validator: discordSnowflake,
index: 6
index: 5
}
},
output: nothing()
@ -551,14 +536,13 @@ export const DatabaseQueries = validatedDefinitions({
resultSets.global
FROM resultSets
WHERE (resultSets.global OR resultSets.discordSnowflake = ?1)),
visibleResults (mappingId, setId, textId, tableId, text, authorId, updated)
visibleResults (mappingId, setId, textId, tableId, text, authorId)
AS (SELECT resultMappings.id AS mappingId,
resultMappings.setId AS setId,
resultMappings.resultId AS textId,
rollableResults.tableId AS tableId,
rollableResults.text AS text,
resultMappings.authorId AS authorId,
resultMappings.updated AS updated
resultMappings.authorId AS authorId
FROM resultMappings
INNER JOIN visibleSets ON resultMappings.setId = visibleSets.id
INNER JOIN rollableResults ON rollableResults.id = resultMappings.resultId),
@ -587,8 +571,7 @@ export const DatabaseQueries = validatedDefinitions({
'text', visibleResults.text,
'tableId', visibleResults.tableId,
'setId', visibleResults.setId,
'authorId', visibleResults.authorId,
'updated', visibleResults.updated))
'authorId', visibleResults.authorId))
FROM visibleResults) AS mappings;`,
parameters: {
'setSnowflake': {
@ -605,10 +588,107 @@ export const DatabaseQueries = validatedDefinitions({
text: string,
tableId: number,
setId: number,
authorId: number,
updated: number
authorId: number
}[],
}>(['sets', 'authors', 'mappings']))
}
},
addAuditRecordForDiscordAuthor: {
query: `INSERT INTO userAuditLog (userId, newName, newUrl, timestamp)
VALUES ((SELECT id FROM authors WHERE authors.discordSnowflake = ?1),
?2,
?3,
?4);`,
parameters: {
'discordSnowflake': {
validator: discordSnowflake,
index: 1,
},
'newName': {
validator: nullable(string),
index: 2,
},
'newUrl': {
validator: nullable(string),
index: 3,
},
'timestamp': {
validator: timestamp,
index: 4,
},
},
output: nothing,
},
addAuditRecordForDiscordSet: {
query: `INSERT INTO setAuditLog (userId, setId, newName, newDescription, timestamp, updateType)
VALUES ((SELECT id FROM authors WHERE authors.discordSnowflake = ?1),
(SELECT id FROM resultSets WHERE resultSets.discordSnowflake = ?1),
?3,
?4,
?5,
?6);`,
parameters: {
'userSnowflake': {
validator: discordSnowflake,
index: 1,
},
'setSnowflake': {
validator: discordSnowflake,
index: 2,
},
'newName': {
validator: nullable(string),
index: 3,
},
'newDescription': {
validator: nullable(string),
index: 4,
},
'timestamp': {
validator: timestamp,
index: 5,
},
'updateType': {
validator: integer,
index: 6,
},
},
output: nothing,
},
addAuditRecordForDiscordResult: {
query: `INSERT INTO resultAuditLog (mappingId, userId, setId, newResultId, timestamp, updateType)
VALUES ((SELECT id FROM authors WHERE authors.discordSnowflake = ?1),
(SELECT id FROM resultSets WHERE resultSets.discordSnowflake = ?1),
?3,
?4,
?5,
?6);`,
parameters: {
'userSnowflake': {
validator: discordSnowflake,
index: 1,
},
'setSnowflake': {
validator: discordSnowflake,
index: 2,
},
'newName': {
validator: nullable(string),
index: 3,
},
'newDescription': {
validator: nullable(string),
index: 4,
},
'timestamp': {
validator: timestamp,
index: 5,
},
'updateType': {
validator: integer,
index: 6,
},
},
output: nothing,
},
} as const satisfies QueryDefinitions);

@ -83,18 +83,6 @@ export function prepareQuery<T extends QueryDefinition<any>>(database: D1Databas
} as PreparedQuery<T>;
}
export function prepareAllQueries<T extends QueryDefinitions>(database: D1Database, q: T): PreparedQueries<T> {
const result: Partial<PreparedQueries<T>> = {};
for (const key of Object.keys(q) as (keyof T & string)[]) {
try {
result[key] = prepareQuery(database, q[key])
} catch (e) {
throw Error(`when preparing ${key}: ${e}`)
}
}
return result as PreparedQueries<T>;
}
export async function runQuery<T>(db: D1Database, query: BoundQuery<T>): Promise<T> {
const startAt = performance.now()
const [results] = await db.batch([query.statement]);
@ -113,17 +101,28 @@ export async function batchQueries<T extends [...unknown[]]>(db: D1Database, que
export class TypedDBWrapper {
private readonly db: D1Database;
private readonly preparedQueries: Map<QueryDefinition<string>, PreparedQuery<QueryDefinition<string>>> = new Map()
constructor(db: D1Database) {
this.db = db;
}
prepare<T extends QueryDefinition<any>>(query: T): PreparedQuery<T> {
return prepareQuery(this.db, query);
prepareOrRetrieve<T extends QueryDefinition<any>>(query: T): PreparedQuery<T> {
const alreadyPrepared = this.preparedQueries.get(query)
if (alreadyPrepared) {
return alreadyPrepared
}
const newlyPrepared = prepareQuery(this.db, query);
this.preparedQueries.set(query, newlyPrepared)
return newlyPrepared;
}
prepareAll<T extends QueryDefinitions>(queries: T): PreparedQueries<T> {
return prepareAllQueries(this.db, queries);
prepareOrRetrieveAll<T extends QueryDefinitions>(queries: T): PreparedQueries<T> {
const result: Partial<PreparedQueries<T>> = {}
for (const key of Object.keys(queries) as Iterable<keyof T>) {
result[key] = this.prepareOrRetrieve(queries[key])
}
return result as PreparedQueries<T>
}
async run<T>(query: BoundQuery<T>): Promise<T> {
@ -134,5 +133,3 @@ export class TypedDBWrapper {
return batchQueries<T>(this.db, queries);
}
}
// TODO: Use the new run and batch functions to fix the Database class's methods

@ -0,0 +1,219 @@
import { AutoRouter, type IRequestStrict } from 'itty-router';
import { type TypedDBWrapper, validatedDefinition } from './querytypes.js';
import { guaranteedSingleton, jsonParser, rows, singleton } from './transformers.js';
import { boolean, discordSnowflake, discordSnowflakeOrId, tableIdentifierSubstring } from './validators.js';
const DatabasePathBase = "/_internal/database/"
async function checkCache(request: Request & {databaseCache: Cache}): Promise<void> {
if (request.method.toUpperCase() !== "GET") {
return
}
const cache = request.databaseCache
const response = await cache.match(request)
if (response) {
Object.assign(request, {cached: response})
} else {
Object.assign(request, {cached: null})
}
}
async function storeCache(response: Response, request: Request & {databaseCache: Cache}): Promise<void> {
if (request.method.toUpperCase() !== "GET") {
return
}
await request.databaseCache.put(request, response)
}
const router = AutoRouter<IRequestStrict & Request & {
databaseCache: Cache,
typedDB: TypedDBWrapper,
ctx: ExecutionContext,
cached: Response|null,
}, []>({
before: [checkCache],
base: DatabasePathBase,
finally: [storeCache],
})
export function callDatabase(url: URL, {typedDB, ctx, databaseCache}: {typedDB: TypedDBWrapper, ctx: ExecutionContext, databaseCache: Cache}): Promise<Response> {
return router.fetch(Object.assign(new Request(url), {typedDB, ctx, databaseCache}))
}
const TableRefStats = validatedDefinition({
query: `SELECT
(SELECT COUNT(*) FROM rollableTableIdentifiers) AS identifiers,
(SELECT COUNT(*) FROM rollableTableHeaders) AS headers,
(SELECT COUNT(*) FROM rollableTableBadges) AS badges`,
parameters: {},
output: guaranteedSingleton<{identifiers: number, headers: number, badges: number}>(),
} as const)
export const TableStatsHeaderName = "Rollable-Table-Ref-Stats"
function tableStatsToHeader(stats: {identifiers: number, headers: number, badges: number}): string {
return `${stats.identifiers},${stats.headers},${stats.badges}`
}
export const GetAllTables = validatedDefinition({
query: `SELECT rollableTables.id AS id,
rollableTables.identifier AS identifier,
rollableTables.name AS name,
rollableTables.emoji AS emoji,
rollableTables.title AS title,
rollableTables.ordinal AS ordinal
FROM rollableTables;`,
parameters: {},
output: rows<{ id: number, identifier: string, name: string, emoji: string, title: string, ordinal: number }>(),
path: "/tables",
} as const)
router.get(GetAllTables.path, async (req: {
typedDB: TypedDBWrapper,
cached: Response|null,
}): Promise<Response> => {
const {cached, typedDB} = req
const statsQuery = typedDB.prepareOrRetrieve(TableRefStats)({})
if (cached) {
const currentStats = tableStatsToHeader(await typedDB.run(statsQuery))
if (currentStats === cached.headers.get(TableStatsHeaderName)) {
return cached
}
}
const tableQuery = typedDB.prepareOrRetrieve(GetAllTables)({})
const [tableStats, tables] = await typedDB.batch(statsQuery, tableQuery)
return new Response(JSON.stringify(tables), {
headers: [[TableStatsHeaderName, tableStatsToHeader(tableStats)], ["Content-Type", "application/json"]]
})
})
export const AutocompleteTable = validatedDefinition({
query: `WITH matchingIds (id) AS (SELECT DISTINCT rollableTableIdentifiers.tableId AS id
FROM rollableTableIdentifiers
WHERE rollableTableIdentifiers.identifier LIKE substr(?1, 1) ESCAPE '\\'
UNION
SELECT DISTINCT rollableTableIdentifiers.tableId AS id
FROM rollableTableIdentifiers
WHERE rollableTableIdentifiers.identifier LIKE ?1 ESCAPE '\\'
UNION
SELECT DISTINCT rollableTableBadges.id AS id
FROM rollableTableBadges
WHERE rollableTableBadges.badge LIKE ?1 ESCAPE '\\'
UNION
SELECT DISTINCT rollableTableHeaders.tableId AS id
FROM rollableTableHeaders
WHERE rollableTableHeaders.header LIKE ?1 ESCAPE '\\')
SELECT rollableTables.id AS id,
rollableTables.identifier AS identifier,
rollableTables.name AS name,
rollableTables.emoji AS emoji
FROM matchingIds
INNER JOIN rollableTables ON matchingIds.id = rollableTables.id
LIMIT 25;`,
parameters: {
'tableIdentifierSubstring': { validator: tableIdentifierSubstring, index: 1 }
},
output: rows<{ id: number, identifier: string, name: string, emoji: string }>(),
pathPrefix: '/autocomplete/tables/'
})
router.get(`${AutocompleteTable.pathPrefix}:partialTableIdent`, async (req: {
partialTableIdent: string,
typedDB: TypedDBWrapper,
cached: Response|null,
}) => {
const {cached, typedDB, partialTableIdent} = req
const statsQuery = typedDB.prepareOrRetrieve(TableRefStats)({})
if (cached) {
const currentStats = tableStatsToHeader(await typedDB.run(statsQuery))
if (currentStats === cached.headers.get(TableStatsHeaderName)) {
return cached
}
}
const tableQuery = typedDB.prepareOrRetrieve(AutocompleteTable)({tableIdentifierSubstring: partialTableIdent})
const [tableStats, tables] = await typedDB.batch(statsQuery, tableQuery)
return new Response(JSON.stringify(tables), {
headers: [[TableStatsHeaderName, tableStatsToHeader(tableStats)]]
})
})
export const LoadSetDetails = validatedDefinition({
query: `SELECT
json_object(
'id', resultSets.id,
'name', resultSets.name,
'description', resultSets.description,
'discordSnowflake', resultSets.discordSnowflake,
'global', CASE WHEN resultSets.global = FALSE THEN json('false') ELSE json('true') END,
'parentSets', (SELECT json_group_array(parentSets.id)
FROM resultSets AS parentSets
WHERE ?2 = FALSE
AND parentSets.global = TRUE)),
'lastModified', (SELECT max(setAuditLog.timestamp)
FROM setAuditLog
WHERE setAuditLog.setId = resultSets.id)
FROM resultSets
WHERE (discordSnowflake = ?1 OR resultSets.id = ?1)
AND global = ?2`,
parameters: {
discordSnowflake: {
validator: discordSnowflakeOrId,
index: 1,
},
global: {
validator: boolean,
index: 2,
},
},
output: rows(jsonParser<{
id: number,
name: string|null,
description: string|null,
discordSnowflake: string|null,
global: boolean,
parentSets: number[],
lastModified: number,
}>(['id', 'name', 'description', 'discordSnowflake', 'global', 'parentSets', 'lastModified'])),
})
export const LoadSetResults = validatedDefinition({
query: `SELECT
json_object(
'mappingId', resultMappings.id,
'textId', rollableResults.id,
'tableId', rollableResults.tableId,
'text', rollableResults.text,
'setId', resultMappings.setId,
'authorId', resultMappings.authorId,
)
FROM resultMappings
INNER JOIN rollableResults ON rollableResults.id = resultMappings.resultId
WHERE resultMappings.setId = ?1`,
parameters: {},
output: singleton
})
export const AutocompleteText = {
setPathInfix: "/set/",
textPathInfix: "/text/",
} as const
router.get(`${AutocompleteTable.pathPrefix}:partialTableIdent${AutocompleteText.setPathInfix}:setId${AutocompleteText.textPathInfix}:partialText`, async (req: {
partialTableIdent: string,
setId: string,
partialText: string,
typedDB: TypedDBWrapper,
cached: Response|null,
}) => {
const {cached, typedDB, partialTableIdent} = req
const statsQuery = typedDB.prepareOrRetrieve(TableRefStats)({})
if (cached) {
const currentStats = tableStatsToHeader(await typedDB.run(statsQuery))
if (currentStats === cached.headers.get(TableStatsHeaderName)) {
return cached
}
}
const tableQuery = typedDB.prepareOrRetrieve(AutocompleteTable)({tableIdentifierSubstring: partialTableIdent})
const [tableStats, tables] = await typedDB.batch(statsQuery, tableQuery)
return new Response(JSON.stringify(tables), {
headers: [[TableStatsHeaderName, tableStatsToHeader(tableStats)]]
})
})

@ -34,6 +34,14 @@ export function discordSnowflake(data: string|Snowflake|undefined): Snowflake {
return text
}
export function discordSnowflakeOrId(data: string|Snowflake|number|undefined): Snowflake|number {
if (typeof data === 'number') {
return integer(data)
} else {
return discordSnowflake(data)
}
}
export function substring(data: string|undefined): string {
const text = string(data, false)
if (text.length === 0) {

@ -5,32 +5,34 @@ import {
CommandContext,
CommandOptionType,
ComponentContext,
InteractionContextType,
SlashCommand,
type SlashCreator
} from 'slash-create/web';
import { type Database } from '../db/database';
import { type Database } from '../db/database.js';
import { type Snowflake } from 'discord-snowflake';
import {
DELETE_ID,
DONE_ID, FAILURE_COLOR,
generateAuthorForResult, generateEmbedForResult,
DONE_ID,
FAILURE_COLOR,
generateEmbedForResult,
generateErrorMessageFor,
generateFieldForResult,
generateFooterForResult,
generateMessageFor,
getEmbedFrom,
loadEmbed, recordError,
loadEmbed,
recordError,
REROLL_ID,
SELECT_ID, SUCCESS_COLOR, WARNING_COLOR
} from './embed';
SELECT_ID,
SUCCESS_COLOR,
WARNING_COLOR
} from './embed.js';
import {
generatedContentsToString, generatedStateToString,
MAX_IDENTIFIER_LENGTH,
MAX_NAME_LENGTH,
MAX_RESULT_LENGTH,
MAX_URL_LENGTH,
type RollTableAuthor
} from '../../common/rolltable';
} from '../../common/rolltable.js';
import markdownEscape from 'markdown-escape';
const tableOption: Omit<ApplicationCommandOptionLimitedString, 'name' | 'description'> = {
@ -169,7 +171,7 @@ export class ResponseCommand extends SlashCommand {
description: 'Modifies the responses available in the generator.',
nsfw: false,
guildIDs: forGuilds,
dmPermission: true,
contexts: [InteractionContextType.BOT_DM, InteractionContextType.GUILD, InteractionContextType.PRIVATE_CHANNEL],
options: [
{
type: CommandOptionType.SUB_COMMAND,
@ -461,13 +463,13 @@ export class ResponseCommand extends SlashCommand {
ephemeral: true
});
break;
case 'conflict':
case 'conflicting':
await ctx.send({
embeds: [generateEmbedForResult('The old response (still existing)', WARNING_COLOR, result.old), generateEmbedForResult('A conflicting response', FAILURE_COLOR, result.new)],
ephemeral: true
});
break;
case 'updated':
case 'edited':
await ctx.send({
embeds: [generateEmbedForResult('The old response (now gone)', SUCCESS_COLOR, result.old), generateEmbedForResult('Your updated response', SUCCESS_COLOR, result.new)]
});

@ -1,10 +1,10 @@
import type { GeneratedContents, RollTableResult } from '../../common/rolltable';
import type { GeneratedContents, RollTableResult } from '../../common/rolltable.js';
import {
type FinalGeneratedContents,
type GeneratedState,
type InProgressGeneratedContents,
type RollTableResultFull
} from '../../common/rolltable';
} from '../../common/rolltable.js';
import {
ButtonStyle,
type ComponentActionRow,
@ -59,7 +59,6 @@ export function generateEmbedForResult(title: string, color: number, value: Roll
color,
author: generateAuthorForResult(value),
fields: [generateFieldForResult(value)],
timestamp: value.updated,
footer: generateFooterForResult(value),
}
}

@ -1,9 +1,9 @@
import { Database } from '../db/database';
import { Database } from '../db/database.js';
import { CloudflareWorkerServer, SlashCreator } from 'slash-create/web';
import { isSnowflake, type Snowflake } from 'discord-snowflake';
import { AuthorCommand, GenerateCommand, ResponseCommand } from './commands';
import { AuthorCommand, GenerateCommand, ResponseCommand } from './commands.js';
import { type IRequestStrict, Router } from 'itty-router';
import { getQueryArray } from '../request/query';
import { getQueryArray } from '../request/query.js';
function getAuthorization(username: string, password: string): string {
return btoa(username + ':' + password);

@ -1,7 +1,7 @@
import { Database } from './db/database';
import { discordRouter } from './discord/router';
import { createCors, Router, IRequestStrict } from 'itty-router';
import { webRouter } from './web/router';
import { Database } from './db/database.js';
import { discordRouter } from './discord/router.js';
import { IRequestStrict, AutoRouter, cors } from 'itty-router';
import { webRouter } from './web/router.js';
export interface Env {
readonly BASE_URL: string;
@ -13,12 +13,14 @@ export interface Env {
readonly DB: D1Database;
}
const { preflight, corsify } = createCors();
const { preflight, corsify } = cors();
const discord = discordRouter('/discord')
const web = webRouter('/')
const router = Router<IRequestStrict, [env: Env, db: Database, ctx: ExecutionContext]>()
.all('*', preflight)
const router = AutoRouter<IRequestStrict & Request, [env: Env, db: Database, ctx: ExecutionContext]>({
before: [preflight],
finally: [corsify],
})
.all('/discord/*', discord.handle.bind(discord))
.all('/*', web.handle.bind(web))
.all('*', (_req, _env, _db, _ctx) => null);
@ -27,6 +29,7 @@ const router = Router<IRequestStrict, [env: Env, db: Database, ctx: ExecutionCon
export default {
async fetch(req: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const startTime = performance.now()
const cache = await caches.open("forDatabase")
return router.handle(req, env, new Database(env.DB), ctx).then((result) => {
if (result instanceof Response) {
return result;
@ -39,9 +42,9 @@ export default {
} else {
return new Response('Not Found', { status: 404, statusText: 'Not Found' });
}
}).catch((reason) => {
}).catch((reason: unknown) => {
return new Response(`Failed: ${reason}`, { status: 500, statusText: 'Internal Server Error' });
}).then((response) => {
}).then((response: Response) => {
return corsify(response);
}).finally(() => {
const endTime = performance.now()

@ -76,7 +76,7 @@
/* Type Checking */
"strict": true /* Enable all strict type-checking options. */,
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied `any` type.. */
// "strictNullChecks": true, /* When type checking, take into account `null` and `undefined`. */
"strictNullChecks": true, /* When type checking, take into account `null` and `undefined`. */
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
// "strictBindCallApply": true, /* Check that the arguments for `bind`, `call`, and `apply` methods match the original function. */
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */

@ -1,4 +1,4 @@
import type { SourceMap } from '../../../common/bundle';
import type { SourceMap } from '../../../common/bundle.js';
export enum SourceMapExtension {
CSS = 'css',

@ -1,11 +1,17 @@
import { type IRequestStrict, Router } from 'itty-router';
import type { Database } from '../db/database';
import { CSS, JS } from './bundles/client.generated';
import type { HashedBundled } from '../../common/bundle';
import { getSourceMapFileName, SourceMapExtension, SourceMaps } from './bundles/sourcemaps';
import { type IRequestStrict, AutoRouter } from 'itty-router';
import type { Database } from '../db/database.js';
import { CSS, JS } from './bundles/client.generated.js';
import type { HashedBundled } from '../../common/bundle.js';
import { getSourceMapFileName, SourceMapExtension, SourceMaps } from './bundles/sourcemaps.js';
import { collapseWhiteSpace } from 'collapse-white-space';
import { getQuerySingleton, takeLast } from '../request/query';
import { StringTemplateBuilder } from './template';
import { getQuerySingleton, takeLast } from '../request/query.js';
import { render } from 'preact-render-to-string'
import { createElement } from 'preact';
import {
MainGeneratorAndResponses,
type MainGeneratorAndResponsesProps
} from '../../common/client/MainGeneratorAndResponses.js';
import type { RollTableDetailsAndResults } from '../../common/rolltable.js';
interface WebEnv {
readonly BASE_URL: string,
@ -47,33 +53,26 @@ export function webRouter(base: string) {
// TODO: use SSR with the Main components here
// TODO: handle POSTs by rerolling and redisplaying appropriately - redirect to a GET with text IDs listed
// TODO: support json output here
const generator = buildGeneratorPage({
const props: MainGeneratorAndResponsesProps = {
initialResults: results.rolled,
initialSelected: results.selected as Set<RollTableDetailsAndResults>,
database: results.db,
targetUrl: req.url,
addToDiscordUrl: env.DISCORD_APP_ID ? `https://discord.com/api/oauth2/authorize?client_id=${env.DISCORD_APP_ID}&permissions=0&scope=applications.commands` : null,
initialEditable: true,
creditsUrl: env.CREDITS_URL,
clientId: env.DISCORD_APP_ID,
generatorTargetUrl: env.BASE_URL,
results: results.rolled,
editable: !results.final,
selected: results.selected,
includesResponses: true,
builder: StringTemplateBuilder,
})
const responses = buildResponsesPage({
tables: Array.from(results.db.tables.values()),
results: results.rolled,
creditsUrl: env.CREDITS_URL,
includesGenerator: true,
builder: StringTemplateBuilder,
})
}
const wrapped = wrapPage({
title: 'Vore Scenario Generator',
script: getSourceMappedJS('combinedGeneratorResponses'),
styles: getSourceMappedCSS('combinedGeneratorResponses'),
noscriptStyles: getSourceMappedCSS('noscript'),
bodyContent: [generator, responses].join('')
bodyContent: render(createElement(MainGeneratorAndResponses, props))
})
return collapseWhiteSpace(wrapped, { style: 'html' });
}
const router = Router<IRequestStrict, [env: WebEnv, db: Database, ctx: ExecutionContext]>({ base })
const router = AutoRouter<IRequestStrict, [env: WebEnv, db: Database, ctx: ExecutionContext]>({ base })
.get('/responses', async (req, _env, _db, _ctx) => {
// TODO: make this actually just the responses
const url = new URL(req.url);

@ -11,8 +11,8 @@
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
/* Language and Environment */
"target": "ES2015", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
"lib": ["dom", "dom.iterable", "ES2015"]
"target": "ESNext", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
"lib": ["dom", "dom.iterable", "ESNext"]
/* Specify a set of bundled library declaration files that describe the target runtime environment. */,
// "jsx": "react" /* Specify what JSX code is generated. */,
// "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */
@ -26,12 +26,12 @@
/* Modules */
// "module": "es2022" /* Specify what module code is generated. */,
// "rootDir": "./", /* Specify the root folder within your source files. */
"rootDir": "../", /* Specify the root folder within your source files. */
"moduleResolution": "node" /* Specify how TypeScript looks up a file from a given module specifier. */,
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
"typeRoots": ["./types"], /* Specify multiple folders that act like `./node_modules/@types`. */
// "typeRoots": ["./types"], /* Specify multiple folders that act like `./node_modules/@types`. */
// "types": ["@cloudflare/workers-types/2023-07-01"] /* Specify type package names to be included without being referenced in a source file. */,
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
// "resolveJsonModule": true /* Enable importing .json files */,
@ -101,8 +101,9 @@
"jsx": "react-jsx",
"jsxImportSource": "preact",
"paths": {
"react": ["./node_modules/preact/compat/"],
"react-dom": ["./node_modules/preact/compat/"]
}
"react": ["../../node_modules/preact/compat/"],
"react-dom": ["../../node_modules/preact/compat/"]
}
},
"exclude": ["server/**"]
}

@ -8,7 +8,7 @@ keep_vars = true
workers_dev = true
[build]
command = "tsx src/build/bundle-client-with-source-map.ts"
command = "tsx src/build/bundle-client-with-source-map.ts src/client src/server/web/bundles/client.generated.ts"
cwd = "."
watch_dir = ["src/client", "src/common", "src/build"]

Loading…
Cancel
Save