Delete all files except for demo

This commit is contained in:
Anthony Wang 2023-02-15 16:22:43 -05:00
parent fb3c976380
commit 091ff04c24
Signed by: a
GPG key ID: 42A5B952E6DD8D38
14 changed files with 0 additions and 771 deletions

View file

@ -1,5 +0,0 @@
# Graffiti Javascript Library
This library enables any webpage to interface with the [Graffiti server](https://github.com/graffiti-garden/graffiti-server). It also includes a plugin that extends it to operate with the [Vue.js framework](https://vuejs.org/).
Check out the live [demo](https://graffiti.garden/graffiti-js/demo) of the library and plugin in action. The demo's source code is in the [`/demo`](https://github.com/graffiti-garden/graffiti-js/tree/main/demo) folder.

View file

@ -1,351 +0,0 @@
import Auth from './src/auth.js'
import GraffitiArray from './src/array.js'
export default class {
// There needs to be a new object map for each tag
constructor(
graffitiURL="https://graffiti.garden",
objectConstructor=()=>({})) {
this.graffitiURL = graffitiURL
this.open = false
this.eventTarget = new EventTarget()
this.tagMap = objectConstructor() // tag->{count, Set(uuid)}
this.objectMap = objectConstructor() // uuid->object
this.GraffitiArray = GraffitiArray(this)
this.#initialize()
}
async #initialize() {
// Perform authorization
this.authParams = await Auth.connect(this.graffitiURL)
// Rewrite the URL
this.wsURL = new URL(this.graffitiURL)
this.wsURL.host = "app." + this.wsURL.host
if (this.wsURL.protocol == 'https:') {
this.wsURL.protocol = 'wss:'
} else {
this.wsURL.protocol = 'ws:'
}
if (this.authParams.token) {
this.wsURL.searchParams.set("token", this.authParams.token)
}
// Commence connection
this.#connect()
}
// Wait for the connection to be
// open (state=true) or closed (state=false)
async connectionState(state) {
if (this.open != state) {
await new Promise(resolve => {
this.eventTarget.addEventListener(
state? "open": "closed", ()=> resolve())
})
}
}
#connect() {
this.ws = new WebSocket(this.wsURL)
this.ws.onmessage = this.#onMessage.bind(this)
this.ws.onclose = this.#onClose.bind(this)
this.ws.onopen = this.#onOpen.bind(this)
}
// authorization functions
get myID() { return this.authParams.myID }
toggleLogIn() {
this.myID? Auth.logOut() : Auth.logIn(this.graffitiURL)
}
async #onClose() {
console.error("lost connection to graffiti server, attemping reconnect soon...")
this.open = false
this.eventTarget.dispatchEvent(new Event("closed"))
await new Promise(resolve => setTimeout(resolve, 2000))
this.#connect()
}
async #request(msg) {
if (!this.open) {
throw "Can't make request! Not connected to graffiti server"
}
// Create a random message ID
const messageID = crypto.randomUUID()
// Create a listener for the reply
const dataPromise = new Promise(resolve => {
this.eventTarget.addEventListener('$'+messageID, (e) => {
resolve(e.data)
})
})
// Send the request
msg.messageID = messageID
this.ws.send(JSON.stringify(msg))
// Await the reply
const data = await dataPromise
delete data.messageID
if ('error' in data) {
throw data
} else {
return data.reply
}
}
#onMessage(event) {
const data = JSON.parse(event.data)
if ('messageID' in data) {
// It's a reply
// Forward it back to the sender
const messageEvent = new Event('$'+data.messageID)
messageEvent.data = data
this.eventTarget.dispatchEvent(messageEvent)
} else if ('update' in data) {
this.#updateCallback(data['update'])
} else if ('remove' in data) {
this.#removeCallback(data['remove'])
} else if (data.type == 'error') {
if (data.reason == 'authorization') {
Auth.logOut()
}
throw data
}
}
#updateCallback(object) {
const uuid = this.#objectUUID(object)
// Add the UUID to the tag map
let subscribed = false
for (const tag of object._tags) {
if (!(tag in this.tagMap)) continue
this.tagMap[tag].uuids.add(uuid)
subscribed = true
}
if (!subscribed) return
// Define object specific properties
if (!('_id' in object)) {
// Assign the object UUID
Object.defineProperty(object, '_id', { value: uuid })
// Add proxy functions so object modifications
// sync with the server
object = new Proxy(object, this.#objectHandler(object, true))
}
this.objectMap[uuid] = object
}
#removeCallback(object) {
const uuid = this.#objectUUID(object)
// Remove the UUID from all relevant tag maps
let supported = false
for (const tag in this.tagMap) {
if (this.tagMap[tag].uuids.has(uuid)) {
if (object._tags.includes(tag)) {
this.tagMap[tag].uuids.delete(uuid)
} else {
supported = true
}
}
}
// If all tags have been removed, delete entirely
if (!supported && uuid in this.objectMap) {
delete this.objectMap[uuid]
}
}
async update(object) {
object._by = this.myID
if (!object._key) object._key = crypto.randomUUID()
// Immediately replace the object
this.#updateCallback(object)
// Send it to the server
try {
await this.#request({ update: object })
} catch(e) {
// Delete the temp object
this.#removeCallback(object)
throw e
}
}
#objectHandler(object, root) {
return {
get: (target, prop, receiver)=>
this.#getObjectProperty(object, target, prop, receiver),
set: (target, prop, val, receiver)=>
this.#setObjectProperty(object, root, target, prop, val, receiver),
deleteProperty: (target, prop)=>
this.#deleteObjectProperty(object, root, target, prop)
}
}
#getObjectProperty(object, target, prop, receiver) {
if (typeof target[prop] === 'object' && target[prop] !== null) {
return new Proxy(Reflect.get(target, prop, receiver), this.#objectHandler(object, false))
} else {
return Reflect.get(target, prop, receiver)
}
}
#setObjectProperty(object, root, target, prop, val, receiver) {
// Store the original, perform the update,
// sync with server and restore original if error
const originalObject = Object.assign({}, object)
if (Reflect.set(target, prop, val, receiver)) {
this.#removeCallback(originalObject)
this.#updateCallback(object)
this.#request({ update: object }).catch(e=> {
this.#removeCallback(object)
this.#updateCallback(originalObject)
throw e
})
return true
} else { return false }
}
#deleteObjectProperty(object, root, target, prop) {
const originalObject = Object.assign({}, object)
if (root && ['_key', '_by', '_tags'].includes(prop)) {
// This is a deletion of the whole object
this.#removeCallback(object)
this.#request({ remove: object._key }).catch(e=> {
this.#updateCallback(originalObject)
throw e
})
return true
} else {
if (Reflect.deleteProperty(target, prop)) {
this.#request({ update: object }).catch(e=> {
this.#updateCallback(originalObject)
throw e
})
return true
} else { return false }
}
}
async myTags() {
return await this.#request({ ls: null })
}
async objectByKey(userID, objectKey) {
return await this.#request({ get: {
_by: userID,
_key: objectKey
}})
}
objects(...tags) {
tags = tags.filter(tag=> tag!=null)
for (const tag of tags) {
if (!(tag in this.tagMap)) {
throw `You are not subscribed to '${tag}'`
}
}
// Merge by UUIDs from all tags and
// convert to relevant objects
const uuids = new Set(tags.map(tag=>[...this.tagMap[tag].uuids]).flat())
const objects = [...uuids].map(uuid=> this.objectMap[uuid])
// Return an array wrapped with graffiti functions
return new this.GraffitiArray(...objects)
}
async subscribe(...tags) {
tags = tags.filter(tag=> tag!=null)
// Look at what is already subscribed to
const subscribingTags = []
for (const tag of tags) {
if (tag in this.tagMap) {
// Increase the count
this.tagMap[tag].count++
} else {
// Create a new slot
this.tagMap[tag] = {
uuids: new Set(),
count: 1
}
subscribingTags.push(tag)
}
}
// Try subscribing in the background
// but don't raise an error since
// the subscriptions will happen once connected
if (subscribingTags.length)
try {
await this.#request({ subscribe: subscribingTags })
} catch {}
}
async unsubscribe(...tags) {
tags = tags.filter(tag=> tag!=null)
// Decrease the count of each tag,
// removing and marking if necessary
const unsubscribingTags = []
for (const tag of tags) {
this.tagMap[tag].count--
if (!this.tagMap[tag].count) {
unsubscribingTags.push(tag)
delete this.tagMap[tag]
}
}
// Unsubscribe from all remaining tags
if (unsubscribingTags.length)
try {
await this.#request({ unsubscribe: unsubscribingTags })
} catch {}
}
async #onOpen() {
console.log("connected to the graffiti socket")
this.open = true
this.eventTarget.dispatchEvent(new Event("open"))
// Clear data
for (let tag in this.tagMap) {
this.tagMap[tag].uuids = new Set()
}
for (let uuid in this.objectMap) delete this.objectMap[uuid]
// Resubscribe
const tags = Object.keys(this.tagMap)
if (tags.length) await this.#request({ subscribe: tags })
}
// Utility function to get a universally unique string
// that represents a particular object
#objectUUID(object) {
if (!object._by || !object._key) {
throw {
type: 'error',
content: 'the object you are trying to identify does not have an owner or key',
object
}
}
return object._by + object._key
}
}

