├── .eslintrc.disable ├── .github └── workflows │ └── node.js.yml ├── .gitignore ├── .husky.disable └── pre-commit ├── .prettierignore.disable ├── .prettierrc.json.disable ├── Dockerfile ├── LICENSE ├── README.md ├── TESTS.md ├── TODOS.md ├── __mocks__ ├── MockFirestore.ts └── mocks.ts ├── admin.tsx ├── app ├── lib │ ├── authentication │ │ └── firebase.ts │ ├── chat │ │ ├── getEmbeddings.ts │ │ ├── getSuggestions.ts │ │ ├── getSuggestionsJSON.ts │ │ ├── integrations │ │ │ ├── huggingFace.ts │ │ │ ├── local.ts │ │ │ ├── openai.ts │ │ │ └── replicate.ts │ │ └── updateUsage.ts │ ├── common │ │ └── serveLibrary.ts │ ├── serverUtils.js │ ├── speech │ │ └── polly.js │ ├── storage │ │ ├── firebase.test.js │ │ ├── firebase.ts │ │ ├── s3.js │ │ └── storageEngine.js │ ├── types.ts │ ├── utils.ts │ └── utils │ │ ├── crypto.ts │ │ ├── middleware.ts │ │ ├── middleware │ │ ├── checkBookAccess.ts │ │ ├── checkChapterAccess.ts │ │ ├── csrf.ts │ │ ├── requireAdmin.ts │ │ └── requireLogin.ts │ │ ├── processDir.ts │ │ └── serveFile.ts ├── routes │ ├── DELETE │ │ └── api │ │ │ ├── book │ │ │ └── [bookid].ts │ │ │ └── chapter │ │ │ └── [bookid] │ │ │ └── [chapterid].ts │ ├── GET │ │ ├── 404.ts │ │ ├── api │ │ │ ├── admin.ts │ │ │ ├── admin │ │ │ │ ├── deleteTestUserBooks.ts │ │ │ │ ├── resetMonthlyTokenCounts.ts │ │ │ │ └── users.ts │ │ │ ├── book │ │ │ │ └── [bookid] │ │ │ │ │ └── export │ │ │ │ │ └── [title].ts │ │ │ ├── books.ts │ │ │ ├── chapter │ │ │ │ └── [bookid] │ │ │ │ │ └── [chapterid] │ │ │ │ │ ├── embeddings.ts │ │ │ │ │ └── export │ │ │ │ │ └── [title].ts │ │ │ ├── history │ │ │ │ └── [bookid] │ │ │ │ │ └── [chapterid].ts │ │ │ ├── image │ │ │ │ └── [s3key].ts │ │ │ ├── lastEdited.ts │ │ │ ├── sseUpdates │ │ │ │ └── [clientSessionId].ts │ │ │ └── utils │ │ │ │ └── define │ │ │ │ └── [word].ts │ │ ├── book │ │ │ ├── [bookid].ts │ │ │ └── [bookid] │ │ │ │ ├── [scrollTop].ts │ │ │ │ └── chapter │ │ │ │ ├── [chapterid].ts │ │ │ │ └── [chapterid] │ │ │ │ └── [textindex].ts │ │ ├── hello.ts │ │ ├── hello │ │ │ └── hi.ts │ │ ├── home.html.ts │ │ ├── index.ts │ │ ├── login.html.ts │ │ ├── login.ts │ │ ├── logout.ts │ │ ├── register.html.ts │ │ └── register.ts │ ├── POST │ │ ├── api │ │ │ ├── book.ts │ │ │ ├── book │ │ │ │ ├── [bookid] │ │ │ │ │ ├── askQuestion.ts │ │ │ │ │ └── embeddings.ts │ │ │ │ └── upload.ts │ │ │ ├── chapter.ts │ │ │ ├── file │ │ │ │ └── upload.ts │ │ │ ├── history.ts │ │ │ ├── speechToText │ │ │ │ └── upload.ts │ │ │ ├── suggestions.ts │ │ │ └── textToSpeech │ │ │ │ ├── data │ │ │ │ └── [chapterid].ts │ │ │ │ ├── file │ │ │ │ └── [s3key].ts │ │ │ │ ├── long.ts │ │ │ │ ├── short.ts │ │ │ │ └── task │ │ │ │ └── [chapterid] │ │ │ │ └── [taskid].ts │ │ └── auth │ │ │ ├── login.ts │ │ │ ├── loginGuestUser.ts │ │ │ └── register.ts │ └── PUT │ │ └── api │ │ ├── book.ts │ │ ├── chapter.ts │ │ └── history │ │ └── commitMessage.ts └── server.ts ├── babel.config.json ├── cypress.config.ts ├── cypress ├── e2e │ ├── Blocks.cy.ts │ ├── BookList.cy.ts │ ├── ChapterList.cy.ts │ ├── Characters.cy.ts │ ├── CompostHeap.cy.ts │ ├── DeleteBook.cy.ts │ ├── EditAndSwitch.cy.ts │ ├── Editor.cy.ts │ ├── FrontMatter.cy.ts │ ├── Fullscreen.cy.ts │ ├── GridMode.cy.ts │ ├── History.cy.ts │ ├── Home.cy.ts │ ├── Login.cy.ts │ ├── Prompts.cy.ts │ ├── Settings.cy.ts │ └── UI.cy.ts ├── fixtures │ └── example.json └── support │ ├── commands.ts │ └── e2e.ts ├── empty.tsx ├── home.tsx ├── jest.config.js ├── library.tsx ├── login.tsx ├── mobile.tsx ├── package.json ├── pages ├── 404.html ├── admin.html ├── library.html ├── login-base.html ├── mobile.html └── privacy.html ├── postcss.config.cjs ├── public ├── chisel-logo-1024.png ├── chisel-logo-128.png ├── chisel-logo-256.png ├── chisel-logo-512.png ├── css │ ├── eb-garamond-400-600.css │ └── latin.woff2 ├── favicon.ico ├── images │ ├── .DS_Store │ ├── focusmode.png │ ├── full.png │ ├── gridmode.png │ ├── history.png │ ├── launcher.png │ ├── logo.png │ └── prompts.png └── manifest.json ├── settings.example.js ├── setupJestMock.ts ├── src ├── App.tsx ├── AppMobile.tsx ├── AskAQuestionSidebar.tsx ├── Auth.tsx ├── BlockActionsSidebar.tsx ├── BlockInfoSidebar.tsx ├── BlocksSidebar.tsx ├── BookEditor.tsx ├── BookList.tsx ├── ChapterList.tsx ├── ChatSidebar.tsx ├── DebugSidebar.tsx ├── DiffViewer.tsx ├── EditHistorySidebar.tsx ├── Editor.tsx ├── EncryptionSidebar.tsx ├── ExportSidebar.tsx ├── FocusChecksSidebar.tsx ├── FocusMode.tsx ├── FocusSidebar.tsx ├── Help.tsx ├── History.tsx ├── Home.tsx ├── Info.test.tsx ├── Info.tsx ├── Library.tsx ├── LibraryContext.ts ├── LibraryDesktop.tsx ├── LibraryMobile.tsx ├── Login.tsx ├── MultipleChoicePopup.tsx ├── Nav.tsx ├── OutlineSidebar.tsx ├── ProgressBar.tsx ├── PromptsSidebar.tsx ├── PublishSidebar.tsx ├── ReadOnlyView.tsx ├── ReplaceSidebar.tsx ├── SearchSidebar.tsx ├── Settings.tsx ├── ShowAllVersions.tsx ├── SimpleSearchSidebar.tsx ├── SpeechSidebar.tsx ├── Structure.tsx ├── SuggestionPanel.tsx ├── SynonymsSidebar.tsx ├── Tabs.tsx ├── TextEditor.tsx ├── TodoListBlock.tsx ├── Types.ts ├── VersionsSidebar.tsx ├── __snapshots__ │ └── Info.test.tsx.snap ├── admin │ └── Users.tsx ├── components │ ├── BlockMenu.tsx │ ├── Button.tsx │ ├── ButtonGroup.tsx │ ├── Calendar.tsx │ ├── CodeBlock.tsx │ ├── ContentEditable.tsx │ ├── EditableInput.tsx │ ├── EmbeddedTextBlock.tsx │ ├── Header.tsx │ ├── ImageBlock.tsx │ ├── InfoSection.tsx │ ├── Input.tsx │ ├── Launcher.tsx │ ├── LibErrorBoundary.tsx │ ├── LibraryLauncher.tsx │ ├── List.tsx │ ├── ListItem.tsx │ ├── ListMenu.tsx │ ├── LoadingPlaceholder.tsx │ ├── MarkdownBlock.tsx │ ├── NavButton.tsx │ ├── Panel.tsx │ ├── PasswordConfirmation.tsx │ ├── PlainClipboard.tsx │ ├── Popup.tsx │ ├── QuillTextArea.tsx │ ├── RadioGroup.tsx │ ├── Select.tsx │ ├── SlideOver.tsx │ ├── SlideTransition.tsx │ ├── Spinner.tsx │ ├── Switch.tsx │ ├── Table.tsx │ ├── Tag.tsx │ ├── TextArea.tsx │ └── VersionsMenu.tsx ├── fetchData.test.js ├── focusModeChecks.ts ├── globals.css ├── jargon.ts ├── lib │ ├── cliches.ts │ ├── diff.tsx │ ├── fetchData.ts │ ├── hooks.ts │ └── languages.tsx ├── reducers │ └── librarySlice.ts ├── sidebars │ └── Sidebar.tsx ├── store.ts ├── utils.test.js └── utils.ts ├── sw.js ├── tailwind.config.cjs ├── tsconfig.json ├── webpack.config.cjs ├── wordnet ├── adj.exc ├── adv.exc ├── cntlist ├── cntlist.rev ├── cousin.exc ├── data.adj ├── data.adv ├── data.noun ├── data.verb ├── dbfiles │ ├── adj.all │ ├── adj.pert │ ├── adj.ppl │ ├── adv.all │ ├── cntlist │ ├── noun.Tops │ ├── noun.act │ ├── noun.animal │ ├── noun.artifact │ ├── noun.attribute │ ├── noun.body │ ├── noun.cognition │ ├── noun.communication │ ├── noun.event │ ├── noun.feeling │ ├── noun.food │ ├── noun.group │ ├── noun.location │ ├── noun.motive │ ├── noun.object │ ├── noun.person │ ├── noun.phenomenon │ ├── noun.plant │ ├── noun.possession │ ├── noun.process │ ├── noun.quantity │ ├── noun.relation │ ├── noun.shape │ ├── noun.state │ ├── noun.substance │ ├── noun.time │ ├── verb.Framestext │ ├── verb.body │ ├── verb.change │ ├── verb.cognition │ ├── verb.communication │ ├── verb.competition │ ├── verb.consumption │ ├── verb.contact │ ├── verb.creation │ ├── verb.emotion │ ├── verb.motion │ ├── verb.perception │ ├── verb.possession │ ├── verb.social │ ├── verb.stative │ └── verb.weather ├── index.adj ├── index.adv ├── index.noun ├── index.sense ├── index.verb ├── log.grind.3.1 ├── noun.exc ├── sentidx.vrb ├── sents.vrb ├── verb.Framestext └── verb.exc └── yarn.lock /.eslintrc.disable: -------------------------------------------------------------------------------- 1 | // Use this file as a starting point for your project's .eslintrc. 2 | // Copy this file, and add rule overrides as needed. 3 | { 4 | "ignorePatterns": ["node_modules/*", "dist/*", "tmp/*", "src/FocusMode.tsx"], 5 | "rules": { 6 | "no-unused-vars": "off", 7 | 8 | "import/extensions": "off", 9 | "import/no-unresolved": "off", 10 | "react/jsx-filename-extension": "off", 11 | /* "no-tabs": "off", */ 12 | "camelcase": "off", 13 | "no-param-reassign": "off", 14 | "@typescript-eslint/no-explicit-any": "off", 15 | "@typescript-eslint/no-unused-vars": "off", 16 | "@typescript-eslint/ban-ts-comment": "off", 17 | "no-use-before-define": "off", 18 | "react/require-default-props": "off", 19 | /* "eqeqeq": "off", */ 20 | "no-shadow": "off", 21 | "import/order": "off", 22 | "@typescript-eslint/no-empty-function": "off", 23 | "import/no-extraneous-dependencies": "off", 24 | "no-duplicate-case": "off", 25 | "no-case-declarations": "off", 26 | /* "consistent-return": "off", */ 27 | "react/jsx-props-no-spreading": "off", 28 | "react/style-prop-object": "off", 29 | 30 | "jsx-a11y/click-events-have-key-events": "off", 31 | "jsx-a11y/no-noninteractive-element-interactions": "off", 32 | "react/no-unescaped-entities": "off", 33 | "react/jsx-no-bind": "off", // JSX props should not use functions 34 | 35 | "max-classes-per-file": "off", 36 | 37 | "no-empty": "off", 38 | "jsx-a11y/no-static-element-interactions": "off", 39 | 40 | // leave these off 41 | "quotes": "off", 42 | "react/prop-types": "off", 43 | "no-console": "off", 44 | "max-len": "off", 45 | "no-underscore-dangle": "off", // _chapter 46 | "no-plusplus": "off", // i++ 47 | 48 | // gives too many false positives, ts checks this anyway 49 | "no-undef": "off", 50 | "comma-dangle": "off", 51 | //"no-trailing-spaces": "off", 52 | "react/no-array-index-key": "off" // Do not use Array index in keys 53 | }, 54 | "extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended", "airbnb"], 55 | "parser": "@typescript-eslint/parser", 56 | "plugins": ["@typescript-eslint"], 57 | "root": true 58 | } 59 | -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: ["main"] 9 | pull_request: 10 | branches: ["main"] 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | 16 | strategy: 17 | matrix: 18 | node-version: [18.x] 19 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 20 | 21 | steps: 22 | - uses: actions/checkout@v3 23 | - name: Use Node.js ${{ matrix.node-version }} 24 | uses: actions/setup-node@v3 25 | with: 26 | node-version: ${{ matrix.node-version }} 27 | cache: "yarn" 28 | - run: yarn 29 | #- run: NODE_OPTIONS=--experimental-vm-modules yarn test 30 | #- run: yarn lint 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | uploads/* 2 | .env 3 | node_modules/* 4 | dist/* 5 | makefile 6 | kustomization.yaml 7 | pod*.yaml 8 | serviceAccountKey.json 9 | settings.ts 10 | settings.js 11 | yarn.lock 12 | tmp/* 13 | yarn-error.log 14 | .idea/ 15 | secondaryBlocklist.js 16 | instantBlocklist.js 17 | *.DS_Store 18 | build/* 19 | app/blocklists/* 20 | app/config/* 21 | -------------------------------------------------------------------------------- /.husky.disable/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | yarn lint 5 | -------------------------------------------------------------------------------- /.prettierignore.disable: -------------------------------------------------------------------------------- 1 | build 2 | coverage 3 | -------------------------------------------------------------------------------- /.prettierrc.json.disable: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 80, 3 | "trailingComma": "none" 4 | } 5 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM --platform=linux/amd64 node:19-alpine 2 | # FROM --platform=linux/amd64 node:19 3 | 4 | WORKDIR / 5 | 6 | # RUN apt-get update 7 | # RUN apt-get install -y ffmpeg 8 | 9 | RUN apk update 10 | RUN apk add ffmpeg git curl 11 | RUN rm -rf /var/cache/apk/* 12 | # Install dependencies based on the preferred package manager 13 | COPY . ./ 14 | ENV NODE_ENV production 15 | 16 | # This works but is ~5 min slower than just copying node_modules 17 | # if you use it, you need to add node_modules to the .dockerignore file 18 | # RUN yarn install --production=true 19 | 20 | # see size using 21 | # docker images 22 | 23 | # see size by layer using 24 | # docker history --human --format "{{.CreatedBy}}: {{.Size}}" 25 | 26 | EXPOSE 80 27 | 28 | ENV PORT 80 29 | ENV HOST 0.0.0.0 30 | 31 | CMD ["yarn", "start"] -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Chisel 2 | 3 | Chisel is an open source writing app. It comes with whisper.cpp and llama.cpp built in, so you can run AI models locally. You can also use the OpenAI API with an API key. All data stays local and private. Here is a quick demo video: 4 | 5 | https://youtu.be/ZLQ5yUOumHo 6 | 7 | To get Chisel, just download the latest version from [releases](https://github.com/egonSchiele/chisel/releases/tag/v0.3.1). 8 | 9 | Chisel currently only works on Macs with the `arm64` architecture. If you have an M1 chip, the app will work for you. 10 | 11 | If you're not sure, here's how to check: Click on the Apple icon on the top left, click on About This Mac, and then see what it says for chip. Mine says "Apple M1 Max". 12 | 13 | To run a model locally, you will need a model in the ggml format. You can look for models in these places: 14 | 15 | - https://github.com/ggerganov/llama.cpp 16 | - http://reddit.com/r/localllama 17 | 18 | ## Chisel may be for you if... 19 | - You're writing a book and want a free alternative to Scrivener. 20 | - You'd like to try using AI to help edit your work. 21 | - You want better speech-to-text results than what Apple has built in. 22 | - You have wanted to organize your chapters into blocks. 23 | 24 | ## Thanks to 25 | Chisel relies on a lot of open source projects, including: 26 | 27 | - Quill 28 | - Electron 29 | - llama.cpp 30 | - whisper.cpp 31 | 32 | A big thanks to the developers on these projects for making Chisel possible. 33 | -------------------------------------------------------------------------------- /TESTS.md: -------------------------------------------------------------------------------- 1 | # Integration tests 2 | - Create, rename, and delete a book: BookList 3 | - Create, rename, and delete a chapter: ChapterList 4 | - Edit a chapter's title and text, test auto-save: Editor 5 | - Edit a chapter's text, save, switch to another chapter, then switch back. Your edits should be there (issue #7): EditAndSwitch 6 | - Should be able to go to grid mode and go back: GridMode 7 | - Can hide and show prompts and sidebar panels using their icons. Pressing escape hides and shows the user interface. Pressing command shift P hides and shows the launcher: UI 8 | - Logging in works: Login 9 | - Home page loads: Home 10 | - Manually saving should create a new history element. Editing and saving again should create another element with a diff. Clicking the older element should restore that state, but not create another entry in the history: History 11 | - editing a prompt in settings: Settings 12 | - full screen mode: Fullscreen 13 | - Deleting a chapter/book updates UI: DeleteBook 14 | - creating blocks, diffing blocks: Blocks 15 | - Test the launcher: Blocks 16 | - Read only mode: Blocks 17 | - Clicking a prompt fetches ai suggestions: Prompts 18 | ## TODO 19 | - Reordering chapters 20 | - Selecting a characters name in the editor shows them in the info panel. 21 | - Chapters show up on front matter. 22 | - Reference blocks. 23 | - restoring history with multiple blocks 24 | - full screen mode history. 25 | - merging blocks: commented out in Blocks -------------------------------------------------------------------------------- /TODOS.md: -------------------------------------------------------------------------------- 1 | ## P2 2 | - add to launcher: rename book and chapter, reorder chapters 3 | - need to clear the cached selected data at some point, maybe once the user starts typing again. 4 | - imports 5 | - move chapter to another book 6 | - cache synonyms 7 | - Fix loading flash 8 | - tags 9 | - Ability to star history. 10 | - bookmark sections of a chapter? or highlights? 11 | - built-in writing streak 12 | - error handling w boundaries? Currently they show in console 13 | - When adding a book and chapter, the process is slow. For the short term change the + button to a loading spinner so people don’t double click it 14 | - by double clicking on the book, it will bring up the modal to change the name. 15 | - use new dnd library for grid 16 | - should be able to change settings without a chapter selected 17 | - add prompts functionality back to launcher 18 | 19 | ## P3 20 | 21 | - accessibility audit 22 | - ask ai to outline entire book 23 | - download all data 24 | - offline mode 25 | - drag and drop should only work for text files 26 | - support other writing formats: comics, screenplays 27 | - translate to other languages 28 | - add whisper for speech-to-text 29 | - allow users to change font, font size 30 | - history is rendering multiple times 31 | 32 | - Use advice from Steven pinker book. 33 | - prompt library 34 | - tables? https://jspreadsheets.com 35 | - switrch grid mode to redux 36 | - show err messages instead of logging to console 37 | - reenable eslint 38 | - listitem dropdown is partly hidden (overflow-hidden?) 39 | - annotations: highlights, comments 40 | - select chapters (hold shift to select multiple) then drag to move 41 | 42 | 43 | - I have sort of a dumb feature that I've always wanted but I don't know enough about writing editors coding to build it. I use Scrivener these days for writing, and I have a Characters tab and a Places/Locations tab. Basically a lot of world-building things. What I'd love is that if I could hover over the character/location name, it would provide a hover pop up or clickable link that would take me to that character. The reason is that I'll create a character with red hair or a Boston accent, and then later I'll write them into a scene and not remember which one they had, and so I have to go searching for it. Would be cool if it could just automatically detect that i'm talking about that character and then link to them. 44 | -------------------------------------------------------------------------------- /__mocks__/mocks.ts: -------------------------------------------------------------------------------- 1 | import { Chapter } from "../src/Types"; 2 | import * as t from "../src/Types"; 3 | 4 | export const mockBook = { 5 | chapters: [ 6 | { 7 | pos: { 8 | x: 0, 9 | y: 0, 10 | }, 11 | chapterid: "chapter_1", 12 | created_at: 1681477740005, 13 | suggestions: [ 14 | { 15 | contents: "Hi there", 16 | type: "Expand", 17 | }, 18 | ], 19 | text: [t.markdownBlock("A man moves to San Francisco for a new job.\n")], 20 | title: "New job", 21 | bookid: "book_1", 22 | }, 23 | { 24 | pos: { 25 | x: 0, 26 | y: 0, 27 | }, 28 | chapterid: "chapter_2", 29 | created_at: 1681583480568, 30 | suggestions: [], 31 | text: [t.markdownBlock("hi there\n\n")], 32 | title: "new chapter fresh from the oven", 33 | favorite: false, 34 | bookid: "book_1", 35 | }, 36 | ], 37 | author: "Unknown", 38 | created_at: 1681583726786, 39 | rowHeadings: ["", "", "", "", "", "", "", "", "", "", "", ""], 40 | columnHeadings: ["", "", "", "", "", "", "", "", "", "", "", ""], 41 | title: "Test story", 42 | chapterOrder: ["chapter_1", "chapter_2"], 43 | userid: "user_1", 44 | bookid: "book_1", 45 | }; 46 | 47 | export const chapter1: Chapter = { 48 | bookid: "book_1", 49 | chapterid: "chapter_1", 50 | title: "New job", 51 | text: [t.markdownBlock("A man moves to San Francisco for a new job.\n")], 52 | pos: { 53 | x: 0, 54 | y: 0, 55 | }, 56 | suggestions: [{ type: "expand", contents: "Hi there" }], 57 | favorite: false, 58 | }; 59 | 60 | export const chapter2: Chapter = { 61 | bookid: "book_1", 62 | chapterid: "chapter_2", 63 | title: "new chapter fresh from the oven", 64 | text: [t.markdownBlock("hi there\n\n")], 65 | pos: { 66 | x: 0, 67 | y: 0, 68 | }, 69 | suggestions: [], 70 | favorite: false, 71 | }; 72 | -------------------------------------------------------------------------------- /admin.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as ReactDOM from "react-dom/client"; 3 | import { BrowserRouter } from "react-router-dom"; 4 | import App from "./src/App"; 5 | import Users from "./src/admin/Users"; 6 | import "./src/globals.css"; 7 | 8 | const domNode = document.getElementById("root"); 9 | const root = ReactDOM.createRoot(domNode); 10 | root.render( 11 | 12 | 13 | , 14 | ); 15 | -------------------------------------------------------------------------------- /app/lib/chat/getEmbeddings.ts: -------------------------------------------------------------------------------- 1 | import settings from "../../config/settings.js"; 2 | import { substringTokens } from "../serverUtils.js"; 3 | import { failure, success } from "../utils.js"; 4 | import { updateUsage } from "./updateUsage.js"; 5 | 6 | export async function getEmbeddings(user, _text) { 7 | // TODO make other AI parts work with custom key. 8 | if (!user.admin) { 9 | return failure("Embeddings currently disabled."); 10 | } 11 | /* const check = checkUsage(user); 12 | if (!check.success === true) { 13 | return check; 14 | } */ 15 | 16 | const input = substringTokens(_text, settings.maxPromptLength); 17 | 18 | const endpoint = "https://api.openai.com/v1/embeddings"; 19 | const reqBody = { 20 | input, 21 | model: "text-embedding-ada-002", 22 | }; 23 | 24 | const res = await fetch(endpoint, { 25 | method: "POST", 26 | headers: { 27 | "Content-Type": "application/json", 28 | Authorization: `Bearer ${settings.openAiApiKey}`, 29 | }, 30 | body: JSON.stringify(reqBody), 31 | }); 32 | const json = await res.json(); 33 | console.log({ json }); 34 | if (json.error) { 35 | return failure(json.error.message); 36 | } 37 | // TODO: call updatePermissionLimit here too 38 | await updateUsage(user, json.usage); 39 | 40 | if (json.data) { 41 | const embeddings = json.data[0].embedding; 42 | return success(embeddings); 43 | } 44 | return failure("no data for embeddings"); 45 | } 46 | -------------------------------------------------------------------------------- /app/lib/chat/getSuggestionsJSON.ts: -------------------------------------------------------------------------------- 1 | import { failure, success } from "../utils.js"; 2 | import { getSuggestions } from "./getSuggestions.js"; 3 | 4 | export async function getSuggestionsJSON( 5 | user, 6 | _prompt, 7 | max_tokens, 8 | model, 9 | num_suggestions, 10 | schema, 11 | retries = 3 12 | ) { 13 | const prompt = `Please respond ONLY with valid json that conforms to this schema: ${schema}. Do not include additional text other than the object json as we will parse this object with JSON.parse. If you do not respond with valid json, we will ask you to try again. Prompt: ${_prompt}`; 14 | 15 | const messages = [{ role: "user", content: prompt }]; 16 | let tries = 0; 17 | let text; 18 | while (tries < retries) { 19 | const suggestions = await getSuggestions( 20 | user, 21 | "", 22 | max_tokens, 23 | model, 24 | num_suggestions, 25 | messages, 26 | null 27 | ); 28 | if (suggestions.success === true) { 29 | text = suggestions.data.choices[0].text; 30 | try { 31 | const json = JSON.parse(text); 32 | return success(json); 33 | } catch (e) { 34 | console.log(e); 35 | tries += 1; 36 | messages.push({ 37 | role: "system", 38 | content: `JSON.parse error: ${e.message}`, 39 | }); 40 | } 41 | } else { 42 | return suggestions; 43 | //tries += 1; 44 | } 45 | } 46 | return failure(text); 47 | } 48 | -------------------------------------------------------------------------------- /app/lib/chat/integrations/huggingFace.ts: -------------------------------------------------------------------------------- 1 | import { HfInference } from "@huggingface/inference"; 2 | import settings from "../../../config/settings.js"; 3 | import { failure, sanitize, success } from "../../utils.js"; 4 | 5 | export async function usingHuggingFace( 6 | user, 7 | prompt, 8 | max_tokens = 500, 9 | _model = "vicuna-13b", 10 | num_suggestions = 1, 11 | _messages = null, 12 | customKey 13 | ) { 14 | if (!user.admin) { 15 | return failure("sorry, only admins can use huggingface models"); 16 | } 17 | /* const models = { 18 | "vicuna-13b": 19 | "replicate/vicuna-13b:6282abe6a492de4145d7bb601023762212f9ddbbe78278bd6771c8b3b2f2a13b", 20 | "llama-7b": 21 | "replicate/llama-7b:ac808388e2e9d8ed35a5bf2eaa7d83f0ad53f9e3df31a42e4eb0a0c3249b3165", 22 | "stablelm-tuned-alpha-7b": 23 | "stability-ai/stablelm-tuned-alpha-7b:c49dae362cbaecd2ceabb5bd34fdb68413c4ff775111fea065d259d577757beb", 24 | "flan-t5-xl": 25 | "replicate/flan-t5-xl:7a216605843d87f5426a10d2cc6940485a232336ed04d655ef86b91e020e9210", 26 | }; 27 | */ const model = "gpt2"; 28 | 29 | if (!model) { 30 | return failure(`invalid model ${_model}`); 31 | } 32 | 33 | const input = { 34 | prompt, 35 | }; 36 | 37 | const inference = new HfInference(settings.huggingFaceApiKey); 38 | 39 | const output = await inference.textGeneration({ 40 | model, 41 | inputs: prompt, 42 | }); 43 | 44 | console.log(output); 45 | return success({ choices: [{ text: output.generated_text }], usage: 0 }); 46 | } 47 | -------------------------------------------------------------------------------- /app/lib/chat/integrations/local.ts: -------------------------------------------------------------------------------- 1 | import settings from "../../../config/settings.js"; 2 | import { failure, sanitize, success } from "../../utils.js"; 3 | 4 | export async function usingLocalAi( 5 | user, 6 | prompt, 7 | max_tokens = 500, 8 | model = "ggml-gpt4all-j", 9 | num_suggestions = 1, 10 | _messages = null, 11 | customKey 12 | ) { 13 | if (!user.admin) { 14 | return failure("sorry, only admins can use localai models"); 15 | } 16 | 17 | console.log("localai", settings.localAiEndpoint, prompt); 18 | const input = { 19 | model, 20 | prompt, 21 | temperature: 0.9, 22 | }; 23 | const body = JSON.stringify(input); 24 | console.log(body); 25 | try { 26 | const output = await fetch(settings.localAiEndpoint, { 27 | method: "POST", 28 | body, 29 | headers: { "Content-Type": "application/json" }, 30 | }); 31 | console.log(output); 32 | 33 | const json = await output.json(); 34 | 35 | console.log({ json }); 36 | 37 | if (json.error) { 38 | return failure(json.error.message); 39 | } 40 | 41 | const choices = json.choices.map((choice) => ({ 42 | text: choice.message.content, 43 | })); 44 | 45 | return success({ choices, usage: 0 }); 46 | } catch (e) { 47 | console.log(e); 48 | return failure(e.message); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /app/lib/chat/integrations/openai.ts: -------------------------------------------------------------------------------- 1 | import settings from "../../../config/settings.js"; 2 | import { failure, sanitize, success } from "../../utils.js"; 3 | 4 | export async function usingOpenAi( 5 | user, 6 | _prompt, 7 | max_tokens = 500, 8 | model = "gpt-3.5-turbo", 9 | num_suggestions = 1, 10 | _messages = [], 11 | customKey 12 | ) { 13 | const chatModels = ["gpt-3.5-turbo", "gpt-3.5-turbo-16k"]; 14 | let endpoint = "https://api.openai.com/v1/completions"; 15 | 16 | const prompt = sanitize(_prompt); 17 | console.log({ prompt }); 18 | let reqBody = { 19 | prompt, 20 | max_tokens, 21 | model, 22 | n: num_suggestions, 23 | }; 24 | if (chatModels.includes(model)) { 25 | endpoint = "https://api.openai.com/v1/chat/completions"; 26 | 27 | let messages = _messages; 28 | if (messages === null) messages = []; 29 | messages.push({ role: "user", content: prompt }); 30 | 31 | reqBody = { 32 | // @ts-ignore 33 | messages, 34 | max_tokens, 35 | model, 36 | n: num_suggestions, 37 | }; 38 | } 39 | 40 | console.log(JSON.stringify(reqBody)); 41 | const bearerKey = customKey || settings.openAiApiKey; 42 | 43 | const res = await fetch(endpoint, { 44 | method: "POST", 45 | headers: { 46 | "Content-Type": "application/json", 47 | Authorization: `Bearer ${bearerKey}`, 48 | }, 49 | body: JSON.stringify(reqBody), 50 | }); 51 | const json = await res.json(); 52 | 53 | console.log({ json }); 54 | 55 | if (json.error) { 56 | return failure(json.error.message); 57 | } 58 | 59 | let choices; 60 | if (chatModels.includes(model)) { 61 | choices = json.choices.map((choice) => ({ 62 | text: choice.message.content, 63 | })); 64 | } else { 65 | choices = json.choices.map((choice) => ({ text: choice.text })); 66 | } 67 | return success({ choices, usage: json.usage }); 68 | } 69 | -------------------------------------------------------------------------------- /app/lib/chat/integrations/replicate.ts: -------------------------------------------------------------------------------- 1 | import Replicate from "replicate"; 2 | 3 | import settings from "../../../config/settings.js"; 4 | import { failure, success } from "../../utils.js"; 5 | 6 | const replicate = new Replicate({ 7 | auth: settings.replicateApiKey, 8 | }); 9 | 10 | export async function usingReplicate( 11 | user, 12 | prompt, 13 | max_tokens = 500, 14 | _model = "vicuna-13b", 15 | num_suggestions = 1, 16 | _messages = null, 17 | customKey 18 | ) { 19 | if (!user.admin) { 20 | return failure("sorry, only admins can use replicate models"); 21 | } 22 | const models = { 23 | "vicuna-13b": 24 | "replicate/vicuna-13b:6282abe6a492de4145d7bb601023762212f9ddbbe78278bd6771c8b3b2f2a13b", 25 | "llama-7b": 26 | "replicate/llama-7b:ac808388e2e9d8ed35a5bf2eaa7d83f0ad53f9e3df31a42e4eb0a0c3249b3165", 27 | "stablelm-tuned-alpha-7b": 28 | "stability-ai/stablelm-tuned-alpha-7b:c49dae362cbaecd2ceabb5bd34fdb68413c4ff775111fea065d259d577757beb", 29 | "flan-t5-xl": 30 | "replicate/flan-t5-xl:7a216605843d87f5426a10d2cc6940485a232336ed04d655ef86b91e020e9210", 31 | }; 32 | const model = models[_model]; 33 | 34 | if (!model) { 35 | return failure(`invalid model ${_model}`); 36 | } 37 | 38 | const input = { 39 | prompt, 40 | }; 41 | const _output = await replicate.run(model, { input }); 42 | const output = _output as unknown as string[]; 43 | console.log(output); 44 | return success({ choices: [{ text: output.join("") }], usage: 0 }); 45 | } 46 | -------------------------------------------------------------------------------- /app/lib/chat/updateUsage.ts: -------------------------------------------------------------------------------- 1 | import { saveUser } from "../authentication/firebase.js"; 2 | 3 | export async function updateUsage(user, usage) { 4 | if (!user || !user.usage) { 5 | console.log("no user or user.usage in updateUsage", { user }); 6 | return; 7 | } 8 | user.usage.openai_api.tokens.month.prompt += usage.prompt_tokens || 0; 9 | user.usage.openai_api.tokens.month.completion += usage.completion_tokens || 0; 10 | 11 | user.usage.openai_api.tokens.total.prompt += usage.prompt_tokens || 0; 12 | user.usage.openai_api.tokens.total.completion += usage.completion_tokens || 0; 13 | 14 | // TODO use real lastHeardFromServer time here 15 | await saveUser(user); 16 | } 17 | -------------------------------------------------------------------------------- /app/lib/common/serveLibrary.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import { getUserId } from "../authentication/firebase.js"; 3 | import { isMobile } from "../utils.js"; 4 | import { serveFile } from "../utils/serveFile.js"; 5 | 6 | export default function serveLibrary(req: Request, res: Response) { 7 | const userid = getUserId(req); 8 | if (isMobile(req)) { 9 | serveFile("mobile.html", res, userid); 10 | } else { 11 | serveFile("library.html", res, userid); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /app/lib/storage/firebase.test.js: -------------------------------------------------------------------------------- 1 | /* import { jest } from "@jest/globals"; 2 | 3 | import { getFirestore } from "firebase-admin/firestore"; 4 | import { 5 | MockFirestore, 6 | MockFirebaseMethodError, 7 | } from "../../__mocks__/MockFirestore"; 8 | import { getBook } from "./firebase"; 9 | 10 | jest.mock("firebase-admin/firestore", () => { 11 | const originalModule = jest.requireActual("firebase-admin/firestore"); 12 | 13 | const mockFirestore = jest.requireActual("../MockFirestore"); 14 | const options = { 15 | allowedMethods: ["get", "updateOrderStatus", "logEmailSent"], 16 | }; 17 | const db = new mockFirestore.MockFirestore(options); 18 | //Mock the default export and named export 'foo' 19 | return { 20 | __esModule: true, 21 | ...originalModule, 22 | getFirestore: jest.fn(() => { 23 | return db; 24 | }), 25 | }; 26 | }); 27 | 28 | describe("getBook", () => { 29 | it("should return a book", async () => { 30 | const book = await getBook("123"); 31 | expect(book).toEqual({ 32 | id: "123", 33 | title: "foo", 34 | }); 35 | }); 36 | }); 37 | */ 38 | 39 | it("passes", () => { 40 | expect(true).toBe(true); 41 | }); 42 | -------------------------------------------------------------------------------- /app/lib/storage/s3.js: -------------------------------------------------------------------------------- 1 | import { success, failure } from "../utils.js"; 2 | 3 | import { 4 | GetObjectCommand, 5 | PutObjectCommand, 6 | S3Client, 7 | } from "@aws-sdk/client-s3"; 8 | import settings from "../../config/settings.js"; 9 | 10 | const region = "us-west-2"; 11 | const bucket = settings.awsBucket; 12 | const credentials = { 13 | accessKeyId: settings.awsAccessKeyId, 14 | secretAccessKey: settings.awsSecretAccessKey, 15 | }; 16 | const s3 = new S3Client({ region, credentials }); 17 | 18 | export const getFromS3 = async (s3key) => { 19 | const params = { 20 | Bucket: settings.awsBucket, 21 | Key: s3key, 22 | }; 23 | try { 24 | const data = await s3.send(new GetObjectCommand(params)); 25 | 26 | const res = await streamToString(data.Body); 27 | return success(res); 28 | } catch (error) { 29 | console.log("error getting from s3", params); 30 | console.log(error); 31 | return failure(error); 32 | } 33 | }; 34 | 35 | export const uploadToS3 = async (s3key, data) => { 36 | const params = { 37 | Bucket: settings.awsBucket, 38 | Key: s3key, 39 | Body: data, 40 | }; 41 | 42 | try { 43 | const data = await s3.send(new PutObjectCommand(params)); 44 | return success(); 45 | } catch (error) { 46 | console.log("error putting to s3", params); 47 | console.log(error); 48 | return failure(error); 49 | } 50 | }; 51 | 52 | export async function streamToString(stream) { 53 | return await new Promise((resolve, reject) => { 54 | try { 55 | const chunks = []; 56 | stream.on("data", (chunk) => chunks.push(chunk)); 57 | stream.on("error", reject); 58 | stream.on("end", () => { 59 | resolve(Buffer.concat(chunks)); 60 | }); 61 | } catch (error) { 62 | console.log({ error }); 63 | return Promise.reject("streamToString failed"); 64 | } 65 | }); 66 | } 67 | -------------------------------------------------------------------------------- /app/lib/types.ts: -------------------------------------------------------------------------------- 1 | export type UpdateData = { 2 | eventName: string; 3 | data: any; 4 | }; 5 | 6 | export type SuccessResult = { 7 | success: true; 8 | data?: any; 9 | }; 10 | 11 | export type FailureResult = { 12 | success: false; 13 | message: string; 14 | }; 15 | 16 | export type Result = SuccessResult | FailureResult; 17 | 18 | export type SpeechData = { 19 | s3key: string; 20 | userid: string; 21 | created_at: number; 22 | }; 23 | -------------------------------------------------------------------------------- /app/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import blocklist from "../blocklists/blocklist.js"; 2 | import secondaryBlocklist from "../blocklists/secondaryBlocklist.js"; 3 | import { Request, Response } from "express"; 4 | import browser from "browser-detect"; 5 | import util from "util"; 6 | import fs from "fs"; 7 | import { exec, execSync } from "child_process"; 8 | import { FailureResult, SuccessResult } from "./types.js"; 9 | 10 | export function isMobile(req: Request) { 11 | return browser(req.headers["user-agent"]).mobile; 12 | } 13 | 14 | export const writeFileAwait = util.promisify(fs.writeFile); 15 | const execAwait = util.promisify(exec); 16 | 17 | export async function run(cmd: string): Promise { 18 | console.log(`$ ${cmd}`); 19 | const resp = await execAwait(cmd); 20 | 21 | // @ts-ignore 22 | return resp.stdout?.toString("UTF8"); 23 | } 24 | 25 | export async function getAudioDuration(filename: string): Promise { 26 | const resp = await run( 27 | `ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 ${filename}` 28 | ); 29 | 30 | return parseFloat(resp) || 0; 31 | } 32 | 33 | export function success(data: any = {}): SuccessResult { 34 | return { success: true, data }; 35 | } 36 | 37 | export function failure(message: string = ""): FailureResult { 38 | return { success: false, message }; 39 | } 40 | 41 | export function sanitize(str) { 42 | return str 43 | .split(" ") 44 | .map((_word) => { 45 | const word = _word.trim().replaceAll(/[^a-zA-Z0-9]/g, ""); 46 | if ( 47 | blocklist.includes(word.toLowerCase()) || 48 | secondaryBlocklist.includes(word.toLowerCase()) 49 | ) { 50 | return "****"; 51 | } else { 52 | return _word; 53 | } 54 | }) 55 | .join(" "); 56 | } 57 | 58 | export function stripPrefix(str: string, prefix: string) { 59 | if (str.startsWith(prefix)) { 60 | return str.slice(prefix.length); 61 | } 62 | return str; 63 | } 64 | -------------------------------------------------------------------------------- /app/lib/utils/crypto.ts: -------------------------------------------------------------------------------- 1 | import settings from "../../config/settings.js"; 2 | export async function stringToHash(str) { 3 | const encoder = new TextEncoder(); 4 | const salt = settings.tokenSalt; 5 | const data = encoder.encode(str + salt); 6 | const hash = await crypto.subtle.digest("SHA-256", data); 7 | return Array.from(new Uint8Array(hash)) 8 | .map((b) => b.toString(16).padStart(2, "0")) 9 | .join(""); 10 | } 11 | -------------------------------------------------------------------------------- /app/lib/utils/middleware.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import { getBookToCheckAccess, getChapter } from "../storage/firebase.js"; 3 | export const noCache = (req: Request, res: Response, next) => { 4 | // res.setHeader("Surrogate-Control", "no-store"); 5 | res.setHeader( 6 | "Cache-Control", 7 | "no-store, no-cache, must-revalidate, proxy-revalidate" 8 | ); 9 | res.setHeader("Pragma", "no-cache"); 10 | res.setHeader("Expires", "0"); 11 | next(); 12 | }; 13 | 14 | export const allowAutoplay = (req: Request, res: Response, next) => { 15 | res.setHeader("Permissions-Policy", "autoplay=(self)"); 16 | next(); 17 | }; 18 | -------------------------------------------------------------------------------- /app/lib/utils/middleware/checkBookAccess.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import { getBookToCheckAccess } from "../../storage/firebase.js"; 3 | 4 | const bookAccessCache = {}; 5 | // eslint-disable-next-line consistent-return 6 | export const checkBookAccess = async (req: Request, res: Response, next) => { 7 | const c = req.cookies; 8 | 9 | let bookid; 10 | if (req.body) { 11 | bookid = req.body.bookid; 12 | } 13 | bookid = bookid || req.params.bookid; 14 | 15 | if (!bookid) { 16 | console.log("no bookid"); 17 | return res.redirect("/404"); 18 | } 19 | 20 | const { userid } = c; 21 | if (!userid) { 22 | console.log("no userid"); 23 | return res.redirect("/404"); 24 | } 25 | 26 | const key = `${userid}-${bookid}`; 27 | if (bookAccessCache[key]) { 28 | res.locals.bookid = bookid; 29 | next(); 30 | return; 31 | } 32 | 33 | const book = await getBookToCheckAccess(bookid); 34 | 35 | if (!book) { 36 | console.log(`no book with id, ${bookid}`); 37 | res.redirect("/404"); 38 | } else if (book.userid !== c.userid) { 39 | console.log("no access to book"); 40 | res.redirect("/404"); 41 | } else { 42 | bookAccessCache[key] = true; 43 | res.locals.bookid = bookid; 44 | next(); 45 | } 46 | }; 47 | -------------------------------------------------------------------------------- /app/lib/utils/middleware/checkChapterAccess.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import { getChapter } from "../../storage/firebase.js"; 3 | 4 | const chapterAccessCache = {}; 5 | 6 | export const checkChapterAccess = async (req: Request, res: Response, next) => { 7 | const c = req.cookies; 8 | 9 | let bookid; 10 | let chapterid; 11 | if (req.body) { 12 | bookid = req.body.bookid; 13 | chapterid = req.body.chapterid; 14 | } 15 | bookid = bookid || req.params.bookid; 16 | chapterid = chapterid || req.params.chapterid; 17 | 18 | if (!bookid || !chapterid) { 19 | console.log("no bookid or chapterid"); 20 | res.redirect("/404"); 21 | } 22 | const { userid } = c; 23 | const key = `${userid}-${bookid}-${chapterid}`; 24 | if (chapterAccessCache[key]) { 25 | res.locals.chapterid = chapterid; 26 | next(); 27 | return; 28 | } 29 | 30 | const chapter = await getChapter(chapterid); 31 | 32 | if (!chapter) { 33 | console.log(`no chapter with id, ${chapterid}`); 34 | res.redirect("/404"); 35 | } else if (chapter.bookid !== bookid) { 36 | console.log("chapter is not part of book", chapterid, bookid); 37 | res.redirect("/404"); 38 | } else { 39 | chapterAccessCache[key] = true; 40 | res.locals.chapterid = chapterid; 41 | next(); 42 | } 43 | }; 44 | -------------------------------------------------------------------------------- /app/lib/utils/middleware/csrf.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | export const csrf = (req: Request, res: Response, next) => { 3 | //return next(); 4 | if (req.method !== "GET") { 5 | const excluded = [ 6 | "/auth/login", 7 | "/auth/register", 8 | "/auth/loginGuestUser", 9 | "/api/audio/upload", 10 | "/api/file/upload", 11 | ]; 12 | if (excluded.includes(req.url)) { 13 | next(); 14 | return; 15 | } 16 | const c = req.cookies; 17 | if (c.csrfToken === req.body.csrfToken) { 18 | next(); 19 | } else { 20 | console.log( 21 | "csrf failed", 22 | req.url, 23 | req.method, 24 | c.csrfToken, 25 | req.body.csrfToken 26 | ); 27 | res 28 | .status(400) 29 | .send("Could not butter your parsnips. Try refreshing your browser.") 30 | .end(); 31 | } 32 | } else { 33 | next(); 34 | } 35 | }; 36 | -------------------------------------------------------------------------------- /app/lib/utils/middleware/requireAdmin.ts: -------------------------------------------------------------------------------- 1 | import { getUser } from "../../authentication/firebase.js"; 2 | import { stringToHash } from "../crypto.js"; 3 | 4 | const mainPageUrl = 5 | process.env.NODE_ENV === "production" 6 | ? "https://egonschiele.github.io/chisel-docs/" 7 | : "/login.html"; 8 | 9 | export const requireAdmin = (req, res, next) => { 10 | const c = req.cookies; 11 | /* console.log({ req }); 12 | */ 13 | if (!req.cookies.userid) { 14 | console.log("no userid"); 15 | res.redirect(mainPageUrl); 16 | } else { 17 | stringToHash(req.cookies.userid).then(async (hash) => { 18 | if (hash !== req.cookies.token) { 19 | res.redirect(mainPageUrl); 20 | } else { 21 | const user = await getUser(req); 22 | if (!user.admin) { 23 | res.redirect(mainPageUrl); 24 | } else { 25 | next(); 26 | } 27 | } 28 | }); 29 | } 30 | }; 31 | -------------------------------------------------------------------------------- /app/lib/utils/middleware/requireLogin.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import { stringToHash } from "../crypto.js"; 3 | 4 | const mainPageUrl = 5 | process.env.NODE_ENV === "production" 6 | ? "https://egonschiele.github.io/chisel-docs/" 7 | : "/login.html"; 8 | export const requireLogin = (req: Request, res: Response, next) => { 9 | const c = req.cookies; 10 | if (!req.cookies.userid) { 11 | console.log("no userid"); 12 | res.redirect(mainPageUrl); 13 | } else { 14 | stringToHash(req.cookies.userid).then((hash) => { 15 | if (hash !== req.cookies.token) { 16 | res.redirect(mainPageUrl); 17 | } else { 18 | next(); 19 | } 20 | }); 21 | } 22 | }; 23 | -------------------------------------------------------------------------------- /app/lib/utils/processDir.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import { stripPrefix } from "../utils.js"; 3 | import path from "path"; 4 | 5 | export function processDir( 6 | app, 7 | dir: string, 8 | prefixToStrip: string, 9 | method: "GET" | "POST" | "PUT" | "DELETE" 10 | ) { 11 | fs.readdir(dir, (readFileErr, files: string[]) => { 12 | if (!files) { 13 | console.log("No files found in", dir); 14 | return; 15 | } 16 | files.forEach(async (file) => { 17 | const requirePath = path.join(dir, file); 18 | const stats = fs.statSync(requirePath); 19 | if (stats.isDirectory()) { 20 | processDir(app, requirePath, prefixToStrip, method); 21 | return; 22 | } 23 | 24 | let routeName = stripPrefix(requirePath, prefixToStrip).replace( 25 | /.js$/, 26 | "" 27 | ); 28 | 29 | if (routeName === "/index") routeName = "/"; 30 | 31 | const routeComponents = routeName.split("/"); 32 | const routeComponentsWithParams = routeComponents.map((component) => { 33 | if (component.startsWith("[") && component.endsWith("]")) { 34 | return ":" + component.slice(1, -1); 35 | } 36 | return component; 37 | }); 38 | routeName = routeComponentsWithParams.join("/"); 39 | 40 | const m = await import("../../../" + requirePath); 41 | 42 | if (m.disabled) { 43 | console.log("DISABLED", method, routeName, "->", requirePath); 44 | } else { 45 | console.log(method, routeName, "->", requirePath); 46 | if (m.middleware) { 47 | app[method.toLowerCase()](routeName, ...m.middleware, m.default); 48 | } else { 49 | app[method.toLowerCase()](routeName, m.default); 50 | } 51 | } 52 | }); 53 | }); 54 | } 55 | -------------------------------------------------------------------------------- /app/lib/utils/serveFile.ts: -------------------------------------------------------------------------------- 1 | import { nanoid } from "nanoid"; 2 | import path from "path"; 3 | import fs from "fs"; 4 | import handlebars from "handlebars"; 5 | import * as SE from "../storage/storageEngine.js"; 6 | 7 | const fileCache = {}; 8 | const render = (filename, _data) => { 9 | let template; 10 | const data = { ..._data }; 11 | if (!fileCache[filename]) { 12 | const source = fs.readFileSync(filename, { encoding: "utf8", flag: "r" }); 13 | template = handlebars.compile(source); 14 | fileCache[filename] = template; 15 | } else { 16 | template = fileCache[filename]; 17 | } 18 | const result = template(data); 19 | 20 | return result; 21 | }; 22 | 23 | const csrfTokenCache = {}; 24 | export function serveFile(filename, res, userid) { 25 | let token; 26 | 27 | if (userid && csrfTokenCache[userid]) { 28 | token = csrfTokenCache[userid]; 29 | } else { 30 | token = nanoid(); 31 | if (userid) csrfTokenCache[userid] = token; 32 | } 33 | const lastEdited = SE.getLastEdited(userid); 34 | res.cookie("csrfToken", token); 35 | console.log(`serving ${filename}`); 36 | const rendered = render(path.resolve(`./dist/pages/${filename}`), { 37 | csrfToken: token, 38 | lastEdited, 39 | }); 40 | res.send(rendered).end(); 41 | } 42 | -------------------------------------------------------------------------------- /app/routes/DELETE/api/book/[bookid].ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import { deleteBook } from "../../../../lib/storage/firebase.js"; 3 | import { UpdateData } from "../../../../lib/types.js"; 4 | import * as SE from "../../../../lib/storage/storageEngine.js"; 5 | import { checkBookAccess } from "../../../../lib/utils/middleware/checkBookAccess.js"; 6 | import { requireLogin } from "../../../../lib/utils/middleware/requireLogin.js"; 7 | 8 | export const middleware = [requireLogin, checkBookAccess]; 9 | 10 | export default async (req: Request, res: Response) => { 11 | const { bookid } = req.params; 12 | const lastHeardFromServer = req.cookies.lastHeardFromServer; 13 | const updateData: UpdateData = { 14 | eventName: "bookDelete", 15 | data: { bookid }, 16 | }; 17 | SE.save(req, res, updateData, async () => { 18 | return await deleteBook(bookid, lastHeardFromServer); 19 | }); 20 | }; 21 | -------------------------------------------------------------------------------- /app/routes/DELETE/api/chapter/[bookid]/[chapterid].ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import { deleteChapter } from "../../../../../lib/storage/firebase.js"; 3 | import { checkBookAccess } from "../../../../../lib/utils/middleware/checkBookAccess.js"; 4 | import { checkChapterAccess } from "../../../../../lib/utils/middleware/checkChapterAccess.js"; 5 | import { requireLogin } from "../../../../../lib/utils/middleware/requireLogin.js"; 6 | import * as SE from "../../../../../lib/storage/storageEngine.js"; 7 | 8 | export const middleware = [requireLogin, checkBookAccess, checkChapterAccess]; 9 | 10 | export default async (req: Request, res: Response) => { 11 | const { chapterid, bookid } = req.params; 12 | console.log("cookies", req.cookies); 13 | const lastHeardFromServer = req.cookies.lastHeardFromServer; 14 | 15 | const updateData = { 16 | eventName: "chapterDelete", 17 | data: { chapterid }, 18 | }; 19 | SE.save(req, res, updateData, async () => { 20 | return await deleteChapter(chapterid, bookid, lastHeardFromServer); 21 | }); 22 | }; 23 | -------------------------------------------------------------------------------- /app/routes/GET/404.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import serveLibrary from "../../lib/common/serveLibrary.js"; 3 | import { requireLogin } from "../../lib/utils/middleware/requireLogin.js"; 4 | import path from "path"; 5 | 6 | export default async (req: Request, res: Response) => { 7 | res.sendFile(path.resolve("./dist/pages/404.html")); 8 | }; 9 | -------------------------------------------------------------------------------- /app/routes/GET/api/admin.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | 3 | import { requireAdmin } from "../../../lib/utils/middleware/requireAdmin.js"; 4 | import { getUserId } from "../../../lib/authentication/firebase.js"; 5 | import { serveFile } from "../../../lib/utils/serveFile.js"; 6 | 7 | export const middleware = [requireAdmin]; 8 | 9 | export default async (req: Request, res: Response) => { 10 | const userid = getUserId(req); 11 | serveFile("admin.html", res, userid); 12 | }; 13 | -------------------------------------------------------------------------------- /app/routes/GET/api/admin/deleteTestUserBooks.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import { deleteBooks } from "../../../../lib/storage/firebase.js"; 3 | import { requireAdmin } from "../../../../lib/utils/middleware/requireAdmin.js"; 4 | 5 | export const middleware = [requireAdmin]; 6 | 7 | export default async (req: Request, res: Response) => { 8 | await deleteBooks("ZMLuWv0J2HkI30kEfm5xs"); 9 | res.status(200).end(); 10 | }; 11 | -------------------------------------------------------------------------------- /app/routes/GET/api/admin/resetMonthlyTokenCounts.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import { requireAdmin } from "../../../../lib/utils/middleware/requireAdmin.js"; 3 | import { resetMonthlyTokenCounts } from "../../../../lib/authentication/firebase.js"; 4 | 5 | export const middleware = [requireAdmin]; 6 | 7 | export default async (req: Request, res: Response) => { 8 | await resetMonthlyTokenCounts(); 9 | res.status(200).end(); 10 | }; 11 | -------------------------------------------------------------------------------- /app/routes/GET/api/admin/users.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import { getUsers } from "../../../../lib/authentication/firebase.js"; 3 | import { requireAdmin } from "../../../../lib/utils/middleware/requireAdmin.js"; 4 | 5 | export const middleware = [requireAdmin]; 6 | 7 | export default async (req: Request, res: Response) => { 8 | const data = await getUsers(); 9 | res.status(200).end(); 10 | }; 11 | -------------------------------------------------------------------------------- /app/routes/GET/api/book/[bookid]/export/[title].ts: -------------------------------------------------------------------------------- 1 | import AdmZip from "adm-zip"; 2 | import { Request, Response } from "express"; 3 | import { chapterToMarkdown } from "../../../../../../lib/serverUtils.js"; 4 | import { getBook } from "../../../../../../lib/storage/firebase.js"; 5 | import { checkBookAccess } from "../../../../../../lib/utils/middleware/checkBookAccess.js"; 6 | import { requireLogin } from "../../../../../../lib/utils/middleware/requireLogin.js"; 7 | 8 | export const middleware = [requireLogin, checkBookAccess]; 9 | 10 | export default async (req: Request, res: Response) => { 11 | try { 12 | const book = await getBook(res.locals.bookid); 13 | 14 | // creating archives 15 | const zip = new AdmZip(); 16 | 17 | book.chapters.forEach((chapter) => { 18 | const content = chapterToMarkdown(chapter, false); 19 | let title = chapter.title || "untitled"; 20 | title = title.replace(/[^a-z0-9_]/gi, "-").toLowerCase() + ".md"; 21 | zip.addFile(title, Buffer.from(content, "utf8"), ""); 22 | }); 23 | const finalZip = zip.toBuffer(); 24 | 25 | res.status(200).send(finalZip); 26 | } catch (error) { 27 | console.error("Error getting chapter:", error); 28 | res.status(400).json({ error }); 29 | } 30 | }; 31 | -------------------------------------------------------------------------------- /app/routes/GET/api/books.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | 3 | import { getUserId } from "../../../lib/authentication/firebase.js"; 4 | import { getBooks } from "../../../lib/storage/firebase.js"; 5 | import { noCache } from "../../../lib/utils/middleware.js"; 6 | import { requireLogin } from "../../../lib/utils/middleware/requireLogin.js"; 7 | import * as SE from "../../../lib/storage/storageEngine.js"; 8 | 9 | export const middleware = [requireLogin, noCache]; 10 | 11 | export default async (req: Request, res: Response) => { 12 | const userid = getUserId(req); 13 | const books = await getBooks(userid); 14 | 15 | // this isn't an edit, so don't update lastEdited 16 | // unless necessary 17 | let lastEdited = SE.getLastEdited(userid); 18 | if (!lastEdited) { 19 | lastEdited = SE.updateLastEdited(req); 20 | } 21 | res.cookie("lastHeardFromServer", lastEdited); 22 | res.status(200).json({ books, lastEdited }); 23 | }; 24 | -------------------------------------------------------------------------------- /app/routes/GET/api/chapter/[bookid]/[chapterid]/embeddings.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import { getUser } from "../../../../../../lib/authentication/firebase.js"; 3 | import { getEmbeddings } from "../../../../../../lib/chat/getEmbeddings.js"; 4 | import { chapterToMarkdown } from "../../../../../../lib/serverUtils.js"; 5 | import { getChapter } from "../../../../../../lib/storage/firebase.js"; 6 | import { checkBookAccess } from "../../../../../../lib/utils/middleware/checkBookAccess.js"; 7 | import { checkChapterAccess } from "../../../../../../lib/utils/middleware/checkChapterAccess.js"; 8 | import { requireLogin } from "../../../../../../lib/utils/middleware/requireLogin.js"; 9 | 10 | export const middleware = [requireLogin, checkBookAccess, checkChapterAccess]; 11 | 12 | export default async (req: Request, res: Response) => { 13 | const chapter = await getChapter(res.locals.chapterid); 14 | 15 | const user = await getUser(req); 16 | const embeddings = await getEmbeddings( 17 | user, 18 | chapterToMarkdown(chapter, false) 19 | ); 20 | res.status(200).json({ embeddings }); 21 | }; 22 | -------------------------------------------------------------------------------- /app/routes/GET/api/chapter/[bookid]/[chapterid]/export/[title].ts: -------------------------------------------------------------------------------- 1 | import AdmZip from "adm-zip"; 2 | import { Request, Response } from "express"; 3 | import { chapterToMarkdown } from "../../../../../../../lib/serverUtils.js"; 4 | import { getChapter } from "../../../../../../../lib/storage/firebase.js"; 5 | import { checkBookAccess } from "../../../../../../../lib/utils/middleware/checkBookAccess.js"; 6 | import { checkChapterAccess } from "../../../../../../../lib/utils/middleware/checkChapterAccess.js"; 7 | import { requireLogin } from "../../../../../../../lib/utils/middleware/requireLogin.js"; 8 | 9 | export const middleware = [requireLogin, checkBookAccess, checkChapterAccess]; 10 | 11 | export default async (req: Request, res: Response) => { 12 | const { title } = req.params; 13 | try { 14 | const chapter = await getChapter(res.locals.chapterid); 15 | 16 | res.set("Content-Disposition", `attachment; filename="${title}"`); 17 | 18 | res.status(200).send(chapterToMarkdown(chapter, false)); 19 | } catch (error) { 20 | console.error("Error getting chapter:", error); 21 | res.status(400).json({ error }); 22 | } 23 | }; 24 | -------------------------------------------------------------------------------- /app/routes/GET/api/history/[bookid]/[chapterid].ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import { getHistory } from "../../../../../lib/storage/firebase.js"; 3 | import { checkBookAccess } from "../../../../../lib/utils/middleware/checkBookAccess.js"; 4 | import { checkChapterAccess } from "../../../../../lib/utils/middleware/checkChapterAccess.js"; 5 | import { requireLogin } from "../../../../../lib/utils/middleware/requireLogin.js"; 6 | 7 | export const middleware = [requireLogin, checkBookAccess, checkChapterAccess]; 8 | 9 | export default async (req: Request, res: Response) => { 10 | const { chapterid } = req.params; 11 | const history = await getHistory(chapterid); 12 | if (!history) { 13 | console.log(`no history with id, ${chapterid}`); 14 | res.status(404).end(); 15 | } else { 16 | res.json(history); 17 | } 18 | }; 19 | -------------------------------------------------------------------------------- /app/routes/GET/api/image/[s3key].ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import { getFromS3 } from "../../../../lib/storage/s3.js"; 3 | import { requireLogin } from "../../../../lib/utils/middleware/requireLogin.js"; 4 | 5 | export const middleware = [requireLogin]; 6 | 7 | export default async (req: Request, res: Response) => { 8 | const { s3key } = req.params; 9 | const data = await getFromS3(s3key); 10 | if (data.success === true) { 11 | res.writeHead(200, { 12 | "Content-Type": "image/png", 13 | }); 14 | res.end(data.data); 15 | } else { 16 | res.status(400).send(data.message).end(); 17 | } 18 | }; 19 | -------------------------------------------------------------------------------- /app/routes/GET/api/lastEdited.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import { noCache } from "../../../lib/utils/middleware.js"; 3 | import { getUser } from "../../../lib/authentication/firebase.js"; 4 | import { requireLogin } from "../../../lib/utils/middleware/requireLogin.js"; 5 | 6 | import * as SE from "../../../lib/storage/storageEngine.js"; 7 | 8 | export const middleware = [requireLogin, noCache]; 9 | 10 | export default async (req: Request, res: Response) => { 11 | const lastEdited = SE.getLastEdited(req.cookies.userid); 12 | const prettyDate = lastEdited 13 | ? new Date(lastEdited).toLocaleString() 14 | : "never"; 15 | console.log("userid", req.cookies.userid, "lastEdited", prettyDate); 16 | res.status(200).json({ lastEdited }); 17 | }; 18 | -------------------------------------------------------------------------------- /app/routes/GET/api/sseUpdates/[clientSessionId].ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | 3 | import * as SE from "../../../../lib/storage/storageEngine.js"; 4 | import { requireLogin } from "../../../../lib/utils/middleware/requireLogin.js"; 5 | import { getUserId } from "../../../../lib/authentication/firebase.js"; 6 | 7 | export const middleware = [requireLogin]; 8 | 9 | export default async (req: Request, res: Response) => { 10 | const userid = getUserId(req); 11 | SE.connectClient(userid, req.params.clientSessionId, req, res); 12 | }; 13 | -------------------------------------------------------------------------------- /app/routes/GET/api/utils/define/[word].ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import { requireLogin } from "../../../../../lib/utils/middleware/requireLogin.js"; 3 | import wordnet from "wordnet"; 4 | 5 | console.log("Initializing wordnet"); 6 | await wordnet.init("wordnet"); 7 | 8 | export const middleware = [requireLogin]; 9 | 10 | export default async (req: Request, res: Response) => { 11 | try { 12 | const definitions = await wordnet.lookup(req.params.word); 13 | definitions.forEach((definition) => { 14 | if (definition.meta && definition.meta.pointers) { 15 | delete definition.meta.pointers; 16 | } 17 | }); 18 | res.status(200).json(definitions); 19 | } catch (error) { 20 | console.error("No definitions found for word:", req.params.word); 21 | res 22 | .status(400) 23 | .json({ error: `No definitions found for word: '${req.params.word}'` }); 24 | } 25 | }; 26 | -------------------------------------------------------------------------------- /app/routes/GET/book/[bookid].ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import serveLibrary from "../../../lib/common/serveLibrary.js"; 3 | import { checkBookAccess } from "../../../lib/utils/middleware/checkBookAccess.js"; 4 | import { checkChapterAccess } from "../../../lib/utils/middleware/checkChapterAccess.js"; 5 | import { requireLogin } from "../../../lib/utils/middleware/requireLogin.js"; 6 | 7 | export const middleware = [requireLogin, checkBookAccess]; 8 | 9 | export default async (req: Request, res: Response) => { 10 | serveLibrary(req, res); 11 | }; 12 | -------------------------------------------------------------------------------- /app/routes/GET/book/[bookid]/[scrollTop].ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import serveLibrary from "../../../../lib/common/serveLibrary.js"; 3 | import { checkBookAccess } from "../../../../lib/utils/middleware/checkBookAccess.js"; 4 | import { checkChapterAccess } from "../../../../lib/utils/middleware/checkChapterAccess.js"; 5 | import { requireLogin } from "../../../../lib/utils/middleware/requireLogin.js"; 6 | 7 | export const middleware = [requireLogin, checkBookAccess]; 8 | 9 | export default async (req: Request, res: Response) => { 10 | serveLibrary(req, res); 11 | }; 12 | -------------------------------------------------------------------------------- /app/routes/GET/book/[bookid]/chapter/[chapterid].ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import serveLibrary from "../../../../../lib/common/serveLibrary.js"; 3 | import { checkBookAccess } from "../../../../../lib/utils/middleware/checkBookAccess.js"; 4 | import { checkChapterAccess } from "../../../../../lib/utils/middleware/checkChapterAccess.js"; 5 | import { requireLogin } from "../../../../../lib/utils/middleware/requireLogin.js"; 6 | 7 | export const middleware = [requireLogin, checkBookAccess, checkChapterAccess]; 8 | 9 | export default async (req: Request, res: Response) => { 10 | serveLibrary(req, res); 11 | }; 12 | -------------------------------------------------------------------------------- /app/routes/GET/book/[bookid]/chapter/[chapterid]/[textindex].ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import serveLibrary from "../../../../../../lib/common/serveLibrary.js"; 3 | import { checkBookAccess } from "../../../../../../lib/utils/middleware/checkBookAccess.js"; 4 | import { checkChapterAccess } from "../../../../../../lib/utils/middleware/checkChapterAccess.js"; 5 | import { requireLogin } from "../../../../../../lib/utils/middleware/requireLogin.js"; 6 | 7 | export const middleware = [requireLogin, checkBookAccess, checkChapterAccess]; 8 | 9 | export default async (req: Request, res: Response) => { 10 | serveLibrary(req, res); 11 | }; 12 | -------------------------------------------------------------------------------- /app/routes/GET/hello.ts: -------------------------------------------------------------------------------- 1 | export default async (req, res) => { 2 | res.send("Hello World"); 3 | }; 4 | -------------------------------------------------------------------------------- /app/routes/GET/hello/hi.ts: -------------------------------------------------------------------------------- 1 | export default async (req, res) => { 2 | res.send("HI"); 3 | }; 4 | -------------------------------------------------------------------------------- /app/routes/GET/home.html.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import serveLibrary from "../../lib/common/serveLibrary.js"; 3 | import { requireLogin } from "../../lib/utils/middleware/requireLogin.js"; 4 | export const middleware = [requireLogin]; 5 | 6 | export default async (req: Request, res: Response) => { 7 | serveLibrary(req, res); 8 | }; 9 | -------------------------------------------------------------------------------- /app/routes/GET/index.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import serveLibrary from "../../lib/common/serveLibrary.js"; 3 | import { requireLogin } from "../../lib/utils/middleware/requireLogin.js"; 4 | export const middleware = [requireLogin]; 5 | 6 | export default async (req: Request, res: Response) => { 7 | serveLibrary(req, res); 8 | }; 9 | -------------------------------------------------------------------------------- /app/routes/GET/login.html.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import { serveFile } from "../../lib/utils/serveFile.js"; 3 | 4 | export default async (req: Request, res: Response) => { 5 | serveFile("login-base.html", res, null); 6 | }; 7 | -------------------------------------------------------------------------------- /app/routes/GET/login.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import { serveFile } from "../../lib/utils/serveFile.js"; 3 | 4 | export default async (req: Request, res: Response) => { 5 | serveFile("login-base.html", res, null); 6 | }; 7 | -------------------------------------------------------------------------------- /app/routes/GET/logout.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | 3 | export default async (req: Request, res: Response) => { 4 | res.clearCookie("userid"); 5 | res.clearCookie("token"); 6 | res.clearCookie("csrfToken"); 7 | res.clearCookie("lastHeardFromServer"); 8 | res.redirect("/login"); 9 | }; 10 | -------------------------------------------------------------------------------- /app/routes/GET/register.html.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import { serveFile } from "../../lib/utils/serveFile.js"; 3 | 4 | export default async (req: Request, res: Response) => { 5 | serveFile("login-base.html", res, null); 6 | }; 7 | -------------------------------------------------------------------------------- /app/routes/GET/register.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import { serveFile } from "../../lib/utils/serveFile.js"; 3 | 4 | export default async (req: Request, res: Response) => { 5 | serveFile("login-base.html", res, null); 6 | }; 7 | -------------------------------------------------------------------------------- /app/routes/POST/api/book.ts: -------------------------------------------------------------------------------- 1 | import { success } from "../../../lib/utils.js"; 2 | import { Request, Response } from "express"; 3 | import { requireLogin } from "../../../lib/utils/middleware/requireLogin.js"; 4 | import { saveBook } from "../../../lib/storage/firebase.js"; 5 | import * as SE from "../../../lib/storage/storageEngine.js"; 6 | import { UpdateData } from "../../../lib/types.js"; 7 | import { getUserId } from "../../../lib/authentication/firebase.js"; 8 | import { makeNewBook } from "../../../lib/storage/firebase.js"; 9 | 10 | export const middleware = [requireLogin]; 11 | 12 | export default async (req: Request, res: Response) => { 13 | const userid = getUserId(req); 14 | const book = makeNewBook({ 15 | userid, 16 | }); 17 | 18 | const updateData: UpdateData = { 19 | eventName: "bookCreate", 20 | data: { book }, 21 | }; 22 | const lastHeardFromServer = req.cookies.lastHeardFromServer; 23 | SE.save(req, res, updateData, async () => { 24 | await saveBook(book, lastHeardFromServer); 25 | return success(book); 26 | }); 27 | }; 28 | -------------------------------------------------------------------------------- /app/routes/POST/api/book/[bookid]/embeddings.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import { getUser } from "../../../../../lib/authentication/firebase.js"; 3 | import { getEmbeddings } from "../../../../../lib/chat/getEmbeddings.js"; 4 | import { chapterToMarkdown } from "../../../../../lib/serverUtils.js"; 5 | import { 6 | getBook, 7 | saveEmbeddings, 8 | saveBook, 9 | } from "../../../../../lib/storage/firebase.js"; 10 | import { checkBookAccess } from "../../../../../lib/utils/middleware/checkBookAccess.js"; 11 | import { requireLogin } from "../../../../../lib/utils/middleware/requireLogin.js"; 12 | 13 | export const middleware = [requireLogin, checkBookAccess]; 14 | 15 | export default async (req: Request, res: Response) => { 16 | const book = await getBook(res.locals.bookid); 17 | const chapters = book.chapters; 18 | const user = await getUser(req); 19 | const timestamp = Date.now(); 20 | let promises = chapters 21 | .filter((chapter) => { 22 | return ( 23 | !chapter.embeddingsLastCalculatedAt || 24 | chapter.created_at > chapter.embeddingsLastCalculatedAt 25 | ); 26 | }) 27 | .map(async (chapter) => { 28 | const embeddings = await getEmbeddings(user, chapterToMarkdown(chapter)); 29 | return { chapter, embeddings }; 30 | }); 31 | const allEmbeddings = await Promise.all(promises); 32 | const promises2 = allEmbeddings.map(async ({ chapter, embeddings }) => { 33 | if (embeddings.success !== true) { 34 | console.error("Error getting embeddings:", embeddings.message); 35 | return; 36 | } 37 | 38 | return await saveEmbeddings(chapter.chapterid, { 39 | embeddings: embeddings.data, 40 | created_at: timestamp, 41 | }); 42 | }); 43 | 44 | book.lastTrainedAt = timestamp; 45 | const lastHeardFromServer = req.cookies.lastHeardFromServer; 46 | await Promise.all([...promises2, saveBook(book, lastHeardFromServer)]); 47 | 48 | res.status(200).json({ lastTrainedAt: timestamp }); 49 | }; 50 | -------------------------------------------------------------------------------- /app/routes/POST/api/book/upload.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import { getUser } from "../../../../lib/authentication/firebase.js"; 3 | import { 4 | makeNewBook, 5 | makeNewChapter, 6 | saveBook, 7 | saveChapter, 8 | } from "../../../../lib/storage/firebase.js"; 9 | import * as SE from "../../../../lib/storage/storageEngine.js"; 10 | import { UpdateData } from "../../../../lib/types.js"; 11 | import { success } from "../../../../lib/utils.js"; 12 | import { requireLogin } from "../../../../lib/utils/middleware/requireLogin.js"; 13 | export const middleware = [requireLogin]; 14 | 15 | export default async (req: Request, res: Response) => { 16 | const user = await getUser(req); 17 | const userid = user.userid; 18 | const chapters = req.body.chapters; 19 | const lastHeardFromServer = req.cookies.lastHeardFromServer; 20 | 21 | const book = makeNewBook({ 22 | userid, 23 | }); 24 | const promises = chapters.map(async (chapter) => { 25 | const newChapter = makeNewChapter(chapter.text, chapter.title, book.bookid); 26 | await saveChapter(newChapter, lastHeardFromServer); 27 | book.chapters.push(newChapter); 28 | }); 29 | await Promise.all(promises); 30 | 31 | const updateData: UpdateData = { 32 | eventName: "bookCreate", 33 | data: { book }, 34 | }; 35 | SE.save(req, res, updateData, async () => { 36 | await saveBook(book, lastHeardFromServer); 37 | return success(book); 38 | }); 39 | }; 40 | -------------------------------------------------------------------------------- /app/routes/POST/api/chapter.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import { requireLogin } from "../../../lib/utils/middleware/requireLogin.js"; 3 | import { makeNewChapter, saveChapter } from "../../../lib/storage/firebase.js"; 4 | import * as SE from "../../../lib/storage/storageEngine.js"; 5 | import { success } from "../../../lib/utils.js"; 6 | import { UpdateData } from "../../../lib/types.js"; 7 | 8 | export const middleware = [requireLogin]; 9 | 10 | export default async (req: Request, res: Response) => { 11 | const { bookid, title, text } = req.body; 12 | const lastHeardFromServer = req.cookies.lastHeardFromServer; 13 | const chapter = makeNewChapter(text, title, bookid); 14 | const updateData: UpdateData = { 15 | eventName: "chapterCreate", 16 | data: { chapter, bookid }, 17 | }; 18 | await SE.save(req, res, updateData, async () => { 19 | await saveChapter(chapter, lastHeardFromServer); 20 | return success(chapter); 21 | }); 22 | }; 23 | -------------------------------------------------------------------------------- /app/routes/POST/api/file/upload.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import formidable from "formidable"; 3 | import fs from "fs"; 4 | import { nanoid } from "nanoid"; 5 | import { uploadToS3 } from "../../../../lib/storage/s3.js"; 6 | import { requireLogin } from "../../../../lib/utils/middleware/requireLogin.js"; 7 | 8 | export const middleware = [requireLogin]; 9 | 10 | export default async (req: Request, res: Response) => { 11 | const form = formidable({ multiples: true }); 12 | form.parse(req, async (err, fields, files) => { 13 | console.log({ err, fields, files }); 14 | if (err) { 15 | res.writeHead(err.httpCode || 400, { "Content-Type": "text/plain" }); 16 | res.end(String(err)); 17 | return; 18 | } 19 | 20 | const c = req.cookies; 21 | if (c.csrfToken !== fields.csrfToken) { 22 | console.log("csrf token mismatch"); 23 | res 24 | .status(400) 25 | .send( 26 | "Could not butter your uploaded parsnips. Try refreshing your browser." 27 | ) 28 | .end(); 29 | return; 30 | } 31 | 32 | let oldPath = files.fileToUpload.filepath; 33 | let rawData = fs.readFileSync(oldPath); 34 | //let audioBlob = new Blob([rawData]); 35 | 36 | const s3key = nanoid(); 37 | const result = await uploadToS3(s3key, rawData); 38 | if (result.success === true) { 39 | res.send({ s3key }); 40 | } else { 41 | res.status(400).send(result.message).end(); 42 | } 43 | }); 44 | }; 45 | -------------------------------------------------------------------------------- /app/routes/POST/api/history.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import { saveToHistory } from "../../../lib/storage/firebase.js"; 3 | import { Result } from "../../../lib/types.js"; 4 | import { requireLogin } from "../../../lib/utils/middleware/requireLogin.js"; 5 | import { Commit } from "../../../../src/Types.js"; 6 | 7 | export const middleware = [requireLogin]; 8 | 9 | export default async (req: Request, res: Response) => { 10 | const { chapterid, id, message, timestamp, patch } = req.body; 11 | const commitData: Commit = { 12 | id, 13 | message, 14 | timestamp, 15 | patch, 16 | }; 17 | console.log("history/new", chapterid, commitData); 18 | 19 | const result: Result = await saveToHistory(chapterid, commitData); 20 | res.status(200).end(); 21 | 22 | if (result.success === true) { 23 | res.status(200).end(); 24 | } else { 25 | res.status(400).send(result.message).end(); 26 | } 27 | }; 28 | -------------------------------------------------------------------------------- /app/routes/POST/api/suggestions.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import settings from "../../../config/settings.js"; 3 | import { getUser } from "../../../lib/authentication/firebase.js"; 4 | import { getSuggestions } from "../../../lib/chat/getSuggestions.js"; 5 | import { substringTokens } from "../../../lib/serverUtils.js"; 6 | import { requireLogin } from "../../../lib/utils/middleware/requireLogin.js"; 7 | 8 | export const middleware = [requireLogin]; 9 | 10 | export default async (req: Request, res: Response) => { 11 | console.log({ body: req.body }); 12 | const user = await getUser(req); 13 | const prompt = substringTokens(req.body.prompt, settings.maxPromptLength); 14 | const suggestions = await getSuggestions( 15 | user, 16 | prompt, 17 | req.body.max_tokens, 18 | req.body.model, 19 | req.body.num_suggestions, 20 | req.body.messages || [], 21 | req.body.customKey 22 | ); 23 | if (suggestions.success === true) { 24 | res.status(200).json(suggestions.data); 25 | } else { 26 | res.status(400).json({ error: suggestions.message }); 27 | } 28 | }; 29 | -------------------------------------------------------------------------------- /app/routes/POST/api/textToSpeech/data/[chapterid].ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import { requireLogin } from "../../../../../lib/utils/middleware/requireLogin.js"; 3 | import { getUserId } from "../../../../../lib/authentication/firebase.js"; 4 | import { getSpeech } from "../../../../../lib/storage/firebase.js"; 5 | 6 | export const middleware = [requireLogin]; 7 | 8 | export default async (req: Request, res: Response) => { 9 | const { chapterid } = req.params; 10 | const userid = getUserId(req); 11 | const data = await getSpeech(chapterid); 12 | if (data && data.userid === userid) { 13 | return res.status(200).json(data); 14 | } 15 | 16 | res.status(403).send("no access").end(); 17 | }; 18 | -------------------------------------------------------------------------------- /app/routes/POST/api/textToSpeech/file/[s3key].ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import { getFromS3 } from "../../../../../lib/storage/s3.js"; 3 | import { requireLogin } from "../../../../../lib/utils/middleware/requireLogin.js"; 4 | 5 | export const middleware = [requireLogin]; 6 | 7 | export default async (req: Request, res: Response) => { 8 | const { s3key } = req.params; 9 | const data = await getFromS3(s3key); 10 | if (data.success === true) { 11 | res.writeHead(200, { 12 | "Content-Type": "audio/mpeg", 13 | "Content-disposition": "inline;filename=chiselaudio.mp3", 14 | "Content-Length": data.data.length, 15 | }); 16 | res.end(data.data); 17 | } else { 18 | res.status(400).send(data.message).end(); 19 | } 20 | }; 21 | -------------------------------------------------------------------------------- /app/routes/POST/api/textToSpeech/long.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import { getUser, saveUser } from "../../../../lib/authentication/firebase.js"; 3 | import { 4 | hasPermission, 5 | updatePermissionLimit, 6 | } from "../../../../lib/serverUtils.js"; 7 | import { textToSpeechLong } from "../../../../lib/speech/polly.js"; 8 | import { requireLogin } from "../../../../lib/utils/middleware/requireLogin.js"; 9 | 10 | export const middleware = [requireLogin]; 11 | 12 | export default async (req: Request, res: Response) => { 13 | const user = await getUser(req); 14 | const { chapterid, text } = req.body; 15 | const truncatedText = text.substring(0, 100_000); 16 | const filename = "test.mp3"; 17 | if (!user) { 18 | console.log("no user"); 19 | res.status(404).end(); 20 | } else { 21 | const permissionCheck = hasPermission( 22 | user, 23 | "amazon_polly", 24 | truncatedText.length 25 | ); 26 | if (permissionCheck.success === true) { 27 | const updateLimit = await updatePermissionLimit( 28 | user, 29 | saveUser, 30 | "amazon_polly", 31 | truncatedText.length 32 | ); 33 | if (!updateLimit.success === true) { 34 | res.status(400).send(updateLimit.message).end(); 35 | return; 36 | } 37 | 38 | const task_id = await textToSpeechLong(truncatedText, filename, res); 39 | res.json({ success: true, task_id }); 40 | } else { 41 | console.log("no polly permission"); 42 | res.status(400).send(permissionCheck.message).end(); 43 | } 44 | } 45 | }; 46 | -------------------------------------------------------------------------------- /app/routes/POST/api/textToSpeech/short.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import fs from "fs"; 3 | import { getUser, saveUser } from "../../../../lib/authentication/firebase.js"; 4 | import { 5 | hasPermission, 6 | updatePermissionLimit, 7 | } from "../../../../lib/serverUtils.js"; 8 | import { textToSpeech } from "../../../../lib/speech/polly.js"; 9 | import { requireLogin } from "../../../../lib/utils/middleware/requireLogin.js"; 10 | 11 | export const middleware = [requireLogin]; 12 | 13 | export default async (req: Request, res: Response) => { 14 | const user = await getUser(req); 15 | const { text } = req.body; 16 | const truncatedText = text.substring(0, 3000); 17 | 18 | if (!user) { 19 | console.log("no user"); 20 | res.status(404).end(); 21 | } else { 22 | const permissionCheck = hasPermission( 23 | user, 24 | "amazon_polly", 25 | truncatedText.length 26 | ); 27 | if (permissionCheck.success === true) { 28 | const updateLimit = await updatePermissionLimit( 29 | user, 30 | saveUser, 31 | "amazon_polly", 32 | truncatedText.length 33 | ); 34 | if (!updateLimit.success === true) { 35 | res.status(400).send(updateLimit.message).end(); 36 | return; 37 | } 38 | const filename = "test.mp3"; 39 | await textToSpeech(truncatedText, filename, res); 40 | console.log("piping"); 41 | const data = fs.readFileSync(filename); 42 | res.writeHead(200, { 43 | "Content-Type": "audio/mpeg", 44 | "Content-disposition": "inline;filename=" + filename, 45 | "Content-Length": data.length, 46 | }); 47 | res.end(data); 48 | } else { 49 | console.log("no polly permission"); 50 | res.status(400).send(permissionCheck.message).end(); 51 | } 52 | } 53 | }; 54 | -------------------------------------------------------------------------------- /app/routes/POST/api/textToSpeech/task/[chapterid]/[taskid].ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import { getUserId } from "../../../../../../lib/authentication/firebase.js"; 3 | import { getTaskStatus } from "../../../../../../lib/speech/polly.js"; 4 | import { saveSpeech } from "../../../../../../lib/storage/firebase.js"; 5 | import { getFromS3 } from "../../../../../../lib/storage/s3.js"; 6 | import { requireLogin } from "../../../../../../lib/utils/middleware/requireLogin.js"; 7 | 8 | export const middleware = [requireLogin]; 9 | 10 | export default async (req: Request, res: Response) => { 11 | try { 12 | const { chapterid, task_id } = req.params; 13 | const userid = getUserId(req); 14 | const status = await getTaskStatus(task_id); 15 | if (status.success === true) { 16 | const s3key = status.data.s3key; 17 | const data = await getFromS3(s3key); 18 | if (data.success === true) { 19 | const created_at = Date.now(); 20 | await saveSpeech(chapterid, { s3key, userid, created_at }); 21 | 22 | // return audio 23 | res.writeHead(200, { 24 | "Content-Type": "audio/mpeg", 25 | "Content-disposition": "inline;filename=chiselaudio.mp3", 26 | "Content-Length": data.data.length, 27 | }); 28 | res.end(data.data); 29 | } else { 30 | res.status(400).send(data.message).end(); 31 | } 32 | } else { 33 | res.status(200).json(status.message).end(); 34 | } 35 | } catch (e) { 36 | console.log(e); 37 | res.status(400).send(e.message).end(); 38 | } 39 | }; 40 | -------------------------------------------------------------------------------- /app/routes/POST/auth/login.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import { submitLogin } from "../../../lib/authentication/firebase.js"; 3 | 4 | export default async (req: Request, res: Response) => { 5 | await submitLogin(req, res); 6 | }; 7 | -------------------------------------------------------------------------------- /app/routes/POST/auth/loginGuestUser.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import { loginGuestUser } from "../../../lib/authentication/firebase.js"; 3 | 4 | export const disabled = true; 5 | 6 | export default async (req: Request, res: Response) => { 7 | await loginGuestUser(req, res); 8 | }; 9 | -------------------------------------------------------------------------------- /app/routes/POST/auth/register.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import { submitRegister } from "../../../lib/authentication/firebase.js"; 3 | 4 | export default async (req: Request, res: Response) => { 5 | await submitRegister(req, res); 6 | }; 7 | -------------------------------------------------------------------------------- /app/routes/PUT/api/book.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import { requireLogin } from "../../../lib/utils/middleware/requireLogin.js"; 3 | import { saveBook } from "../../../lib/storage/firebase.js"; 4 | import * as SE from "../../../lib/storage/storageEngine.js"; 5 | import { UpdateData } from "../../../lib/types.js"; 6 | 7 | export const middleware = [requireLogin]; 8 | 9 | export default async (req: Request, res: Response) => { 10 | const { book } = req.body; 11 | const lastHeardFromServer = req.cookies.lastHeardFromServer; 12 | 13 | const updateData: UpdateData = { 14 | eventName: "bookUpdate", 15 | data: { book }, 16 | }; 17 | await SE.save(req, res, updateData, async () => { 18 | return await saveBook(book, lastHeardFromServer); 19 | }); 20 | }; 21 | -------------------------------------------------------------------------------- /app/routes/PUT/api/chapter.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import { requireLogin } from "../../../lib/utils/middleware/requireLogin.js"; 3 | import { saveChapter } from "../../../lib/storage/firebase.js"; 4 | import * as SE from "../../../lib/storage/storageEngine.js"; 5 | import { UpdateData } from "../../../lib/types.js"; 6 | 7 | export const middleware = [requireLogin]; 8 | 9 | export default async (req: Request, res: Response) => { 10 | const { chapter } = req.body; 11 | const lastHeardFromServer = req.cookies.lastHeardFromServer; 12 | const updateData: UpdateData = { 13 | eventName: "chapterUpdate", 14 | data: { chapter }, 15 | }; 16 | await SE.save(req, res, updateData, async () => { 17 | return await saveChapter(chapter, lastHeardFromServer); 18 | }); 19 | }; 20 | -------------------------------------------------------------------------------- /app/routes/PUT/api/history/commitMessage.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import { 3 | editCommitMessage, 4 | saveToHistory, 5 | } from "../../../../lib/storage/firebase.js"; 6 | import { Result } from "../../../../lib/types.js"; 7 | import { requireLogin } from "../../../../lib/utils/middleware/requireLogin.js"; 8 | 9 | export const middleware = [requireLogin]; 10 | 11 | export default async (req: Request, res: Response) => { 12 | const { chapterid, message, index } = req.body; 13 | 14 | console.log("editCommitMessage", chapterid, message, index); 15 | 16 | const result = await editCommitMessage(chapterid, message, parseInt(index)); 17 | res.status(200).end(); 18 | 19 | if (result.success === true) { 20 | res.status(200).end(); 21 | } else { 22 | res.status(400).send(result.message).end(); 23 | } 24 | }; 25 | -------------------------------------------------------------------------------- /app/server.ts: -------------------------------------------------------------------------------- 1 | import compression from "compression"; 2 | 3 | import cors from "cors"; 4 | import dotenv from "dotenv"; 5 | import express from "express"; 6 | import rateLimit from "express-rate-limit"; 7 | 8 | import { allowAutoplay } from "./lib/utils/middleware.js"; 9 | import { csrf } from "./lib/utils/middleware/csrf.js"; 10 | 11 | import cookieParser from "cookie-parser"; 12 | 13 | import { processDir } from "./lib/utils/processDir.js"; 14 | 15 | dotenv.config(); 16 | 17 | const app = express(); 18 | app.use(cors()); 19 | app.use(express.json({ limit: "5mb" })); 20 | app.use( 21 | express.urlencoded({ 22 | limit: "5mb", 23 | extended: true, 24 | }) 25 | ); 26 | app.use(compression()); 27 | 28 | app.use(express.static("public")); 29 | app.use(express.static("dist")); 30 | 31 | app.use(cookieParser()); 32 | app.disable("x-powered-by"); 33 | const apiLimiter = rateLimit({ 34 | windowMs: 15 * 60 * 1000, // 15 minutes 35 | max: 1000, // Limit each IP to 100 requests per `window` (here, per 15 minutes) 36 | standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers 37 | legacyHeaders: false, // Disable the `X-RateLimit-*` headers 38 | }); 39 | 40 | // Apply the rate limiting middleware to API calls only 41 | app.use("/api", apiLimiter); 42 | app.use("/auth/loginGuestUser", apiLimiter); 43 | 44 | app.use(csrf); 45 | app.use(allowAutoplay); 46 | 47 | processDir(app, "./build/routes/GET", "build/routes/GET", "GET"); 48 | processDir(app, "./build/routes/POST", "build/routes/POST", "POST"); 49 | processDir(app, "./build/routes/PUT", "build/routes/PUT", "PUT"); 50 | processDir(app, "./build/routes/DELETE", "build/routes/DELETE", "DELETE"); 51 | 52 | const port = process.env.PORT || 80; 53 | app.listen(port, () => console.log(`Server running on port ${port}`)); 54 | -------------------------------------------------------------------------------- /babel.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env", 4 | "@babel/preset-react", 5 | "@babel/preset-typescript" 6 | ], 7 | "plugins": ["@babel/plugin-syntax-import-assertions"] 8 | } 9 | -------------------------------------------------------------------------------- /cypress.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "cypress"; 2 | 3 | export default defineConfig({ 4 | defaultCommandTimeout: 6000, 5 | blockHosts: ["*.google-analytics.com", "*.plausible.io"], 6 | e2e: { 7 | setupNodeEvents(on, config) { 8 | // implement node event listeners here 9 | }, 10 | experimentalRunAllSpecs: true, 11 | }, 12 | }); 13 | -------------------------------------------------------------------------------- /cypress/e2e/Blocks.cy.ts: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | import settings from "../../settings.js"; 3 | 4 | const title = "a brand new title"; 5 | const text = "first\nsecond\nthird"; 6 | const text2 = "fourth\nfifth\nsixth"; 7 | 8 | describe("blocks", () => { 9 | it("tests various block functionality", () => { 10 | cy.viewport(1980, 1080); 11 | cy.login(); 12 | 13 | cy.newBook(); 14 | 15 | cy.selectBook(); 16 | cy.newChapter(); 17 | 18 | cy.selectChapter(); 19 | 20 | // You can fold a block 21 | cy.get("div[data-selector='texteditor-0']").type(text); 22 | 23 | // make a new block 24 | cy.launcher("new block after current"); 25 | 26 | cy.get("div[data-selector='texteditor-1']").type(text2); 27 | cy.get("div[data-selector='close-0']").click(); 28 | cy.get("div[data-selector='texteditor-0']").should("not.exist"); 29 | cy.get("p[data-selector='text-preview-0']").contains(text.split("\n")[0]); 30 | cy.get("div[data-selector='texteditor-1']").contains(text2.split("\n")[0]); 31 | // merge block up 32 | // cannot test because can't get the ql-editor to stay active 33 | // when invoking the launcher. argh 34 | /* cy.launcher(`merge block up`) 35 | 36 | cy.get("div[data-selector='texteditor-1']").should("not.exist"); 37 | cy.get("div[data-selector='texteditor-0']").contains("first"); 38 | cy.get("div[data-selector='texteditor-0']").contains("second"); 39 | cy.get("div[data-selector='texteditor-0']").contains("fourth"); 40 | cy.get("div[data-selector='texteditor-0']").contains("fifth"); 41 | 42 | // make another new block 43 | cy.launcher("new block after current") 44 | cy.get("div[data-selector='texteditor-1']").type(text2) */ 45 | 46 | cy.autoSave(); 47 | 48 | // The block stays folded 49 | cy.visit("http://localhost:80/"); 50 | cy.selectBook(); 51 | cy.selectChapter(); 52 | cy.get("div[data-selector='texteditor-0']").should("not.exist"); 53 | cy.get("p[data-selector='text-preview-0']").contains(text.split("\n")[0]); 54 | cy.get("div[data-selector='texteditor-1']").contains("fourth"); 55 | 56 | // readonly mode 57 | cy.get("button[data-selector='readonly-open']").click(); 58 | 59 | // closed block is shown 60 | cy.contains("div[id=readonly]", "first"); 61 | cy.contains("div[id=readonly]", "fourth"); 62 | cy.get("button[data-selector='readonly-close']").click(); 63 | 64 | // diff viewer 65 | cy.get("div[data-selector='open-0']").click(); 66 | cy.launcher("diff with block below"); 67 | cy.contains("div[id='diff-view']", "first"); 68 | cy.get("button[data-selector='diff-view-close']").click(); 69 | 70 | cy.deleteChapter(); 71 | cy.deleteBook(); 72 | 73 | // finally, delete the book so we're back to a clean slate 74 | }); 75 | }); 76 | -------------------------------------------------------------------------------- /cypress/e2e/BookList.cy.ts: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | import settings from "../../settings.js"; 3 | 4 | describe("books", () => { 5 | it("lets you add, rename, and delete a book", () => { 6 | cy.login(); 7 | cy.newBook(); 8 | cy.renameBook(); 9 | cy.deleteBook(); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /cypress/e2e/ChapterList.cy.ts: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | import settings from "../../settings.js"; 3 | 4 | describe("chapters", () => { 5 | it("lets you add, rename, and delete a chapter", () => { 6 | cy.login(); 7 | 8 | cy.newBook(); 9 | 10 | cy.selectBook(); 11 | 12 | cy.contains("h3", "No chapters"); 13 | 14 | cy.newChapter(); 15 | 16 | cy.renameChapter(); 17 | cy.deleteChapter(); 18 | cy.deleteBook(); 19 | 20 | // finally, delete the book so we're back to a clean slate 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /cypress/e2e/Characters.cy.ts: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | import settings from "../../settings.js"; 3 | 4 | 5 | const name = "asterix"; 6 | const description = "the gaul"; 7 | 8 | describe("characters", () => { 9 | it("lets you add characters", () => { 10 | cy.login(); 11 | 12 | cy.newBook(); 13 | 14 | cy.selectBook(); 15 | cy.get("label").contains("Synopsis"); 16 | cy.get("button[data-selector='add-character-button']").click(); 17 | cy.get("input[data-selector='character--name']").type(name) 18 | cy.get(`textarea[data-selector='character-${name}-description']`).type(description) 19 | cy.autoSave(); 20 | 21 | cy.visit("http://localhost:80/"); 22 | cy.selectBook(); 23 | cy.get(`input[data-selector='character-${name}-name']`).should('have.value', name); 24 | cy.get(`textarea[data-selector='character-${name}-description']`).should('have.value', description); 25 | cy.deleteBook(); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /cypress/e2e/CompostHeap.cy.ts: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | import settings from "../../settings.js"; 3 | 4 | describe("compost heap", () => { 5 | it("has a compost heap, with no menu", () => { 6 | cy.login(); 7 | 8 | cy.get("p[data-selector='booklist-compost-list-item']").contains("Compost Heap") 9 | cy.get("button[data-selector='booklist-list-item-menu-button']").should("not.exist") 10 | }); 11 | }); -------------------------------------------------------------------------------- /cypress/e2e/DeleteBook.cy.ts: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | import settings from "../../settings.js"; 3 | 4 | const title = "a brand new title"; 5 | const text = "some text"; 6 | 7 | describe("delete book", () => { 8 | it("if you delete a chapter, the editor state is cleared", () => { 9 | cy.login(); 10 | 11 | 12 | cy.newBook(); 13 | 14 | cy.selectBook(); 15 | 16 | cy.newChapter(); 17 | cy.selectChapter(); 18 | 19 | cy.get("div[data-selector='text-editor-title']").type(`${title}{enter}`); 20 | cy.get(".ql-editor").last().type(`${text}{enter}`); 21 | cy.get(".ql-editor").last().type(`{command+s}`); 22 | 23 | cy.deleteChapter(); 24 | cy.contains("div", title).should("not.exist"); 25 | 26 | cy.deleteBook(); 27 | }); 28 | 29 | it("if you delete a book, the editor state is cleared", () => { 30 | cy.login(); 31 | 32 | 33 | cy.newBook(); 34 | 35 | cy.selectBook(); 36 | 37 | cy.newChapter(); 38 | cy.selectChapter(); 39 | 40 | cy.get("div[data-selector='text-editor-title']").type(`${title}{enter}`); 41 | cy.get(".ql-editor").last().type(`${text}{enter}`); 42 | cy.get(".ql-editor").last().type(`{command+s}`); 43 | 44 | cy.deleteBook(); 45 | cy.contains("div", title).should("not.exist"); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /cypress/e2e/EditAndSwitch.cy.ts: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | import settings from "../../settings.js"; 3 | 4 | const title = "first chapter"; 5 | const text = "some important text"; 6 | 7 | describe("edit and switch", () => { 8 | it("if you edit a chapter, move to another chapter, and then go back, your edits should show (issue #7)", () => { 9 | cy.login(); 10 | 11 | cy.newBook(); 12 | 13 | cy.selectBook(); 14 | 15 | cy.contains("h3", "No chapters"); 16 | 17 | cy.newChapter(); 18 | cy.newChapter(); 19 | 20 | cy.selectFirstChapter(); 21 | 22 | cy.get("div[data-selector='text-editor-title']").type(`${title}{enter}`); 23 | cy.get(".ql-editor").last().type(`${text}{enter}`); 24 | 25 | cy.autoSave(); 26 | 27 | // go to the other chapter 28 | cy.selectLastChapter(); 29 | 30 | // go back 31 | cy.selectFirstChapter(); 32 | 33 | // your edits should show 34 | cy.contains("div[data-selector='text-editor-title']", title); 35 | cy.contains(".ql-editor", text); 36 | 37 | cy.deleteFirstChapter(); 38 | cy.deleteChapter(); 39 | cy.deleteBook(); 40 | 41 | // finally, delete the book so we're back to a clean slate 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /cypress/e2e/Editor.cy.ts: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | import settings from "../../settings.js"; 3 | 4 | const title = "a brand new title"; 5 | const text = "some text"; 6 | 7 | describe("editor", () => { 8 | it("lets you edit and save text", () => { 9 | cy.login(); 10 | 11 | cy.newBook(); 12 | 13 | cy.selectBook(); 14 | cy.newChapter(); 15 | 16 | cy.selectChapter(); 17 | cy.get("div[data-selector='text-editor-title']").type(`${title}{enter}`); 18 | 19 | // Retest autosave twice. The first time, the created_at timestamp will be updated on the server 20 | // and the new timestamp will be passed to the client. If it gets success fully updated, 21 | // the second autosave will work. If it doesn't, the second autosave will fail. 22 | cy.get(".ql-editor").last().type(`some `); 23 | cy.autoSave(); 24 | cy.get(".ql-editor").last().type(`text {enter}`); 25 | cy.autoSave(); 26 | 27 | cy.visit("http://localhost:80/"); 28 | cy.selectBook(); 29 | 30 | cy.selectChapter(); 31 | 32 | cy.contains("div[data-selector='text-editor-title']", title); 33 | 34 | cy.contains(".ql-editor", text); 35 | 36 | cy.deleteChapter(); 37 | cy.deleteBook(); 38 | 39 | // finally, delete the book so we're back to a clean slate 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /cypress/e2e/FrontMatter.cy.ts: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | import settings from "../../settings.js"; 3 | 4 | 5 | const text = "some text"; 6 | 7 | describe("front matter", () => { 8 | it("lets you edit the synopsis", () => { 9 | cy.login(); 10 | 11 | cy.newBook(); 12 | 13 | cy.selectBook(); 14 | cy.get("label").contains("Synopsis"); 15 | cy.get("textarea[id='synopsis']").type(`${text} {enter}`); 16 | cy.autoSave(); 17 | 18 | cy.visit("http://localhost:80/"); 19 | cy.selectBook(); 20 | cy.get("textarea[id='synopsis']").contains(text); 21 | cy.deleteBook(); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /cypress/e2e/Fullscreen.cy.ts: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | import settings from "../../settings.js"; 3 | 4 | const title = "a brand new title"; 5 | const text = "some text"; 6 | const text2 = "another brilliant change"; 7 | const historyPanel = "div[data-selector='history-panel']"; 8 | 9 | describe("fullscreen", () => { 10 | it("lets you enter and exit full screen mode", () => { 11 | cy.login(); 12 | 13 | cy.newBook(); 14 | 15 | cy.selectBook(); 16 | 17 | cy.newChapter(); 18 | 19 | cy.selectChapter(); 20 | 21 | cy.toggleRightSidebar(); 22 | // also show book and chapter lists 23 | //cy.get("button[data-selector='open-lists-button']").click(); 24 | 25 | cy.contains("span", "Minimize").should("not.exist"); 26 | 27 | // go full screen 28 | cy.get("button[data-selector='maximize-button']").click(); 29 | cy.contains("span", "Minimize"); 30 | cy.contains("span", "Maximize").should("not.exist"); 31 | cy.contains("h3", "1 chapter").should("not.exist"); 32 | 33 | // go back 34 | cy.get("button[data-selector='minimize-button']").click(); 35 | cy.contains("span", "Minimize").should("not.exist"); 36 | cy.contains("span", "Maximize"); 37 | cy.contains("h3", "1 chapter"); 38 | 39 | // go full screen 40 | cy.get("button[data-selector='maximize-button']").click(); 41 | cy.contains("span", "Minimize"); 42 | cy.contains("span", "Maximize").should("not.exist"); 43 | cy.contains("h3", "1 chapter").should("not.exist"); 44 | 45 | // go back 46 | cy.get("button[data-selector='close-sidebar-button']").click(); 47 | cy.contains("span", "Minimize").should("not.exist"); 48 | cy.contains("span", "Maximize"); 49 | cy.contains("h3", "1 chapter"); 50 | 51 | cy.deleteChapter(); 52 | cy.deleteBook(); 53 | 54 | // finally, delete the book so we're back to a clean slate 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /cypress/e2e/GridMode.cy.ts: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | import settings from "../../settings.js"; 3 | 4 | const title = "first chapter"; 5 | const text = "some important text"; 6 | 7 | /* describe("grid mode", () => { 8 | it("if you edit a chapter, move to another chapter, and then go back, your edits should show (issue #7)", () => { 9 | cy.login(); 10 | 11 | cy.newBook(); 12 | 13 | cy.get("a[data-selector='booklist-list-item-link']").click(); 14 | 15 | cy.contains("h3", "No chapters"); 16 | 17 | cy.newChapter(); 18 | cy.newChapter(); 19 | 20 | cy.get( 21 | "button[data-selector='chapter-menu-list-item-menu-button']", 22 | ).click(); 23 | 24 | cy.contains("div", "Grid mode"); 25 | 26 | cy.get( 27 | "div[data-selector='chapter-menu-list-item-button-Grid mode']", 28 | ).click(); 29 | 30 | cy.contains("h1", "Grid Mode"); 31 | cy.contains(".handle", "New chapter"); 32 | cy.get("button[data-selector='back-button']").click(); 33 | 34 | cy.deleteFirstChapter(); 35 | cy.deleteChapter(); 36 | cy.deleteBook(); 37 | 38 | // finally, delete the book so we're back to a clean slate 39 | }); 40 | }); 41 | */ -------------------------------------------------------------------------------- /cypress/e2e/History.cy.ts: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | import settings from "../../settings.js"; 3 | 4 | const title = "a brand new title"; 5 | const text = "some text"; 6 | const text2 = "another brilliant change"; 7 | const historyPanel = "div[data-selector='history-panel']"; 8 | 9 | describe("history", () => { 10 | it("adds to history on save", () => { 11 | cy.login(); 12 | 13 | cy.newBook(); 14 | 15 | cy.selectBook(); 16 | 17 | cy.contains("h3", "No chapters"); 18 | 19 | cy.newChapter(); 20 | 21 | cy.selectChapter(); 22 | 23 | cy.toggleRightSidebar(); 24 | cy.showHistory(); 25 | 26 | // no history yet 27 | cy.contains(historyPanel).should("not.exist"); 28 | 29 | // first sav 30 | cy.get(".ql-editor").last().type(`${text}{enter}`); 31 | cy.wait(2000); 32 | cy.manuallySave(); 33 | // TODO fix 34 | cy.contains(historyPanel, "some"); 35 | cy.get(historyPanel).should("have.length", 1); 36 | 37 | // second save 38 | cy.get(".ql-editor").last().type(`${text2}{enter}`); 39 | cy.manuallySave(); 40 | cy.contains(historyPanel, text2); 41 | cy.get(historyPanel).should("have.length", 2); 42 | 43 | // clicking history restores it 44 | cy.contains(".ql-editor", text2); 45 | cy.get(historyPanel).last().click(); 46 | cy.contains(".ql-editor", text2).should("not.exist"); 47 | cy.contains(".ql-editor", "some"); 48 | 49 | // restoring history doesn't add to history 50 | cy.get(historyPanel).should("have.length", 2); 51 | 52 | //cy.openLists(); 53 | cy.deleteChapter(); 54 | cy.deleteBook(); 55 | 56 | // finally, delete the book so we're back to a clean slate 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /cypress/e2e/Home.cy.ts: -------------------------------------------------------------------------------- 1 | describe("homepage", () => { 2 | it("loads", () => { 3 | cy.visit("http://localhost:80/"); 4 | // TODO failing on redirect 5 | /* cy.contains("h1", "Chisel editor"); */ 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /cypress/e2e/Login.cy.ts: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | import settings from "../../settings.js"; 3 | 4 | describe("login", () => { 5 | it("works", () => { 6 | cy.login(); 7 | // UI should reflect this user being logged in 8 | cy.contains("h3", "No books"); 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /cypress/e2e/Prompts.cy.ts: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | import settings from "../../settings.js"; 3 | 4 | const title = "a brand new title"; 5 | const text = "once upon a time,"; 6 | const text2 = "another brilliant change"; 7 | const historyPanel = "div[data-selector='history-panel']"; 8 | 9 | const promptLabel = "NewPrompt!"; 10 | const promptText = "This is a new prompt!"; 11 | 12 | describe("prompts", () => { 13 | it("should fetch ai text for a prompt", () => { 14 | cy.viewport(1980, 1080); 15 | 16 | cy.login(); 17 | 18 | cy.newBook(); 19 | 20 | cy.selectBook(); 21 | 22 | cy.newChapter(); 23 | 24 | cy.selectChapter(); 25 | 26 | cy.toggleRightSidebar(); 27 | 28 | cy.showSuggestions(); 29 | cy.get(`div[data-selector='ai-suggestion-panel']`).should("not.exist"); 30 | 31 | cy.get(".ql-editor").last().type(`${text}{enter}`); 32 | 33 | cy.togglePrompts(); 34 | cy.intercept({ 35 | method: "POST", 36 | url: "/api/suggestions", 37 | }).as("postSuggestions"); 38 | 39 | cy.get("[data-selector='prompt-Expand-button-list-item']").click(); 40 | 41 | cy.wait("@postSuggestions", { timeout: 15000 }); 42 | 43 | cy.get(`[data-selector='ai-suggestion-panel']`).should("exist"); 44 | cy.get(`[data-selector='ai-suggestion-panel']`).first().click(); 45 | 46 | cy.get("[data-selector='texteditor-0']").contains("once upon a"); 47 | 48 | cy.autoSave(); 49 | 50 | // go back, the new suggestion should be there 51 | cy.visit("http://localhost:80/"); 52 | 53 | cy.selectBook(); 54 | cy.selectChapter(); 55 | 56 | cy.get(`[data-selector='ai-suggestion-panel']`).should("exist"); 57 | 58 | cy.get(`[data-selector='ai-suggestion-panel']`).should("exist"); 59 | 60 | // delete it 61 | cy.get(`svg[data-selector='delete-ai-suggestion-panel']`).click(); 62 | 63 | cy.get(`[data-selector='ai-suggestion-panel']`).should("not.exist"); 64 | cy.autoSave(); 65 | // go back, the new suggestion should not be there anymore 66 | cy.visit("http://localhost:80/"); 67 | 68 | cy.selectBook(); 69 | cy.selectChapter(); 70 | 71 | cy.get(`[data-selector='ai-suggestion-panel']`).should("not.exist"); 72 | 73 | cy.deleteChapter(); 74 | cy.deleteBook(); 75 | 76 | // finally, delete the book so we're back to a clean slate 77 | }); 78 | }); 79 | -------------------------------------------------------------------------------- /cypress/e2e/Settings.cy.ts: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | import settings from "../../settings.js"; 3 | 4 | const title = "a brand new title"; 5 | const text = "some text"; 6 | const text2 = "another brilliant change"; 7 | const historyPanel = "div[data-selector='history-panel']"; 8 | 9 | const promptLabel = "NewPrompt!"; 10 | const promptText = "This is a new prompt!"; 11 | 12 | describe("settings", () => { 13 | it("adds a new prompt using settings", () => { 14 | cy.login(); 15 | 16 | cy.newBook(); 17 | 18 | cy.selectBook(); 19 | 20 | cy.newChapter(); 21 | 22 | cy.selectChapter(); 23 | 24 | cy.toggleRightSidebar(); 25 | cy.togglePrompts(); 26 | cy.showSettings(); 27 | 28 | cy.get("input[data-selector='prompt-Expand-label']").type( 29 | `{selectAll}{backspace}${promptLabel}` 30 | ); 31 | cy.get(`textarea[data-selector='prompt-${promptLabel}-text']`).type( 32 | `{selectAll}{backspace}${promptText}` 33 | ); 34 | 35 | // save! 36 | cy.manuallySave(); 37 | cy.wait(2000); 38 | // now the new prompt should be there 39 | cy.visit("http://localhost:80/"); 40 | 41 | cy.selectBook(); 42 | cy.selectChapter(); 43 | 44 | cy.get(`input[data-selector='prompt-${promptLabel}-label']`).should( 45 | "have.value", 46 | promptLabel 47 | ); 48 | 49 | // delete it 50 | cy.get( 51 | `button[data-selector='prompt-${promptLabel}-delete-button']` 52 | ).click(); 53 | 54 | cy.get(`input[data-selector='prompt-${promptLabel}-label']`).should( 55 | "not.exist" 56 | ); 57 | 58 | // add the expand prompt back 59 | cy.get("button[data-selector='sidebar-new-prompt-button']").click(); 60 | 61 | cy.get("input[data-selector='prompt-NewPrompt-label']").type( 62 | "{selectAll}{backspace}Expand" 63 | ); 64 | cy.get("textarea[data-selector='prompt-Expand-text']").type( 65 | "Write another paragraph for this text:" 66 | ); 67 | 68 | // save! 69 | cy.manuallySave(); 70 | 71 | // the prompt we just added should be there 72 | cy.visit("http://localhost:80/"); 73 | 74 | cy.selectBook(); 75 | cy.selectChapter(); 76 | 77 | cy.get(`input[data-selector='prompt-${promptLabel}-label']`).should( 78 | "not.exist" 79 | ); 80 | 81 | cy.get("input[data-selector='prompt-Expand-label']").should( 82 | "have.value", 83 | "Expand" 84 | ); 85 | //cy.openLists(); 86 | 87 | cy.deleteChapter(); 88 | cy.deleteBook(); 89 | 90 | // finally, delete the book so we're back to a clean slate 91 | }); 92 | }); 93 | -------------------------------------------------------------------------------- /cypress/e2e/UI.cy.ts: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | import settings from "../../settings.js"; 3 | 4 | describe("UI", () => { 5 | it("various UI tests", () => { 6 | cy.login(); 7 | 8 | cy.newBook(); 9 | 10 | cy.selectBook(); 11 | 12 | cy.contains("h3", "No chapters"); 13 | 14 | cy.newChapter(); 15 | 16 | cy.selectChapter(); 17 | 18 | // various UI elements show up 19 | cy.contains("span", "Saved"); 20 | 21 | // show sidebar 22 | cy.get("button[data-selector='sidebar-button']").click(); 23 | 24 | cy.get("button[data-selector='info-button']").click(); 25 | cy.contains("h3", "Info"); 26 | 27 | cy.get("button[data-selector='history-button']").click(); 28 | cy.contains("h3", "History"); 29 | 30 | cy.get("button[data-selector='settings-button']").click(); 31 | cy.contains("h3", "Settings"); 32 | 33 | cy.get("button[data-selector='suggestions-button']").click(); 34 | cy.contains("h3", "Suggestions"); 35 | 36 | // hide sidebar 37 | cy.get("button[data-selector='sidebar-button']").click(); 38 | cy.contains("h3", "Suggestions").should("not.exist"); 39 | 40 | // show prompts 41 | cy.get("button[data-selector='prompts-button']").click(); 42 | cy.contains("h3", "Prompts"); 43 | 44 | // esc hides ui 45 | cy.get("body").type("{esc}"); 46 | cy.contains("h3", "Suggestions").should("not.exist"); 47 | cy.contains("h3", "Prompts").should("not.exist"); 48 | cy.contains("h3", "1 chapter").should("not.exist"); 49 | cy.contains("h3", "1 book").should("not.exist"); 50 | 51 | // esc again shows ui, only what was opened before 52 | cy.get("body").type("{esc}"); 53 | //cy.contains("h3", "Suggestions"); 54 | cy.contains("h3", "Prompts"); 55 | //cy.contains("h3", "1 chapter"); 56 | //cy.contains("h3", "1 book"); 57 | 58 | cy.get("body").type("{command+shift+p}"); 59 | /* cy.contains("input[data-selector='launcher-search-input']"); 60 | */ cy.contains("ul", "New Chapter"); 61 | cy.get("body").type("{command+shift+p}"); 62 | 63 | cy.openLists(); 64 | cy.deleteChapter(); 65 | cy.deleteBook(); 66 | 67 | // finally, delete the book so we're back to a clean slate 68 | }); 69 | }); 70 | -------------------------------------------------------------------------------- /cypress/fixtures/example.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Using fixtures to represent data", 3 | "email": "hello@cypress.io", 4 | "body": "Fixtures are a great way to mock data for responses to routes" 5 | } 6 | -------------------------------------------------------------------------------- /cypress/support/e2e.ts: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/e2e.ts is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import './commands'; 18 | 19 | 20 | -------------------------------------------------------------------------------- /empty.tsx: -------------------------------------------------------------------------------- 1 | import "./src/globals.css"; 2 | -------------------------------------------------------------------------------- /home.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as ReactDOM from "react-dom/client"; 3 | import { BrowserRouter } from "react-router-dom"; 4 | import Home from "./src/Home"; 5 | import "./src/globals.css"; 6 | 7 | const domNode = document.getElementById("root"); 8 | const root = ReactDOM.createRoot(domNode); 9 | root.render( 10 | 11 | 12 | , 13 | ); 14 | -------------------------------------------------------------------------------- /library.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as ReactDOM from "react-dom/client"; 3 | import { BrowserRouter } from "react-router-dom"; 4 | import { Provider } from "react-redux"; 5 | import App from "./src/App"; 6 | import { store } from "./src/store"; 7 | 8 | const domNode = document.getElementById("root"); 9 | const root = ReactDOM.createRoot(domNode); 10 | root.render( 11 | 12 | 13 | 14 | 15 | 16 | ); 17 | 18 | const registerServiceWorker = async () => { 19 | if ("serviceWorker" in navigator) { 20 | try { 21 | const registration = await navigator.serviceWorker.register("/sw.js", { 22 | scope: "/", 23 | }); 24 | if (registration.installing) { 25 | console.log("Service worker installing"); 26 | } else if (registration.waiting) { 27 | console.log("Service worker installed"); 28 | } else if (registration.active) { 29 | console.log("Service worker active"); 30 | } 31 | } catch (error) { 32 | console.error(`Registration failed with ${error}`); 33 | } 34 | } 35 | }; 36 | 37 | registerServiceWorker(); 38 | 39 | document.addEventListener("copy", function (e) { 40 | const text_only = document 41 | .getSelection() 42 | .toString() 43 | .replaceAll("“", '"') 44 | .replaceAll("”", '"'); 45 | // @ts-ignore 46 | const clipdata = e.clipboardData || window.clipboardData; 47 | 48 | // i.e. for vs code 49 | clipdata.setData("text/plain", text_only); 50 | 51 | // i.e. for google docs, email, slack. 52 | // Disabling, because if html is not set, it will grab the text/plain version. 53 | // Otherwise it will try to render `text_only` as html, which removes all line breaks. 54 | // clipdata.setData("text/html", text_only); 55 | e.preventDefault(); 56 | }); 57 | 58 | window.addEventListener("scroll", (e) => { 59 | console.log("stop scrolling"); 60 | e.preventDefault(); 61 | window.scroll(0, 0); 62 | }); 63 | 64 | //window.addEventListener("focus", (e) => window.location.reload()); 65 | 66 | // https://stackoverflow.com/a/46722645 67 | // because we scroll #editDiv instead of the window, we want to control and restore the scroll ourselves. 68 | history.scrollRestoration = "manual"; 69 | -------------------------------------------------------------------------------- /login.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as ReactDOM from "react-dom/client"; 3 | import { BrowserRouter } from "react-router-dom"; 4 | import { Provider } from "react-redux"; 5 | import { store } from "./src/store"; 6 | import Login from "./src/Login"; 7 | import "./src/globals.css"; 8 | import LibraryContext from "./src/LibraryContext"; 9 | const domNode = document.getElementById("root"); 10 | const root = ReactDOM.createRoot(domNode); 11 | 12 | root.render( 13 | 14 | 15 | 16 | 17 | 18 | ); 19 | -------------------------------------------------------------------------------- /mobile.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as ReactDOM from "react-dom/client"; 3 | import { BrowserRouter } from "react-router-dom"; 4 | import { Provider } from "react-redux"; 5 | import { store } from "./src/store"; 6 | import AppMobile from "./src/AppMobile"; 7 | 8 | const domNode = document.getElementById("root"); 9 | const root = ReactDOM.createRoot(domNode); 10 | root.render( 11 | 12 | 13 | 14 | 15 | 16 | ); 17 | -------------------------------------------------------------------------------- /pages/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | <%= htmlWebpackPlugin.options.title %> 9 | 10 | 11 | 12 | 13 |

