From 5bebe072b2edf7ed9f11cffdb38d1d5065937643 Mon Sep 17 00:00:00 2001 From: Max Date: Wed, 28 Oct 2026 10:11:40 -0700 Subject: [PATCH] Tons of fixes to prepare for staged releases. --- server/index.js | 156 ++++++++---- server/models/Attachment.js | 8 +- server/models/Note.js | 42 +++- server/models/ShareNote.js | 2 +- server/models/Tag.js | 2 +- server/routes/attachmentController.js | 28 ++- server/routes/cycletrackingController.js | 84 ++++--- server/routes/metrictrackingController.js | 96 +++---- server/routes/noteController.js | 64 ++++- server/routes/quicknoteController.js | 16 +- server/routes/tagController.js | 32 ++- server/routes/userController.js | 4 - v2client-vite-vue3/package.json | 2 + v2client-vite-vue3/pnpm-lock.yaml | 40 ++- .../src/assets/semantic-helper.css | 5 + .../components/Metrictracking/LineGraph.vue | 212 ++++++++++++++++ .../Metrictracking/MetricGraphsComponent.vue | 177 ++++--------- .../Metrictracking/PillCalendarGraph.vue | 90 ++++++- .../src/components/NoteInputPanel.vue | 235 +++++++++++------- v2client-vite-vue3/src/main.js | 3 + .../src/mixins/SquireButtonFunctions.js | 10 +- .../src/pages/MetrictrackingPage.vue | 79 ++++-- v2client-vite-vue3/src/pages/NotesPage.vue | 172 +++++++++---- v2client-vite-vue3/src/tailwind.css | 25 ++ 24 files changed, 1110 insertions(+), 474 deletions(-) create mode 100644 v2client-vite-vue3/src/components/Metrictracking/LineGraph.vue create mode 100644 v2client-vite-vue3/src/tailwind.css diff --git a/server/index.js b/server/index.js index 0888712..347db8f 100644 --- a/server/index.js +++ b/server/index.js @@ -16,6 +16,9 @@ require('module-alias')(require('path').join(__dirname, '..')) //Auth helper, used for decoding users web token 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 const helmet = require('helmet') @@ -51,6 +54,14 @@ global.SocketIo = io 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){ //When a user connects, add them to their own room @@ -59,11 +70,11 @@ io.on('connection', function(socket){ socket.on('user_connect', token => { Auth.decodeToken(token) .then(userData => { - socket.join(userData.userId) + socket.join(userRoom(userData.userId)) //Track active logged in user accounts - const usersInRoom = io.sockets.adapter.rooms[userData.userId] - io.to(userData.userId).emit('update_active_user_count', usersInRoom.length) + const usersInRoom = io.sockets.adapter.rooms[userRoom(userData.userId)] + io.to(userRoom(userData.userId)).emit('update_active_user_count', usersInRoom.length) }).catch(error => { //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 => { Auth.decodeToken(token) .then(userData => { - socket.join(userData.userId) + socket.join(userRoom(userData.userId)) //Track active logged in user accounts - const usersInRoom = io.sockets.adapter.rooms[userData.userId] - io.to(userData.userId).emit('update_active_user_count', usersInRoom.length) + const usersInRoom = io.sockets.adapter.rooms[userRoom(userData.userId)] + io.to(userRoom(userData.userId)).emit('update_active_user_count', usersInRoom.length) }).catch(error => { // console.log(error) @@ -107,67 +118,106 @@ io.on('connection', function(socket){ }) }) - socket.on('join_room', rawTextId => { - // Join user to rawtextid room when they enter - socket.join(rawTextId) + // + // The client asks for a NOTE, not a room. The server authorises the request + // 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 } = {}) => { - //If there are past diffs for this note, send them to the user - if(noteDiffs[rawTextId] != undefined){ - - //Sort all note diffs by when they were created. - noteDiffs[rawTextId].sort((a,b) => { return a.time - b.time }) + Auth.decodeToken(token) + .then(userData => { - //Emit all sorted diffs to user - socket.emit('past_diffs', noteDiffs[rawTextId]) - } + return Note.getRawTextIdForUser(noteId, userData.userId) + .then(rawTextId => { - const usersInRoom = io.sockets.adapter.rooms[rawTextId] - if(usersInRoom){ - //Update users in room count - io.to(rawTextId).emit('update_user_count', usersInRoom.length) - } - }) + // No claim on this note -> no room, no diffs + if(rawTextId == null){ return } - socket.on('leave_room', roomId => { - socket.leave(roomId) - // console.log('User Left room') + const room = noteRoom(rawTextId) - const usersInRoom = io.sockets.adapter.rooms[roomId] - if(usersInRoom){ - // console.log('Users in room', usersInRoom.length) - io.to(roomId).emit('update_user_count', usersInRoom.length) - } - - }) + // 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.on('note_diff', data => { + socket.join(room) - //Log each diff for note - const noteId = data.id - delete data.id - if(noteDiffs[noteId] == undefined){ noteDiffs[noteId] = [] } - data.time = +new Date - - noteDiffs[noteId].push(data) + //If there are past diffs for this note, send them to the user + if(noteDiffs[rawTextId] != undefined){ - // Go over each user in this note-room - io.in(noteId).clients((error, clients) => { - if (error) throw error; + //Sort all note diffs by when they were created. + noteDiffs[rawTextId].sort((a,b) => { return a.time - b.time }) - //Go through each client in note-room and send them the diff - clients.forEach(socketId => { - // only send off diff if user - if(socketId != socket.id){ - io.to(socketId).emit('incoming_diff', data) + //Emit all sorted diffs to user + socket.emit('past_diffs', noteDiffs[rawTextId]) + } + + const usersInRoom = io.sockets.adapter.rooms[room] + if(usersInRoom){ + //Update users in room count + io.to(room).emit('update_user_count', usersInRoom.length) } }) - - }); - + }) + .catch(() => { + //Not logged in, or no access. Join nothing. + }) }) - socket.on('truncate_diffs_at_save', checkpoint => { + socket.on('leave_room', ({ noteId } = {}) => { + // 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){ + io.to(entry.room).emit('update_user_count', usersInRoom.length) + } + }) + + 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 + if(noteDiffs[rawTextId] == undefined){ noteDiffs[rawTextId] = [] } + + // 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 + + noteDiffs[rawTextId].push(payload) + + // Everyone in the note room except the sender + socket.to(room).emit('incoming_diff', payload) + }) + + socket.on('truncate_diffs_at_save', (checkpoint = {}) => { + + const entry = socket.authorizedRooms && socket.authorizedRooms[checkpoint.noteId] + if(!entry){ return } + 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] if(diffSet && diffSet.length > 0){ diff --git a/server/models/Attachment.js b/server/models/Attachment.js index 50d9100..7571c54 100644 --- a/server/models/Attachment.js +++ b/server/models/Attachment.js @@ -300,10 +300,10 @@ Attachment.scanTextForWebsites = (io, userId, noteId, noteText) => { Attachment.scrapeUrlsCreateAttachments(userId, noteId, foundUrls).then( freshlyScrapedText => { //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 - SocketIo.to(userId).emit('update_note_attachments') + SocketIo.to(`user:${userId}`).emit('update_note_attachments') solrAttachmentText += freshlyScrapedText resolve(solrAttachmentText) @@ -597,10 +597,10 @@ Attachment.pushUrl = (pushkey,url) => { if(typeof SocketIo != 'undefined'){ //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 - SocketIo.to(userId).emit('update_note_attachments') + SocketIo.to(`user:${userId}`).emit('update_note_attachments') } return resolve(true) diff --git a/server/models/Note.js b/server/models/Note.js index da15dc5..0302538 100644 --- a/server/models/Note.js +++ b/server/models/Note.js @@ -199,7 +199,7 @@ Note.create = (userId, noteTitle = '', noteText = '', masterKey) => { .then((rows, fields) => { 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 @@ -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]) .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) => { 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 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 = ?', [pinned, now, noteId, userId]) .then((rows, fields) => { - SocketIo.to(userId).emit('note_attribute_modified', noteId) + SocketIo.to(`user:${userId}`).emit('note_attribute_modified', noteId) 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 = ?', [archived, now, noteId, userId]) .then((rows, fields) => { - SocketIo.to(userId).emit('note_attribute_modified', noteId) + SocketIo.to(`user:${userId}`).emit('note_attribute_modified', noteId) resolve(true) }) }) @@ -581,7 +581,7 @@ Note.setTrashed = (userId, noteId, trashedBoolean, masterKey) => { Note.reindex(userId, masterKey, removeFromIndex) - SocketIo.to(userId).emit('note_attribute_modified', noteId) + SocketIo.to(`user:${userId}`).emit('note_attribute_modified', noteId) 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){ //Remove note ID from index @@ -1190,4 +1190,30 @@ Note.search = (userId, searchQuery, searchTags, fastFilters, masterKey) => { .catch(console.log) }) -} \ No newline at end of file +} + +// +// 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) + }) + }) +} diff --git a/server/models/ShareNote.js b/server/models/ShareNote.js index 04b7991..e1f7ef2 100644 --- a/server/models/ShareNote.js +++ b/server/models/ShareNote.js @@ -75,7 +75,7 @@ ShareNote.addUserToSharedNote = (userId, noteId, shareUserId, masterKey) => { const sharedUserNoteId = rows[0]['insertId'] //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 return resolve({success, shareUserId, sharedUserNoteId}) diff --git a/server/models/Tag.js b/server/models/Tag.js index deedc51..237bbca 100644 --- a/server/models/Tag.js +++ b/server/models/Tag.js @@ -58,7 +58,7 @@ Tag.addToNote = (userId, noteId, tagText) => { Tag.associateWithNote(userId, noteId, newTagId) .then( result => { - SocketIo.to(userId).emit('new_note_text_saved', {noteId}) + SocketIo.to(`user:${userId}`).emit('new_note_text_saved', {noteId}) resolve(result) }) }) diff --git a/server/routes/attachmentController.js b/server/routes/attachmentController.js index 16fee9e..a1b1a8c 100644 --- a/server/routes/attachmentController.js +++ b/server/routes/attachmentController.js @@ -7,8 +7,6 @@ let router = express.Router() let Attachment = require('@models/Attachment') let Note = require('@models/Note') -let userId = null -let masterKey = null // middleware that is specific to this router router.use(function setUserId (req, res, next) { @@ -19,23 +17,33 @@ router.use(function setUserId (req, res, next) { } if(req.headers.userId){ - userId = req.headers.userId - masterKey = req.headers.masterKey next() } }) 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) .then( data => res.send(data) ) }) 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) .then( data => res.send(data) ) }) 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) .then( result => { Note.reindex(userId, req.body.noteId) @@ -44,11 +52,19 @@ router.post('/update', 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) .then( data => res.send(data) ) }) 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 const noteId = parseInt(req.body.noteId) @@ -67,6 +83,10 @@ router.post('/upload', upload.single('file'), function (req, res, next) { // get push key 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) .then( data => res.send(data) ) diff --git a/server/routes/cycletrackingController.js b/server/routes/cycletrackingController.js index 1efb797..83d1946 100644 --- a/server/routes/cycletrackingController.js +++ b/server/routes/cycletrackingController.js @@ -1,41 +1,45 @@ -// -// /api/cycle-tracking -// - -var express = require('express') -var router = express.Router() - -let CycleTracking = require('@models/CycleTracking'); - -let userId = null -let masterKey = null - -// middleware that is specific to this router -router.use(function setUserId (req, res, next) { - - //Session key is required to continue - if(!req.headers.sessionId){ - next('Unauthorized') - } - - if(req.headers.userId){ - userId = req.headers.userId - masterKey = req.headers.masterKey - next() - } -}) - -//Get quick note text -router.post('/get', function (req, res) { - CycleTracking.get(userId, masterKey) - .then( data => res.send(data) ) -}) - -//Push text to quick note -router.post('/save', function (req, res) { - CycleTracking.save(userId, req.body.cycleData, masterKey) - .then( data => res.send(data) ) -}) - - +// +// /api/cycle-tracking +// + +var express = require('express') +var router = express.Router() + +let CycleTracking = require('@models/CycleTracking'); + + +// middleware that is specific to this router +router.use(function setUserId (req, res, next) { + + //Session key is required to continue + if(!req.headers.sessionId){ + next('Unauthorized') + } + + if(req.headers.userId){ + next() + } +}) + +//Get quick note text +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) + .then( data => res.send(data) ) +}) + +//Push text to quick note +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) + .then( data => res.send(data) ) +}) + + module.exports = router \ No newline at end of file diff --git a/server/routes/metrictrackingController.js b/server/routes/metrictrackingController.js index f6fe21d..20bd642 100644 --- a/server/routes/metrictrackingController.js +++ b/server/routes/metrictrackingController.js @@ -1,45 +1,53 @@ -// -// /api/metric-tracking -// - -var express = require('express') -var router = express.Router() - -let MetricTracking = require('@models/MetricTracking'); - -let userId = null -let masterKey = null - -// middleware that is specific to this router -router.use(function setUserId (req, res, next) { - - //Session key is required to continue - if(!req.headers.sessionId){ - next('Unauthorized') - } - - if(req.headers.userId){ - userId = req.headers.userId - masterKey = req.headers.masterKey - next() - } -}) - -router.post('/get', function (req, res) { - MetricTracking.get(userId, masterKey) - .then( data => res.send(data) ) -}) - -router.post('/create', function (req, res) { - MetricTracking.create(userId, masterKey) - .then( data => res.send(data) ) -}) - -//Push text to quick note -router.post('/save', function (req, res) { - MetricTracking.save(userId, req.body.cycleData, masterKey) - .then( data => res.send(data) ) -}) - - +// +// /api/metric-tracking +// + +var express = require('express') +var router = express.Router() + +let MetricTracking = require('@models/MetricTracking'); + + +// middleware that is specific to this router +router.use(function setUserId (req, res, next) { + + //Session key is required to continue + if(!req.headers.sessionId){ + next('Unauthorized') + } + + if(req.headers.userId){ + next() + } +}) + +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) + .then( data => res.send(data) ) +}) + +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) + .then( data => res.send(data) ) +}) + +//Push text to quick note +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) + .then( data => res.send(data) ) +}) + + module.exports = router \ No newline at end of file diff --git a/server/routes/noteController.js b/server/routes/noteController.js index 2c59a05..61766fc 100644 --- a/server/routes/noteController.js +++ b/server/routes/noteController.js @@ -5,8 +5,6 @@ let Note = require('@models/Note') let User = require('@models/User') let ShareNote = require('@models/ShareNote') -let userId = null -let masterKey = null // middleware that is specific to this router router.use(function setUserId (req, res, next) { @@ -17,8 +15,6 @@ router.use(function setUserId (req, res, next) { } if(req.headers.userId){ - userId = req.headers.userId - masterKey = req.headers.masterKey next() } }) @@ -27,6 +23,10 @@ router.use(function setUserId (req, res, next) { // Note actions // 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) .then( noteObject => { @@ -39,21 +39,37 @@ router.post('/get', 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) .then( data => res.send(data) ) }) 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) .then( id => res.send({id}) ) }) 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) .then( id => res.send({id}) ) }) 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) .then( NoteAndTags => { res.send(NoteAndTags) @@ -61,6 +77,10 @@ router.post('/search', 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) .then( data => { res.send(data) @@ -72,18 +92,30 @@ router.post('/reindex', function (req, res) { // Update single note attributes // 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) .then( results => { res.send(results) }) }) 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) .then( results => { res.send(results) }) }) 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) .then( results => { res.send(results) @@ -94,11 +126,19 @@ router.post('/settrashed', function (req, res) { // Share Note Actions // 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) .then(results => res.send(results)) }) 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) User.getByUserName(req.body.username) .then( user => { @@ -111,22 +151,38 @@ router.post('/shareadduser', 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) ShareNote.removeUserFromSharedNote(userId, req.body.noteId, req.body.shareUserNoteId, masterKey) .then(results => res.send(results)) }) 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 ShareNote.migrateToShared(userId, req.body.noteId, masterKey) .then(results => res.send(true)) }) 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 ShareNote.decryptSharedKey(userId, req.body.noteId, masterKey) .then(results => res.send(results)) }) 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 ShareNote.migrateToNormal(userId, req.body.noteId, masterKey) .then(results => res.send(true)) diff --git a/server/routes/quicknoteController.js b/server/routes/quicknoteController.js index 1e79a75..f7aa411 100644 --- a/server/routes/quicknoteController.js +++ b/server/routes/quicknoteController.js @@ -3,8 +3,6 @@ var router = express.Router() let QuickNote = require('@models/QuickNote'); -let userId = null -let masterKey = null // middleware that is specific to this router router.use(function setUserId (req, res, next) { @@ -15,26 +13,36 @@ router.use(function setUserId (req, res, next) { } if(req.headers.userId){ - userId = req.headers.userId - masterKey = req.headers.masterKey next() } }) //Get quick note text 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) .then( data => res.send(data) ) }) //Push text to quick note 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) .then( data => res.send(data) ) }) //Push text to quick note 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) .then( data => res.send(data) ) }) diff --git a/server/routes/tagController.js b/server/routes/tagController.js index 7157221..bb57298 100644 --- a/server/routes/tagController.js +++ b/server/routes/tagController.js @@ -3,8 +3,6 @@ var router = express.Router() let Tags = require('@models/Tag') -let userId = null -let masterKey = null // middleware that is specific to this router router.use(function setUserId (req, res, next) { @@ -15,49 +13,75 @@ router.use(function setUserId (req, res, next) { } if(req.headers.userId){ - userId = req.headers.userId - masterKey = req.headers.masterKey next() } }) //Get the latest notes the user has created 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) .then( data => res.send(data) ) }) //Get latest tags based on when it was put on a note 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) .then( data => res.send(data) ) }) //Get the latest notes the user has created 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()) .then( data => res.send(data) ) }) 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) .then( data => res.send(data) ) }) //Get the latest notes the user has created 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) .then( data => res.send(data) ) }) //Get the latest notes the user has created 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) .then( data => res.send(data) ) }) //Get all the tags for this user in order of usage 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) .then( data => res.send(data) ) }) diff --git a/server/routes/userController.js b/server/routes/userController.js index 8162ee3..853f09b 100644 --- a/server/routes/userController.js +++ b/server/routes/userController.js @@ -5,8 +5,6 @@ const User = require('@models/User') const Auth = require('@helpers/Auth') const cs = require('@helpers/CryptoString') -let userId = null -let masterKey = null // middleware that is specific to this router router.use(function setUserId (req, res, next) { @@ -17,8 +15,6 @@ router.use(function setUserId (req, res, next) { } if(req.headers.userId){ - userId = req.headers.userId - masterKey = req.headers.masterKey next() } }) diff --git a/v2client-vite-vue3/package.json b/v2client-vite-vue3/package.json index b3b1888..875679b 100644 --- a/v2client-vite-vue3/package.json +++ b/v2client-vite-vue3/package.json @@ -11,12 +11,14 @@ "dependencies": { "@tailwindcss/vite": "^4.1.13", "axios": "^1.1.3", + "chart.js": "^4.6.1", "diff-match-patch": "^1.0.5", "fomantic-ui-css": "^2.9.0", "mitt": "^3.0.1", "socket.io-client": "^2.5.0", "tailwindcss": "^4.1.13", "vue": "^3.5.18", + "vue-chartjs": "^5.3.4", "vue-router": "^4.0.0", "vuedraggable": "^4.1.0", "vuex": "^4.0.0" diff --git a/v2client-vite-vue3/pnpm-lock.yaml b/v2client-vite-vue3/pnpm-lock.yaml index ec158c6..50e3766 100644 --- a/v2client-vite-vue3/pnpm-lock.yaml +++ b/v2client-vite-vue3/pnpm-lock.yaml @@ -13,7 +13,10 @@ importers: version: 4.1.18(vite@7.3.0(jiti@2.6.1)(lightningcss@1.30.2)) axios: specifier: ^1.1.3 - version: 1.18.1 + version: 1.18.1(debug@4.4.3) + chart.js: + specifier: ^4.6.1 + version: 4.6.1 diff-match-patch: specifier: ^1.0.5 version: 1.0.5 @@ -32,6 +35,9 @@ importers: vue: specifier: ^3.5.18 version: 3.5.26 + vue-chartjs: + specifier: ^5.3.4 + version: 5.3.4(chart.js@4.6.1)(vue@3.5.26) vue-router: specifier: ^4.0.0 version: 4.6.4(vue@3.5.26) @@ -240,6 +246,9 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@kurkle/color@0.3.4': + resolution: {integrity: sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==} + '@rolldown/pluginutils@1.0.0-beta.53': resolution: {integrity: sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==} @@ -530,6 +539,10 @@ packages: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} + chart.js@4.6.1: + resolution: {integrity: sha512-hJyniucsFmq//EvXYvJ6c6mahPoI3kaxdjVH8awTaJ/fDNWeSqqqArixMJWkWNN6fcYvPU3HneSANFNYtRi61g==} + engines: {pnpm: '>=8'} + combined-stream@1.0.8: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} @@ -895,6 +908,12 @@ packages: yaml: optional: true + vue-chartjs@5.3.4: + resolution: {integrity: sha512-x3Fqob8RQvrTdssfi9ecsCzEkFOd8JPmNwSkSQzdfKj/uBsRJs/Y88cZcZIEcPsTVfMGwMo4MOoihoDG2DoE/g==} + peerDependencies: + chart.js: ^4.1.1 + vue: ^3.0.0-0 || ^2.7.0 + vue-router@4.6.4: resolution: {integrity: sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==} peerDependencies: @@ -1049,6 +1068,8 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@kurkle/color@0.3.4': {} + '@rolldown/pluginutils@1.0.0-beta.53': {} '@rollup/rollup-android-arm-eabi@4.54.0': @@ -1261,9 +1282,9 @@ snapshots: asynckit@0.4.0: {} - axios@1.18.1: + axios@1.18.1(debug@4.4.3): dependencies: - follow-redirects: 1.16.0 + follow-redirects: 1.16.0(debug@4.4.3) form-data: 4.0.6 https-proxy-agent: 5.0.1 proxy-from-env: 2.1.0 @@ -1282,6 +1303,10 @@ snapshots: es-errors: 1.3.0 function-bind: 1.1.2 + chart.js@4.6.1: + dependencies: + '@kurkle/color': 0.3.4 + combined-stream@1.0.8: dependencies: delayed-stream: 1.0.0 @@ -1397,7 +1422,9 @@ snapshots: optionalDependencies: picomatch: 4.0.3 - follow-redirects@1.16.0: {} + follow-redirects@1.16.0(debug@4.4.3): + optionalDependencies: + debug: 4.4.3 fomantic-ui-css@2.9.4: dependencies: @@ -1636,6 +1663,11 @@ snapshots: jiti: 2.6.1 lightningcss: 1.30.2 + vue-chartjs@5.3.4(chart.js@4.6.1)(vue@3.5.26): + dependencies: + chart.js: 4.6.1 + vue: 3.5.26 + vue-router@4.6.4(vue@3.5.26): dependencies: '@vue/devtools-api': 6.6.4 diff --git a/v2client-vite-vue3/src/assets/semantic-helper.css b/v2client-vite-vue3/src/assets/semantic-helper.css index 4307ef9..8666f40 100644 --- a/v2client-vite-vue3/src/assets/semantic-helper.css +++ b/v2client-vite-vue3/src/assets/semantic-helper.css @@ -1001,3 +1001,8 @@ padding-right: 10px; background-color: rgba(0,0,0,0.7); z-index: 1000; } + + +i.outline { + outline-style: none; +} diff --git a/v2client-vite-vue3/src/components/Metrictracking/LineGraph.vue b/v2client-vite-vue3/src/components/Metrictracking/LineGraph.vue new file mode 100644 index 0000000..fb41a89 --- /dev/null +++ b/v2client-vite-vue3/src/components/Metrictracking/LineGraph.vue @@ -0,0 +1,212 @@ + + + + + diff --git a/v2client-vite-vue3/src/components/Metrictracking/MetricGraphsComponent.vue b/v2client-vite-vue3/src/components/Metrictracking/MetricGraphsComponent.vue index f933fcd..50a32a7 100644 --- a/v2client-vite-vue3/src/components/Metrictracking/MetricGraphsComponent.vue +++ b/v2client-vite-vue3/src/components/Metrictracking/MetricGraphsComponent.vue @@ -43,12 +43,12 @@
- +
Add/Edit Graphs
- +
@@ -64,7 +64,12 @@
-
+
+

