diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..defe718 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "ao-lib"] + path = ao-lib + url = http://git.coalitionofinvisiblecolleges.org:3009/autonomousorganization/ao-lib.git diff --git a/index.js b/index.js index 4bd3c5d..02e4746 100644 --- a/index.js +++ b/index.js @@ -4,7 +4,7 @@ import { execSync } from 'child_process' // Import ao-cli core features import { exitIfRoot, detectOS, updateSoftware } from './scripts/system.js' -import { checkAoEnvFile, aoEnv, setAoEnv, AO_ENV_FILE_PATH } from './scripts/settings.js' +import { checkAoEnvFile, aoEnv, setAoEnv, AO_ENV_FILE_PATH } from './ao-lib/settings.js' import { unicornPortal, asciiArt, clearScreen } from './scripts/console.js' import { welcome, exclaim, farewell, yesOrNo, promptMenu } from './scripts/welcome.js' import wander from './scripts/forest.js' @@ -12,7 +12,7 @@ import useAoMenu from './scripts/ao.js' import aoInstallWizard, { chooseAoVersion, checkAo } from './scripts/wizard.js' import testsMenu from './scripts/tests.js' import { headerStyle } from './scripts/styles.js' -import { sleep, randomInt } from './scripts/util.js' +import { randomInt } from './ao-lib/util.js' import './scripts/strings.js' // Import AO modular features @@ -20,6 +20,8 @@ import features, { featuresMenu } from './scripts/features/index.js' import manual, { printManualPage, manualFolderAsMenu, AO_MANUAL_PATH } from './scripts/features/manual.js' import aoCli from './scripts/features/ao-cli.js' +const sleep = (ms = 550) => { return new Promise((r) => setTimeout(r, ms)) } + // Prints the AO Main Menu and executes the user's choice async function mainMenu() { //console.log(`\n${headerStyle('AO Main Menu')}\n`) diff --git a/scripts/ao.js b/scripts/ao.js index c19f633..2e22432 100644 --- a/scripts/ao.js +++ b/scripts/ao.js @@ -2,10 +2,10 @@ // The ao-cli client has no store so it makes frequent (hopefully very quick) calls to get exactly the information it needs. // This requires us to make sure the AO API server's REST API is concise and efficient. // The only places ao-cli should call out to an AO server (i.e., use the API in api.js) are in this Use AO menu and in the Tests menu. -import { aoEnv } from './settings.js' +import { aoEnv } from '../ao-lib/settings.js' import { isLoggedIn, loginPrompt, logout } from './session.js' import { getTopPriorityText } from './priority.js' -import { AO_DEFAULT_HOSTNAME } from './api.js' +import { AO_DEFAULT_HOSTNAME } from '../ao-lib/api.js' import { headerStyle } from './styles.js' import { cardMenu } from './cards.js' import { connectMenu } from './connect.js' @@ -15,7 +15,6 @@ import { promptMenu } from './welcome.js' // Prints the Use AO Menu and executes the user's choice. Using the AO as a client occurs only under this menu item (except Tests menu). export default async function useAoMenu() { const loggedIn = isLoggedIn() - console.log(`\n${headerStyle('AO')}\n`) if(loggedIn) { console.log('Logged in as:', aoEnv('AO_CLI_SESSION_USERNAME')) const topPriority = await getTopPriorityText() @@ -30,20 +29,20 @@ export default async function useAoMenu() { aoMenuChoices.push( 'Deck', 'Chat', - 'Connect', + 'P2P', ) } aoMenuChoices.push( loggedIn ? 'Log Out' : 'Log In', 'Back to Main Menu' ) - const answer = await promptMenu(aoMenuChoices, 'Use AO features', 'connect to an AO server to use AO features') + const answer = await promptMenu(aoMenuChoices, 'AO', 'connect to an AO server to use AO features') switch(answer) { case 'Deck': //await todoList('My Todo List', ['Add full AO install process to ao-cli in convenient format', 'Add AO server unit tests to ao-cli', 'Get groceries', 'Play music every day']) while(await cardMenu()) {} break - case 'Connect': + case 'P2P': while(await connectMenu()) {} break case 'Chat': diff --git a/scripts/api.js b/scripts/api.js deleted file mode 100644 index 4b875e6..0000000 --- a/scripts/api.js +++ /dev/null @@ -1,1125 +0,0 @@ -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: taskId, - subTaskId, - 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) { - const act = { - type: 'task-prioritized', - taskId: taskId, - inId: inId, - position: position, - ...(echelon && { echelon: echelon }), - blame: currentMemberId - } - console.log(act) - return await postEvent(act) -} - -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 titleMissionCard(taskId, newTitle) { - return await postEvent({ - type: 'task-guilded', - taskId: taskId, - guild: newTitle, - blame: currentMemberId - }) -} - -// 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/scripts/cards.js b/scripts/cards.js index 0aff672..67af6a7 100644 --- a/scripts/cards.js +++ b/scripts/cards.js @@ -1,25 +1,34 @@ // Cards module - everything related to cards should go here (database install is automatic for AO server so no feature module) -import { getCardByName, createCard, prioritizeCard } from './api.js' +import { getCardByName, createCard, prioritizeCard } from '../ao-lib/api.js' import { headerStyle } from './styles.js' import { prioritiesMenu } from './priority.js' -import { aoEnv } from './settings.js' +import { subcardsMenu } from './hand.js' +import { aoEnv } from '../ao-lib/settings.js' import { promptMenu, askQuestionText } from './welcome.js' +import { tagsMenu } from './tags.js' // The card menu is complex and so has been split into this separate file export async function cardMenu() { const cardChoices = [ { title: 'Top priorities', value: 'priorities', short: 'priorities' }, // hand? (7) (add #s in parens) { title: 'Cards in hand', value: 'subcards', short: 'hand' }, // (current) deck? (60) - { title: 'Browse full deck', value: 'browse', short: 'browse' }, // archive? (10,000) + { title: 'Tags', value: 'tags', short: 'tags' }, // magically abstracting hierarchical nametags + //{ title: 'Browse full deck', value: 'search', short: 'browse' }, // archive? (10,000) // will become search-as-you-type for card 'Back to AO Menu' ] const answer = await promptMenu(cardChoices, 'My Deck') - switch(answer.card_menu) { + switch(answer) { case 'priorities': while(await prioritiesMenu()) {} break case 'subcards': - while(await subcardsMenu()) {} + let previousAnswer + do { + previousAnswer = await subcardsMenu(undefined, previousAnswer) // undefined taskId defaults to member card + } while(previousAnswer !== false) {} + break + case 'tags': + while(await tagsMenu()) {} break case 'browse': while(await browseMenu()) {} @@ -30,27 +39,23 @@ export async function cardMenu() { return true } -async function subcardsMenu() { - console.log('Not yet implemented') -} - async function browseMenu() { console.log('Not yet implemented') } // Ask the user to create a card, checks if it already exists, and then creates it if it doesn't -export async function createCardInteractive(prioritized = true) { +export async function createCardInteractive(prioritized = false) { const memberId = aoEnv('AO_CLI_SESSION_MEMBERID') if(!memberId) { console.log('Not logged in.') return false } const answer = await askQuestionText('New card or Enter to end:') - if(answer.new_card_text.trim().length <= 0) { + if(answer === false || answer.trim().length <= 0) { return false } // Check if the card alerady exists - const fetchedCards = await getCardByName(answer.new_card_text, false) + const fetchedCards = await getCardByName(answer, false) if(fetchedCards && fetchedCards.length >= 1) { if(fetchedCards.length >= 2) { console.log('More than one copy of this card was found. This should not happen.') @@ -61,10 +66,12 @@ export async function createCardInteractive(prioritized = true) { if(!prioritizeResult.ok) { console.log('May have failed to prioritize card:', prioritizeResult) } + } else { + // todo: subtask the card } - return false + return answer } - console.log('card does not exist yet. creating...', answer.new_card_text) - const result = await createCard(answer.new_card_text, false, true) - return answer.new_card_text + console.log('card does not exist yet. creating...', answer) + const result = await createCard(answer, false, prioritized) + return answer } diff --git a/scripts/connect.js b/scripts/connect.js index 6d66532..6b8bcba 100644 --- a/scripts/connect.js +++ b/scripts/connect.js @@ -1,9 +1,9 @@ // Each AO API server can connect peer-to-peer over Tor. Tor addresses are unique and data is end-to-end encrypted. import { headerStyle } from './styles.js' -import { aoEnv, setAoEnv } from './settings.js' +import { aoEnv, setAoEnv } from '../ao-lib/settings.js' import { isLoggedIn } from './session.js' import { isInstalled } from './features/tor.js' -import { connectToAo, getAoBootstrapList, bootstrap } from './api.js' +import { connectToAo, getAoBootstrapList, bootstrap } from '../ao-lib/api.js' import { roger, promptMenu } from './welcome.js' // Prints a menu to connect your AO to other AOs and manage connections diff --git a/scripts/console.js b/scripts/console.js index f8507af..a67999d 100644 --- a/scripts/console.js +++ b/scripts/console.js @@ -3,8 +3,7 @@ import chalkAnimation from 'chalk-animation' import gradient from 'gradient-string' import figlet from 'figlet' import { createSpinner } from 'nanospinner' -import { sleep } from './util.js' -import { selectRandom } from './util.js' +import { delay as sleep, selectRandom } from '../ao-lib/util.js' import { centerLines } from './strings.js' // Displays a brief randomly-selected rainbow-animated phrase @@ -43,4 +42,4 @@ export function spinner(waitingMessage = 'Please wait...', doneMessage = 'Done.' return (doneMessageOverwrite = null) => { spinner.success({text: doneMessageOverwrite || doneMessage}) } -} \ No newline at end of file +} diff --git a/scripts/crypto.js b/scripts/crypto.js deleted file mode 100755 index bcf85ca..0000000 --- a/scripts/crypto.js +++ /dev/null @@ -1,30 +0,0 @@ -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') -} \ No newline at end of file diff --git a/scripts/forest.js b/scripts/forest.js index 926acbb..550c437 100644 --- a/scripts/forest.js +++ b/scripts/forest.js @@ -1,5 +1,5 @@ // Greeting text functions that can be hooked into your cd function so that moving between folders becomes an experience of the AO -import { selectRandom, randomInt } from './util.js' +import { selectRandom, randomInt } from '../ao-lib/util.js' import { isLoggedIn } from './session.js' import { getTopPriorityText } from './priority.js' diff --git a/scripts/hand.js b/scripts/hand.js new file mode 100644 index 0000000..84b66c9 --- /dev/null +++ b/scripts/hand.js @@ -0,0 +1,178 @@ +// View and create cards with the .subtask of other cards, which is an array of taskIds +import { headerStyle } from './styles.js' +import { aoEnv } from '../ao-lib/settings.js' +import { getCard, playCard, completeCard, uncheckCard, discardCardFromCard, prioritizeCard, grabCard, dropCard } from '../ao-lib/api.js' +import { getNewHighestEchelonScore } from './priority.js' +import { createCardInteractive } from './cards.js' +import { promptMenu } from './welcome.js' +import { tagCardInteractive, viewTagMenu } from './tags.js' + +// Displays the subtasks of the given taskId in a menu. Selecting a card shows a menu for that card. If taskId is null, member card is used. +// The terms tasks and cards are used mostly interchangeably in the code. For the user, 'subcards' is preferred for clarity/generality, +// but can be used to refer either to the .subTasks or all of the cards in another card (including subTasks, priorities, completed, and pinned) +export async function subcardsMenu(taskId = null, previousIndex = null) { + console.log(`\n${headerStyle('Cards in My Hand')}`) + let subtaskChoices = [] + + const memberId = aoEnv('AO_CLI_SESSION_MEMBERID') + if(!memberId) { + console.log('Not logged in.') + return false + } + if(!taskId) { + // Get the subtasks of my member card + taskId = memberId + } + const fetchedCards = await getCard(taskId, 'subcards') // will fetch both priorities and subtasks in one array + if(!fetchedCards || fetchedCards.length < 1) { + console.log('Failed to fetch the specified card, this is bad.') + return false + } + const card = fetchedCards[0] // first card is the requested card itself + // Separate fetched cards into correct ordered list of priorities and subtasks + let priorityCards = card.priorities.map((priorityTaskId, i) => { + const priorityCard = fetchedCards.find(p => p.taskId === priorityTaskId) + if(!priorityCard) { + return 'Missing card, repair your database' + } + return priorityCard + }) + priorityCards.reverse() + const subtaskCards = card.subTasks.map((subtaskTaskId, i) => { + const subtaskCard = fetchedCards.find(st => st.taskId === subtaskTaskId) + if(!subtaskCard) { + return 'Missing card, repair your database' + } + return subtaskCard + }) + console.log('There are', subtaskCards.length, 'subcards in this card') + subtaskChoices = subtaskCards.map((subtaskCard, i) => { + const shortenedName = subtaskCard.name.substring(0, 70) + (subtaskCard.name.length >= 70 ? '...' : '') + return { + title: shortenedName, + value: { index: i, card: subtaskCard }, + short: shortenedName + } + }) + subtaskChoices.push( + { title: 'Play card here', value: 'create_here', short: 'new card' }, + { title: 'Back Up', value: false, short: 'back' } + ) + const cardName = taskId === memberId ? 'My Hand' : card.name.substring(0, 70).toTitleCase() + const answer = await promptMenu(subtaskChoices, 'Cards in ' + cardName, undefined, previousIndex) + switch(answer) { + case false: + return false + case 'create_here': + let previousCardCreatedText + do { + previousCardCreatedText = await createCardInteractive() + console.log('done with round') + } while(previousCardCreatedText != '\n') + console.log('returning true') + return answer.index + case 'Missing card, repair your database': + console.log('Database repair yet implemented, sorry.') + return answer.index + } + if(answer === false) { + return previousIndex + } + let chosenTask = answer.card + const chosenTaskId = chosenTask.taskId + let previousAnswer + do { + previousAnswer = await subtaskCardMenu(chosenTask, answer.index, taskId, priorityCards) // send priorities for echelon info in case they upboat + if(previousAnswer) { + const fetchedCards = await getCard(chosenTaskId, false) + if(!fetchedCards || fetchedCards.length < 1) { + console.log('The card has disappeared. Maybe it was deleted, or cards held by no one are automatically cleaned up every five minutes.') + return false + } + chosenTask = fetchedCards[0] + } + } while(previousAnswer !== false) + return answer.index +} + +// Short action-oriented menu for cards in the subtasks list +// Index is the position of the card in the list that it is in, used for fencepost case to display upboat contextually +// inId is the taskId of the parent card that we are in contextually as we look at the given card in its list +// allPriorities is an array of task objects for the other priorities in the .priorities for the card this card is in (adjacent to this card) +async function subtaskCardMenu(card, index, inId, allPriorities) { + if(!card) { + console.log('subtaskCardMenu: card is required.') + return false + } + const taskId = card.taskId + const memberId = aoEnv('AO_CLI_SESSION_MEMBERID') + if(!memberId) { + console.log('Not logged in.') + return false + } + const isChecked = card.claimed.includes(memberId) + const guild = card.guild === true ? card.name.toTitleCase() : card.guild || false + let subtaskChoices = [] + const isInDeck = card.deck.includes(memberId) + if(!isInDeck) { + subtaskChoices.push({ title: 'Grab card (add to my deck)', value: 'grab', short: 'grab card' }) + } + subtaskChoices.push( + { title: 'Discard from hand (downboat)', value: 'downboat', short: 'downboat' }, + { title: 'Prioritize (upboat)', value: 'upboat', short: 'upboat' }, + { title: isChecked ? 'Uncheck' : 'Check off', value: 'check', short: 'check!' }, + { title: guild ? 'Tag: ' + guild : 'Tag', value: 'tag', short: 'tag' } + ) + if(guild) { + subtaskChoices.push({ title: 'View tag', value: 'view_tag', short: 'view tag' }) + } + if(isInDeck) { + subtaskChoices.push({ title: 'Remove from my deck', value: 'drop', short: 'drop card' }) + } + subtaskChoices.push( + { title: 'Browse within', value: 'browse', short: 'browse' }, + { title: 'Back Up', value: false, short: 'back' } + ) + const answer = await promptMenu(subtaskChoices, 'Card: ' + card.name) + switch(answer) { + case 'grab': + await grabCard(taskId) + break + case 'drop': + await dropCard(taskId) + break + case 'check': + if(isChecked) { + await uncheckCard(taskId) + } else { + await completeCard(taskId) + } + break + case 'tag': + await tagCardInteractive(card) + break + case 'view_tag': + while(await viewTagMenu(card.guild)) + break + case 'upboat': + console.log('upboat') + const { newPosition, newEchelonScore } = getNewHighestEchelonScore(card.echelon, allPriorities) + //console.log('newPosition is', newPosition, 'and newEchelonScore is', newEchelonScore) + await prioritizeCard(taskId, inId, newPosition, newEchelonScore) + return false + case 'downboat': + console.log(taskId, inId, 'discard') + await discardCardFromCard(taskId, inId) + return false + case 'browse': + let previousAnswer + do { + previousAnswer = await subcardsMenu(taskId, previousAnswer) // undefined taskId defaults to member card + } while(previousAnswer !== false) {} + break + break + default: + return false + } + return true +} diff --git a/scripts/priority.js b/scripts/priority.js index b87ea3f..14fb523 100644 --- a/scripts/priority.js +++ b/scripts/priority.js @@ -1,7 +1,7 @@ // Prioritize cards within other cards. Each card has a .priorities array of other taskIds. import { headerStyle } from './styles.js' -import { aoEnv } from './settings.js' -import { getCard, prioritizeCard, completeCard, uncheckCard, refocusCard } from './api.js' +import { aoEnv } from '../ao-lib/settings.js' +import { getCard, prioritizeCard, completeCard, uncheckCard, refocusCard } from '../ao-lib/api.js' import { createCardInteractive } from './cards.js' import { promptMenu } from './welcome.js' @@ -64,10 +64,11 @@ export async function prioritiesMenu(taskId = null) { if(!priorityCard) { return 'Missing card, repair your database' } + const shortenedName = priorityCard.name.substring(0, 70) + (priorityCard.name.length >= 70 ? '...' : '') return { - title: priorityCard.name, + title: shortenedName, value: { index: i, card: priorityCard }, - short: priorityCard.name.substring(0, 70) + priorityCard.name.length >= 70 ? '...' : '' + short: shortenedName } }) let firstIndexEchelonDecreases @@ -84,24 +85,24 @@ export async function prioritiesMenu(taskId = null) { } } prioritiesChoices.push( - { title: 'Create priority', value: 'create_here', short: 'new priority' }, + { title: 'Play priority', value: 'create_here', short: 'new priority' }, { title: 'Back to Deck', value: false, short: 'back' } ) - const answer = await promptMenu(prioritiesChoices, 'My Priorities', undefined, undefined, 'Upboated tasks will be inserted above this line') + const answer = await promptMenu(prioritiesChoices, 'My Priorities', undefined, undefined, 'Prioritized cards will be inserted above this line') switch(answer) { case false: return false case 'create_here': let previousCardCreatedText do { - previousCardCreatedText = await createCardInteractive() + previousCardCreatedText = await createCardInteractive(true) } while(previousCardCreatedText != '\n') return true case 'Missing card, repair your database': console.log('Database repair yet implemented, sorry.') return true } - let chosenTask = answer.priorities_menu.card + let chosenTask = answer.card const chosenTaskId = chosenTask.taskId let previousAnswer do { @@ -115,12 +116,12 @@ export async function prioritiesMenu(taskId = null) { chosenTask = fetchedCards[0] } } while(previousAnswer !== false) - console.log('Card menu not yet implemented.') return true } // Short action-oriented menu for cards in the priorities list // Index is the position of the card in the list that it is in, used for fencepost case to display upboat contextually +// allPriorities is an array of task objects for the other priorities in the priorities list this card is in (adjacent to this card) async function priorityCardMenu(card, index, allPriorities) { if(!card) { console.log('priorityCardMenu: card is required.') @@ -135,11 +136,11 @@ async function priorityCardMenu(card, index, allPriorities) { const isChecked = card.claimed.includes(memberId) let priorityChoices = [] if(index != 0) { - priorityChoices.push({ title: 'Upboat', value: 'upboat', short: 'upboat' }) + priorityChoices.push({ title: 'Prioritize (upboat)', value: 'upboat', short: 'upboat' }) } priorityChoices.push( { title: isChecked ? 'Uncheck' : 'Check off', value: 'check', short: 'check!' }, - { title: 'Downboat', value: 'downboat', short: 'downboat' }, + { title: 'Discard from priorities (downboat)', value: 'downboat', short: 'downboat' }, //{ title: 'Browse within', value: 'browse', short: 'browse' } { title: 'Back to Priorities', value: false, short: 'back' } ) @@ -153,48 +154,7 @@ async function priorityCardMenu(card, index, allPriorities) { } break case 'upboat': - console.log('upboat') - let firstEchelonScore - let newEchelonScore - let newPosition = 0 - console.log('upboat2') - console.log('card is', card) - //console.log(allPriorities) - breakHere: - for(let i = 0; i < allPriorities.length; i++) { - console.log('upboat3') - const priority = allPriorities[i] - console.log('priority is', priority) - if(i === 0) { - console.log('upboat3.1') - firstEchelonScore = priority.echelon - console.log('upboat3.11115', priority.name, priority.echelon, typeof priority.echelon) - if(isNaN(firstEchelonScore)) { - console.log('upboat3.2') - newEchelonScore = 1 - break breakHere - } - if(!card.echelon || card.echelon < priority.echelon) { - console.log('upboat3.3') - newEchelonScore = priority.echelon - } else if(card.echelon && priority.echelon && card.echelon === priority.echelon) { - console.log('upboat3.4') - newEchelonScore = priority.echelon + 1 - break breakHere - } else if(card.echelon && priority.echelon && card.echelon > priority.echelon) { - console.log('upboat3.5') - break breakHere - } - } - console.log('upboat4') - if(priority.echelon !== firstEchelonScore) { - newPosition = i - break breakHere - } - console.log('upboat5') - } - console.log('upboat6') - console.log('newPosition is', newPosition, 'and newEchelonScore is', newEchelonScore) + const { newPosition, newEchelonScore } = getNewHighestEchelonScore(card.echelon, allPriorities) await prioritizeCard(taskId, memberId, newPosition, newEchelonScore) return false case 'downboat': @@ -207,3 +167,41 @@ async function priorityCardMenu(card, index, allPriorities) { } return true } + +// Calculates and returns what the echelon score should be to prioritize a priority with the given echelon score to the correct place +// in tnhe given list of tasks. +// newPriorityEchelonScore is the .echolon of the task object about to be prioritized +// allPriorities is an array of task objects for the other priorities in the priorities list this card is in (adjacent to this card) +export function getNewHighestEchelonScore(newPriorityEchelon, allPriorities) { + let firstEchelonScore + let newEchelonScore + let newPosition = 0 + for(let i = 0; i < allPriorities.length; i++) { + const priority = allPriorities[i] + if(i === 0) { + firstEchelonScore = priority.echelon + if(isNaN(firstEchelonScore)) { + // Top priority does have a (valid) echelon score, so echelon = 0 and the new echelon score should be 1 + newEchelonScore = 1 + break + } + if(!newPriorityEchelon || newPriorityEchelon < priority.echelon) { + // Prioritized card does not have an echelon score or it is less than the current priority's echelon score (so keep going to find insert position) + newEchelonScore = priority.echelon + //break + } else if(newPriorityEchelon && priority.echelon && newPriorityEchelon === priority.echelon) { + // Echelon of new priority is same as top priority, so increase it by one (and it goes to top) + newEchelonScore = priority.echelon + 1 + break + } else if(newPriorityEchelon && priority.echelon && newPriorityEchelon > priority.echelon) { + // Echelon of new priority is already greater than the new priority, so its echelon doesn't change (and it goes to top) + break + } + } + if(priority.echelon !== firstEchelonScore) { + newPosition = i + break + } + } + return { newPosition, newEchelonScore } +} diff --git a/scripts/services.js b/scripts/services.js index 9baf31e..9fd8f6e 100644 --- a/scripts/services.js +++ b/scripts/services.js @@ -2,7 +2,7 @@ import { execSync } from 'child_process' import path from 'path' import fs from 'fs' -import { aoEnv, setAoEnv } from './settings.js' +import { aoEnv, setAoEnv } from '../ao-lib/settings.js' import { isFile } from './files.js' import { askQuestionText } from './welcome.js' diff --git a/scripts/session.js b/scripts/session.js index ca3cf0b..7b5b6a8 100644 --- a/scripts/session.js +++ b/scripts/session.js @@ -1,5 +1,5 @@ -import { createSession, logout as apiLogout } from './api.js' -import { aoEnv, setAoEnv } from './settings.js' +import { createSession, logout as apiLogout } from '../ao-lib/api.js' +import { aoEnv, setAoEnv } from '../ao-lib/settings.js' import { askQuestionText } from './welcome.js' // Returns true if there is a session cookie for ao-cli saved in the AO .env file (=ready to make session requests) diff --git a/scripts/settings.js b/scripts/settings.js deleted file mode 100644 index 41f0582..0000000 --- a/scripts/settings.js +++ /dev/null @@ -1,120 +0,0 @@ -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/scripts/shadowchat.js b/scripts/shadowchat.js index 6ab6f86..94544c0 100644 --- a/scripts/shadowchat.js +++ b/scripts/shadowchat.js @@ -1,19 +1,21 @@ // AO shadowchat feature menu including bootstrap network server list browser, chatroom list on each server, and chatroom interface // Called shadowchat because no record is kept of the chat messages, and all connections happen E2E over tor // As this feature gets build, sensible standards must be developed around when tor addresses change hands, when users authenticate, etc -import { aoEnv } from './settings.js' +import { aoEnv } from '../ao-lib/settings.js' import { isLoggedIn } from './session.js' import { startPublicBootstrap } from './bootstrap.js' import { headerStyle } from './styles.js' -import { sleep } from './util.js' import { askQuestionText, promptMenu } from './welcome.js' -import { AO_DEFAULT_HOSTNAME, startSocketListeners, socketStatus, socket, shadowchat } from './api.js' +import { AO_DEFAULT_HOSTNAME, startSocketListeners, socketStatus, socket, shadowchat } from '../ao-lib/api.js' +import { execSync } from 'child_process' import { fileURLToPath } from 'url' import path from 'path' const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) +const sleep = (ms = 550) => { return new Promise((r) => setTimeout(r, ms)) } + // Prints a menu that allows you to join the global AO chatrooms export default async function chatMenu() { let answers = {} @@ -64,7 +66,8 @@ export default async function chatMenu() { while(await browseChatrooms()) {} break case 'join_chat': - console.log('Not yet implemented') + console.log('Launching Simplex Chat...') + execSync('bash simplex-chat') break case 'Address Book': console.log('The point of this address book is to make it possible to type short, one-word names and have them resolve to tor addresses.') diff --git a/scripts/tags.js b/scripts/tags.js new file mode 100644 index 0000000..1ba83c4 --- /dev/null +++ b/scripts/tags.js @@ -0,0 +1,157 @@ +// Also called guilds, groups, or the title of a card, the .guild field of a card can hold a string that is 'about' the card, or true (and the tag is then the card .name itself) +// Right now one tag can be added to each card. Maybe it should be multiple, with tag #1 being the main/title tag +// When viewing a card, it should show the tag in the menu, or if not tagged, the option to tag it (with setTagInteractive) +// If the tag is set, an option should appear to view the tag (menu) +// When viewing a tag, it should be possible tag the tag. This creates the tag as a card with the new tag as its tag +// When viewing a tag, it should be possible to view all the cards tagged by that tag +// Because tags are implemented as part of cards, to put a tag in another tag, we make a card for that tag and tag it (give it a .guild) +import { askQuestionText, promptMenu } from './welcome.js' +import { tagCard, getCardsForTag, setCardProperty, getCardByName, createCard } from '../ao-lib/api.js' + +// Tells the user the current guild/tag of the specified card, and allows them to set a new one (or blank to leave unchanged) +export async function tagCardInteractive(card) { + if(card.guild === true) { + } else if(card.guild) { + console.log('This card\'s tag is:') + console.log(card.guild) + } else { + console.log('This card is not yet tagged.') + } + const answer = await askQuestionText('Type new tag (or Enter/ESC to cancel):') + if(!answer || answer === 'ESC' || answer == '') { + return + } + await tagCard(card.taskId, answer) +} + +// The tags index displays the tag of every card with a tag, sorted by echelon, then by we'll see! +export async function tagsMenu() { + const allTaggedCards = await getCardsForTag('*') + + // Build a pure list of tags (since we don't do this in the database) + const tags = {} + allTaggedCards.forEach(card => { + const guild = card.guild === true ? card.name.toTitleCase() : card.guild + if(guild.includes('test')) console.log('guild:', guild, 'card is', card) + //console.log('guild is', guild) + // Skip any card that is within another tagged card or tagged with the name of another tag + if(allTaggedCards.some(t => { + if(t.taskId === card.taskId) return false + if(t.priorities.concat(t.subTasks, t.completed).includes(card.taskId)) { + if(guild.includes('test')) console.log('reason 1, t is', t) + + return true + } + if(t.pins && t.pins.some(pin => pin.taskId === card.taskId)) { + if(guild.includes('test')) console.log('reason 2') + + return true + } + if(card.taskId !== t.taskId && guild === card.name && card.guild && (card.echelon || 0) < (t.echelon || 0)) { + if(guild.includes('test')) console.log('reason 3') + + return true + } + return false + })) { + return + } + + if(!tags.hasOwnProperty(guild)) { + tags[guild] = card.echelon + return + } + if(!tags[guild] || tags[guild] < card.echelon) { + tags[guild] = card.echelon + } + }) + + let tagChoices = Object.entries(tags).sort((entryA, entryB) => { + return (entryB[1] || 0) - (entryA[1] || 0) + }) + + tagChoices = tagChoices.map((entry, i) => { + const [tag, echelon] = entry + const paddedEchelon = echelon >= 0 ? echelon.toString().padEnd(4) : ' ' + return { title: paddedEchelon + tag, value: tag, short: 'view tag' } + }) + tagChoices.push({ title: 'Back to Deck Menu', value: false, short: 'back' }) + const answer = await promptMenu(tagChoices, 'All Tags') + console.log('answer:', answer) + if(!answer) { + return false + } + await viewTagMenu(answer, allTaggedCards) + return true +} + +// Prints the menu for viewing a single tag, with options to view cards tagged by the tag, or to tag the tag +export async function viewTagMenu(tag, allTaggedCards) { + const tagChoices = [ + { title: 'Prioritize tag', value: 'upboat', short: 'prioritize tag' }, + { title: 'Cards tagged \'' + tag + '\'', value: 'tagged_cards', short: 'view tagged cards' }, // (current) deck? (60) + { title: 'Tag this tag', value: 'tagtag' }, // hand? (7) (add #s in parens) + 'Back to Tags Index' + ] + const answer = await promptMenu(tagChoices, 'My Deck') + switch(answer) { + case 'upboat': + await tagToTopEchelon(tag, allTaggedCards) + return false + case 'tagged_cards': + while(await cardsTaggedByMenu(tag)) {} + break + case 'tagtag': + await tagTagInteractive(tag, allTaggedCards) + break + default: + return false + } + return true +} + +// Finds the first card with the given tag and sets its echelon score to the highest + 1 of all tagged cards +async function tagToTopEchelon(tag, allTaggedCards) { + let highestEchelonScore = 0 + allTaggedCards.forEach(card => { + if(card.echelon > highestEchelonScore) { + highestEchelonScore = card.echelon + } + }) + highestEchelonScore++ + const firstCardWithTag = allTaggedCards.find(card => card.guild === tag || (card.guild === true && card.name.toTitleCase() === tag)) + if(!firstCardWithTag) { + console.log('Could not find the tag you just selected, this is bad') + return + } + await setCardProperty(firstCardWithTag.taskId, 'echelon', highestEchelonScore) + return +} + +// Prints an interactive list of all cards tagged by the given tag, that can be browsed just like in the priorities and deck browsers +async function cardsTaggedByMenu(tag) { +} + +// Allows the user to tag a tag by making a new card for the tag that contains the tagged card +// A card can have its .guild set to true, in which case the guild is the card .name +// Returns true if the tag was tagged/categorized +export async function tagTagInteractive(tag, allTaggedCards) { + console.log('Tag: ', tag) + const answer = await askQuestionText('Type a tag to categorize (or Enter/ESC to cancel):') + if(!answer || answer === 'ESC' || answer == '') { + return false + } + // Make a card for the tag if it doesn't already exist + let guildCard = (await getCardByName(tag, false))[0] + if(!guildCard) { + await createCard(tag) + guildCard = (await getCardByName(tag, false))[0] + if(!guildCard) { + console.log('Failed to create tag card, sorry.') + return false + } + } + await setCardProperty(guildCard.taskId, 'guild', true) + await tagToTopEchelon(answer, allTaggedCards) + return await tagCard(guildCard.taskId, answer) +} diff --git a/scripts/tests.js b/scripts/tests.js index 8235bcf..aa9c8dc 100644 --- a/scripts/tests.js +++ b/scripts/tests.js @@ -2,7 +2,7 @@ // The tests actually happen so your database will be modified (future: allow switching databases or automatically switch) // The tests use an AO API file saved in the same directory; this file must be kept up-to-date // Maybe in the future a precompiled api.js created from api.ts can be hosted so that ao-cli does not have to compile any TypeScript -import { createSession, logout } from './api.js' +import { createSession, logout } from '../ao-lib/api.js' import { promptMenu } from './welcome.js' async function testLoginAndOut() { diff --git a/scripts/util.js b/scripts/util.js deleted file mode 100644 index daa7ddc..0000000 --- a/scripts/util.js +++ /dev/null @@ -1,20 +0,0 @@ -// General helper functions - -// 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)] -} - -// Waits for the given number of milliseconds (or a brief pause by default) -export function sleep(ms = 550) { - return new Promise((r) => setTimeout(r, ms)) -} - -export const isObject = (obj) => Object.prototype.toString.call(obj) === '[object Object]' diff --git a/scripts/welcome.js b/scripts/welcome.js index 43a2010..b3414c3 100644 --- a/scripts/welcome.js +++ b/scripts/welcome.js @@ -1,7 +1,7 @@ import chalk from 'chalk' //import inquirer from 'inquirer' import prompts from 'prompts' -import { selectRandom } from './util.js' +import { selectRandom } from '../ao-lib/util.js' import { greenChalk, theAO, theMenu, headerStyle } from './styles.js' // Different sets of messages that can be randomly selected from @@ -22,7 +22,7 @@ const welcomeMessages = [ `A black cat crosses your path. Most people wouldn't notice, but you know you have entered the AO.`, `Dipping your brush in ink, you draw a perfect circle. This is ${theAO}.`, `You are offered a choice between two pills. However, you have secretly built up an immunity to both pills, and trick your opponent into taking one. Inconceviably, you are in ${theAO}.`, - `A young man with spiky hair and golden skin appears before you in a halo of light. He guides you to ${theAO}.`, + `A young man with spiky hair and glowing skin appears before you in a halo of light. He guides you to ${theAO}.`, `Looking for a shortcut, you worm your way through through the hedges, and, after struggling through the brush, emerge into a sunny estate garden. You've found the AO.`, `You find a small animal burrow dug-out near the riverside. Crawling in, you find a network of caves that lead to ${theAO}.`, `You receive a handwritten letter in the mail, which reads, in fine calligraphy:, "Dear —, You are in ${theAO}."`, @@ -119,7 +119,7 @@ export async function askQuestionText(prompt = 'Please enter a string:', promptO } Object.assign(options, promptOptions) const answer = await prompts(options) - return answer.value || 'ESC' + return answer.value || false } export async function promptMenu(choices, prompt = 'Please choose:', hint = '(Use arrow keys)', defaultValue = null, warningMessage = null, numbered = false) { @@ -155,6 +155,11 @@ export async function promptMenu(choices, prompt = 'Please choose:', hint = '(Us if(typeof answer.value === 'string') { return answer.value } + if(!isNaN(answer.value) && (answer.value < 0 || answer.value > choices.length) || answer.value === false) { + return false + } else if(isNaN(answer.value)) { + return answer.value + } const chosenOption = choices[answer.value].value || choices[answer.value].title if(!chosenOption) { return choices[answer.value] diff --git a/scripts/wizard.js b/scripts/wizard.js index 17d50cd..6519d7a 100644 --- a/scripts/wizard.js +++ b/scripts/wizard.js @@ -1,7 +1,7 @@ // Functions related to intelligently installing the AO as a whole. Specific additional feature modules are each a file under ./features. import path from 'path' import { execSync } from 'child_process' -import { aoEnv, setAoEnv } from './settings.js' +import { aoEnv, setAoEnv } from '../ao-lib/settings.js' import { detectOS, updateSoftware, isInstalled } from './system.js' import { isFolder, isFile } from './files.js' import { aoIsInstalled } from './features/ao-server.js'