404

14 |

Page not found

15 | 16 | -------------------------------------------------------------------------------- /pages/admin.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | <%= htmlWebpackPlugin.options.title %> 10 | 11 | 12 | 13 | 14 | 15 |
16 | 17 | -------------------------------------------------------------------------------- /pages/library.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | <%= htmlWebpackPlugin.options.title %> 28 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 |
45 | 46 | -------------------------------------------------------------------------------- /pages/login-base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | <%= htmlWebpackPlugin.options.title %> 10 | 11 | 15 | 16 | 17 |
18 | 19 | -------------------------------------------------------------------------------- /pages/mobile.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 20 | 21 | <%= htmlWebpackPlugin.options.title %> 22 | 23 | 24 | 25 | 26 |
27 | 28 | -------------------------------------------------------------------------------- /pages/privacy.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | <%= htmlWebpackPlugin.options.title %> 9 | 10 | 11 | 12 |

Privacy Policy

13 |

Chisel uses cookies to keep you logged in, and uses Google Analytics for analytics. We also DO NOT ENCRYPT what you write. That means if an attacker were to gain access to the database, they would be able to see what you have written. If you'd like to help fix this, please submit a PR! In the meantime we highly encourage you to set up your own instance of Chisel. We have no control over, and assume no responsibility for the content, privacy policies or practices of any third party sites, products or services.

