diff --git a/package-lock.json b/package-lock.json index 4cda09d2e..69b2061b1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,15 @@ { "name": "Twine", - "version": "2.9.0", + "version": "2.9.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "Twine", - "version": "2.9.0", + "version": "2.9.1", "license": "GPL-3.0", "dependencies": { + "@lukeed/uuid": "^2.0.1", "@popperjs/core": "^2.9.1", "@tabler/icons": "^1.119.0", "@testing-library/dom": "^9.3.1", @@ -3512,6 +3513,25 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@lukeed/csprng": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@lukeed/csprng/-/csprng-1.1.0.tgz", + "integrity": "sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==", + "engines": { + "node": ">=8" + } + }, + "node_modules/@lukeed/uuid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@lukeed/uuid/-/uuid-2.0.1.tgz", + "integrity": "sha512-qC72D4+CDdjGqJvkFMMEAtancHUQ7/d/tAiHf64z8MopFDmcrtbcJuerDtFceuAfQJ2pDSfCKCtbqoGBNnwg0w==", + "dependencies": { + "@lukeed/csprng": "^1.1.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/@malept/cross-spawn-promise": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-1.1.1.tgz", diff --git a/package.json b/package.json index 6bff33f30..68887425e 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "npm": ">=8" }, "dependencies": { + "@lukeed/uuid": "^2.0.1", "@popperjs/core": "^2.9.1", "@tabler/icons": "^1.119.0", "@testing-library/dom": "^9.3.1", diff --git a/src/setupTests.ts b/src/setupTests.ts index 1bd66d08b..3ce6234af 100644 --- a/src/setupTests.ts +++ b/src/setupTests.ts @@ -1,7 +1,6 @@ import {toHaveNoViolations} from 'jest-axe'; import {configure} from '@testing-library/dom'; import '@testing-library/jest-dom'; -import {faker} from '@faker-js/faker'; import 'jest-canvas-mock'; // Always mock these files so that Jest doesn't see import.meta. @@ -51,9 +50,5 @@ afterEach(() => delete (window as any).matchMedia); } }; -// ... and jsdom doesn't implement the crypto module, which we use to generate UUIDs. - -(window as any).crypto.randomUUID = () => faker.string.uuid(); - window.Element.prototype.releasePointerCapture = () => {}; window.Element.prototype.setPointerCapture = () => {}; diff --git a/src/store/persistence/electron-ipc/story-formats/load.ts b/src/store/persistence/electron-ipc/story-formats/load.ts index fd5a8c303..7118149ce 100644 --- a/src/store/persistence/electron-ipc/story-formats/load.ts +++ b/src/store/persistence/electron-ipc/story-formats/load.ts @@ -1,3 +1,4 @@ +import {v4 as uuid} from '@lukeed/uuid'; import {TwineElectronWindow} from '../../../../electron/shared'; import {StoryFormatsState} from '../../../story-formats/story-formats.types'; @@ -15,7 +16,7 @@ export async function load(): Promise { } return storyFormats.map(data => ({ - id: window.crypto.randomUUID(), + id: uuid(), loadState: 'unloaded', name: data.name, selected: false, diff --git a/src/store/persistence/local-storage/prefs/save.ts b/src/store/persistence/local-storage/prefs/save.ts index 9b3e67df9..915c9b15e 100644 --- a/src/store/persistence/local-storage/prefs/save.ts +++ b/src/store/persistence/local-storage/prefs/save.ts @@ -1,3 +1,4 @@ +import {v4 as uuid} from '@lukeed/uuid'; import {PrefsState} from '../../../prefs'; export function save(state: PrefsState) { @@ -17,7 +18,7 @@ export function save(state: PrefsState) { const ids: string[] = []; for (const name in state) { - const id = window.crypto.randomUUID(); + const id = uuid(); ids.push(id); window.localStorage.setItem( diff --git a/src/store/persistence/local-storage/story-formats/save.ts b/src/store/persistence/local-storage/story-formats/save.ts index 027b96d25..401442793 100644 --- a/src/store/persistence/local-storage/story-formats/save.ts +++ b/src/store/persistence/local-storage/story-formats/save.ts @@ -1,3 +1,4 @@ +import {v4 as uuid} from '@lukeed/uuid'; import {StoryFormatsState} from '../../../story-formats/story-formats.types'; export function save(state: StoryFormatsState) { @@ -17,8 +18,8 @@ export function save(state: StoryFormatsState) { const ids: string[] = []; - state.forEach(format => { - const id = window.crypto.randomUUID(); + for (const format of state) { + const id = uuid(); // We have to remove the `properties` property if it exists, as that is // dynamically added when loading. @@ -34,7 +35,7 @@ export function save(state: StoryFormatsState) { selected: undefined }) ); - }); + } window.localStorage.setItem('twine-storyformats', ids.join(',')); } diff --git a/src/store/stories/action-creators/create-story.ts b/src/store/stories/action-creators/create-story.ts index db20e70fe..93faafa37 100644 --- a/src/store/stories/action-creators/create-story.ts +++ b/src/store/stories/action-creators/create-story.ts @@ -1,3 +1,4 @@ +import {v4 as uuid} from '@lukeed/uuid'; import {Thunk} from 'react-hook-thunk-reducer'; import {PrefsState} from '../../prefs'; import {StoriesAction, StoriesState, Story} from '../stories.types'; @@ -10,7 +11,7 @@ export function createStory( prefs: PrefsState, props: Partial> & Pick ): Thunk { - const id = window.crypto.randomUUID(); + const id = uuid(); if (props.name.trim() === '') { throw new Error('Story name cannot be empty'); diff --git a/src/store/stories/action-creators/duplicate-story.ts b/src/store/stories/action-creators/duplicate-story.ts index e31cf282c..ca305c6e0 100644 --- a/src/store/stories/action-creators/duplicate-story.ts +++ b/src/store/stories/action-creators/duplicate-story.ts @@ -1,3 +1,4 @@ +import {v4 as uuid} from '@lukeed/uuid'; import {CreateStoryAction, Story} from '../stories.types'; import {unusedName} from '../../../util/unused-name'; @@ -8,21 +9,21 @@ export function duplicateStory( story: Story, stories: Story[] ): CreateStoryAction { - const id = window.crypto.randomUUID(); + const id = uuid(); return { type: 'createStory', props: { ...story, id, - ifid: window.crypto.randomUUID(), + ifid: uuid(), name: unusedName( story.name, stories.map(story => story.name) ), passages: story.passages.map(passage => ({ ...passage, - id: window.crypto.randomUUID(), + id: uuid(), story: id })) } diff --git a/src/store/stories/reducer/create-passage.ts b/src/store/stories/reducer/create-passage.ts index 28c456faa..f56072b11 100644 --- a/src/store/stories/reducer/create-passage.ts +++ b/src/store/stories/reducer/create-passage.ts @@ -1,3 +1,4 @@ +import {v4 as uuid} from '@lukeed/uuid'; import {passageDefaults} from '../defaults'; import {Passage, Story, StoriesState} from '../stories.types'; @@ -34,7 +35,7 @@ export function createPassage( const newPassage: Passage = { ...passageDefaults(), - id: window.crypto.randomUUID(), + id: uuid(), ...passageProps, story: story.id }; diff --git a/src/store/stories/reducer/create-story.ts b/src/store/stories/reducer/create-story.ts index 16ab3df9c..3942770c1 100644 --- a/src/store/stories/reducer/create-story.ts +++ b/src/store/stories/reducer/create-story.ts @@ -1,3 +1,4 @@ +import {v4 as uuid} from '@lukeed/uuid'; import {passageDefaults, storyDefaults} from '../defaults'; import {Story, StoriesState} from '../stories.types'; @@ -20,9 +21,9 @@ export function createStory(state: StoriesState, storyProps: Partial) { } const story: Story = { - id: window.crypto.randomUUID(), + id: uuid(), ...storyDefaults(), - ifid: window.crypto.randomUUID().toUpperCase(), + ifid: uuid().toUpperCase(), lastUpdate: new Date(), passages: [], tags: [], diff --git a/src/store/stories/reducer/repair/repair-passage.ts b/src/store/stories/reducer/repair/repair-passage.ts index a04a89e3f..ef1b5f47d 100644 --- a/src/store/stories/reducer/repair/repair-passage.ts +++ b/src/store/stories/reducer/repair/repair-passage.ts @@ -1,3 +1,4 @@ +import {v4 as uuid} from '@lukeed/uuid'; import {passageDefaults} from '../../defaults'; import {Passage, Story} from '../../stories.types'; @@ -25,15 +26,16 @@ export function repairPassage(passage: Passage, parentStory: Story): Passage { // Give the passage an ID if it has none. if (typeof passage.id !== 'string' || passage.id === '') { - const newId = window.crypto.randomUUID(); + const newId = uuid(); logRepair(passage, 'id', newId, 'was undefined or empty string'); - repairs.id = window.crypto.randomUUID(); + repairs.id = newId; } // Apply default properties to the passage. - Object.entries(passageDefs).forEach(([key, value]) => { + for (const key in passageDefs) { + const value = passageDefs[key as keyof typeof passageDefs]; const defKey = key as keyof typeof passageDefs; if ( @@ -43,11 +45,11 @@ export function repairPassage(passage: Passage, parentStory: Story): Passage { logRepair(passage, defKey, passageDefs[defKey]); (repairs[defKey] as Passage[typeof defKey]) = passageDefs[defKey]; } - }); + } // Make passage coordinates 0 or greater. - ['left', 'top'].forEach(pos => { + for (const pos of ['left', 'top']) { const posKey = pos as keyof Passage; if ( @@ -57,11 +59,11 @@ export function repairPassage(passage: Passage, parentStory: Story): Passage { logRepair(passage, posKey, 0, 'was negative'); (repairs[posKey] as Passage[typeof posKey]) = 0; } - }); + } // Make passage dimensions 5 or greater. - ['height', 'width'].forEach(dim => { + for (const dim of ['height', 'width']) { const dimKey = dim as keyof Passage; if ( @@ -71,7 +73,7 @@ export function repairPassage(passage: Passage, parentStory: Story): Passage { logRepair(passage, dimKey, 0, 'was less than 5'); (repairs[dimKey] as Passage[typeof dimKey]) = 5; } - }); + } // Repair story property if it doesn't point to the parent story. @@ -91,7 +93,7 @@ export function repairPassage(passage: Passage, parentStory: Story): Passage { return otherPassage.id === passage.id; }) ) { - const newId = window.crypto.randomUUID(); + const newId = uuid(); logRepair( passage, diff --git a/src/store/stories/reducer/repair/repair-story.ts b/src/store/stories/reducer/repair/repair-story.ts index 6b1a479b2..665b3f773 100644 --- a/src/store/stories/reducer/repair/repair-story.ts +++ b/src/store/stories/reducer/repair/repair-story.ts @@ -1,3 +1,4 @@ +import {v4 as uuid} from '@lukeed/uuid'; import {satisfies} from 'semver'; import {Story} from '../../stories.types'; import {storyDefaults} from '../../defaults'; @@ -33,7 +34,7 @@ export function repairStory( // Give the story an ID if it has none. if (typeof story.id !== 'string' || story.id === '') { - const newId = window.crypto.randomUUID(); + const newId = uuid(); logRepair(story, 'id', newId, 'was bad type or empty string'); repairs.id = newId; @@ -42,7 +43,7 @@ export function repairStory( // Give the story an IFID if it has none. if (typeof story.ifid !== 'string' || story.id === '') { - const newIfid = window.crypto.randomUUID(); + const newIfid = uuid(); logRepair(story, 'ifid', newIfid, 'was bad type or empty string'); repairs.ifid = newIfid; @@ -50,8 +51,9 @@ export function repairStory( // Apply default properties to the story. - Object.entries(storyDefs).forEach(([key, value]) => { + for (const key in storyDefs) { const defKey = key as keyof typeof storyDefs; + const value = storyDefs[defKey]; if ( (typeof value === 'number' && !Number.isFinite(story[defKey])) || @@ -60,7 +62,7 @@ export function repairStory( logRepair(story, defKey, storyDefs[defKey]); (repairs[defKey] as Story[typeof defKey]) = storyDefs[defKey]; } - }); + } if ( typeof story.storyFormat !== 'string' || @@ -144,7 +146,7 @@ export function repairStory( return otherStory.id === story.id; }) ) { - const newId = window.crypto.randomUUID(); + const newId = uuid(); logRepair(story, 'id', newId, "conflicted with another story's ID"); repairs.id = newId; diff --git a/src/store/story-formats/reducer.ts b/src/store/story-formats/reducer.ts index 40e6bf516..a051242db 100644 --- a/src/store/story-formats/reducer.ts +++ b/src/store/story-formats/reducer.ts @@ -1,3 +1,4 @@ +import {v4 as uuid} from '@lukeed/uuid'; import {builtins} from './defaults'; import { StoryFormat, @@ -37,7 +38,7 @@ export const reducer: React.Reducer = ( // Add any builtins not present. - builtinFormats.forEach(builtinFormat => { + for (const builtinFormat of builtinFormats) { if ( !result.some( f => @@ -50,13 +51,14 @@ export const reducer: React.Reducer = ( ); result.push({ ...builtinFormat, - id: window.crypto.randomUUID(), + id: uuid(), loadState: 'unloaded', selected: false, userAdded: false }); } - }); + } + return result; } @@ -71,10 +73,7 @@ export const reducer: React.Reducer = ( return state; } - return [ - ...state, - {...action.props, id: window.crypto.randomUUID(), loadState: 'unloaded'} - ]; + return [...state, {...action.props, id: uuid(), loadState: 'unloaded'}]; case 'delete': return state.filter(f => f.id !== action.id); diff --git a/src/store/story-formats/story-formats-context.tsx b/src/store/story-formats/story-formats-context.tsx index fa9ed23a1..b1cec7b57 100644 --- a/src/store/story-formats/story-formats-context.tsx +++ b/src/store/story-formats/story-formats-context.tsx @@ -1,3 +1,4 @@ +import {v4 as uuid} from '@lukeed/uuid'; import * as React from 'react'; import useThunkReducer from 'react-hook-thunk-reducer'; import {usePersistence} from '../persistence/use-persistence'; @@ -13,7 +14,7 @@ import {reducer} from './reducer'; const defaultBuiltins: StoryFormat[] = builtins().map(f => ({ ...f, - id: window.crypto.randomUUID(), + id: uuid(), loadState: 'unloaded', selected: false, userAdded: false diff --git a/src/util/import.ts b/src/util/import.ts index b52664e9d..a3ee4f749 100644 --- a/src/util/import.ts +++ b/src/util/import.ts @@ -5,6 +5,7 @@ // affects startup time in the Twine desktop app. This module moves data from // the filesystem into local storage, and the app can't begin until it's done. +import {v4 as uuid} from '@lukeed/uuid'; import defaults from 'lodash/defaults'; import {passageDefaults, storyDefaults, Passage, Story} from '../store/stories'; @@ -69,8 +70,8 @@ function domToObject(storyEl: Element): ImportedStory { const startPassagePid = storyEl.getAttribute('startnode'); let startPassageId: string | undefined = undefined; const story: ImportedStory = { - ifid: storyEl.getAttribute('ifid') ?? window.crypto.randomUUID(), - id: window.crypto.randomUUID(), + ifid: storyEl.getAttribute('ifid') ?? uuid().toUpperCase(), + id: uuid(), lastUpdate: undefined, name: storyEl.getAttribute('name') ?? undefined, storyFormat: storyEl.getAttribute('format') ?? undefined, @@ -95,7 +96,7 @@ function domToObject(storyEl: Element): ImportedStory { return {...result, [tagName]: el.getAttribute('color')}; }, {}), passages: query(storyEl, selectors.passageData).map(passageEl => { - const id = window.crypto.randomUUID(); + const id = uuid(); const position = parseDimensions(passageEl.getAttribute('position')); const size = parseDimensions(passageEl.getAttribute('size')); @@ -140,11 +141,7 @@ export function importStories( // Merge in defaults. We can't use object spreads here because undefined // values would override defaults. - const story: Story = defaults( - importedStory, - {id: window.crypto.randomUUID()}, - storyDefaults() - ); + const story: Story = defaults(importedStory, {id: uuid()}, storyDefaults()); // Override the last update as requested. diff --git a/src/util/twee.ts b/src/util/twee.ts index 631eb10d8..8b16407b0 100644 --- a/src/util/twee.ts +++ b/src/util/twee.ts @@ -1,3 +1,4 @@ +import {v4 as uuid} from '@lukeed/uuid'; import sortBy from 'lodash/sortBy'; import {Passage, passageDefaults, Story, storyDefaults} from '../store/stories'; import {unusedName} from './unused-name'; @@ -73,7 +74,7 @@ export function passageFromTwee(source: string): Omit { const passage: Omit = { ...passageDefaults(), - id: window.crypto.randomUUID(), + id: uuid(), name: unescapeForTweeHeader( rawName .replace(/^(\\\s)+/g, match => ' '.repeat(match.length / 2)) @@ -134,12 +135,12 @@ export function passageFromTwee(source: string): Omit { * Converts a story from Twee source. */ export function storyFromTwee(source: string) { - const id = window.crypto.randomUUID(); + const id = uuid(); const story: Story = { ...storyDefaults(), id, - ifid: window.crypto.randomUUID(), + ifid: uuid().toUpperCase(), lastUpdate: new Date(), passages: source .split(/^::/m)