No graphs yet

+

Choose Add/Edit Graphs, then New Graph, and pick which metrics to plot.

+
+ +
@@ -126,15 +131,20 @@ Click "Edit Graphs" then, Select Graph type and Metrics to display
-
-
+
+
-
+
{{ graphType }}
@@ -160,12 +170,12 @@
- +
Add/Edit Graphs
- +
@@ -208,6 +218,7 @@ import { defineAsyncComponent } from 'vue' ], components: { 'PillCalendarGraph':defineAsyncComponent(() => import('@/components/Metrictracking/PillCalendarGraph.vue')), + 'LineGraph':defineAsyncComponent(() => import('@/components/Metrictracking/LineGraph.vue')), }, data: function(){ return { @@ -223,33 +234,9 @@ import { defineAsyncComponent } from 'vue' // Constants this.PILL_CALENDAR = PILL_CALENDAR 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(){ 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: { saveGraphs(){ @@ -269,10 +256,35 @@ import { defineAsyncComponent } from 'vue' this.$emit('toggleEditGraphs') }, 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() }, + // 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(){ this.localGraphData.push({}) this.saveGraphs() @@ -331,103 +343,6 @@ import { defineAsyncComponent } from 'vue' 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 - - }, } } \ No newline at end of file diff --git a/v2client-vite-vue3/src/components/Metrictracking/PillCalendarGraph.vue b/v2client-vite-vue3/src/components/Metrictracking/PillCalendarGraph.vue index 03d7367..0d4c46e 100644 --- a/v2client-vite-vue3/src/components/Metrictracking/PillCalendarGraph.vue +++ b/v2client-vite-vue3/src/components/Metrictracking/PillCalendarGraph.vue @@ -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 { width: 100%; } @@ -262,6 +269,14 @@
+ +
+
+ + Days Since {{ entry.label }} - no entries yet + Days Since {{ entry.label }} - {{ entry.days }} {{ entry.days === 1 ? 'day' : 'days' }}. +
+
@@ -272,7 +287,7 @@ // let chartData = {} export default { - inject: ['getFieldColor', 'getFieldIcon'], + inject: ['getFieldColor', 'getFieldIcon', 'getFieldLabel'], props: [ 'graph', // options associated with this graph 'userFields', // all field attributes @@ -313,6 +328,53 @@ this.setupCalendar(new Date()) }, computed: { + // "Days Since - 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(){ let chartData = {} @@ -393,11 +455,26 @@ }, }, 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){ + // 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 - let isBooleanField = this.userFields[fieldId].type == 'boolean' - if(this.userFields[fieldId].customOptions){ + let isBooleanField = this.userFields[fieldId]?.type == 'boolean' + if(this.userFields[fieldId]?.customOptions){ let options = this.userFields[fieldId].customOptions 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 }, getDayValue(fieldId, value){ diff --git a/v2client-vite-vue3/src/components/NoteInputPanel.vue b/v2client-vite-vue3/src/components/NoteInputPanel.vue index 09a4407..89d162e 100644 --- a/v2client-vite-vue3/src/components/NoteInputPanel.vue +++ b/v2client-vite-vue3/src/components/NoteInputPanel.vue @@ -62,7 +62,7 @@