View file

@ -1,76 +0,0 @@
import { ref, reactive } from 'vue'
import Graffiti from '../../graffiti.js'
export default {
install(app, options) {
const graffitiURL = options && 'url' in options?
options.url : 'https://graffiti.garden'
// Initialize graffiti with reactive entries
const graffiti = new Graffiti(graffitiURL, ()=>reactive({}))
// Create a reactive variable that
// tracks connection state
const connectionState = ref(false)
;(function waitForState(state) {
graffiti.connectionState(state).then(()=> {
connectionState.value = state
waitForState(!state)
})})(true)
Object.defineProperty(app.config.globalProperties, "$graffitiConnected", {
get: ()=> connectionState.value
})
// Latch on to the graffiti ID
// when the connection state first becomes true
let myID = null
Object.defineProperty(app.config.globalProperties, "$graffitiMyID", {
get: ()=> {
if (connectionState.value) myID = graffiti.myID
return myID
}
})
// Add static functions
for (const key of ['toggleLogIn', 'update', 'myTags', 'objectByKey']) {
const vueKey = '$graffiti' + key.charAt(0).toUpperCase() + key.slice(1)
app.config.globalProperties[vueKey] = graffiti[key].bind(graffiti)
}
// A component for subscribing and
// unsubscribing to tags that returns
// a reactive array of the results
app.component('GraffitiObjects', {
props: ['tags'],
watch: {
tags: {
async handler(newTags, oldTags=[]) {
// Subscribe to the new tags
await graffiti.subscribe(...newTags)
// Unsubscribe to the existing tags
await graffiti.unsubscribe(...oldTags)
},
immediate: true,
deep: true
}
},
// Handle unmounting too
unmount() {
graffiti.unsubscribe(this.tags)
},
computed: {
objects() {
return graffiti.objects(...this.tags)
}
},
template: '<slot :objects="objects"></slot>'
})
}
}

