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. 4313
      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. 38
      src/common/client/Button.css
  33. 17
      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. 200
      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. 340
      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. 40
      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. 110
      src/common/client/ResponsesPage.tsx
  57. 26
      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. 27
      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="USE_DOUBLE_QUOTES" value="false" />
<option name="FORCE_QUOTE_STYlE" value="true" /> <option name="FORCE_QUOTE_STYlE" value="true" />
<option name="ENFORCE_TRAILING_COMMA" value="Remove" /> <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_OBJECT_LITERAL_BRACES" value="true" />
<option name="SPACES_WITHIN_IMPORTS" value="true" /> <option name="SPACES_WITHIN_IMPORTS" value="true" />
</JSCodeStyleSettings> </JSCodeStyleSettings>
@ -19,7 +18,7 @@
<option name="USE_DOUBLE_QUOTES" value="false" /> <option name="USE_DOUBLE_QUOTES" value="false" />
<option name="FORCE_QUOTE_STYlE" value="true" /> <option name="FORCE_QUOTE_STYlE" value="true" />
<option name="ENFORCE_TRAILING_COMMA" value="Remove" /> <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="USE_IMPORT_TYPE" value="ALWAYS" />
<option name="SPACES_WITHIN_OBJECT_LITERAL_BRACES" value="true" /> <option name="SPACES_WITHIN_OBJECT_LITERAL_BRACES" value="true" />
<option name="SPACES_WITHIN_IMPORTS" value="true" /> <option name="SPACES_WITHIN_IMPORTS" value="true" />

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

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