14 | 15 |

Privacy Policy Changes

16 |

Although most changes are likely to be minor, we may change the Privacy Policy from time to time, and in our sole discretion. We encourage visitors to frequently check this page for any changes to Chisel's Privacy Policy. Your continued use of this site after any change in this Privacy Policy will constitute your acceptance of such change.

17 | Hey I want better privacy and will help improve it for everyone 18 | 19 | -------------------------------------------------------------------------------- /postcss.config.cjs: -------------------------------------------------------------------------------- 1 | const tailwindcss = require("tailwindcss"); 2 | 3 | module.exports = { 4 | plugins: ["postcss-preset-env", tailwindcss], 5 | }; 6 | -------------------------------------------------------------------------------- /public/chisel-logo-1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/egonSchiele/chisel/550b6fba61f556ba83ef6038a779ce4c05ae1932/public/chisel-logo-1024.png -------------------------------------------------------------------------------- /public/chisel-logo-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/egonSchiele/chisel/550b6fba61f556ba83ef6038a779ce4c05ae1932/public/chisel-logo-128.png -------------------------------------------------------------------------------- /public/chisel-logo-256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/egonSchiele/chisel/550b6fba61f556ba83ef6038a779ce4c05ae1932/public/chisel-logo-256.png -------------------------------------------------------------------------------- /public/chisel-logo-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/egonSchiele/chisel/550b6fba61f556ba83ef6038a779ce4c05ae1932/public/chisel-logo-512.png -------------------------------------------------------------------------------- /public/css/eb-garamond-400-600.css: -------------------------------------------------------------------------------- 1 | 2 | /* latin */ 3 | @font-face { 4 | font-family: 'EB Garamond'; 5 | font-style: normal; 6 | font-weight: 400; 7 | font-display: swap; 8 | src: url(/css/latin.woff2) format('woff2'); 9 | unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; 10 | } 11 | 12 | 13 | /* latin */ 14 | @font-face { 15 | font-family: 'EB Garamond'; 16 | font-style: normal; 17 | font-weight: 600; 18 | font-display: swap; 19 | src: url(/css/latin.woff2) format('woff2'); 20 | unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; 21 | } 22 | -------------------------------------------------------------------------------- /public/css/latin.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/egonSchiele/chisel/550b6fba61f556ba83ef6038a779ce4c05ae1932/public/css/latin.woff2 -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/egonSchiele/chisel/550b6fba61f556ba83ef6038a779ce4c05ae1932/public/favicon.ico -------------------------------------------------------------------------------- /public/images/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/egonSchiele/chisel/550b6fba61f556ba83ef6038a779ce4c05ae1932/public/images/.DS_Store -------------------------------------------------------------------------------- /public/images/focusmode.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/egonSchiele/chisel/550b6fba61f556ba83ef6038a779ce4c05ae1932/public/images/focusmode.png -------------------------------------------------------------------------------- /public/images/full.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/egonSchiele/chisel/550b6fba61f556ba83ef6038a779ce4c05ae1932/public/images/full.png -------------------------------------------------------------------------------- /public/images/gridmode.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/egonSchiele/chisel/550b6fba61f556ba83ef6038a779ce4c05ae1932/public/images/gridmode.png -------------------------------------------------------------------------------- /public/images/history.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/egonSchiele/chisel/550b6fba61f556ba83ef6038a779ce4c05ae1932/public/images/history.png -------------------------------------------------------------------------------- /public/images/launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/egonSchiele/chisel/550b6fba61f556ba83ef6038a779ce4c05ae1932/public/images/launcher.png -------------------------------------------------------------------------------- /public/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/egonSchiele/chisel/550b6fba61f556ba83ef6038a779ce4c05ae1932/public/images/logo.png -------------------------------------------------------------------------------- /public/images/prompts.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/egonSchiele/chisel/550b6fba61f556ba83ef6038a779ce4c05ae1932/public/images/prompts.png -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Chisel Editor", 3 | "short_name": "Chisel", 4 | "start_url": "https://chiseleditor.com", 5 | "background_color": "rgb(17,24,39)", 6 | "display": "fullscreen", 7 | "icons": [{ 8 | "src": "images/logo.png", 9 | "sizes": "512x512", 10 | "type": "image/png" 11 | }] 12 | } -------------------------------------------------------------------------------- /settings.example.js: -------------------------------------------------------------------------------- 1 | export default { 2 | openAiApiKey: "", 3 | replicateApiKey: "", 4 | huggingFaceApiKey: "", 5 | awsAccessKeyId: "", 6 | awsSecretAccessKey: "", 7 | localAiEndpoint: "", 8 | maxMonthlyTokens: 100000, 9 | maxMonthlyGuestTokens: 50000, 10 | maxPromptLength: 2048, 11 | maxTokens: 4000, 12 | maxSuggestions: 3, 13 | storage: "firebase", 14 | tokenSalt: "", 15 | firebaseConfig: { 16 | apiKey: "", 17 | authDomain: "", 18 | projectId: "", 19 | storageBucket: "", 20 | messagingSenderId: "", 21 | appId: "", 22 | measurementId: "", 23 | }, 24 | limits: { 25 | chapterLength: -1, // in characters 26 | historyLength: -1, 27 | }, 28 | // optional, needed for integration testing 29 | /* testuser: { 30 | userid: '', 31 | email: '', 32 | password: '' 33 | } */ 34 | }; 35 | -------------------------------------------------------------------------------- /setupJestMock.ts: -------------------------------------------------------------------------------- 1 | const localStorageMock = { 2 | store: {}, 3 | getItem(key) { 4 | return this.store[key]; 5 | }, 6 | setItem(key, value) { 7 | this.store[key] = value.toString(); 8 | }, 9 | clear() { 10 | this.store = {}; 11 | }, 12 | removeItem(key) { 13 | delete this.store[key]; 14 | }, 15 | }; 16 | 17 | Object.defineProperty(global, 'localStorage', { value: localStorageMock }); 18 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import { Routes, Route, useParams, useLocation } from "react-router-dom"; 2 | import React from "react"; 3 | /* import Book from "./Book"; 4 | */ import Library from "./Library"; 5 | 6 | const DebugRouter = ({ children }: { children: any }) => { 7 | const location = useLocation(); 8 | 9 | console.log( 10 | `Route: ${location.pathname}${location.search}, State: ${JSON.stringify( 11 | location.state 12 | )}` 13 | ); 14 | 15 | return children; 16 | }; 17 | 18 | export default function App() { 19 | return ( 20 |
21 | 22 | 23 | } 26 | /> 27 | } 30 | /> 31 | } /> 32 | } /> 33 | } /> 34 | {/* } /> 35 | */}{" "} 36 | 37 | 38 |
39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /src/AppMobile.tsx: -------------------------------------------------------------------------------- 1 | import { Routes, Route } from "react-router-dom"; 2 | import React from "react"; 3 | 4 | import LibraryMobile from "./LibraryMobile"; 5 | import Library from "./Library"; 6 | 7 | export default function AppMobile() { 8 | return ( 9 |
10 | 11 | } 14 | /> 15 | } 18 | /> 19 | } /> 20 | } /> 21 | 22 |
23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /src/BlocksSidebar.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Bars3Icon, 3 | InformationCircleIcon, 4 | Square2StackIcon, 5 | } from "@heroicons/react/24/outline"; 6 | import React, { useState } from "react"; 7 | import { useDispatch, useSelector } from "react-redux"; 8 | import { getText, librarySlice } from "./reducers/librarySlice"; 9 | import { RootState } from "./store"; 10 | 11 | import { Tab } from "@headlessui/react"; 12 | import BlockActionsSidebar from "./BlockActionsSidebar"; 13 | import BlockInfoSidebar from "./BlockInfoSidebar"; 14 | import OutlineSidebar from "./OutlineSidebar"; 15 | import VersionsSidebar from "./VersionsSidebar"; 16 | import { useColors } from "./lib/hooks"; 17 | import { classNames } from "./utils"; 18 | 19 | export default function BlockSidebar({}: {}) { 20 | //const [selectedIndex, setSelectedIndex] = useState(tabIndex); 21 | 22 | const state = useSelector((state: RootState) => state.library.editor); 23 | const tab = useSelector( 24 | (state: RootState) => state.library.panels.leftSidebar.activePanel 25 | ); 26 | 27 | const index = state.activeTextIndex; 28 | const currentText = useSelector(getText(index)); 29 | const colors = useColors(); 30 | const dispatch = useDispatch(); 31 | 32 | let selectedIndex = 0; 33 | if (tab === "versions") selectedIndex = 1; 34 | 35 | function setSelectedIndex(index: number) { 36 | if (index === 0) { 37 | dispatch(librarySlice.actions.toggleBlocks()); 38 | } else if (index === 1) { 39 | dispatch(librarySlice.actions.toggleVersions()); 40 | } 41 | } 42 | 43 | if (!currentText) return null; 44 | function getClassNames({ selected }) { 45 | const defaultClasses = 46 | "w-full py-1 text-sm font-medium text-center focus:outline-none"; 47 | return classNames( 48 | defaultClasses, 49 | selected ? `${colors.background}` : `${colors.selectedBackground}` 50 | ); 51 | } 52 | return ( 53 |
54 | 55 | 56 | 57 | 60 | 61 | 62 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 |
77 | ); 78 | } 79 | -------------------------------------------------------------------------------- /src/EditHistorySidebar.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useDispatch, useSelector } from "react-redux"; 3 | import { useNavigate } from "react-router-dom"; 4 | import * as t from "./Types"; 5 | import List from "./components/List"; 6 | import ListItem from "./components/ListItem"; 7 | import { useColors } from "./lib/hooks"; 8 | import { 9 | getSelectedBook, 10 | getSelectedChapter, 11 | librarySlice, 12 | } from "./reducers/librarySlice"; 13 | import { RootState } from "./store"; 14 | 15 | export default function EditHistorySidebar() { 16 | const editHistory: t.EditHistory[] = useSelector( 17 | (state: RootState) => state.library.editHistory 18 | ); 19 | const currentBook = useSelector(getSelectedBook); 20 | const currentChapter = useSelector(getSelectedChapter); 21 | const dispatch = useDispatch(); 22 | const navigate = useNavigate(); 23 | const colors = useColors(); 24 | 25 | if (!currentChapter) return null; 26 | const items = editHistory.map((history, i) => { 27 | return ( 28 | { 34 | dispatch(librarySlice.actions.restoreFromEditHistory(i)); 35 | }} 36 | /> 37 | ); 38 | }); 39 | 40 | return ( 41 | 48 | ); 49 | } 50 | -------------------------------------------------------------------------------- /src/FocusChecksSidebar.tsx: -------------------------------------------------------------------------------- 1 | import sortBy from "lodash/sortBy"; 2 | 3 | import { daleChall } from "dale-chall"; 4 | import { stemmer } from "stemmer"; 5 | import range from "lodash/range"; 6 | import { XMarkIcon } from "@heroicons/react/24/outline"; 7 | import React, { useEffect, useRef, useState } from "react"; 8 | import { hedges } from "hedges"; 9 | import NavButton from "./components/NavButton"; 10 | import { fillers } from "fillers"; 11 | import cliches from "./lib/cliches"; 12 | import List from "./components/List"; 13 | import { syllable } from "syllable"; 14 | import Button from "./components/Button"; 15 | import jargon from "./jargon"; 16 | import { normalize, findSubarray, split } from "./utils"; 17 | import * as fd from "./lib/fetchData"; 18 | import { useKeyboardScroll } from "./lib/hooks"; 19 | import { useDispatch, useSelector } from "react-redux"; 20 | import { RootState } from "./store"; 21 | import { useNavigate } from "react-router-dom"; 22 | import { 23 | getSelectedBook, 24 | getSelectedChapter, 25 | librarySlice, 26 | } from "./reducers/librarySlice"; 27 | import { EditorState } from "./Types"; 28 | import ListItem from "./components/ListItem"; 29 | 30 | export default function FocusSidebar() { 31 | const state: EditorState = useSelector( 32 | (state: RootState) => state.library.editor 33 | ); 34 | const currentBook = useSelector(getSelectedBook); 35 | const index = state.activeTextIndex; 36 | const currentChapter = useSelector(getSelectedChapter); 37 | const dispatch = useDispatch(); 38 | const navigate = useNavigate(); 39 | 40 | const items = [ 41 | , 49 | ]; 50 | const byIndex = (a) => a.range.index; 51 | if (state.focusModeChecks) { 52 | sortBy(state.focusModeChecks, byIndex).forEach((check, i) => { 53 | const content = check.content.substring(0, 20); 54 | items.push( 55 |
  • 56 | { 60 | dispatch(librarySlice.actions.setSelection(check.range)); 61 | }} 62 | /* onMouseEnter={() => { 63 | dispatch(librarySlice.actions.setSelection(check.range)); 64 | }} 65 | onMouseLeave={() => { 66 | dispatch(librarySlice.actions.clearPushSelectionToEditor()); 67 | }} */ 68 | /> 69 |
  • 70 | ); 71 | }); 72 | } 73 | return ( 74 | 81 | ); 82 | } 83 | -------------------------------------------------------------------------------- /src/FocusSidebar.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Bars3Icon, 3 | BeakerIcon, 4 | BugAntIcon, 5 | InformationCircleIcon, 6 | } from "@heroicons/react/24/outline"; 7 | import React, { useState } from "react"; 8 | import { useSelector } from "react-redux"; 9 | import { getText } from "./reducers/librarySlice"; 10 | import { RootState } from "./store"; 11 | 12 | import { Tab } from "@headlessui/react"; 13 | import BlockActionsSidebar from "./BlockActionsSidebar"; 14 | import BlockInfoSidebar from "./BlockInfoSidebar"; 15 | import OutlineSidebar from "./OutlineSidebar"; 16 | import FocusChecksSidebar from "./FocusChecksSidebar"; 17 | import SynonymsSidebar from "./SynonymsSidebar"; 18 | import { useColors } from "./lib/hooks"; 19 | 20 | function classNames(...classes) { 21 | return classes.filter(Boolean).join(" "); 22 | } 23 | 24 | export default function FocusSidebar({ tabIndex = 1 }: { tabIndex?: number }) { 25 | const [selectedIndex, setSelectedIndex] = useState(tabIndex); 26 | 27 | const state = useSelector((state: RootState) => state.library.editor); 28 | const index = state.activeTextIndex; 29 | const currentText = useSelector(getText(index)); 30 | const colors = useColors(); 31 | if (!currentText) return null; 32 | function getClassNames({ selected }) { 33 | const defaultClasses = 34 | "w-full py-1 text-sm font-medium text-center focus:outline-none"; 35 | return classNames( 36 | defaultClasses, 37 | selected ? colors.selectedBackground : colors.background 38 | ); 39 | } 40 | return ( 41 |
    42 | 43 | 44 | 45 | 48 | 49 | 50 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 |
    65 | ); 66 | } 67 | -------------------------------------------------------------------------------- /src/Help.tsx: -------------------------------------------------------------------------------- 1 | /* import levenshtein from "js-levenshtein"; 2 | import intersection from "lodash/intersection"; 3 | */ import sortBy from "lodash/sortBy"; 4 | import { apStyleTitleCase } from "ap-style-title-case"; 5 | import React, { Fragment, useState, useEffect } from "react"; 6 | import { MagnifyingGlassIcon } from "@heroicons/react/20/solid"; 7 | import { Combobox, Dialog, Transition } from "@headlessui/react"; 8 | import { MenuItem } from "./Types"; 9 | 10 | function classNames(...classes) { 11 | return classes.filter(Boolean).join(" "); 12 | } 13 | 14 | export default function Help({}: {}) { 15 | return ( 16 | 17 | {}}> 18 | 27 |
    28 | 29 | 30 |
    31 | 40 | 41 |
    42 |

    Help

    43 |

    Keyboard shortcuts:

    44 |
      45 |
    • Command+Shift+P: Open command palette
    • 46 |
    • Command+Shift+O: Open file navigator
    • 47 |
    48 | 49 | Docs 50 | 51 |
    52 |
    53 |
    54 |
    55 |
    56 |
    57 | ); 58 | } 59 | -------------------------------------------------------------------------------- /src/Info.test.tsx: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | import renderer from "react-test-renderer"; 3 | import React from "react"; 4 | import Info from "./Info"; 5 | import { Provider } from "react-redux"; 6 | 7 | import { store } from "./store"; 8 | 9 | it("shows the info panel", () => { 10 | const component = renderer.create( 11 | 12 | 13 | 14 | ); 15 | const tree = component.toJSON(); 16 | expect(tree).toMatchSnapshot(); 17 | }); 18 | -------------------------------------------------------------------------------- /src/LibraryContext.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from "react"; 2 | import * as t from "./Types"; 3 | const LibraryContext = createContext(null); 4 | export default LibraryContext; 5 | -------------------------------------------------------------------------------- /src/Login.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Auth from "./Auth"; 3 | import { Routes, Route } from "react-router-dom"; 4 | import Library from "./Library"; 5 | import { setCookie } from "./utils"; 6 | 7 | function AuthApp() { 8 | const [error, setError] = React.useState(null); 9 | async function submitLogin(email, password) { 10 | await submitBase("/auth/login", email, password); 11 | } 12 | 13 | async function submitRegister(email, password) { 14 | await submitBase("/auth/register", email, password); 15 | } 16 | async function submitBase(url, email, password) { 17 | const res = await fetch(url, { 18 | method: "POST", 19 | headers: { 20 | "Content-Type": "application/json", 21 | }, 22 | body: JSON.stringify({ email, password }), 23 | }); 24 | const json = await res.json(); 25 | if (!res.ok) { 26 | setError(json.message); 27 | return; 28 | } else { 29 | setError(null); 30 | const { userid, token } = json; 31 | 32 | setCookie("userid", userid, 14); 33 | setCookie("token", token, 14); 34 | window.location.href = "/"; 35 | } 36 | } 37 | const login = ( 38 | 46 | ); 47 | 48 | const register = ( 49 | 57 | ); 58 | 59 | return ( 60 |
    61 | 62 | 63 | 64 | 65 | 66 | 67 |
    68 | ); 69 | } 70 | 71 | export default function Login() { 72 | return ( 73 |
    74 | 75 |
    76 | ); 77 | } 78 | -------------------------------------------------------------------------------- /src/MultipleChoicePopup.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import * as t from "./Types"; 3 | import { useColors } from "./lib/hooks"; 4 | import { useDispatch } from "react-redux"; 5 | import { librarySlice } from "./reducers/librarySlice"; 6 | import { XMarkIcon } from "@heroicons/react/24/outline"; 7 | 8 | function Option({ 9 | option, 10 | onClick, 11 | }: { 12 | option: t.MultipleChoiceOption; 13 | onClick: (value: string) => void; 14 | }) { 15 | const colors = useColors(); 16 | return ( 17 |
    { 20 | onClick(option.value); 21 | }} 22 | > 23 | {option.label} 24 |
    25 | ); 26 | } 27 | 28 | export default function MultipleChoicePopup({ 29 | options, 30 | onClick, 31 | title = "", 32 | className = "", 33 | }: { 34 | options: t.MultipleChoiceOption[]; 35 | onClick: (value: string) => void; 36 | title?: string; 37 | className?: string; 38 | }) { 39 | const dispatch = useDispatch(); 40 | function _onClick(value) { 41 | onClick(value); 42 | dispatch(librarySlice.actions.hideMultipleChoicePopup()); 43 | } 44 | return ( 45 |
    48 | {title &&

    {title}

    } 49 | {options.map((option, i) => ( 50 |
    59 | ); 60 | } 61 | -------------------------------------------------------------------------------- /src/ProgressBar.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useSelector } from "react-redux"; 3 | import { 4 | getProgress, 5 | getSelectedChapterVisibleTextLength, 6 | } from "./reducers/librarySlice"; 7 | import { RootState } from "./store"; 8 | 9 | export default function ProgressBar({}) { 10 | const progress = useSelector(getProgress); 11 | /* console.log("progress", progress); */ 12 | if (progress === null) return null; 13 | return ( 14 |
    18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 34 | 35 |
    36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /src/PromptsSidebar.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from "react"; 2 | import { useDispatch } from "react-redux"; 3 | import LibraryContext from "./LibraryContext"; 4 | import * as t from "./Types"; 5 | import List from "./components/List"; 6 | import ListItem from "./components/ListItem"; 7 | 8 | export default function PromptsSidebar({}: {}) { 9 | // const state = useSelector((state: RootState) => state.library.editor); 10 | const dispatch = useDispatch(); 11 | const { settings, fetchSuggestions } = useContext( 12 | LibraryContext 13 | ) as t.LibraryContextType; 14 | 15 | const prompts = settings.prompts.map((prompt, i) => ( 16 |
  • 17 | { 22 | await fetchSuggestions(prompt, []); 23 | }} 24 | selector={`prompt-${prompt.label}-button`} 25 | /> 26 |
  • 27 | )); 28 | 29 | return ( 30 | 37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /src/PublishSidebar.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext, useEffect, useState } from "react"; 2 | import { useDispatch, useSelector } from "react-redux"; 3 | import { useNavigate } from "react-router-dom"; 4 | import LibraryContext from "./LibraryContext"; 5 | import * as t from "./Types"; 6 | import Button from "./components/Button"; 7 | import List from "./components/List"; 8 | import { 9 | getSelectedBook, 10 | getSelectedChapter, 11 | getText, 12 | librarySlice, 13 | } from "./reducers/librarySlice"; 14 | import { RootState } from "./store"; 15 | import { useColors } from "./lib/hooks"; 16 | import { 17 | ChevronLeftIcon, 18 | ChevronRightIcon, 19 | Square2StackIcon, 20 | } from "@heroicons/react/24/outline"; 21 | import * as fd from "./lib/fetchData"; 22 | import Switch from "./components/Switch"; 23 | import { isTextishBlock, prettySeconds, useInterval } from "./utils"; 24 | import InfoSection from "./components/InfoSection"; 25 | import Spinner from "./components/Spinner"; 26 | import { set } from "cypress/types/lodash"; 27 | 28 | export default function PublishSidebar() { 29 | const state = useSelector((state: RootState) => state.library.editor); 30 | const currentBook = useSelector(getSelectedBook); 31 | const index = state.activeTextIndex; 32 | const currentChapter = useSelector(getSelectedChapter); 33 | const dispatch = useDispatch(); 34 | const navigate = useNavigate(); 35 | const colors = useColors(); 36 | const [speechTaskID, setSpeechTaskID] = React.useState(""); 37 | const [speechTaskStatus, setSpeechTaskStatus] = React.useState(""); 38 | const [fullChapter, setFullChapter] = React.useState(false); 39 | const [audioBlob, setAudioBlob] = React.useState(null); 40 | const [loading, setLoading] = React.useState(false); 41 | if (!currentChapter) return null; 42 | 43 | const items = []; 44 | const spinner = { 45 | label: "Loading", 46 | icon: , 47 | onClick: () => {}, 48 | }; 49 | return ( 50 | 57 | ); 58 | } 59 | -------------------------------------------------------------------------------- /src/ReadOnlyView.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from "react"; 2 | import * as t from "./Types"; 3 | import CodeBlock from "./components/CodeBlock"; 4 | import MarkdownBlock from "./components/MarkdownBlock"; 5 | import { Link } from "react-router-dom"; 6 | import { useSelector } from "react-redux"; 7 | import { RootState } from "./store"; 8 | import LibraryContext from "./LibraryContext"; 9 | import { getFontSizeClass } from "./utils"; 10 | import ImageBlock from "./components/ImageBlock"; 11 | export default function ReadOnlyView({ textBlocks, fontClass }) { 12 | const state: t.State = useSelector((state: RootState) => state.library); 13 | const { settings } = useContext(LibraryContext) as t.LibraryContextType; 14 | let fontSize = settings.design?.fontSize || 18; 15 | const fontSizeClass = getFontSizeClass(fontSize); 16 | 17 | return textBlocks.map((text: t.TextBlock, index) => { 18 | if (text.type === "code") { 19 | return ( 20 | 21 | ); 22 | } else if (text.type === "markdown") { 23 | return ( 24 | 25 | ); 26 | } else if (text.type === "image") { 27 | return ; 28 | } else if (text.type === "embeddedText") { 29 | let chapter = null; 30 | let book = null; 31 | if (text.bookid) { 32 | book = state.books.find((book) => book.bookid === text.bookid); 33 | if (book) { 34 | chapter = book.chapters.find( 35 | (chapter) => chapter.chapterid === text.chapterid 36 | ); 37 | } 38 | } 39 | if (chapter) { 40 | return ( 41 | t.open)} 43 | fontClass={fontClass} 44 | /> 45 | ); 46 | } else { 47 | return null; 48 | } 49 | } else { 50 | return ( 51 |
    52 |
    53 |             {text.text}
    54 |           
    55 |
    56 | ); 57 | } 58 | }); 59 | } 60 | -------------------------------------------------------------------------------- /src/SearchSidebar.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Bars3Icon, 3 | BeakerIcon, 4 | ComputerDesktopIcon, 5 | InformationCircleIcon, 6 | MagnifyingGlassIcon, 7 | Square2StackIcon, 8 | } from "@heroicons/react/24/outline"; 9 | import React, { useState } from "react"; 10 | import { useDispatch, useSelector } from "react-redux"; 11 | import { getText, librarySlice } from "./reducers/librarySlice"; 12 | import { RootState } from "./store"; 13 | 14 | import { Tab } from "@headlessui/react"; 15 | import BlockActionsSidebar from "./BlockActionsSidebar"; 16 | import BlockInfoSidebar from "./BlockInfoSidebar"; 17 | import OutlineSidebar from "./OutlineSidebar"; 18 | import VersionsSidebar from "./VersionsSidebar"; 19 | import { useColors } from "./lib/hooks"; 20 | import SimpleSearchSidebar from "./SimpleSearchSidebar"; 21 | import AskAQuestionSidebar from "./AskAQuestionSidebar"; 22 | import ReplaceSidebar from "./ReplaceSidebar"; 23 | 24 | function classNames(...classes) { 25 | return classes.filter(Boolean).join(" "); 26 | } 27 | 28 | export default function SearchSidebar({}: {}) { 29 | //const [selectedIndex, setSelectedIndex] = useState(tabIndex); 30 | 31 | const dispatch = useDispatch(); 32 | 33 | const colors = useColors(); 34 | 35 | /* const tab = useSelector( 36 | (state: RootState) => state.library.panels.leftSidebar.activePanel 37 | ); 38 | let selectedIndex = 0; 39 | if (tab === "versions") selectedIndex = 1; 40 | 41 | function setSelectedIndex(index: number) { 42 | if (index === 0) { 43 | dispatch(librarySlice.actions.toggleBlocks()); 44 | } else if (index === 1) { 45 | dispatch(librarySlice.actions.toggleVersions()); 46 | } 47 | } */ 48 | 49 | const [selectedIndex, setSelectedIndex] = useState(0); 50 | 51 | function getClassNames({ selected }) { 52 | const defaultClasses = 53 | "w-full py-1 text-sm font-medium text-center focus:outline-none"; 54 | return classNames( 55 | defaultClasses, 56 | selected ? `${colors.background}` : `${colors.selectedBackground}` 57 | ); 58 | } 59 | return ( 60 |
    61 | 62 | 63 | 64 | 67 | 68 | 69 | 72 | 73 | 74 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 |
    92 | ); 93 | } 94 | -------------------------------------------------------------------------------- /src/ShowAllVersions.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useDispatch, useSelector } from "react-redux"; 3 | import { useNavigate } from "react-router-dom"; 4 | import { EditorState } from "./Types"; 5 | import Button from "./components/Button"; 6 | import List from "./components/List"; 7 | import ListItem from "./components/ListItem"; 8 | import sortBy from "lodash/sortBy"; 9 | 10 | import { 11 | getSelectedBook, 12 | getSelectedChapter, 13 | librarySlice, 14 | } from "./reducers/librarySlice"; 15 | import { RootState } from "./store"; 16 | import { useColors, useFonts } from "./lib/hooks"; 17 | import { XMarkIcon } from "@heroicons/react/24/outline"; 18 | import { nanoid } from "nanoid"; 19 | 20 | export default function ShowAllVersions({ index }) { 21 | const state: EditorState = useSelector( 22 | (state: RootState) => state.library.editor 23 | ); 24 | const currentBook = useSelector(getSelectedBook); 25 | 26 | const currentChapter = useSelector(getSelectedChapter); 27 | const currentText = currentChapter.text[index]; 28 | const dispatch = useDispatch(); 29 | const navigate = useNavigate(); 30 | const colors = useColors(); 31 | const { fontClass, fontSizeClass } = useFonts(); 32 | const items = []; 33 | 34 | if (currentText.type === "embeddedText") { 35 | return

    Embedded text blocks cannot have versions

    ; 36 | } 37 | const versions = []; // [{ text: currentText.text, id: null }]; 38 | if (currentText.versions) { 39 | versions.push(...currentText.versions); 40 | } 41 | items.push( 42 |

    48 | {versions.length} Other {versions.length === 1 ? "Version" : "Versions"} 49 |

    50 | ); 51 | sortBy(versions, ["text"]).forEach((version, i) => { 52 | items.push( 53 |
    { 57 | if (version.id !== null) { 58 | dispatch( 59 | librarySlice.actions.switchVersion({ 60 | index, 61 | versionid: version.id, 62 | }) 63 | ); 64 | } 65 | dispatch(librarySlice.actions.setActiveTextIndex(index)); 66 | dispatch( 67 | librarySlice.actions.toggleShowAllVersions({ 68 | index, 69 | }) 70 | ); 71 | }} */ 72 | > 73 |
    76 |           {version.text}
    77 |         
    78 |
    79 | ); 80 | }); 81 | 82 | return ( 83 |
    88 | {items} 89 |
    90 | ); 91 | } 92 | -------------------------------------------------------------------------------- /src/SuggestionPanel.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Panel from "./components/Panel"; 3 | import { useColors, useFonts } from "./lib/hooks"; 4 | import { librarySlice } from "./reducers/librarySlice"; 5 | import { useDispatch, useSelector } from "react-redux"; 6 | import { AppDispatch, RootState } from "./store"; 7 | import Button from "./components/Button"; 8 | export default function SuggestionPanel({ 9 | title, 10 | contents, 11 | onDelete, 12 | index, 13 | savedForLater, 14 | }) { 15 | const activeTextIndex = useSelector( 16 | (state: RootState) => state.library.editor.activeTextIndex 17 | ); 18 | const colors = useColors(); 19 | const { fontSizeClass } = useFonts(); 20 | const dispatch = useDispatch(); 21 | 22 | return ( 23 |
    24 | { 28 | dispatch(librarySlice.actions.addToContents(contents)); 29 | }} 30 | onDelete={onDelete} 31 | selector="ai-suggestion-panel" 32 | > 33 |
    {contents}
    34 |
    35 |
    36 | 50 | 64 |
    65 |
    66 | ); 67 | } 68 | -------------------------------------------------------------------------------- /src/__snapshots__/Info.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`shows the info panel 1`] = ` 4 |
    7 |

    8 | 3 9 | 10 | 13 | words 14 | 15 |

    16 |

    17 | 5 18 | 19 | 22 | syllables 23 | 24 |

    25 |

    26 | 4 27 | 28 | 31 | tokens (estimate) 32 | 33 |

    34 |

    35 | 1 min read 36 | 37 | 40 |

    41 |
    42 | `; 43 | -------------------------------------------------------------------------------- /src/components/Button.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { ButtonSize } from "../Types"; 3 | import { useColors } from "../lib/hooks"; 4 | 5 | export default function Button({ 6 | children, 7 | onClick = () => {}, 8 | className = "", 9 | disabled = false, 10 | rounded = true, 11 | size = "medium", 12 | style = "primary", 13 | selector = "", 14 | plausibleEventName = "", 15 | }: { 16 | size?: ButtonSize; 17 | children: any; 18 | onClick?: any; 19 | className?: string; 20 | disabled?: boolean; 21 | rounded?: boolean; 22 | style?: "primary" | "secondary"; 23 | selector?: string; 24 | plausibleEventName?: string; 25 | }) { 26 | const globalColors = useColors(); 27 | 28 | let colors = `border border-gray-300 dark:border-gray-700 ${globalColors.buttonBackgroundColorSecondary} ${globalColors.buttonTextColorSecondary}`; 29 | 30 | if (style === "secondary") { 31 | colors = `${globalColors.buttonBackgroundColor} ${globalColors.buttonTextColor}`; 32 | } 33 | 34 | if (disabled) { 35 | colors = `bg-gray-500 ${globalColors.buttonTextColor}`; 36 | } 37 | 38 | const sizes = { 39 | small: " py-1 px-2 text-sm", 40 | medium: " py-2 px-3 text-sm", 41 | large: "py-3 px-4 text-base", 42 | }; 43 | 44 | const sizeCss = sizes[size]; 45 | 46 | const rounds = { 47 | small: "rounded-md", 48 | medium: "rounded-md", 49 | large: "rounded-lg", 50 | }; 51 | 52 | const roundedCss = rounded ? rounds[size] : ""; 53 | 54 | let _plausibleEventName = plausibleEventName; 55 | if (_plausibleEventName === "" && selector !== "") { 56 | _plausibleEventName = `button-click-${selector}`; 57 | } 58 | 59 | let plausibleEventCss = ""; 60 | if (_plausibleEventName !== "") { 61 | plausibleEventCss = `plausible-event-name=${_plausibleEventName}`; 62 | } 63 | 64 | return ( 65 | 74 | ); 75 | } 76 | -------------------------------------------------------------------------------- /src/components/ButtonGroup.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Button from "./Button"; 3 | 4 | export default function ButtonGroup({ 5 | children, 6 | className = "" 7 | }: { 8 | children: React.ReactNode; 9 | className?: string; 10 | }) { 11 | return {children}; 12 | } 13 | -------------------------------------------------------------------------------- /src/components/Calendar.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from "react"; 2 | import { createContext } from "react"; 3 | import * as t from "../Types"; 4 | 5 | import { Fragment } from "react"; 6 | import { 7 | ChevronDownIcon, 8 | ChevronLeftIcon, 9 | ChevronRightIcon, 10 | EllipsisHorizontalIcon, 11 | } from "@heroicons/react/20/solid"; 12 | import { Menu, Transition } from "@headlessui/react"; 13 | import { useColors } from "../lib/hooks"; 14 | import { useSelector } from "react-redux"; 15 | import { 16 | getSelectedChapter, 17 | getSelectedChapterWritingStreak, 18 | } from "../reducers/librarySlice"; 19 | 20 | import Calendar from "react-widgets/Calendar"; 21 | import { pluralize, dateToDate } from "../utils"; 22 | 23 | const WritingStreakContext = createContext(null); 24 | 25 | function Day({ date, label }) { 26 | const writingStreak = useContext(WritingStreakContext) as t.Date[] | null; 27 | 28 | if (!writingStreak) { 29 | return
    {label}
    ; 30 | } 31 | const _date = dateToDate(date); 32 | 33 | const isInWritingStreak = writingStreak.findIndex( 34 | (date) => 35 | date.day === _date.day && 36 | date.month === _date.month && 37 | date.year === _date.year 38 | ); 39 | if (isInWritingStreak > -1) { 40 | if ( 41 | isInWritingStreak === 0 || 42 | isInWritingStreak === writingStreak.length - 1 43 | ) { 44 | return ( 45 |
    {label}
    46 | ); 47 | } else { 48 | return
    {label}
    ; 49 | } 50 | } else { 51 | return
    {label}
    ; 52 | } 53 | } 54 | 55 | export default function CalendarWidget({ 56 | writingStreak, 57 | }: { 58 | writingStreak: t.Date[] | null; 59 | }) { 60 | const colors = useColors(); 61 | if (!writingStreak) 62 | return

    No writing streak.

    ; 63 | return ( 64 |
    65 | 68 | 69 | 70 | 71 |
    72 | ); 73 | } 74 | -------------------------------------------------------------------------------- /src/components/CodeBlock.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import SyntaxHighlighter from "../lib/languages"; 3 | import vs2015 from "react-syntax-highlighter/dist/esm/styles/hljs/vs2015"; 4 | 5 | export default function CodeBlock({ 6 | text, 7 | language, 8 | }: { 9 | text: string; 10 | language: string; 11 | }) { 12 | const fixedText = text.trim().replaceAll(" ", "\t"); 13 | 14 | return ( 15 |
    16 |
    17 | {language} 18 |
    19 | 25 | {fixedText} 26 | 27 |
    28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /src/components/ContentEditable.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef } from "react"; 2 | 3 | const DEFAULT_CLASSES = "focus:outline-none"; 4 | 5 | export default function ContentEditable({ 6 | value, 7 | onSubmit, 8 | className = "", 9 | style = {}, 10 | nextFocus = null, 11 | selector = "", 12 | onClick = () => {}, 13 | }) { 14 | const [content, setContent] = React.useState(value); 15 | const [edited, setEdited] = React.useState(false); 16 | const handleChange = (evt) => { 17 | const value = evt.target.innerHTML.replace(/
    | /g, " ").trim(); 18 | setContent(value); 19 | }; 20 | 21 | const handleSubmit = () => { 22 | console.log("handleSubmit"); 23 | onSubmit(content); 24 | }; 25 | 26 | const div = useRef(null); 27 | useEffect(() => { 28 | if (!div.current) return; 29 | function onPaste(e) { 30 | try { 31 | e.preventDefault(); 32 | var text = e.clipboardData.getData("text/plain"); 33 | document.execCommand("insertHTML", false, text); 34 | } catch (e) { 35 | console.error("error on paste", e); 36 | } 37 | } 38 | div.current.addEventListener("paste", onPaste); 39 | return () => { 40 | if (div.current) div.current.removeEventListener("paste", onPaste); 41 | }; 42 | }, [div.current]); 43 | 44 | const onKeyDown = (evt) => { 45 | if ( 46 | (evt.metaKey && evt.code === "KeyS") || 47 | evt.key === "Enter" || 48 | evt.key === "Tab" 49 | ) { 50 | if (document.activeElement === div.current) { 51 | evt.preventDefault(); 52 | setEdited(false); 53 | console.warn("submitting", content); 54 | onSubmit(content); 55 | if (nextFocus) { 56 | nextFocus(); 57 | } 58 | } 59 | } else { 60 | setEdited(true); 61 | } 62 | }; 63 | 64 | return ( 65 | <> 66 |
    67 |
    79 | {value} 80 |
    81 | {edited && ( 82 | 83 | Enter to save 84 | 85 | )} 86 |
    87 | 88 | ); 89 | } 90 | -------------------------------------------------------------------------------- /src/components/EditableInput.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Button from "./Button"; 3 | import Input from "./Input"; 4 | 5 | export default function EditableInput({ 6 | value, 7 | onSubmit, 8 | children, 9 | className = "", 10 | }) { 11 | const [inputValue, setInputValue] = React.useState(value); 12 | const [isEditing, setIsEditing] = React.useState(false); 13 | 14 | const inputRef = React.useRef(null); 15 | 16 | React.useEffect(() => { 17 | if (isEditing) { 18 | inputRef.current?.focus(); 19 | } 20 | }, [isEditing]); 21 | 22 | return ( 23 |
    24 | {isEditing ? ( 25 |
    26 | setInputValue(e.target.value)} 32 | onBlur={() => { 33 | onSubmit(inputValue); 34 | setIsEditing(false); 35 | }} 36 | onKeyDown={(e) => { 37 | if (e.key === "Enter") { 38 | onSubmit(inputValue); 39 | setIsEditing(false); 40 | } 41 | }} 42 | /> 43 | 52 |
    53 | ) : ( 54 |
    setIsEditing(true)}>{children}
    55 | )} 56 |
    57 | ); 58 | } 59 | -------------------------------------------------------------------------------- /src/components/Header.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | export default function Header({ children, className = "" }) { 3 | return ( 4 |

    7 | {children} 8 |

    9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /src/components/ImageBlock.tsx: -------------------------------------------------------------------------------- 1 | import * as t from "../Types"; 2 | import { useDispatch } from "react-redux"; 3 | import { librarySlice } from "../reducers/librarySlice"; 4 | import { useState } from "react"; 5 | import React from "react"; 6 | import { useColors, useFonts } from "../lib/hooks"; 7 | function Image({ url, className = "" }) { 8 | return ; 9 | } 10 | 11 | export default function ImageBlock({ 12 | text, 13 | index, 14 | }: { 15 | text: t.ImageBlock; 16 | index: number; 17 | }) { 18 | const dispatch = useDispatch(); 19 | const colors = useColors(); 20 | const { fontSizeClass } = useFonts(); 21 | function setActiveTextIndex() { 22 | dispatch(librarySlice.actions.setActiveTextIndex(index)); 23 | } 24 | 25 | const images = text.text.split("\n").filter((x) => x !== ""); 26 | 27 | if (text.open === false) { 28 | return ( 29 |
    33 | {images.length} images hidden. 34 |
    35 | ); 36 | } 37 | 38 | if (text.display === "linear") { 39 | return ( 40 |
    44 | {images.map((url) => ( 45 | 46 | ))} 47 |
    48 | ); 49 | } else { 50 | return ( 51 |
    55 | {images.map((url) => ( 56 | 57 | ))} 58 |
    59 | ); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/components/InfoSection.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useSelector } from "react-redux"; 3 | import { syllable } from "syllable"; 4 | import { RootState } from "../store"; 5 | import { getCharacters } from "../reducers/librarySlice"; 6 | import readingTime from "reading-time/lib/reading-time"; 7 | import { round } from "../utils"; 8 | const countSyllables = (text: string) => { 9 | try { 10 | return syllable(text); 11 | } catch (error) { 12 | console.error("Error counting syllables:", error); 13 | return 0; 14 | } 15 | }; 16 | function Line({ text, subtext }) { 17 | return ( 18 |

    19 | {text} {subtext} 20 |

    21 | ); 22 | } 23 | export default function InfoSection({ 24 | text, 25 | showSyllables = false, 26 | showPollyCost = false, 27 | }) { 28 | const word_count = text.trim().split(/\s+/).length; 29 | const syllable_count = showSyllables ? countSyllables(text.trim()) : 0; 30 | const pollyCharacterCost = 4.0 / 1_000_000.0; 31 | return ( 32 |
    33 | 34 | 35 | {showSyllables && } 36 | 40 | 41 | 42 | {showPollyCost && ( 43 | 47 | )} 48 | 49 | {/*

    50 | {syllable_count} syllables 51 |

    */} 52 |
    53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /src/components/Input.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { forwardRef } from "react"; 4 | 5 | const Input = forwardRef(function Input( 6 | { 7 | name, 8 | value, 9 | onChange, 10 | rounded = true, 11 | title = null, 12 | className = "", 13 | divClassName = "mt-xs mb-sm", 14 | inputClassName = "", 15 | labelClassName = "", 16 | placeholder = "", 17 | onBlur = null, 18 | onKeyDown = null, 19 | selector = "", 20 | icon = null, 21 | type = "text", 22 | }, 23 | ref 24 | ) { 25 | const roundedCss = rounded ? "rounded-md" : ""; 26 | return ( 27 |
    28 | {title && ( 29 | 32 | )} 33 | {icon && ( 34 |
    35 | {icon} 36 |
    37 | )} 38 | 39 |
    40 | {type === "text" && ( 41 | 54 | )} 55 | {type === "password" && ( 56 | 70 | )} 71 |
    72 |
    73 | ); 74 | }); 75 | 76 | export default Input; 77 | -------------------------------------------------------------------------------- /src/components/LibErrorBoundary.tsx: -------------------------------------------------------------------------------- 1 | import { ErrorBoundary } from "react-error-boundary"; 2 | import React from "react"; 3 | export default function LibErrorBoundary({ component, children }) { 4 | return ( 5 | 8 |

    9 | Oops, something went wrong with the {component}. 10 |

    11 | 12 | } 13 | > 14 | {children} 15 |
    16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /src/components/ListMenu.tsx: -------------------------------------------------------------------------------- 1 | import React, { Fragment } from "react"; 2 | import { Popover, Transition } from "@headlessui/react"; 3 | import { ChevronDownIcon } from "@heroicons/react/20/solid"; 4 | import { EllipsisHorizontalIcon, HeartIcon } from "@heroicons/react/24/outline"; 5 | import { MenuItem } from "../Types"; 6 | import { apStyleTitleCase } from "ap-style-title-case"; 7 | export default function ListMenu({ 8 | items, 9 | label = "Menu", 10 | selector = "menu", 11 | className = "", 12 | buttonClassName = "", 13 | icon = null, 14 | }: { 15 | items: MenuItem[]; 16 | label?: string; 17 | selector?: string; 18 | className?: string; 19 | buttonClassName?: string; 20 | icon?: React.ReactNode; 21 | }) { 22 | const animCss = 23 | "transition ease-in-out hover:scale-125 duration-100 active:scale-75 hover:dark:text-white"; 24 | 25 | return ( 26 | 27 | 31 | {label} 32 | {!icon && ( 33 | 36 | )} 37 | {icon && icon} 38 | 39 | 40 | 49 | 52 |
    53 | {items.map((item, index) => ( 54 |
    60 |
    {item.icon}
    61 |
    62 | {apStyleTitleCase(item.label)} 63 |
    64 |
    65 | ))} 66 |
    67 |
    68 |
    69 |
    70 | ); 71 | } 72 | -------------------------------------------------------------------------------- /src/components/LoadingPlaceholder.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | export default function LoadingPlaceholder({ loaded, placeholder, children }) { 3 | return loaded ? children : placeholder; 4 | } 5 | 6 | export function PanelPlaceholder({ loaded, show, children, className = "" }) { 7 | if (!show && !loaded) return null; 8 | /* const placeholder = ( 9 |
    12 | ); 13 | 14 | */ 15 | const placeholder =
    ; 16 | return ( 17 | 22 | ); 23 | } 24 | 25 | export function EditorPlaceholder({ loaded, children, className = "" }) { 26 | const placeholder = ( 27 |
    30 | ); 31 | return ( 32 | 37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /src/components/MarkdownBlock.tsx: -------------------------------------------------------------------------------- 1 | import * as t from "../Types"; 2 | import React, { useContext } from "react"; 3 | import { marked } from "marked"; 4 | import * as DOMPurify from "dompurify"; 5 | import CodeBlock from "./CodeBlock"; 6 | import ReactDOMServer from "react-dom/server"; 7 | import LibraryContext from "../LibraryContext"; 8 | import { getFontSizeClass } from "../utils"; 9 | 10 | const renderer = { 11 | code(code, infostring, escaped) { 12 | const element = ; 13 | const htmlString = ReactDOMServer.renderToString(element); 14 | return htmlString; 15 | }, 16 | }; 17 | marked.setOptions({ smartypants: true }); 18 | marked.use({ renderer }); 19 | 20 | export default function MarkdownBlock({ 21 | text, 22 | className = "", 23 | }: { 24 | text: string; 25 | className?: string; 26 | }) { 27 | const { settings } = useContext(LibraryContext) as t.LibraryContextType; 28 | let fontSize = settings.design?.fontSize || 18; 29 | const fontSizeClass = getFontSizeClass(fontSize); 30 | let html = marked.parse(text); 31 | html = DOMPurify.sanitize(html); 32 | return ( 33 |
    37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /src/components/NavButton.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useColors } from "../lib/hooks"; 3 | 4 | export default function NavButton({ 5 | label, 6 | onClick, 7 | children, 8 | className = "", 9 | selector = "", 10 | selected = false, 11 | color = "list", 12 | }: { 13 | label: string; 14 | onClick: () => void; 15 | children: React.ReactNode; 16 | className?: string; 17 | selector?: string; 18 | selected?: boolean; 19 | color?: "list" | "nav"; 20 | }) { 21 | const colors = useColors(); 22 | const animCss = 23 | "transition ease-in-out hover:scale-125 duration-100 active:scale-75 hover:dark:text-white"; 24 | let selectedCss = ""; 25 | 26 | if (color === "list") { 27 | selectedCss = selected 28 | ? `${colors.selectedBackground} ${colors.selectedTextColor}` 29 | : `${colors.background} ${colors.secondaryTextColor}`; 30 | } else { 31 | selectedCss = selected 32 | ? `${colors.navBackgroundColorSelected} ${colors.selectedTextColor}` 33 | : `${colors.navBackgroundColor} ${colors.secondaryTextColor}`; 34 | } 35 | 36 | const plausibleLabel = label.replace(" ", "-").toLowerCase(); 37 | 38 | return ( 39 | 49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /src/components/Panel.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { XMarkIcon } from "@heroicons/react/24/outline"; 3 | import { useColors } from "../lib/hooks"; 4 | 5 | export default function Panel({ 6 | title, 7 | children, 8 | onClick = () => {}, 9 | onDelete = null, 10 | className = "", 11 | selector = "", 12 | }) { 13 | return ( 14 |
    15 |
    16 |

    {title}

    17 | {onDelete && ( 18 | 23 | )} 24 |
    25 | 26 |
    29 |
    34 | {children} 35 |
    36 |
    37 |
    38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /src/components/PasswordConfirmation.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Button from "./Button"; 3 | import Input from "./Input"; 4 | export default function PasswordConfirmation({ 5 | value, 6 | onChange, 7 | onSubmit, 8 | onSubmitLabel = "Submit", 9 | className = "", 10 | }) { 11 | const [passwordConfirm, setPasswordConfirm] = React.useState(""); 12 | 13 | const isError = passwordConfirm !== "" && passwordConfirm !== value; 14 | return ( 15 |
    16 | 24 | setPasswordConfirm(e.target.value)} 30 | className="my-0" 31 | /> 32 | {isError && ( 33 |

    34 | Passwords do not match. 35 |

    36 | )} 37 | 38 | 49 |
    50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /src/components/PlainClipboard.tsx: -------------------------------------------------------------------------------- 1 | import { Quill } from "react-quill"; 2 | 3 | var Clipboard = Quill.import("modules/clipboard"); 4 | var Delta = Quill.import("delta"); 5 | 6 | export default class PlainClipboard extends Clipboard { 7 | convert(html = null) { 8 | //console.log("convert", html); 9 | if (typeof html === "string") { 10 | //console.log("its a string"); 11 | this.container.innerHTML = html; 12 | } 13 | let text = this.container.innerText; 14 | //console.log("text", text); 15 | this.container.innerHTML = ""; 16 | return new Delta().insert(text); 17 | } 18 | onPaste(clipboardEvent) { 19 | //console.log("onPaste", clipboardEvent); 20 | if (clipboardEvent.defaultPrevented || !this.quill.isEnabled()) return; 21 | if (!clipboardEvent.clipboardData) return; 22 | const pastedData = clipboardEvent.clipboardData.getData("Text"); 23 | clipboardEvent.preventDefault(); 24 | const range = this.quill.getSelection(); 25 | if (range.length > 0) { 26 | this.quill.deleteText(range.index, range.length, "user"); 27 | } 28 | this.quill.insertText(range.index, pastedData, "user"); 29 | this.quill.setSelection(range.index + pastedData.length); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/components/QuillTextArea.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef } from "react"; 2 | import ReactQuill from "react-quill"; 3 | import { librarySlice } from "../reducers/librarySlice"; 4 | import { useDispatch } from "react-redux"; 5 | import { useColors } from "../lib/hooks"; 6 | 7 | export default function QuillTextArea({ 8 | onChange, 9 | value, 10 | bookid, 11 | title = "", 12 | inputClassName = "", 13 | labelClassName = "", 14 | }) { 15 | const dispatch = useDispatch(); 16 | const quillRef = useRef(); 17 | const colors = useColors(); 18 | 19 | useEffect(() => { 20 | if (!quillRef.current) return; 21 | // @ts-ignore 22 | const editor = quillRef.current.getEditor(); 23 | editor.setText(value); 24 | }, [quillRef.current, bookid]); 25 | 26 | const handleTextChange = (value) => { 27 | if (!quillRef.current) return; 28 | // @ts-ignore 29 | const editor = quillRef.current.getEditor(); 30 | const text = editor.getText(); 31 | onChange(text); 32 | }; 33 | 34 | return ( 35 |
    36 | {title && ( 37 | 42 | )} 43 | 44 | 59 |
    60 | ); 61 | } 62 | -------------------------------------------------------------------------------- /src/components/RadioGroup.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { RadioGroup as HeadlessRadioGroup } from "@headlessui/react"; 3 | import { CheckIcon } from "@heroicons/react/24/outline"; 4 | import { useColors } from "../lib/hooks"; 5 | 6 | export default function RadioGroup({ 7 | value, 8 | onChange, 9 | label, 10 | options, 11 | className = "", 12 | }) { 13 | const colors = useColors(); 14 | function getStyle(checked) { 15 | let styles = ` w-full p-xs my-1 rounded-md border border-gray-300 dark:border-gray-700 text-sm cursor-pointer flex `; 16 | if (checked) { 17 | styles += colors.buttonBackgroundColor; 18 | } else { 19 | styles += colors.buttonBackgroundColorSecondary; 20 | } 21 | return styles; 22 | } 23 | function option(type, label) { 24 | return ( 25 | 26 | {({ checked }) => ( 27 |
    28 | {label} 29 | {checked && ( 30 |
    31 | 32 |
    33 | )} 34 |
    35 | )} 36 |
    37 | ); 38 | } 39 | 40 | return ( 41 | 46 | 47 | {label} 48 | 49 | {options.map((opt) => option(opt.type, opt.label))} 50 | 51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /src/components/Select.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export default function Select({ 4 | name, 5 | value, 6 | onChange, 7 | title = null, 8 | className = "", 9 | children, 10 | }) { 11 | return ( 12 |
    13 | {title && ( 14 | 20 | )} 21 | 30 |
    31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /src/components/SlideOver.tsx: -------------------------------------------------------------------------------- 1 | import React, { Fragment, useState } from "react"; 2 | import { Dialog, Transition } from "@headlessui/react"; 3 | import { XMarkIcon } from "@heroicons/react/24/outline"; 4 | 5 | export default function SlideOver({ 6 | title, 7 | children, 8 | open, 9 | setOpen, 10 | size = "medium", 11 | }) { 12 | let sizeCss = ""; 13 | if (size === "large") { 14 | sizeCss = "w-history"; 15 | } 16 | return ( 17 | 18 | 19 |
    20 | 21 |
    22 |
    23 |
    26 | 35 | 36 |
    41 | {/*
    42 |
    43 | 44 | {title} 45 | 46 |
    47 | 55 |
    56 |
    57 |
    */} 58 |
    {children}
    59 |
    60 |
    61 |
    62 |
    63 |
    64 |
    65 |
    66 |
    67 | ); 68 | } 69 | -------------------------------------------------------------------------------- /src/components/SlideTransition.tsx: -------------------------------------------------------------------------------- 1 | import { Transition } from "@headlessui/react"; 2 | import React from "react"; 3 | 4 | export default function SlideTransition({ show, direction, children }) { 5 | let enterFrom = "opacity-0 translate-x-full"; 6 | let enterTo = "translate-x-0 opacity-100"; 7 | 8 | if (direction === "left") { 9 | enterFrom = "opacity-0 -translate-x-full"; 10 | enterTo = "translate-x-0 opacity-100"; 11 | } 12 | return ( 13 | 28 | {children} 29 | 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /src/components/Spinner.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export default function Spinner({ className = "" }) { 4 | return ( 5 |
    6 | 22 | Loading... 23 |
    24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /src/components/Switch.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { Switch as HeadlessSwitch } from "@headlessui/react"; 3 | 4 | export default function Switch({ 5 | label, 6 | enabled, 7 | setEnabled, 8 | className = "", 9 | divClassName = "", 10 | }) { 11 | return ( 12 |
    13 | 14 | 24 | 30 |
    31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /src/components/Table.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | type TableRow = [string, string]; 4 | export default function Table({ rows }: { rows: TableRow[] }) { 5 | return ( 6 |
    7 | 8 | 9 | {rows.map((row, i) => ( 10 | 11 | 12 | 15 | 16 | ))} 17 | 18 |
    {row[0]} 13 | {row[1]} 14 |
    19 |
    20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /src/components/Tag.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | export default function Tag({ letter, className = "" }) { 3 | return ( 4 |
    7 | {letter} 8 |
    9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /src/components/TextArea.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useColors } from "../lib/hooks"; 3 | 4 | export default function TextArea({ 5 | name, 6 | value, 7 | onChange, 8 | rounded = true, 9 | title = null, 10 | ref = null, 11 | className = "", 12 | inputClassName = "", 13 | labelClassName = "", 14 | placeholder = "", 15 | onBlur = null, 16 | onKeyDown = null, 17 | rows = 4, 18 | selector = "", 19 | }: { 20 | name: string; 21 | value: string; 22 | onChange: (e: React.ChangeEvent) => void; 23 | rounded?: boolean; 24 | title?: string | null; 25 | ref?: React.RefObject | null; 26 | className?: string; 27 | inputClassName?: string; 28 | labelClassName?: string; 29 | placeholder?: string; 30 | onBlur?: ((e: React.FocusEvent) => void) | null; 31 | onKeyDown?: ((e: React.KeyboardEvent) => void) | null; 32 | rows?: number; 33 | selector?: string; 34 | }) { 35 | const roundedCss = rounded ? "rounded-md" : ""; 36 | const colors = useColors(); 37 | return ( 38 |
    39 | {title && ( 40 | 46 | )} 47 |
    48 |