View file

@ -1,61 +0,0 @@
// Extend the array class to expose update
// functionality, plus provide some
// useful helper methods
export default function(graffiti) {
return class GraffitiArray extends Array {
get mine() {
return this.filter(o=> o._by==graffiti.myID)
}
get notMine() {
return this.filter(o=> o._by!=graffiti.myID)
}
get authors() {
return [...new Set(this.map(o=> o._by))]
}
removeMine() {
this.mine.map(o=> delete o._key)
}
#getProperty(obj, propertyPath) {
// Split it up by periods
propertyPath = propertyPath.match(/([^\.]+)/g)
// Traverse down the path tree
for (const property of propertyPath) {
obj = obj[property]
}
return obj
}
sortBy(propertyPath) {
const sortOrder = propertyPath[0] == '-'? -1 : 1
if (sortOrder < 0) propertyPath = propertyPath.substring(1)
return this.sort((a, b)=> {
const propertyA = this.#getProperty(a, propertyPath)
const propertyB = this.#getProperty(b, propertyPath)
return sortOrder * (
propertyA < propertyB? -1 :
propertyA > propertyB? 1 : 0 )
})
}
groupBy(propertyPath) {
return this.reduce((chain, obj)=> {
const property = this.#getProperty(obj, propertyPath)
if (property in chain) {
chain[property].push(obj)
} else {
chain[property] = new GraffitiArray(obj)
}
return chain
}, {})
}
}
}

