Skip to content

Commit

Permalink
Upload scales to a server for sharing
Browse files Browse the repository at this point in the history
Add privacy policy and terms of service.

ref #456
  • Loading branch information
frostburn committed May 18, 2024
1 parent 8b01f59 commit 89960e0
Show file tree
Hide file tree
Showing 27 changed files with 1,071 additions and 84 deletions.
1 change: 1 addition & 0 deletions .env.development
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
VITE_API_URL=http://localhost:17461/
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,6 @@ coverage
*.njsproj
*.sln
*.sw?

# Production secrets
.env.production
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,10 @@ npm run test:e2e
npm run lint
```

### Run the backend server

See [sw-server](https://github.com/xenharmonic-devs/sw-server) for the backend component for storing and retrieving scales.

## License

MIT, see [LICENCE](LICENSE) for details.
Expand Down
2 changes: 1 addition & 1 deletion cypress/e2e/basic.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ describe("404 page", () => {
it("creates an octaplex", () => {
cy.visit("/non-existing-page");
cy.contains("h2", "Not found");
cy.get("a").last().click();
cy.get("#octaplex").click();
cy.get("button").first().click();
cy.contains("h2", "Scale data");
cy.get("#scale-name").should(
Expand Down
12 changes: 6 additions & 6 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "scale-workshop",
"version": "3.0.0-beta.27",
"version": "3.0.0-beta.28",
"scripts": {
"dev": "vite",
"build": "run-p type-check \"build-only {@}\" --",
Expand All @@ -21,7 +21,7 @@
"moment-of-symmetry": "^0.5.2",
"pinia": "^2.1.7",
"qs": "^6.12.0",
"sonic-weave": "^0.1.1",
"sonic-weave": "^0.2.0",
"sw-synth": "^0.1.0",
"temperaments": "^0.5.3",
"values.js": "^2.1.1",
Expand Down
24 changes: 19 additions & 5 deletions src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -313,7 +313,7 @@ function typingKeydown(event: CoordinateKeyboardEvent) {
let index = scale.scale.baseMidiNote + scale.scale.size * scale.equaveShift + scale.degreeShift
if (scale.keyboardMode === 'isomorphic') {
index += x * state.isomorphicHorizontal + (2 - y) * state.isomorphicVertical
index += x * scale.isomorphicHorizontal + (2 - y) * scale.isomorphicVertical
} else {
if (scale.qwertyMapping.has(event.code)) {
// QWERTY mapping incorporates shifts
Expand Down Expand Up @@ -356,8 +356,8 @@ onMounted(() => {
scale.userBaseFrequency = scaleWorkshopOneData.freq
scale.autoFrequency = false
scale.baseMidiNote = scaleWorkshopOneData.midi
state.isomorphicHorizontal = scaleWorkshopOneData.horizontal
state.isomorphicVertical = scaleWorkshopOneData.vertical
scale.isomorphicHorizontal = scaleWorkshopOneData.horizontal
scale.isomorphicVertical = scaleWorkshopOneData.vertical
if (scaleWorkshopOneData.data !== undefined) {
const colors = scaleWorkshopOneData.colors ?? ''
Expand Down Expand Up @@ -394,8 +394,8 @@ onMounted(() => {
scale.userBaseFrequency = decodedState.baseFrequency
scale.autoFrequency = false
scale.baseMidiNote = decodedState.baseMidiNote
state.isomorphicHorizontal = decodedState.isomorphicHorizontal
state.isomorphicVertical = decodedState.isomorphicVertical
scale.isomorphicHorizontal = decodedState.isomorphicHorizontal
scale.isomorphicVertical = decodedState.isomorphicVertical
scale.keyboardMode = decodedState.keyboardMode
scale.pianoMode = pianoMode
scale.equaveShift = decodedState.equaveShift
Expand Down Expand Up @@ -505,6 +505,10 @@ function panic() {
:typingKeyboard="typingKeyboard"
@panic="panic"
/>
<footer id="app-footer">
<RouterLink to="/privacy-policy">Privacy policy</RouterLink>,
<RouterLink to="/terms-of-service">Terms of service</RouterLink>
</footer>
</template>

<style>
Expand Down Expand Up @@ -600,4 +604,14 @@ nav a:first-of-type {
#app-tray ul li .active {
color: var(--color-accent-text);
}
#app-footer {
font-size: small;
line-height: 1;
padding-right: 1em;
text-align: right;
}
#app-footer a {
color: var(--color-text-mute);
}
</style>
33 changes: 32 additions & 1 deletion src/__tests__/util.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import { describe, it, expect } from 'vitest'

import { autoKeyColors, formatExponential, formatHertz, gapKeyColors } from '../utils'
import {
autoKeyColors,
encodeUrlSafe64,
formatExponential,
formatHertz,
gapKeyColors,
randomId
} from '../utils'

function naiveExponential(x: number, fractionDigits = 3) {
if (Math.abs(x) < 10000) {
Expand Down Expand Up @@ -92,3 +99,27 @@ describe('Gap key color algorithm', () => {
)
})
})

describe('URL safe number encoder', () => {
it('encodes the whole range', () => {
const expected = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-~'
for (let i = 0; i < 64; ++i) {
expect(encodeUrlSafe64(i)).toBe(expected[i])
}
})
})

describe('Unique ID generator', () => {
it('produces a short URL-friendly identifier', () => {
const urlSafe = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-~'
const id = randomId()
expect(id).toHaveLength(9)
for (const char of id) {
expect(urlSafe).toContain(char)
}
})

it("won't collide with this particular identifier for 30 years", () => {
expect(randomId()).not.toBe('oKh5gWb04')
})
})
109 changes: 79 additions & 30 deletions src/components/ExporterButtons.vue
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
<script setup lang="ts">
import { APP_TITLE } from '@/constants'
import { API_URL, APP_TITLE } from '@/constants'
import { exportFile, type ExporterKey } from '@/exporters'
import type { ExporterParams } from '@/exporters/base'
import { useAudioStore } from '@/stores/audio'
import { useScaleStore } from '@/stores/scale'
import { useStateStore } from '@/stores/state'
import { sanitizeFilename } from '@/utils'
import { defineAsyncComponent, ref } from 'vue'
import { makeEnvelope, sanitizeFilename } from '@/utils'
import { computed, defineAsyncComponent, ref } from 'vue'
const KorgExportModal = defineAsyncComponent(
() => import('@/components/modals/export/KorgExport.vue')
Expand All @@ -19,42 +20,90 @@ const ReaperExportModal = defineAsyncComponent(
const state = useStateStore()
const scale = useScaleStore()
const audio = useAudioStore()
const exportTextClipboard = ref('[URL sharing disabled]')
const exportTextClipboard = ref(
API_URL ? "Copy this scale's unique URL to clipboard" : '[URL sharing disabled]'
)
const showKorgExportModal = ref(false)
const showMtsSysexExportModal = ref(false)
const showReaperExportModal = ref(false)
const uploadBody = computed(() => {
return JSON.stringify({
id: scale.id,
payload: {
scale: scale.toJSON(),
audio: audio.toJSON()
},
envelope: makeEnvelope(state.shareStatistics)
})
})
function uploadScale(retries = 1): Promise<string> {
const uploadId = scale.id
if (scale.uploadedId === uploadId) {
return Promise.resolve(`${window.location.host}/scale/${uploadId}`)
}
return new Promise((resolve) => {
if (!API_URL) {
return resolve(window.location.host)
}
fetch(new URL('scale', API_URL), { method: 'POST', body: uploadBody.value })
.then((res) => {
// Id collision: Retry
if (res.status === 409 && retries > 0) {
scale.rerollId()
return uploadScale(retries - 1).then(resolve)
}
if (res.ok) {
scale.uploadedId = uploadId
return resolve(`${window.location.host}/scale/${uploadId}`)
} else {
return resolve(window.location.host)
}
})
.catch(() => resolve(window.location.host))
})
}
defineExpose({ uploadScale })
function copyToClipboard() {
exportTextClipboard.value = 'No!'
window.setTimeout(() => {
exportTextClipboard.value = '[URL sharing disabled]'
}, 5000)
/*
window.navigator.clipboard.writeText(window.location.href)
exportTextClipboard.value = '[Copied URL to clipboard]'
window.setTimeout(() => {
exportTextClipboard.value = "Copy this scale's unique URL to clipboard"
}, 5000)
*/
if (API_URL) {
uploadScale().then((url) => {
window.navigator.clipboard.writeText(url)
exportTextClipboard.value = '[Copied URL to clipboard]'
window.setTimeout(() => {
exportTextClipboard.value = "Copy this scale's unique URL to clipboard"
}, 5000)
})
} else {
exportTextClipboard.value = 'You must have sw-server running for this to work!'
window.setTimeout(() => {
exportTextClipboard.value = '[URL sharing disabled]'
}, 5000)
}
}
function doExport(exporter: ExporterKey) {
const params: ExporterParams = {
newline: state.newline,
scaleUrl: window.location.href,
filename: sanitizeFilename(scale.scale.title),
relativeIntervals: scale.relativeIntervals,
scale: scale.scale,
labels: scale.labels,
midiOctaveOffset: -1,
description: scale.scale.title,
sourceText: scale.sourceText,
appTitle: APP_TITLE,
date: new Date()
}
uploadScale().then((scaleUrl) => {
const params: ExporterParams = {
newline: state.newline,
scaleUrl,
filename: sanitizeFilename(scale.scale.title),
relativeIntervals: scale.relativeIntervals,
scale: scale.scale,
labels: scale.labels,
midiOctaveOffset: -1,
description: scale.scale.title,
sourceText: scale.sourceText,
appTitle: APP_TITLE,
date: new Date()
}
exportFile(exporter, params)
exportFile(exporter, params)
})
}
</script>
<template>
Expand Down Expand Up @@ -93,7 +142,7 @@ function doExport(exporter: ExporterKey) {
/>
</Teleport>
<h2>Export current settings</h2>
<a href="#" class="btn disabled" @click="copyToClipboard">
<a href="#" :class="{ btn: true, disabled: !API_URL }" @click="copyToClipboard">
<p><strong>Share scale</strong></p>
<p>{{ exportTextClipboard }}</p>
</a>
Expand Down
3 changes: 3 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ import { Interval, TimeMonzo } from 'sonic-weave'
import { version } from '../package.json'
import { Fraction, PRIME_CENTS } from 'xen-dev-utils'

// .env config
export const API_URL: string | undefined = import.meta.env.VITE_API_URL

// GLOBALS
export const APP_TITLE = `Scale Workshop ${version}`

Expand Down
26 changes: 26 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,32 @@ import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from '@/App.vue'
import router from '@/router'
import { API_URL } from './constants'

if (import.meta.env.DEV) {
if (API_URL) {
fetch(API_URL)
.then((res) => res.text())
.then((body) => {
if (!body.includes('Scale Workshop server')) {
console.warn('VITE_API_URL responded with foreign data.')
console.log(body)
} else {
console.info('VITE_API_URL responded. Scale URLs should work.')
}
})
.catch((err) => {
console.warn('VITE_API_URL did not respond. Is sw-server running?')
console.error(err)
})
} else {
console.warn('VITE_API_URL not configured. Scale URLs will not work.')
}
}

if (!localStorage.getItem('uuid')) {
localStorage.setItem('uuid', crypto.randomUUID())
}

const app = createApp(App)

Expand Down
15 changes: 15 additions & 0 deletions src/router/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,21 @@ const router = createRouter({
// which is lazy-loaded when the route is visited.
component: () => import('../views/AboutView.vue')
},
{
path: '/scale/:id',
name: 'load-scale',
component: () => import('../views/LoadScaleView.vue')
},
{
path: '/privacy-policy',
name: 'privacy-policy',
component: () => import('../views/PrivacyPolicy.vue')
},
{
path: '/terms-of-service',
name: 'terms-of-service',
component: () => import('../views/TermsOfService.vue')
},
{
path: '/analysis',
name: 'analysis',
Expand Down
Loading

0 comments on commit 89960e0

Please sign in to comment.