4313
package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -9,37 +9,40 @@
"start": "wrangler dev", "start": "wrangler dev",
"generate": "tsx src/build/bundle-client.ts" "generate": "tsx src/build/bundle-client.ts"
}, },
"type": "module",
"devDependencies": { "devDependencies": {
"@babel/core": "^7.23.7", "@babel/core": "^7.23.7",
"@babel/plugin-transform-runtime": "^7.23.7", "@babel/plugin-transform-runtime": "^7.23.7",
"@babel/preset-env": "^7.23.8", "@babel/preset-env": "^7.23.8",
"@babel/preset-react": "^7.24.7",
"@babel/preset-typescript": "^7.23.3", "@babel/preset-typescript": "^7.23.3",
"@babel/runtime": "^7.23.8", "@babel/runtime": "^7.23.8",
"@cloudflare/workers-types": "^4.20231218.0", "@cloudflare/workers-types": "^4.20231218.0",
"@rollup/plugin-babel": "^6.0.4", "@rollup/plugin-commonjs": "^26.0.1",
"@rollup/plugin-commonjs": "^25.0.7",
"@rollup/plugin-node-resolve": "^15.2.3", "@rollup/plugin-node-resolve": "^15.2.3",
"@rollup/plugin-terser": "^0.4.4", "@rollup/plugin-terser": "^0.4.4",
"@types/clean-css": "^4.2.11", "@types/clean-css": "^4.2.11",
"@types/escape-html": "^1.0.4", "@types/escape-html": "^1.0.4",
"@types/less": "^3.0.6",
"@types/markdown-escape": "^1.1.3", "@types/markdown-escape": "^1.1.3",
"@types/slug": "^5.0.7", "@types/slug": "^5.0.7",
"change-case": "^5.4.2", "change-case": "^5.4.2",
"clean-css": "^5.3.3", "clean-css": "^5.3.3",
"fast-deep-equal": "^3.1.3", "fast-deep-equal": "^3.1.3",
"less": "^4.2.0", "rollup": "^4.18.0",
"rollup": "^4.9.5",
"rollup-plugin-ts": "^3.4.5", "rollup-plugin-ts": "^3.4.5",
"tsx": "^4.7.0", "rollup-plugin-typescript2": "^0.36.0",
"typescript": "^5.0.4", "ts-loader": "^9.5.1",
"tsx": "^4.16.0",
"typescript": "^5.5.2",
"wrangler": "^3.0.0" "wrangler": "^3.0.0"
}, },
"dependencies": { "dependencies": {
"collapse-white-space": "^2.1.0", "collapse-white-space": "^2.1.0",
"discord-snowflake": "^2.0.0", "discord-snowflake": "^2.0.0",
"escape-html": "^1.0.3", "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", "markdown-escape": "^2.0.0",
"preact": "^10.19.6", "preact": "^10.19.6",
"preact-iso": "^2.4.0", "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) const bundle = await getBundle(inPath)
await writeBundle(bundle, outPath, true) 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'); console.info('generated client helpers');
}).catch((err) => { }).catch((err) => {
console.error('could not generate client helpers'); console.error('could not generate client helpers');
console.error(err && 'stack' in err ? err.stack : err); console.error(err)
throw err; throw err;
}).catch(() => { }).catch(() => {
process.exit(1); process.exit(1);

@ -9,22 +9,20 @@ import {
type VariableDeclaration, type VariableDeclaration,
type VariableStatement type VariableStatement
} from 'typescript'; } from 'typescript';
import typescriptModule from 'typescript'; import { writeFile, readdir } from 'node:fs/promises';
import { readFile, writeFile, readdir } from 'node:fs/promises'; import { basename, join } from 'node:path';
import { basename, dirname, join, normalize } from 'node:path';
import {createHash} from 'node:crypto'; import {createHash} from 'node:crypto';
import {camelCase} from 'change-case'; import {camelCase} from 'change-case';
import { render as renderLess } from 'less'; import { default as CleanCSS } from 'clean-css';
import CleanCSS from 'clean-css';
import type { import type {
HashedBundled, HashedBundled,
SourceMappedHashedBundled, SourceMappedHashedBundled,
SourceMappedBundled, SourceMappedBundled,
Bundled, Bundled,
MaybeSourceMappedHashedBundled, SourceMap MaybeSourceMappedHashedBundled, SourceMap
} from '../common/bundle'; } from '../common/bundle.js';
import { rollup, type RollupCache } from 'rollup'; 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 terser from '@rollup/plugin-terser';
import nodeResolve from '@rollup/plugin-node-resolve'; import nodeResolve from '@rollup/plugin-node-resolve';
import commonJs from '@rollup/plugin-commonjs'; 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> { async function processLess(atPath: string): Promise<SourceMappedBundled> {
const fileBase = basename(atPath.substring(0, atPath.length - LESS_SUFFIX.length)); const fileBase = basename(atPath.substring(0, atPath.length - CSS_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 { styles, sourceMap } = await new CleanCSS({ const { styles, sourceMap } = await new CleanCSS({
sourceMap: true, sourceMap: true,
sourceMapInlineSources: true, sourceMapInlineSources: true,
@ -94,15 +82,10 @@ async function processLess(atPath: string): Promise<SourceMappedBundled> {
inline: ['all'], inline: ['all'],
rebase: false, rebase: false,
compatibility: '*', compatibility: '*',
fetch(uri): never { fetch(url: string) {
throw Error(`external files are unexpected after less compilation, but found ${uri}`) throw Error(`external files are unexpected, but found ${url}`)
}, },
}).minify({ }).minify([atPath])
[fileBase + '.css']: {
styles: lessCss,
sourceMap: lessMap
}
})
return { bundled: styles, sourceMap: {...JSON.parse(sourceMap!.toString()), file: fileBase + ".css"} as SourceMap }; 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, cache: cache ?? true,
input: atPath, input: atPath,
plugins: [ plugins: [
nodeResolve({ nodeResolve({}),
}), commonJs({}),
commonJs({
}),
typescript({ typescript({
transpiler: "babel", tsconfig: join(inDir, '..', 'tsconfig.json'),
typescript: typescriptModule,
tsconfig: join(inDir, 'tsconfig.json')
}), }),
terser({}) terser({}),
] ]
}) })
const {output: [chunk]} = await build.generate({ 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'; const TS_SUFFIX = '-entrypoint.ts';
function hashBundled<T extends Bundled>(value: T & {readonly hash?: never}): T & HashedBundled { 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()) { if (!ent.isFile()) {
continue; continue;
} }
if (ent.name.endsWith(LESS_SUFFIX)) { if (ent.name.endsWith(CSS_SUFFIX)) {
css.set(camelCase(ent.name.substring(0, ent.name.length - LESS_SUFFIX.length)), hashBundled(await processLess(join(inDir, ent.name)))); 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)) { } else if (ent.name.endsWith(TS_SUFFIX)) {
const {cache: newCache, bundle} = await processTypescript(join(inDir, ent.name), inDir, cache) const {cache: newCache, bundle} = await processTypescript(join(inDir, ent.name), inDir, cache)
cache = newCache cache = newCache
@ -172,9 +151,6 @@ export async function getBundle(inDir: string): Promise<{ css: Map<string, Sourc
return { css, js }; 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, 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, 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> 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.createIdentifier( "MaybeSourceMappedHashedBundled")),
]) ])
), ),
factory.createStringLiteral("../../common/bundle.js")), factory.createStringLiteral("../../../common/bundle.js")),
exportObjectLiteral('CSS', css, includeSourceMap), exportObjectLiteral('CSS', css, includeSourceMap),
exportObjectLiteral('JS', js, includeSourceMap) exportObjectLiteral('JS', js, includeSourceMap)
], factory.createToken(SyntaxKind.EndOfFileToken), NodeFlags.None)), { ], factory.createToken(SyntaxKind.EndOfFileToken), NodeFlags.None)), {

@ -1,8 +1,8 @@
import { DEFAULT_IN_PATH, DEFAULT_OUT_PATH, getBundle, writeBundle } from './bundler'; import { getBundle, writeBundle } from './bundler.js';
import { getSourceMapFileName, SourceMapExtension, SourceMaps } from '../server/web/bundles/sourcemaps'; import {getSourceMapFileName, SourceMapExtension, SourceMaps} from '../server/web/bundles/sourcemaps.js';
import deepEqual from 'fast-deep-equal'; 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 bundle = await getBundle(inPath)
const errors: string[] = [] const errors: string[] = []
for (const [name, {hash, sourceMap}] of bundle.css) { 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) 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'); console.info('generated client helpers and confirmed sourcemaps are present');
}).catch((err) => { }).catch((err) => {
console.error('could not generate client helpers or confirm sourcemaps are present'); console.error('could not generate client helpers or confirm sourcemaps are present');

@ -1,16 +1,19 @@
{ {
"compilerOptions": { "compilerOptions": {
"module": "commonjs", "module": "ESNext",
"esModuleInterop": true, "esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"target": "ESNext", "target": "ESNext",
"strict": true,
"noEmit": true, "noEmit": true,
"noImplicitAny": true, "noImplicitAny": true,
"moduleResolution": "node", "allowSyntheticDefaultImports": true,
"allowJs": true,
"moduleResolution": "Bundler",
"sourceMap": true, "sourceMap": true,
"baseUrl": "./" "baseUrl": "./",
"importsNotUsedAsValues": "remove",
}, },
"include": [ "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 "AttributionAuthor.css";
@import "AttributionSet"; @import "AttributionSet.css";
.attributed { .attributed {
position: relative; position: relative;
@ -41,6 +41,9 @@
.attribution .attributionBubble * { .attribution .attributionBubble * {
user-select: none; user-select: none;
} }
.attribution .attributionBubble > * {
margin: 0.1rem;
}
.attribution .button { .attribution .button {
margin-top: 0.5rem; margin-top: 0.5rem;

@ -1,16 +1,16 @@
import type { import type {
RollTableAuthor, RollTableAuthor,
} from '../rolltable'; } from '../rolltable.js';
import { import {
AttributionAuthor, AttributionAuthor,
reconstituteAttributionAuthorIfExists reconstituteAttributionAuthorIfExists
} from './AttributionAuthor'; } from './AttributionAuthor.js';
import { Fragment } from 'preact'; import { Fragment } from 'preact';
import { import {
AttributionSet, AttributionSet,
type AttributionSetProps, type AttributionSetProps,
reconstituteAttributionSetIfExists reconstituteAttributionSetIfExists
} from './AttributionSet'; } from './AttributionSet.js';
import type { PropsWithChildren } from 'preact/compat'; import type { PropsWithChildren } from 'preact/compat';
export interface AttributionPropsFull { 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 { export function reconstituteAttributionAuthorIfExists(element: HTMLParagraphElement | null, partial?: Partial<RollTableAuthor>|null): RollTableAuthor|null {
if (!element || partial === null) { if (!element || partial === null) {

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

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

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

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

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

@ -3,17 +3,17 @@ import {
type AttributionPropsEmpty, type AttributionPropsFull, type AttributionPropsEmpty, type AttributionPropsFull,
type PartialAttributionProps, type PartialAttributionPropsEmpty, type PartialAttributionProps, type PartialAttributionPropsEmpty,
reconstituteAttribution, reconstituteAttribution,
} from './Attribution'; } from './Attribution.js';
import { import {
reconstituteResultText, reconstituteResultText,
ResultText, ResultText,
type ResultTextPropsFull, type ResultTextPropsFull,
type ResultTextPropsLimited type ResultTextPropsLimited
} from './ResultText'; } from './ResultText.js';
import { LinkButton } from './Button'; import { LinkButton } from './Button.js';
import { responseIdPrefix } from './ResponseElement'; import { responseIdPrefix } from './ResponseElement.js';
import { IncludesResponses } from './ResponsesPage';
import { useContext } from 'preact/hooks'; import { useContext } from 'preact/hooks';
import { IncludesResponses } from './contexts.js';
export type GeneratedResultPropsFull = AttributionPropsFull & ResultTextPropsFull export type GeneratedResultPropsFull = AttributionPropsFull & ResultTextPropsFull
@ -33,15 +33,15 @@ export function reconstituteGeneratedResult(div: HTMLDivElement, partial?: Parti
reconstituteResultText(div.querySelector(".resultText")!, partial) reconstituteResultText(div.querySelector(".resultText")!, partial)
const attribution = const attribution =
reconstituteAttribution(div.querySelector(".attribution")!, partial) reconstituteAttribution(div.querySelector(".attribution")!, partial)
if (result.updated && attribution.set) { if (attribution.set) {
return { return {
...attribution, ...attribution,
...result, ...result,
} } as GeneratedResultPropsFull
} else { } else {
return { return {
...result as ResultTextPropsLimited, ...result as ResultTextPropsLimited,
} } as GeneratedResultPropsLimited
} }
} }
@ -51,7 +51,7 @@ export function GeneratedResult(props: GeneratedResultProps) {
<ResultText {...props} /> <ResultText {...props} />
<Attribution {...props}> <Attribution {...props}>
{includesResponses && props.set {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} : null}
</Attribution> </Attribution>
</div> </div>

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

@ -1,18 +1,16 @@
import { FormButton, LinkButton } from './Button.js';
import { FormButton, LinkButton } from './Button';
import { createContext, Fragment } from 'preact'; import { createContext, Fragment } from 'preact';
import { usePopup } from './usePopup'; import { usePopup } from './usePopup.js';
import { useCallback, useContext } from 'preact/hooks'; import { useCallback, useContext, useEffect, useMemo, useState } from 'preact/hooks';
import { ExportFormat, exportFormatToString } from '../rolltable'; import { ExportFormat, exportFormatToString } from '../rolltable.js';
import { import {
GeneratedElement, GeneratedElement,
type GeneratedElementProps, type GeneratedElementProps,
reconstituteGeneratedElement, reconstituteGeneratedElement,
} from './GeneratedElement'; } from './GeneratedElement.js';
import { IncludesResponses } from './ResponsesPage'; import { tableIdentifier } from './TableHeader.js';
import { tableIdentifier } from './TableHeader'; import { type FormInfo, type FormReturnType, useSubmitCallback } from './useSubmitCallback.js';
import { IncludesResponses } from './contexts.js';
export const IncludesGenerator = createContext(false)
export interface GeneratorProps { export interface GeneratorProps {
generatorTargetUrl: string generatorTargetUrl: string
@ -26,11 +24,6 @@ export enum GeneratorSelect {
None = "none", None = "none",
} }
export enum GeneratorReroll {
All = "all",
Selected = "selected",
}
export function reconstituteGenerator(element: HTMLDivElement, partial?: Partial<GeneratorProps>): GeneratorProps { export function reconstituteGenerator(element: HTMLDivElement, partial?: Partial<GeneratorProps>): GeneratorProps {
const addToDiscordUrl = partial?.addToDiscordUrl ?? element.querySelector<HTMLAnchorElement>("#addToDiscord")?.href ?? null const addToDiscordUrl = partial?.addToDiscordUrl ?? element.querySelector<HTMLAnchorElement>("#addToDiscord")?.href ?? null
const editable = partial?.editable ?? !!element.querySelector("#rollButtons") const editable = partial?.editable ?? !!element.querySelector("#rollButtons")
@ -43,129 +36,174 @@ export function reconstituteGenerator(element: HTMLDivElement, partial?: Partial
addToDiscordUrl, addToDiscordUrl,
editable, editable,
generatorTargetUrl, 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 { export interface GeneratorEvents {
onCopy?: (format: ExportFormat) => Promise<void> onCopy?: (format: ExportFormat) => Promise<void>
onReroll?: (which: GeneratorReroll) => Promise<void>
onSelect?: (which: GeneratorSelect) => void onSelect?: (which: GeneratorSelect) => void
onSelectionChange?: (tableId: number, selected: boolean) => void onSelectionChange?: (tableId: number, selected: boolean) => void
onSubmit?: (action: GeneratorFormInfo) => GeneratorSubmitResult
} }
enum GeneratorSelectionState { enum GeneratorSelectionState {
All = "All", All = "All",
Partial = "Some", 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 includesResponses = useContext(IncludesResponses);
const [copyPopupHost, showCopyPopup] = usePopup<HTMLDivElement>() const [popupHost, showPopup] = usePopup<HTMLDivElement>()
const [rerollPopupHost, showRerollPopup] = usePopup<HTMLDivElement>() const [firstRender, setFirstRender] = useState(true)
const copyWrapper = useCallback((format: ExportFormat) => { const copyWrapper = useCallback((format: ExportFormat) => {
if (!onCopy) { if (!onCopy) {
console.error("No copy handler") 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(() => { onCopy(format).then(() => {
return showCopyPopup(`Copied ${exportFormatToString(format)} to clipboard`) return showPopup(`Copied ${exportFormatToString(format)} to clipboard`)
}).catch((ex: unknown) => { }).catch((ex: unknown) => {
console.error(ex) 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) => { }).catch((ex: unknown) => {
console.error(ex) console.error(ex)
}) })
}, [showCopyPopup, onCopy]) }, [showPopup, onCopy])
const md = useCallback(() => copyWrapper(ExportFormat.Markdown), [copyWrapper]) const md = useCallback(() => copyWrapper(ExportFormat.Markdown), [copyWrapper])
const bb = useCallback(() => copyWrapper(ExportFormat.BBCode), [copyWrapper]) const bb = useCallback(() => copyWrapper(ExportFormat.BBCode), [copyWrapper])
const emoji = useCallback(() => copyWrapper(ExportFormat.TextEmoji), [copyWrapper]) const emoji = useCallback(() => copyWrapper(ExportFormat.TextEmoji), [copyWrapper])
const text = useCallback(() => copyWrapper(ExportFormat.TextOnly), [copyWrapper]) const text = useCallback(() => copyWrapper(ExportFormat.TextOnly), [copyWrapper])
const selected = elements.reduce<null|GeneratorSelectionState>((current, next) => { const selected = useMemo(() => {
if (next.selected === null) { if (firstRender) {
return current return null;
}
switch (current) {
case GeneratorSelectionState.Partial:
return GeneratorSelectionState.Partial
case GeneratorSelectionState.None:
return next.selected ? GeneratorSelectionState.Partial : GeneratorSelectionState.None
case GeneratorSelectionState.All:
return next.selected ? GeneratorSelectionState.All : GeneratorSelectionState.Partial
case null:
return next.selected ? GeneratorSelectionState.All : GeneratorSelectionState.None
} }
}, null) return elements.reduce<GeneratorSelectionState>((current, next) => {
if (next.selected === null) {
return current
}
switch (current) {
case GeneratorSelectionState.Partial:
return GeneratorSelectionState.Partial
case GeneratorSelectionState.None:
return next.selected ? GeneratorSelectionState.Partial : GeneratorSelectionState.None
case GeneratorSelectionState.All:
return next.selected ? GeneratorSelectionState.All : GeneratorSelectionState.Partial
case GeneratorSelectionState.Unselectable:
return next.selected ? GeneratorSelectionState.All : GeneratorSelectionState.None
}
}, GeneratorSelectionState.Unselectable);
}, [elements])
const selectAll = useCallback((ev: Event) => { const selectAll = useCallback((ev: Event) => {
if (!onSelect || (ev.currentTarget instanceof HTMLButtonElement && ev.currentTarget.disabled)) { if (!onSelect || (ev.currentTarget instanceof HTMLButtonElement && ev.currentTarget.disabled)) {
return return
} }
onSelect(GeneratorSelect.All) onSelect(GeneratorSelect.All)
}, [onSelect, showRerollPopup]) }, [onSelect])
const selectNone = useCallback((ev: Event) => { const selectNone = useCallback((ev: Event) => {
if (!onSelect || (ev.currentTarget instanceof HTMLButtonElement && ev.currentTarget.disabled)) { if (!onSelect || (ev.currentTarget instanceof HTMLButtonElement && ev.currentTarget.disabled)) {
return return
} }
onSelect(GeneratorSelect.None) onSelect(GeneratorSelect.None)
}, [onSelect, showRerollPopup]) }, [onSelect])
const rerollSelected = useCallback((ev: Event) => {
if (!onReroll || (ev.currentTarget instanceof HTMLButtonElement && ev.currentTarget.disabled)) { const submitHandler = useCallback((d: FormInfo) => {
return if (!onSubmit) {
} return {allowSubmit: true}
onReroll(GeneratorReroll.Selected).then(() => {}).catch((ex: unknown) => { }
console.error(ex) const action = d.data.get("action")
return showRerollPopup(`Failed to reroll`, 'error') switch (action) {
}).catch((ex: unknown) => { case GeneratorFormAction.Reroll:
console.error(ex) case GeneratorFormAction.RerollAll:
}) case GeneratorFormAction.GoToOffline:
}, [onReroll, showRerollPopup]) case GeneratorFormAction.GoToResponses:
const rerollAll = useCallback((ev: Event) => { case GeneratorFormAction.OpenInGenerator:
if (!onReroll || (ev.currentTarget instanceof HTMLButtonElement && ev.currentTarget.disabled)) { case GeneratorFormAction.SaveScenario:
return const {allowSubmit, status} = onSubmit({
} ...d,
onReroll(GeneratorReroll.All).then(() => {}).catch((ex: unknown) => { action
console.error(ex) })
return showRerollPopup(`Failed to reroll all`, 'error') if (status) {
}).catch((ex: unknown) => { status.then((text) => {
console.error(ex) if (text) {
}) return showPopup(text, "success")
}, [onReroll, showRerollPopup]) }
}).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')
}
})
}
return {allowSubmit}
default:
return {allowSubmit: true}
}
}, [onSubmit, showPopup])
useEffect(() => {
setFirstRender(false)
}, [])
const submitCallback = useSubmitCallback(submitHandler)
return <div id="generator" class="page"> 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> <h2 id="generatorHead">Your generated scenario</h2>
<ul id="generatedScenario"> <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> </ul>
<div id="generatorControls"> <div ref={popupHost} id="generatorControls">
<div ref={copyPopupHost} id="copyButtons" className="buttons requiresJs jsPopupHost"> <div id="copyButtons" className="buttons requiresJs jsPopupHost">
<FormButton id="copyMD" type="button" onClick={onCopy && md} disabled={!onCopy}>Markdown</FormButton> <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="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="copyEmojiText" type="button" onClick={onCopy && emoji} disabled={!onCopy}>Text + Emoji</FormButton>
<FormButton id="copyText" type="button" onClick={onCopy && text} disabled={!onCopy}>Text Only</FormButton> <FormButton id="copyText" type="button" onClick={onCopy && text} disabled={!onCopy}>Text Only</FormButton>
</div> </div>
{editable ? <div ref={rerollPopupHost} id="rollButtons" class="buttons jsPopupHost"> {editable ? <div id="rollButtons" class="buttons jsPopupHost">
<FormButton type="submit" id="reroll" name="submit" <FormButton type="submit" id="reroll" name="action"
value="reroll" disabled={onReroll && (!selected || selected === GeneratorSelectionState.None)} onClick={onReroll && rerollSelected}> value={GeneratorFormAction.Reroll} disabled={selected === GeneratorSelectionState.None || selected === GeneratorSelectionState.Unselectable}>
Reroll {selected === GeneratorSelectionState.All ? 'All' : 'Selected'} Reroll Selected
</FormButton> </FormButton>
<FormButton type="button" id="selectAll" class="requiresJs" onClick={selectAll} disabled={!onSelect || selected === GeneratorSelectionState.All}>Select All</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 || selected === GeneratorSelectionState.None}>Select None</FormButton> <FormButton type="button" id="selectNone" class="requiresJs" onClick={selectNone} disabled={!onSelect || selected === GeneratorSelectionState.None || selected === GeneratorSelectionState.Unselectable}>Select None</FormButton>
</div> : null} </div> : null}
<div id="scenarioButtons" class="buttons"> <div id="scenarioButtons" class="buttons">
{editable ? <Fragment> {editable ? <Fragment>
<LinkButton id="rerollAll" href={generatorTargetUrl} external={false} onClick={onReroll && rerollAll}>New Scenario</LinkButton> <FormButton key={"newScenario"} id="rerollAll" name="action" value={GeneratorFormAction.RerollAll} type="submit">New Scenario</FormButton>
<FormButton id="saveScenario" name="submit" value="saveScenario" type="submit">Get Scenario Link</FormButton> <FormButton key={"saveScenario"} id="saveScenario" name="action" value={GeneratorFormAction.SaveScenario} type="submit">Get Scenario Link</FormButton>
</Fragment> : <Fragment> </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>} </Fragment>}
</div> </div>
{ addToDiscordUrl || includesResponses ? <div id="generatorLinks" class="buttons">
<div id="generatorLinks" class="buttons"> {addToDiscordUrl && <LinkButton key={"addToDiscord"} external={true} id="addToDiscord" href={addToDiscordUrl}>Add to Discord</LinkButton>}
{addToDiscordUrl && <LinkButton 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 ? <LinkButton external={false} id="goToResponses" href="#responses">View Possible Responses</LinkButton> : null} {!includesResponses ? <FormButton key={"viewResponses"} id="goToResponses" name="action" value={GeneratorFormAction.GoToResponses}>Switch to Responses</FormButton> : null}
</div> : null} {!includesResponses ? <FormButton key={"viewOffline"} id="goToOfflineVer" name="action" value={GeneratorFormAction.GoToOffline}>Switch to Offline Version</FormButton> : null}
</div>
</div> </div>
</form> </form>
</div> </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,64 +1,128 @@
import { GeneratorPage, GeneratorSelect, IncludesGenerator } from './GeneratorPage'; import {
import { PageFooter } from './PageFooter'; GeneratorFormAction,
import type { GeneratedElementProps } from './GeneratedElement'; type GeneratorFormInfo,
import { useCallback, useMemo, useState } from 'preact/hooks'; GeneratorPage,
import { ExportFormat, exportScenario, RollSelections, type RollTable, type RollTableResult } from '../rolltable'; 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 { export function elementsToValuesAndSelections(elements: GeneratedElementProps[]):
editable: boolean {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 generatorTargetUrl: string
addToDiscordUrl: string addToDiscordUrl: string|null
creditsUrl: string creditsUrl: string
initialEditable: boolean
initialResults: ReadonlyMap<RollTable, RollTableResult> initialResults: ReadonlyMap<RollTable, RollTableResult>
initialSelected?: ReadonlySet<RollTable> initialSelected: ReadonlySet<RollTable>|null
} }
export interface GeneratorMainEvents { export function reconstituteMainGeneratorOnly(
copyText?: (text: string) => Promise<void> 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 return {
// TODO: add the other two top-level pages (MainResponsesOnly, MainGeneratorResponses) with "reconstitute" functions generatorTargetUrl,
// TODO: add the entry points that reconstitute and hydrate each of the respective top-level pages addToDiscordUrl,
creditsUrl,
function MainGeneratorOnly({ initialEditable: editable,
editable, generatorTargetUrl, addToDiscordUrl, initialResults: values,
creditsUrl, initialResults, initialSelected, copyText}: GeneratorMainProps & GeneratorMainEvents) { initialSelected: selections,
const [results, ] = }
}
export function MainGeneratorOnly({
initialEditable, generatorTargetUrl, addToDiscordUrl,
creditsUrl, initialResults, initialSelected,
}: MainGeneratorProps) {
const [results, setResults] =
useState<ReadonlyMap<RollTable, RollTableResult>>(initialResults) useState<ReadonlyMap<RollTable, RollTableResult>>(initialResults)
const [selected, setSelected] = const [selected, setSelected] =
useState<ReadonlySet<RollTable>|null>(initialSelected ?? null) useState<ReadonlySet<RollTable>|null>(initialSelected)
const onCopy = useCallback(async (format: ExportFormat) => { const [editable, setEditable] = useState(initialEditable)
if (!copyText) { const [currentUrl, setCurrentUrl] = useState<URL|null>(null)
return Promise.reject(Error("Copy functionality is not implemented")) const abortController = useRef<AbortController|null>(null)
}
return copyText(exportScenario(Array.from(results.values()), format))
}, [copyText, results])
const onSelectionChange = useCallback((tableId: number, state: boolean) => {
const table = Array.from(initialResults.keys()).find(table => table.full && table.id === tableId)
if (!table) {
return
}
const newSelection = new RollSelections(selected)
if (state) {
newSelection.add(table)
} else {
newSelection.delete(table)
}
setSelected(newSelection)
}, [initialResults, selected, setSelected])
const onSelect = useCallback((select: GeneratorSelect) => {
switch (select) {
case GeneratorSelect.All:
setSelected(new RollSelections(initialResults.keys()));
break;
case GeneratorSelect.None:
setSelected(new RollSelections());
break;
}
}, [initialResults, setSelected])
const elements = useMemo(() => { const elements = useMemo(() => {
const output: GeneratedElementProps[] = [] const output: GeneratedElementProps[] = []
for (const result of results.values()) { for (const result of results.values()) {
@ -76,18 +140,168 @@ function MainGeneratorOnly({
} }
return output return output
}, [results, selected]) }, [results, selected])
return <IncludesGenerator.Provider value={true}> const onHistoryState = useCallback((state: unknown, url: URL) => {
<GeneratorPage // TODO: validate that this is in fact one of the states that this version of the page created,
generatorTargetUrl={generatorTargetUrl} // or at least is parseable by it
elements={elements} const {values, selections} = elementsToValuesAndSelections(state as GeneratedElementProps[])
addToDiscordUrl={addToDiscordUrl} setResults(values)
editable={editable} setSelected(selections)
onCopy={onCopy} setCurrentUrl(url)
// TODO: implement onReroll using JSON fetch }, [selected, setResults, setSelected, setCurrentUrl])
// specifically: POST to the target URL as if you're submitting the form, useHistoryState({
// _but_ add Accept: text/json to indicate you want it for API purposes and not as a page state: elements,
onSelect={selected ? onSelect : undefined} key: results,
onSelectionChange={selected ? onSelectionChange : undefined} /> url: currentUrl,
<PageFooter creditsUrl={creditsUrl} /> onState: onHistoryState,
</IncludesGenerator.Provider> })
const onCopy = useCallback(async (format: ExportFormat) => {
return copyText(exportScenario(Array.from(results.values()), format))
}, [results])
const onSelectionChange = useCallback((tableId: number, state: boolean) => {
const table = Array.from(initialResults.keys()).find(table => table.full && table.id === tableId)
if (!table) {
return
}
const newSelection = new RollSelections(selected)
if (state) {
newSelection.add(table)
} else {
newSelection.delete(table)
}
setSelected(newSelection)
}, [initialResults, selected, setSelected])
const onSelect = useCallback((select: GeneratorSelect) => {
switch (select) {
case GeneratorSelect.All:
setSelected(new RollSelections(initialResults.keys()));
break;
case GeneratorSelect.None:
setSelected(new RollSelections());
break;
}
}, [initialResults, setSelected])
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
}
}
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}
onSelect={selected ? onSelect : 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 { Fragment } from 'preact';
import { IncludesResponses } from './ResponsesPage';
import { IncludesGenerator } from './GeneratorPage';
import { useContext } from 'preact/hooks'; import { useContext } from 'preact/hooks';
import { IncludesGenerator, IncludesResponses } from './contexts.js';
export interface PageFooterProps { export interface PageFooterProps {
creditsUrl: string 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 { import {
Attribution, Attribution,
type AttributionPropsFull, type AttributionPropsFull,
type PartialAttributionPropsFull, reconstituteAttribution type PartialAttributionPropsFull, reconstituteAttribution
} from './Attribution'; } from './Attribution.js';
import { FormButton } from './Button'; import { FormButton } from './Button.js';
import { IncludesGenerator } from './GeneratorPage'; import { useContext, useEffect, useState } from 'preact/hooks';
import { useCallback, useContext, useEffect, useState } from 'preact/hooks'; import { createRef } from 'preact';
import { type Context, createContext, createRef } from 'preact'; import { pulseElement } from './pulseElement.js';
import { pulseElement } from './pulseElement'; import { IncludesGenerator, EditableResponses } from './contexts.js';
import { ResponsesSubmitType } from './responsesForm.js';
export const CurrentSelectedResponse: Context<number|null> = createContext<number|null>(null)
export interface ResponseElementProps { export interface ResponseElementProps {
attribution: AttributionPropsFull attribution: AttributionPropsFull
@ -18,10 +17,6 @@ export interface ResponseElementProps {
selected: boolean selected: boolean
} }
export interface ResponseElementEvents {
onSelected?: (mappingId: number) => void
}
export interface PartialResponseElementProps { export interface PartialResponseElementProps {
attribution?: PartialAttributionPropsFull attribution?: PartialAttributionPropsFull
result?: Partial<ResultTextPropsFull> result?: Partial<ResultTextPropsFull>
@ -40,14 +35,9 @@ export function reconstituteResponseElement(element: HTMLLIElement, partial?: Pa
export const responseIdPrefix="response-" export const responseIdPrefix="response-"
export function ResponseElement({attribution, result, selected, onSelected}: ResponseElementProps & ResponseElementEvents) { export function ResponseElement({attribution, result, selected}: ResponseElementProps) {
const includesGenerator = useContext(IncludesGenerator); const editable = useContext(EditableResponses)
const [lastSelected, setLastSelected] = useState(selected) const [lastSelected, setLastSelected] = useState(selected)
const onSelect = useCallback(() => {
if (onSelected) {
onSelected(result.mappingId)
}
}, [attribution, result, onSelected])
const ref = createRef<HTMLLIElement>() const ref = createRef<HTMLLIElement>()
useEffect(() => { useEffect(() => {
if (lastSelected !== selected) { if (lastSelected !== selected) {
@ -57,12 +47,14 @@ export function ResponseElement({attribution, result, selected, onSelected}: Res
} }
} }
}, [selected, lastSelected, setLastSelected, ref]); }, [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} /> <ResultText {...result} />
<Attribution {...attribution}> <Attribution {...attribution}>
{includesGenerator {!selected && editable
? <FormButton type={"button"} class="makeResponseActive requiresJs" onClick={onSelect}>Set in Generated Scenario</FormButton> ? <p class="buttons"><FormButton type={"submit"} class="makeResponseActive"
: null} name={ResponsesSubmitType.ChangeSelected} value={`${result.textId}`}>Set in Generated Scenario</FormButton></p>
: null}
</Attribution> </Attribution>
</li> </li>
} }

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

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

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

@ -1,42 +1,106 @@
import { createContext } from 'preact'; import { reconstituteResponseType, responseListIdPrefix, ResponseType, type ResponseTypeProps } from './ResponseType.js';
import { responseListIdPrefix, ResponseType, type ResponseTypeProps } from './ResponseType'; import { FormButton, LinkButton } from './Button.js';
import { IncludesGenerator } from './GeneratorPage'; import { TableEmoji, tableIdentifier, TableName } from './TableHeader.js';
import { LinkButton } from './Button';
import { TableEmoji, tableIdentifier, TableName } from './TableHeader';
import { useContext } from 'preact/hooks'; import { useContext } from 'preact/hooks';
import { type FormInfo, useSubmitCallback } from './useSubmitCallback.js';
export const IncludesResponses = createContext(false) 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 { export interface ResponsesProps {
targetUrl: string
target: ResponsesTarget
oldState?: string
types: ResponseTypeProps[] types: ResponseTypeProps[]
} }
export interface ResponsesEvents { export enum ResponsesTarget {
onSelectResponse: (tableId: number, mappingId: number) => void Scenario = "Scenario",
Generator = "Generator",
API = "API",
}
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))
}
} }
// TODO: add a "reconstitute" function for ResponsesPage 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); const includesGenerator = useContext(IncludesGenerator);
return <div id="responses" class="page"> const [headerPopupRef, showHeaderPopup] = usePopup()
<header id="responsesHeader" class="window">
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> <h1 id="responsesHead">Possible Responses</h1>
<nav id="responsesHeaderNav" class="buttons"> <nav id="responsesHeaderNav" class="buttons">
{types.map(type => {types.map(type =>
<LinkButton key={tableIdentifier(type.table)} href={`#${responseListIdPrefix}${tableIdentifier(type.table)}`} external={false}> <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>)} </LinkButton>)}
{includesGenerator {includesGenerator
? <LinkButton href={"#generator"} external={false} id="returnToGenerator">Return to Generator</LinkButton> ? <LinkButton href="#generator" external={false} id="returnToGenerator">Return to {target === ResponsesTarget.API ? "Generator" : "Scenario"}</LinkButton>
: null} : 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> </nav>
</header> </header>
<ul id="responseLists"> <EditableResponses.Provider value={target === ResponsesTarget.API}>
{types.map(type => <ul id="responseLists">
<ResponseType key={tableIdentifier(type.table)} {types.map(type =>
onSelectResponse={onSelectResponse} <ResponseType key={tableIdentifier(type.table)}
{...type} />)} {...type} />)}
</ul> </ul>
</div> </EditableResponses.Provider>
</form>
} }

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

@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from 'preact/hooks'; import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
import { pulseElement } from './pulseElement'; import { pulseElement } from './pulseElement.js';
import { createRef } from 'preact';
export interface ResultTextPropsBase { export interface ResultTextPropsBase {
text: string text: string
@ -8,33 +9,33 @@ export interface ResultTextPropsBase {
export interface ResultTextPropsFull extends ResultTextPropsBase { export interface ResultTextPropsFull extends ResultTextPropsBase {
mappingId: number mappingId: number
textId: number textId: number
updated: Date
} }
export interface ResultTextPropsLimited extends ResultTextPropsBase { export interface ResultTextPropsLimited extends ResultTextPropsBase {
mappingId?: null mappingId?: null
textId?: null textId: number|null
updated?: null
} }
export type ResultTextProps = ResultTextPropsFull|ResultTextPropsLimited export type ResultTextProps = ResultTextPropsFull|ResultTextPropsLimited
export function reconstituteResultText(button: HTMLButtonElement, partial: Partial<ResultTextProps> = {}): ResultTextProps { export function reconstituteResultText(button: HTMLButtonElement, partial: Partial<ResultTextProps> = {}): ResultTextProps {
const text = button.innerText const text = button.innerText
if (typeof partial.mappingId ?? button.dataset["mappingId"] === "undefined") { const textId = typeof (partial.textId ?? button.dataset["textId"]) === "undefined"
return {text} ? null
: (partial.textId ?? parseInt(button.dataset["textId"]!))
if (textId === null || typeof (partial.mappingId ?? button.dataset["mappingId"]) === "undefined") {
return {text, textId}
} else { } else {
return { return {
text, text,
mappingId: partial.mappingId ?? parseInt(button.dataset["mappingId"]!), mappingId: partial.mappingId ?? parseInt(button.dataset["mappingId"]!),
textId: partial.textId ?? parseInt(button.dataset["textId"]!), textId: textId,
updated: partial.updated ?? new Date(parseInt(button.dataset["updated"]!))
} }
} }
} }
export function ResultText({text, mappingId, textId, updated}: ResultTextProps) { export function ResultText({text, mappingId, textId}: ResultTextProps) {
const ref = useRef<HTMLButtonElement>(null) const ref = createRef<HTMLButtonElement>()
const [lastText, setLastText] = useState<string>(text) const [lastText, setLastText] = useState<string>(text)
useEffect(() => { useEffect(() => {
if (text !== lastText) { if (text !== lastText) {
@ -44,8 +45,16 @@ export function ResultText({text, mappingId, textId, updated}: ResultTextProps)
} }
} }
}, [ref, text, lastText, setLastText]); }, [ref, text, lastText, setLastText]);
return <button className="resultText" ref={ref} const onClick = useCallback(() => {
{...(updated if (ref.current) {
? { "data-mapping-id": mappingId, "data-text-id": textId, "data-updated": updated.getTime() } 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> : {})}>{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) { export function pulseElement(element: HTMLElement) {
if (!element.offsetParent) {
return
}
element.removeEventListener("animationend", onPulseEnd) element.removeEventListener("animationend", onPulseEnd)
element.removeEventListener("animationcancel", onPulseEnd) element.removeEventListener("animationcancel", onPulseEnd)
if (element.classList.contains("pulse")) { 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 markdownEscape from 'markdown-escape';
import { bbcodeEscape } from './bbcode'; import { bbcodeEscape } from './bbcode.js';
export interface RollTableLimited { export interface RollTableBase {
readonly full: false, readonly full: false|'details'|'results'|'input'
readonly emoji: string, readonly emoji: string,
readonly title: string, readonly title: string,
readonly header: string, readonly header: string,
readonly ordinal: number, 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 id: number,
readonly identifier: string, readonly identifier: string,
readonly emoji: string,
readonly name: 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 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) { export function rollTableToString(v: RollTable) {
if (v.full) { 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_RESULT_LENGTH = 150;
export const MAX_IDENTIFIER_LENGTH = 20; export const MAX_IDENTIFIER_LENGTH = 20;
export const MAX_NAME_LENGTH = 50; export const MAX_NAME_LENGTH = 50;
@ -50,6 +128,18 @@ export interface RollTableAuthor {
readonly relation: string; 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 { export interface RollTableResultSet {
readonly id: number; readonly id: number;
readonly name: string | null; readonly name: string | null;
@ -57,9 +147,23 @@ export interface RollTableResultSet {
readonly global: boolean; 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> { export interface RollTableResultLimited<T extends RollTableOrInput = RollTable> {
readonly full: false, readonly full: false,
readonly text: string, readonly text: string,
readonly textId: number|null
readonly tableId?: never
readonly table: T, readonly table: T,
} }
@ -72,27 +176,38 @@ export interface RollTableResultFull<T extends RollTableOrInput = RollTableDetai
readonly text: string, readonly text: string,
readonly set: RollTableResultSet, readonly set: RollTableResultSet,
readonly author: RollTableAuthor | null, readonly author: RollTableAuthor | null,
readonly updated: Date,
} }
export type RollTableResult<T extends RollTableOrInput = RollTable> = RollTableResultLimited<T> | RollTableResultFull<T> 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 { export function isRollTableResult<TableT extends RollTableTruncated>(result: unknown, options: {expectTable: TableT, tableCheckOptions?: {skipResultsCheck?: boolean}}): result is RollTableResult<TableT>
return `${v.relation} ${v.name} (${v.id})` 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 { export interface RollTableResultLookup {
readonly textId: number, readonly textId: number,
readonly mappingId: number, readonly mappingId: number,
@ -101,39 +216,19 @@ export interface RollTableResultLookup {
readonly text: string, readonly text: string,
readonly setId: number, readonly setId: number,
readonly authorId: number | null, readonly authorId: number | null,
readonly updated: Date,
} }
export interface RollTableDetailsInputResults extends RollTableDetailsBase { export type RollTableResultOrLookup<T extends RollTableOrInput = RollTable> = RollTableResultFull<T>|RollTableResultLookup
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>>
}
interface RollTableDetailsAndResultsInternal extends RollTableDetailsBase { export function rollResultToString(v: RollTableResult) {
readonly full: 'results' if (v.full) {
readonly resultsById: Map<number, RollTableResultFull<this>> return `${v.text} (${v.mappingId}: ${v.textId}/${rollTableToStringShort(v.table)}/${setToString(v.set)}/${v.author ? authorToString(v.author) : 'no author'})`
readonly resultsByText: Map<string, RollTableResultFull<this>> } else {
return `${v.text} (???: ${rollTableToStringShort(v.table)})`
}
} }
export type RollTableDetails = RollTableDetailsNoResults|RollTableDetailsAndResults export function compareRollTables(a: RollTableOrInput, b: RollTableOrInput): number {
function compareRollTables(a: RollTableOrInput, b: RollTableOrInput): number {
return (a.ordinal - b.ordinal) || return (a.ordinal - b.ordinal) ||
("id" in a !== "id" in b ? "id" in a ? -1 : 1 : 0) || ("id" in a !== "id" in b ? "id" in a ? -1 : 1 : 0) ||
("id" in a && "id" in b ? a.id - b.id : 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 (a && a.full) {
if (b && b.full) { if (b && b.full) {
if (a.set.global === b.set.global) { if (a.set.global === b.set.global) {
return a.updated.getDate() < b.updated.getDate() ? preferA : preferB return equalPreference
} else { } else {
return !a.set.global ? preferA : preferB return !b.set.global ? preferB : preferA
} }
} else { } else {
return preferA 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 { export function getResultFrom(table: RollTable, originalResult: RollTableResult): RollTableResult {
const dbResult = table.full === "results" ? table.resultsByText.get(originalResult.text) : null const dbResult = table.full === "results" ? table.resultsByText.get(originalResult.text) : null
return dbResult ?? { return dbResult ?? {
full: false, full: false,
table, table,
textId: originalResult.textId,
text: originalResult.text text: originalResult.text
} }
} }
@ -327,10 +418,10 @@ export class RollTableDatabase implements Iterable<RollTableDetailsAndResults> {
} }
addResult(result: RollTableResultOrLookup<RollTableDetailsOrInput>|readonly [number, RollTableResultOrLookup<RollTableDetailsOrInput>]): RollTableResultFull<RollTableDetailsAndResults> { addResult(result: RollTableResultOrLookup<RollTableDetailsOrInput>|readonly [number, RollTableResultOrLookup<RollTableDetailsOrInput>]): RollTableResultFull<RollTableDetailsAndResults> {
if (isResultArray(result)) { if (isRollTableDetailsInputPair(result)) {
const [, innerResult] = result as [number, RollTableResultOrLookup<RollTableDetailsOrInput>]; const [, innerResult] = result as [number, RollTableResultOrLookup<RollTableDetailsOrInput>];
return this.addResult(innerResult); return this.addResult(innerResult);
} else if (isRollTableResult(result)) { } else if ("table" in result && result.table) {
if (!this.tables.has(result.table.id)) { if (!this.tables.has(result.table.id)) {
this.addTableInternal({... result.table, full: 'details'}) this.addTableInternal({... result.table, full: 'details'})
} }
@ -346,8 +437,7 @@ export class RollTableDatabase implements Iterable<RollTableDetailsAndResults> {
setId: result.set.id, setId: result.set.id,
textId: result.textId, textId: result.textId,
text: result.text, text: result.text,
mappingId: result.mappingId, mappingId: result.mappingId
updated: result.updated
}) })
} else { } else {
const internalTable = this.tablesById.get(result.tableId); const internalTable = this.tablesById.get(result.tableId);
@ -370,8 +460,7 @@ export class RollTableDatabase implements Iterable<RollTableDetailsAndResults> {
text: result.text, text: result.text,
table: internalTable, table: internalTable,
author: internalAuthor, author: internalAuthor,
set: internalSet, set: internalSet
updated: result.updated
}; };
if (compareRollTableResults(oldText, out) > 0) { if (compareRollTableResults(oldText, out) > 0) {
internalTable.resultsByText.set(out.text, out); 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> { 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]> { [Symbol.iterator](): IterableIterator<[T, U]> {
return this.entries(); 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 GeneratedState,
type InProgressGeneratedContents, type InProgressGeneratedContents,
type InProgressGeneratedState, type InProgressGeneratedState,
RolledValues, rollOn, rollResultToString, RolledValues, rollOn,
RollSelections, RollSelections,
type RollTable, type RollTable,
type RollTableAuthor, type RollTableAuthor,
RollTableDatabase, RollTableDatabase,
type RollTableDetailsNoResults, type RollTableDetailsNoResults,
type RollTableResultFull, type RollTableResultFull,
} from '../../common/rolltable'; } from '../../common/rolltable.js';
import { type PreparedQueries, type QueryOutput, TypedDBWrapper } from './querytypes'; import { type PreparedQueries, type QueryOutput, TypedDBWrapper } from './querytypes.js';
import { DatabaseQueries } from './queries'; import { DatabaseQueries } from './queries.js';
function processOperationResult(result: QueryOutput<(typeof DatabaseQueries)['getResultMappingsForDiscordSet']>[number] | undefined): (RollTableResultFull<RollTableDetailsNoResults> & { function processOperationResult(result: QueryOutput<(typeof DatabaseQueries)['getResultMappingsForDiscordSet']>[number] | undefined): (RollTableResultFull<RollTableDetailsNoResults> | undefined) {
status: 'updated' | 'existing'
}) | undefined {
if (!result) { if (!result) {
return result; return result;
} }
@ -49,18 +47,17 @@ function processOperationResult(result: QueryOutput<(typeof DatabaseQueries)['ge
description: result.setDescription, description: result.setDescription,
global: !!(result.setGlobal) global: !!(result.setGlobal)
}, },
updated: new Date(result.updated),
status: result.status
}; };
} }
// TODO: add some caching for read-response-database style actions
export class Database { export class Database {
private readonly db: TypedDBWrapper; private readonly db: TypedDBWrapper;
private readonly queries: PreparedQueries<typeof DatabaseQueries>; private readonly queries: PreparedQueries<typeof DatabaseQueries>;
constructor(db: D1Database) { constructor(db: D1Database) {
this.db = new TypedDBWrapper(db); this.db = new TypedDBWrapper(db);
this.queries = this.db.prepareAll(DatabaseQueries); this.queries = this.db.prepareOrRetrieveAll(DatabaseQueries);
} }
async autocompleteTable(tableSoFar: string) { async autocompleteTable(tableSoFar: string) {
@ -91,7 +88,6 @@ export class Database {
setSnowflake: setId setSnowflake: setId
}), }),
this.queries.getResultMappingsForDiscordSet({ this.queries.getResultMappingsForDiscordSet({
timestamp,
tableIdentifier: table, tableIdentifier: table,
text, text,
setSnowflake: setId, setSnowflake: setId,
@ -102,30 +98,32 @@ export class Database {
if (!result) { if (!result) {
throw Error('failed adding the new response'); throw Error('failed adding the new response');
} }
return { return result;
...result,
status: result.status === 'updated' ? 'added' : 'existed'
};
} }
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: 'nonexistent'
} | { } | {
status: 'noneditable', status: 'noneditable',
old: RollTableResultFull<RollTableDetailsNoResults> old: RollTableResultFull<RollTableDetailsNoResults>
} | { } | {
status: 'conflict' | 'updated', status: 'edited'|'conflicting',
old: RollTableResultFull<RollTableDetailsNoResults>, old: RollTableResultFull<RollTableDetailsNoResults>,
new: RollTableResultFull<RollTableDetailsNoResults>, new: RollTableResultFull<RollTableDetailsNoResults>,
}> { }> {
const [oldResults, , , , newResults] = await this.db.batch( const [oldResultsBefore, newResultsBefore, , , , oldResultsAfter, newResultsAfter] = await this.db.batch(
this.queries.getResultMappingsForDiscordSet({ this.queries.getResultMappingsForDiscordSet({
timestamp,
tableIdentifier: table, tableIdentifier: table,
text: oldText, text: oldText,
setSnowflake: setId, setSnowflake: setId,
includeGlobal: true includeGlobal: true
}), }),
this.queries.getResultMappingsForDiscordSet({
tableIdentifier: table,
text: newText,
setSnowflake: setId,
includeGlobal: true
}),
this.queries.addResultForEditMapping({ this.queries.addResultForEditMapping({
tableIdentifier: table, tableIdentifier: table,
oldText, oldText,
@ -141,7 +139,6 @@ export class Database {
setSnowflake: setId setSnowflake: setId
}), }),
this.queries.editMappingForDiscord({ this.queries.editMappingForDiscord({
timestamp,
tableIdentifier: table, tableIdentifier: table,
oldText, oldText,
newText, newText,
@ -149,26 +146,31 @@ export class Database {
setSnowflake: setId setSnowflake: setId
}), }),
this.queries.getResultMappingsForDiscordSet({ this.queries.getResultMappingsForDiscordSet({
timestamp, tableIdentifier: table,
text: oldText,
setSnowflake: setId,
includeGlobal: false
}),
this.queries.getResultMappingsForDiscordSet({
tableIdentifier: table, tableIdentifier: table,
text: newText, text: newText,
setSnowflake: setId, setSnowflake: setId,
includeGlobal: false includeGlobal: false
}) })
); );
const oldResult = processOperationResult(oldResults[0]); const oldResult = processOperationResult(oldResultsBefore[0]);
if (!oldResult) { if (!oldResult) {
return { status: 'nonexistent' }; return { status: 'nonexistent' };
} }
if (oldResult.set?.global) { if (oldResult.set?.global) {
return { status: 'noneditable', old: oldResult }; return { status: 'noneditable', old: oldResult };
} }
const newResult = processOperationResult(newResults[0]); const newResult = processOperationResult(newResultsAfter[0]);
if (!newResult) { if (!newResult) {
throw Error('failed to update response'); throw Error('failed to update response');
} }
return { return {
status: newResult.status === 'updated' ? 'updated' : 'conflict', status: 'edited',
old: oldResult, old: oldResult,
new: newResult new: newResult
}; };
@ -182,7 +184,6 @@ export class Database {
}> { }> {
const [oldResults, deleted] = await this.db.batch( const [oldResults, deleted] = await this.db.batch(
this.queries.getResultMappingsForDiscordSet({ this.queries.getResultMappingsForDiscordSet({
timestamp: null,
tableIdentifier: table, tableIdentifier: table,
text, text,
setSnowflake: setId, setSnowflake: setId,
@ -218,7 +219,6 @@ export class Database {
status: 'existent', status: 'existent',
} & RollTableResultFull<RollTableDetailsNoResults>)> { } & RollTableResultFull<RollTableDetailsNoResults>)> {
const results = await this.db.run(this.queries.getResultMappingsForDiscordSet({ const results = await this.db.run(this.queries.getResultMappingsForDiscordSet({
timestamp: null,
tableIdentifier: table, tableIdentifier: table,
text, text,
setSnowflake: setId, setSnowflake: setId,
@ -294,7 +294,7 @@ export class Database {
tables: tables.map(v => ({ ...v, full: 'details' })), tables: tables.map(v => ({ ...v, full: 'details' })),
authors, authors,
sets, sets,
results: mappings.map(v => ({ ...v, updated: new Date(v.updated) })) results: mappings.map(v => ({ ...v }))
}); });
if (!oldResults && !reroll) { if (!oldResults && !reroll) {
return db; return db;
@ -332,6 +332,7 @@ export class Database {
const result = lookupResult ?? { const result = lookupResult ?? {
full: false, full: false,
text: text, text: text,
textId: null,
table table
} }
rolled.add(result); rolled.add(result);

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

@ -83,18 +83,6 @@ export function prepareQuery<T extends QueryDefinition<any>>(database: D1Databas
} as PreparedQuery<T>; } 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> { export async function runQuery<T>(db: D1Database, query: BoundQuery<T>): Promise<T> {
const startAt = performance.now() const startAt = performance.now()
const [results] = await db.batch([query.statement]); const [results] = await db.batch([query.statement]);
@ -113,17 +101,28 @@ export async function batchQueries<T extends [...unknown[]]>(db: D1Database, que
export class TypedDBWrapper { export class TypedDBWrapper {
private readonly db: D1Database; private readonly db: D1Database;
private readonly preparedQueries: Map<QueryDefinition<string>, PreparedQuery<QueryDefinition<string>>> = new Map()
constructor(db: D1Database) { constructor(db: D1Database) {
this.db = db; this.db = db;
} }
prepare<T extends QueryDefinition<any>>(query: T): PreparedQuery<T> { prepareOrRetrieve<T extends QueryDefinition<any>>(query: T): PreparedQuery<T> {
return prepareQuery(this.db, query); 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> { prepareOrRetrieveAll<T extends QueryDefinitions>(queries: T): PreparedQueries<T> {
return prepareAllQueries(this.db, queries); 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> { async run<T>(query: BoundQuery<T>): Promise<T> {
@ -134,5 +133,3 @@ export class TypedDBWrapper {
return batchQueries<T>(this.db, queries); 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 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 { export function substring(data: string|undefined): string {
const text = string(data, false) const text = string(data, false)
if (text.length === 0) { if (text.length === 0) {

@ -5,32 +5,34 @@ import {
CommandContext, CommandContext,
CommandOptionType, CommandOptionType,
ComponentContext, ComponentContext,
InteractionContextType,
SlashCommand, SlashCommand,
type SlashCreator type SlashCreator
} from 'slash-create/web'; } from 'slash-create/web';
import { type Database } from '../db/database'; import { type Database } from '../db/database.js';
import { type Snowflake } from 'discord-snowflake'; import { type Snowflake } from 'discord-snowflake';
import { import {
DELETE_ID, DELETE_ID,
DONE_ID, FAILURE_COLOR, DONE_ID,
generateAuthorForResult, generateEmbedForResult, FAILURE_COLOR,
generateEmbedForResult,
generateErrorMessageFor, generateErrorMessageFor,
generateFieldForResult,
generateFooterForResult,
generateMessageFor, generateMessageFor,
getEmbedFrom, getEmbedFrom,
loadEmbed, recordError, loadEmbed,
recordError,
REROLL_ID, REROLL_ID,
SELECT_ID, SUCCESS_COLOR, WARNING_COLOR SELECT_ID,
} from './embed'; SUCCESS_COLOR,
WARNING_COLOR
} from './embed.js';
import { import {
generatedContentsToString, generatedStateToString,
MAX_IDENTIFIER_LENGTH, MAX_IDENTIFIER_LENGTH,
MAX_NAME_LENGTH, MAX_NAME_LENGTH,
MAX_RESULT_LENGTH, MAX_RESULT_LENGTH,
MAX_URL_LENGTH, MAX_URL_LENGTH,
type RollTableAuthor type RollTableAuthor
} from '../../common/rolltable'; } from '../../common/rolltable.js';
import markdownEscape from 'markdown-escape'; import markdownEscape from 'markdown-escape';
const tableOption: Omit<ApplicationCommandOptionLimitedString, 'name' | 'description'> = { const tableOption: Omit<ApplicationCommandOptionLimitedString, 'name' | 'description'> = {
@ -169,7 +171,7 @@ export class ResponseCommand extends SlashCommand {
description: 'Modifies the responses available in the generator.', description: 'Modifies the responses available in the generator.',
nsfw: false, nsfw: false,
guildIDs: forGuilds, guildIDs: forGuilds,
dmPermission: true, contexts: [InteractionContextType.BOT_DM, InteractionContextType.GUILD, InteractionContextType.PRIVATE_CHANNEL],
options: [ options: [
{ {
type: CommandOptionType.SUB_COMMAND, type: CommandOptionType.SUB_COMMAND,
@ -461,13 +463,13 @@ export class ResponseCommand extends SlashCommand {
ephemeral: true ephemeral: true
}); });
break; break;
case 'conflict': case 'conflicting':
await ctx.send({ await ctx.send({
embeds: [generateEmbedForResult('The old response (still existing)', WARNING_COLOR, result.old), generateEmbedForResult('A conflicting response', FAILURE_COLOR, result.new)], embeds: [generateEmbedForResult('The old response (still existing)', WARNING_COLOR, result.old), generateEmbedForResult('A conflicting response', FAILURE_COLOR, result.new)],
ephemeral: true ephemeral: true
}); });
break; break;
case 'updated': case 'edited':
await ctx.send({ await ctx.send({
embeds: [generateEmbedForResult('The old response (now gone)', SUCCESS_COLOR, result.old), generateEmbedForResult('Your updated response', SUCCESS_COLOR, result.new)] 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 { import {
type FinalGeneratedContents, type FinalGeneratedContents,
type GeneratedState, type GeneratedState,
type InProgressGeneratedContents, type InProgressGeneratedContents,
type RollTableResultFull type RollTableResultFull
} from '../../common/rolltable'; } from '../../common/rolltable.js';
import { import {
ButtonStyle, ButtonStyle,
type ComponentActionRow, type ComponentActionRow,
@ -59,7 +59,6 @@ export function generateEmbedForResult(title: string, color: number, value: Roll
color, color,
author: generateAuthorForResult(value), author: generateAuthorForResult(value),
fields: [generateFieldForResult(value)], fields: [generateFieldForResult(value)],
timestamp: value.updated,
footer: generateFooterForResult(value), 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 { CloudflareWorkerServer, SlashCreator } from 'slash-create/web';
import { isSnowflake, type Snowflake } from 'discord-snowflake'; 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 { type IRequestStrict, Router } from 'itty-router';
import { getQueryArray } from '../request/query'; import { getQueryArray } from '../request/query.js';
function getAuthorization(username: string, password: string): string { function getAuthorization(username: string, password: string): string {
return btoa(username + ':' + password); return btoa(username + ':' + password);

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

@ -76,7 +76,7 @@
/* Type Checking */ /* Type Checking */
"strict": true /* Enable all strict type-checking options. */, "strict": true /* Enable all strict type-checking options. */,
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied `any` type.. */ // "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. */ // "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. */ // "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. */ // "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 { export enum SourceMapExtension {
CSS = 'css', CSS = 'css',

@ -1,11 +1,17 @@
import { type IRequestStrict, Router } from 'itty-router'; import { type IRequestStrict, AutoRouter } from 'itty-router';
import type { Database } from '../db/database'; import type { Database } from '../db/database.js';
import { CSS, JS } from './bundles/client.generated'; import { CSS, JS } from './bundles/client.generated.js';
import type { HashedBundled } from '../../common/bundle'; import type { HashedBundled } from '../../common/bundle.js';
import { getSourceMapFileName, SourceMapExtension, SourceMaps } from './bundles/sourcemaps'; import { getSourceMapFileName, SourceMapExtension, SourceMaps } from './bundles/sourcemaps.js';
import { collapseWhiteSpace } from 'collapse-white-space'; import { collapseWhiteSpace } from 'collapse-white-space';
import { getQuerySingleton, takeLast } from '../request/query'; import { getQuerySingleton, takeLast } from '../request/query.js';
import { StringTemplateBuilder } from './template'; 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 { interface WebEnv {
readonly BASE_URL: string, readonly BASE_URL: string,
@ -47,33 +53,26 @@ export function webRouter(base: string) {
// TODO: use SSR with the Main components here // 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: handle POSTs by rerolling and redisplaying appropriately - redirect to a GET with text IDs listed
// TODO: support json output here // 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, 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({ const wrapped = wrapPage({
title: 'Vore Scenario Generator', title: 'Vore Scenario Generator',
script: getSourceMappedJS('combinedGeneratorResponses'), script: getSourceMappedJS('combinedGeneratorResponses'),
styles: getSourceMappedCSS('combinedGeneratorResponses'), styles: getSourceMappedCSS('combinedGeneratorResponses'),
noscriptStyles: getSourceMappedCSS('noscript'), noscriptStyles: getSourceMappedCSS('noscript'),
bodyContent: [generator, responses].join('') bodyContent: render(createElement(MainGeneratorAndResponses, props))
}) })
return collapseWhiteSpace(wrapped, { style: 'html' }); 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) => { .get('/responses', async (req, _env, _db, _ctx) => {
// TODO: make this actually just the responses // TODO: make this actually just the responses
const url = new URL(req.url); const url = new URL(req.url);

@ -11,8 +11,8 @@
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
/* Language and Environment */ /* Language and Environment */
"target": "ES2015", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ "target": "ESNext", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
"lib": ["dom", "dom.iterable", "ES2015"] "lib": ["dom", "dom.iterable", "ESNext"]
/* Specify a set of bundled library declaration files that describe the target runtime environment. */, /* Specify a set of bundled library declaration files that describe the target runtime environment. */,
// "jsx": "react" /* Specify what JSX code is generated. */, // "jsx": "react" /* Specify what JSX code is generated. */,
// "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */
@ -26,12 +26,12 @@
/* Modules */ /* Modules */
// "module": "es2022" /* Specify what module code is generated. */, // "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. */, "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. */ // "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. */ // "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. */ // "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. */, // "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. */ // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
// "resolveJsonModule": true /* Enable importing .json files */, // "resolveJsonModule": true /* Enable importing .json files */,
@ -101,8 +101,9 @@
"jsx": "react-jsx", "jsx": "react-jsx",
"jsxImportSource": "preact", "jsxImportSource": "preact",
"paths": { "paths": {
"react": ["./node_modules/preact/compat/"], "react": ["../../node_modules/preact/compat/"],
"react-dom": ["./node_modules/preact/compat/"] "react-dom": ["../../node_modules/preact/compat/"]
} }
} },
"exclude": ["server/**"]
} }

@ -8,7 +8,7 @@ keep_vars = true
workers_dev = true workers_dev = true
[build] [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 = "." cwd = "."
watch_dir = ["src/client", "src/common", "src/build"] watch_dir = ["src/client", "src/common", "src/build"]

Loading…
Cancel
Save