-
-
Notifications
You must be signed in to change notification settings - Fork 3.6k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
🔖 feat: Conversation Bookmarks (#3344)
* feat: add tags property in Conversation model * feat: add ConversationTag model * feat: add the tags parameter to getConvosByPage * feat: add API route to ConversationTag * feat: add types of ConversationTag * feat: add data access functions for conversation tags * feat: add Bookmark table component * feat: Add an action to bookmark * feat: add Bookmark nav component * fix: failed test * refactor: made 'Saved' tag a constant * feat: add new bookmark to current conversation * chore: Add comment * fix: delete tag from conversations when it's deleted * fix: Update the query cache when the tag title is changed. * chore: fix typo * refactor: add description of rebuilding bookmarks * chore: remove unused variables * fix: position when adding a new bookmark * refactor: add comment, rename a function * refactor: add a unique constraint in ConversationTag * chore: add localizations
- Loading branch information
Showing
65 changed files
with
3,752 additions
and
37 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,268 @@ | ||
//const crypto = require('crypto'); | ||
|
||
const logger = require('~/config/winston'); | ||
const Conversation = require('./schema/convoSchema'); | ||
const ConversationTag = require('./schema/conversationTagSchema'); | ||
|
||
const SAVED_TAG = 'Saved'; | ||
|
||
const updateTagsForConversation = async (user, conversationId, tags) => { | ||
try { | ||
const conversation = await Conversation.findOne({ user, conversationId }); | ||
if (!conversation) { | ||
return { message: 'Conversation not found' }; | ||
} | ||
|
||
const addedTags = tags.tags.filter((tag) => !conversation.tags.includes(tag)); | ||
const removedTags = conversation.tags.filter((tag) => !tags.tags.includes(tag)); | ||
for (const tag of addedTags) { | ||
await ConversationTag.updateOne({ tag, user }, { $inc: { count: 1 } }, { upsert: true }); | ||
} | ||
for (const tag of removedTags) { | ||
await ConversationTag.updateOne({ tag, user }, { $inc: { count: -1 } }); | ||
} | ||
conversation.tags = tags.tags; | ||
await conversation.save({ timestamps: { updatedAt: false } }); | ||
return conversation.tags; | ||
} catch (error) { | ||
logger.error('[updateTagsToConversation] Error updating tags', error); | ||
return { message: 'Error updating tags' }; | ||
} | ||
}; | ||
|
||
const createConversationTag = async (user, data) => { | ||
try { | ||
const cTag = await ConversationTag.findOne({ user, tag: data.tag }); | ||
if (cTag) { | ||
return cTag; | ||
} | ||
|
||
const addToConversation = data.addToConversation && data.conversationId; | ||
const newTag = await ConversationTag.create({ | ||
user, | ||
tag: data.tag, | ||
count: 0, | ||
description: data.description, | ||
position: 1, | ||
}); | ||
|
||
await ConversationTag.updateMany( | ||
{ user, position: { $gte: 1 }, _id: { $ne: newTag._id } }, | ||
{ $inc: { position: 1 } }, | ||
); | ||
|
||
if (addToConversation) { | ||
const conversation = await Conversation.findOne({ | ||
user, | ||
conversationId: data.conversationId, | ||
}); | ||
if (conversation) { | ||
const tags = [...(conversation.tags || []), data.tag]; | ||
await updateTagsForConversation(user, data.conversationId, { tags }); | ||
} else { | ||
logger.warn('[updateTagsForConversation] Conversation not found', data.conversationId); | ||
} | ||
} | ||
|
||
return await ConversationTag.findOne({ user, tag: data.tag }); | ||
} catch (error) { | ||
logger.error('[createConversationTag] Error updating conversation tag', error); | ||
return { message: 'Error updating conversation tag' }; | ||
} | ||
}; | ||
|
||
const replaceOrRemoveTagInConversations = async (user, oldtag, newtag) => { | ||
try { | ||
const conversations = await Conversation.find({ user, tags: { $in: [oldtag] } }); | ||
for (const conversation of conversations) { | ||
if (newtag && newtag !== '') { | ||
conversation.tags = conversation.tags.map((tag) => (tag === oldtag ? newtag : tag)); | ||
} else { | ||
conversation.tags = conversation.tags.filter((tag) => tag !== oldtag); | ||
} | ||
await conversation.save({ timestamps: { updatedAt: false } }); | ||
} | ||
} catch (error) { | ||
logger.error('[replaceOrRemoveTagInConversations] Error updating conversation tags', error); | ||
return { message: 'Error updating conversation tags' }; | ||
} | ||
}; | ||
|
||
const updateTagPosition = async (user, tag, newPosition) => { | ||
try { | ||
const cTag = await ConversationTag.findOne({ user, tag }); | ||
if (!cTag) { | ||
return { message: 'Tag not found' }; | ||
} | ||
|
||
const oldPosition = cTag.position; | ||
|
||
if (newPosition === oldPosition) { | ||
return cTag; | ||
} | ||
|
||
const updateOperations = []; | ||
|
||
if (newPosition > oldPosition) { | ||
// Move other tags up | ||
updateOperations.push({ | ||
updateMany: { | ||
filter: { | ||
user, | ||
position: { $gt: oldPosition, $lte: newPosition }, | ||
tag: { $ne: SAVED_TAG }, | ||
}, | ||
update: { $inc: { position: -1 } }, | ||
}, | ||
}); | ||
} else { | ||
// Move other tags down | ||
updateOperations.push({ | ||
updateMany: { | ||
filter: { | ||
user, | ||
position: { $gte: newPosition, $lt: oldPosition }, | ||
tag: { $ne: SAVED_TAG }, | ||
}, | ||
update: { $inc: { position: 1 } }, | ||
}, | ||
}); | ||
} | ||
|
||
// Update the target tag's position | ||
updateOperations.push({ | ||
updateOne: { | ||
filter: { _id: cTag._id }, | ||
update: { $set: { position: newPosition } }, | ||
}, | ||
}); | ||
|
||
await ConversationTag.bulkWrite(updateOperations); | ||
|
||
return await ConversationTag.findById(cTag._id); | ||
} catch (error) { | ||
logger.error('[updateTagPosition] Error updating tag position', error); | ||
return { message: 'Error updating tag position' }; | ||
} | ||
}; | ||
module.exports = { | ||
SAVED_TAG, | ||
ConversationTag, | ||
getConversationTags: async (user) => { | ||
try { | ||
const cTags = await ConversationTag.find({ user }).sort({ position: 1 }).lean(); | ||
cTags.sort((a, b) => (a.tag === SAVED_TAG ? -1 : b.tag === SAVED_TAG ? 1 : 0)); | ||
|
||
return cTags; | ||
} catch (error) { | ||
logger.error('[getShare] Error getting share link', error); | ||
return { message: 'Error getting share link' }; | ||
} | ||
}, | ||
|
||
createConversationTag, | ||
updateConversationTag: async (user, tag, data) => { | ||
try { | ||
const cTag = await ConversationTag.findOne({ user, tag }); | ||
if (!cTag) { | ||
return createConversationTag(user, data); | ||
} | ||
|
||
if (cTag.tag !== data.tag || cTag.description !== data.description) { | ||
cTag.tag = data.tag; | ||
cTag.description = data.description === undefined ? cTag.description : data.description; | ||
await cTag.save(); | ||
} | ||
|
||
if (data.position !== undefined && cTag.position !== data.position) { | ||
await updateTagPosition(user, tag, data.position); | ||
} | ||
|
||
// update conversation tags properties | ||
replaceOrRemoveTagInConversations(user, tag, data.tag); | ||
return await ConversationTag.findOne({ user, tag: data.tag }); | ||
} catch (error) { | ||
logger.error('[updateConversationTag] Error updating conversation tag', error); | ||
return { message: 'Error updating conversation tag' }; | ||
} | ||
}, | ||
|
||
deleteConversationTag: async (user, tag) => { | ||
try { | ||
const currentTag = await ConversationTag.findOne({ user, tag }); | ||
if (!currentTag) { | ||
return; | ||
} | ||
|
||
await currentTag.deleteOne({ user, tag }); | ||
|
||
await replaceOrRemoveTagInConversations(user, tag, null); | ||
return currentTag; | ||
} catch (error) { | ||
logger.error('[deleteConversationTag] Error deleting conversation tag', error); | ||
return { message: 'Error deleting conversation tag' }; | ||
} | ||
}, | ||
|
||
updateTagsForConversation, | ||
rebuildConversationTags: async (user) => { | ||
try { | ||
const conversations = await Conversation.find({ user }).select('tags'); | ||
const tagCountMap = {}; | ||
|
||
// Count the occurrences of each tag | ||
conversations.forEach((conversation) => { | ||
conversation.tags.forEach((tag) => { | ||
if (tagCountMap[tag]) { | ||
tagCountMap[tag]++; | ||
} else { | ||
tagCountMap[tag] = 1; | ||
} | ||
}); | ||
}); | ||
|
||
const tags = await ConversationTag.find({ user }).sort({ position: -1 }); | ||
|
||
// Update existing tags and add new tags | ||
for (const [tag, count] of Object.entries(tagCountMap)) { | ||
const existingTag = tags.find((t) => t.tag === tag); | ||
if (existingTag) { | ||
existingTag.count = count; | ||
await existingTag.save(); | ||
} else { | ||
const newTag = new ConversationTag({ user, tag, count }); | ||
tags.push(newTag); | ||
await newTag.save(); | ||
} | ||
} | ||
|
||
// Set count to 0 for tags that are not in the grouped tags | ||
for (const tag of tags) { | ||
if (!tagCountMap[tag.tag]) { | ||
tag.count = 0; | ||
await tag.save(); | ||
} | ||
} | ||
|
||
// Sort tags by position in descending order | ||
tags.sort((a, b) => a.position - b.position); | ||
|
||
// Move the tag with name "saved" to the first position | ||
const savedTagIndex = tags.findIndex((tag) => tag.tag === SAVED_TAG); | ||
if (savedTagIndex !== -1) { | ||
const [savedTag] = tags.splice(savedTagIndex, 1); | ||
tags.unshift(savedTag); | ||
} | ||
|
||
// Reassign positions starting from 0 | ||
tags.forEach((tag, index) => { | ||
tag.position = index; | ||
tag.save(); | ||
}); | ||
return tags; | ||
} catch (error) { | ||
logger.error('[rearrangeTags] Error rearranging tags', error); | ||
return { message: 'Error rearranging tags' }; | ||
} | ||
}, | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,31 @@ | ||
const mongoose = require('mongoose'); | ||
|
||
const conversationTagSchema = mongoose.Schema( | ||
{ | ||
tag: { | ||
type: String, | ||
index: true, | ||
}, | ||
user: { | ||
type: String, | ||
index: true, | ||
}, | ||
description: { | ||
type: String, | ||
index: true, | ||
}, | ||
count: { | ||
type: Number, | ||
default: 0, | ||
}, | ||
position: { | ||
type: Number, | ||
default: 0, | ||
}, | ||
}, | ||
{ timestamps: true }, | ||
); | ||
|
||
conversationTagSchema.index({ tag: 1, user: 1 }, { unique: true }); | ||
|
||
module.exports = mongoose.model('ConversationTag', conversationTagSchema); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.