├── .env ├── public ├── env.js ├── robots.txt ├── favicon.ico ├── images │ ├── help │ │ ├── 1.jpg │ │ ├── 2.jpg │ │ ├── 3.jpg │ │ ├── nero_1.jpg │ │ ├── audacity_1.jpg │ │ ├── audacity_2.jpg │ │ ├── audacity_3.jpg │ │ ├── adobe_audition_1.png │ │ ├── adobe_audition_2.png │ │ ├── adobe_audition_3.png │ │ ├── adobe_audition_4.png │ │ └── adobe_audition_5.png │ ├── read-only.gif │ ├── CUEgenerator.png │ └── README │ │ └── global-performer.png ├── manifest.json ├── index.html └── index-firebase.html ├── .env.test ├── .npmrc ├── .prettierignore ├── src ├── react-app-env.d.ts ├── Cue │ ├── index.ts │ ├── TimingParsers.test.ts │ ├── FormHandler.ts │ ├── Formatter.ts │ ├── Formatter.test.ts │ ├── TimingParsers.ts │ ├── Parser.test.ts │ └── Parser.ts ├── setupTests.ts ├── Components │ ├── CounterContext.tsx │ ├── App.css │ ├── App.test.tsx │ ├── Header.test.tsx │ ├── Help.css │ ├── Header.css │ ├── Heder │ │ └── Counter.tsx │ ├── Form │ │ └── FormSelect.tsx │ ├── Header.tsx │ ├── App.tsx │ ├── Form.css │ ├── Help.tsx │ └── Form.tsx ├── Services │ ├── API.test.ts │ ├── index.ts │ ├── CueStorage.ts │ ├── Analytics.ts │ ├── CueStorage.test.ts │ ├── Analytics.test.ts │ └── API.ts ├── types.ts ├── Utils │ ├── index.ts │ └── index.test.ts ├── index.css ├── reportWebVitals.ts ├── index.tsx └── Config │ └── index.ts ├── .vscode └── settings.json ├── .env.development ├── .prettierrc.json ├── README.md ├── .github ├── pull_request_template.md └── workflows │ ├── firebase-hosting-pull-request.yml │ └── firebase-hosting-merge.yml ├── firebase.json ├── .env.production ├── .gitignore ├── tsconfig.json ├── package.json └── CHANGELOG.md /.env: -------------------------------------------------------------------------------- 1 | REACT_APP_API_URL= -------------------------------------------------------------------------------- /public/env.js: -------------------------------------------------------------------------------- 1 | window.env = {}; -------------------------------------------------------------------------------- /.env.test: -------------------------------------------------------------------------------- 1 | REACT_APP_API_URL=fake-url -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | node-options="--openssl-legacy-provider" 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Ignore artifacts: 2 | build 3 | coverage 4 | -------------------------------------------------------------------------------- /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.rulers": [ 3 | 80,120 4 | ] 5 | } -------------------------------------------------------------------------------- /.env.development: -------------------------------------------------------------------------------- 1 | REACT_APP_API_URL=http://localhost:5001/cuegenerator/us-central1/api -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "tabWidth": 2, 4 | "singleQuote": true 5 | } 6 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DmitryVarennikov/cuegenerator-react/main/public/favicon.ico -------------------------------------------------------------------------------- /src/Cue/index.ts: -------------------------------------------------------------------------------- 1 | import FormHandler from '../Cue/FormHandler'; 2 | export const formHandler = new FormHandler(); 3 | -------------------------------------------------------------------------------- /public/images/help/1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DmitryVarennikov/cuegenerator-react/main/public/images/help/1.jpg -------------------------------------------------------------------------------- /public/images/help/2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DmitryVarennikov/cuegenerator-react/main/public/images/help/2.jpg -------------------------------------------------------------------------------- /public/images/help/3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DmitryVarennikov/cuegenerator-react/main/public/images/help/3.jpg -------------------------------------------------------------------------------- /public/images/read-only.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DmitryVarennikov/cuegenerator-react/main/public/images/read-only.gif -------------------------------------------------------------------------------- /public/images/CUEgenerator.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DmitryVarennikov/cuegenerator-react/main/public/images/CUEgenerator.png -------------------------------------------------------------------------------- /public/images/help/nero_1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DmitryVarennikov/cuegenerator-react/main/public/images/help/nero_1.jpg -------------------------------------------------------------------------------- /public/images/help/audacity_1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DmitryVarennikov/cuegenerator-react/main/public/images/help/audacity_1.jpg -------------------------------------------------------------------------------- /public/images/help/audacity_2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DmitryVarennikov/cuegenerator-react/main/public/images/help/audacity_2.jpg -------------------------------------------------------------------------------- /public/images/help/audacity_3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DmitryVarennikov/cuegenerator-react/main/public/images/help/audacity_3.jpg -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CUEgenerator 2 | 3 | This repository is archived in favor of [cuegenerator-svelte](https://github.com/DmitryVarennikov/cuegenerator-svelte) -------------------------------------------------------------------------------- /public/images/README/global-performer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DmitryVarennikov/cuegenerator-react/main/public/images/README/global-performer.png -------------------------------------------------------------------------------- /public/images/help/adobe_audition_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DmitryVarennikov/cuegenerator-react/main/public/images/help/adobe_audition_1.png -------------------------------------------------------------------------------- /public/images/help/adobe_audition_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DmitryVarennikov/cuegenerator-react/main/public/images/help/adobe_audition_2.png -------------------------------------------------------------------------------- /public/images/help/adobe_audition_3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DmitryVarennikov/cuegenerator-react/main/public/images/help/adobe_audition_3.png -------------------------------------------------------------------------------- /public/images/help/adobe_audition_4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DmitryVarennikov/cuegenerator-react/main/public/images/help/adobe_audition_4.png -------------------------------------------------------------------------------- /public/images/help/adobe_audition_5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DmitryVarennikov/cuegenerator-react/main/public/images/help/adobe_audition_5.png -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 2 | # ========================================== 3 | # 4 | # fix|feat|docs|test|build|refactor: subject 5 | # 6 | # [optional body] 7 | # 8 | # BREAKING CHANGE: ? 9 | # -------------------------------------------------------------------------------- /src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom'; 6 | -------------------------------------------------------------------------------- /src/Components/CounterContext.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | type CounterContext = { 4 | counter: number; 5 | setCounter: (value: number) => void; 6 | }; 7 | const CounterContext = React.createContext({ counter: 0, setCounter: (value: number) => {} }); 8 | export default CounterContext; 9 | -------------------------------------------------------------------------------- /firebase.json: -------------------------------------------------------------------------------- 1 | { 2 | "hosting": { 3 | "public": "build", 4 | "ignore": [ 5 | "firebase.json", 6 | "**/.*", 7 | "**/node_modules/**" 8 | ], 9 | "rewrites": [ 10 | { 11 | "source": "**", 12 | "destination": "/index.html" 13 | } 14 | ] 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/Services/API.test.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import API from './API'; 3 | 4 | describe('API', () => { 5 | it('no uncaught promise when API is down', async () => { 6 | const api = new API('/'); 7 | const counter = await api.getCounter(); 8 | expect(counter).toBe(undefined); 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /src/Services/index.ts: -------------------------------------------------------------------------------- 1 | import { apiUrl, firebaseConfig } from '../Config'; 2 | import API from './API'; 3 | import Analytics from './Analytics'; 4 | import CueStorage from './CueStorage'; 5 | 6 | export const api = new API(apiUrl); 7 | export const analytics = new Analytics(firebaseConfig); 8 | export const cueStorage = new CueStorage(); 9 | -------------------------------------------------------------------------------- /src/Components/App.css: -------------------------------------------------------------------------------- 1 | * { 2 | margin: 0px; 3 | padding: 0px; 4 | outline: none; 5 | } 6 | 7 | body { 8 | font-family: 'Trebuchet MS', Arial, Tahoma; 9 | background-color: #4d4d4d; 10 | font-size: 17px; 11 | } 12 | 13 | #container { 14 | width: 1000px; 15 | margin: 0 auto; 16 | margin-top: 20px; 17 | padding: 20px; 18 | overflow: auto; 19 | } 20 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Cuegenerator", 3 | "name": "Cuegenerator", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#4d4d4d" 15 | } 16 | -------------------------------------------------------------------------------- /src/Components/App.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, screen } from '@testing-library/react'; 3 | import App from './App'; 4 | 5 | test('check container element', () => { 6 | render(); 7 | const element = screen.getByTestId('container'); 8 | expect(element).toBeInTheDocument(); 9 | expect(element.getAttribute('id')).toBe('container'); 10 | }); 11 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export type TrackList = Array<{ track: number; performer: string; title: string; time: string }>; 2 | export type TimeEntry = { mn: number; sc: number; fr: number }; 3 | export type Timings = Array; 4 | export type CueFormInputs = { 5 | performer: string; 6 | title: string; 7 | fileName: string; 8 | fileType: string; 9 | trackList: string; 10 | regionsList: string; 11 | }; 12 | -------------------------------------------------------------------------------- /src/Utils/index.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | 3 | export const replaceFileExt = (fileName: string, ext: string) => { 4 | const baseName = path.basename(fileName, path.extname(fileName)); 5 | return baseName + ext; 6 | }; 7 | 8 | export const makeCueFileName = (soundFileName: string) => { 9 | if (soundFileName) { 10 | return replaceFileExt(soundFileName, '.cue'); 11 | } 12 | return 'Untitled.cue'; 13 | }; 14 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 12 | monospace; 13 | } 14 | -------------------------------------------------------------------------------- /.env.production: -------------------------------------------------------------------------------- 1 | REACT_APP_API_URL=https://us-central1-cuegenerator.cloudfunctions.net/api 2 | 3 | REACT_APP_API_KEY=AIzaSyCChgj0Ybm5wtCaftXHAYgpXbTyXL5NLws 4 | REACT_APP_AUTH_DOMAIN="cuegenerator.firebaseapp.com" 5 | REACT_APP_PROJECT_ID=cuegenerator 6 | REACT_APP_STORAGE_BUCKET="cuegenerator.appspot.com" 7 | REACT_APP_MESSAGING_SENDER_ID=747548828434 8 | REACT_APP_APP_ID="1:747548828434:web:19096ad9bd290cb48f09cb" 9 | REACT_APP_MEASUREMENT_ID="G-HFZF5M8PG4" -------------------------------------------------------------------------------- /src/Components/Header.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, screen } from '@testing-library/react'; 3 | import Header from './Header'; 4 | 5 | test('check feedback link', () => { 6 | render(
); 7 | const linkElement = screen.getByText(/Leave your feedback on GitHub/i); 8 | expect(linkElement).toBeInTheDocument(); 9 | expect(linkElement.getAttribute('href')).toBe('https://github.com/DmitryVarennikov/cuegenerator-react'); 10 | }); 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | 25 | 26 | /.firebaserc 27 | /.firebase/* 28 | -------------------------------------------------------------------------------- /src/Cue/TimingParsers.test.ts: -------------------------------------------------------------------------------- 1 | import { standard } from './TimingParsers'; 2 | 3 | describe('TimingParsers', () => { 4 | describe('standard', () => { 5 | it('DD:DD:DD', () => { 6 | const actual = standard(' 01:23:45 '); 7 | expect(actual).toEqual({ fr: 45, mn: 1, sc: 23 }); 8 | }); 9 | it('D:DD:DD', () => { 10 | const actual = standard(' 0:05:47 '); 11 | expect(actual).toEqual({ fr: 47, mn: 0, sc: 5 }); 12 | }); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /src/Components/Help.css: -------------------------------------------------------------------------------- 1 | #help { 2 | width: 900px; 3 | margin: auto; 4 | margin-top: 20px; 5 | } 6 | 7 | #help ul.table_of_contents { 8 | list-style: none; 9 | margin: 40px 0; 10 | } 11 | #help ul.table_of_contents li a { 12 | color: #fcdf79; 13 | } 14 | 15 | #help h2 { 16 | font-size: 22px; 17 | color: #fcdf79; 18 | } 19 | 20 | #help img { 21 | margin: 10px 0; 22 | border: 1px solid #000; 23 | } 24 | 25 | #help div.delimiter { 26 | margin-bottom: 100px; 27 | } 28 | -------------------------------------------------------------------------------- /src/reportWebVitals.ts: -------------------------------------------------------------------------------- 1 | import { ReportHandler } from 'web-vitals'; 2 | 3 | const reportWebVitals = (onPerfEntry?: ReportHandler) => { 4 | if (onPerfEntry && onPerfEntry instanceof Function) { 5 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 6 | getCLS(onPerfEntry); 7 | getFID(onPerfEntry); 8 | getFCP(onPerfEntry); 9 | getLCP(onPerfEntry); 10 | getTTFB(onPerfEntry); 11 | }); 12 | } 13 | }; 14 | 15 | export default reportWebVitals; 16 | -------------------------------------------------------------------------------- /src/Components/Header.css: -------------------------------------------------------------------------------- 1 | #logo { 2 | float: left; 3 | background-position: left top; 4 | background-repeat: no-repeat; 5 | width: 217px; 6 | height: 58px; 7 | } 8 | 9 | #logo a { 10 | color: transparent; 11 | } 12 | 13 | span#counter { 14 | color: #fcdf79; 15 | } 16 | 17 | #feedback { 18 | float: right; 19 | } 20 | 21 | #feedback .links { 22 | float: right; 23 | } 24 | #feedback a { 25 | color: #fcdf79; 26 | } 27 | 28 | #feedback a:hover { 29 | color: #fff9e6; 30 | } 31 | 32 | .clear { 33 | clear: both; 34 | } 35 | -------------------------------------------------------------------------------- /src/Components/Heder/Counter.tsx: -------------------------------------------------------------------------------- 1 | import { useContext, useEffect, useState } from 'react'; 2 | import { api } from '../../Services'; 3 | import CounterContext from '../CounterContext'; 4 | 5 | export default function Counter() { 6 | const { counter, setCounter } = useContext(CounterContext); 7 | 8 | useEffect(() => { 9 | const fetchCounter = async () => { 10 | const counter = await api.getCounter(); 11 | if (counter) setCounter(counter); 12 | }; 13 | fetchCounter(); 14 | }, []); 15 | 16 | return ({counter}); 17 | } 18 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import './index.css'; 4 | import App from './Components/App'; 5 | import reportWebVitals from './reportWebVitals'; 6 | 7 | ReactDOM.render( 8 | 9 | 10 | , 11 | document.getElementById('root') 12 | ); 13 | 14 | // If you want to start measuring performance in your app, pass a function 15 | // to log results (for example: reportWebVitals(console.log)) 16 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 17 | reportWebVitals(); 18 | -------------------------------------------------------------------------------- /src/Config/index.ts: -------------------------------------------------------------------------------- 1 | export const apiUrl = process.env.REACT_APP_API_URL || ''; 2 | if (!apiUrl) { 3 | throw new Error('Environment variable "REACT_APP_API_URL" was not set. Can not determine API url'); 4 | } 5 | 6 | export const firebaseConfig = { 7 | apiKey: process.env.REACT_APP_API_KEY, 8 | authDomain: process.env.REACT_APP_AUTH_DOMAIN, 9 | projectId: process.env.REACT_APP_PROJECT_ID, 10 | storageBucket: process.env.REACT_APP_STORAGE_BUCKET, 11 | messagingSenderId: process.env.REACT_APP_MESSAGING_SENDER_ID, 12 | appId: process.env.REACT_APP_APP_ID, 13 | measurementId: process.env.REACT_APP_MEASUREMENT_ID, 14 | }; -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "module": "esnext", 17 | "moduleResolution": "node", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "noEmit": true, 21 | "jsx": "react-jsx" 22 | }, 23 | "include": [ 24 | "src" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /src/Components/Form/FormSelect.tsx: -------------------------------------------------------------------------------- 1 | const generateSelectOptions = (options: Array) => { 2 | return options.map((option) => { 3 | const value = option.toString(); 4 | return ( 5 | 8 | ); 9 | }); 10 | }; 11 | 12 | export default function FormSelect( 13 | options: Array, 14 | name: string, 15 | value: string, 16 | onChange: (event: any) => void, 17 | tabIndex: number 18 | ) { 19 | return ( 20 | 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /src/Components/Header.tsx: -------------------------------------------------------------------------------- 1 | import './Header.css'; 2 | import Counter from './Heder/Counter'; 3 | 4 | export default function Header() { 5 | return ( 6 | <> 7 |

8 | CUEgenerator 9 |

10 | 11 | 12 | 19 | 20 |
21 | 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /src/Services/CueStorage.ts: -------------------------------------------------------------------------------- 1 | import { CueFormInputs } from '../types'; 2 | 3 | export default class CueStorage { 4 | private static PREV_CUE_KEY = 'prev-cue'; 5 | private storage = localStorage; 6 | 7 | getPrevCue(): CueFormInputs | null { 8 | const value = this.storage.getItem(CueStorage.PREV_CUE_KEY); 9 | 10 | if (value) { 11 | try { 12 | return JSON.parse(value); 13 | } catch (e) { 14 | console.error('Error while pasring storage value', e); 15 | } 16 | } 17 | 18 | return null; 19 | } 20 | hasPrevCue(): boolean { 21 | return this.getPrevCue() != null; 22 | } 23 | setPrevCue(cueFormInputs: CueFormInputs): void { 24 | const value = JSON.stringify(cueFormInputs); 25 | this.storage.setItem(CueStorage.PREV_CUE_KEY, value); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Services/Analytics.ts: -------------------------------------------------------------------------------- 1 | import firebase from 'firebase'; 2 | import _ from 'lodash'; 3 | 4 | export enum AnalyticsEvent { 5 | CUE_FILE_SAVED = 'cue_file_saved', 6 | PREV_CUE_LOADED = 'prev_cue_loaded', 7 | } 8 | 9 | export default class Analytics { 10 | private analytics; 11 | constructor(options: Object) { 12 | // enable only when non-empty options are passed 13 | if (Analytics.areOptions(options)) { 14 | firebase.initializeApp(options); 15 | this.analytics = firebase.analytics(); 16 | } 17 | } 18 | 19 | // check if every option value is not empty 20 | private static areOptions = (options: Object) => !Object.values(options).every(_.isEmpty); 21 | 22 | logEvent(event: AnalyticsEvent) { 23 | if (this.analytics) { 24 | this.analytics.logEvent(event.toString()); 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Components/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import './App.css'; 3 | import Form from './Form'; 4 | import Help from './Help'; 5 | import Header from './Header'; 6 | import { BrowserRouter, Route, Switch } from 'react-router-dom'; 7 | import CounterContext from './CounterContext'; 8 | 9 | export default function App() { 10 | const [counter, setCounter] = useState(0); 11 | 12 | return ( 13 |
14 | 15 |
16 | 17 | 18 | 19 | 20 | {/* */} 21 | 22 | 23 | 24 |
25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /.github/workflows/firebase-hosting-pull-request.yml: -------------------------------------------------------------------------------- 1 | # This file was auto-generated by the Firebase CLI 2 | # https://github.com/firebase/firebase-tools 3 | 4 | name: Test and deploy to Firebase Hosting on PR 5 | on: 6 | pull_request_target: 7 | branches: 8 | - main 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | - run: npm ci && npm test 15 | build_and_preview: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v3 19 | - name: Use Node.js 20 | uses: actions/setup-node@v3 21 | with: 22 | node-version: 20.2 23 | - run: node -v && npm ci && CI=false npm run build 24 | - uses: FirebaseExtended/action-hosting-deploy@v0 25 | with: 26 | repoToken: '${{ secrets.GITHUB_TOKEN }}' 27 | firebaseServiceAccount: '${{ secrets.FIREBASE_SERVICE_ACCOUNT_CUEGENERATOR }}' 28 | projectId: cuegenerator 29 | env: 30 | FIREBASE_CLI_PREVIEWS: hostingchannels 31 | -------------------------------------------------------------------------------- /.github/workflows/firebase-hosting-merge.yml: -------------------------------------------------------------------------------- 1 | # This file was auto-generated by the Firebase CLI 2 | # https://github.com/firebase/firebase-tools 3 | 4 | name: Deploy to Firebase Hosting on release merge 5 | 'on': 6 | push: 7 | branches: 8 | - main 9 | jobs: 10 | build_and_deploy: 11 | runs-on: ubuntu-latest 12 | if: "startsWith(github.event.head_commit.message, 'fix') || startsWith(github.event.head_commit.message, 'feat')" 13 | steps: 14 | - uses: actions/checkout@v3 15 | - name: Use Node.js 16 | uses: actions/setup-node@v3 17 | with: 18 | node-version: 20.2 19 | - run: node -v && npm ci && CI=false npm run build 20 | - uses: FirebaseExtended/action-hosting-deploy@v0 21 | with: 22 | repoToken: '${{ secrets.GITHUB_TOKEN }}' 23 | firebaseServiceAccount: '${{ secrets.FIREBASE_SERVICE_ACCOUNT_CUEGENERATOR }}' 24 | channelId: live 25 | projectId: cuegenerator 26 | env: 27 | FIREBASE_CLI_PREVIEWS: hostingchannels 28 | 29 | -------------------------------------------------------------------------------- /src/Utils/index.test.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import * as Utils from '.'; 3 | 4 | describe('Utils', () => { 5 | describe('replaceFileExt', () => { 6 | it('replace existing extension', () => { 7 | const actual = Utils.replaceFileExt('my sound file.mp3', '.cue'); 8 | const expected = 'my sound file.cue'; 9 | expect(actual).toBe(expected); 10 | }); 11 | it('no extension', () => { 12 | const actual = Utils.replaceFileExt('my sound file', '.cue'); 13 | const expected = 'my sound file.cue'; 14 | expect(actual).toBe(expected); 15 | }); 16 | }); 17 | 18 | describe('makeCueFileName', () => { 19 | it('non-empty sound file name', () => { 20 | const actual = Utils.makeCueFileName('sound file name'); 21 | const expected = 'sound file name.cue'; 22 | expect(actual).toBe(expected); 23 | }); 24 | it('empty sound file name', () => { 25 | const actual = Utils.makeCueFileName(''); 26 | const expected = 'Untitled.cue'; 27 | expect(actual).toBe(expected); 28 | }); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /src/Cue/FormHandler.ts: -------------------------------------------------------------------------------- 1 | import Formatter from './Formatter'; 2 | import Parser, { ParserHelper } from './Parser'; 3 | 4 | const newParser = new Parser(new ParserHelper()); 5 | const newFormatter = new Formatter(); 6 | 7 | export default class FormHandler { 8 | private parser: Parser; 9 | private formatter: Formatter; 10 | constructor(parser: Parser = newParser, formatter: Formatter = newFormatter) { 11 | this.parser = parser; 12 | this.formatter = formatter; 13 | } 14 | 15 | createCue( 16 | performer: string, 17 | title: string, 18 | fileName: string, 19 | fileType: string, 20 | trackList: string, 21 | regionsList: string 22 | ) { 23 | const formattedPerformer = this.formatter.formatPerformer(this.parser.parsePerformer(performer)); 24 | const formattedTitle = this.formatter.formatTitle(this.parser.parseTitle(title)); 25 | const formattedFileName = this.formatter.formatFilename(this.parser.parseFileName(fileName), fileType); 26 | const formattedTracklist = this.formatter.formatTracklist( 27 | this.parser.parseTrackList(trackList), 28 | this.parser.parseTimings(regionsList), 29 | performer 30 | ); 31 | 32 | return formattedPerformer + formattedTitle + formattedFileName + formattedTracklist; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Services/CueStorage.test.ts: -------------------------------------------------------------------------------- 1 | import CueStorage from './CueStorage'; 2 | 3 | const mockStorage = { 4 | getItem: jest.fn(), 5 | setItem: jest.fn(), 6 | }; 7 | 8 | describe('CueStorage', () => { 9 | it('getPrevCue when no value', () => { 10 | const cueStorage = new CueStorage(); 11 | // @ts-ignore 12 | cueStorage.storage = mockStorage; 13 | mockStorage.getItem.mockReturnValue(null); 14 | 15 | const prevCue = cueStorage.getPrevCue(); 16 | expect(prevCue).toBe(null); 17 | }); 18 | it('getPrevCue when value is not parsable', () => { 19 | const cueStorage = new CueStorage(); 20 | // @ts-ignore 21 | cueStorage.storage = mockStorage; 22 | mockStorage.getItem.mockReturnValue('a'); 23 | 24 | const prevCue = cueStorage.getPrevCue(); 25 | expect(prevCue).toBe(null); 26 | }); 27 | it('setPrevCue', () => { 28 | const cueStorage = new CueStorage(); 29 | // @ts-ignore 30 | cueStorage.storage = mockStorage; 31 | 32 | const cueFormInputs = { 33 | performer: 'p', 34 | title: 't', 35 | fileName: 'fn', 36 | fileType: 'ft', 37 | trackList: 'tl', 38 | regionsList: 'rl', 39 | }; 40 | cueStorage.setPrevCue(cueFormInputs); 41 | expect(mockStorage.setItem).toHaveBeenCalled(); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /src/Services/Analytics.test.ts: -------------------------------------------------------------------------------- 1 | import firebase from 'firebase'; 2 | import Analytics, { AnalyticsEvent } from './Analytics'; 3 | 4 | jest.mock('firebase', () => { 5 | return { 6 | initializeApp: jest.fn(), 7 | analytics: jest.fn(), 8 | }; 9 | }); 10 | 11 | describe('Analytics', () => { 12 | it('init firebase when options passed', () => { 13 | new Analytics({ apiKey: 'apiKey', authDomain: 'authDomain' }); 14 | 15 | expect(firebase.initializeApp).toHaveBeenCalled(); 16 | expect(firebase.initializeApp).toHaveBeenCalledWith({ apiKey: 'apiKey', authDomain: 'authDomain' }); 17 | expect(firebase.analytics).toHaveBeenCalled(); 18 | }); 19 | it('no firebase init call when no options passed', () => { 20 | new Analytics({}); 21 | 22 | expect(firebase.initializeApp).toHaveBeenCalledTimes(0); 23 | expect(firebase.analytics).toHaveBeenCalledTimes(0); 24 | }); 25 | it('log event when analytics was initialzied', () => { 26 | const analytics = new Analytics({ non: 'empty' }); 27 | // @ts-ignore 28 | analytics['analytics'] = { 29 | logEvent: jest.fn(), 30 | }; 31 | 32 | analytics.logEvent(AnalyticsEvent.CUE_FILE_SAVED); 33 | expect(analytics['analytics']?.logEvent).toHaveBeenCalled(); 34 | expect(analytics['analytics']?.logEvent).toHaveBeenCalledWith(AnalyticsEvent.CUE_FILE_SAVED); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /src/Cue/Formatter.ts: -------------------------------------------------------------------------------- 1 | import { Timings, TimeEntry, TrackList } from '../types'; 2 | 3 | export default class Formatter { 4 | formatPerformer(value: string) { 5 | return 'PERFORMER "' + value + '"\n'; 6 | } 7 | 8 | formatTitle(value: string) { 9 | return 'TITLE "' + value + '"\n'; 10 | } 11 | 12 | formatFilename(name: string, type: string) { 13 | return `FILE "${name}" ${type}\n`; 14 | } 15 | 16 | formatTracklist(tracklist: TrackList, regionsList: Timings, globalPerformer: string) { 17 | let output = ''; 18 | for (var i = 0; i < tracklist.length; i++) { 19 | const row = tracklist[i]; 20 | const performer = row.performer || globalPerformer; 21 | const time = regionsList[i] ? timeEntryToString(regionsList[i]) : row.time; 22 | 23 | output += ' TRACK ' + (row.track < 10 ? '0' + row.track : row.track) + ' AUDIO\n'; 24 | output += ' PERFORMER "' + performer + '"\n'; 25 | output += ' TITLE "' + row.title + '"\n'; 26 | // when first track does not start at 00:00:00 27 | // "INDEX 00 00:00:00" line has to be the first index 28 | if (i === 0 && time !== '00:00:00') { 29 | output += ' INDEX 00 00:00:00\n'; 30 | } 31 | output += ' INDEX 01 ' + time + '\n'; 32 | } 33 | 34 | return output; 35 | } 36 | } 37 | 38 | export const timeEntryToString = (timeEntry: TimeEntry): string => { 39 | const mn = timeEntry.mn.toString(10).padStart(2, '0'); 40 | const sc = timeEntry.sc.toString(10).padStart(2, '0'); 41 | const fr = timeEntry.fr.toString(10).padStart(2, '0'); 42 | return `${mn}:${sc}:${fr}`; 43 | }; 44 | -------------------------------------------------------------------------------- /src/Components/Form.css: -------------------------------------------------------------------------------- 1 | .form-button { 2 | float: right; 3 | margin: 0; 4 | padding: 0 10px; 5 | font-size: 17px; 6 | font-weight: bold; 7 | height: 28px; 8 | } 9 | .form-button-margin { 10 | margin-right: 10px; 11 | } 12 | .save-button { 13 | background-color: #fcdf79; 14 | } 15 | 16 | label { 17 | color: #fff9e6; 18 | } 19 | 20 | input, 21 | textarea, 22 | select { 23 | background-color: #fff9e6; 24 | border: 1px solid #000; 25 | resize: none; 26 | 27 | -moz-border-radius: 3px; /* Firefox */ 28 | -webkit-border-radius: 3px; /* Safari, Chrome */ 29 | border-radius: 3px; /* CSS3 */ 30 | } 31 | 32 | #cue_fields { 33 | margin-top: 20px; 34 | float: left; 35 | } 36 | 37 | #cue_fields .field { 38 | clear: both; 39 | line-height: 25px; 40 | } 41 | 42 | #cue_fields .field label { 43 | float: left; 44 | display: block; 45 | width: 100px; 46 | padding-right: 10px; 47 | } 48 | 49 | #cue_fields .field label sup a { 50 | color: #fcdf79; 51 | font-size: 12px; 52 | } 53 | 54 | #cue_fields .field input, 55 | #cue_fields .field textarea, 56 | #cue_fields .field select { 57 | float: left; 58 | display: block; 59 | width: 350px; 60 | height: 20px; 61 | margin: 0; 62 | } 63 | 64 | #cue_fields .field textarea#tracklist { 65 | height: 200px; 66 | } 67 | 68 | #cue_fields .field textarea#regions_list { 69 | height: 100px; 70 | } 71 | 72 | #cue_ready { 73 | margin-left: 20px; 74 | margin-top: 20px; 75 | float: right; 76 | } 77 | 78 | #cue_ready #cue { 79 | height: 480px; 80 | width: 510px; 81 | background-position: center; 82 | background-repeat: no-repeat; 83 | background-color: #fff9e6; 84 | } 85 | 86 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cuegenerator-react", 3 | "version": "1.3.0", 4 | "private": true, 5 | "dependencies": { 6 | "@testing-library/jest-dom": "^5.11.9", 7 | "@testing-library/react": "^11.2.5", 8 | "@testing-library/user-event": "^12.7.3", 9 | "firebase": "^8.6.1", 10 | "jsonwebtoken": "^8.5.1", 11 | "lodash": "^4.17.21", 12 | "react": "^17.0.1", 13 | "react-dom": "^17.0.1", 14 | "react-router": "^5.2.0", 15 | "react-router-dom": "^5.2.0", 16 | "react-scripts": "4.0.3", 17 | "web-vitals": "^1.1.0" 18 | }, 19 | "scripts": { 20 | "start": "react-scripts start", 21 | "build": "react-scripts build", 22 | "test": "tsc -v && tsc && react-scripts test --watchAll=false", 23 | "coverage": "npm test -- --coverage", 24 | "eject": "react-scripts eject", 25 | "deploy": "npm run build && firebase deploy --only hosting --project cuegenerator --json", 26 | "release": "standard-version", 27 | "postrelease": "git push --follow-tags origin main" 28 | }, 29 | "eslintConfig": { 30 | "extends": [ 31 | "react-app", 32 | "react-app/jest" 33 | ] 34 | }, 35 | "browserslist": { 36 | "production": [ 37 | ">0.2%", 38 | "not dead", 39 | "not op_mini all" 40 | ], 41 | "development": [ 42 | "last 1 chrome version", 43 | "last 1 firefox version", 44 | "last 1 safari version" 45 | ] 46 | }, 47 | "devDependencies": { 48 | "@types/jest": "^26.0.20", 49 | "@types/jsonwebtoken": "^8.5.0", 50 | "@types/lodash": "^4.14.168", 51 | "@types/node": "^12.20.4", 52 | "@types/react": "^17.0.2", 53 | "@types/react-dom": "^17.0.1", 54 | "@types/react-router": "^5.1.12", 55 | "@types/react-router-dom": "^5.1.7", 56 | "prettier": "^2.2.1", 57 | "standard-version": "^9.1.1", 58 | "typescript": "^4.2.2" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/Cue/Formatter.test.ts: -------------------------------------------------------------------------------- 1 | import { Timings, TrackList } from '../types'; 2 | import Formatter from './Formatter'; 3 | 4 | const formatter = new Formatter(); 5 | describe('Formatter', () => { 6 | // let formatter: Formatter | undefined; 7 | // beforeAll(() => { 8 | 9 | // }); 10 | test('formatPerformer', () => { 11 | const actual = formatter.formatPerformer('Bobina'); 12 | const expected = 'PERFORMER "Bobina"\n'; 13 | expect(actual).toBe(expected); 14 | }); 15 | test('formatTitle', () => { 16 | const actual = formatter.formatTitle('Russia Goes Clubbing'); 17 | const expected = 'TITLE "Russia Goes Clubbing"\n'; 18 | expect(actual).toBe(expected); 19 | }); 20 | test('formatFilename', () => { 21 | const actual = formatter.formatFilename('Bobina - Russia Goes Clubbing #272.cue', 'WAVE'); 22 | const expected = 'FILE "Bobina - Russia Goes Clubbing #272.cue" WAVE\n'; 23 | expect(actual).toBe(expected); 24 | }); 25 | test('formatTracklist_1', () => { 26 | const tracklist: TrackList = [{ track: 1, performer: '', title: 'Miami Echoes', time: '02:41:00' }]; 27 | const regionsList: Timings = []; 28 | const globalPerformer = 'Bobina'; 29 | 30 | var actual = formatter.formatTracklist(tracklist, regionsList, globalPerformer); 31 | var expected = ''; 32 | expected += ' TRACK 01 AUDIO\n'; 33 | expected += ' PERFORMER "Bobina"\n'; 34 | expected += ' TITLE "Miami Echoes"\n'; 35 | expected += ' INDEX 00 00:00:00\n'; 36 | expected += ' INDEX 01 02:41:00\n'; 37 | expect(actual).toBe(expected); 38 | }); 39 | test('formatTracklist_2', () => { 40 | const tracklist: TrackList = [{ track: 1, performer: '', title: 'Miami Echoes', time: '' }]; 41 | const regionsList: Timings = [{ mn: 2, sc: 41, fr: 0 }]; //['02:41:00']; 42 | const globalPerformer = 'Bobina'; 43 | 44 | var actual = formatter.formatTracklist(tracklist, regionsList, globalPerformer); 45 | var expected = ''; 46 | expected += ' TRACK 01 AUDIO\n'; 47 | expected += ' PERFORMER "Bobina"\n'; 48 | expected += ' TITLE "Miami Echoes"\n'; 49 | expected += ' INDEX 00 00:00:00\n'; 50 | expected += ' INDEX 01 02:41:00\n'; 51 | expect(actual).toBe(expected); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /src/Services/API.ts: -------------------------------------------------------------------------------- 1 | import jwt from 'jsonwebtoken'; 2 | import _ from 'lodash'; 3 | 4 | export default class API { 5 | private token: string | undefined = undefined; 6 | constructor(private basePath: string) { 7 | } 8 | private url(path: string) { 9 | return this.basePath + path; 10 | } 11 | private async getToken() { 12 | try { 13 | const response = await fetch(this.url('/')); 14 | const { token } = await response.json(); 15 | return token; 16 | } catch (e) { 17 | console.error(e); 18 | return undefined; 19 | } 20 | } 21 | private async reFetchTokenIfNeeded() { 22 | if (this.token) { 23 | try { 24 | const { exp } = jwt.decode(this.token) as { exp: number }; 25 | // give it a 10 seconds leeway 26 | const almostNow = new Date().getTime() - 10 * 1000; 27 | if (exp < almostNow) { 28 | // token is valid, exiting function 29 | return; 30 | } 31 | } catch (e) { 32 | console.error('Error while decoding token', { token: this.token, e }); 33 | } 34 | } 35 | 36 | // re-fetch 37 | this.token = await this.getToken(); 38 | } 39 | private async fetch(path: string, addOptions: RequestInit = {}) { 40 | try { 41 | await this.reFetchTokenIfNeeded(); 42 | const init = { 43 | headers: { 44 | 'Content-Type': 'application/json', 45 | Authorization: `Bearer ${this.token}`, 46 | }, 47 | }; 48 | const options = _.merge(init, addOptions); 49 | return await fetch(this.url(path), options); 50 | } catch (e) { 51 | console.error('Error while fetching', { path, addOptions, e }); 52 | // @TODO: show error 53 | } 54 | } 55 | public async getCounter(): Promise { 56 | const response = await this.fetch('/counter'); 57 | if (response) { 58 | const { counter } = await response.json(); 59 | return counter; 60 | } 61 | } 62 | public async bumpCounter( 63 | performer: string, 64 | title: string, 65 | fileName: string, 66 | cue: string 67 | ): Promise { 68 | const body = JSON.stringify({ performer, title, fileName, cue }); 69 | const response = await this.fetch('/counter', { method: 'POST', body }); 70 | if (response) { 71 | const { counter } = await response.json(); 72 | return counter; 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. 4 | 5 | ## [1.3.0](https://github.com/dVaffection/cuegenerator-react/compare/v1.2.1...v1.3.0) (2021-05-17) 6 | 7 | 8 | ### Features 9 | 10 | * Keep the previous cue form state ([0b0b64c](https://github.com/dVaffection/cuegenerator-react/commit/0b0b64ccc72daa62de47f6852e1834fd85fdd5cf)) 11 | 12 | 13 | ### Bug Fixes 14 | 15 | * Change app name to CUEgenerator ([c359579](https://github.com/dVaffection/cuegenerator-react/commit/c359579cfe01aa30c397e31cbbce62501b1b706d)), closes [#27](https://github.com/dVaffection/cuegenerator-react/issues/27) 16 | 17 | ### [1.2.1](https://github.com/dVaffection/cuegenerator-react/compare/v1.2.0...v1.2.1) (2021-05-01) 18 | 19 | 20 | ### Bug Fixes 21 | 22 | * Tweak parser to cast minutes to "XX", e.g. "05:12:03" instead of "5:12:03" ([21f3604](https://github.com/dVaffection/cuegenerator-react/commit/21f360474ccf8f9603eb388198d77e22d9bf7ae9)) 23 | 24 | ## [1.2.0](https://github.com/dVaffection/cuegenerator-react/compare/v1.1.0...v1.2.0) (2021-04-30) 25 | 26 | 27 | ### Features 28 | 29 | * Replace double quotes with single quotes in performer and title ([d2451c7](https://github.com/dVaffection/cuegenerator-react/commit/d2451c7c54a08f798c014ce507e30b408dc2e996)), closes [#23](https://github.com/dVaffection/cuegenerator-react/issues/23) 30 | 31 | 32 | ### Bug Fixes 33 | 34 | * Uncaught (in promise) TypeError: Failed to fetch in Counter.tsx:12 ([c5fc88e](https://github.com/dVaffection/cuegenerator-react/commit/c5fc88ec274c81889503d39313e4b29757a9d5aa)), closes [#24](https://github.com/dVaffection/cuegenerator-react/issues/24) 35 | 36 | ## [1.1.0](https://github.com/dVaffection/cuegenerator-react/compare/v1.0.4...v1.1.0) (2021-03-28) 37 | 38 | 39 | ### Features 40 | 41 | * Parse time in track list without leading zero for minutes ([8eb37de](https://github.com/dVaffection/cuegenerator-react/commit/8eb37de4d2d9b627750d04bfa9c14bf3973e4055)) 42 | 43 | ### [1.0.4](https://github.com/dVaffection/cuegenerator-react/compare/v1.0.3...v1.0.4) (2021-03-28) 44 | 45 | 46 | ### Bug Fixes 47 | 48 | * a downloadable cue file must have the ".cue" extension ([2f928b8](https://github.com/dVaffection/cuegenerator-react/commit/2f928b8d22f4a69ae9a559368e076fcac39a92bd)) 49 | 50 | ### [1.0.3](https://github.com/dVaffection/cuegenerator-react/compare/v1.0.2...v1.0.3) (2021-03-24) 51 | 52 | ### [1.0.2](https://github.com/dVaffection/cuegenerator-react/compare/v1.0.1...v1.0.2) (2021-03-23) 53 | 54 | ### 1.0.1 (2021-03-23) 55 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 14 | 15 | 24 | CUEgenerator 25 | 26 | 27 | 28 | 29 |
30 | 40 | 41 | 42 | 43 | 44 | 60 | 61 | 62 | -------------------------------------------------------------------------------- /src/Components/Help.tsx: -------------------------------------------------------------------------------- 1 | import './Help.css'; 2 | 3 | export default function Help() { 4 | return ( 5 |
6 | 20 |

Adobe Audition

21 | Set Markers 22 | Run "Abby Screenshot Reader" 29 | Press Alt+Enter or that button 36 | Select the "Markers" area 43 | Paste 44 | 45 |
46 | 47 |

Audacity

48 | Set labels 49 | Export labels 50 | Copy-paste labels to the "Timings" text area 57 | 58 |
59 | 60 |

Sony Sound Forge

61 | View Regions List 62 | Right click anywhere within Regions List 69 | Copy/Paste to the regions list field 76 | 77 |
78 | 79 |

Nero Burning ROM

80 | Nero Burning ROM timings 87 |
88 | ); 89 | } 90 | -------------------------------------------------------------------------------- /src/Cue/TimingParsers.ts: -------------------------------------------------------------------------------- 1 | import { TimeEntry } from '../types'; 2 | 3 | // frames can not be more than 74, so floor them instead of round 4 | export const msToFrames = (ms: number): number => Math.floor(parseFloat(0 + '.' + ms) * 75); 5 | 6 | export const adobeAudition = (value: string): TimeEntry | undefined => { 7 | const matches = value.match(/(\d{1,3}):(\d{2}).(\d{3,6})/i); 8 | if (null === matches) return; 9 | 10 | const mn = Number(matches[1]); 11 | const sc = Number(matches[2]); 12 | const ms = Number(matches[3]); 13 | const fr = msToFrames(ms); 14 | return { mn, sc, fr }; 15 | }; 16 | 17 | export const soundforge = (value: string): TimeEntry | undefined => { 18 | const matches = value.match(/(\d{2}:\d{2}:\d{2}[\.,:]\d{2})/i); 19 | if (null === matches) return; 20 | 21 | const time = matches[0].split(':'); 22 | const hr = time.shift() || '0'; 23 | let mn: string | number = time.shift() || '0'; 24 | 25 | // frames can be separated by .(dot) or :(colon) or ,(comma) 26 | let sc; 27 | let fr; 28 | if (time.length > 1) { 29 | sc = time.shift(); 30 | fr = time.shift(); 31 | } else { 32 | let sc_fr = time.shift() || ''; 33 | let sc_fr_split; 34 | switch (true) { 35 | case -1 != sc_fr.indexOf('.'): 36 | sc_fr_split = sc_fr.split('.'); 37 | sc = sc_fr_split.shift(); 38 | fr = sc_fr_split.shift(); 39 | break; 40 | case -1 != sc_fr.indexOf(','): 41 | sc_fr_split = sc_fr.split(','); 42 | sc = sc_fr_split.shift(); 43 | fr = sc_fr_split.shift(); 44 | break; 45 | } 46 | } 47 | 48 | if (sc === undefined || fr === undefined) return undefined; 49 | 50 | mn = parseInt(mn, 10) + parseInt(hr, 10) * 60; 51 | return { mn, sc: Number(sc), fr: Number(fr) }; 52 | }; 53 | 54 | // Nero/Winamp formats mm(m):ss(:|.)ii 55 | export const winampNero = (value: string): TimeEntry | undefined => { 56 | const matches = value.match(/(\d{2,3}:\d{2}[\.:]\d{2})/i); 57 | if (null === matches) return; 58 | 59 | const time = matches[0].split(':'); 60 | const mn = time.shift(); 61 | let sc; 62 | let fr; 63 | if (time.length == 1) { 64 | var sc_fr = time[0].split('.'); 65 | sc = sc_fr.shift(); 66 | fr = sc_fr.shift(); 67 | } else { 68 | sc = time.shift(); 69 | fr = time.shift(); 70 | } 71 | 72 | if (sc === undefined || fr === undefined) return undefined; 73 | 74 | return { mn: Number(mn), sc: Number(sc), fr: Number(fr) }; 75 | }; 76 | 77 | export const audacity = (value: string): TimeEntry | undefined => { 78 | const matches = value.match(/(\d{1,5}).(\d{6})/i); 79 | if (null === matches) return; 80 | 81 | const milliseconds = Number(matches[2]); 82 | const seconds = Number(matches[1]) || 0; 83 | const minutes = Math.floor(seconds / 60); 84 | 85 | const mn = minutes > 0 ? minutes : 0; 86 | const sc = seconds % 60; 87 | // frames can not be more than 74, so floor them instead of round 88 | const fr = msToFrames(milliseconds); 89 | return { mn, sc, fr }; 90 | }; 91 | 92 | // try to recognise raw cue timings 93 | export const standard = (value: string): TimeEntry | undefined => { 94 | const matches = value.match(/(\d{1,2}:\d{2}:\d{2})/i); 95 | if (null === matches) return; 96 | 97 | const time = matches[0].split(':'); 98 | const mn = parseInt(time[0], 10); 99 | const sc = parseInt(time[1], 10); 100 | const fr = parseInt(time[2], 10); 101 | 102 | return { mn, sc, fr }; 103 | }; 104 | -------------------------------------------------------------------------------- /public/index-firebase.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Welcome to Firebase Hosting 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 24 | 25 | 26 | 40 | 41 | 42 |
43 |

