Tons of fixes to prepare for staged releases.
This commit is contained in:
1 parent
9f37d3faf1
commit
5bebe072b2
24 files changed
+989
-353
No files matched your search
+87
-37
@@ -16,6 +16,9 @@ require('module-alias')(require('path').join(__dirname, '..'))
|
|||||||
//Auth helper, used for decoding users web token
|
//Auth helper, used for decoding users web token
|
||||||
let Auth = require('@helpers/Auth')
|
let Auth = require('@helpers/Auth')
|
||||||
|
|
||||||
|
//Note model, used to authorise socket room joins
|
||||||
|
let Note = require('@models/Note')
|
||||||
|
|
||||||
//Helmet adds additional security to express server
|
//Helmet adds additional security to express server
|
||||||
const helmet = require('helmet')
|
const helmet = require('helmet')
|
||||||
|
|
||||||
@@ -51,6 +54,14 @@ global.SocketIo = io
|
|||||||
|
|
||||||
let noteDiffs = {}
|
let noteDiffs = {}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Room names are namespaced. Previously note rooms were keyed on the bare
|
||||||
|
// note_raw_text.id and user rooms on the bare user.id, sharing one flat
|
||||||
|
// keyspace -- so user 7 and note_raw_text 7 were the same room.
|
||||||
|
//
|
||||||
|
const userRoom = userId => `user:${userId}`
|
||||||
|
const noteRoom = rawTextId => `note:${rawTextId}`
|
||||||
|
|
||||||
io.on('connection', function(socket){
|
io.on('connection', function(socket){
|
||||||
|
|
||||||
//When a user connects, add them to their own room
|
//When a user connects, add them to their own room
|
||||||
@@ -59,11 +70,11 @@ io.on('connection', function(socket){
|
|||||||
socket.on('user_connect', token => {
|
socket.on('user_connect', token => {
|
||||||
Auth.decodeToken(token)
|
Auth.decodeToken(token)
|
||||||
.then(userData => {
|
.then(userData => {
|
||||||
socket.join(userData.userId)
|
socket.join(userRoom(userData.userId))
|
||||||
|
|
||||||
//Track active logged in user accounts
|
//Track active logged in user accounts
|
||||||
const usersInRoom = io.sockets.adapter.rooms[userData.userId]
|
const usersInRoom = io.sockets.adapter.rooms[userRoom(userData.userId)]
|
||||||
io.to(userData.userId).emit('update_active_user_count', usersInRoom.length)
|
io.to(userRoom(userData.userId)).emit('update_active_user_count', usersInRoom.length)
|
||||||
|
|
||||||
}).catch(error => {
|
}).catch(error => {
|
||||||
//Don't add user to room if they are not logged in
|
//Don't add user to room if they are not logged in
|
||||||
@@ -74,11 +85,11 @@ io.on('connection', function(socket){
|
|||||||
socket.on('get_active_user_count', token => {
|
socket.on('get_active_user_count', token => {
|
||||||
Auth.decodeToken(token)
|
Auth.decodeToken(token)
|
||||||
.then(userData => {
|
.then(userData => {
|
||||||
socket.join(userData.userId)
|
socket.join(userRoom(userData.userId))
|
||||||
|
|
||||||
//Track active logged in user accounts
|
//Track active logged in user accounts
|
||||||
const usersInRoom = io.sockets.adapter.rooms[userData.userId]
|
const usersInRoom = io.sockets.adapter.rooms[userRoom(userData.userId)]
|
||||||
io.to(userData.userId).emit('update_active_user_count', usersInRoom.length)
|
io.to(userRoom(userData.userId)).emit('update_active_user_count', usersInRoom.length)
|
||||||
|
|
||||||
}).catch(error => {
|
}).catch(error => {
|
||||||
// console.log(error)
|
// console.log(error)
|
||||||
@@ -107,9 +118,30 @@ io.on('connection', function(socket){
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
socket.on('join_room', rawTextId => {
|
//
|
||||||
// Join user to rawtextid room when they enter
|
// The client asks for a NOTE, not a room. The server authorises the request
|
||||||
socket.join(rawTextId)
|
// against the session and derives the room itself, so a client can no longer
|
||||||
|
// name an arbitrary room and be handed its buffered diffs.
|
||||||
|
//
|
||||||
|
socket.on('join_room', ({ noteId, token } = {}) => {
|
||||||
|
|
||||||
|
Auth.decodeToken(token)
|
||||||
|
.then(userData => {
|
||||||
|
|
||||||
|
return Note.getRawTextIdForUser(noteId, userData.userId)
|
||||||
|
.then(rawTextId => {
|
||||||
|
|
||||||
|
// No claim on this note -> no room, no diffs
|
||||||
|
if(rawTextId == null){ return }
|
||||||
|
|
||||||
|
const room = noteRoom(rawTextId)
|
||||||
|
|
||||||
|
// Remember what this socket is allowed into, so leave_room and
|
||||||
|
// note_diff never have to trust a client-supplied room again
|
||||||
|
socket.authorizedRooms = socket.authorizedRooms || {}
|
||||||
|
socket.authorizedRooms[noteId] = { room, rawTextId }
|
||||||
|
|
||||||
|
socket.join(room)
|
||||||
|
|
||||||
//If there are past diffs for this note, send them to the user
|
//If there are past diffs for this note, send them to the user
|
||||||
if(noteDiffs[rawTextId] != undefined){
|
if(noteDiffs[rawTextId] != undefined){
|
||||||
@@ -121,53 +153,71 @@ io.on('connection', function(socket){
|
|||||||
socket.emit('past_diffs', noteDiffs[rawTextId])
|
socket.emit('past_diffs', noteDiffs[rawTextId])
|
||||||
}
|
}
|
||||||
|
|
||||||
const usersInRoom = io.sockets.adapter.rooms[rawTextId]
|
const usersInRoom = io.sockets.adapter.rooms[room]
|
||||||
if(usersInRoom){
|
if(usersInRoom){
|
||||||
//Update users in room count
|
//Update users in room count
|
||||||
io.to(rawTextId).emit('update_user_count', usersInRoom.length)
|
io.to(room).emit('update_user_count', usersInRoom.length)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
//Not logged in, or no access. Join nothing.
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
socket.on('leave_room', roomId => {
|
socket.on('leave_room', ({ noteId } = {}) => {
|
||||||
socket.leave(roomId)
|
|
||||||
// console.log('User Left room')
|
|
||||||
|
|
||||||
const usersInRoom = io.sockets.adapter.rooms[roomId]
|
// Only rooms this socket was actually authorised into
|
||||||
|
const entry = socket.authorizedRooms && socket.authorizedRooms[noteId]
|
||||||
|
if(!entry){ return }
|
||||||
|
|
||||||
|
socket.leave(entry.room)
|
||||||
|
delete socket.authorizedRooms[noteId]
|
||||||
|
|
||||||
|
const usersInRoom = io.sockets.adapter.rooms[entry.room]
|
||||||
if(usersInRoom){
|
if(usersInRoom){
|
||||||
// console.log('Users in room', usersInRoom.length)
|
io.to(entry.room).emit('update_user_count', usersInRoom.length)
|
||||||
io.to(roomId).emit('update_user_count', usersInRoom.length)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|
||||||
socket.on('note_diff', data => {
|
socket.on('note_diff', (data = {}) => {
|
||||||
|
|
||||||
|
// Room comes from what this socket was authorised into, never from the
|
||||||
|
// payload
|
||||||
|
const entry = socket.authorizedRooms && socket.authorizedRooms[data.noteId]
|
||||||
|
if(!entry){ return }
|
||||||
|
|
||||||
|
const room = entry.room
|
||||||
|
const rawTextId = entry.rawTextId
|
||||||
|
|
||||||
//Log each diff for note
|
//Log each diff for note
|
||||||
const noteId = data.id
|
if(noteDiffs[rawTextId] == undefined){ noteDiffs[rawTextId] = [] }
|
||||||
delete data.id
|
|
||||||
if(noteDiffs[noteId] == undefined){ noteDiffs[noteId] = [] }
|
|
||||||
data.time = +new Date
|
|
||||||
|
|
||||||
noteDiffs[noteId].push(data)
|
// Carry the raw-text id through. This used to `delete data.id`, stripping
|
||||||
|
// the only identifier before storing and broadcasting -- so a client with
|
||||||
|
// several notes open on one socket could not tell which note a patch
|
||||||
|
// belonged to, and applied every one to whichever editor it found first.
|
||||||
|
const payload = Object.assign({}, data, { rawTextId, time: +new Date })
|
||||||
|
delete payload.noteId
|
||||||
|
|
||||||
// Go over each user in this note-room
|
noteDiffs[rawTextId].push(payload)
|
||||||
io.in(noteId).clients((error, clients) => {
|
|
||||||
if (error) throw error;
|
|
||||||
|
|
||||||
//Go through each client in note-room and send them the diff
|
// Everyone in the note room except the sender
|
||||||
clients.forEach(socketId => {
|
socket.to(room).emit('incoming_diff', payload)
|
||||||
// only send off diff if user
|
|
||||||
if(socketId != socket.id){
|
|
||||||
io.to(socketId).emit('incoming_diff', data)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
});
|
socket.on('truncate_diffs_at_save', (checkpoint = {}) => {
|
||||||
|
|
||||||
})
|
const entry = socket.authorizedRooms && socket.authorizedRooms[checkpoint.noteId]
|
||||||
|
if(!entry){ return }
|
||||||
socket.on('truncate_diffs_at_save', checkpoint => {
|
checkpoint.rawTextId = entry.rawTextId
|
||||||
|
|
||||||
|
// NOTE: the comparison below is `diffSet[i].hash == checkpoint`, comparing
|
||||||
|
// a hash string against the whole checkpoint object, so it never matches
|
||||||
|
// and sliceTo stays 0 -- meaning the entire diff set is discarded at every
|
||||||
|
// save. That happens to be the fail-safe outcome (the saved text becomes
|
||||||
|
// the new baseline), and the correct slice direction for a real fix is
|
||||||
|
// ambiguous, so it is deliberately left alone rather than guessed at.
|
||||||
let diffSet = noteDiffs[checkpoint.rawTextId]
|
let diffSet = noteDiffs[checkpoint.rawTextId]
|
||||||
if(diffSet && diffSet.length > 0){
|
if(diffSet && diffSet.length > 0){
|
||||||
|
|
||||||
|
|||||||
@@ -300,10 +300,10 @@ Attachment.scanTextForWebsites = (io, userId, noteId, noteText) => {
|
|||||||
Attachment.scrapeUrlsCreateAttachments(userId, noteId, foundUrls).then( freshlyScrapedText => {
|
Attachment.scrapeUrlsCreateAttachments(userId, noteId, foundUrls).then( freshlyScrapedText => {
|
||||||
|
|
||||||
//Once everything is done being scraped, emit new attachment events
|
//Once everything is done being scraped, emit new attachment events
|
||||||
SocketIo.to(userId).emit('update_counts')
|
SocketIo.to(`user:${userId}`).emit('update_counts')
|
||||||
|
|
||||||
// Tell user to update attachments with scraped text
|
// Tell user to update attachments with scraped text
|
||||||
SocketIo.to(userId).emit('update_note_attachments')
|
SocketIo.to(`user:${userId}`).emit('update_note_attachments')
|
||||||
|
|
||||||
solrAttachmentText += freshlyScrapedText
|
solrAttachmentText += freshlyScrapedText
|
||||||
resolve(solrAttachmentText)
|
resolve(solrAttachmentText)
|
||||||
@@ -597,10 +597,10 @@ Attachment.pushUrl = (pushkey,url) => {
|
|||||||
|
|
||||||
if(typeof SocketIo != 'undefined'){
|
if(typeof SocketIo != 'undefined'){
|
||||||
//Once everything is done being scraped, emit new attachment events
|
//Once everything is done being scraped, emit new attachment events
|
||||||
SocketIo.to(userId).emit('update_counts')
|
SocketIo.to(`user:${userId}`).emit('update_counts')
|
||||||
|
|
||||||
// Tell user to update attachments with scraped text
|
// Tell user to update attachments with scraped text
|
||||||
SocketIo.to(userId).emit('update_note_attachments')
|
SocketIo.to(`user:${userId}`).emit('update_note_attachments')
|
||||||
}
|
}
|
||||||
|
|
||||||
return resolve(true)
|
return resolve(true)
|
||||||
|
|||||||
+33
-7
@@ -199,7 +199,7 @@ Note.create = (userId, noteTitle = '', noteText = '', masterKey) => {
|
|||||||
.then((rows, fields) => {
|
.then((rows, fields) => {
|
||||||
|
|
||||||
if(typeof SocketIo != 'undefined'){
|
if(typeof SocketIo != 'undefined'){
|
||||||
SocketIo.to(userId).emit('new_note_created', rows[0].insertId)
|
SocketIo.to(`user:${userId}`).emit('new_note_created', rows[0].insertId)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Indexing is done on save
|
// Indexing is done on save
|
||||||
@@ -490,7 +490,7 @@ Note.update = (userId, noteId, noteText, noteTitle, color, pinned, archived, has
|
|||||||
}
|
}
|
||||||
db.promise().query('UPDATE note SET snippet = ? WHERE id = ?', [updatedSnippet, otherNote.id])
|
db.promise().query('UPDATE note SET snippet = ? WHERE id = ?', [updatedSnippet, otherNote.id])
|
||||||
.then((rows, fields) => {
|
.then((rows, fields) => {
|
||||||
SocketIo.to(otherNote['user_id']).emit('new_note_text_saved', {'noteId':otherNote.id, hash})
|
SocketIo.to(`user:${otherNote['user_id']}`).emit('new_note_text_saved', {'noteId':otherNote.id, hash})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -514,7 +514,7 @@ Note.update = (userId, noteId, noteText, noteTitle, color, pinned, archived, has
|
|||||||
.then((rows, fields) => {
|
.then((rows, fields) => {
|
||||||
|
|
||||||
if(typeof SocketIo != 'undefined'){
|
if(typeof SocketIo != 'undefined'){
|
||||||
SocketIo.to(userId).emit('new_note_text_saved', {noteId, hash})
|
SocketIo.to(`user:${userId}`).emit('new_note_text_saved', {noteId, hash})
|
||||||
|
|
||||||
//Async attachment reindex
|
//Async attachment reindex
|
||||||
Attachment.scanTextForWebsites(SocketIo, userId, noteId, noteText)
|
Attachment.scanTextForWebsites(SocketIo, userId, noteId, noteText)
|
||||||
@@ -538,7 +538,7 @@ Note.setPinned = (userId, noteId, pinnedBoolean) => {
|
|||||||
.query('UPDATE note JOIN note_raw_text ON note_raw_text_id = note_raw_text.id SET pinned = ?, updated = ? WHERE note.id = ? AND user_id = ?',
|
.query('UPDATE note JOIN note_raw_text ON note_raw_text_id = note_raw_text.id SET pinned = ?, updated = ? WHERE note.id = ? AND user_id = ?',
|
||||||
[pinned, now, noteId, userId])
|
[pinned, now, noteId, userId])
|
||||||
.then((rows, fields) => {
|
.then((rows, fields) => {
|
||||||
SocketIo.to(userId).emit('note_attribute_modified', noteId)
|
SocketIo.to(`user:${userId}`).emit('note_attribute_modified', noteId)
|
||||||
resolve(true)
|
resolve(true)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -555,7 +555,7 @@ Note.setArchived = (userId, noteId, archivedBoolead) => {
|
|||||||
.query('UPDATE note JOIN note_raw_text ON note_raw_text_id = note_raw_text.id SET archived = ?, updated = ? WHERE note.id = ? AND user_id = ?',
|
.query('UPDATE note JOIN note_raw_text ON note_raw_text_id = note_raw_text.id SET archived = ?, updated = ? WHERE note.id = ? AND user_id = ?',
|
||||||
[archived, now, noteId, userId])
|
[archived, now, noteId, userId])
|
||||||
.then((rows, fields) => {
|
.then((rows, fields) => {
|
||||||
SocketIo.to(userId).emit('note_attribute_modified', noteId)
|
SocketIo.to(`user:${userId}`).emit('note_attribute_modified', noteId)
|
||||||
resolve(true)
|
resolve(true)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -581,7 +581,7 @@ Note.setTrashed = (userId, noteId, trashedBoolean, masterKey) => {
|
|||||||
Note.reindex(userId, masterKey, removeFromIndex)
|
Note.reindex(userId, masterKey, removeFromIndex)
|
||||||
|
|
||||||
|
|
||||||
SocketIo.to(userId).emit('note_attribute_modified', noteId)
|
SocketIo.to(`user:${userId}`).emit('note_attribute_modified', noteId)
|
||||||
resolve(true)
|
resolve(true)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -653,7 +653,7 @@ Note.delete = (userId, noteId, masterKey = null) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
SocketIo.to(userId).emit('update_counts')
|
SocketIo.to(`user:${userId}`).emit('update_counts')
|
||||||
|
|
||||||
if(masterKey){
|
if(masterKey){
|
||||||
//Remove note ID from index
|
//Remove note ID from index
|
||||||
@@ -1191,3 +1191,29 @@ Note.search = (userId, searchQuery, searchTags, fastFilters, masterKey) => {
|
|||||||
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Resolve a note's raw-text id for a given user.
|
||||||
|
// Doubles as the access check: a user with no claim on this note gets null,
|
||||||
|
// so authorisation and room derivation are the same lookup.
|
||||||
|
//
|
||||||
|
Note.getRawTextIdForUser = (noteId, userId) => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
|
||||||
|
const id = parseInt(noteId, 10)
|
||||||
|
if(!Number.isInteger(id) || id < 1){ return resolve(null) }
|
||||||
|
|
||||||
|
db.promise()
|
||||||
|
.query('SELECT note_raw_text_id FROM note WHERE id = ? AND user_id = ? LIMIT 1', [id, userId])
|
||||||
|
.then((rows) => {
|
||||||
|
|
||||||
|
if(!rows[0] || rows[0].length == 0){ return resolve(null) }
|
||||||
|
|
||||||
|
return resolve( rows[0][0]['note_raw_text_id'] )
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.log(error)
|
||||||
|
return resolve(null)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -75,7 +75,7 @@ ShareNote.addUserToSharedNote = (userId, noteId, shareUserId, masterKey) => {
|
|||||||
const sharedUserNoteId = rows[0]['insertId']
|
const sharedUserNoteId = rows[0]['insertId']
|
||||||
|
|
||||||
//Emit update count event to user shared with - so they see the note in real time
|
//Emit update count event to user shared with - so they see the note in real time
|
||||||
SocketIo.to(shareUserId).emit('update_counts')
|
SocketIo.to(`user:${shareUserId}`).emit('update_counts')
|
||||||
|
|
||||||
let success = true
|
let success = true
|
||||||
return resolve({success, shareUserId, sharedUserNoteId})
|
return resolve({success, shareUserId, sharedUserNoteId})
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ Tag.addToNote = (userId, noteId, tagText) => {
|
|||||||
Tag.associateWithNote(userId, noteId, newTagId)
|
Tag.associateWithNote(userId, noteId, newTagId)
|
||||||
.then( result => {
|
.then( result => {
|
||||||
|
|
||||||
SocketIo.to(userId).emit('new_note_text_saved', {noteId})
|
SocketIo.to(`user:${userId}`).emit('new_note_text_saved', {noteId})
|
||||||
resolve(result)
|
resolve(result)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -7,8 +7,6 @@ let router = express.Router()
|
|||||||
let Attachment = require('@models/Attachment')
|
let Attachment = require('@models/Attachment')
|
||||||
let Note = require('@models/Note')
|
let Note = require('@models/Note')
|
||||||
|
|
||||||
let userId = null
|
|
||||||
let masterKey = null
|
|
||||||
|
|
||||||
// middleware that is specific to this router
|
// middleware that is specific to this router
|
||||||
router.use(function setUserId (req, res, next) {
|
router.use(function setUserId (req, res, next) {
|
||||||
@@ -19,23 +17,33 @@ router.use(function setUserId (req, res, next) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if(req.headers.userId){
|
if(req.headers.userId){
|
||||||
userId = req.headers.userId
|
|
||||||
masterKey = req.headers.masterKey
|
|
||||||
next()
|
next()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
router.post('/search', function (req, res) {
|
router.post('/search', function (req, res) {
|
||||||
|
//Bind per request. These were module-level singletons shared by every
|
||||||
|
//concurrent request, so one user's masterKey could overwrite another's.
|
||||||
|
const { userId, masterKey } = req.headers
|
||||||
|
|
||||||
Attachment.search(userId, req.body.noteId, req.body.attachmentType, req.body.offset, req.body.setSize, req.body.includeShared)
|
Attachment.search(userId, req.body.noteId, req.body.attachmentType, req.body.offset, req.body.setSize, req.body.includeShared)
|
||||||
.then( data => res.send(data) )
|
.then( data => res.send(data) )
|
||||||
})
|
})
|
||||||
|
|
||||||
router.post('/textsearch', function (req, res) {
|
router.post('/textsearch', function (req, res) {
|
||||||
|
//Bind per request. These were module-level singletons shared by every
|
||||||
|
//concurrent request, so one user's masterKey could overwrite another's.
|
||||||
|
const { userId, masterKey } = req.headers
|
||||||
|
|
||||||
Attachment.textSearch(userId, req.body.searchTerm)
|
Attachment.textSearch(userId, req.body.searchTerm)
|
||||||
.then( data => res.send(data) )
|
.then( data => res.send(data) )
|
||||||
})
|
})
|
||||||
|
|
||||||
router.post('/update', function (req, res) {
|
router.post('/update', function (req, res) {
|
||||||
|
//Bind per request. These were module-level singletons shared by every
|
||||||
|
//concurrent request, so one user's masterKey could overwrite another's.
|
||||||
|
const { userId, masterKey } = req.headers
|
||||||
|
|
||||||
Attachment.update(userId, req.body.attachmentId, req.body.updatedText, req.body.noteId)
|
Attachment.update(userId, req.body.attachmentId, req.body.updatedText, req.body.noteId)
|
||||||
.then( result => {
|
.then( result => {
|
||||||
Note.reindex(userId, req.body.noteId)
|
Note.reindex(userId, req.body.noteId)
|
||||||
@@ -44,11 +52,19 @@ router.post('/update', function (req, res) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
router.post('/delete', function (req, res) {
|
router.post('/delete', function (req, res) {
|
||||||
|
//Bind per request. These were module-level singletons shared by every
|
||||||
|
//concurrent request, so one user's masterKey could overwrite another's.
|
||||||
|
const { userId, masterKey } = req.headers
|
||||||
|
|
||||||
Attachment.delete(userId, req.body.attachmentId)
|
Attachment.delete(userId, req.body.attachmentId)
|
||||||
.then( data => res.send(data) )
|
.then( data => res.send(data) )
|
||||||
})
|
})
|
||||||
|
|
||||||
router.post('/upload', upload.single('file'), function (req, res, next) {
|
router.post('/upload', upload.single('file'), function (req, res, next) {
|
||||||
|
//Bind per request. These were module-level singletons shared by every
|
||||||
|
//concurrent request, so one user's masterKey could overwrite another's.
|
||||||
|
const { userId, masterKey } = req.headers
|
||||||
|
|
||||||
|
|
||||||
//Create attachment with file information and node id
|
//Create attachment with file information and node id
|
||||||
const noteId = parseInt(req.body.noteId)
|
const noteId = parseInt(req.body.noteId)
|
||||||
@@ -67,6 +83,10 @@ router.post('/upload', upload.single('file'), function (req, res, next) {
|
|||||||
|
|
||||||
// get push key
|
// get push key
|
||||||
router.post('/getbookmarklet', function (req, res) {
|
router.post('/getbookmarklet', function (req, res) {
|
||||||
|
//Bind per request. These were module-level singletons shared by every
|
||||||
|
//concurrent request, so one user's masterKey could overwrite another's.
|
||||||
|
const { userId, masterKey } = req.headers
|
||||||
|
|
||||||
|
|
||||||
Attachment.getPushkeyBookmarklet(userId)
|
Attachment.getPushkeyBookmarklet(userId)
|
||||||
.then( data => res.send(data) )
|
.then( data => res.send(data) )
|
||||||
|
|||||||
@@ -7,8 +7,6 @@ var router = express.Router()
|
|||||||
|
|
||||||
let CycleTracking = require('@models/CycleTracking');
|
let CycleTracking = require('@models/CycleTracking');
|
||||||
|
|
||||||
let userId = null
|
|
||||||
let masterKey = null
|
|
||||||
|
|
||||||
// middleware that is specific to this router
|
// middleware that is specific to this router
|
||||||
router.use(function setUserId (req, res, next) {
|
router.use(function setUserId (req, res, next) {
|
||||||
@@ -19,20 +17,26 @@ router.use(function setUserId (req, res, next) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if(req.headers.userId){
|
if(req.headers.userId){
|
||||||
userId = req.headers.userId
|
|
||||||
masterKey = req.headers.masterKey
|
|
||||||
next()
|
next()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
//Get quick note text
|
//Get quick note text
|
||||||
router.post('/get', function (req, res) {
|
router.post('/get', function (req, res) {
|
||||||
|
//Bind per request. These were module-level singletons shared by every
|
||||||
|
//concurrent request, so one user's masterKey could overwrite another's.
|
||||||
|
const { userId, masterKey } = req.headers
|
||||||
|
|
||||||
CycleTracking.get(userId, masterKey)
|
CycleTracking.get(userId, masterKey)
|
||||||
.then( data => res.send(data) )
|
.then( data => res.send(data) )
|
||||||
})
|
})
|
||||||
|
|
||||||
//Push text to quick note
|
//Push text to quick note
|
||||||
router.post('/save', function (req, res) {
|
router.post('/save', function (req, res) {
|
||||||
|
//Bind per request. These were module-level singletons shared by every
|
||||||
|
//concurrent request, so one user's masterKey could overwrite another's.
|
||||||
|
const { userId, masterKey } = req.headers
|
||||||
|
|
||||||
CycleTracking.save(userId, req.body.cycleData, masterKey)
|
CycleTracking.save(userId, req.body.cycleData, masterKey)
|
||||||
.then( data => res.send(data) )
|
.then( data => res.send(data) )
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -7,8 +7,6 @@ var router = express.Router()
|
|||||||
|
|
||||||
let MetricTracking = require('@models/MetricTracking');
|
let MetricTracking = require('@models/MetricTracking');
|
||||||
|
|
||||||
let userId = null
|
|
||||||
let masterKey = null
|
|
||||||
|
|
||||||
// middleware that is specific to this router
|
// middleware that is specific to this router
|
||||||
router.use(function setUserId (req, res, next) {
|
router.use(function setUserId (req, res, next) {
|
||||||
@@ -19,24 +17,34 @@ router.use(function setUserId (req, res, next) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if(req.headers.userId){
|
if(req.headers.userId){
|
||||||
userId = req.headers.userId
|
|
||||||
masterKey = req.headers.masterKey
|
|
||||||
next()
|
next()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
router.post('/get', function (req, res) {
|
router.post('/get', function (req, res) {
|
||||||
|
//Bind per request. These were module-level singletons shared by every
|
||||||
|
//concurrent request, so one user's masterKey could overwrite another's.
|
||||||
|
const { userId, masterKey } = req.headers
|
||||||
|
|
||||||
MetricTracking.get(userId, masterKey)
|
MetricTracking.get(userId, masterKey)
|
||||||
.then( data => res.send(data) )
|
.then( data => res.send(data) )
|
||||||
})
|
})
|
||||||
|
|
||||||
router.post('/create', function (req, res) {
|
router.post('/create', function (req, res) {
|
||||||
|
//Bind per request. These were module-level singletons shared by every
|
||||||
|
//concurrent request, so one user's masterKey could overwrite another's.
|
||||||
|
const { userId, masterKey } = req.headers
|
||||||
|
|
||||||
MetricTracking.create(userId, masterKey)
|
MetricTracking.create(userId, masterKey)
|
||||||
.then( data => res.send(data) )
|
.then( data => res.send(data) )
|
||||||
})
|
})
|
||||||
|
|
||||||
//Push text to quick note
|
//Push text to quick note
|
||||||
router.post('/save', function (req, res) {
|
router.post('/save', function (req, res) {
|
||||||
|
//Bind per request. These were module-level singletons shared by every
|
||||||
|
//concurrent request, so one user's masterKey could overwrite another's.
|
||||||
|
const { userId, masterKey } = req.headers
|
||||||
|
|
||||||
MetricTracking.save(userId, req.body.cycleData, masterKey)
|
MetricTracking.save(userId, req.body.cycleData, masterKey)
|
||||||
.then( data => res.send(data) )
|
.then( data => res.send(data) )
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ let Note = require('@models/Note')
|
|||||||
let User = require('@models/User')
|
let User = require('@models/User')
|
||||||
let ShareNote = require('@models/ShareNote')
|
let ShareNote = require('@models/ShareNote')
|
||||||
|
|
||||||
let userId = null
|
|
||||||
let masterKey = null
|
|
||||||
|
|
||||||
// middleware that is specific to this router
|
// middleware that is specific to this router
|
||||||
router.use(function setUserId (req, res, next) {
|
router.use(function setUserId (req, res, next) {
|
||||||
@@ -17,8 +15,6 @@ router.use(function setUserId (req, res, next) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if(req.headers.userId){
|
if(req.headers.userId){
|
||||||
userId = req.headers.userId
|
|
||||||
masterKey = req.headers.masterKey
|
|
||||||
next()
|
next()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -27,6 +23,10 @@ router.use(function setUserId (req, res, next) {
|
|||||||
// Note actions
|
// Note actions
|
||||||
//
|
//
|
||||||
router.post('/get', function (req, res) {
|
router.post('/get', function (req, res) {
|
||||||
|
//Bind per request. These were module-level singletons shared by every
|
||||||
|
//concurrent request, so one user's masterKey could overwrite another's.
|
||||||
|
const { userId, masterKey } = req.headers
|
||||||
|
|
||||||
Note.get(userId, req.body.noteId, masterKey)
|
Note.get(userId, req.body.noteId, masterKey)
|
||||||
.then( noteObject => {
|
.then( noteObject => {
|
||||||
|
|
||||||
@@ -39,21 +39,37 @@ router.post('/get', function (req, res) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
router.post('/delete', function (req, res) {
|
router.post('/delete', function (req, res) {
|
||||||
|
//Bind per request. These were module-level singletons shared by every
|
||||||
|
//concurrent request, so one user's masterKey could overwrite another's.
|
||||||
|
const { userId, masterKey } = req.headers
|
||||||
|
|
||||||
Note.delete(userId, req.body.noteId, masterKey)
|
Note.delete(userId, req.body.noteId, masterKey)
|
||||||
.then( data => res.send(data) )
|
.then( data => res.send(data) )
|
||||||
})
|
})
|
||||||
|
|
||||||
router.post('/create', function (req, res) {
|
router.post('/create', function (req, res) {
|
||||||
|
//Bind per request. These were module-level singletons shared by every
|
||||||
|
//concurrent request, so one user's masterKey could overwrite another's.
|
||||||
|
const { userId, masterKey } = req.headers
|
||||||
|
|
||||||
Note.create(userId, req.body.title, req.body.text, masterKey)
|
Note.create(userId, req.body.title, req.body.text, masterKey)
|
||||||
.then( id => res.send({id}) )
|
.then( id => res.send({id}) )
|
||||||
})
|
})
|
||||||
|
|
||||||
router.post('/update', function (req, res) {
|
router.post('/update', function (req, res) {
|
||||||
|
//Bind per request. These were module-level singletons shared by every
|
||||||
|
//concurrent request, so one user's masterKey could overwrite another's.
|
||||||
|
const { userId, masterKey } = req.headers
|
||||||
|
|
||||||
Note.update(userId, req.body.noteId, req.body.text, req.body.title, req.body.color, req.body.pinned, req.body.archived, req.body.hash, masterKey)
|
Note.update(userId, req.body.noteId, req.body.text, req.body.title, req.body.color, req.body.pinned, req.body.archived, req.body.hash, masterKey)
|
||||||
.then( id => res.send({id}) )
|
.then( id => res.send({id}) )
|
||||||
})
|
})
|
||||||
|
|
||||||
router.post('/search', function (req, res) {
|
router.post('/search', function (req, res) {
|
||||||
|
//Bind per request. These were module-level singletons shared by every
|
||||||
|
//concurrent request, so one user's masterKey could overwrite another's.
|
||||||
|
const { userId, masterKey } = req.headers
|
||||||
|
|
||||||
Note.search(userId, req.body.searchQuery, req.body.searchTags, req.body.fastFilters, masterKey)
|
Note.search(userId, req.body.searchQuery, req.body.searchTags, req.body.fastFilters, masterKey)
|
||||||
.then( NoteAndTags => {
|
.then( NoteAndTags => {
|
||||||
res.send(NoteAndTags)
|
res.send(NoteAndTags)
|
||||||
@@ -61,6 +77,10 @@ router.post('/search', function (req, res) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
router.post('/reindex', function (req, res) {
|
router.post('/reindex', function (req, res) {
|
||||||
|
//Bind per request. These were module-level singletons shared by every
|
||||||
|
//concurrent request, so one user's masterKey could overwrite another's.
|
||||||
|
const { userId, masterKey } = req.headers
|
||||||
|
|
||||||
Note.reindex(userId, masterKey)
|
Note.reindex(userId, masterKey)
|
||||||
.then( data => {
|
.then( data => {
|
||||||
res.send(data)
|
res.send(data)
|
||||||
@@ -72,18 +92,30 @@ router.post('/reindex', function (req, res) {
|
|||||||
// Update single note attributes
|
// Update single note attributes
|
||||||
//
|
//
|
||||||
router.post('/setpinned', function (req, res) {
|
router.post('/setpinned', function (req, res) {
|
||||||
|
//Bind per request. These were module-level singletons shared by every
|
||||||
|
//concurrent request, so one user's masterKey could overwrite another's.
|
||||||
|
const { userId, masterKey } = req.headers
|
||||||
|
|
||||||
Note.setPinned(userId, req.body.noteId, req.body.pinned)
|
Note.setPinned(userId, req.body.noteId, req.body.pinned)
|
||||||
.then( results => {
|
.then( results => {
|
||||||
res.send(results)
|
res.send(results)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
router.post('/setarchived', function (req, res) {
|
router.post('/setarchived', function (req, res) {
|
||||||
|
//Bind per request. These were module-level singletons shared by every
|
||||||
|
//concurrent request, so one user's masterKey could overwrite another's.
|
||||||
|
const { userId, masterKey } = req.headers
|
||||||
|
|
||||||
Note.setArchived(userId, req.body.noteId, req.body.archived)
|
Note.setArchived(userId, req.body.noteId, req.body.archived)
|
||||||
.then( results => {
|
.then( results => {
|
||||||
res.send(results)
|
res.send(results)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
router.post('/settrashed', function (req, res) {
|
router.post('/settrashed', function (req, res) {
|
||||||
|
//Bind per request. These were module-level singletons shared by every
|
||||||
|
//concurrent request, so one user's masterKey could overwrite another's.
|
||||||
|
const { userId, masterKey } = req.headers
|
||||||
|
|
||||||
Note.setTrashed(userId, req.body.noteId, req.body.trashed, masterKey)
|
Note.setTrashed(userId, req.body.noteId, req.body.trashed, masterKey)
|
||||||
.then( results => {
|
.then( results => {
|
||||||
res.send(results)
|
res.send(results)
|
||||||
@@ -94,11 +126,19 @@ router.post('/settrashed', function (req, res) {
|
|||||||
// Share Note Actions
|
// Share Note Actions
|
||||||
//
|
//
|
||||||
router.post('/getshareinfo', function (req, res) {
|
router.post('/getshareinfo', function (req, res) {
|
||||||
|
//Bind per request. These were module-level singletons shared by every
|
||||||
|
//concurrent request, so one user's masterKey could overwrite another's.
|
||||||
|
const { userId, masterKey } = req.headers
|
||||||
|
|
||||||
ShareNote.getShareInfo(userId, req.body.noteId, req.body.rawTextId)
|
ShareNote.getShareInfo(userId, req.body.noteId, req.body.rawTextId)
|
||||||
.then(results => res.send(results))
|
.then(results => res.send(results))
|
||||||
})
|
})
|
||||||
|
|
||||||
router.post('/shareadduser', function (req, res) {
|
router.post('/shareadduser', function (req, res) {
|
||||||
|
//Bind per request. These were module-level singletons shared by every
|
||||||
|
//concurrent request, so one user's masterKey could overwrite another's.
|
||||||
|
const { userId, masterKey } = req.headers
|
||||||
|
|
||||||
// ShareNote.addUser(userId, req.body.noteId, req.body.rawTextId, req.body.username, masterKey)
|
// ShareNote.addUser(userId, req.body.noteId, req.body.rawTextId, req.body.username, masterKey)
|
||||||
User.getByUserName(req.body.username)
|
User.getByUserName(req.body.username)
|
||||||
.then( user => {
|
.then( user => {
|
||||||
@@ -111,22 +151,38 @@ router.post('/shareadduser', function (req, res) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
router.post('/shareremoveuser', function (req, res) {
|
router.post('/shareremoveuser', function (req, res) {
|
||||||
|
//Bind per request. These were module-level singletons shared by every
|
||||||
|
//concurrent request, so one user's masterKey could overwrite another's.
|
||||||
|
const { userId, masterKey } = req.headers
|
||||||
|
|
||||||
// (userId, noteId, shareNoteUserId, shareUserId, masterKey)
|
// (userId, noteId, shareNoteUserId, shareUserId, masterKey)
|
||||||
ShareNote.removeUserFromSharedNote(userId, req.body.noteId, req.body.shareUserNoteId, masterKey)
|
ShareNote.removeUserFromSharedNote(userId, req.body.noteId, req.body.shareUserNoteId, masterKey)
|
||||||
.then(results => res.send(results))
|
.then(results => res.send(results))
|
||||||
})
|
})
|
||||||
|
|
||||||
router.post('/enableshare', function (req, res) {
|
router.post('/enableshare', function (req, res) {
|
||||||
|
//Bind per request. These were module-level singletons shared by every
|
||||||
|
//concurrent request, so one user's masterKey could overwrite another's.
|
||||||
|
const { userId, masterKey } = req.headers
|
||||||
|
|
||||||
//Create Shared Encryption Key for Note
|
//Create Shared Encryption Key for Note
|
||||||
ShareNote.migrateToShared(userId, req.body.noteId, masterKey)
|
ShareNote.migrateToShared(userId, req.body.noteId, masterKey)
|
||||||
.then(results => res.send(true))
|
.then(results => res.send(true))
|
||||||
})
|
})
|
||||||
router.post('/getsharekey', function (req, res) {
|
router.post('/getsharekey', function (req, res) {
|
||||||
|
//Bind per request. These were module-level singletons shared by every
|
||||||
|
//concurrent request, so one user's masterKey could overwrite another's.
|
||||||
|
const { userId, masterKey } = req.headers
|
||||||
|
|
||||||
//Get Shared Key for a note
|
//Get Shared Key for a note
|
||||||
ShareNote.decryptSharedKey(userId, req.body.noteId, masterKey)
|
ShareNote.decryptSharedKey(userId, req.body.noteId, masterKey)
|
||||||
.then(results => res.send(results))
|
.then(results => res.send(results))
|
||||||
})
|
})
|
||||||
router.post('/disableshare', function (req, res) {
|
router.post('/disableshare', function (req, res) {
|
||||||
|
//Bind per request. These were module-level singletons shared by every
|
||||||
|
//concurrent request, so one user's masterKey could overwrite another's.
|
||||||
|
const { userId, masterKey } = req.headers
|
||||||
|
|
||||||
//Removed shared encryption key from note
|
//Removed shared encryption key from note
|
||||||
ShareNote.migrateToNormal(userId, req.body.noteId, masterKey)
|
ShareNote.migrateToNormal(userId, req.body.noteId, masterKey)
|
||||||
.then(results => res.send(true))
|
.then(results => res.send(true))
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ var router = express.Router()
|
|||||||
|
|
||||||
let QuickNote = require('@models/QuickNote');
|
let QuickNote = require('@models/QuickNote');
|
||||||
|
|
||||||
let userId = null
|
|
||||||
let masterKey = null
|
|
||||||
|
|
||||||
// middleware that is specific to this router
|
// middleware that is specific to this router
|
||||||
router.use(function setUserId (req, res, next) {
|
router.use(function setUserId (req, res, next) {
|
||||||
@@ -15,26 +13,36 @@ router.use(function setUserId (req, res, next) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if(req.headers.userId){
|
if(req.headers.userId){
|
||||||
userId = req.headers.userId
|
|
||||||
masterKey = req.headers.masterKey
|
|
||||||
next()
|
next()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
//Get quick note text
|
//Get quick note text
|
||||||
router.post('/get', function (req, res) {
|
router.post('/get', function (req, res) {
|
||||||
|
//Bind per request. These were module-level singletons shared by every
|
||||||
|
//concurrent request, so one user's masterKey could overwrite another's.
|
||||||
|
const { userId, masterKey } = req.headers
|
||||||
|
|
||||||
QuickNote.get(userId, masterKey)
|
QuickNote.get(userId, masterKey)
|
||||||
.then( data => res.send(data) )
|
.then( data => res.send(data) )
|
||||||
})
|
})
|
||||||
|
|
||||||
//Push text to quick note
|
//Push text to quick note
|
||||||
router.post('/update', function (req, res) {
|
router.post('/update', function (req, res) {
|
||||||
|
//Bind per request. These were module-level singletons shared by every
|
||||||
|
//concurrent request, so one user's masterKey could overwrite another's.
|
||||||
|
const { userId, masterKey } = req.headers
|
||||||
|
|
||||||
QuickNote.update(userId, req.body.pushText, masterKey)
|
QuickNote.update(userId, req.body.pushText, masterKey)
|
||||||
.then( data => res.send(data) )
|
.then( data => res.send(data) )
|
||||||
})
|
})
|
||||||
|
|
||||||
//Push text to quick note
|
//Push text to quick note
|
||||||
router.post('/new', function (req, res) {
|
router.post('/new', function (req, res) {
|
||||||
|
//Bind per request. These were module-level singletons shared by every
|
||||||
|
//concurrent request, so one user's masterKey could overwrite another's.
|
||||||
|
const { userId, masterKey } = req.headers
|
||||||
|
|
||||||
QuickNote.newNote(userId)
|
QuickNote.newNote(userId)
|
||||||
.then( data => res.send(data) )
|
.then( data => res.send(data) )
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ var router = express.Router()
|
|||||||
|
|
||||||
let Tags = require('@models/Tag')
|
let Tags = require('@models/Tag')
|
||||||
|
|
||||||
let userId = null
|
|
||||||
let masterKey = null
|
|
||||||
|
|
||||||
// middleware that is specific to this router
|
// middleware that is specific to this router
|
||||||
router.use(function setUserId (req, res, next) {
|
router.use(function setUserId (req, res, next) {
|
||||||
@@ -15,49 +13,75 @@ router.use(function setUserId (req, res, next) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if(req.headers.userId){
|
if(req.headers.userId){
|
||||||
userId = req.headers.userId
|
|
||||||
masterKey = req.headers.masterKey
|
|
||||||
next()
|
next()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
//Get the latest notes the user has created
|
//Get the latest notes the user has created
|
||||||
router.post('/suggest', function (req, res) {
|
router.post('/suggest', function (req, res) {
|
||||||
|
//Bind per request. These were module-level singletons shared by every
|
||||||
|
//concurrent request, so one user's masterKey could overwrite another's.
|
||||||
|
const { userId, masterKey } = req.headers
|
||||||
|
|
||||||
Tags.suggest(userId, req.body.noteId, req.body.tagText)
|
Tags.suggest(userId, req.body.noteId, req.body.tagText)
|
||||||
.then( data => res.send(data) )
|
.then( data => res.send(data) )
|
||||||
})
|
})
|
||||||
|
|
||||||
//Get latest tags based on when it was put on a note
|
//Get latest tags based on when it was put on a note
|
||||||
router.post('/latest', function (req, res) {
|
router.post('/latest', function (req, res) {
|
||||||
|
//Bind per request. These were module-level singletons shared by every
|
||||||
|
//concurrent request, so one user's masterKey could overwrite another's.
|
||||||
|
const { userId, masterKey } = req.headers
|
||||||
|
|
||||||
Tags.latest(userId, req.body.noteId)
|
Tags.latest(userId, req.body.noteId)
|
||||||
.then( data => res.send(data) )
|
.then( data => res.send(data) )
|
||||||
})
|
})
|
||||||
|
|
||||||
//Get the latest notes the user has created
|
//Get the latest notes the user has created
|
||||||
router.post('/addtonote', function (req, res) {
|
router.post('/addtonote', function (req, res) {
|
||||||
|
//Bind per request. These were module-level singletons shared by every
|
||||||
|
//concurrent request, so one user's masterKey could overwrite another's.
|
||||||
|
const { userId, masterKey } = req.headers
|
||||||
|
|
||||||
Tags.addToNote(userId, req.body.noteId, req.body.tagText.toLowerCase())
|
Tags.addToNote(userId, req.body.noteId, req.body.tagText.toLowerCase())
|
||||||
.then( data => res.send(data) )
|
.then( data => res.send(data) )
|
||||||
})
|
})
|
||||||
|
|
||||||
router.post('/removefromnote', function (req, res) {
|
router.post('/removefromnote', function (req, res) {
|
||||||
|
//Bind per request. These were module-level singletons shared by every
|
||||||
|
//concurrent request, so one user's masterKey could overwrite another's.
|
||||||
|
const { userId, masterKey } = req.headers
|
||||||
|
|
||||||
Tags.removeTagFromNote(userId, req.body.tagId)
|
Tags.removeTagFromNote(userId, req.body.tagId)
|
||||||
.then( data => res.send(data) )
|
.then( data => res.send(data) )
|
||||||
})
|
})
|
||||||
|
|
||||||
//Get the latest notes the user has created
|
//Get the latest notes the user has created
|
||||||
router.post('/get', function (req, res) {
|
router.post('/get', function (req, res) {
|
||||||
|
//Bind per request. These were module-level singletons shared by every
|
||||||
|
//concurrent request, so one user's masterKey could overwrite another's.
|
||||||
|
const { userId, masterKey } = req.headers
|
||||||
|
|
||||||
Tags.get(userId, req.body.noteId)
|
Tags.get(userId, req.body.noteId)
|
||||||
.then( data => res.send(data) )
|
.then( data => res.send(data) )
|
||||||
})
|
})
|
||||||
|
|
||||||
//Get the latest notes the user has created
|
//Get the latest notes the user has created
|
||||||
router.post('/fornote', function (req, res) {
|
router.post('/fornote', function (req, res) {
|
||||||
|
//Bind per request. These were module-level singletons shared by every
|
||||||
|
//concurrent request, so one user's masterKey could overwrite another's.
|
||||||
|
const { userId, masterKey } = req.headers
|
||||||
|
|
||||||
Tags.fornote(userId, req.body.noteId)
|
Tags.fornote(userId, req.body.noteId)
|
||||||
.then( data => res.send(data) )
|
.then( data => res.send(data) )
|
||||||
})
|
})
|
||||||
|
|
||||||
//Get all the tags for this user in order of usage
|
//Get all the tags for this user in order of usage
|
||||||
router.post('/usertags', function (req, res) {
|
router.post('/usertags', function (req, res) {
|
||||||
|
//Bind per request. These were module-level singletons shared by every
|
||||||
|
//concurrent request, so one user's masterKey could overwrite another's.
|
||||||
|
const { userId, masterKey } = req.headers
|
||||||
|
|
||||||
Tags.userTags(userId, req.body.searchQuery, req.body.searchTags, req.body.fastFilters)
|
Tags.userTags(userId, req.body.searchQuery, req.body.searchTags, req.body.fastFilters)
|
||||||
.then( data => res.send(data) )
|
.then( data => res.send(data) )
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ const User = require('@models/User')
|
|||||||
const Auth = require('@helpers/Auth')
|
const Auth = require('@helpers/Auth')
|
||||||
const cs = require('@helpers/CryptoString')
|
const cs = require('@helpers/CryptoString')
|
||||||
|
|
||||||
let userId = null
|
|
||||||
let masterKey = null
|
|
||||||
|
|
||||||
// middleware that is specific to this router
|
// middleware that is specific to this router
|
||||||
router.use(function setUserId (req, res, next) {
|
router.use(function setUserId (req, res, next) {
|
||||||
@@ -17,8 +15,6 @@ router.use(function setUserId (req, res, next) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if(req.headers.userId){
|
if(req.headers.userId){
|
||||||
userId = req.headers.userId
|
|
||||||
masterKey = req.headers.masterKey
|
|
||||||
next()
|
next()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -11,12 +11,14 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tailwindcss/vite": "^4.1.13",
|
"@tailwindcss/vite": "^4.1.13",
|
||||||
"axios": "^1.1.3",
|
"axios": "^1.1.3",
|
||||||
|
"chart.js": "^4.6.1",
|
||||||
"diff-match-patch": "^1.0.5",
|
"diff-match-patch": "^1.0.5",
|
||||||
"fomantic-ui-css": "^2.9.0",
|
"fomantic-ui-css": "^2.9.0",
|
||||||
"mitt": "^3.0.1",
|
"mitt": "^3.0.1",
|
||||||
"socket.io-client": "^2.5.0",
|
"socket.io-client": "^2.5.0",
|
||||||
"tailwindcss": "^4.1.13",
|
"tailwindcss": "^4.1.13",
|
||||||
"vue": "^3.5.18",
|
"vue": "^3.5.18",
|
||||||
|
"vue-chartjs": "^5.3.4",
|
||||||
"vue-router": "^4.0.0",
|
"vue-router": "^4.0.0",
|
||||||
"vuedraggable": "^4.1.0",
|
"vuedraggable": "^4.1.0",
|
||||||
"vuex": "^4.0.0"
|
"vuex": "^4.0.0"
|
||||||
|
|||||||
Generated
+36
-4
@@ -13,7 +13,10 @@ importers:
|
|||||||
version: 4.1.18([email protected]([email protected])([email protected]))
|
version: 4.1.18([email protected]([email protected])([email protected]))
|
||||||
axios:
|
axios:
|
||||||
specifier: ^1.1.3
|
specifier: ^1.1.3
|
||||||
version: 1.18.1
|
version: 1.18.1([email protected])
|
||||||
|
chart.js:
|
||||||
|
specifier: ^4.6.1
|
||||||
|
version: 4.6.1
|
||||||
diff-match-patch:
|
diff-match-patch:
|
||||||
specifier: ^1.0.5
|
specifier: ^1.0.5
|
||||||
version: 1.0.5
|
version: 1.0.5
|
||||||
@@ -32,6 +35,9 @@ importers:
|
|||||||
vue:
|
vue:
|
||||||
specifier: ^3.5.18
|
specifier: ^3.5.18
|
||||||
version: 3.5.26
|
version: 3.5.26
|
||||||
|
vue-chartjs:
|
||||||
|
specifier: ^5.3.4
|
||||||
|
version: 5.3.4([email protected])([email protected])
|
||||||
vue-router:
|
vue-router:
|
||||||
specifier: ^4.0.0
|
specifier: ^4.0.0
|
||||||
version: 4.6.4([email protected])
|
version: 4.6.4([email protected])
|
||||||
@@ -240,6 +246,9 @@ packages:
|
|||||||
'@jridgewell/[email protected]':
|
'@jridgewell/[email protected]':
|
||||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
||||||
|
|
||||||
|
'@kurkle/[email protected]':
|
||||||
|
resolution: {integrity: sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==}
|
||||||
|
|
||||||
'@rolldown/[email protected]':
|
'@rolldown/[email protected]':
|
||||||
resolution: {integrity: sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==}
|
resolution: {integrity: sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==}
|
||||||
|
|
||||||
@@ -530,6 +539,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
|
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-hJyniucsFmq//EvXYvJ6c6mahPoI3kaxdjVH8awTaJ/fDNWeSqqqArixMJWkWNN6fcYvPU3HneSANFNYtRi61g==}
|
||||||
|
engines: {pnpm: '>=8'}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
|
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
@@ -895,6 +908,12 @@ packages:
|
|||||||
yaml:
|
yaml:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-x3Fqob8RQvrTdssfi9ecsCzEkFOd8JPmNwSkSQzdfKj/uBsRJs/Y88cZcZIEcPsTVfMGwMo4MOoihoDG2DoE/g==}
|
||||||
|
peerDependencies:
|
||||||
|
chart.js: ^4.1.1
|
||||||
|
vue: ^3.0.0-0 || ^2.7.0
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==}
|
resolution: {integrity: sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -1049,6 +1068,8 @@ snapshots:
|
|||||||
'@jridgewell/resolve-uri': 3.1.2
|
'@jridgewell/resolve-uri': 3.1.2
|
||||||
'@jridgewell/sourcemap-codec': 1.5.5
|
'@jridgewell/sourcemap-codec': 1.5.5
|
||||||
|
|
||||||
|
'@kurkle/[email protected]': {}
|
||||||
|
|
||||||
'@rolldown/[email protected]': {}
|
'@rolldown/[email protected]': {}
|
||||||
|
|
||||||
'@rollup/[email protected]':
|
'@rollup/[email protected]':
|
||||||
@@ -1261,9 +1282,9 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]([email protected]):
|
||||||
dependencies:
|
dependencies:
|
||||||
follow-redirects: 1.16.0
|
follow-redirects: 1.16.0([email protected])
|
||||||
form-data: 4.0.6
|
form-data: 4.0.6
|
||||||
https-proxy-agent: 5.0.1
|
https-proxy-agent: 5.0.1
|
||||||
proxy-from-env: 2.1.0
|
proxy-from-env: 2.1.0
|
||||||
@@ -1282,6 +1303,10 @@ snapshots:
|
|||||||
es-errors: 1.3.0
|
es-errors: 1.3.0
|
||||||
function-bind: 1.1.2
|
function-bind: 1.1.2
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
dependencies:
|
||||||
|
'@kurkle/color': 0.3.4
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
dependencies:
|
dependencies:
|
||||||
delayed-stream: 1.0.0
|
delayed-stream: 1.0.0
|
||||||
@@ -1397,7 +1422,9 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
picomatch: 4.0.3
|
picomatch: 4.0.3
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]([email protected]):
|
||||||
|
optionalDependencies:
|
||||||
|
debug: 4.4.3
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -1636,6 +1663,11 @@ snapshots:
|
|||||||
jiti: 2.6.1
|
jiti: 2.6.1
|
||||||
lightningcss: 1.30.2
|
lightningcss: 1.30.2
|
||||||
|
|
||||||
|
[email protected]([email protected])([email protected]):
|
||||||
|
dependencies:
|
||||||
|
chart.js: 4.6.1
|
||||||
|
vue: 3.5.26
|
||||||
|
|
||||||
[email protected]([email protected]):
|
[email protected]([email protected]):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@vue/devtools-api': 6.6.4
|
'@vue/devtools-api': 6.6.4
|
||||||
|
|||||||
@@ -1001,3 +1001,8 @@ padding-right: 10px;
|
|||||||
background-color: rgba(0,0,0,0.7);
|
background-color: rgba(0,0,0,0.7);
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
i.outline {
|
||||||
|
outline-style: none;
|
||||||
|
}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
<template>
|
||||||
|
<div class="line-graph-holder">
|
||||||
|
<Line v-if="hasData" :data="chartData" :options="chartOptions" />
|
||||||
|
<div v-else class="no-data">No data for the selected range.</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { Line } from 'vue-chartjs'
|
||||||
|
import {
|
||||||
|
Chart as ChartJS,
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
PointElement,
|
||||||
|
LineElement,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
} from 'chart.js'
|
||||||
|
|
||||||
|
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Tooltip, Legend)
|
||||||
|
|
||||||
|
// Categorical slots, validated for both of this app's surfaces (#fff / #000)
|
||||||
|
// with scripts/validate_palette.js. Assigned by a field's stable position,
|
||||||
|
// never by its position in a particular graph, so toggling fields on and off
|
||||||
|
// never repaints the ones that stay.
|
||||||
|
const SERIES = {
|
||||||
|
light: ['#2a78d6','#eb6834','#1baf7a','#eda100','#e87ba4','#008300','#4a3aa7','#e34948'],
|
||||||
|
dark: ['#3987e5','#d95926','#199e70','#c98500','#d55181','#008300','#9085e9','#e66767'],
|
||||||
|
}
|
||||||
|
|
||||||
|
// A field can carry an explicit Fomantic colour name chosen by the user; that
|
||||||
|
// is their intent and it matches the field's icon elsewhere on the page, so it
|
||||||
|
// wins over the generated slot.
|
||||||
|
const FOMANTIC = {
|
||||||
|
red:'#DB2828', orange:'#F2711C', yellow:'#FBBD08', olive:'#B5CC18',
|
||||||
|
green:'#21BA45', teal:'#00B5AD', blue:'#2185D0', violet:'#6435C9',
|
||||||
|
purple:'#A333C8', pink:'#E03997', brown:'#A5673F', grey:'#767676',
|
||||||
|
black:'#1B1C1D',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'MetricLineGraph',
|
||||||
|
inject: ['getFieldColor'],
|
||||||
|
components: { Line },
|
||||||
|
props: {
|
||||||
|
graph: { type: Object, required: true }, // { fieldIds: [...] }
|
||||||
|
userFields: { type: Object, default: () => ({}) },
|
||||||
|
cycleData: { type: Object, default: () => ({}) },
|
||||||
|
tempChartDays: { type: [Number, String], default: 30 },
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
isDark(){
|
||||||
|
return Number(this.$store.getters.getIsNightMode) === 1
|
||||||
|
},
|
||||||
|
surface(){
|
||||||
|
return this.isDark ? '#000000' : '#ffffff'
|
||||||
|
},
|
||||||
|
textColor(){
|
||||||
|
// Text wears text tokens, never the series colour.
|
||||||
|
return this.isDark ? '#d9d9d9' : '#3d3d3d'
|
||||||
|
},
|
||||||
|
gridColor(){
|
||||||
|
// One step off the surface, hairline and recessive.
|
||||||
|
return this.isDark ? 'rgba(255,255,255,0.12)' : 'rgba(0,0,0,0.10)'
|
||||||
|
},
|
||||||
|
fieldIds(){
|
||||||
|
return this.graph?.fieldIds || []
|
||||||
|
},
|
||||||
|
// Every date present in the data, oldest first, trimmed to the
|
||||||
|
// requested window. Sorting by real date rather than object key
|
||||||
|
// order means out-of-order writes cannot scramble the line.
|
||||||
|
dateCodes(){
|
||||||
|
const codes = Object.keys(this.cycleData)
|
||||||
|
.map(code => ({ code, time: this.codeToDate(code) }))
|
||||||
|
.filter(entry => !isNaN(entry.time))
|
||||||
|
.sort((a, b) => a.time - b.time)
|
||||||
|
.map(entry => entry.code)
|
||||||
|
|
||||||
|
const span = parseInt(this.tempChartDays)
|
||||||
|
if(!span || span < 1 || span >= codes.length){ return codes }
|
||||||
|
return codes.slice(-span)
|
||||||
|
},
|
||||||
|
hasData(){
|
||||||
|
return this.fieldIds.length > 0 && this.dateCodes.length > 0
|
||||||
|
},
|
||||||
|
chartData(){
|
||||||
|
return {
|
||||||
|
labels: this.dateCodes.map(code => this.formatLabel(code)),
|
||||||
|
datasets: this.fieldIds.map(fieldId => {
|
||||||
|
const colour = this.colourFor(fieldId)
|
||||||
|
return {
|
||||||
|
label: this.userFields[fieldId]?.label || fieldId,
|
||||||
|
data: this.dateCodes.map(code => this.valueAt(code, fieldId)),
|
||||||
|
borderColor: colour,
|
||||||
|
backgroundColor: colour,
|
||||||
|
borderWidth: 2,
|
||||||
|
tension: 0.25,
|
||||||
|
// A marker that is drawn is >= 8px with a 2px ring in
|
||||||
|
// the surface colour; dense series only show them on
|
||||||
|
// hover so the line stays readable.
|
||||||
|
pointRadius: this.denseData ? 0 : 4,
|
||||||
|
pointHoverRadius: 5,
|
||||||
|
pointBorderColor: this.surface,
|
||||||
|
pointBorderWidth: 2,
|
||||||
|
pointHoverBorderColor: this.surface,
|
||||||
|
pointHoverBorderWidth: 2,
|
||||||
|
pointHitRadius: 12,
|
||||||
|
spanGaps: false, // a missing day is a gap, not a zero
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
denseData(){
|
||||||
|
return this.dateCodes.length > 40
|
||||||
|
},
|
||||||
|
chartOptions(){
|
||||||
|
return {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
animation: { duration: 200 },
|
||||||
|
interaction: { mode: 'index', intersect: false },
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
grid: { display: false },
|
||||||
|
ticks: { color: this.textColor, maxRotation: 0, autoSkipPadding: 16 },
|
||||||
|
border: { color: this.gridColor },
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
grid: { color: this.gridColor, lineWidth: 1, drawTicks: false },
|
||||||
|
ticks: { color: this.textColor, padding: 8 },
|
||||||
|
border: { display: false },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
// Always present: identity is never carried by colour alone,
|
||||||
|
// and it is the relief for the low-contrast light slots.
|
||||||
|
legend: {
|
||||||
|
display: true,
|
||||||
|
position: 'bottom',
|
||||||
|
labels: {
|
||||||
|
color: this.textColor,
|
||||||
|
usePointStyle: true,
|
||||||
|
pointStyle: 'circle',
|
||||||
|
boxWidth: 8,
|
||||||
|
boxHeight: 8,
|
||||||
|
padding: 14,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
backgroundColor: this.isDark ? '#1b1b1b' : '#ffffff',
|
||||||
|
titleColor: this.textColor,
|
||||||
|
bodyColor: this.textColor,
|
||||||
|
borderColor: this.gridColor,
|
||||||
|
borderWidth: 1,
|
||||||
|
padding: 10,
|
||||||
|
usePointStyle: true,
|
||||||
|
callbacks: {
|
||||||
|
label: ctx => {
|
||||||
|
const v = ctx.parsed.y
|
||||||
|
return `${ctx.dataset.label}: ${v === null ? '—' : v}`
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
// Date codes are stored as D.M.YYYY
|
||||||
|
codeToDate(code){
|
||||||
|
const [d, m, y] = String(code).split('.').map(Number)
|
||||||
|
return new Date(y, (m || 1) - 1, d || 1).getTime()
|
||||||
|
},
|
||||||
|
formatLabel(code){
|
||||||
|
const date = new Date(this.codeToDate(code))
|
||||||
|
if(isNaN(date.getTime())){ return code }
|
||||||
|
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
|
||||||
|
},
|
||||||
|
valueAt(code, fieldId){
|
||||||
|
const raw = this.cycleData[code]?.[fieldId]
|
||||||
|
if(raw === undefined || raw === null || raw === ''){ return null }
|
||||||
|
const num = Number(raw)
|
||||||
|
// Non-numeric field types cannot be plotted on a value axis; a gap
|
||||||
|
// is honest, where the old code substituted -1 and bent the scale.
|
||||||
|
return isNaN(num) ? null : num
|
||||||
|
},
|
||||||
|
colourFor(fieldId){
|
||||||
|
const named = this.getFieldColor ? this.getFieldColor(fieldId) : null
|
||||||
|
if(named && FOMANTIC[named]){ return FOMANTIC[named] }
|
||||||
|
|
||||||
|
// Stable slot: keyed off the field's position in userFields, so the
|
||||||
|
// colour follows the field rather than its rank in this graph.
|
||||||
|
const all = Object.keys(this.userFields)
|
||||||
|
const index = all.indexOf(fieldId)
|
||||||
|
const slots = this.isDark ? SERIES.dark : SERIES.light
|
||||||
|
return slots[(index < 0 ? 0 : index) % slots.length]
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.line-graph-holder {
|
||||||
|
width: 100%;
|
||||||
|
height: 320px;
|
||||||
|
}
|
||||||
|
.no-data {
|
||||||
|
padding: 2em 0;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--menu-text, #767676);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -43,12 +43,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="sixteen wide column">
|
<div class="sixteen wide column">
|
||||||
<dix class="ui basic segment" v-if="!editGraphs">
|
<div class="ui basic segment" v-if="!editGraphs">
|
||||||
<div class="ui button" v-on:click="toggleEditGraphs">
|
<div class="ui button" v-on:click="toggleEditGraphs">
|
||||||
<i class="edit icon"></i>
|
<i class="edit icon"></i>
|
||||||
<span>Add/Edit Graphs</span>
|
<span>Add/Edit Graphs</span>
|
||||||
</div>
|
</div>
|
||||||
</dix>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div v-if="editGraphs">
|
<div v-if="editGraphs">
|
||||||
@@ -64,7 +64,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-for="(graph, index) in graphs" :class="`ui not-padded ${editGraphs?'active ':'inactive '}segment animate-height`">
|
<div class="ui basic center aligned segment" v-if="!graphs || graphs.length == 0">
|
||||||
|
<h4 class="ui header">No graphs yet</h4>
|
||||||
|
<p>Choose <b>Add/Edit Graphs</b>, then <b>New Graph</b>, and pick which metrics to plot.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-for="(graph, index) in graphs" :class="`ui not-padded ${editGraphs?'active ':'inactive '}segment`">
|
||||||
|
|
||||||
<!-- Edit options -->
|
<!-- Edit options -->
|
||||||
<div class="ui small header" v-if="editGraphs">
|
<div class="ui small header" v-if="editGraphs">
|
||||||
@@ -126,15 +131,20 @@
|
|||||||
<span v-if="!editGraphs">Click "Edit Graphs" then,</span>
|
<span v-if="!editGraphs">Click "Edit Graphs" then,</span>
|
||||||
Select Graph type and Metrics to display
|
Select Graph type and Metrics to display
|
||||||
</div>
|
</div>
|
||||||
<div v-if="graph?.type == undefined && graph.fieldIds && graph.fieldIds.length > 0">
|
<div v-if="isLineGraph(graph) && graph.fieldIds && graph.fieldIds.length > 0">
|
||||||
<div :id="`graphdiv${index}`" style="width: 100%; min-height: 320px;"></div>
|
<LineGraph
|
||||||
|
:graph="graph"
|
||||||
|
:userFields="userFields"
|
||||||
|
:cycleData="cycleData"
|
||||||
|
:tempChartDays="tempChartDays"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="ui segment" v-if="editGraphs">
|
<div class="ui segment" v-if="editGraphs">
|
||||||
|
|
||||||
<!-- change graph type -->
|
<!-- change graph type -->
|
||||||
<div v-for="(graphType, graphId) in graphTypesDef" class="ui buttons">
|
<div v-for="(graphType, graphId) in graphTypesDef" class="ui buttons">
|
||||||
<div class="ui tiny button" v-on:click="changeGraphType(index, graphId)" :class="{'green':(String(graphId) == String(graph?.type))}">
|
<div class="ui tiny button" v-on:click="changeGraphType(index, graphId)" :class="{'green':isGraphTypeActive(graphId, graph)}">
|
||||||
{{ graphType }}
|
{{ graphType }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -160,12 +170,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="sixteen wide column">
|
<div class="sixteen wide column">
|
||||||
<dix class="ui basic segment" v-if="!editGraphs">
|
<div class="ui basic segment" v-if="!editGraphs">
|
||||||
<div class="ui button" v-on:click="toggleEditGraphs">
|
<div class="ui button" v-on:click="toggleEditGraphs">
|
||||||
<i class="edit icon"></i>
|
<i class="edit icon"></i>
|
||||||
<span>Add/Edit Graphs</span>
|
<span>Add/Edit Graphs</span>
|
||||||
</div>
|
</div>
|
||||||
</dix>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div v-if="editGraphs">
|
<div v-if="editGraphs">
|
||||||
@@ -208,6 +218,7 @@ import { defineAsyncComponent } from 'vue'
|
|||||||
],
|
],
|
||||||
components: {
|
components: {
|
||||||
'PillCalendarGraph':defineAsyncComponent(() => import('@/components/Metrictracking/PillCalendarGraph.vue')),
|
'PillCalendarGraph':defineAsyncComponent(() => import('@/components/Metrictracking/PillCalendarGraph.vue')),
|
||||||
|
'LineGraph':defineAsyncComponent(() => import('@/components/Metrictracking/LineGraph.vue')),
|
||||||
},
|
},
|
||||||
data: function(){
|
data: function(){
|
||||||
return {
|
return {
|
||||||
@@ -223,33 +234,9 @@ import { defineAsyncComponent } from 'vue'
|
|||||||
// Constants
|
// Constants
|
||||||
this.PILL_CALENDAR = PILL_CALENDAR
|
this.PILL_CALENDAR = PILL_CALENDAR
|
||||||
this.LAST_DONE = LAST_DONE
|
this.LAST_DONE = LAST_DONE
|
||||||
|
|
||||||
// Include JS libraries
|
|
||||||
let graphsScript = document.createElement('script')
|
|
||||||
graphsScript.setAttribute('src', '//cdnjs.cloudflare.com/ajax/libs/dygraph/2.1.0/dygraph.min.js')
|
|
||||||
document.head.appendChild(graphsScript)
|
|
||||||
},
|
},
|
||||||
mounted(){
|
mounted(){
|
||||||
this.localGraphData = this.graphs
|
this.localGraphData = this.graphs
|
||||||
|
|
||||||
this.graphCurrentData()
|
|
||||||
},
|
|
||||||
updated(){
|
|
||||||
// update graphs here? Or watch graphs prop
|
|
||||||
},
|
|
||||||
watch: {
|
|
||||||
// whenever question changes, this function will run
|
|
||||||
userFields(newFields, oldFields) {
|
|
||||||
// console.log([newFields, oldFields])
|
|
||||||
if( JSON.stringify(oldFields) == "{}" ){
|
|
||||||
this.graphCurrentData()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
tempChartDays(newDays, oldDays){
|
|
||||||
if( newDays != oldDays ){
|
|
||||||
this.graphCurrentData()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
saveGraphs(){
|
saveGraphs(){
|
||||||
@@ -269,10 +256,35 @@ import { defineAsyncComponent } from 'vue'
|
|||||||
this.$emit('toggleEditGraphs')
|
this.$emit('toggleEditGraphs')
|
||||||
},
|
},
|
||||||
changeGraphType(index, newType){
|
changeGraphType(index, newType){
|
||||||
console.log(index + ' change to ' + newType)
|
|
||||||
this.localGraphData[index]['type'] = newType
|
// graphTypesDef is an object, so its keys are strings and the
|
||||||
|
// default line graph arrives here as the STRING 'undefined'.
|
||||||
|
// Saved graphs represent the line graph as an absent type, so
|
||||||
|
// remove the key rather than storing a string that no render
|
||||||
|
// test can match.
|
||||||
|
if(this.isLineGraphType(newType)){
|
||||||
|
delete this.localGraphData[index].type
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
this.localGraphData[index].type = newType
|
||||||
|
}
|
||||||
|
|
||||||
this.saveGraphs()
|
this.saveGraphs()
|
||||||
},
|
},
|
||||||
|
// A line graph is the default: an absent type. Accept the string
|
||||||
|
// form too so graphs saved by the older code still render.
|
||||||
|
isLineGraphType(type){
|
||||||
|
return type === undefined || type === null || type === '' || type === 'undefined'
|
||||||
|
},
|
||||||
|
isLineGraph(graph){
|
||||||
|
return this.isLineGraphType(graph?.type)
|
||||||
|
},
|
||||||
|
isGraphTypeActive(graphId, graph){
|
||||||
|
if(this.isLineGraphType(graphId)){
|
||||||
|
return this.isLineGraph(graph)
|
||||||
|
}
|
||||||
|
return String(graphId) === String(graph?.type)
|
||||||
|
},
|
||||||
addGraph(){
|
addGraph(){
|
||||||
this.localGraphData.push({})
|
this.localGraphData.push({})
|
||||||
this.saveGraphs()
|
this.saveGraphs()
|
||||||
@@ -331,103 +343,6 @@ import { defineAsyncComponent } from 'vue'
|
|||||||
|
|
||||||
return title
|
return title
|
||||||
},
|
},
|
||||||
graphCurrentData(){
|
|
||||||
|
|
||||||
// try again if dygraphs isn't loaded
|
|
||||||
if( typeof(window.Dygraph) != 'function' ){
|
|
||||||
setTimeout(() => {
|
|
||||||
this.graphCurrentData()
|
|
||||||
}, 100)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const graphOptions = {
|
|
||||||
interactionModel: {},
|
|
||||||
// pointClickCallback: function(e, pt){
|
|
||||||
// console.log(e)
|
|
||||||
// console.log(pt)
|
|
||||||
// console.log(this.getValue(pt.idx, 0))
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Excel date format YYYYMMDD
|
|
||||||
const convertToExcelDate = (dateCode) => {
|
|
||||||
return dateCode
|
|
||||||
.split('.')
|
|
||||||
.reverse()
|
|
||||||
.map(item => String(item).padStart(2,0))
|
|
||||||
.join('')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate set of keys for graph length
|
|
||||||
let dataKeys = Object.keys(this.cycleData)
|
|
||||||
dataKeys = dataKeys.splice(0, this.tempChartDays)
|
|
||||||
console.log(dataKeys)
|
|
||||||
|
|
||||||
|
|
||||||
// build CSV data for each graph
|
|
||||||
this.graphs.forEach((graph,index) => {
|
|
||||||
|
|
||||||
// only chart line graphs with dygraphs
|
|
||||||
if( graph.type != undefined ){
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if( !graph.fieldIds ){
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// CSV or path to a CSV file.
|
|
||||||
let dataString = ""
|
|
||||||
|
|
||||||
// Lookup graph field titles
|
|
||||||
let graphLabels = ['Date']
|
|
||||||
graph.fieldIds.forEach(fieldId => {
|
|
||||||
const graphLabel = this.userFields[fieldId]?.label
|
|
||||||
const escapedLabel = graphLabel.replaceAll(',','')
|
|
||||||
graphLabels.push(escapedLabel)
|
|
||||||
})
|
|
||||||
dataString += graphLabels.join(',') + '\n'
|
|
||||||
|
|
||||||
|
|
||||||
// build each row, for each day
|
|
||||||
for (var i = 0; i < dataKeys.length; i++) {
|
|
||||||
|
|
||||||
let nextFragment = []
|
|
||||||
// push date code to first column
|
|
||||||
nextFragment.push(convertToExcelDate(dataKeys[i]))
|
|
||||||
|
|
||||||
graph.fieldIds.forEach(fieldId => {
|
|
||||||
|
|
||||||
const currentEntry = this.cycleData[dataKeys[i]]
|
|
||||||
let currentValue = currentEntry[fieldId]
|
|
||||||
|
|
||||||
// setup correct float graphing
|
|
||||||
if(fieldId == 'BT'){
|
|
||||||
// parse temp to fixed length float 00.00
|
|
||||||
currentValue = parseFloat(currentValue).toFixed(2)
|
|
||||||
}
|
|
||||||
|
|
||||||
if( currentValue == undefined ){
|
|
||||||
currentValue = -1
|
|
||||||
}
|
|
||||||
|
|
||||||
nextFragment.push(currentValue)
|
|
||||||
|
|
||||||
})
|
|
||||||
|
|
||||||
dataString += nextFragment.join(',') + "\n"
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
let graphDiv = document.getElementById("graphdiv"+index)
|
|
||||||
const g = new Dygraph(graphDiv, dataString ,graphOptions)
|
|
||||||
|
|
||||||
})
|
|
||||||
|
|
||||||
return
|
|
||||||
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -81,6 +81,13 @@
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.days-since {
|
||||||
|
padding: 0.75em 0.5em 0.25em 0.5em;
|
||||||
|
font-size: 0.95em;
|
||||||
|
}
|
||||||
|
.days-since-row {
|
||||||
|
padding: 0.15em 0;
|
||||||
|
}
|
||||||
.pill-container {
|
.pill-container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
@@ -262,6 +269,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- time since each metric was last recorded -->
|
||||||
|
<div class="days-since" v-if="daysSince.length > 0">
|
||||||
|
<div class="days-since-row" v-for="entry in daysSince" :key="entry.fieldId">
|
||||||
|
<i :class="`${entry.color} ${entry.icon} icon`"></i>
|
||||||
|
<span v-if="entry.days === null">Days Since {{ entry.label }} - no entries yet</span>
|
||||||
|
<span v-else>Days Since {{ entry.label }} - {{ entry.days }} {{ entry.days === 1 ? 'day' : 'days' }}.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -272,7 +287,7 @@
|
|||||||
// let chartData = {}
|
// let chartData = {}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
inject: ['getFieldColor', 'getFieldIcon'],
|
inject: ['getFieldColor', 'getFieldIcon', 'getFieldLabel'],
|
||||||
props: [
|
props: [
|
||||||
'graph', // options associated with this graph
|
'graph', // options associated with this graph
|
||||||
'userFields', // all field attributes
|
'userFields', // all field attributes
|
||||||
@@ -313,6 +328,53 @@
|
|||||||
this.setupCalendar(new Date())
|
this.setupCalendar(new Date())
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
// "Days Since <metric> - <n> days" for every field on this graph.
|
||||||
|
// Counts a day as recorded only if it passes the same
|
||||||
|
// showZeroValuesCheck the pills use, so the "Show/Hide Lowest Value"
|
||||||
|
// toggle moves these numbers too.
|
||||||
|
daysSince(){
|
||||||
|
|
||||||
|
// showZeroValuesCheck reads lowestGraphValue, which getChartData
|
||||||
|
// assigns as a side effect -- touch it so the threshold is current
|
||||||
|
// and so this recomputes whenever the chart data does.
|
||||||
|
void this.getChartData
|
||||||
|
|
||||||
|
const dayMs = 86400000
|
||||||
|
const midnight = (time) => {
|
||||||
|
const date = new Date(time)
|
||||||
|
date.setHours(0, 0, 0, 0)
|
||||||
|
return date.getTime()
|
||||||
|
}
|
||||||
|
const today = midnight(Date.now())
|
||||||
|
|
||||||
|
return (this.graph?.fieldIds || []).map(fieldId => {
|
||||||
|
|
||||||
|
let latest = null
|
||||||
|
|
||||||
|
Object.keys(this.cycleData || {}).forEach(dateCode => {
|
||||||
|
|
||||||
|
const value = this.cycleData[dateCode]?.[fieldId]
|
||||||
|
if(value === undefined || value === null || value === ''){ return }
|
||||||
|
|
||||||
|
// honour the Lowest Value toggle
|
||||||
|
if(!this.showZeroValuesCheck(value, fieldId)){ return }
|
||||||
|
|
||||||
|
const time = this.dateCodeToTime(dateCode)
|
||||||
|
// ignore unparseable codes and anything dated ahead of today
|
||||||
|
if(time === null || time > today){ return }
|
||||||
|
|
||||||
|
if(latest === null || time > latest){ latest = time }
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
fieldId,
|
||||||
|
label: this.getFieldLabel(fieldId),
|
||||||
|
icon: this.getFieldIcon(fieldId),
|
||||||
|
color: this.getFieldColor(fieldId),
|
||||||
|
days: latest === null ? null : Math.round((today - latest) / dayMs),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
getChartData(){
|
getChartData(){
|
||||||
|
|
||||||
let chartData = {}
|
let chartData = {}
|
||||||
@@ -393,11 +455,26 @@
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
// Date codes are stored unpadded as D.M.YYYY
|
||||||
|
dateCodeToTime(dateCode){
|
||||||
|
|
||||||
|
const parts = String(dateCode).split('.').map(Number)
|
||||||
|
if(parts.length !== 3 || parts.some(isNaN)){ return null }
|
||||||
|
|
||||||
|
const [day, month, year] = parts
|
||||||
|
const date = new Date(year, month - 1, day)
|
||||||
|
date.setHours(0, 0, 0, 0)
|
||||||
|
|
||||||
|
return isNaN(date.getTime()) ? null : date.getTime()
|
||||||
|
},
|
||||||
showZeroValuesCheck(dayValue, fieldId){
|
showZeroValuesCheck(dayValue, fieldId){
|
||||||
|
|
||||||
|
// Optional chaining: a graph can reference a default field (BT,
|
||||||
|
// CM, ...) that was never customised, so it has no userFields
|
||||||
|
// entry and the bare lookup threw.
|
||||||
// if graph type is boolean or there are two options
|
// if graph type is boolean or there are two options
|
||||||
let isBooleanField = this.userFields[fieldId].type == 'boolean'
|
let isBooleanField = this.userFields[fieldId]?.type == 'boolean'
|
||||||
if(this.userFields[fieldId].customOptions){
|
if(this.userFields[fieldId]?.customOptions){
|
||||||
let options = this.userFields[fieldId].customOptions
|
let options = this.userFields[fieldId].customOptions
|
||||||
|
|
||||||
isBooleanField = options.split(',').length == 2
|
isBooleanField = options.split(',').length == 2
|
||||||
@@ -415,6 +492,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// lowestGraphValue comes from Math.min of the charted values, so
|
||||||
|
// it is Infinity when the window holds no data -- without the
|
||||||
|
// finite guard nothing would ever clear the threshold.
|
||||||
|
if(!isFinite(this.lowestGraphValue)){
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
return this.showZeroValues || dayValue > this.lowestGraphValue
|
return this.showZeroValues || dayValue > this.lowestGraphValue
|
||||||
},
|
},
|
||||||
getDayValue(fieldId, value){
|
getDayValue(fieldId, value){
|
||||||
|
|||||||
@@ -62,7 +62,7 @@
|
|||||||
<div class="menu-bottom-half" :class="{ 'hide-text':(openNotes > 1) }">
|
<div class="menu-bottom-half" :class="{ 'hide-text':(openNotes > 1) }">
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
<div class="edit-button" v-on:click="$router.push(`/notes/open/${noteid}/menu/table`)" data-tooltip="Insert Table" data-position="bottom center">
|
<div class="edit-button" v-on:click="openMenuRoute('table')" data-tooltip="Insert Table" data-position="bottom center">
|
||||||
<i class="border all icon"></i>
|
<i class="border all icon"></i>
|
||||||
</div> -->
|
</div> -->
|
||||||
|
|
||||||
@@ -87,13 +87,13 @@
|
|||||||
<div class="edit-divide"></div>
|
<div class="edit-divide"></div>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
<div class="edit-button" v-on:click="$router.push(`/notes/open/${noteid}/menu/colors`)" data-tooltip="Note Color" data-position="top center" :style="{ 'background-color':styleObject['noteBackground'], 'color':styleObject['noteText']}">
|
<div class="edit-button" v-on:click="openMenuRoute('colors')" data-tooltip="Note Color" data-position="top center" :style="{ 'background-color':styleObject['noteBackground'], 'color':styleObject['noteText']}">
|
||||||
<i class="paint brush icon"></i>
|
<i class="paint brush icon"></i>
|
||||||
</div> -->
|
</div> -->
|
||||||
<!-- <div class="edit-button" v-on:click="$router.push(`/notes/open/${noteid}/menu/tags`)" data-tooltip="Tags" data-position="top center">
|
<!-- <div class="edit-button" v-on:click="openMenuRoute('tags')" data-tooltip="Tags" data-position="top center">
|
||||||
<i class="tags icon"></i>
|
<i class="tags icon"></i>
|
||||||
</div> -->
|
</div> -->
|
||||||
<div class="edit-button" v-on:click="$router.push(`/notes/open/${noteid}/menu/images`)" data-tooltip="Images" data-position="top center">
|
<div class="edit-button" v-on:click="openMenuRoute('images')" data-tooltip="Images" data-position="top center">
|
||||||
<i class="image icon"></i>
|
<i class="image icon"></i>
|
||||||
<span>Images</span>
|
<span>Images</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -105,7 +105,7 @@
|
|||||||
|
|
||||||
<div class="edit-divide"></div>
|
<div class="edit-divide"></div>
|
||||||
|
|
||||||
<div class="edit-button" v-on:click="$router.push(`/notes/open/${noteid}/menu/options`)" data-tooltip="More Options" data-position="top center">
|
<div class="edit-button" v-on:click="openMenuRoute('options')" data-tooltip="More Options" data-position="top center">
|
||||||
|
|
||||||
<i class="ellipsis horizontal icon"></i>
|
<i class="ellipsis horizontal icon"></i>
|
||||||
|
|
||||||
@@ -157,7 +157,7 @@
|
|||||||
|
|
||||||
<!-- tags on the side, only show on desktop -->
|
<!-- tags on the side, only show on desktop -->
|
||||||
<div class="note-mini-tag-area"
|
<div class="note-mini-tag-area"
|
||||||
v-on:click="$router.push(`/notes/open/${noteid}/menu/tags`)"
|
v-on:click="openMenuRoute('tags')"
|
||||||
:style="{ 'background-color':styleObject['noteBackground'], 'color':styleObject['noteText'] }">
|
:style="{ 'background-color':styleObject['noteBackground'], 'color':styleObject['noteText'] }">
|
||||||
|
|
||||||
<span class="add-mini-tag" v-if="allTags.length == 0">
|
<span class="add-mini-tag" v-if="allTags.length == 0">
|
||||||
@@ -201,7 +201,7 @@
|
|||||||
|
|
||||||
<!-- Squire Box -->
|
<!-- Squire Box -->
|
||||||
<div
|
<div
|
||||||
id="squire-id"
|
:id="`squire-id-${noteid}`"
|
||||||
class="squire-box"
|
class="squire-box"
|
||||||
ref="squirebox"
|
ref="squirebox"
|
||||||
:style="{ 'background-color':styleObject['noteBackground'], 'color':styleObject['noteText'] }"
|
:style="{ 'background-color':styleObject['noteBackground'], 'color':styleObject['noteText'] }"
|
||||||
@@ -369,7 +369,6 @@ import { defineAsyncComponent } from 'vue'
|
|||||||
|
|
||||||
import SquireButtonFunctions from '@/mixins/SquireButtonFunctions.js'
|
import SquireButtonFunctions from '@/mixins/SquireButtonFunctions.js'
|
||||||
|
|
||||||
let rawNoteText = '' // Used for comparing and generating diffs
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'NoteInputPanel',
|
name: 'NoteInputPanel',
|
||||||
@@ -419,6 +418,9 @@ import { defineAsyncComponent } from 'vue'
|
|||||||
|
|
||||||
//All the squire settings
|
//All the squire settings
|
||||||
editor: null,
|
editor: null,
|
||||||
|
// Baseline for diff generation. Per-instance: as a module-scoped
|
||||||
|
// variable this was shared by every open note's panel.
|
||||||
|
rawNoteText: '',
|
||||||
usersOnNote: 0,
|
usersOnNote: 0,
|
||||||
|
|
||||||
sideMenuOpen: false,
|
sideMenuOpen: false,
|
||||||
@@ -475,25 +477,17 @@ import { defineAsyncComponent } from 'vue'
|
|||||||
},
|
},
|
||||||
beforeMount(){
|
beforeMount(){
|
||||||
|
|
||||||
this.$bus.$on('new_file_upload', ({noteId, imageCode}) => {
|
// Named handlers so beforeUnmount can remove only THIS panel's.
|
||||||
if(this.noteid == noteId && this.editor){
|
// mitt's off(type) with no handler removes every panel's.
|
||||||
this.editor.moveCursorToEnd()
|
this.$bus.$on('new_file_upload', this.onFileUpload)
|
||||||
this.editor.insertHTML(imageCode)
|
this.$bus.$on('close_note_by_id', this.onCloseNoteById)
|
||||||
this.save()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
this.$bus.$on('close_note_by_id', (noteId) => {
|
|
||||||
if(noteId == this.noteid){
|
|
||||||
this.closeButtonAction()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
beforeUnmount(){
|
beforeUnmount(){
|
||||||
|
|
||||||
this.$io.emit('leave_room', this.rawTextId)
|
this.$io.emit('leave_room', { noteId: this.noteid })
|
||||||
|
|
||||||
this.$bus.$off('new_file_upload')
|
this.$bus.$off('new_file_upload', this.onFileUpload)
|
||||||
|
this.$bus.$off('close_note_by_id', this.onCloseNoteById)
|
||||||
|
|
||||||
this.destroyAttachmentStyles()
|
this.destroyAttachmentStyles()
|
||||||
|
|
||||||
@@ -533,13 +527,13 @@ import { defineAsyncComponent } from 'vue'
|
|||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
testModify(){
|
testModify(){
|
||||||
const text = document.getElementById('squire-id').children[0].innerHTML
|
const text = this.$refs.squirebox.children[0].innerHTML
|
||||||
const prepended = 'Hello-> ' + text
|
const prepended = 'Hello-> ' + text
|
||||||
document.getElementById('squire-id').children[0].innerHTML = prepended
|
this.$refs.squirebox.children[0].innerHTML = prepended
|
||||||
|
|
||||||
const text1 = document.getElementById('squire-id').children[2].innerHTML
|
const text1 = this.$refs.squirebox.children[2].innerHTML
|
||||||
const prepended1 = 'Hey-> ' + text1
|
const prepended1 = 'Hey-> ' + text1
|
||||||
document.getElementById('squire-id').children[2].innerHTML = prepended1
|
this.$refs.squirebox.children[2].innerHTML = prepended1
|
||||||
},
|
},
|
||||||
simulateTyping(index = 0){
|
simulateTyping(index = 0){
|
||||||
|
|
||||||
@@ -593,7 +587,7 @@ import { defineAsyncComponent } from 'vue'
|
|||||||
this.setText(this.noteText)
|
this.setText(this.noteText)
|
||||||
|
|
||||||
// Use squire box HTML for diff/patch changes
|
// Use squire box HTML for diff/patch changes
|
||||||
rawNoteText = document.getElementById('squire-id').innerHTML
|
this.rawNoteText = this.$refs.squirebox.innerHTML
|
||||||
|
|
||||||
//focus on open, not on mobile, it causes the keyboard to pop up, thats annoying
|
//focus on open, not on mobile, it causes the keyboard to pop up, thats annoying
|
||||||
if(!this.$store.getters.getIsUserOnMobile){
|
if(!this.$store.getters.getIsUserOnMobile){
|
||||||
@@ -867,6 +861,8 @@ import { defineAsyncComponent } from 'vue'
|
|||||||
},
|
},
|
||||||
//Called on squire event for keyup
|
//Called on squire event for keyup
|
||||||
diffText(event){
|
diffText(event){
|
||||||
|
console.log('Skipping diff text function')
|
||||||
|
return
|
||||||
// console.log(event.type)
|
// console.log(event.type)
|
||||||
|
|
||||||
const diffEvents = ['keyup','pathChange', 'click']
|
const diffEvents = ['keyup','pathChange', 'click']
|
||||||
@@ -880,17 +876,17 @@ import { defineAsyncComponent } from 'vue'
|
|||||||
this.diffTextTimeout = setTimeout(() => {
|
this.diffTextTimeout = setTimeout(() => {
|
||||||
|
|
||||||
// Current Editor Text
|
// Current Editor Text
|
||||||
const liveEditorElm = document.getElementById('squire-id')
|
const liveEditorElm = this.$refs.squirebox
|
||||||
|
|
||||||
// virtual element for selecting div
|
// virtual element for selecting div
|
||||||
let virtualEditorElm = document.createElement('div')
|
let virtualEditorElm = document.createElement('div')
|
||||||
virtualEditorElm.innerHTML = rawNoteText
|
virtualEditorElm.innerHTML = this.rawNoteText
|
||||||
|
|
||||||
// element at cursor
|
// element at cursor
|
||||||
const elmAtCaret = window.getSelection().getRangeAt(0).startContainer.parentNode
|
const elmAtCaret = window.getSelection().getRangeAt(0).startContainer.parentNode
|
||||||
|
|
||||||
// Remove beginngin selector from path, make it more generic
|
// Remove beginngin selector from path, make it more generic
|
||||||
const path = this.generateSelector(elmAtCaret).replace('div#squire-id > ','')
|
const path = this.generateSelector(elmAtCaret).replace(`div#squire-id-${this.noteid} > `,'')
|
||||||
let workingPath = ''
|
let workingPath = ''
|
||||||
|
|
||||||
// default to entire note text, select down if path
|
// default to entire note text, select down if path
|
||||||
@@ -938,10 +934,10 @@ import { defineAsyncComponent } from 'vue'
|
|||||||
const patch_text = dmp.patch_toText(patch_list)
|
const patch_text = dmp.patch_toText(patch_list)
|
||||||
|
|
||||||
// save raw text for future diffs
|
// save raw text for future diffs
|
||||||
rawNoteText = liveEditorElm.innerHTML
|
this.rawNoteText = liveEditorElm.innerHTML
|
||||||
|
|
||||||
let newPatch = {
|
let newPatch = {
|
||||||
id: this.rawTextId,
|
noteId: this.noteid,
|
||||||
diff: patch_text,
|
diff: patch_text,
|
||||||
path: path,
|
path: path,
|
||||||
// testing metrics
|
// testing metrics
|
||||||
@@ -961,7 +957,10 @@ import { defineAsyncComponent } from 'vue'
|
|||||||
// console.log('incoming patches ', incomingPatchs)
|
// console.log('incoming patches ', incomingPatchs)
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
|
|
||||||
const editorElement = document.getElementById('squire-id')
|
const editorElement = this.$refs.squirebox
|
||||||
|
|
||||||
|
// Socket-driven: this panel may not be rendered yet, or may be gone
|
||||||
|
if(!editorElement){ return resolve(false) }
|
||||||
|
|
||||||
// iterate over incoming patches because they apply to specific divs
|
// iterate over incoming patches because they apply to specific divs
|
||||||
incomingPatchs.forEach(patch => {
|
incomingPatchs.forEach(patch => {
|
||||||
@@ -988,10 +987,10 @@ import { defineAsyncComponent } from 'vue'
|
|||||||
})
|
})
|
||||||
|
|
||||||
// save editor HTML after change for future comparisons
|
// save editor HTML after change for future comparisons
|
||||||
rawNoteText = editorElement.innerHTML
|
this.rawNoteText = editorElement.innerHTML
|
||||||
|
|
||||||
// update hash on patch
|
// update hash on patch
|
||||||
this.lastNoteHash = this.hashString( rawNoteText )
|
this.lastNoteHash = this.hashString( this.rawNoteText )
|
||||||
|
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
return resolve(true)
|
return resolve(true)
|
||||||
@@ -1041,7 +1040,7 @@ import { defineAsyncComponent } from 'vue'
|
|||||||
|
|
||||||
//tell websockets to truncate history at this save
|
//tell websockets to truncate history at this save
|
||||||
this.lastNoteHash = currentHash //Update last saved note hash
|
this.lastNoteHash = currentHash //Update last saved note hash
|
||||||
this.$io.emit('truncate_diffs_at_save', {'rawTextId':this.rawTextId, 'hash':currentHash })
|
this.$io.emit('truncate_diffs_at_save', {'noteId':this.noteid, 'hash':currentHash })
|
||||||
|
|
||||||
const postData = {
|
const postData = {
|
||||||
'noteId': this.currentNoteId,
|
'noteId': this.currentNoteId,
|
||||||
@@ -1093,7 +1092,8 @@ import { defineAsyncComponent } from 'vue'
|
|||||||
this.setupLoadedNoteData(response)
|
this.setupLoadedNoteData(response)
|
||||||
|
|
||||||
//Manually set squire text
|
//Manually set squire text
|
||||||
const editorElement = document.getElementById('squire-id')
|
const editorElement = this.$refs.squirebox
|
||||||
|
if(!editorElement){ return }
|
||||||
editorElement.innerHTML = response.data.text
|
editorElement.innerHTML = response.data.text
|
||||||
|
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
@@ -1146,6 +1146,15 @@ import { defineAsyncComponent } from 'vue'
|
|||||||
|
|
||||||
return hash;
|
return hash;
|
||||||
},
|
},
|
||||||
|
// Menu routes are pushed (not replaced) because the history entry is
|
||||||
|
// load-bearing: every menu closes via $router.go(-1). Forwarding the
|
||||||
|
// current query keeps ?open= alive across the menu and the pop back.
|
||||||
|
openMenuRoute(menu){
|
||||||
|
this.$router.push({
|
||||||
|
path: `/notes/open/${this.noteid}/menu/${menu}`,
|
||||||
|
query: this.$route.query,
|
||||||
|
})
|
||||||
|
},
|
||||||
closeButtonAction(playAnimation = false){
|
closeButtonAction(playAnimation = false){
|
||||||
this.sizeDown = playAnimation
|
this.sizeDown = playAnimation
|
||||||
const animationTimeout = (playAnimation ? 300 : 0)
|
const animationTimeout = (playAnimation ? 300 : 0)
|
||||||
@@ -1181,47 +1190,75 @@ import { defineAsyncComponent } from 'vue'
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
destroyWebSockets(){
|
destroyWebSockets(){
|
||||||
this.$io.removeListener('past_diffs')
|
|
||||||
this.$io.removeListener('update_user_count')
|
// By reference: removeListener(event) alone would remove the other
|
||||||
this.$io.removeListener('incoming_diff')
|
// open panels' handlers too. new_note_text_saved was never removed
|
||||||
this.$io.removeListener('update_note_attachments')
|
// at all and leaked a handler on every open/close.
|
||||||
|
this.$io.removeListener('update_user_count', this.onUpdateUserCount)
|
||||||
|
this.$io.removeListener('past_diffs', this.onPastDiffs)
|
||||||
|
this.$io.removeListener('incoming_diff', this.onIncomingDiff)
|
||||||
|
this.$io.removeListener('new_note_text_saved', this.onNewNoteTextSaved)
|
||||||
|
this.$io.removeListener('update_note_attachments', this.onUpdateNoteAttachments)
|
||||||
|
|
||||||
clearTimeout(this.idleNoteTimeout)
|
clearTimeout(this.idleNoteTimeout)
|
||||||
},
|
},
|
||||||
initWebsocketEvents(){
|
// Socket handlers are named methods rather than inline closures so that
|
||||||
|
// destroyWebSockets() can remove THIS panel's handler by reference.
|
||||||
//Tell server to push this note into a room
|
// removeListener(event) with no handler drops every open panel's.
|
||||||
this.$io.emit('join_room', this.rawTextId )
|
onFileUpload({noteId, imageCode}){
|
||||||
|
if(this.noteid == noteId && this.editor){
|
||||||
this.$io.on('update_user_count', userCount => {
|
this.editor.moveCursorToEnd()
|
||||||
|
this.editor.insertHTML(imageCode)
|
||||||
|
this.save()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onCloseNoteById(noteId){
|
||||||
|
if(noteId == this.noteid){
|
||||||
|
this.closeButtonAction()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onUpdateUserCount(userCount){
|
||||||
this.usersOnNote = userCount
|
this.usersOnNote = userCount
|
||||||
})
|
},
|
||||||
|
|
||||||
//Apply all diffs since last save
|
//Apply all diffs since last save
|
||||||
this.$io.on('past_diffs', diffSinceLastUpdate => {
|
onPastDiffs(diffSinceLastUpdate){
|
||||||
|
|
||||||
|
// Every open panel shares one socket, so filter to this note's room
|
||||||
|
if(!this.isDiffForThisNote(diffSinceLastUpdate)){ return }
|
||||||
|
|
||||||
if(diffSinceLastUpdate != null){
|
if(diffSinceLastUpdate != null){
|
||||||
this.diffsApplied = diffSinceLastUpdate.length
|
this.diffsApplied = diffSinceLastUpdate.length
|
||||||
// console.log('Got Diffs Total -> ', diffSinceLastUpdate)
|
|
||||||
}
|
}
|
||||||
console.log(diffSinceLastUpdate)
|
|
||||||
this.patchText(diffSinceLastUpdate)
|
this.patchText(diffSinceLastUpdate)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
this.restoreCaretPosition()
|
this.restoreCaretPosition()
|
||||||
.catch(e => console.log(e) )
|
.catch(e => console.log(e) )
|
||||||
})
|
})
|
||||||
})
|
},
|
||||||
|
onIncomingDiff(incomingDiff){
|
||||||
|
|
||||||
this.$io.on('incoming_diff', incomingDiff => {
|
if(!this.isDiffForThisNote([incomingDiff])){ return }
|
||||||
|
|
||||||
this.patchText([incomingDiff])
|
this.patchText([incomingDiff])
|
||||||
.then(() => {
|
.then(() => {
|
||||||
this.restoreCaretPosition()
|
this.restoreCaretPosition()
|
||||||
.catch(e => console.log(e) )
|
.catch(e => console.log(e) )
|
||||||
})
|
})
|
||||||
})
|
},
|
||||||
|
// A diff payload carries the raw-text id it belongs to. Anything without
|
||||||
|
// one is dropped rather than guessed at -- applying a patch to the wrong
|
||||||
|
// note is how note bodies got clobbered.
|
||||||
|
isDiffForThisNote(diffs){
|
||||||
|
|
||||||
this.$io.on('new_note_text_saved', ({noteId, hash}) => {
|
if(diffs == null){ return false }
|
||||||
|
|
||||||
|
const list = [].concat(diffs).filter(diff => diff != null)
|
||||||
|
if(list.length == 0){ return false }
|
||||||
|
|
||||||
|
return list.every(diff => String(diff.rawTextId) === String(this.rawTextId))
|
||||||
|
},
|
||||||
|
onNewNoteTextSaved({noteId, hash}){
|
||||||
|
|
||||||
if(this.statusText != 'saved'){
|
if(this.statusText != 'saved'){
|
||||||
return
|
return
|
||||||
@@ -1234,13 +1271,25 @@ import { defineAsyncComponent } from 'vue'
|
|||||||
if(sameIdCheck && differentHashCheck){
|
if(sameIdCheck && differentHashCheck){
|
||||||
this.loadNoteNextFromServer()
|
this.loadNoteNextFromServer()
|
||||||
}
|
}
|
||||||
|
},
|
||||||
})
|
onUpdateNoteAttachments(){
|
||||||
|
|
||||||
this.$io.on('update_note_attachments', () => {
|
|
||||||
this.getAttachments()
|
this.getAttachments()
|
||||||
|
},
|
||||||
|
initWebsocketEvents(){
|
||||||
|
|
||||||
|
//Tell server to push this note into a room
|
||||||
|
// Ask for a note, not a room. The server authorises against the
|
||||||
|
// token and derives the room itself.
|
||||||
|
this.$io.emit('join_room', {
|
||||||
|
noteId: this.noteid,
|
||||||
|
token: localStorage.getItem('loginToken'),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
this.$io.on('update_user_count', this.onUpdateUserCount)
|
||||||
|
this.$io.on('past_diffs', this.onPastDiffs)
|
||||||
|
this.$io.on('incoming_diff', this.onIncomingDiff)
|
||||||
|
this.$io.on('new_note_text_saved', this.onNewNoteTextSaved)
|
||||||
|
this.$io.on('update_note_attachments', this.onUpdateNoteAttachments)
|
||||||
},
|
},
|
||||||
logNoteInteraction(){
|
logNoteInteraction(){
|
||||||
|
|
||||||
@@ -1253,6 +1302,8 @@ import { defineAsyncComponent } from 'vue'
|
|||||||
},
|
},
|
||||||
saveCaretPosition(event){
|
saveCaretPosition(event){
|
||||||
|
|
||||||
|
return
|
||||||
|
|
||||||
//Find index of child element based on past range
|
//Find index of child element based on past range
|
||||||
const element = window.getSelection().getRangeAt(0).startContainer.parentNode
|
const element = window.getSelection().getRangeAt(0).startContainer.parentNode
|
||||||
|
|
||||||
@@ -1271,7 +1322,9 @@ import { defineAsyncComponent } from 'vue'
|
|||||||
|
|
||||||
if(!this.lastRange){ return resolve(true) }
|
if(!this.lastRange){ return resolve(true) }
|
||||||
|
|
||||||
const editorElement = document.getElementById('squire-id')
|
const editorElement = this.$refs.squirebox
|
||||||
|
if(!editorElement){ return resolve(true) }
|
||||||
|
|
||||||
const lastElement = editorElement.querySelector(this.lastRange)
|
const lastElement = editorElement.querySelector(this.lastRange)
|
||||||
|
|
||||||
if( !lastElement ){ return resolve(true) }
|
if( !lastElement ){ return resolve(true) }
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ import 'fomantic-ui-css/components/label.min.css'
|
|||||||
|
|
||||||
import './assets/semantic-helper.css'
|
import './assets/semantic-helper.css'
|
||||||
|
|
||||||
|
// Last, so Tailwind utilities win specificity ties against Fomantic
|
||||||
|
import './tailwind.css'
|
||||||
|
|
||||||
import io from 'socket.io-client'
|
import io from 'socket.io-client'
|
||||||
import EventBus from './EventBus'
|
import EventBus from './EventBus'
|
||||||
import Helpers from './Helpers'
|
import Helpers from './Helpers'
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ const SquireButtonFunctions = {
|
|||||||
//
|
//
|
||||||
|
|
||||||
//Fetch the container
|
//Fetch the container
|
||||||
let container = document.getElementById('squire-id')
|
let container = this.$refs.squirebox
|
||||||
this.$router.go(-1)
|
this.$router.go(-1)
|
||||||
|
|
||||||
setTimeout(()=>{
|
setTimeout(()=>{
|
||||||
@@ -189,7 +189,7 @@ const SquireButtonFunctions = {
|
|||||||
//
|
//
|
||||||
|
|
||||||
//Fetch the container
|
//Fetch the container
|
||||||
let container = document.getElementById('squire-id')
|
let container = this.$refs.squirebox
|
||||||
|
|
||||||
//Close menu if user is on mobile, then sort list
|
//Close menu if user is on mobile, then sort list
|
||||||
this.$router.go(-1)
|
this.$router.go(-1)
|
||||||
@@ -249,7 +249,7 @@ const SquireButtonFunctions = {
|
|||||||
//
|
//
|
||||||
|
|
||||||
//Fetch the container
|
//Fetch the container
|
||||||
let container = document.getElementById('squire-id')
|
let container = this.$refs.squirebox
|
||||||
|
|
||||||
//Close menu if user is on mobile
|
//Close menu if user is on mobile
|
||||||
this.$router.go(-1)
|
this.$router.go(-1)
|
||||||
@@ -317,7 +317,7 @@ const SquireButtonFunctions = {
|
|||||||
//
|
//
|
||||||
|
|
||||||
//Fetch the container
|
//Fetch the container
|
||||||
let container = document.getElementById('squire-id')
|
let container = this.$refs.squirebox
|
||||||
|
|
||||||
//Close menu if user is on mobile, then sort list
|
//Close menu if user is on mobile, then sort list
|
||||||
this.$router.go(-1)
|
this.$router.go(-1)
|
||||||
@@ -377,7 +377,7 @@ const SquireButtonFunctions = {
|
|||||||
// this.diffNoteText = this.editor._getHTML()
|
// this.diffNoteText = this.editor._getHTML()
|
||||||
|
|
||||||
//Make sure all list items have draggable property
|
//Make sure all list items have draggable property
|
||||||
// let container = document.getElementById('squire-id')
|
// let container = this.$refs.squirebox
|
||||||
// let listItems = container.getElementsByTagName('li')
|
// let listItems = container.getElementsByTagName('li')
|
||||||
// for(let itemIndex in listItems){
|
// for(let itemIndex in listItems){
|
||||||
// // console.log(listItems[itemIndex])
|
// // console.log(listItems[itemIndex])
|
||||||
|
|||||||
@@ -355,9 +355,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="ui form" :class="{'loading-day':loadingDay}">
|
<div class="ui form" :class="{'loading-day':loadingDay}">
|
||||||
<draggable v-model="fields" class="ui compact grid" ghost-class="ghost" @end="onDragEnd" handle=".draggable">
|
<draggable v-model="fields" class="ui compact grid" ghost-class="ghost" @end="onDragEnd" handle=".draggable" :item-key="field => field">
|
||||||
<div v-for="field in fields" :key="field"
|
<template #item="{ element: field }">
|
||||||
:class="userFields[field]?.width ? userFields[field]?.width+' wide stretched column':'eight wide stretched column'">
|
<div :class="userFields[field]?.width ? userFields[field]?.width+' wide stretched column':'eight wide stretched column'">
|
||||||
|
|
||||||
<div class="ui very compact grid input-grid">
|
<div class="ui very compact grid input-grid">
|
||||||
<!-- field label display -->
|
<!-- field label display -->
|
||||||
@@ -443,6 +443,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
</draggable>
|
</draggable>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -599,7 +600,6 @@
|
|||||||
<div :class="{'green':(tempChartDays == 7)}" v-on:click="tempChartDays = 7; " class="ui button">7</div>
|
<div :class="{'green':(tempChartDays == 7)}" v-on:click="tempChartDays = 7; " class="ui button">7</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- <div id="graphdiv" style="width: 100%; min-height: 320px;"></div> -->
|
|
||||||
<div class="ui divider"></div>
|
<div class="ui divider"></div>
|
||||||
|
|
||||||
<MetricGraphs
|
<MetricGraphs
|
||||||
@@ -686,8 +686,9 @@
|
|||||||
<div class="ui small dividing header">
|
<div class="ui small dividing header">
|
||||||
Active Fields
|
Active Fields
|
||||||
</div>
|
</div>
|
||||||
<draggable v-model="fields" class="ui very compact internally celled grid" ghost-class="ghost" @end="onDragEnd" handle=".draggable">
|
<draggable v-model="fields" class="ui very compact internally celled grid" ghost-class="ghost" @end="onDragEnd" handle=".draggable" :item-key="fieldId => fieldId">
|
||||||
<div v-for="fieldId in fields" :key="fieldId" class="row">
|
<template #item="{ element: fieldId }">
|
||||||
|
<div class="row">
|
||||||
<div class="eight wide column">
|
<div class="eight wide column">
|
||||||
<i class="draggable grip lines icon"></i>
|
<i class="draggable grip lines icon"></i>
|
||||||
{{ userFields[fieldId]?.label }}
|
{{ userFields[fieldId]?.label }}
|
||||||
@@ -708,14 +709,14 @@
|
|||||||
<!-- {{ openDay[fieldId] }} -->
|
<!-- {{ openDay[fieldId] }} -->
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
</draggable>
|
</draggable>
|
||||||
|
|
||||||
<div class="ui small dividing header">
|
<div class="ui small dividing header">
|
||||||
Other Fields
|
Other Fields
|
||||||
</div>
|
</div>
|
||||||
<draggable class="ui very compact internally celled grid">
|
<div class="ui very compact internally celled grid">
|
||||||
<template v-for="(field,fieldId) in userFields" :key="fieldId">
|
<div class="ui row" v-for="(field,fieldId) in unusedUserFields" :key="fieldId">
|
||||||
<div class="ui row" v-if="!fields.includes(fieldId)">
|
|
||||||
<div class="eight wide column">
|
<div class="eight wide column">
|
||||||
<i class="draggable grip lines icon"></i>
|
<i class="draggable grip lines icon"></i>
|
||||||
{{ userFields[fieldId]?.label }}
|
{{ userFields[fieldId]?.label }}
|
||||||
@@ -727,10 +728,7 @@
|
|||||||
<div class="ui tiny compact button" v-on:click="startUsingCustomField(fieldId)">Show</div>
|
<div class="ui tiny compact button" v-on:click="startUsingCustomField(fieldId)">Show</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
<div class="ui row" v-for="(field,fieldId) in unusedDefaultFields" :key="'default-'+fieldId">
|
||||||
<template v-for="(field,fieldId) in defaultFields" :key="fieldId">
|
|
||||||
<div class="ui row"
|
|
||||||
v-if="!fields.includes(fieldId) && !Object.keys(userFields).includes(fieldId)">
|
|
||||||
|
|
||||||
<div class="eight wide column">
|
<div class="eight wide column">
|
||||||
<i class="draggable grip lines icon"></i>
|
<i class="draggable grip lines icon"></i>
|
||||||
@@ -743,8 +741,7 @@
|
|||||||
<div class="ui tiny compact button" v-on:click="startUsingCustomField(fieldId)">Show</div>
|
<div class="ui tiny compact button" v-on:click="startUsingCustomField(fieldId)">Show</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</div>
|
||||||
</draggable>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
@@ -886,8 +883,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</modal>
|
</modal>
|
||||||
|
|
||||||
<!-- may or may not need digraph CSS, maybe disect -->
|
|
||||||
<!-- <link rel="stylesheet" href="//cdnjs.cloudflare.com/ajax/libs/dygraph/2.1.0/dygraph.min.css" /> -->
|
|
||||||
|
|
||||||
<div class="ui basic segment"></div>
|
<div class="ui basic segment"></div>
|
||||||
|
|
||||||
@@ -920,6 +915,7 @@ import { defineAsyncComponent } from 'vue'
|
|||||||
return {
|
return {
|
||||||
getFieldColor: field => this.getFieldColor(field),
|
getFieldColor: field => this.getFieldColor(field),
|
||||||
getFieldIcon: field => this.getFieldIcon(field),
|
getFieldIcon: field => this.getFieldIcon(field),
|
||||||
|
getFieldLabel: field => this.getFieldLabel(field),
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
components: {
|
components: {
|
||||||
@@ -1036,6 +1032,21 @@ import { defineAsyncComponent } from 'vue'
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
// vuedraggable's computeNodes walks the default slot's vnodes directly
|
||||||
|
// and cannot handle the fragments that <template v-for> produces, so
|
||||||
|
// the filtering happens here instead of with a v-if in the slot.
|
||||||
|
unusedUserFields(){
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(this.userFields).filter(([fieldId]) => !this.fields.includes(fieldId))
|
||||||
|
)
|
||||||
|
},
|
||||||
|
unusedDefaultFields(){
|
||||||
|
const userFieldIds = Object.keys(this.userFields)
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(this.defaultFields).filter(([fieldId]) =>
|
||||||
|
!this.fields.includes(fieldId) && !userFieldIds.includes(fieldId))
|
||||||
|
)
|
||||||
|
},
|
||||||
getToday(){
|
getToday(){
|
||||||
return this.generateDateCode(new Date())
|
return this.generateDateCode(new Date())
|
||||||
},
|
},
|
||||||
@@ -1115,6 +1126,27 @@ import { defineAsyncComponent } from 'vue'
|
|||||||
return color
|
return color
|
||||||
// userFields[field]?.icon
|
// userFields[field]?.icon
|
||||||
},
|
},
|
||||||
|
getFieldLabel(field){
|
||||||
|
|
||||||
|
let label = null
|
||||||
|
|
||||||
|
// user defined
|
||||||
|
if(this.userFields[field]?.label){
|
||||||
|
label = this.userFields[field].label
|
||||||
|
}
|
||||||
|
|
||||||
|
// default field value
|
||||||
|
if(!label && this.defaultFields[field]?.label){
|
||||||
|
label = this.defaultFields[field]?.label
|
||||||
|
}
|
||||||
|
|
||||||
|
// fall back to the raw id
|
||||||
|
if(!label){
|
||||||
|
label = field
|
||||||
|
}
|
||||||
|
|
||||||
|
return label
|
||||||
|
},
|
||||||
getFieldIcon(field){
|
getFieldIcon(field){
|
||||||
|
|
||||||
let icon = null
|
let icon = null
|
||||||
@@ -1389,6 +1421,10 @@ import { defineAsyncComponent } from 'vue'
|
|||||||
// update field to be reactive
|
// update field to be reactive
|
||||||
this.openDay[fieldId] = value
|
this.openDay[fieldId] = value
|
||||||
|
|
||||||
|
// Push straight into cycleData so the graphs redraw immediately;
|
||||||
|
// the server write below stays debounced.
|
||||||
|
this.applyOpenDayToCycleData()
|
||||||
|
|
||||||
// remove debounce and set to modified
|
// remove debounce and set to modified
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
//0 blank, 1 modified, 2 saving, 3 saved
|
//0 blank, 1 modified, 2 saving, 3 saved
|
||||||
@@ -1422,7 +1458,9 @@ import { defineAsyncComponent } from 'vue'
|
|||||||
|
|
||||||
|
|
||||||
},
|
},
|
||||||
saveDayData(){
|
// Local-only: fold the open day's values into cycleData. Cheap and
|
||||||
|
// synchronous, so callers can run it on every keystroke.
|
||||||
|
applyOpenDayToCycleData(){
|
||||||
|
|
||||||
// remove empty keys
|
// remove empty keys
|
||||||
let cleanDayData = {}
|
let cleanDayData = {}
|
||||||
@@ -1432,12 +1470,15 @@ import { defineAsyncComponent } from 'vue'
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Only save entry if there is data
|
// Only keep entry if there is data
|
||||||
delete this.cycleData[this.calendar.dateCode]
|
delete this.cycleData[this.calendar.dateCode]
|
||||||
if(Object.keys(cleanDayData).length > 0){
|
if(Object.keys(cleanDayData).length > 0){
|
||||||
this.cycleData[this.calendar.dateCode] = cleanDayData
|
this.cycleData[this.calendar.dateCode] = cleanDayData
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
saveDayData(){
|
||||||
|
|
||||||
|
this.applyOpenDayToCycleData()
|
||||||
this.saveCycleData()
|
this.saveCycleData()
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -206,6 +206,9 @@ import { defineAsyncComponent } from 'vue'
|
|||||||
searchTags: [],
|
searchTags: [],
|
||||||
notes: [],
|
notes: [],
|
||||||
openNotes: [],
|
openNotes: [],
|
||||||
|
// fullPath of our own last URL write, so the $route watcher can
|
||||||
|
// recognise it and not treat it as an inbound navigation
|
||||||
|
lastSyncedFullPath: null,
|
||||||
collapseFloatingList: false,
|
collapseFloatingList: false,
|
||||||
highlights: [],
|
highlights: [],
|
||||||
searchDebounce: null,
|
searchDebounce: null,
|
||||||
@@ -383,11 +386,26 @@ import { defineAsyncComponent } from 'vue'
|
|||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
|
|
||||||
//Open note on PAGE LOAD if ID is set
|
// Restore every note named in ?open=
|
||||||
if(this.$route.params.id > 1){
|
const ids = this.parseOpenQuery(this.$route.query.open)
|
||||||
this.openNote(this.$route.params.id)
|
|
||||||
|
// Fold the legacy /notes/open/:id deep link in, first position
|
||||||
|
const paramId = parseInt(this.$route.params.id, 10)
|
||||||
|
if(Number.isInteger(paramId) && paramId > 1 && !ids.includes(paramId)){
|
||||||
|
ids.unshift(paramId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Push directly rather than via openNote(), which would write the URL
|
||||||
|
// once per note; one normalising write follows instead.
|
||||||
|
ids.forEach(id => {
|
||||||
|
if(!this.openNotes.includes(id)){
|
||||||
|
this.openNotes.push(id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Unconditional, so a junk ?open=abc gets scrubbed too
|
||||||
|
this.syncOpenNotesToUrl()
|
||||||
|
|
||||||
//Loads initial batch and tags
|
//Loads initial batch and tags
|
||||||
this.reset()
|
this.reset()
|
||||||
|
|
||||||
@@ -398,17 +416,31 @@ import { defineAsyncComponent } from 'vue'
|
|||||||
},
|
},
|
||||||
'$route' (to, from) {
|
'$route' (to, from) {
|
||||||
|
|
||||||
|
// Our own URL write, not an inbound navigation
|
||||||
|
if(to.fullPath === this.lastSyncedFullPath){ return }
|
||||||
|
|
||||||
// Reload the notes if returning to this page
|
// The discriminator is the absence of ?open=, NOT the path. A menu's
|
||||||
if(to.fullPath == '/notes' && !from.fullPath.includes('/notes/open/')){
|
// $router.go(-1) lands on /notes?open=5 with `from` containing
|
||||||
|
// '/notes/open/', so a path comparison here would close every note
|
||||||
|
// every time a side menu is dismissed.
|
||||||
|
const landingOnBareNotes = (to.path === '/notes' && !to.query.open)
|
||||||
|
|
||||||
|
// Reload the notes if arriving at an empty /notes from elsewhere
|
||||||
|
if(landingOnBareNotes && !from.path.startsWith('/notes/open/') && from.path !== '/notes'){
|
||||||
this.reset()
|
this.reset()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close all notes if returning to /notes page
|
// Explicit navigation to a note-less /notes closes everything
|
||||||
if(to.fullPath == '/notes' && from.fullPath.includes('/notes/open/')){
|
if(landingOnBareNotes && this.openNotes.length > 0){
|
||||||
this.closeAllNotes()
|
this.closeAllNotes()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stale history entry (e.g. go(-1) after another note was closed):
|
||||||
|
// rewrite the URL from the array. Never read it.
|
||||||
|
if(to.path === '/notes' && to.query.open && to.query.open !== this.openNotes.join(',')){
|
||||||
|
this.syncOpenNotesToUrl()
|
||||||
|
}
|
||||||
|
|
||||||
//Lookup tags set in URL
|
//Lookup tags set in URL
|
||||||
if(to.params.tag && this.$store.getters.totals && this.$store.getters.totals['tags'][to.params.tag]){
|
if(to.params.tag && this.$store.getters.totals && this.$store.getters.totals['tags'][to.params.tag]){
|
||||||
|
|
||||||
@@ -435,81 +467,121 @@ import { defineAsyncComponent } from 'vue'
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
openNote(id, event = null){
|
// Where the open-note URL should point. A menu or a tag search owns the
|
||||||
|
// path, so only a plain notes view gets normalised back to /notes.
|
||||||
|
openNotesTargetPath(){
|
||||||
|
|
||||||
//
|
// A side menu is open; its close button is a $router.go(-1), so the
|
||||||
|
// path has to stay put or there is nowhere sane to land.
|
||||||
|
if(this.$route.params.openMenu){ return this.$route.path }
|
||||||
|
|
||||||
const intId = parseInt(id)
|
// Preserve a tag search
|
||||||
if(this.openNotes.includes(intId)){
|
if(this.$route.params.tag){ return this.$route.path }
|
||||||
|
|
||||||
console.log('Open already open note?')
|
return '/notes'
|
||||||
|
},
|
||||||
|
// Mirror openNotes into ?open=. This is the ONLY place the open-note
|
||||||
|
// URL is written.
|
||||||
|
syncOpenNotesToUrl(){
|
||||||
|
|
||||||
// const openIndex = this.openNotes.indexOf(intId)
|
const query = Object.assign({}, this.$route.query)
|
||||||
// if(openIndex != -1){
|
|
||||||
// console.log('Open note and remove it ', intId + ' on index ' + openIndex)
|
if(this.openNotes.length > 0){
|
||||||
// this.openNotes.splice(openIndex, 1)
|
query.open = this.openNotes.join(',')
|
||||||
// }
|
}
|
||||||
// this.$bus.$emit('close_note_by_id', intId)
|
else {
|
||||||
|
delete query.open
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = { path: this.openNotesTargetPath(), query }
|
||||||
|
const targetFullPath = this.$router.resolve(target).fullPath
|
||||||
|
|
||||||
|
// Already correct -- don't navigate at all
|
||||||
|
if(targetFullPath === this.$route.fullPath){
|
||||||
|
this.lastSyncedFullPath = targetFullPath
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set before navigating so the watcher sees it
|
||||||
|
this.lastSyncedFullPath = targetFullPath
|
||||||
|
|
||||||
|
this.$router.replace(target).catch(() => {})
|
||||||
|
},
|
||||||
|
// Tolerant parse of ?open=. Handles 'abc', '1,,2,2', '-4' and the
|
||||||
|
// repeated form ?open=1&open=2 (which arrives as an array).
|
||||||
|
parseOpenQuery(raw){
|
||||||
|
|
||||||
|
if(raw === undefined || raw === null){ return [] }
|
||||||
|
|
||||||
|
// Cap: a hand-crafted URL shouldn't be able to fire 50 note fetches
|
||||||
|
const MAX_RESTORE = 8
|
||||||
|
|
||||||
|
return [].concat(raw)
|
||||||
|
.join(',')
|
||||||
|
.split(',')
|
||||||
|
.map(value => parseInt(value, 10))
|
||||||
|
.filter(value => Number.isInteger(value) && value > 0)
|
||||||
|
.filter((value, index, all) => all.indexOf(value) === index)
|
||||||
|
.slice(0, MAX_RESTORE)
|
||||||
|
},
|
||||||
|
openNote(id, event = null){
|
||||||
|
|
||||||
|
const intId = parseInt(id, 10)
|
||||||
|
|
||||||
|
// Also absorbs the undefined the '$route.params.id' watcher now gets
|
||||||
|
// when we normalise /notes/open/5 into /notes?open=5
|
||||||
|
if(!Number.isInteger(intId) || intId < 1){ return }
|
||||||
|
|
||||||
//Don't open note if a link is clicked in display card
|
//Don't open note if a link is clicked in display card
|
||||||
if(event && event.target && event.target.nodeName){
|
if(event && event.target && event.target.nodeName){
|
||||||
const nodeClick = event.target.nodeName
|
const nodeClick = event.target.nodeName
|
||||||
if(nodeClick == 'A'){ return }
|
if(nodeClick == 'A'){ return }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Push note to stack if not open
|
if(this.openNotes.includes(intId)){
|
||||||
if(Number.isInteger(intId) && !this.openNotes.includes(intId)){
|
// Already open, but an external push('/notes/open/5') still needs
|
||||||
this.openNotes.push(intId)
|
// the URL normalised back to the full set
|
||||||
}
|
this.syncOpenNotesToUrl()
|
||||||
|
|
||||||
this.$nextTick(() => {
|
|
||||||
// change route if open ID is not the same as current ID
|
|
||||||
if(this.$route.params.id != id){
|
|
||||||
console.log('Open note, change route -> route id ' + this.$route.params.id + ' note id ->' + id + ', ' +(this.$route.params.id == id))
|
|
||||||
this.$router.push('/notes/open/'+id)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.openNotes.push(intId)
|
||||||
|
this.syncOpenNotesToUrl()
|
||||||
},
|
},
|
||||||
closeNote(noteId, modified){
|
closeNote(noteId, modified, syncUrl = true){
|
||||||
|
|
||||||
console.log('close note', this.$route.fullPath)
|
// parseInt: indexOf is strict, and ids arriving from the URL are
|
||||||
|
// strings. Without this the splice silently misses.
|
||||||
|
const intId = parseInt(noteId, 10)
|
||||||
|
const openIndex = this.openNotes.indexOf(intId)
|
||||||
|
const didClose = (openIndex !== -1)
|
||||||
|
|
||||||
const openIndex = this.openNotes.indexOf(noteId)
|
if(didClose){
|
||||||
if(openIndex != -1){
|
|
||||||
console.log('Removing note id ', noteId + ' on index ' + openIndex)
|
|
||||||
this.openNotes.splice(openIndex, 1)
|
this.openNotes.splice(openIndex, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// //A note has been closed
|
// Only write the URL when the array actually changed. NoteInputPanel
|
||||||
// if(this.$route.fullPath != '/notes'){
|
// emits close_active_note twice per note (closeButtonAction -> close(),
|
||||||
// this.$router.push('/notes')
|
// then beforeUnmount -> close()); the second arrives after the splice.
|
||||||
// }
|
if(didClose && syncUrl){
|
||||||
if(this.openNotes.length == 0 && this.$route.fullPath != '/notes'){
|
this.syncOpenNotesToUrl()
|
||||||
this.$router.push('/notes')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if(modified){
|
if(modified){
|
||||||
console.log('Just closed Note -> ' + noteId + ', modified -> ', modified)
|
|
||||||
this.$store.dispatch('fetchAndUpdateUserTotals')
|
this.$store.dispatch('fetchAndUpdateUserTotals')
|
||||||
//Focus and animate if modified
|
//Focus and animate if modified
|
||||||
this.updateSingleNote(noteId, modified)
|
this.updateSingleNote(noteId, modified)
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('closeNote(): Open notes length ', this.openNotes.length)
|
|
||||||
},
|
},
|
||||||
closeAllNotes(){
|
closeAllNotes(){
|
||||||
console.log('Close all notes ------------')
|
|
||||||
|
// Backwards, so the in-loop splice can't skip an entry. URL writes are
|
||||||
|
// deferred so this is one navigation rather than N.
|
||||||
for (let i = this.openNotes.length - 1; i >= 0; i--) {
|
for (let i = this.openNotes.length - 1; i >= 0; i--) {
|
||||||
console.log('Close all notes -> ' + this.openNotes[i])
|
this.closeNote(this.openNotes[i], false, false)
|
||||||
this.closeNote(this.openNotes[i])
|
|
||||||
}
|
}
|
||||||
console.log('----------------')
|
|
||||||
|
this.syncOpenNotesToUrl()
|
||||||
},
|
},
|
||||||
toggleTagFilter(tagId){
|
toggleTagFilter(tagId){
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
/*
|
||||||
|
* Tailwind 4, wired to coexist with Fomantic UI.
|
||||||
|
*
|
||||||
|
* Deliberately NOT `@import "tailwindcss"` (the whole thing). That pulls in
|
||||||
|
* Preflight as `@layer base`, which zeroes margin/padding/border on every
|
||||||
|
* element, strips heading sizes and list markers, forces img/svg to
|
||||||
|
* display:block and makes buttons and inputs transparent. Dropped on top of
|
||||||
|
* Fomantic it visibly breaks the existing UI.
|
||||||
|
*
|
||||||
|
* Utilities are imported UNLAYERED on purpose. Tailwind's own index.css wraps
|
||||||
|
* them in `@layer utilities`, and an unlayered stylesheet beats any layered
|
||||||
|
* rule regardless of specificity -- since all of Fomantic is unlayered, the
|
||||||
|
* utilities would quietly lose every conflict. Unlayered they compete on normal
|
||||||
|
* specificity instead, and win ties by source order because this file is
|
||||||
|
* imported last in main.js.
|
||||||
|
*
|
||||||
|
* Fomantic mostly uses two-class selectors (.ui.button = specificity 0,2,0), so
|
||||||
|
* it still out-specifies a single utility (0,1,0). To beat Fomantic on a
|
||||||
|
* property it already sets, use the important modifier -- `p-4!`, `bg-red-500!`.
|
||||||
|
*
|
||||||
|
* To try Preflight anyway, add: @import "tailwindcss/preflight.css" layer(base);
|
||||||
|
*/
|
||||||
|
|
||||||
|
@import "tailwindcss/theme.css" layer(theme);
|
||||||
|
@import "tailwindcss/utilities.css";
|
||||||
Reference in new issue
Block a user