View file

@ -1,125 +0,0 @@
export default {
async logIn(graffitiURL) {
// Generate a random client secret and state
const clientSecret = crypto.randomUUID()
const state = crypto.randomUUID()
// The client ID is the secret's hex hash
const clientID = await this.sha256(clientSecret)
// Store the client secret as a local variable
window.localStorage.setItem('graffitiClientSecret', clientSecret)
window.localStorage.setItem('graffitiClientID', clientID)
window.localStorage.setItem('graffitiAuthState', state)
// Redirect to the login window
const loginURL = this.authURL(graffitiURL)
loginURL.searchParams.set('client_id', clientID)
loginURL.searchParams.set('redirect_uri', window.location.href)
loginURL.searchParams.set('state', state)
window.location.href = loginURL
},
async connect(graffitiURL) {
// Check to see if we are already logged in
let token = window.localStorage.getItem('graffitiToken')
let myID = window.localStorage.getItem('graffitiID')
if (!token || !myID) {
// Remove them both in case one exists
// and the other does not
token = myID = null
// Check to see if we are redirecting back
const url = new URL(window.location)
if (url.searchParams.has('code')) {
// Extract the code and state from the URL and strip it from the history
const code = url.searchParams.get('code')
const state = url.searchParams.get('state')
url.searchParams.delete('code')
url.searchParams.delete('state')
window.history.replaceState({}, '', url)
// Get stored variables and remove them
const clientSecret = window.localStorage.getItem('graffitiClientSecret')
const clientID = window.localStorage.getItem('graffitiClientID')
const storedState = window.localStorage.getItem('graffitiAuthState')
window.localStorage.removeItem('graffitiClientSecret')
window.localStorage.removeItem('graffitiClientID')
window.localStorage.removeItem('graffitiAuthState')
// Make sure state has been preserved
if (state != storedState) {
throw new Error("The state in local storage does not match the state sent by the server")
}
// Construct the body of the POST
let form = new FormData()
form.append('client_id', clientID)
form.append('client_secret', clientSecret)
form.append('code', code)
// Ask to exchange the code for a token
const tokenURL = this.authURL(graffitiURL)
tokenURL.pathname = '/token'
const response = await fetch(tokenURL, {
method: 'post',
body: form
})
// Make sure the response is OK
if (!response.ok) {
let reason = response.status + ": "
try {
reason += (await response.json()).detail
} catch (e) {
reason += response.statusText
}
throw new Error(`The authorization code could not be exchanged for a token.\n\n${reason}`)
}
// Parse out the token
const data = await response.json()
token = data.access_token
myID = data.owner_id
// And make sure that the token is valid
if (!token || !myID) {
throw new Error(`The authorization token could not be parsed from the response.\n\n${data}`)
}
// Store the token and ID
window.localStorage.setItem('graffitiToken', token)
window.localStorage.setItem('graffitiID', myID)
}
}
return { myID, token }
},
logOut() {
window.localStorage.removeItem('graffitiToken')
window.localStorage.removeItem('graffitiID')
window.location.reload()
},
authURL(graffitiURL) {
const url = new URL(graffitiURL)
url.host = "auth." + url.host
return url
},
async sha256(input) {
const encoder = new TextEncoder()
const inputBytes = encoder.encode(input)
const outputBuffer = await crypto.subtle.digest('SHA-256', inputBytes)
const outputArray = Array.from(new Uint8Array(outputBuffer))
return outputArray.map(b => b.toString(16).padStart(2, '0')).join('')
}
}