Welcome

44 |

Firebase Hosting Setup Complete

45 |

You're seeing this because you've successfully setup Firebase Hosting. Now it's time to go build something extraordinary!

46 | Open Hosting Documentation 47 |
48 |

Firebase SDK Loading…

49 | 50 | 88 | 89 | 90 | -------------------------------------------------------------------------------- /src/Cue/Parser.test.ts: -------------------------------------------------------------------------------- 1 | import { timeEntryToString } from './Formatter'; 2 | import Parser, { ParserHelper } from './Parser'; 3 | 4 | const parserHelper = new ParserHelper(); 5 | const parser = new Parser(parserHelper); 6 | 7 | describe('Parser', () => { 8 | test('parseTitle', () => { 9 | const value = ' Russia Goes Clubbing 249 (2013-07-17) (Live @ Zouk, Singapore) '; 10 | const actual = parser.parseTitle(value); 11 | const expected = 'Russia Goes Clubbing 249 (2013-07-17) (Live @ Zouk, Singapore)'; 12 | expect(actual).toBe(expected); 13 | }); 14 | test('parsePerformer', () => { 15 | const value = ' Bobina '; 16 | const actual = parser.parsePerformer(value); 17 | const expected = 'Bobina'; 18 | expect(actual).toBe(expected); 19 | }); 20 | test('parseFileName', () => { 21 | const value = ' Bobina - Russia Goes Clubbing #249 [Live @ Zouk, Singapore].mp3 '; 22 | const actual = parser.parseFileName(value); 23 | const expected = 'Bobina - Russia Goes Clubbing #249 [Live @ Zouk, Singapore].mp3'; 24 | expect(actual).toBe(expected); 25 | }); 26 | test('parseTracklist_1', () => { 27 | const value = '\ 28 | 02:41 Bobina - Miami "Echoes"'; 29 | const actual = parser.parseTrackList(value); 30 | const expected = [ 31 | { 32 | performer: 'Bobina', 33 | time: '02:41:00', 34 | title: `Miami 'Echoes'`, 35 | track: 1, 36 | }, 37 | ]; 38 | expect(actual).toStrictEqual(expected); 39 | }); 40 | test('parseTracklist_2', () => { 41 | const value = '\ 42 | 02:41 Miami "Echoes"'; 43 | const actual = parser.parseTrackList(value); 44 | const expected = [ 45 | { 46 | performer: '', 47 | time: '02:41:00', 48 | title: `Miami 'Echoes'`, 49 | track: 1, 50 | }, 51 | ]; 52 | expect(actual).toStrictEqual(expected); 53 | }); 54 | test('parseRegionsList', () => { 55 | const regionsList: { [key: string]: string } = { 56 | ' Marker 06 01:10:38:52': '70:38:52', 57 | ' 22 02:01:50.04': '121:50:04', 58 | ' 22 02:01:50,04': '121:50:04', 59 | ' 5541.293333 7143.640000 19': '92:21:21', 60 | ' 50:10:01 \n': '50:10:01', 61 | '01 120:10.01 (Split)': '120:10:01', 62 | 'Marker 02 0:09.623': '00:09:46', 63 | }; 64 | 65 | Object.keys(regionsList).forEach((key: string) => { 66 | const actual = parser.parseTimings(key); 67 | const expected = regionsList[key]; 68 | expect(timeEntryToString(actual[0])).toBe(expected); 69 | }); 70 | }); 71 | }); 72 | 73 | describe('ParserHelper', () => { 74 | test('splitTitlePerformer', () => { 75 | const value = "02:41 Dinka - Elements (EDX's 5un5hine Remix)"; 76 | const actual = parserHelper.splitTitlePerformer(value); 77 | const expected = { 78 | performer: '02:41 Dinka', 79 | title: "Elements (EDX's 5un5hine Remix)", 80 | }; 81 | expect(actual).toStrictEqual(expected); 82 | }); 83 | test('splitTitlePerformerTitleOnly', () => { 84 | const value = "02:41 Dinka Elements (EDX's 5un5hine Remix)"; 85 | const actual = parserHelper.splitTitlePerformer(value); 86 | const expected = { 87 | performer: '', 88 | title: "02:41 Dinka Elements (EDX's 5un5hine Remix)", 89 | }; 90 | expect(actual).toStrictEqual(expected); 91 | }); 92 | test('removeDoubleQuotes', () => { 93 | const value = 'Elements (EDX "5un5hine" Remix)'; 94 | const actual = parserHelper.removeDoubleQuotes(value); 95 | const expected = 'Elements (EDX 5un5hine Remix)'; 96 | expect(actual).toBe(expected); 97 | }); 98 | test('replaceDoubleQuotes', () => { 99 | const value = `Elements (EDX "5un5hine" Remix)`; 100 | const actual = parserHelper.replaceDoubleQuotes(value); 101 | const expected = `Elements (EDX '5un5hine' Remix)`; 102 | expect(actual).toBe(expected); 103 | }); 104 | test('separateTime', () => { 105 | const timePerformers: { [key: string]: { time: string; residue: string } } = { 106 | '[08:45] 03. 8 Ball': { 107 | time: '08:45', 108 | residue: '[] 03. 8 Ball', 109 | }, 110 | '01.[18:02] Giuseppe': { 111 | time: '18:02', 112 | residue: '01.[] Giuseppe', 113 | }, 114 | '10:57 02. Space Manoeuvres': { 115 | time: '10:57', 116 | residue: '02. Space Manoeuvres', 117 | }, 118 | ' CJ Bolland ': { 119 | time: '', 120 | residue: 'CJ Bolland', 121 | }, 122 | '04 Mr. Fluff': { 123 | time: '', 124 | residue: '04 Mr. Fluff', 125 | }, 126 | '9999:53 T.O.M': { 127 | time: '9999:53', 128 | residue: 'T.O.M', 129 | }, 130 | '999:02:28 Mossy': { 131 | time: '999:02:28', 132 | residue: 'Mossy', 133 | }, 134 | '2:28 NO LEADING ZERO': { 135 | time: '2:28', 136 | residue: 'NO LEADING ZERO', 137 | }, 138 | 'School 1:42': { 139 | time: '1:42', 140 | residue: 'School', 141 | }, 142 | }; 143 | Object.keys(timePerformers).forEach((key) => { 144 | const actual = parserHelper.separateTime(key); 145 | const actualTime = actual.time; 146 | const actualPerformer = actual.residue; 147 | 148 | const expectedTime = timePerformers[key].time; 149 | const expectedPerformer = timePerformers[key].residue; 150 | 151 | expect(actualTime).toBe(expectedTime); 152 | expect(actualPerformer).toBe(expectedPerformer); 153 | }); 154 | }); 155 | test('cleanOffTime', () => { 156 | const performers: { [key: string]: string } = { 157 | '] Giuseppe': 'Giuseppe', 158 | '02. Space Manoeuvres': 'Space Manoeuvres', 159 | '04 Mr. Fluff': 'Mr. Fluff', 160 | 'CJ Bolland': 'CJ Bolland', 161 | '08) CJ Bolland': 'CJ Bolland', 162 | '] 03. 8 Ball': '8 Ball', 163 | }; 164 | Object.keys(performers).forEach((key) => { 165 | const actual = parserHelper.cleanOffTime(key); 166 | const expected = performers[key]; 167 | expect(actual).toBe(expected); 168 | }); 169 | }); 170 | test('castTime', () => { 171 | const times: { [key: string]: string } = { 172 | // h:m:s|m:s -> m:s:f 173 | '999:02:28': '59942:28:00', 174 | '9999:53': '9999:53:00', 175 | '3:28': '03:28:00', 176 | '1:02:28': '62:28:00', 177 | '56:63': '56:63:00', 178 | '246:10': '246:10:00', 179 | '': '00:00:00', 180 | '00:05:12': '05:12:00', 181 | }; 182 | 183 | Object.keys(times).forEach(function (key) { 184 | const actual = parserHelper.castTime(key); 185 | const expected = times[key]; 186 | expect(actual).toBe(expected); 187 | }); 188 | }); 189 | }); 190 | -------------------------------------------------------------------------------- /src/Cue/Parser.ts: -------------------------------------------------------------------------------- 1 | import { Timings, TimeEntry, TrackList } from '../types'; 2 | import { timeEntryToString } from './Formatter'; 3 | import { adobeAudition, audacity, soundforge, standard, winampNero } from './TimingParsers'; 4 | 5 | export class ParserHelper { 6 | // that's how we tell performer and title apart 7 | private titlePerformerSeparators = [ 8 | ' - ', // 45 hyphen-minus 9 | ' – ', // 8211 en dash 10 | ' ‒ ', // 8210 figure dash 11 | ' — ', // 8212 em dash 12 | ' ― ', // 8213 horizontal bar 13 | ]; 14 | splitTitlePerformer(value: string) { 15 | // `foreach` and `switch` are toooooooooo slow! 16 | let split = [], 17 | performer = '', 18 | title = ''; 19 | 20 | if (-1 !== value.search(this.titlePerformerSeparators[0])) { 21 | split = value.split(this.titlePerformerSeparators[0]); 22 | } else if (-1 !== value.search(this.titlePerformerSeparators[1])) { 23 | split = value.split(this.titlePerformerSeparators[1]); 24 | } else if (-1 !== value.search(this.titlePerformerSeparators[2])) { 25 | split = value.split(this.titlePerformerSeparators[2]); 26 | } else if (-1 !== value.search(this.titlePerformerSeparators[3])) { 27 | split = value.split(this.titlePerformerSeparators[3]); 28 | } else if (-1 !== value.search(this.titlePerformerSeparators[4])) { 29 | split = value.split(this.titlePerformerSeparators[4]); 30 | } else { 31 | split = [value]; 32 | } 33 | 34 | // if string wasn't split yet then we get just a title (performer assumed to be the global one) 35 | if (1 === split.length) { 36 | performer = ''; 37 | title = split.shift() || ''; 38 | } else { 39 | performer = split.shift() || ''; 40 | title = split.join(' '); 41 | } 42 | 43 | return { 44 | performer: performer.trim(), 45 | title: title.trim(), 46 | }; 47 | } 48 | 49 | separateTime(value: string) { 50 | var time = '', 51 | residue = ''; 52 | 53 | // ask to increase minutes up to 9999 referred to the "h:m" not "h:m:f" format 54 | // but I still increased "h:m:f" up to 999 hours just in case 55 | // https://github.com/dVaffection/cuegenerator/issues/14 56 | 57 | // 01. 9999:53 | 999:02:28 58 | var pattern = /(?:\d{2}\.)?\[?((?:\d{1,3}:)?\d{1,4}:\d{2})\]?.*/i; 59 | var matches = value.match(pattern); 60 | 61 | if (matches && matches[1]) { 62 | time = matches[1].trim(); 63 | // residue = value.substring(value.indexOf(matches[1]) + matches[1].length).trim(); 64 | residue = value.replace(time, '').trim(); 65 | } else { 66 | residue = value.trim(); 67 | } 68 | 69 | return { 70 | time: time, 71 | residue: residue, 72 | }; 73 | } 74 | 75 | /** 76 | * Accept time in format either hr:mn:sc or mn:sc 77 | * 78 | * @param {String} 79 | * @returns mn:sc:fr 80 | */ 81 | castTime(value: string) { 82 | let castTime = '00:00:00'; 83 | value = value.trim(); 84 | 85 | const pattern = /^\d{1,4}:\d{2}:\d{2}$/; 86 | const matches = value.match(pattern); 87 | if (matches) { 88 | const times = value.split(':'); 89 | const hrParsed = parseInt(times[0], 10); 90 | const mnParsed = parseInt(times[1], 10); 91 | const sc = times[2].padStart(2, '0'); 92 | const mn = String(hrParsed * 60 + mnParsed).padStart(2, '0'); 93 | castTime = mn + ':' + sc + ':00'; 94 | } else { 95 | const pattern = /(^\d{1,4}):(\d{2})$/; 96 | const matches = value.match(pattern); 97 | if (matches) { 98 | const mn = matches[1].padStart(2, '0'); 99 | const sc = matches[2].padStart(2, '0'); 100 | castTime = `${mn}:${sc}:00`; 101 | } 102 | } 103 | 104 | return castTime; 105 | } 106 | 107 | cleanOffTime(value: string) { 108 | var pattern = /^(?:\]? )?(?:\d{2}\)?\.? )?(.*)$/i; 109 | var matches = value.match(pattern); 110 | 111 | if (matches && matches[1]) { 112 | value = matches[1]; 113 | } 114 | 115 | return value; 116 | } 117 | 118 | removeDoubleQuotes(value: string) { 119 | return value.replace(/"/g, ''); 120 | } 121 | 122 | replaceDoubleQuotes(value: string) { 123 | return value.replace(/"/g, "'"); 124 | } 125 | } 126 | 127 | export default class Parser { 128 | static readonly regionsListParsers = [adobeAudition, audacity, soundforge, winampNero, standard]; 129 | 130 | constructor(readonly helper: ParserHelper) {} 131 | 132 | parsePerformer(v: string): string { 133 | return v.trim(); 134 | } 135 | parseTitle(v: string): string { 136 | return v.trim(); 137 | } 138 | parseFileName(v: string): string { 139 | return v.trim(); 140 | } 141 | parseTrackList(value: string): TrackList { 142 | const trackList = []; 143 | let time, performer, title; 144 | 145 | const contentInLines = value.split('\n'); 146 | for (let i = 0, track = 1; i < contentInLines.length; i++, track++) { 147 | const row = contentInLines[i].trim(); 148 | if (!row.length) { 149 | track--; 150 | continue; 151 | } 152 | 153 | const performerTitle = this.helper.splitTitlePerformer(row); 154 | 155 | if (performerTitle.performer) { 156 | const timePerformer = this.helper.separateTime(performerTitle.performer); 157 | time = this.helper.castTime(timePerformer.time); 158 | performer = this.helper.cleanOffTime(timePerformer.residue); 159 | title = performerTitle.title; 160 | } else { 161 | performer = ''; 162 | const timeTitle = this.helper.separateTime(performerTitle.title); 163 | time = this.helper.castTime(timeTitle.time); 164 | title = this.helper.cleanOffTime(timeTitle.residue); 165 | } 166 | 167 | performer = this.helper.replaceDoubleQuotes(performer); 168 | title = this.helper.replaceDoubleQuotes(title); 169 | 170 | trackList.push({ 171 | track, 172 | performer, 173 | title, 174 | time, 175 | }); 176 | } 177 | 178 | return trackList; 179 | } 180 | 181 | parseTimings(value: string): Timings { 182 | const contents = value.split('\n'); 183 | 184 | // define a parser 185 | let regionsListParser: typeof Parser.regionsListParsers[0] | undefined; 186 | for (const parser of Parser.regionsListParsers) { 187 | const timeEntry = parser(contents[0] || ''); 188 | if (timeEntry) { 189 | regionsListParser = parser; 190 | break; 191 | } 192 | } 193 | 194 | if (regionsListParser === undefined) return []; 195 | 196 | // apply the parser for every line 197 | return contents 198 | .map((row) => regionsListParser!(row)) 199 | .filter((timeEntry): timeEntry is TimeEntry => timeEntry !== undefined); 200 | } 201 | } 202 | -------------------------------------------------------------------------------- /src/Components/Form.tsx: -------------------------------------------------------------------------------- 1 | import { useContext, useEffect, useState } from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | import _ from 'lodash'; 4 | import './Form.css'; 5 | import FormSelect from './Form/FormSelect'; 6 | import { formHandler } from '../Cue'; 7 | import { api, analytics, cueStorage } from '../Services'; 8 | import CounterContext from './CounterContext'; 9 | import { makeCueFileName } from '../Utils'; 10 | import { AnalyticsEvent } from '../Services/Analytics'; 11 | import { CueFormInputs } from '../types'; 12 | 13 | interface FORM_STATE_TYPE extends CueFormInputs { 14 | cue: string; 15 | } 16 | 17 | // type FORM_STATE_TYPE = { 18 | // performer: string; 19 | // title: string; 20 | // fileName: string; 21 | // fileType: string; 22 | // trackList: string; 23 | // regionsList: string; 24 | // cue: string; 25 | // }; 26 | 27 | export default function Form() { 28 | const fileTypes = ['MP3', 'AAC', 'AIFF', 'ALAC', 'BINARY', 'FLAC', 'MOTOROLA', 'WAVE']; 29 | const FORM_INIT_STATE = { 30 | performer: '', 31 | title: '', 32 | fileName: '', 33 | fileType: fileTypes[0], 34 | trackList: '', 35 | regionsList: '', 36 | cue: '', 37 | }; 38 | 39 | const [formState, setFormState] = useState(FORM_INIT_STATE); 40 | const [clientViewportHeight, setClientViewportHeight] = useState(0); 41 | const [tracklistHeight, setTracklistHeight] = useState('auto'); 42 | const [cueHeight, setCueHeight] = useState('auto'); 43 | const [isLoadCueButtonVisible, setLoadCueButtonVisible] = useState(false); 44 | const { setCounter } = useContext(CounterContext); 45 | 46 | useEffect(() => { 47 | setClientViewportHeight(window.innerHeight); 48 | setTracklistHeight(clientViewportHeight - 20 - 375); 49 | setCueHeight(clientViewportHeight - 20 - 173); 50 | }, [clientViewportHeight]); 51 | useEffect(() => { 52 | setLoadCueButtonVisible(cueStorage.hasPrevCue()); 53 | }, []); 54 | 55 | const anyInputOnChange = (event: React.ChangeEvent) => { 56 | const name = event.target.name; 57 | const value = event.target.value || ''; 58 | const preUpdatedState = { ...formState, ...{ [name]: value } }; 59 | 60 | const { performer, title, fileName, fileType, trackList, regionsList } = preUpdatedState; 61 | const cue = formHandler.createCue(performer, title, fileName, fileType, trackList, regionsList); 62 | const updatedState = { ...preUpdatedState, ...{ cue } }; 63 | 64 | setFormState(updatedState); 65 | }; 66 | const cueOnFocusHandler = _.once((event: any) => event.target.select()); 67 | const saveButtonOnClick = async (event: React.FormEvent) => { 68 | if (!formState.cue) return; 69 | bumpCueCounter(); 70 | saveCueAsFile(); 71 | analytics.logEvent(AnalyticsEvent.CUE_FILE_SAVED); 72 | 73 | const { performer, title, fileName, fileType, trackList, regionsList } = formState; 74 | cueStorage.setPrevCue({ performer, title, fileName, fileType, trackList, regionsList }); 75 | setLoadCueButtonVisible(cueStorage.hasPrevCue()); 76 | }; 77 | const bumpCueCounter = async () => { 78 | const { performer, title, fileName, cue } = formState; 79 | const counter = await api.bumpCounter(performer, title, fileName, cue); 80 | if (counter) setCounter(counter); 81 | }; 82 | const saveCueAsFile = () => { 83 | const blob = new Blob([formState.cue], { type: 'octet/stream' }); 84 | let url = window.URL.createObjectURL(blob); 85 | let a = document.createElement('a'); 86 | a.href = url; 87 | a.download = makeCueFileName(formState.fileName); 88 | a.click(); 89 | }; 90 | const loadButtonOnClick = async (event: React.FormEvent) => { 91 | const prevCue = cueStorage.getPrevCue(); 92 | if (prevCue) { 93 | const { performer, title, fileName, fileType, trackList, regionsList } = prevCue; 94 | const cue = formHandler.createCue(performer, title, fileName, fileType, trackList, regionsList); 95 | const updatedState = { ...formState, ...prevCue, ...{ cue } }; 96 | setFormState(updatedState); 97 | } 98 | 99 | analytics.logEvent(AnalyticsEvent.PREV_CUE_LOADED); 100 | }; 101 | 102 | return ( 103 |
104 | 111 | {isLoadCueButtonVisible && ( 112 | 119 | )} 120 |
121 | 122 |
123 |
124 | 125 | 133 |
134 |
135 | 136 | 144 |
145 |
146 | 147 | 155 |
156 |
157 | 158 | {FormSelect(fileTypes, 'fileType', formState.fileType, anyInputOnChange, 4)} 159 |
160 |
161 | 162 | 172 |
173 |
174 | 182 | 191 |
192 |
193 |
194 | 195 | 207 |
208 |
209 | ); 210 | } 211 | --------------------------------------------------------------------------------