diff --git a/ao-lib/api.js b/ao-lib/api.js new file mode 100644 index 0000000..d5c9711 --- /dev/null +++ b/ao-lib/api.js @@ -0,0 +1,1127 @@ +import request from 'superagent' +import { v1 as uuidV1 } from 'uuid' +import { io } from 'socket.io-client' +import { createHash, hmacHex } from './crypto.js' +import { isObject } from './util.js' +import { aoEnv } from './settings.js' + +// The AO API server endpoint this ao-cli client will attempt to connect to +export const AO_DEFAULT_HOSTNAME = 'localhost:8003' +const HOSTNAME = aoEnv('AO_CLI_TARGET_HOSTNAME') || AO_DEFAULT_HOSTNAME +const [HOST, PORT] = HOSTNAME.split(':') + +// The AO API server websocket endpoint this ao-cli client will attempt to connect to +const AO_SOCKET_URL = 'http://' + AO_DEFAULT_HOSTNAME // was process.env.NODE_ENV === 'development' ? 'http://' + AO_DEFAULT_HOSTNAME : '/' +export const socket = io(AO_SOCKET_URL, { + autoConnect: false +}) + +// The current connected state of the websocket, can be 'attemptingAuthentication' | 'authenticationSuccess' | 'authenticationFailed' +export let socketStatus + +// Load the current session cookies from the AO .env file +let currentMemberId = aoEnv('AO_CLI_SESSION_MEMBERID') +let currentSessionId = aoEnv('AO_CLI_SESSION_ID') +let currentSessionToken = aoEnv('AO_CLI_SESSION_TOKEN') + +// Performs a GET request to the specified endpoint, sending the given payload +export async function getRequest(endpoint, payload = null, alternateHost = null, verbose = true) { + const target = alternateHost || HOSTNAME + try { + if(payload) { + return await request + .get(target + endpoint) + .send(payload) + } else { + return await request.get(target + endpoint) + } + } catch (err) { + if(verbose) console.log('request failed', err) + return null + } +} + +// Performs a POST request to the specified endpoint, sending the given payload +export async function postRequest(endpoint, payload = null, verbose = true) { + if (!currentSessionToken) { + if(verbose) console.log('Session token not set, API not ready.') + return new Promise(() => null) + } + try { + if(payload) { + return await request + .post(HOSTNAME + endpoint) + .send(payload) + .set('authorization', currentSessionToken) + .set('session', currentSessionId) + } else { + return await request.post(HOSTNAME + endpoint) + .set('authorization', currentSessionToken) + .set('session', currentSessionId) + } + } catch (err) { + if(verbose) console.log('request failed', err) + return null + } +} + +// Performs a post request to the /event endpoint, sending the given JSON object as the event +export async function postEvent(event, verbose) { + return await postRequest('/events', event, verbose) +} + +// Attempts login with the given username and password combo. If successful, returns the generated session and token (login cookies). +export async function createSession(user, pass) { + const session = uuidV1() + let sessionKey = createHash(session + createHash(pass)) + const token = hmacHex(session, sessionKey) + const result = await request + .post(HOSTNAME + '/session') + .set('authorization', token) + .set('session', session) + .set('name', user) + .on('error', () => false) + currentMemberId = result.body.memberId + currentSessionToken = token + currentSessionId = session + return { session, token, memberId: currentMemberId } +} + +export async function logout() { + return await postRequest('/logout') +} + +// AO p2p over tor features +export async function nameAo(newName) { + return await postEvent({ + type: 'ao-named', + alias: newName + }) +} + +// When you call startSocketListeners, it will either attempt to connect and authenticate on a web socket, or fail and return. +// onAuthenticated is a function that will be called when the client authenticates on the web socket (logs in/connects). +// In your onAuthenticated function, you should trigger fetchState or other initial fetching of state from server. +// eventCallback is a function (ev) => {} that will be called whenever an event is received on the socket. +// In this way, initial state can be fetched and then updates received after that can be used to update the local state model. +export async function startSocketListeners(onAuthenticated, onEvent, verbose = true) { + if(typeof onAuthenticated !== 'function' || typeof onEvent !== 'function') { + console.log('startSocketListeners requires two callback functions as arguments.') + return + } + socket.connect() + socket.on('connect', () => { + if(verbose) console.log('websocket connected') + socketStatus = 'attemptingAuthentication' + if(!currentSessionId || !currentSessionToken) { + if(verbose) console.log('No current session, must log in to authenticate and use socket.') + return + } + + socket.emit('authentication', { + session: currentSessionId, + token: currentSessionToken, + }) + }) + socket.on('authenticated', () => { + if(verbose) console.log('websocket authenticated') + socketStatus = 'authenticationSuccess' + socket.on('eventstream', onEvent) + onAuthenticated() + }) + socket.on('disconnect', reason => { + if(verbose) console.log('websocket disconnected') + socketStatus = 'authenticationFailed' + socket.connect() + }) +} + +// Requests the public bootstrap list by making a public (not logged in) GET request to this or the specified server +export async function getAoBootstrapList(serverOnion = null) { + const result = await getRequest('/bootstrap', undefined, serverOnion, false) + if(!result || !result.ok || result.body.addresses.length < 1) { + return null + } + return result.body.addresses +} + +// Gets the bootsrap list from the specified or our server, then recursively bootstraps from each address on the list +// The AO network is small right now so this shouldn't cause any problems for a while +export async function bootstrap(serverOnion = null) { + if(!serverOnion) serverOnion = HOSTNAME + let alreadyQueried = [ serverOnion ] + let onionList = await getAoBootstrapList(serverOnion) + if(!onionList) { + return null + } + for(let i = 0; i < onionList.length; i++) { + const onion = onionList[i] + let more = await bootstrap(onion) + if(!more) continue + more = more.filter(onion => !onionList.concat(alreadyQueried).includes(onion)) + onionList.concat(more) + } + return onionList +} + +export async function shadowchat(room, message, username) { + return await postEvent({ + type: 'shadowchat', + room: room, + name: username, + message: message, + }) +} + +export async function connectToAo(address, secret) { + return await postEvent({ + type: 'ao-outbound-connected', + address: address, + secret: secret + }) +} + +export async function deleteAoConnection(address) { + return await postEvent({ + type: 'ao-disconnected', + address: address + }) +} + +export async function relayEventToOtherAo(address, event) { + return await postEvent({ + type: 'ao-relay', + address: address, + ev: event + }) +} + +export async function linkCardOnAo(taskId, address) { + return await postEvent({ + type: 'ao-linked', + address: address, + taskId: taskId + }) +} + +// Avatar and presence features +export async function bark() { + return await postEvent({ + type: 'doge-barked', + memberId: currentMemberId + }) +} + +export async function hopped(taskId) { + return await postEvent({ + type: 'doge-hopped', + memberId: currentMemberId, + taskId: taskId + }) +} + +export async function mute() { + return await updateMemberField('muted', true) +} + +export async function unmute() { + return await updateMemberField('muted', false) +} + +// Memes feature +export async function fetchMeme(memeHash, progressCallback) { + return request + .get(HOSTNAME + '/meme/' + memeHash) + .responseType('blob') + .set('Authorization', currentSessionToken) + .on('progress', function (e) { + progressCallback(e.percent) + }) + .then(res => { + console.log('got meme! res is ', res) + return res.body + }) +} + +export async function downloadMeme(memeHash) { + return request + .get(HOSTNAME + '/download/' + memeHash) + .set('Authorization', currentSessionToken) + .then(res => { + // console.log('got meme! res is ', res) + return res + }) +} + +export async function uploadMemes(formData, progressCallback) { + return postRequest('/upload', formData) + .on('progress', function (e) { + console.log('Percentage done: ', e) + if (e && e.hasOwnProperty('percent') && e.percent >= 0) { + progressCallback(e.percent) + } + }) + .on('error', err => { + console.log('Upload failed with error:', err) + return false + }) + .then(res => { + console.log('sent files. res is', res) + return res + }) +} + +export async function cacheMeme(taskId) { + return await postEvent({ + type: 'meme-cached', + taskId + }) +} + +// Cards feature +// Returns the card and other cards as specified by the alsoGetRelevant arg +// If multiple cards are returned, they will be returned in their global deck order (global creation order on server) +export async function getCard(taskId, alsoGetRelevant = 'subcards') { + taskId = taskId.trim().toLowerCase() + let payload = { taskId: taskId } + const result = await postRequest('/fetchTaskByID', payload, false) // todo: change to flat text, not JSON (?) + if(!result || !result.body) { + //console.log('Error fetching task.') + return null + } + if(alsoGetRelevant) { + let relevantCards = await getAllRelevantCards(result.body, alsoGetRelevant) + return [result.body, ...relevantCards] + } + return [result.body] +} + +// Cards feature +export async function getCardByName(taskName, alsoGetRelevant = 'subcards') { + taskName = taskName.trim() + let payload = { taskName: taskName } + const result = await postRequest('/fetchTaskByName_exact', payload, false) // todo: change to flat text, not JSON (?) + if(!result || !result.body || result.statusCode === 204 || result.statusCode === 400) { + //console.log('Error fetching task.') + return null + } + if(alsoGetRelevant) { + let relevantCards = await getAllRelevantCards(result.body, alsoGetRelevant) + return [result.body, ...relevantCards] + } + return [result.body] +} + +// Fetches all cards related to the given card object, i.e., cards that could be seen or navigated to immediately from that card +// scope = 'priority' returns only the first/top priority card within the specified card +// scope = 'priorities' returns only the priorities within the specified card +// scope = 'subcards' returns all subcards (priorities, pinned, subTasks, completed) +// Further scopes are not currently needed because the server also includes some related cards with each send +// If existingTasks: Map is provided, those cards will be skipped +// Returns the new cards that were fetched (not any existingTasks), plus cards the server chooses to also include +export async function getAllRelevantCards( + seedTask, + scope = 'priorities', + existingTasks +) { + if(existingTasks === undefined) { + existingTasks = new Map() + } + let taskIdsToFetch + + // Choose which taskIds we are going to request from the server + switch (scope) { + case 'priority': + taskIdsToFetch = new Set([seedTask.priorities.at(-1)]) + break + case 'priorities': + taskIdsToFetch = new Set(seedTask.priorities) + break + case 'subcards': + taskIdsToFetch = new Set( + seedTask.priorities.concat(seedTask.subTasks, seedTask.completed) + ) + if (seedTask.pins && seedTask.pins.length >= 1) { + seedTask.pins.forEach(pin => { + taskIdsToFetch.add(pin.taskId) + }) + } + } + + // Filter out the taskIds for tasks we already have + taskIdsToFetch = [...taskIdsToFetch].filter(taskId => { + if (!taskId) { + return false + } + const existingTask = existingTasks.get(taskId) + return !existingTask + }) + if(taskIdsToFetch.length < 1) { + return [] + } + + // Fetch the cards + try { + const result = await postRequest('/fetchTasks', { taskIds: taskIdsToFetch }) + // Filter again (overlapping queries or intelligent server can cause duplicates to be returned) + const newTasksOnly = result.body.filter( + fetchedTask => !existingTasks.get(fetchedTask.taskId) + ) + return newTasksOnly + } catch (error) { + console.log('Error fetching relevant tasks:', { taskIdsToFetch, error }) + return null + } +} + +export async function createCard( + name, + anonymous = false, + prioritized = false +) { + return await postEvent({ + type: 'task-created', + name: name, + color: 'blue', + deck: (anonymous || !currentMemberId) ? [] : [currentMemberId], + inId: anonymous ? null : currentMemberId || null, + prioritized: prioritized, + }, false) +} + +// This is different from only setting a card property because when a card's color changes, it is bumped to the top of the .subTasks color pile +export async function colorCard(taskId, color) { + return await postEvent({ + type: 'task-colored', + taskId: taskId, + color: color, + inId: null, // add this when we have context, mutation works on server + blame: currentMemberId + }) +} + +// Set arbitrary metadata on a card +export async function setCardProperty(taskId, property, value) { + return await postEvent({ + type: 'task-property-set', + taskId: taskId, + property: property, + value: value, + blame: currentMemberId + }) +} + +// Card send feature +export async function passCard(taskId, toMemberId) { + return await postEvent({ + type: 'task-passed', + taskId: taskId, + toMemberId: toMemberId, + fromMemberId: currentMemberId + }) +} + +// Send an immediate bark and notification to the member if possible, reminding them that they have an unread message from you (no automated pings) +export async function remindMember(memberId) { + return await postEvent({ + type: 'member-reminded', + toMemberId: memberId, + fromMemberId: currentMemberId + }) +} + +// Cards-in-cards feature +export async function playCard(from = null, to) { + return await postEvent({ + type: 'task-played', + from: from, + to: to, + memberId: currentMemberId + }) +} + +export async function discardCardFromCard(taskId, inId) { + return await postEvent({ + type: 'task-de-sub-tasked', + taskId: inId, + subTask: taskId, + blame: currentMemberId + }) +} + +// Empties a card's priorities and subtasks +export async function emptyCard(taskId) { + return await postEvent({ + type: 'task-emptied', + taskId: taskId, + blame: currentMemberId + }) +} + +export async function swapCard(inId, taskId1, taskId2) { + return await postEvent({ + type: 'task-swapped', + taskId: inId, + swapId1: taskId1, + swapId2: taskId2, + blame: currentMemberId + }) +} + +export async function bumpCard(taskId, inId, direction) { + return await postEvent({ + type: 'task-bumped', + taskId: inId, + bumpId: taskId, + direction: direction, + blame: currentMemberId + }) +} + +// Deck features +export async function grabCard(taskId) { + return await postEvent({ + type: 'task-grabbed', + taskId: taskId, + memberId: currentMemberId + }) +} + +export async function grabPile(taskId) { + return await postEvent({ + type: 'pile-grabbed', + taskId: taskId, + memberId: currentMemberId + }) +} + +export async function dropCard(taskId) { + return await postEvent({ + type: 'task-dropped', + taskId: taskId, + memberId: currentMemberId + }) +} + +export async function removeCards(taskIds) { + return await postEvent({ + type: 'tasks-removed', + taskIds: taskIds, + memberId: currentMemberId + }) +} + +export async function dropPile(taskId) { + return await postEvent({ + type: 'pile-dropped', + taskId: taskId, + memberId: currentMemberId + }) +} + +// Priority feature +export async function prioritizeCard(taskId, inId, position = 0, echelon = null) { + return await postEvent({ + type: 'task-prioritized', + taskId: taskId, + inId: inId, + position: position, + ...(echelon && { echelon: echelon }), + blame: currentMemberId + }) +} + +export async function prioritizePile(inId) { + return await postEvent({ + type: 'task-prioritized', + inId: inId + }) +} + +export async function refocusCard(taskId, inId) { + return await postEvent({ + type: 'task-refocused', + taskId: taskId, + inId: inId, + blame: currentMemberId + }) +} + +export async function refocusPile(inId) { + return await postEvent({ + type: 'pile-refocused', + inId: inId + }) +} + +export async function allocatePriority(inId, taskId, points = 1) { + return await postEvent({ + type: 'task-allocated', + taskId: inId, + allocatedId: taskId, + amount: points, + blame: currentMemberId + //inId: inId, + }) +} + +// Guilds feature +export async function tagCard(taskId, newTitle) { + return await postEvent({ + type: 'task-guilded', + taskId: taskId, + guild: newTitle, + blame: currentMemberId + }) +} + +export async function getCardsForTag(tag) { + return (await postRequest('/fetchTasksByGuild', { guild: tag })).body.cards +} + +// Checkmarks feature +export async function completeCard(taskId) { + return await postEvent({ + type: 'task-claimed', + taskId: taskId, + memberId: currentMemberId + }) +} + +export async function uncheckCard(taskId) { + return await postEvent({ + type: 'task-unclaimed', + taskId: taskId, + memberId: currentMemberId + }) +} + +export async function setClaimInterval(taskId, newClaimInterval) { + return await setCardProperty(taskId, claimInterval, newClaimInterval) +} + +// Hardware resources feature +export async function createResource( + resourceId, + name, + charged, + secret, + trackStock +) { + return await postEvent({ + type: 'resource-created', + resourceId: resourceId, + name: name, + charged: charged, + secret: secret, + trackStock: trackStock, + blame: currentMemberId + }) +} + +export async function useResource(resourceId, amount, charged, notes = '') { + return await postEvent({ + type: 'resource-used', + resourceId: resourceId, + memberId: currentMemberId, + amount: amount, + charged: charged, + notes: notes + }) +} + +export async function stockResource(resourceId, amount, paid, notes = '') { + return await postEvent({ + type: 'resource-stocked', + resourceId: resourceId, + memberId: currentMemberId, + amount: amount, + paid: paid, + notes: notes + }) +} + +export async function purgeResource(resourceId) { + return await postEvent({ + type: 'resource-purged', + resourceId: resourceId, + blame: currentMemberId + }) +} + +export async function bookResource(taskId, startTime, endTime) { + return await postEvent({ + type: 'resource-booked', + resourceId: taskId, + memberId: currentMemberId, + startTs: startTime, + endTs: endTime + }) +} + +// Member account features +export async function updateMemberField(field, newValue) { + if (field === 'secret') { + newValue = createHash(newValue) + } + return await postEvent({ + type: 'member-field-updated', + memberId: currentMemberId, + field: field, + newfield: newValue + }) +} + +// Member admin features +export async function createMember(name, fob = '') { + const secret = createHash(name) + return await postEvent({ + type: 'member-created', + name, + secret, + fob + }) +} + +export async function activateMember(memberId) { + return await postEvent({ + type: 'member-activated', + memberId: memberId + }) +} + +export async function deactivateMember(memberId) { + return await postEvent({ + type: 'member-deactivated', + memberId: memberId + }) +} + +// senpai function +export async function resetPassword(memberId) { + return await postEvent({ + type: 'member-secret-reset', + kohaiId: memberId, + senpaiId: currentMemberId + }) +} + +// senpai function +export async function promoteMember(memberId) { + return await postEvent({ + type: 'member-promoted', + kohaiId: memberId, + senpaiId: currentMemberId + }) +} + +// senpai function +export async function banMember(memberId) { + return await postEvent({ + type: 'member-banned', + kohaiId: memberId, + senpaiId: currentMemberId + }) +} + +// senpai function +export async function unbanMember(memberId) { + return await postEvent({ + type: 'member-unbanned', + kohaiId: memberId, + senpaiId: currentMemberId + }) +} + +// senpai function +export async function purgeMember(memberId) { + return await postEvent({ + type: 'member-purged', + memberId: memberId, + blame: currentMemberId + }) +} + +// Each member has a list of tickers. Each ticker is a string. +// Sets the ticker at position tickerListIndex to symbol coinSymbol. +export async function setTicker(fromCoin, toCoin, tickerListIndex) { + return await postEvent({ + type: 'member-ticker-set', + memberId: currentMemberId, + fromCoin: fromCoin, + toCoin: toCoin, + index: tickerListIndex + }) +} + +// Timeclock features +export async function clockTime(seconds, taskId, date) { + return await postEvent({ + type: 'task-time-clocked', + taskId: taskId, + memberId: currentMemberId, + seconds: seconds, + date: date + }) +} + +export async function startTimeClock(taskId, inId) { + return await postEvent({ + type: 'task-started', + taskId: taskId, + inId: inId, + memberId: currentMemberId + }) +} + +export async function stopTimeClock(taskId) { + return await postEvent({ + type: 'task-stopped', + taskId: taskId, + memberId: currentMemberId + }) +} + +// Group membership features +export async function assignMembership(taskId, memberId, level) { + return await postEvent({ + type: 'task-membership', + taskId: taskId, + memberId: memberId, + level: level, + blame: currentMemberId + }) +} + +export async function stashCard(taskId, inId, level) { + return await postEvent({ + type: 'task-stashed', + taskId: taskId, + inId: inId, + level: level, + blame: currentMemberId + }) +} + +export async function unstashCard(taskId, inId, level) { + return await postEvent({ + type: 'task-unstashed', + taskId: taskId, + inId: inId, + level: level, + blame: currentMemberId + }) +} + +// Unreads feature +export async function visitCard(taskId, inChat = false, notify = false) { + return await postEvent({ + type: 'task-visited', + taskId: taskId, + memberId: currentMemberId, + area: inChat ? 1 : 0, + notify: notify + }) +} + +/* +export async function markSeen(taskId) { + const task = aoStore.hashMap.get(taskId) + const act = { + type: 'task-seen', + taskId: taskId, + memberId: currentMemberId, + } + // console.log('card marked seen') + return await postEvent(act) +} +*/ + +// Pinboard feature +export async function resizeGrid(taskId, newHeight, newWidth, newSize) { + return await postEvent({ + type: 'grid-resized', + taskId: taskId, + height: newHeight, + width: newWidth, + size: newSize || 9 + }) +} + +export async function createCardWithGrid(name, height, width) { + return await postEvent({ + type: 'grid-created', + name: name, + height: height, + width: width, + color: 'blue', + deck: [currentMemberId] + }) +} + +export async function addGridToCard(taskId, height, width, spread = 'pyramid') { + return await postEvent({ + type: 'grid-added', + taskId: taskId, + spread: spread, + height: height, + width: width + }) +} + +export async function removeGridFromCard(taskId) { + return await postEvent({ + type: 'grid-removed', + taskId: taskId + }) +} + +// This function encodes whatever is passed by the search box as a URIComponent and passes it to a search endpoint, returning the response when supplied +export async function search(querystring, take = 10, skip = 0) { + const qs = encodeURIComponent(querystring) + const params = `?take=${take}&skip=${skip}` + return await postRequest('/search/' + qs + params) +} + +export async function requestBtcQr(taskId) { + return await postEvent({ + type: 'address-updated', + taskId + }) +} + +export async function requestLightningInvoice(taskId, amount = 0) { + return await postEvent({ + type: 'invoice-created', + taskId, + amount: amount + }) +} + +// Proposals features +export async function signCard(taskId, opinion = 1) { + return await postEvent({ + type: 'task-signed', + taskId: taskId, + memberId: currentMemberId, + opinion: opinion + }) +} + +export async function setQuorum(quorum) { + return await postEvent({ + type: 'quorum-set', + quorum: quorum + }) +} + +/*reaction( + () => { + //return aoStore.state.socketState + }, + socketState => console.log('AO: client/api.ts: socketState: ' + socketState) +) +console.log("NODE_ENV is", process.env.NODE_ENV)*/ + +//const api = new AoApi(socket) +//export default api + +/*async fetchState() { + const session = window.localStorage.getItem('session') + const token = window.localStorage.getItem('token') + const user = window.localStorage.getItem('user') + if (session && token && user) { + return request + .post('/state') + .set('Authorization', token) + .then(res => { + aoStore.state.user = user + // console.log( + // 'AO: client/api.ts: fetchState: initial state: ', + // res.body + // ) + + let dataPackageToSendToClient = res.body + // Get the memberId + let memberId + dataPackageToSendToClient.stateToSend.sessions.forEach(sessionItem => { + if (session === sessionItem.session) { + memberId = sessionItem.ownerId + } + }) + + // See if we got our member card + let foundMemberCard + if(!memberId) { + console.log("memberId missing when loading state") + } else { + dataPackageToSendToClient.stateToSend.tasks.forEach(task => { + if(task.taskId === memberId) { + foundMemberCard = task + } + }) + } + if(foundMemberCard) { + console.log("State includes member card:", foundMemberCard) + } else { + console.log("State does not include member card") + } + + aoStore.initializeState(dataPackageToSendToClient.stateToSend) + + let metaData = dataPackageToSendToClient.metaData + aoStore.memberDeckSize = metaData.memberDeckSize + aoStore.bookmarksTaskId = metaData.bookmarksTaskId + + return true + }) + .catch(() => false) + } + return Promise.resolve(false) +}*/ + +/* +export async createCardIfDoesNotExist( + name, + color, + anonymous +) { +return new Promise((resolve, reject) => { + aoStore.getTaskByName_async(name, (task) => { + if (isObject(task)) { + console.log("task exists!") + resolve(task) + } else { + const act = { + type: 'task-created', + name: name, + color: color || 'blue', + deck: [currentMemberId], + inId: null, + prioritized: false, + } + postEvent(act).then((res) => { + aoStore.getTaskByName_async(name, (task) => { + resolve(task) + }) + }) + } + }) + }) +} +*/ + +/* +export async createAndPlayCard(name, color, anonymous, to) { + return new Promise((resolve, reject) => { + this.createCardIfDoesNotExist(name, color, anonymous).then(success => { + aoStore.getTaskByName_async(name, found => { + to.taskId = found.taskId + resolve(this.playCard(null, to)) + }) + }) + }) +} +*/ +/*async findOrCreateCardInCard( + name, + inId, + prioritizedpostEvent(act) = false, + color = 'blue', + anonymous +) { + return new Promise((resolve, reject) => { + aoStore.getTaskByName_async(name, found => { + console.log('gotTaskByName name was ', name, 'and found is ', found) + let act + if (found) { + if (prioritized) { + resolve(this.prioritizeCard(found.taskId, inId)) + return + } else { + act = { + type: 'task-sub-tasked', + taskId: inId, + subTask: found.taskId, + memberId: anonymous ? null : currentMemberId, + } + } + } else { + act = { + type: 'task-created', + name: name, + color: color, + deck: anonymous ? [] : [currentMemberId], + inId: inId, + prioritized: prioritized, + } + } + resolve( + postEvent(act).then(res => return res) + ) + }) + }) +}*/ + +/*async function pinCardToGrid( + x, + y, + name, + inId +) { + return new Promise((resolve, reject) => { + aoStore.getTaskByName_async(name, (task) => { + console.log('gotTaskByName name was ', name, 'and found is ', task) + // console.log("AO: client/api.ts: pinCardToGrid: ", {x, y, name, inId, task}) + + if (isObject(task)) { + const fromLocation + const toLocation: CardLocation = { + taskId.taskId, + inId: inId, + coords: { y, x } + } + playCard() + } else { + const act = { + type: 'task-created', + name: name, + color: 'blue', + deck: [currentMemberId], + inId: inId, + prioritized: false, + } + postEvent(act).then(res => { + const taskId = JSON.parse(res.text).event.taskId + const gridAct = { + type: 'grid-pin', + inId: inId, + taskId: taskId, + x: x, + y: y, + memberId: currentMemberId, + } + resolve( + request + .post('/events') + .set('Authorization', currentSessionToken) + .send(gridAct) + ) + }) + } + }) + }) +}*/ + +/*async function unpinCardFromGrid( + x, + y, + inId +) { + return await postEvent({ + type: 'grid-unpin', + x, + y, + inId, + }) +}*/ diff --git a/ao-lib/calculations.ts b/ao-lib/calculations.ts new file mode 100644 index 0000000..2bcafba --- /dev/null +++ b/ao-lib/calculations.ts @@ -0,0 +1,122 @@ +const satsPerBtc = 100000000 // one hundred million per btc +import { createHash } from './crypto.js' +import { Task } from './types.js' + +export function crawlerHash(tasks: Task[], taskId: string) { + const crawlerResults = crawler(tasks, taskId) + let buffers: number[] = [] + crawlerResults.forEach(tId => { + buffers.push(...Buffer.from(tId)) + }) + const bufferResult = Buffer.from(buffers) + return createHash(bufferResult) +} + +export function crawler(tasks: Task[], taskId: string): string[] { + let history: string[] = [] + tasks.forEach(task => { + if (task.taskId === taskId) { + let crawler: string[] = [taskId] + do { + let newCards: string[] = [] + crawler.forEach(t => { + if (history.indexOf(t) >= 0) return + history.push(t) + let subTask = tasks.filter(pst => pst.taskId === t)[0] + if (subTask) { + let gridCells = + subTask.pins && subTask.pins.length >= 1 + ? subTask.pins.map(pin => pin.taskId) + : [] + newCards = newCards + .concat(subTask.subTasks) + .concat(subTask.priorities) + .concat(subTask.completed) + .concat(gridCells) + } + }) + crawler = newCards + } while (crawler.length > 0) + } + }) + return history +} + +export function shortName(name) { + let limit = 280 + let shortened = name.substring(0, limit) + if (name.length > limit) { + shortened += '…' + } + return shortened +} + +export function cardColorCSS(color) { + return { + redwx: color == 'red', + bluewx: color == 'blue', + greenwx: color == 'green', + yellowwx: color == 'yellow', + purplewx: color == 'purple', + blackwx: color == 'black', + } +} + +export function isString(x) { + return Object.prototype.toString.call(x) === '[object String]' +} + +export function cadToSats(cadAmt, spot) { + let sats = (parseFloat(cadAmt) / parseFloat(spot)) * satsPerBtc + return Math.floor(sats) +} + +export function satsToCad(sats, spot) { + let cad = sats * (spot / satsPerBtc) + return cad.toFixed(2) +} + +export function calculateMsThisMonth() { + let today = new Date() + let daysThisMonth = new Date( + today.getFullYear(), + today.getMonth(), + 0 + ).getDate() + return daysThisMonth * 24 * 60 * 60 * 1000 +} + +export function getMeridienTime(ts) { + let d = new Date(parseInt(ts)) + let hour24 = d.getHours() + + let rollover = 0 + if (hour24 >= 24) { + rollover = 1 + hour24 %= 24 + } + + let hour, meridien + if (hour24 > 12) { + meridien = 'pm' + hour = hour24 - 12 + } else { + meridien = 'am' + hour = hour24 + } + + let date = d.getDate() + rollover + let month = d.getMonth() + 1 + let minute = d.getMinutes() + let year = d.getFullYear() + + let weekday = d.toString().slice(0, 3) + + return { weekday, year, month, date, hour, minute, meridien } +} + +export function toTitleCase(str) { + return str.replace(/\w\S*/g, function (txt) { + return txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase() + }) +} diff --git a/ao-lib/cards.ts b/ao-lib/cards.ts new file mode 100644 index 0000000..a96ab74 --- /dev/null +++ b/ao-lib/cards.ts @@ -0,0 +1,798 @@ +import { isString } from './calculations.js' +import { v1 } from 'uuid' +import { + Task, + Signature, + UserSeen, + CardZone, + Coords, + CardLocation, + Pinboard, + PinboardStyle, + CardPass, +} from './types.js' + +// With this set to 1, actions will occur immediately, as if they were not potential-based actions +export const POTENTIALS_TO_EXECUTE = 1 + +// Default style when a new pinboard is created without specifiying the style +const defaultPinboardStyle: PinboardStyle = 'pyramid' + +// Grid squares have a width measure in ems. The default number for this is 9 and the +/- buttons increase or decrease this by 1. +const defaultSquareSizeInEms = 9 + +// The one and only function to create a new blank card, please use it everywhere cards are created for standardization and searchability. +export function blankCard( + taskId, + name, + color, + created, + deck = [], + parents = [], + height = undefined, + width = undefined +): Task { + let newCard = { + taskId, + color, + deck, + name: typeof name !== 'string' ? 'invalid filename' : name.trim(), + address: '', // Could be left undefined by default? + bolt11: '', // Could be left undefined by default? + book: undefined, // Could be left undefined by default? + boost: 0, + priorities: [], + subTasks: [], + completed: [], + pinboard: + height && width && height >= 1 && width >= 1 + ? blankPinboard(height, width) + : null, + pins: [], + parents: parents, + claimed: [], + passed: [], + signed: [], // Could be left undefined by default? + guild: false, + created: created, + lastClaimed: 0, + payment_hash: '', // Could be left undefined by default? + highlights: [], + seen: deck.length >= 1 ? [{ memberId: deck[0], timestamp: created }] : [], + time: [], + allocations: [], + } + return newCard +} + +// Returns a blank pinboard of the default style +export function blankPinboard( + height = 3, + width = 3, + spread = defaultPinboardStyle +): Pinboard { + const newGrid = { + spread: spread, + height: height, + width: width, + size: defaultSquareSizeInEms, + } + return newGrid +} + +// Version of this function for the server, merge with above +export function allReachableHeldParentsServer(tasks, origin, memberId) { + if (!origin?.hasOwnProperty('taskId')) { + return [] + } + let queue = [origin] + let reachableCards = [] + + let visited = {} + visited[origin.taskId] = true + let i = 0 + while (queue.length >= 1) { + let task = queue.pop() + if ( + task === undefined || + task.subTasks === undefined || + task.priorities === undefined || + task.completed === undefined + ) { + console.log('Invalid task found during returned cards search, skipping.') + continue + } + + if (task.deck.indexOf(memberId) < 0 && task.taskId !== memberId) { + continue + } + + reachableCards.push(task) + if (task.hasOwnProperty('parents') && task.parents.length >= 1) { + let parents = tasks.filter(taskItem => + task.parents.includes(taskItem.taskId) + ) + parents.forEach(st => { + if (!st.hasOwnProperty('taskId')) { + console.log('Missing parent found during returned cards search.') + return + } + if (!visited.hasOwnProperty(st.taskId)) { + visited[st.taskId] = true + queue.push(st) + } + }) + } + } + + return reachableCards +} + +let dupesGlossary = {} +// Adds a synonym taskId for another taskId to the duplicates glossary +export function registerDuplicateTaskId(originalTaskId, duplicateTaskId) { + if (!dupesGlossary[duplicateTaskId]) { + dupesGlossary[duplicateTaskId] = originalTaskId + console.log(Object.keys(dupesGlossary).length, 'cards with duplicates') + } +} + +// Returns the task with the given taskId from the given list of tasks, or null +// Uses the duplicates glossary to return synonymous tasks that were created by junk task-created events +export function getTask(tasks, taskId) { + // Look up duplicate tasks in the duplicates glossary to politely overlook duplicate task-created mutations + let loops = 0 + while (dupesGlossary[taskId] && loops < 4) { + taskId = dupesGlossary[taskId] + //console.log("Looked up duplicate task:", taskId, " (", Object.keys(dupesGlossary).length, " duplicated)") + loops++ + } + if (loops >= 4) { + console.log( + 'Woah, four or more redirects in the duplicates glossary, something weird' + ) + } + return tasks.find(task => { + if (task.taskId === taskId) { + return task + } + }) +} + +// Returns the first task that exactly matches the given value of the given property +// Todo: move property to be the first argument +export function getTaskBy(tasks, value, property) { + return tasks.find(task => { + if (task[property] === value) { + return task + } + }) +} + +// Returns true if the given taskId matches an existing task/card in the state/database +// The state should match the database exactly, +// because it is sourced from the database via the deterministic event mutations in mutations.js +export function taskExists(tasks, taskId) { + return tasks.some(task => task.taskId === taskId) +} + +// Marks the task as seen by the given memberId +export function seeTask(task, memberId) { + if (!task.seen) { + task.seen = [] + } + task.seen = task.seen.filter(seenObject => seenObject.memberId !== memberId) + task.seen.push({ memberId: memberId, timestamp: Date.now() }) +} + +// Clears any pending passes to the specified memberId from the task +// This is done whenever a member accepts a pass or grabs or handles a card +export function clearPassesTo(tasks, task, memberId, alsoClearFrom = false) { + const lengthBefore = task.passed.length + task.passed = task.passed.filter( + d => d[1] !== memberId || (alsoClearFrom ? d[0] !== memberId : false) + ) + if (lengthBefore != task.passed.length) { + const memberCard = getTask(tasks, memberId) + changeGiftCount(memberCard, task.passed.length - lengthBefore) + } +} + +// Takes a member card and increases or decreases its .giftCount property, adding it if necessary +export function changeGiftCount(memberCard, amount) { + if (!memberCard) { + return + } + + if (!memberCard.hasOwnProperty('giftCount')) { + memberCard.giftCount = 0 + } + + memberCard.giftCount += amount + + if (memberCard.giftCount < 0) { + memberCard.giftCount = 0 + } +} + +// Grabs a task, adding it to the member's deck +// The associated pending pass on the card, if any, will be removed +// You cannot grab your own member card +export function grabTask(tasks, task, memberId) { + clearPassesTo(tasks, task, memberId) + if (memberId && task.deck.indexOf(memberId) === -1) { + if (task.taskId !== memberId) { + task.deck.push(memberId) + } + } +} + +// Drops the task, removing it from the member's deck +export function dropTask(task, memberId) { + task.deck = task.deck.filter(d => d !== memberId) +} + +// Adds the given taskId to list of the card's parents (this is a cache) +export function addParent(task, parentId) { + if (!task.hasOwnProperty('parents') || !Array.isArray(task.parents)) { + task.parents = [] + } + if (!task.parents.some(pId => pId === parentId)) { + task.parents.push(parentId) + } +} + +// Removes the given taskId from the list of the card's parents +// This function seems to make no sense +export function removeParent(task, parentId) { + if (!task.hasOwnProperty('parents') || !Array.isArray(task.parents)) { + return + } + let gridCells = [] + if (task.pins && task.pins.length >= 1) { + gridCells = task.pins.map(pin => pin.taskId) + } + + let stashItems: string[] = [] + if (task.stash && Object.keys(task.stash).length >= 1) { + stashItems = [...Object.values(task.stash)] + } + + const allSubTasks = [ + ...task.priorities, + ...task.subTasks, + ...gridCells, + ...task.completed, + ...stashItems, + ] + + if (!allSubTasks.some(stId => stId === parentId)) { + task.parents = task.parents.filter(tId => tId !== parentId) + } +} + +// Removes the second card from the first card's list of parents, +// unless the card is actuall still a parent +export function removeParentIfNotParent(task, parent) { + if ( + !task.hasOwnProperty('parents') || + !Array.isArray(task.parents) || + task.parents.length < 1 + ) { + return + } + let gridCells = [] + if (parent.pins && parent.pins.length >= 1) { + gridCells = parent.pins.map(pin => pin.taskId) + } + + let stashItems: string[] = [] + if (parent.stash && Object.keys(parent.stash).length >= 1) { + stashItems = [...Object.values(parent.stash)] + } + + const allSubTasks = [ + ...parent.priorities, + ...parent.subTasks, + ...gridCells, + ...parent.completed, + ...stashItems, + ] + + if (!allSubTasks.some(stId => stId === task.taskId)) { + task.parents = task.parents.filter(tId => tId !== parent.taskId) + } +} + +// Removes the given taskId from the priorities, subTasks, and completed of the given task +// Does NOT remove the taskId from the grid +export function filterFromSubpiles(task, taskId) { + const start = [ + task?.priorities?.length || null, + task?.subTask?.length || null, + task?.completed?.length || null, + ] + discardPriority(task, taskId) + discardSubTask(task, taskId) + discardCompletedTask(task, taskId) + if ( + (start[0] !== null && start[0] - task?.priorities?.length > 0) || + (start[1] !== null && start[1] - task?.subTasks?.length > 0) || + (start[2] !== null && start[2] - task?.completed?.length > 0) + ) { + return true + } + return false +} + +// Marks as unseen (clears seen from) the given task, unless it's on the list +// Unseens bubble-up one level, but if you've seen the child, it doesn't affect you +export function clearSeenExcept(task, exceptionMemberIds: UserSeen[] = []) { + if (task?.seen?.length >= 1) { + task.seen = task.seen.filter(userseen => + exceptionMemberIds.includes(userseen.memberId) + ) + } +} + +// Re-adds the given taskId to the given card's subTasks (moving it to the end) +// This will move it to the top/front of the list of cards in the GUI +// Precondition: The subtask referred to by subTaskId exists (otherwise it will create a broken card link / missing reference) +export function addSubTask(task, subTaskId) { + if (!task) { + console.log( + 'Attempting to add a subtask to a missing task, this should never happen' + ) + return + } + discardSubTask(task, subTaskId) + task.subTasks.push(subTaskId) +} + +// Removes the given discardTaskId from the given task's subtasks +function discardSubTask(task, discardTaskId) { + if (!task || !discardTaskId || !task.subTasks || task.subTasks.length <= 0) + return + task.subTasks = task.subTasks.filter(stId => stId !== discardTaskId) +} + +// Removes the given discardTaskId from the given task's completed tasks list +function discardCompletedTask(task, discardTaskId) { + if (!task || !discardTaskId || !task.completed || task.completed.length <= 0) + return + task.completed = task.completed.filter(stId => stId !== discardTaskId) +} + +// Adds a completed task to the completed list in a card or moves it to the top of the list +function addCompletedTask(task, completedTaskId) { + discardCompletedTask(task, completedTaskId) + task.completed.push(completedTaskId) +} + +// Adds the subTask to the given new parent task and adds the parent as a parent to the new subTask +export function putTaskInTask(subTask, inTask) { + addSubTask(inTask, subTask.taskId) + addParent(subTask, inTask.taskId) +} + +// Re-adds the given taskId to the given card's priorities (moving it to the end) +// This will move it to the top/front of the list of cards in the GUI +export function addPriority(task, taskId) { + discardPriority(task, taskId) + task.priorities.push(taskId) +} + +// Removes the given discardTaskId from the given task's subtasks +function discardPriority(task, discardTaskId) { + if ( + !task || + !discardTaskId || + !task.priorities || + task.priorities.length <= 0 + ) + return + task.priorities = task.priorities.filter(stId => stId !== discardTaskId) +} + +export function unpinTasksOutOfBounds(tasks, task) { + if (!tasks || !task || !task.pins || task.pins.length <= 0) { + return + } + const vertLimit = task.pinboard.spread === 'rune' ? 1 : task.pinboard.height + for (let i = task.pins.length - 1; i >= 0; i--) { + const pin = task.pins[i] + const { taskId, y, x } = pin + const horizLimit = + task.pinboard.spread === 'pyramid' ? y + 1 : task.pinboard.width + if (x >= horizLimit || y >= vertLimit) { + const theSubTask = getTask(tasks, taskId) + unpinTaskFromTask(task, { y: y, x: x }) + if (theSubTask) { + putTaskInTask(theSubTask, task) + } else { + console.log('A missing card was removed from the pinboard:', taskId) + } + } + } +} + +// Unpins the card from the given coordinates in a card and returns its taskId +function unpinTaskFromTask(task, coords) { + let result + if (!task.pins || task.pins.length <= 0) { + return null + } + task.pins.some((pin, i) => { + const { pinId, y, x } = pin + if (y == coords.y && x == coords.x) { + result = task.pins.splice(i, 1)[0] + } + }) + return result +} + +// Precondition: The spec should validate whether this is a legal move based upon the current gridStyle of the card +// In other words, this function does not check if the coordinates are off the side of the pinboard +// Unlike the functions to add subtasks, this function does NOT attempt to filter the pinboard before adding a card, +// so duplicates will occur unless you unpin first from the origin coords +// However, it WILL check where the card is going to be placed, and if a card is already there, that card will drop into .subTasks +function pinTaskToTask(task, taskId, coords) { + if (!task.hasOwnProperty('pins') || !Array.isArray(task.pins)) { + task.pins = [] + } + + // If this taskId is already at this location, do nothing + if ( + task.pins.some( + pin => pin.taskId === taskId && pin.y === coords.y && pin.x === coords.x + ) + ) { + return + } + + // If there is already something pinned there, drop it into subTasks + const previousPinnedTaskId = unpinTaskFromTask(task, coords)?.taskId + + if (previousPinnedTaskId) { + addSubTask(task, previousPinnedTaskId) + } + + task.pins.push({ taskId, y: coords.y, x: coords.x }) +} + +function putTaskInTaskZone(task, inTask, toLocation) { + switch (toLocation.zone) { + case 'priorities': + // Move the card to the .priorities + //filterFromSubpiles(inTask, task.taskId) + addPriority(inTask, task.taskId) + addParent(task, inTask.taskId) + break + case 'grid': + // Move the card to the .pins using coordinates in the current gridStyle, or fail if not possible + // If there isn't a grid on this card, add a grid large enough for the new coordinates to fit on + if (!inTask.pinboard) { + inTask.pinboard = blankPinboard( + Math.max(toLocation.coords.y, 3), + Math.max(toLocation.coords.x, 3) + ) + } + pinTaskToTask(inTask, task.taskId, toLocation.coords) + addParent(task, inTask.taskId) + break + case 'completed': + // Move the card to the .completed + addCompletedTask(inTask, task.taskId) + break + case 'discard': + // Remove the card from its inId, or save in .completed if it's + // Could replace task-de-sub-tasked + filterFromSubpiles(inTask, task.taskId) + break + case 'context': + case 'panel': + // These don't do anything on the server, it's a zone only on the client + break + case 'gifts': + // Deprecated? + break + case 'stash': + // the .level on the toLocation.level tells what stash level to put the card in + // Rethink this + break + case 'card': + case 'subTasks': + default: + // Move the card to the .subTasks (replaces task-sub-tasked) + putTaskInTask(task, inTask) + break + } +} + +// Removes the specified discardTaskId from the specified zone of the given task +// If zone argument is 'card' or empty, tries to discard from priorities, subTasks, and completed (but not grid) +export function discardTaskFromZone(task, fromLocation) { + switch (fromLocation.zone) { + case 'grid': + unpinTaskFromTask(task, fromLocation.coords) + return + case 'priorities': + discardPriority(task, fromLocation.taskId) + return + case 'completed': + discardCompletedTask(task, fromLocation.taskId) + return + case 'subTasks': + discardSubTask(task, fromLocation.taskId) + return + } +} + +// Moves a card from one location to another location. +// fromLocation defines the card to be unplayed from somewhere, and toLocation defines a card to be placed somewhere. +// fromLocation and toLocation are CardLocation objects defining a taskId in a location. +// The fromLocation is an optional CardLocation that, if provided, requires a taskId and zone at minimum +// If null, no card will be unplayed. +// fromLocation.taskId and toLocation.taskId can be different, +// so it is possible to play a different card than was unplayed in one move (i.e., swap out a card) +// Right now the card being played must exist; card creation and modification is separate since it includes color etc. +// Maybe toLocation should be option also, simplifying discards and further decomposing a play. +export function atomicCardPlay( + tasks, + fromLocation: CardLocation, + toLocation: CardLocation, + memberId +) { + const taskId = + fromLocation && fromLocation.taskId + ? fromLocation.taskId + : toLocation.taskId + const theCard = getTask(tasks, taskId) + if (!theCard && fromLocation?.zone !== 'grid') { + return + } + + const theCardMovedTo = getTask(tasks, toLocation.inId) + if ( + !theCardMovedTo && + !['discard', 'context', 'panel'].includes(toLocation.zone) + ) { + console.log( + 'Attempting to move a card to a missing card, this should never happen. Missing card:', + toLocation.inId, + 'and zone:', + toLocation.zone + ) + return + } + + if (theCard && memberId) { + // memberId should be required, but temporarily for debugging + // You cannot play a card without having seen it + seeTask(theCard, memberId) + + // You cannot play a card without first grabbing it + grabTask(tasks, theCard, memberId) + } + + // Remove the card from wherever it was moved from + const fromInId = fromLocation?.inId + const theCardMovedFrom = getTask(tasks, fromInId) + if (theCardMovedFrom) { + discardTaskFromZone(theCardMovedFrom, fromLocation) + if (fromLocation.inId !== toLocation.inId) { + removeParentIfNotParent(theCard, theCardMovedFrom) + } + + // Save the card to the completed cards if it has at least one checkmark + if ( + fromLocation.zone != 'completed' && + toLocation.zone === 'discard' && + theCard && + theCard.claimed && + theCard.claimed.length >= 1 + ) { + addCompletedTask(theCardMovedFrom, taskId) + } + + // If removing from priorities, remove allocations that were on the card from theCardMovedFrom + if ( + fromLocation?.zone === 'priorities' && + toLocation.zone !== 'priorities' && + theCardMovedFrom.allocations && + Array.isArray(theCardMovedFrom.allocations) + ) { + theCardMovedFrom.allocations = theCardMovedFrom.allocations.filter(al => { + if (al.allocatedId === taskId) { + theCardMovedFrom.boost += al.amount + return false + } + return true + }) + } + } + + // Move card to wherever it was moved to + if (theCard) { + putTaskInTaskZone(theCard, theCardMovedTo, toLocation) + } +} + +// Adds the given taskId to the given card's stash of the specified level +// Each membership level added to a card has a corresponding stash level +export function stashTask(task, taskId, level) { + if ( + !task.hasOwnProperty('stash') || + !( + typeof task.stash === 'object' && + task.stash !== null && + !Array.isArray(task.stash) + ) + ) { + task.stash = {} + } + if (!task.stash.hasOwnProperty('level')) { + task.stash[level] = [] + } + task.stash[level] = task.stash[level].filter(tId => tId !== taskId) + task.stash[level].push(taskId) +} + +// Removes the given taskId from the given card's stash of the specified level +export function unstashTask(task, taskId, level) { + if ( + !task.hasOwnProperty('stash') || + !( + typeof task.stash === 'object' && + task.stash !== null && + !Array.isArray(task.stash) + ) + ) { + return + } + if (!task.stash.hasOwnProperty('level')) { + return + } + task.stash[level] = task.stash[level].filter(tId => tId !== taskId) +} + +// A potentials list is a list of signatures, each signature endorsing a specific task event-type +// When POTENTIALS_TO_EXECUTE potentials accrue for a given event-type it is executed, like an action potential +// This allows built-in AO mutations to be voted upon by members before execution +// Because it is a vote, duplicate potentials for the same event-type are prevented +export function addPotential(member, signature) { + if (!member.potentials) { + member.potentials = [] + } + + member.potentials = member.potentials.filter( + pot => + !( + pot.opinion === signature.opinion && pot.memberId === signature.memberId + ) + ) + + member.potentials.push(signature) +} + +// Returns true if there are POTENTIALS_TO_EXECUTE or more potentials of the specified event-type on the object +export function checkPotential(member, eventType) { + return ( + member.potentials.filter(pot => pot.opinion === eventType).length >= + POTENTIALS_TO_EXECUTE + ) +} + +// Clears all potentials of the specified event-type from the given card +export function clearPotential(member, eventType) { + member.potentials = member.potentials.filter(pot => pot.opinion !== eventType) +} + +// Sets the lastUsed property of the given object to the given timestamp +export function updateLastUsed(member, timestamp) { + member.lastUsed = timestamp +} + +export function safeMerge(cardA, cardZ) { + if (!cardA || !cardZ) { + console.log('attempt to merge nonexistent card') + return + } + + if (!cardZ.taskId || !isString(cardZ.taskId)) { + console.log('attempt to merge card with a missing or invalid taskId') + return + } + + if (!cardZ.color) { + console.log('attempt to merge card without a color') + return + } + + if (isString(cardZ.color) && cardZ.color.trim().length >= 1) { + cardA.color = cardZ.color + } + + if (isString(cardZ.guild) && cardZ.color.trim().length >= 1) { + cardA.guild = cardZ.guild + } + + const filterNull = tasks => { + return tasks.filter(task => task !== null && task !== undefined) + } + + cardA.book = cardZ.book + cardA.address = cardZ.address + cardA.bolt11 = cardZ.bolt11 + cardA.priorities = [ + ...new Set(cardA.priorities.concat(filterNull(cardZ.priorities))), + ] + cardA.subTasks = [ + ...new Set(cardA.subTasks.concat(filterNull(cardZ.subTasks))), + ] + cardA.completed = [ + ...new Set(cardA.completed.concat(filterNull(cardZ.completed))), + ] + + // Replace the pinboard (maybe they could merge? or at least drop existing pins down to subTasks) + if ( + cardZ.pinboard && + cardZ.pinboard.height >= 1 && + cardZ.pinboard.width >= 1 + ) { + if (!cardA.pinboard) { + cardA.pinboard = blankPinboard() + } + cardA.pinboard.height = Math.max( + cardA.pinboard.height, + cardZ.pinboard.height + ) + cardA.pinboard.width = Math.max(cardA.pinboard.width, cardZ.pinboard.width) + cardA.pinboard.spread = cardZ.pinboard.spread + if (cardZ.pins && Array.isArray(cardZ.pins)) { + cardA.pins = cardZ.pins + } + } + + cardA.passed = [...new Set([...cardA.passed, ...filterNull(cardZ.passed)])] + // Remove duplicate passes + let passesNoDuplicates: CardPass[] = [] + cardA.passed.forEach(pass => { + if ( + !passesNoDuplicates.some( + pass2 => pass[0] === pass2[0] && pass[1] === pass2[1] + ) + ) { + passesNoDuplicates.push(pass) + } + }) + cardA.passed = passesNoDuplicates + + // XXX only add in merge for now + // XXX bolt11 / address need to clearly indicate origin ao + // XXX book should be a list? +} + +// A card's .signed is an append-only list of all signing events. +// This function reduces it to just each member's current opinion +// signed is type Signature[] +export function mostRecentSignaturesOnly(signed) { + let mostRecentSignaturesOnly = signed.filter((signature, index) => { + let lastIndex + for (let i = signed.length - 1; i >= 0; i--) { + if (signed[i].memberId === signature.memberId) { + lastIndex = i + break + } + } + return lastIndex === index + }) + return mostRecentSignaturesOnly +} + +// Signed is type Signature[] +export function countCurrentSignatures(signed) { + return mostRecentSignaturesOnly(signed).filter( + signature => signature.opinion >= 1 + ).length +} diff --git a/ao-lib/crypto.js b/ao-lib/crypto.js new file mode 100644 index 0000000..abce401 --- /dev/null +++ b/ao-lib/crypto.js @@ -0,0 +1,30 @@ +import crypto from 'crypto' // Does not work on client because this is a Node library, but works for below server-only functions +// These libraries are old but they work and can be included on both server and client +import shajs from 'sha.js' +import hmac from 'hash.js/lib/hash/hmac.js' +import sha256 from 'hash.js/lib/hash/sha/256.js' // Only works for shorter hashes, not in createHash used for hashing meme files + +export function createHash(payload) { + return shajs('sha256').update(payload).digest('hex') +} + +export function hmacHex(data, signingKey) { + return hmac(sha256, signingKey).update(data).digest('hex') +} + +export function derivePublicKey(p) { + return crypto.createPublicKey(p).export({ + type: 'spki', + format: 'pem', + }) +} + +export function encryptToPublic(pub, info) { + return crypto.publicEncrypt(pub, new Buffer(info)).toString('hex') +} + +export function decryptFromPrivate(priv, hiddenInfo) { + return crypto + .privateDecrypt(priv, Buffer.from(hiddenInfo, 'hex')) + .toString('latin1') +} diff --git a/ao-lib/members.ts b/ao-lib/members.ts new file mode 100644 index 0000000..1145d67 --- /dev/null +++ b/ao-lib/members.ts @@ -0,0 +1,100 @@ +import { getTask } from './cards.js' + +// DUPLICATED function from client/cardActions.ts +// Returns the number of vouches a member has. Member card must exist for each voucher. Members do not have to be active to vouch. +// This function can be used either on the client or the server. If on the server, server state must be passed as the second arg. +export function countVouches(memberId, state): number | null { + let card + card = getTask(state.tasks, memberId) + if (!card || !card.hasOwnProperty('deck')) return null + + let count = 0 + + const memberCards = card.deck + .map(memberId => state.members.find(m => m.memberId === memberId)) + .forEach(memberCard => { + if (memberCard !== undefined) { + count++ + } + }) + + return count +} + +// Returns true if the senpai memberId is ahead of the kohai memberId in the members list order +export function isAheadOf(senpaiId, kohaiId, state, errRes?) { + if (errRes === undefined) { + errRes = [] + } + let senpaiRank = state.members.findIndex(m => m.memberId === senpaiId) + let kohaiRank = state.members.findIndex(m => m.memberId === kohaiId) + if (senpaiRank < kohaiRank) { + return 1 + } else if (kohaiRank < senpaiRank) { + return -1 + } + + errRes.push('member is not ahead of other member in order of member list') + return 0 +} + +// Returns true if the senpai has more attack than the kohai has defense +// A member's defense is their number of vouches, or the highest attack score out of anyone who vouches for them (whichever is greater) +// This method does not check if vouchers exist, therefore it depends on the mutations being perfect +// and there not being any invalid members leftover in the .deck / vouchers list of the other member +export function isDecidedlyMorePopularThan(senpaiId, kohaiId, state, errRes?) { + if (errRes === undefined) { + errRes = [] + } + + const senpaiCard = state.tasks.find(t => t.taskId === senpaiId) + if (!senpaiCard) { + errRes.push('invalid member detected') + return null + } + const kohaiCard = state.tasks.find(t => t.taskId === kohaiId) + if (!kohaiCard) { + errRes.push('invalid member detected') + return null + } + + const senpaiVouches = countVouches(senpaiId, state) + + let kohaiVouchCards = state.tasks.filter( + t => kohaiCard.deck.indexOf(t.taskId) >= 0 + ) + + let kohaiVouches = kohaiVouchCards.length + + kohaiVouchCards.forEach(card => { + if (card.taskId !== senpaiCard.taskId) { + kohaiVouches = Math.max(kohaiVouches, countVouches(card.taskId, state)) + } + }) + if (senpaiVouches > kohaiVouches) { + return 1 + } else if (kohaiVouches > senpaiVouches) { + return -1 + } + + errRes.push('member does not have more vouches than other member') + return 0 +} + +// Returns true if the senpaiId member is both isAheadOf and isDecidedlyMorePopularThan the kohaiId member +export function isSenpaiOf(senpaiId, kohaiId, state, errRes?) { + if (errRes === undefined) { + errRes = [] + } + + const rank = isAheadOf(senpaiId, kohaiId, state, errRes) + const vouches = isDecidedlyMorePopularThan(senpaiId, kohaiId, state, errRes) + if (rank === 1 && vouches === 1) { + return 1 + } else if (rank === -1 && vouches === -1) { + return -1 + } + + errRes.push('member is not a senpai of the other member') + return 0 +} diff --git a/ao-lib/mutations.ts b/ao-lib/mutations.ts new file mode 100644 index 0000000..3f6de5d --- /dev/null +++ b/ao-lib/mutations.ts @@ -0,0 +1,1549 @@ +// Mutations are state builders. +// The current state is the result of all the events in the system fed through the mutation functions. +// `server/state.js` for server; `modules/*` for vuex. + +// const Vue = require('vue') +import { createHash } from './crypto.js' +import { + blankCard, + blankPinboard, + getTask, + getTaskBy, + atomicCardPlay, + unpinTasksOutOfBounds, + discardTaskFromZone, + taskExists, + seeTask, + clearPassesTo, + changeGiftCount, + grabTask, + dropTask, + addParent, + removeParentIfNotParent, + filterFromSubpiles, + clearSeenExcept, + addSubTask, + putTaskInTask, + addPriority, + stashTask, + unstashTask, + addPotential, + checkPotential, + clearPotential, + updateLastUsed, + safeMerge, + registerDuplicateTaskId, + POTENTIALS_TO_EXECUTE, +} from './cards.js' + +function aoMuts(aos, ev) { + switch (ev.type) { + //case 'ao-linked': + // aos.forEach((ao, i) => { + // if (ao.address === ev.address) { + // ao.links.push(ev.taskId) + // } + // }) + // break + case 'ao-inbound-connected': + let inAddressConnect = aos.some(a => { + if (a.address === ev.address) { + a.inboundSecret = ev.secret + a.lastContact = Date.now() + return true + } + }) + if (!inAddressConnect) { + let newEv = { + address: ev.address, + outboundSecret: false, + inboundSecret: ev.secret, + lastContact: Date.now(), + } + aos.push(newEv) + } + break + case 'ao-outbound-connected': + let outAddressConnect = aos.some(a => { + if (a.address === ev.address) { + a.outboundSecret = ev.secret + a.lastContact = Date.now() + return true + } + }) + if (!outAddressConnect) { + let newEv = { + address: ev.address.trim(), + outboundSecret: ev.secret, + inboundSecret: false, + lastContact: Date.now(), + } + aos.push(newEv) + } + break + case 'ao-disconnected': + aos.forEach((ao, i) => { + if (ao.address.trim() === ev.address.trim()) { + aos.splice(i, 1) + } + }) + break + } +} + +function cashMuts(cash, ev) { + switch (ev.type) { + case 'ao-named': + cash.alias = ev.alias + break + case 'spot-updated': + cash.spot = ev.spot + break + case 'currency-switched': + cash.currency = ev.currency + break + case 'rent-set': + cash.rent = parseFloat(ev.amount) + break + case 'cap-set': + cash.cap = ev.amount + break + case 'funds-set': + cash.outputs = ev.outputs + cash.channels = ev.channels + break + case 'quorum-set': + cash.quorum = ev.quorum + break + case 'task-boosted': + if (ev.txid) cash.usedTxIds.push(ev.txid) + break + case 'task-boosted-lightning': + cash.pay_index = ev.pay_index + break + case 'get-node-info': + cash.info = ev.info + break + } +} + +function membersMuts(members, ev) { + switch (ev.type) { + case 'ao-outbound-connected': + break + case 'ao-disconnected': + break + case 'member-created': + updateLastUsed(ev, ev.timestamp) + ev.muted = true + ev.p0wned = true + members.push(ev) + break + case 'member-activated': + members.forEach(member => { + if (member.memberId === ev.memberId && !member.banned) { + if (member.active < 0) { + member.active = -1 * member.active + } else { + member.active++ + } + } + }) + break + case 'task-boosted': + members.forEach(member => { + if (member.memberId === ev.taskId) { + if (member.active < 0) { + member.active = -1 * member.active + } else { + member.active++ + } + } + }) + break + case 'task-boosted-lightning': + members.forEach(member => { + if (member.memberId === ev.taskId) { + if (member.active < 0) { + member.active = -1 * member.active + } else { + member.active++ + } + } + }) + break + case 'task-visited': + members.forEach(member => { + if (member.memberId === ev.memberId) { + updateLastUsed(member, ev.timestamp) + } + }) + break + case 'member-deactivated': + members.forEach(member => { + if (member.memberId === ev.memberId) { + if (member.active >= 0) { + member.active = -1 * member.active - 1 + } + } + }) + break + case 'member-secret-reset': + members.forEach(member => { + if (member.memberId === ev.kohaiId) { + const newSig = { + memberId: ev.senpaiId, + timestamp: ev.timestamp, + opinion: ev.type, + } + + addPotential(member, newSig) + + if (checkPotential(member, 'member-secret-reset')) { + member.p0wned = true + member.secret = createHash(member.name) + clearPotential(member, 'member-secret-reset') + } + } + }) + break + case 'member-promoted': + let toIndex + let fromIndex + if ( + members.some((member, i) => { + if (member.memberId === ev.senpaiId) { + toIndex = i + return true + } + }) && + members.some((member, i) => { + if (member.memberId === ev.kohaiId) { + fromIndex = i + return true + } + }) + ) { + members.splice(toIndex, 0, members.splice(fromIndex, 1)[0]) + } + case 'member-banned': + members.forEach(member => { + if (member.memberId === ev.kohaiId) { + const newSig = { + memberId: ev.senpaiId, + timestamp: ev.timestamp, + opinion: ev.type, + } + + addPotential(member, newSig) + + if (checkPotential(member, 'member-banned')) { + member.banned = true + if (member.active >= 0) { + member.active = -1 * member.active - 1 + } + } + } + }) + break + case 'member-unbanned': + members.forEach(member => { + if ( + member.memberId === ev.kohaiId && + member.hasOwnProperty('potentials') && + member.potentials.length >= 1 + ) { + const beforeBans = member.potentials.filter( + p => p.opinion === 'member-banned' + ).length + + member.potentials = member.potentials.filter( + p => !(p.opinion === 'member-banned' && p.memberId === ev.senpaiId) + ) + + const afterBans = member.potentials.filter( + p => p.opinion === 'member-banned' + ).length + + if ( + beforeBans >= POTENTIALS_TO_EXECUTE && + afterBans < POTENTIALS_TO_EXECUTE + ) { + member.banned = false + } + } + }) + break + case 'member-purged': + for (let i = members.length - 1; i >= 0; i--) { + const member = members[i] + if (member.memberId === ev.memberId) { + const newSig = { + memberId: ev.blame, + timestamp: ev.timestamp, + opinion: ev.type, + } + + //addPotential(member, newSig) + + //if (testPotential(member, 'member-purged')) { + members.splice(i, 1) + //} + } + } + + break + case 'resource-used': + members.forEach(member => { + if (member.memberId === ev.memberId) { + updateLastUsed(member, ev.timestamp) + } + }) + break + + case 'member-field-updated': + members.forEach(member => { + if (member.memberId === ev.memberId) { + member[ev.field] = ev.newfield + if (ev.field === 'secret') { + member.p0wned = false + } + } + }) + break + + case 'member-ticker-set': + members.forEach(member => { + if (member.memberId === ev.memberId) { + if (!member.tickers) { + member.tickers = [] + } + if ( + !ev.fromCoin || + ev.fromCoin.trim().length < 1 || + !ev.toCoin || + ev.toCoin.trim().length < 1 + ) { + member.tickers.splice(ev.index, 1) + } else { + member.tickers[ev.index] = { + from: ev.fromCoin.trim().toLowerCase(), + to: ev.toCoin.trim().toLowerCase(), + } + } + } + }) + break + + case 'doge-barked': + members.forEach(member => { + // this should only bump up for mutual doges + if (member.memberId === ev.memberId) { + updateLastUsed(member, ev.timestamp) + // then bark + } + }) + break + } +} + +function resourcesMuts(resources, ev) { + switch (ev.type) { + case 'resource-created': + let resourceIds = resources.map(r => r.resourceId) + if (resourceIds.indexOf(ev.resourceId) === -1) { + resources.push(ev) + } else { + console.log( + 'BAD data duplicate resource rejected in mutation, dup resource task likely created' + ) + } + break + case 'resource-used': + resources.forEach(resource => { + if (resource.resourceId == ev.resourceId) { + resource.stock -= parseInt(ev.amount) + } + }) + break + case 'resource-purged': + resources.forEach((r, i) => { + if (r.resourceId === ev.resourceId) { + resources.splice(i, 1) + } + }) + break + case 'resource-stocked': + resources.forEach(resource => { + if (resource.resourceId == ev.resourceId) { + resource.stock += parseInt(ev.amount) + } + }) + break + case 'channel-created': + resources.forEach((r, i) => { + if (r.resourceId == ev.resourceId) { + r.pubkey = ev.pubkey + } + }) + break + } +} + +function memesMuts(memes, ev) { + switch (ev.type) { + case 'meme-added': + const fileHash = ev.data + if ( + !memes.some(file => { + return file.hash === ev.hash + }) + ) { + memes.push({ + memeId: ev.taskId, + filename: ev.filename, + hash: ev.hash, + filetype: ev.filetype, + }) + // console.log('added meme file: ', ev.filename) + } else { + // console.log('meme file already in state: ', ev.filename) + } + break + case 'task-removed': + for (let i = memes.length - 1; i >= 0; i--) { + const meme = memes[i] + if (meme.memeId === ev.taskId) { + memes.splice(i, 1) + } + } + break + } +} + +function sessionsMuts(sessions, ev) { + switch (ev.type) { + case 'session-created': + let idHasSession = sessions.some(session => { + // replace that sessions creds, + let match = false + if (session.ownerId === ev.ownerId) { + match = true + Object.assign(session, session, ev) + } + return match // true terminates the some loop & idHasSession->true too + }) + + if (idHasSession) { + // edited in session + } else { + // id didn't previously have session + sessions.push(ev) + } + break + case 'session-killed': + for (let i = sessions.length - 1; i >= 0; i--) { + const s = sessions[i] + if (s.session == ev.session) { + sessions.splice(i, 1) + } + } + break + case 'ao-outbound-connected': + sessions.push({ + ownerId: ev.address, + token: ev.secret, + session: ev.address, + }) + break + } +} + +let missingTaskIds = [] +function tasksMuts(tasks, ev) { + let theTask + let inTask + let memberTask + + // Most tasks have a taskId and memberId, and many have an inId, so pull these out in a standard way + const memberTaskId = ev.memberId || ev.blame + if ( + memberTaskId && + memberTaskId !== 'cleanup' && + typeof memberTaskId === 'string' && + !memberTaskId.includes('.onion') + ) { + memberTask = getTask(tasks, memberTaskId) + if ( + !memberTask && + ev.type !== 'member-created' && + ev.type !== 'member-purged' + ) { + if (!missingTaskIds.includes(memberTaskId)) { + missingTaskIds.push(memberTaskId) + console.log( + ev.type + ': first missing member task for memberId', + memberTaskId, + '(' + missingTaskIds.length + ')' + ) + } + return + } + } + + const theTaskId = + ev.taskId || + ev.subTask || + ev.resourceId || + ev?.from?.taskId || + ev?.to?.taskId + if (theTaskId) { + theTask = getTask(tasks, theTaskId) + if ( + !theTask && + ev.type !== 'task-created' && + ev.type !== 'grid-created' && + ev.type !== 'resource-created' && + ev.type !== 'meme-added' + ) { + if (!missingTaskIds.includes(theTaskId)) { + missingTaskIds.push(theTaskId) + //console.log(ev.type + ': first missing task event for taskId', theTaskId, '(' + missingTaskIds.length + ') ev:', ev) + } + return // continuing may crash, but returning may cause further inconsistencies going forward + } + } + + if (ev.inId) { + inTask = getTask(tasks, ev.inId) + if (!inTask && ev.type !== 'task-created') { + if (!missingTaskIds.includes(ev.inId)) { + missingTaskIds.push(ev.inId) + //console.log(ev.type + ': first missing task event for inId', ev.inId, '(' + missingTaskIds.length + ') ev:', ev) + } + return // continuing may crash, but returning may cause further inconsistencies going forward + } + } + + switch (ev.type) { + case 'highlighted': + tasks.forEach(task => { + if (task.taskId === ev.taskId) { + let didUpdateInline = false + task.highlights.forEach((h, i) => { + if (h.memberId === ev.memberId) { + didUpdateInline = true + if (h.valence === ev.valence) { + task.highlights.splice(i, 1) + } else { + h.valence = ev.valence + } + } + }) + if (!didUpdateInline) { + task.highlights.push({ + memberId: ev.memberId, + valence: ev.valence, + }) + } + } + }) + break + case 'ao-outbound-connected': + tasks.push(blankCard(ev.address, ev.address, 'purple', ev.timestamp)) + break + case 'ao-disconnected': + break + case 'resource-created': + tasks.push(blankCard(ev.resourceId, ev.resourceId, 'red', ev.timestamp)) + break + case 'member-created': + tasks.push(blankCard(ev.memberId, ev.memberId, 'blue', ev.timestamp)) + break + case 'member-purged': + // This is terribly redundant since the same potential builds up on the member. + // Maybe the potentials system should be abstracted out to the spec or validation layer; + // Attempts to call limited functions instead produce an action-potential event. + // The original idea was potentials would only build up on members, not tasks. + let purgedMemberCard = false + for (let i = tasks.length - 1; i >= 0; i--) { + const task = tasks[i] + if (task.taskId === ev.memberId) { + let newSig = { + memberId: ev.blame, + timestamp: ev.timestamp, + opinion: ev.type, + } + + addPotential(task, newSig) + + if (checkPotential(task, 'member-purged')) { + tasks.splice(i, 1) + purgedMemberCard = true + } + } + } + + if (purgedMemberCard) { + tasks.forEach((t, j) => { + t.subTasks = t.subTasks.filter(st => st !== ev.memberId) + t.priorities = t.priorities.filter(st => st !== ev.memberId) + t.completed.filter(st => st !== ev.memberId) + t.claimed = t.claimed.filter(st => st !== ev.memberId) + t.deck = t.deck.filter(st => st !== ev.memberId) + clearPassesTo(tasks, t, ev.memberId, true) + if (t.pins && t.pins.length >= 1) { + t.pins.forEach((pin, i) => { + const cell = pin.taskId + if (cell === ev.memberId) { + tasks[j].pins.spice(i, 1) + } + }) + } + }) + } + break + case 'meme-added': + // console.log('meme-added taskId is', ev.taskId) + if (!tasks.some(t => t.taskId === ev.taskId)) { + // console.log('adding meme', ev.taskId) + tasks.push(blankCard(ev.taskId, ev.filename, 'yellow', ev.timestamp)) + } + break + case 'task-created': + const foundExistingTask = getTaskBy(tasks, ev.name, 'name') + if (foundExistingTask?.taskId) { + registerDuplicateTaskId(foundExistingTask.taskId, ev.taskId) + break + } + tasks.push( + blankCard( + ev.taskId, + ev.name, + ev.color, + ev.timestamp, + ev.deck, + ev.inId ? [ev.inId] : [] + ) + ) + if (inTask) { + if (ev.prioritized) { + addPriority(inTask, ev.taskId) + } else { + addSubTask(inTask, ev.taskId) + } + addParent(inTask, ev.inId) + clearSeenExcept(inTask, ev.deck.length >= 1 ? [ev.deck[0]] : undefined) // The very font of novelty + } + break + case 'address-updated': + tasks.forEach(t => { + if (t.taskId === ev.taskId) { + t.address = ev.address + } + }) + break + case 'task-passed': + let pass = [ev.fromMemberId, ev.toMemberId] + + if ( + !theTask.passed.some(p => { + if (p[0] === pass[0] && p[1] === pass[1]) { + return true + } + }) + ) { + theTask.passed.push(pass) + const recipient = getTask(tasks, ev.toMemberId) + if (recipient) { + changeGiftCount(recipient, 1) + } + } + break + case 'task-grabbed': + grabTask(tasks, theTask, ev.memberId) + break + case 'task-seen': + tasks.forEach(task => { + if (task.taskId === ev.taskId) { + if (!task.seen) { + task.seen = [] + } + if ( + !task.seen.some(t => { + return t.memberId === ev.memberId + }) + ) { + task.seen.push({ memberId: ev.memberId, timestamp: Date.now() }) + } + } + }) + break + case 'task-started': + const tsFound = theTask + + if (tsFound) { + if (!tsFound.timelog) { + tsFound.timelog = [] + } + // console.log('task-started pre timelog is', tsFound.timelog) + tsFound.timelog.push({ + memberId: ev.memberId, + taskId: ev.taskId, + inId: ev.inId, + start: ev.timestamp, + stop: null, + }) + } + // console.log('task-started post timelog is', tsFound.timelog) + break + case 'task-stopped': + // console.log('task-stopped 1') + const tstFound = theTask + // console.log('task-stopped 2') + if (tstFound) { + // console.log('task-stopped 3') + // console.log('task-stopped pre timelog is', tstFound.timelog) + if (!tstFound.timelog) { + return + } + // console.log('task-stopped 4') + for (var i = tstFound.timelog.length - 1; i >= 0; i--) { + // console.log('task-stopped 5') + if (tstFound.timelog[i].memberId === ev.memberId) { + // console.log('task-stopped 6') + if ( + tstFound.timelog[i].stop && + tstFound.timelog[i].stop > tstFound.timelog[i].start + ) { + // console.log( + // 'task-stopped 7 stop is', + // tstFound.timelog[i].stop, + // ' and start is', + // tstFound.timelog[i].start + // ) + console.log( + 'Stop time already set for most recent start time, triggering this event should not be possible in the GUI' + ) + } else { + // console.log('task-stopped 8') + tstFound.timelog[i].stop = ev.timestamp + // tstFound.timelog.push({ + // memberId: 'test', + // taskId: 'testId', + // inId: null, + // start: null, + // stop: null, + // }) + } + } + // console.log('task-stopped post timelog is', tstFound.timelog) + + break + } + } + break + case 'task-time-clocked': + tasks.forEach(task => { + if (task.taskId === ev.taskId) { + let found = task.time.find(t => { + return t.memberId === ev.memberId + }) + if (!found) { + task.time.push({ + memberId: ev.memberId, + timelog: [ev.seconds], + date: [ev.date], + }) + } else { + if (!found.timelog) { + found.timelog = [] + } + if (!found.date) { + found.date = [] + if (found.timelog.length > found.date.length) { + let count = found.timelog.length - found.date.length + while (count > 0) { + found.date.push(null) + count-- + } + } + } + found.timelog.push(ev.seconds) + found.date.push(ev.date) + } + } + }) + break + case 'task-signed': + tasks.forEach(task => { + if (task.taskId === ev.taskId) { + clearPassesTo(tasks, task, ev.memberId) + if (task.deck.indexOf(ev.memberId) === -1) { + task.deck.push(ev.memberId) + } + let newSig = { + memberId: ev.memberId, + timestamp: ev.timestamp, + opinion: ev.opinion, + } + if (!task.signed) { + task.signed = [] + } + task.signed.push(newSig) + if (task.guild && task.guild.length >= 1) { + if ( + ev.opinion === 1 && + (!task.hasOwnProperty('memberships') || + !Array.isArray(task.memberships)) + ) { + if (!task.memberships) { + task.memberships = [] + } + task.memberships.push({ memberId: ev.memberId, level: 2 }) + } else if ( + ev.opinion === 0 && + task.hasOwnProperty('memberships') && + Array.isArray(task.memberships) + ) { + task.memberships.filter(memb => memb.memberId !== ev.memberId) + } + } + } + }) + break + case 'task-membership': + tasks.forEach(task => { + if (task.taskId === ev.taskId) { + if (task.guild && task.guild.length >= 1) { + // The member must have signed the task affirmatively + if (!task.signed || !task.signed.length || task.signed.length < 1) { + return + } + + let mostRecentOpinion + for (let i = task.signed.length - 1; i--; i >= 0) { + const signature = task.signed[i] + if (signature.memberId === ev.memberId) { + mostRecentOpinion = signature.opinion + break + } + } + + if (mostRecentOpinion < 1) { + return + } + + if ( + !task.memberships || + !task.memberships.length || + task.memberships.length < 1 + ) { + return + } + + const promoterLevel = task.memberships.find( + membership => membership.memberId === ev.blame + )?.level + + if (!promoterLevel || promoterLevel < 1) { + return + } + + const promotedLevel = + task.memberships.find( + membership => membership.memberId === ev.memberId + )?.level || 0 + + let maxLevel = 0 + task.memberships.forEach(membership => { + maxLevel = Math.max(maxLevel, membership.level) + }) + + // The promoter must be a member at least one level higher + // Or, the highest-level member of a group can promote themselves + const canPromote = + (promoterLevel > promotedLevel && + promoterLevel >= ev.level + 1) || + (ev.memberId === ev.blame && + promoterLevel === maxLevel && + maxLevel >= 1) + + if (!canPromote) { + return + } + + task.memberships = task.memberships.filter( + memb => memb.memberId !== ev.memberId + ) + if (ev.level !== 0) { + task.memberships.push({ + memberId: ev.memberId, + level: ev.level, + }) + } + } + } + }) + break + case 'task-stashed': + // I think the spec is only run on event creation, not load from database, + // so make sure the task exists before linking to it from another card + const toStash = theTask + if (toStash) { + grabTask(tasks, toStash, ev.blame) + addParent(toStash, ev.inId) + + tasks.forEach(task => { + if (task.taskId === ev.inId) { + stashTask(task, ev.taskId, ev.level) + } + }) + } + break + case 'task-unstashed': + // I think the spec is only run on event creation, not load from database, + // so make sure the task exists before linking to it from another card + const toUnstash = theTask + const unstashParentCard = inTask + if (toUnstash && unstashParentCard) { + grabTask(tasks, toUnstash, ev.blame) + + tasks.forEach(task => { + if (task.taskId === ev.inId) { + unstashTask(task, ev.taskId, ev.level) + removeParentIfNotParent(task, unstashParentCard) + } + }) + } + break + case 'pile-grabbed': + if (!ev.memberId) { + break + } + tasks.forEach(task => { + if (task.taskId === ev.taskId) { + clearPassesTo(tasks, task, ev.memberId) + let crawler = [ev.taskId] + let history = [] + let newCards = [] + do { + newCards = [] + crawler.forEach(t => { + if (history.indexOf(t) >= 0) return + let subTask = tasks.filter(pst => pst.taskId === t) + if (subTask.length < 1) { + // console.log( + // 'missing subtask, this is messy. parent task name: ', + // task.name + // ) + return + } + if (subTask.length > 1) { + console.log('duplicate task found, this is very bad') + } + subTask = subTask[0] + if ( + subTask === undefined || + subTask.subTasks === undefined || + subTask.priorities === undefined || + subTask.completed === undefined + ) { + console.log('invalid task data found, this is very bad') + return + } + + history.push(t) + + if ( + subTask.deck.indexOf(ev.memberId) === -1 && + ev.taskId !== ev.memberId + ) { + clearPassesTo(tasks, subTask, ev.memberId) + subTask.deck.push(ev.memberId) + } + newCards = newCards + .concat(subTask.subTasks) + .concat(subTask.priorities) + .concat(subTask.completed) + }) + crawler = newCards + } while (crawler.length > 0) + } + }) + break + case 'task-dropped': + theTask.deck = theTask.deck.filter(d => d !== ev.memberId) + clearPassesTo(tasks, theTask, ev.memberId) + break + case 'pile-dropped': + if (!ev.memberId) { + break + } + tasks.forEach(task => { + if (task.taskId === ev.taskId) { + clearPassesTo(tasks, task, ev.memberId) + let crawler = [ev.taskId] + let history = [] + let newCards = [] + do { + newCards = [] + crawler.forEach(t => { + if (history.indexOf(t) >= 0) return + let subTask = tasks.filter(pst => pst.taskId === t) + if (subTask.length < 1) { + console.log('missing subtask, this is messy') + return + } + if (subTask.length > 1) { + console.log('duplicate task found, this is very bad') + } + subTask = subTask[0] + if ( + subTask === undefined || + subTask.subTasks === undefined || + subTask.priorities === undefined || + subTask.completed === undefined + ) { + console.log('invalid task data found, this is very bad') + return + } + + history.push(t) + + if ( + subTask.deck.indexOf(ev.memberId) >= 0 && + ev.taskId !== ev.memberId + ) { + clearPassesTo(tasks, subTask, ev.memberId) + dropTask(subTask, ev.memberId) + } + newCards = newCards + .concat(subTask.subTasks) + .concat(subTask.priorities) + .concat(subTask.completed) + }) + crawler = newCards + } while (crawler.length > 0) + } + }) + break + case 'task-removed': + for (let i = tasks.length - 1; i >= 0; i--) { + const task = tasks[i] + if (task.taskId === ev.taskId) { + tasks.splice(i, 1) + } + } + tasks.forEach((t, i) => { + t.subTasks = t.subTasks.filter(st => st !== ev.taskId) + t.priorities = t.priorities.filter(st => st !== ev.taskId) + t.completed = t.completed.filter(st => st !== ev.taskId) + t.pins = t.pins.filter(pin => pin.taskId !== ev.taskId) + }) + break + case 'tasks-removed': + for (let i = tasks.length - 1; i >= 0; i--) { + const task = tasks[i] + if (ev.taskIds.includes(task.taskId)) { + tasks.splice(i, 1) + } + } + tasks.forEach((t, i) => { + t.subTasks = t.subTasks.filter(st => !ev.taskIds.includes(st)) + t.priorities = t.priorities.filter(st => !ev.taskIds.includes(st)) + t.completed = t.completed.filter(st => !ev.taskIds.includes(st)) + t.pins = + t.pins && Array.isArray(t.pins) + ? t.pins.filter(pin => !ev.taskIds.includes(pin.taskId)) + : [] + }) + break + case 'pile-prioritized': + inTask.priorities = inTask.priorities.concat(inTask.subTasks) + inTask.subTasks = [] + break + case 'task-refocused': + atomicCardPlay( + tasks, + { taskId: ev.taskId, inId: ev.inId, zone: 'priorities' }, + { taskId: ev.taskId, inId: ev.inId, zone: 'subTasks' }, + ev.blame + ) + break + case 'pile-refocused': + tasks.forEach(task => { + if (task.taskId === ev.inId) { + task.priorities.forEach(stId => { + tasks.forEach(st => { + if (st.taskId === stId) { + if (st.claimed && st.claimed.length >= 1) { + task.completed.push(stId) + } else { + task.subTasks.push(stId) + } + } + }) + task.priorities = [] + if (task.allocations && Array.isArray(task.allocations)) { + task.allocations.forEach(allocation => { + task.boost += allocation.amount + }) + task.allocations = [] + } + }) + } + }) + break + case 'task-played': + atomicCardPlay(tasks, ev.from, ev.to, ev.memberId) + break + case 'task-sub-tasked': + atomicCardPlay( + tasks, + { taskId: ev.subTask }, + { taskId: ev.subTask, inId: ev.taskId, zone: 'subTasks' }, + ev.memberId + ) + break + case 'task-de-sub-tasked': + atomicCardPlay( + tasks, + { taskId: ev.subTask, inId: ev.taskId, zone: 'subTasks' }, + { taskId: ev.subTask, zone: 'discard' }, + ev.memberId + ) + break + case 'task-prioritized': + atomicCardPlay( + tasks, + { taskId: ev.taskId, inId: ev.inId, zone: 'card' }, + { taskId: ev.taskId, inId: ev.inId, zone: 'priorities' }, + ev.memberId + ) + break + case 'grid-pin': + if (typeof ev.y === 'string') { + ev.y = parseInt(ev.y) + } + if (typeof ev.x === 'string') { + ev.x = parseInt(ev.x) + } + atomicCardPlay( + tasks, + { taskId: ev.taskId, inId: ev.inId, zone: 'subTasks' }, + { + taskId: ev.taskId, + inId: ev.inId, + zone: 'grid', + coords: { y: parseInt(ev.y), x: parseInt(ev.x) }, + }, + ev?.memberId + ) + break + case 'grid-unpin': + //console.log("event type is", ev.type) + atomicCardPlay( + tasks, + { + taskId: ev.taskId, + inId: ev.inId, + zone: 'grid', + coords: { y: parseInt(ev.y), x: parseInt(ev.x) }, + }, + { + taskId: ev.taskId, + inId: ev.inId, + zone: 'subTasks', + coords: { y: 0 }, + }, + ev.memberId + ) + break + case 'task-emptied': + let updateParents = [] + const emptiedParent = theTask + updateParents = [...theTask.priorities, ...theTask.subTasks] + theTask.priorities = [] + theTask.subTasks = [] + tasks.forEach(task => { + if (updateParents.indexOf(task.taskId) >= 0) { + removeParentIfNotParent(task, emptiedParent) + } + }) + break + case 'task-guilded': + theTask.guild = ev.guild + break + case 'task-property-set': + let properties = ev.property.split('.') + + if (properties?.length >= 2) { + if ( + !theTask.hasOwnProperty(properties[0]) || + typeof theTask[properties[0]] != 'object' + ) { + theTask[properties[0]] = {} + } + theTask[properties[0]][properties[1]] = ev.value + } else { + theTask[ev.property] = ev.value + } + if (ev.property === 'pinboard.spread') { + unpinTasksOutOfBounds(tasks, theTask) + } + break + case 'task-colored': + theTask.color = ev.color + + if (ev.inId) { + addSubTask(inTask, ev.taskId) + } + break + case 'task-claimed': + let paid = parseFloat(ev.paid) > 0 ? parseFloat(ev.paid) : 0 + let bounty = 0 + tasks.forEach(task => { + let found = false + task.priorities.some(taskId => { + if (taskId !== ev.taskId) { + return false + } else { + found = true + return true + } + }) + + task.subTasks.some(taskId => { + if (taskId !== ev.taskId) { + return false + } else { + found = true + return true + } + }) + + if (found) { + if (task.priorities.indexOf(ev.taskId) === -1) { + task.subTasks = task.subTasks.filter(tId => tId !== ev.subTask) + task.completed = task.completed.filter(tId => tId !== ev.subTask) + task.completed.push(ev.taskId) + } + // let alloc = false + if (task.allocations && Array.isArray(task.allocations)) { + task.allocations = task.allocations.filter(al => { + if (al.allocatedId === ev.taskId) { + bounty += al.amount + return false + } + return true + }) + } + } + if (task.taskId === ev.taskId) { + clearPassesTo(tasks, task, ev.memberId) + if (task.deck.indexOf(ev.memberId) === -1) { + if (ev.taskId !== ev.memberId && ev.memberId) { + task.deck.push(ev.memberId) + } + } + if (task.claimed.indexOf(ev.memberId) === -1) { + task.claimed.push(ev.memberId) + } + task.lastClaimed = ev.timestamp + } + }) + tasks.forEach(task => { + if (task.taskId === ev.memberId) { + task.boost += paid + bounty + } + }) + break + case 'task-unclaimed': + theTask.claimed = theTask.claimed.filter(mId => mId !== ev.memberId) + if (theTask.claimed.length < 1) { + tasks.forEach(p => { + if ( + p.priorities.indexOf(ev.taskId) === -1 && + p.completed.indexOf(ev.taskId) > -1 + ) { + p.completed = p.completed.filter(taskId => taskId !== ev.taskId) + addSubTask(p, ev.taskId) + } + }) + } + break + case 'task-reset': + const clearAllCheckmarksFromTask = taskToReset => { + if (!taskToReset) return + taskToReset.claimed = [] + taskToReset.lastClaimed = ev.timestamp + } + if (theTask.uncheckThisCard) { + clearAllCheckmarksFromTask(theTask) + } + if ( + theTask.uncheckPriorities && + theTask.priorities && + theTask.priorities.length >= 1 + ) { + theTask.priorities.forEach(tId => { + const priorityCard = getTask(tasks, tId) + clearAllCheckmarksFromTask(priorityCard) + }) + } + if (theTask.uncheckPinned && theTask.pins && theTask.pins.length >= 1) { + theTask.pins.forEach(pin => { + const pinnedCard = getTask(tasks, pin.taskId) + clearAllCheckmarksFromTask(pinnedCard) + }) + } + break + case 'task-boosted': + let amount = parseFloat(ev.amount) + let boost = parseFloat(theTask.boost) + if (amount > 0) { + theTask.boost = amount + boost + theTask.address = '' + } + break + case 'task-boosted-lightning': + const taskToBoostLightning = getTaskBy( + tasks, + ev.payment_hash, + 'payment_hash' + ) + let amountToBoost = parseFloat(ev.amount) + let boostLightning = parseFloat(taskToBoostLightning.boost) + if (amountToBoost > 0) { + taskToBoostLightning.boost = amountToBoost + boostLightning + taskToBoostLightning.bolt11 = '' + taskToBoostLightning.payment_hash = '' + } + break + case 'task-allocated': + if (!Number.isInteger(ev.amount) || ev.amount < 0) { + break + } + if ( + !theTask.hasOwnProperty('allocations') || + !Array.isArray(theTask.allocations) + ) { + theTask.allocations = [] + } + if (ev.amount === 0) { + theTask.allocations = theTask.allocations.filter(als => { + if (als.allocatedId == ev.allocatedId) { + theTask.boost += als.amount + return false + } + return true + }) + break + } + const alreadyPointed = theTask.allocations.some(als => { + const diff = ev.amount - als.amount + if (als.allocatedId !== ev.allocatedId) return false + if (diff > theTask.boost) return true + theTask.boost -= diff + als.amount = ev.amount + return true + }) + if (!alreadyPointed) { + theTask.allocations.push(ev) + theTask.boost -= ev.amount + } + addPriority(theTask, ev.allocatedId) + break + case 'resource-booked': + theTask.book = ev + break + case 'resource-used': + const charged = parseFloat(ev.charged) + if (charged > 0) { + memberTask.boost -= charged + + const resourceCard = theTask + resourceCard.boost += charged + } + break + case 'invoice-created': + theTask.payment_hash = ev.payment_hash + theTask.bolt11 = ev.bolt11 + break + case 'task-swapped': + let task + tasks.forEach(t => { + if (t.taskId === ev.taskId) { + task = t + } + }) + + if (task) { + let originalIndex = task.subTasks.indexOf(ev.swapId1) + let swapIndex = task.subTasks.indexOf(ev.swapId2) + + let originalIndexCompleted = task.completed.indexOf(ev.swapId1) + let swapIndexCompleted = task.completed.indexOf(ev.swapId2) + + if (originalIndex > -1 && swapIndex > -1) { + let newST = task.subTasks.slice() + newST[originalIndex] = ev.swapId2 + newST[swapIndex] = ev.swapId1 + task.subTasks = newST + } + + if (originalIndexCompleted > -1 && swapIndexCompleted > -1) { + let newCompleted = task.completed.slice() + newCompleted[originalIndexCompleted] = ev.swapId2 + newCompleted[swapIndexCompleted] = ev.swapId1 + task.completed = newCompleted + } + } + break + case 'task-bumped': + let taskB + tasks.forEach(t => { + if (t.taskId === ev.taskId) { + taskB = t + } + }) + + if (taskB) { + let originalIndex = taskB.subTasks.indexOf(ev.bumpId) + let originalIndexCompleted = taskB.completed.indexOf(ev.bumpId) + if ( + originalIndex === taskB.subTasks.length - 1 && + ev.direction === -1 + ) { + let newST = [ev.bumpId] + newST = newST.concat( + taskB.subTasks.slice(0, taskB.subTasks.length - 1) + ) + taskB.subTasks = newST + } + + if (originalIndex === 0 && ev.direction === 1) { + let newST = taskB.subTasks.slice(1) + newST.push(ev.bumpId) + taskB.subTasks = newST + } + } + break + case 'tasks-received': + ev.tasks.forEach(newT => { + let existingTask = getTask(tasks, newT.taskId) + if (!existingTask) { + existingTask = blankCard( + newT.taskId, + newT.name, + newT.color, + newT.timestamp, + newT.parents, + newT.height, + newT.width + ) + tasks.push(existingTask) + } + safeMerge(existingTask, newT) + }) + + // Loop through the new cards and remove invalid references to cards that don't exist on this server + /*changedIndexes.forEach(tId => { + const t = tasks[tId] + let beforeLength = t.subTasks.length + t.subTasks = t.subTasks.filter(stId => taskExists(tasks, stId)) + t.priorities = t.priorities.filter(stId => taskExists(tasks, stId)) + t.completed = t.completed.filter(stId => taskExists(tasks, stId)) + t.deck = t.deck.filter(stId => + tasks.some(sst => sst.taskId === stId && sst.taskId === sst.name) + ) + if (t?.grid?.rows && Object.keys(t.grid.rows).length >= 1) { + let filteredRows = {} + Object.entries(t.grid.rows).forEach(([x, row]) => { + let filteredRow = {} + + if (row) { + Object.entries(row).forEach(([y, stId]) => { + if (taskExists(tasks, stId)) { + filteredRow[y] = stId + } + }) + if (Object.keys(filteredRow).length < 1) { + filteredRows[x] = {} + } else { + filteredRows[x] = filteredRow + } + } + }) + t.grid.rows = filteredRows + } + })*/ + break + case 'task-visited': + // Remove the avatar from everywhere else + tasks.forEach(task => { + if (task.hasOwnProperty('avatars')) { + task.avatars = task.avatars.filter( + avatarLocation => avatarLocation.memberId !== ev.memberId + ) + } + }) + if (!theTask.hasOwnProperty('avatars')) { + theTask.avatars = [] + } + theTask.avatars.push({ + memberId: ev.memberId, + timestamp: ev.timestamp, + area: ev.area, + }) + theTask.lastClaimed = ev.timestamp + break + case 'member-charged': + memberTask.boost -= parseFloat(ev.charged) + if (memberTask.boost < 0) { + memberTask.boost = 0 + } + break + case 'grid-created': + tasks.push( + blankCard( + ev.taskId, + ev.name, + ev.color, + ev.timestamp, + ev.deck, + ev.height, + ev.width + ) + ) + break + case 'grid-added': + theTask.pinboard = blankPinboard(ev.height, ev.width, ev.spread) + break + case 'grid-removed': + theTask.pins = [] + theTask.pinboard = false + break + case 'grid-resized': + if (!theTask.pinboard) { + theTask.pinboard = blankPinboard(ev.height, ev.width) + } + theTask.pinboard.height = ev.height + theTask.pinboard.width = ev.width + theTask.pinboard.size = ev.size || 9 + unpinTasksOutOfBounds(tasks, theTask) + break + } +} + +export default { + aoMuts, + cashMuts, + membersMuts, + resourcesMuts, + memesMuts, + sessionsMuts, + tasksMuts, + POTENTIALS_TO_EXECUTE, +} diff --git a/ao-lib/semantics.ts b/ao-lib/semantics.ts new file mode 100644 index 0000000..6fdaa14 --- /dev/null +++ b/ao-lib/semantics.ts @@ -0,0 +1,132 @@ +const defaultSemantics = { + glossary: { + card: 'card', + user: 'member', + username: 'hackername', + proposal: 'proposition', + avatar: 'avatar', + }, + levels: { + 0: 'guest', + 1: 'member', + 2: 'elite member', + }, +} + +let loadedGlossary = {} +/* Too complex for .env, need a new solution, maybe use fs here to import custom JSON or YAML file +if (config.semantics && config.semantics.glossary) { + loadedGlossary = config.semantics.glossary +}*/ +const serverGlossary = { ...defaultSemantics.glossary, ...loadedGlossary } + +function pluralize(word) { + let plural = word + if (Array.isArray(plural)) { + plural = plural[1] + } else { + if (plural[plural.length - 1] === 's') { + plural = plural + 'es' + } else { + plural = plural + 's' + } + } + return plural +} + +export function capitalize(word) { + if (word.length < 1) { + return '' + } + return word[0].toUpperCase() + word.substring(1) +} + +// Returns the given word or string, with all instances of words in the glossary in configuration.js replaced with their gloss. +// In a multi-word string, it will correctly distinguish between all-lowercase keywords and those +// with their first letter capitalized, and replace them correctly. Original hardcoded keywords must be typed (in this codebase) +// in either all lowercase or with the first letter capitalized to be caught be the word replacement. +export function gloss(wordOrSentence, plural = false) { + let result + if (wordOrSentence.indexOf(' ') < 0) { + const word = wordOrSentence + + result = word + const lowercase = word.toLowerCase() + const pluralEntry = Object.entries(serverGlossary).find( + ([keyword, synonym]) => { + return ( + (Array.isArray(keyword) && keyword[1] === lowercase) || + pluralize(keyword) === lowercase + ) + } + ) + const singularEntry = Object.entries(serverGlossary).find( + ([keyword, synonym]) => + (Array.isArray(keyword) && keyword[0] === lowercase) || + keyword === lowercase + ) + if (pluralEntry || singularEntry) { + result = pluralEntry ? pluralize(pluralEntry[1]) : singularEntry[1] + if (Array.isArray(result)) { + result = result[0] + } + if (word[0].toLowerCase() !== word[0]) { + result = result[0].toUpperCase() + result.substring(1) + } + } + } else { + result = wordOrSentence + + Object.entries(serverGlossary).forEach(([keyword, synonym]) => { + // replace lowercase plural version of the keyword + const pluralKeyword = pluralize(keyword) + const pluralSynonym = pluralize(synonym) + let regexp = new RegExp('\\b' + pluralKeyword + '\\b', 'g') + result = result.replace(regexp, pluralSynonym) + + // replace capitalized plural version of the keyword + const pluralKeywordUppercase = capitalize(pluralKeyword) + const pluralSynonymUppercase = capitalize(pluralSynonym) + regexp = new RegExp('\\b' + pluralKeywordUppercase + '\\b', 'g') + result = result.replace(regexp, pluralSynonymUppercase) + + // replace lowercase singular version of the keyword + regexp = new RegExp('\\b' + keyword + '\\b', 'g') + const singularSynonym = Array.isArray(synonym) ? synonym[0] : synonym + result = result.replace(regexp, singularSynonym) + + // replace capitalized singular version of the keyword + const singularKeywordUppercase = capitalize(keyword) + const singularSynonymUppercase = capitalize(singularSynonym) + regexp = new RegExp('\\b' + singularKeywordUppercase + '\\b', 'g') + result = result.replace(regexp, singularSynonymUppercase) + }) + } + return result +} + +let loadedLevels = {} +/*if (config.semantics && config.semantics.levels) { + loadedLevels = config.semantics.levels +}*/ +const serverLevels = { ...defaultSemantics.levels, ...loadedLevels } + +export function glossLevel(level) { + if (level < 0) { + return null + } + let highestMatchingWord + Object.entries(serverLevels).some(([index, word]) => { + if (index <= level) { + highestMatchingWord = word + } + if (index >= level) { + return true + } + }) + return highestMatchingWord || 'member' +} + +export function getSemantics() { + return { glossary: serverGlossary, levels: serverLevels } +} diff --git a/ao-lib/settings.js b/ao-lib/settings.js new file mode 100644 index 0000000..41f0582 --- /dev/null +++ b/ao-lib/settings.js @@ -0,0 +1,120 @@ +import { execSync } from 'child_process' +import fs from 'fs' +import { parse, stringify } from 'envfile' + +export const AO_ENV_FILE_PATH = process.env.HOME + '/.ao/.env' + +function createAoFolderIfDoesNotExist() { + +} + +// Check for an AO env file at ~/.ao/.env and returns true if it exists +export function checkAoEnvFile() { + try { + execSync(`[ -f "${AO_ENV_FILE_PATH}" ]`) + return true + } catch(err) { + return false + } +} + +export function aoEnv(variable) { + let envFileContents = {} + try { + envFileContents = fs.readFileSync(AO_ENV_FILE_PATH) + } catch(err) { + if(err.code === 'ENOENT') { + //console.log('The .env file does not exist, so the requested value', variable, 'is empty.') + } else { + console.log('Unknown error loading .env file in aoEnv, aborting.') + } + return null + } + const parsedFile = parse(envFileContents) + + if(!parsedFile.hasOwnProperty(variable)) { + return null + } + + // Convert ENV idiom to programmatic types + switch(parsedFile[variable]) { + case '1': + case 'true': + case 'TRUE': + case 'yes': + case 'YES': + return true + case '0': + case 'false': + case 'FALSE': + case 'no': + case 'NO': + return false + } + + return parsedFile[variable] +} + +// Sets and saves the given ENV=value to the global ~/.ao/.env file +// If value is null, the env variable will be deleted +// Returns true if a change was made, false if no change was made or if it failed +export function setAoEnv(variable, value) { + createAoFolderIfDoesNotExist() + if(typeof variable !== 'string') { + console.log('ENV variable name must be a string for setAoEnv') + return false + } + + // Convert types to standard ENV file idiom + switch(value) { + case true: + case 'TRUE': + case 'yes': + case 'YES': + value = '1' + break + case false: + case 'FALSE': + case 'no': + case 'NO': + value = '0' + } + + let envFileContents = {} + try { + envFileContents = fs.readFileSync(AO_ENV_FILE_PATH) + } catch(err) { + if(err.code === 'ENOENT') { + console.log('The .env file hasn\'t been created yet, creating.') + } else { + console.log('Unknown error loading .env file in setAoEnv, aborting. Error:', err) + return false + } + } + + const parsedFile = parse(envFileContents) + if(parsedFile[variable] == value) { + console.log(variable, 'is already', value, 'so no change was made.') + return false + } + + if(value === null) { + delete parsedFile[variable] + } else { + parsedFile[variable] = value + } + + const stringified = stringify(parsedFile) + fs.writeFileSync(AO_ENV_FILE_PATH, stringified) + + // Confirm the variable was set in the .env file correctly + if(aoEnv(variable) != value) { + console.log('Value was not saved correctly, sorry.') + return false + } + return true +} + +function setAndSaveEnvironmentVariable(variable, value, path) { + +} diff --git a/ao-lib/types.ts b/ao-lib/types.ts new file mode 100644 index 0000000..e8a245c --- /dev/null +++ b/ao-lib/types.ts @@ -0,0 +1,351 @@ +// Every card has a color, right now only five preset colors exist (they can be renamed with the glossary and recolored with CSS) +export type Color = 'red' | 'yellow' | 'green' | 'purple' | 'blue' | 'black' + +// The regions or areas within a card +// There are four main zones within a card: the priorities, +// the optional pinboard (grid/pyramid/rune),the subTasks (main pile), and completed cards +export type CardZone = + | 'card' // The card itself, the whole card + | 'priorities' // The card's priorities section (right card drawer) + | 'grid' // A pinboard that can be added to the card (shows on card) + | 'subTasks' // The main pile of subcards within this card (bottom card drawer) + | 'completed' // Checked-off tasks archived when discarded (viewable in priorities card drawer) + | 'context' // The context area above a card that contains the card history (cards can be dragged from here) + | 'discard' // The background of the page behind the card, where cards can be dropped to discard + | 'panel' // Any other unspecified side panel where cards can be dragged out of (to copy, not move, the card) + | 'gifts' // The gifts area at the top left of the member card where cards you receive accumulate. + | 'stash' // The stashed cards area of the card (right card drawer) + +// A card's pinboard can be in one of three styles +export type PinboardStyle = 'grid' | 'pyramid' | 'rune' + +// The global left sidebar can open and display one of these tabs at a time +export type LeftSidebarTab = + | 'hub' + | 'gifts' + | 'guilds' + | 'members' + | 'calendar' + | 'bounties' + | 'manual' + | 'search' + | 'deck' + +// The global right sidebar can display one of these Bull tabs +export type RightSidebarTab = 'resources' | 'p2p' | 'crypto' | 'membership' + +// The right side of a card displays these tabs, which can be clicked to open the corresponding card drawer +export type CardTab = 'priorities' | 'timecube' | 'lightning' + +// When a member gifts/sends/passes a card to another member, the cards .pass array holds an array of passes +// The 0th element holds the memberId of the sender, and the 1st element holds the memberId of the recipient +export type CardPass = string[2] + +// Definition of an AO +export interface AoState { + session: string + token: string + loggedIn: boolean + user: string + ao: ConnectedAo[] + sessions: Session[] + members: Member[] + tasks: Task[] + resources: Resource[] + memes: Meme[] + socketState?: string + protectedRouteRedirectPath?: string + bookings?: Booking[] // Used on server to track calendar events on a timer + cash: { + address: string + alias: string + currency: string + spot: number + rent: number + cap: number + quorum: number + pay_index: number + usedTxIds: number[] + outputs: Output[] + channels: Channel[] + info: SatInfo + } + loader?: { + token: string + session: string + connected: string + connectionError: string + reqStatus: string + lastPing: number + } +} + +// An AO serves its members, who each have an account on the AO server. +export interface Member { + type: 'member-created' // The event is added directly to the database so it has this as an artifact, could filter on member-created and remove here + name: string // The name of the member + memberId: string // The unique UUID of the member + address: string // ??? + active: number // The member's active status. Number increases each consecutive active month. + balance: number // Member's point balance + badges: [] // Badges that the member has collected + tickers: Ticker[] // Customizable list of crypto tickers on the member's right sidebar + info: {} // ??? + timestamp: number // When the member was created + lastUsed: number // Last time the member logged in, used a resource, or barked + muted: boolean // Whether the member has sound effects turned on or off (sound effects not currently implemented) + priorityMode: boolean // Whether the member has activated Priority Mode, which shows the first priority above its parent card + fob: string // The member can enter a fob ID number from a physical fob, saved here for when they tap + potentials: Signature[] // List of potential actions built up on the member (not currently in use) + banned: boolean // True if the member is currently banned (member continues to exist) + draft: string // The member's currently-saved draft (also saved on client) + tutorial?: boolean // Whether the member has completed the initial interactive tour of the AO + p0wned?: boolean // Whether the member has had their password reset (changes to false when they set it themselves) + phone?: string // Phone number used for Signal notifications +} + +// A member can create and collect cards. The words 'card' and 'task' are used as synonyms, because the AO is meant for action. +export interface Task { + taskId: string // ao-react: Random UUID | ao-3: CRC-32 hash of the content + name: string // The text of the card, the main content. Can be plain text, Markdown, or HTML and JavaScript (optimized for injection). + color: Color // Color of the card as a word | Future: Could be any color word or hex code paired with a word naming the color + deck: string[] // *Array of memberIds of members who grabbed and are holding the card in their deck + guild: string | boolean // Optional guild / pin / tag title for the card. This is editable (unlike cards currently). Guild cards are indexed in Guilds sidebar on left. (Value of 'true' could mean that guild name equals card text [not implemented yet].) + address: string // Mainnet bitcoin address for this card (generated by calling address) + bolt11?: string // Lightning network bitcoin address for this carde (generated by calling invoice-created) + payment_hash: string // + book?: Booking // Book/schedule this card as an event on the calendar + priorities: string[] // *Array of taskIds of cards prioritized within this card + subTasks: string[] // *Array of taskIds of cards within this card + completed: string[] // *Array of taskIds of checked-off completed cards within this cards. Cards saved here when discarded with any checkmarks on them. + pinboard?: Pinboard | null // *Pinboard object containing pinboard properties + pins?: Pin[] // *New way of doing the Grid, Pyramid, and upcoming Rune layouts for the card + parents: string[] // *List of this cards parents, ought to be kept updated by mutations. + claimed: string[] // Lists of taskIds who have checked this card (thus claiming the bounty) + claimInterval?: number // Automatic uncheck timer in milliseconds [this feature will change to uncheck the cards within] + //uncheckInterval // Rename of claimInterval to be rolled out + uncheckThisCard?: boolean // Unchecks this card every uncheckInterval if true + uncheckPriorities?: boolean // Unchecks prioritized cards every uncheckInterval if true + uncheckPinned?: boolean // Unchecks pinned cards every uncheckInterval if true (maybe could combine with uncheckPriorities) + dimChecked?: boolean // If true, checked cards on the pinboard and in the priorities list will display visually dimmed to make tasking easier + signed: Signature[] // Members can explicitly sign cards to endorse them (future option to counter-sign as -1 is already built-in) + passed: string[][] // Array of [senderMemberId, receiverMemberId] pairs of pending gifts sent to the receiving member. Cleared when opened. + giftCount?: number // Count of unopened gift cards that ought to be kept automatically updated, for showing this number ot other members + lastClaimed: number // The last time someone checked this card off (Unix timestamp) + allocations: Allocation[] // List of points temporarily allocated to this card from parent cards, making this card a claimable bounty + boost: number // Bonus points on the card (?) + goal?: number // Optional points goal shows after the current number of points on a card, e.g., 8/10 points raised in the crowdfund. + highlights: number[] + seen: UserSeen[] // Array of events marking the first (?) or most recent (?) time they looked at the card. Used for unread markers. + timelog?: LabourTime[] // Arary of timelog events on the card + created: number // When the card was created (Unix timestamp) + showChatroom?: boolean // Whether or not to show the chatroom tab. Only allowed on cards with a .guild set for simplicity and transparency's sake. + avatars?: AvatarLocation[] // When a member joins a chatroom, it shows they are "at" that card. | Future: Little avator icons that can be moved from card to card or clicked to follow. + memberships?: Membership[] // Members can "join" a card as members. The first member is automatically Level 2 and can boss the Level 1's around. You can decrease your level and lose your power. + showStash?: boolean // Whether or not to show the stash tab. Only allowed on cards with a .guild set for simplicity and transparency's sake. + stash?: { + // *Stash of more cards associated with this card. Members have access to stashes of their level and below. + [key: number]: string[] // Each numbered stash level contains a list of taskIds of cards stored in that stash level. + } + unionHours?: number // Number of estimated hours for the task + unionSkill?: number // Skill level required for the task (0-5) + unionHazard?: number // Hazard level for the task (0-5) + loadedFromServer?: boolean // True if the card has been loaded from the server, false if empty placeholder taskId object + stars?: number // Can be simple number or later a Rating[] + touches?: number // New feature to count number of time a card was handled, to identify popular cards and personal hotspots. + aoGridToolDoNotUpdateUI?: boolean // Rendering hack, maybe this can be improved and removed + // *These properties contain taskIds of cards that are within or closely associated with this card, for the purposes of search, content buffering, etc. +} + +// A booked/scheduled event or resource +export interface Booking { + memberId: string // The member that scheduled the event (?) + startTs: number // The start of the event (Unix timestamp) + endTs: number // The end time of the event. Optional—but if omitted behavior is undefined. (Unix timestamp) +} + +// An AO can connect to another AO over tor to send cards +export interface ConnectedAo { + name?: string + address: string + outboundSecret: false | string + inboundSecret: string + lastContact: number + links: string[] +} + +// Hardware devices can be connected to the AO as resources over LAN. Resources can be activated in the Bull (right sidebar). +export interface Resource { + resourceId: string // UUID of the resource + name: string + charged: number // How many points it costs to use the resource each time + secret: string // ??? + trackStock: boolean // If true, the resource will track its inventory + stock: number // Simple numeric tracking of inventory stock, works for most things +} + +// Files detected in the ~/.ao/memes folder are each loaded as a Meme +export interface Meme { + memeId: string // UUID that matches the corresponding taskId of the card that is created in lockstep with the Meme object. + filename: string // Just the filename and extension, not the path + hash: string // Hash of the file + filetype: string +} + +// Cordinates of a card on a pinboard or in a list of cards +export interface Coords { + x?: number + y: number +} + +// Specifies a card taskId at a given location at a specific location within another card. +// For pinboard locations, .coords must coordinates for the current pinboard type +// For stashed cards, .level specifies which stash level the card is stored in +export interface CardLocation { + taskId?: string // Optional because sometimes a CardLocation is used to described a location where a card will be played/placed + inId?: string + zone?: CardZone // Optional because sometimes a CardLocation describes a card that is not in any location yet + level?: number + coords?: Coords +} + +// An atomic card play, defining a card to remove (from) and a card to place (to) +export interface CardPlay { + from: CardLocation + to: CardLocation +} + +// Defines the dimensions and other properties of a spread or layout of cards. +// Could be expanded or inherited from to create new types of spreads such as a freeform canvas or non-euclidian pinboard. +export interface Pinboard { + spread: PinboardStyle + height: number + width?: number + size: number // Size of squares, roughly in ems +} + +// A card pinned to a pinboard +// Pinboards set to rune layout only use x (y always 0) +export interface Pin { + taskId: string + y: number + x?: number +} + +// A guild can temporarily allocate some of its points to a task in its priorities +// Anyone who checks off the task will claim the points on it, including all its allocations +// Allocated points are actually moved from the parent card at the time (in state) and moved back if the card is deprioritized +// All mutations related to moving points around must be perfect to prevent double spend issues +export interface Allocation { + type?: string + taskId: string + allocatedId: string + amount: number + blame?: string +} + +// A member may sign a card with a positive (1, default), neutral (0), or opposing (-1) opinion, or instead sign with a note/comment +// This can be used for votes, upvotes/downvotes (and maybe reaction emojis? reactions could replace signing) +export interface Signature { + memberId: string + timestamp: Date + opinion: number | string +} + +// A member may rate a card 0-5 stars +export interface Rating { + memberId: string + stars: number +} + +// A card is marked when a member sees it for the first time. Used to mark unread cards (feature currently inactive). +export interface UserSeen { + memberId: string + timestamp: Date +} + +// Log of one duration of time to the timelog on a card +export interface LabourTime { + memberId: string + taskId: string + inId: string + start: number + stop: number +} + +// Each member has an "avatar" (currently no icon yet) that can be placed on a card to show your location to other members. +// When you join a chatroom on a card, you automatically hop your avatar to that card (task-visited event). +export interface AvatarLocation { + memberId: string + timestamp: number + area: number +} + +// Members can join guilds. Members of a guild have a numeric level. +// Each level has a stash of cards that only members of that level or above can edit. +// Members of higher levels can change the level of members with the same or lower levels. +// The system is stupid and you can increase your own level too low and lose your power, or too high and mess up the stash. +export interface Membership { + memberId: string + level: number +} + +// Members can add tickers in the sidebar, which show the exchange rate between a 'from' and 'to' currency (three-letter currency abbreviation) +export interface Ticker { + from: string + to: string +} + +export interface Output { + value: number +} + +export interface Channel { + channel_sat: number + channel_total_sat: number +} + +// A browser session object +export interface Session { + type: 'session-created' // Event is added directly to the database, this is an artifact of that + session: string // Session string? + ownerId: string // MemeberId of the session owner + timestamp: Date // When the session was created +} + +export interface LightningChannel { + peer_id?: any + funding_txid?: any + state?: any + connected?: boolean + channel_total_sat: number + channel_sat: number +} + +export interface SatInfo { + channels?: LightningChannel[] + mempool?: { sampleTxns: any[]; size: any; bytes: any } + blockheight?: number + blockfo?: any + id?: any + outputs?: any[] + address?: { address: string }[] +} + +export interface SearchResults { + query: string + page: number + missions: Task[] + members: Task[] + tasks: Task[] + all: Task[] + length: number +} + +export const emptySearchResults = { + missions: [], + members: [], + tasks: [], + all: [], + length: 0, +} diff --git a/ao-lib/util.js b/ao-lib/util.js new file mode 100644 index 0000000..0ee71a3 --- /dev/null +++ b/ao-lib/util.js @@ -0,0 +1,57 @@ +// General helper functions + +export const cancelablePromise = promise => { + let isCanceled = false + + const wrappedPromise = new Promise((resolve, reject) => { + promise.then( + value => (isCanceled ? reject({ isCanceled, value }) : resolve(value)), + error => reject({ isCanceled, error }) + ) + }) + + return { + promise: wrappedPromise, + cancel: () => (isCanceled = true), + } +} + +export const noop = () => {} + +export const delay = (n = 550) => new Promise(resolve => setTimeout(resolve, n)) + +export const isObject = obj => { return Object.prototype.toString.call(obj) === '[object Object]' } + +export const convertToDuration = (milliseconds) => { + const stringifyTime = (time) => String(time).padStart(2, '0') + const seconds = Math.floor(milliseconds / 1000) + const minutes = Math.floor(seconds / 60) + const hours = Math.floor(minutes / 60) + return `${stringifyTime(hours)}:${stringifyTime( + minutes % 60 + )}:${stringifyTime(seconds % 60)}` +} + +export const convertToTimeWorked = (milliseconds) => { + const seconds = Math.floor(milliseconds / 1000) + const minutes = Math.floor(seconds / 60) + const hours = Math.floor(minutes / 60) + + if (hours > 0) { + return `${hours}h, ${minutes % 60}m` + } else { + return `${minutes % 60}m` + } +} + + // Returns a random int between min and max (inclusive) +export function randomInt(min, max) { + min = Math.ceil(min); + max = Math.floor(max); + return Math.floor(Math.random() * (max - min + 1)) + min; +} + +// Returns a random item from the given array +export function selectRandom(arrayToChooseFrom) { + return arrayToChooseFrom[randomInt(0, arrayToChooseFrom.length - 1)] +}