View file

@ -1,153 +0,0 @@
export default {
query(property) {
return {
[property]: {
$type: 'array',
$type: ['int', 'long'],
},
$nor: [
{ [property]: { $gt: this.maxInt } },
{ [property]: { $lt: 0 } },
]
}
},
get before() {
return []
},
get after() {
return [this.maxInt+1]
},
between(a, b, scale=100) {
// Strip zeros and find common length
const aLength = this.lengthWithoutZeros(a)
const bLength = this.lengthWithoutZeros(b)
const minLength = Math.min(aLength, bLength)
// Initialize output
const out = []
// Find the break point where a[i] != b[i]
let i = 0
while (i < minLength && a[i] == b[i]) {
out.push(a[i])
i++
}
// Initialize upper and lower bounds for
// sampling the last digit
let lowerBound = 1
let upperBound = this.maxInt
if (i < minLength) {
// If the break happened before we hit
// the end of one of the arrays
if (Math.abs(a[i] - b[i]) > 1) {
// If a[i] and b[i] are more than one
// away from each other, just sample
// between them
lowerBound = Math.min(a[i], b[i]) + 1
upperBound = Math.max(a[i], b[i]) - 1
} else {
// If they are one away no integers
// will fit in between, so add new layer
const lesser = (a[i] < b[i])? a : b
out.push(lesser[i])
i++
while (i < lesser.length && lesser[i] >= this.maxInt) {
// If the lesser is at it's limit,
// we will need to add even more layers
out.push(lesser[i])
i++
}
if (i < lesser.length) {
// Sample something greater than
// the lesser digit
lowerBound = lesser[i] + 1
}
}
} else {
// The break happened because we hit
// the end of one of the arrays.
if (aLength == bLength) {
// If they are entirely equal,
// there is nothing in between
// just return what we have
return out
}
const longerLength = Math.max(aLength, bLength)
const longer = (a.length == longerLength)? a : b
while (i < longerLength && longer[i] == 0) {
// Skip past the zeros because we can't sample
// for digits less than zero
out.push(0)
i++
}
if (i < longerLength) {
if (longer[i] == 1) {
// If longer is at it's limit,
// we still need to add another layer
out.push(0)
} else {
upperBound = longer[i] - 1
}
}
}
// Create a random number in [0,1] but bias it to be small,
// so that numbers tend to increase by a small amount.
let random = Math.random()
random = -Math.log(1-random)/scale
random = Math.min(random, 1)
// Finally, sample between the upper and lower bounds
out.push(Math.floor(random * (upperBound + 1 - lowerBound)) + lowerBound)
return out
},
compare(a, b) {
// Strip zeros and find common length
const aLength = this.lengthWithoutZeros(a)
const bLength = this.lengthWithoutZeros(b)
const minLength = Math.min(aLength, bLength)
// See if there are any differences
for (let i = 0; i < minLength; i++) {
if (a[i] > b[i]) {
return 1
} else if (a[i] < b[i]) {
return -1
}
}
// If they are all the same up til now,
// the longer one is bigger
if (aLength > bLength) {
return 1
} else if (aLength < bLength) {
return -1
} else {
return 0
}
},
lengthWithoutZeros(a) {
let length = a.length
while (length > 0 && a[length - 1] == 0) {
length--
}
return length
},
maxInt: 9007199254740991,
}