From fc0ee2e41b657e6c52108a49bd5e00baf0c47c4f Mon Sep 17 00:00:00 2001 From: "John R. Supplee" Date: Fri, 22 Jan 2021 00:37:16 +0200 Subject: [PATCH 01/16] Global search add sort operator * Add sort operator * add 'overdue' value to 'due' operator --- client/components/main/globalSearch.js | 33 +++++++++++++++----- i18n/en.i18n.json | 2 ++ models/cards.js | 43 ++++++++++++++++++++++++-- 3 files changed, 68 insertions(+), 10 deletions(-) diff --git a/client/components/main/globalSearch.js b/client/components/main/globalSearch.js index 8ad6aec2f..8795579df 100644 --- a/client/components/main/globalSearch.js +++ b/client/components/main/globalSearch.js @@ -212,6 +212,7 @@ BlazeComponent.extendComponent({ operatorMap[TAPi18n.__('operator-due')] = 'dueAt'; operatorMap[TAPi18n.__('operator-created')] = 'createdAt'; operatorMap[TAPi18n.__('operator-modified')] = 'modifiedAt'; + operatorMap[TAPi18n.__('operator-sort')] = 'sort'; // eslint-disable-next-line no-console console.log('operatorMap:', operatorMap); @@ -256,12 +257,16 @@ BlazeComponent.extendComponent({ } else if ( ['dueAt', 'createdAt', 'modifiedAt'].includes(operatorMap[op]) ) { - const days = parseInt(value, 10); + let days = parseInt(value, 10); + let duration = null; if (isNaN(days)) { if (['day', 'week', 'month', 'quarter', 'year'].includes(value)) { - value = moment() - .subtract(1, value) - .format(); + duration = value; + value = moment(); + } else if (value === 'overdue') { + value = moment(); + duration = 'days'; + days = 0; } else { this.parsingErrors.push({ tag: 'operator-number-expected', @@ -270,9 +275,23 @@ BlazeComponent.extendComponent({ value = null; } } else { - value = moment() - .subtract(days, 'days') - .format(); + value = moment(); + } + if (value) { + if (operatorMap[op] === 'dueAt') { + value = value.add(days, duration ? duration : 'days').format(); + } else { + value = value + .subtract(days, duration ? duration : 'days') + .format(); + } + } + } else if (operatorMap[op] === 'sort') { + if (!['due', 'modified', 'created', 'system'].includes(value)) { + this.parsingErrors.push({ + tag: 'operator-sort-invalid', + value, + }); } } if (Array.isArray(params[operatorMap[op]])) { diff --git a/i18n/en.i18n.json b/i18n/en.i18n.json index 3e020dac7..02bf2ab99 100644 --- a/i18n/en.i18n.json +++ b/i18n/en.i18n.json @@ -898,8 +898,10 @@ "operator-due": "due", "operator-created": "created", "operator-modified": "modified", + "operator-sort": "sort", "operator-unknown-error": "%s is not an operator", "operator-number-expected": "operator __operator__ expected a number, got '__value__'", + "operator-sort-invalid": "sort of '%s' is invalid", "heading-notes": "Notes", "globalSearch-instructions-heading": "Search Instructions", "globalSearch-instructions-description": "Searches can include operators to refine the search. Operators are specified by writing the operator name and value separated by a colon. For example, an operator specification of `list:Blocked` would limit the search to cards that are contained in a list named *Blocked*. If the value contains spaces or special characters it must be enclosed in quotation marks (e.g. `__operator_list__:\"To Review\"`).", diff --git a/models/cards.js b/models/cards.js index dd2b347bc..55ace7c0a 100644 --- a/models/cards.js +++ b/models/cards.js @@ -1955,7 +1955,7 @@ Cards.globalSearch = queryParams => { } if (queryParams.dueAt !== null) { - selector.dueAt = { $gte: new Date(queryParams.dueAt) }; + selector.dueAt = { $lte: new Date(queryParams.dueAt) }; } if (queryParams.createdAt !== null) { @@ -2092,7 +2092,8 @@ Cards.globalSearch = queryParams => { // eslint-disable-next-line no-console console.log('selector:', selector); - const cards = Cards.find(selector, { + + const projection = { fields: { _id: 1, archived: 1, @@ -2111,7 +2112,43 @@ Cards.globalSearch = queryParams => { labelIds: 1, }, limit: 50, - }); + }; + + if (queryParams.sort === 'due') { + projection.sort = { + dueAt: 1, + boardId: 1, + swimlaneId: 1, + listId: 1, + sort: 1, + }; + } else if (queryParams.sort === 'modified') { + projection.sort = { + modifiedAt: -1, + boardId: 1, + swimlaneId: 1, + listId: 1, + sort: 1, + }; + } else if (queryParams.sort === 'created') { + projection.sort = { + createdAt: -1, + boardId: 1, + swimlaneId: 1, + listId: 1, + sort: 1, + }; + } else if (queryParams.sort === 'system') { + projection.sort = { + boardId: 1, + swimlaneId: 1, + listId: 1, + modifiedAt: 1, + sort: 1, + }; + } + + const cards = Cards.find(selector, projection); // eslint-disable-next-line no-console console.log('count:', cards.count()); From c9db8e0a262c8a704d1349899b00d070217bd9b8 Mon Sep 17 00:00:00 2001 From: "John R. Supplee" Date: Sat, 23 Jan 2021 12:02:51 +0200 Subject: [PATCH 02/16] Escape regex strings --- models/cards.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/models/cards.js b/models/cards.js index fed7c1729..caacc93be 100644 --- a/models/cards.js +++ b/models/cards.js @@ -1904,7 +1904,7 @@ Cards.globalSearch = queryParams => { const queryBoards = []; queryParams.boards.forEach(query => { const boards = Boards.userSearch(userId, { - title: new RegExp(query, 'i'), + title: new RegExp(escapeForRegex(query), 'i'), }); if (boards.count()) { boards.forEach(board => { @@ -1922,7 +1922,7 @@ Cards.globalSearch = queryParams => { const querySwimlanes = []; queryParams.swimlanes.forEach(query => { const swimlanes = Swimlanes.find({ - title: new RegExp(query, 'i'), + title: new RegExp(escapeForRegex(query), 'i'), }); if (swimlanes.count()) { swimlanes.forEach(swim => { @@ -1940,7 +1940,7 @@ Cards.globalSearch = queryParams => { const queryLists = []; queryParams.lists.forEach(query => { const lists = Lists.find({ - title: new RegExp(query, 'i'), + title: new RegExp(escapeForRegex(query), 'i'), }); if (lists.count()) { lists.forEach(list => { @@ -2050,7 +2050,7 @@ Cards.globalSearch = queryParams => { } else { // eslint-disable-next-line no-console // console.log('label:', label); - const reLabel = new RegExp(label, 'i'); + const reLabel = new RegExp(escapeForRegex(label), 'i'); // eslint-disable-next-line no-console // console.log('reLabel:', reLabel); boards = Boards.userSearch(userId, { @@ -2081,7 +2081,7 @@ Cards.globalSearch = queryParams => { } if (queryParams.text) { - const regex = new RegExp(queryParams.text, 'i'); + const regex = new RegExp(escapeForRegex(queryParams.text), 'i'); selector.$or = [ { title: regex }, From dd163b9923a678a22e06c34e60a6011e13c36b4d Mon Sep 17 00:00:00 2001 From: "John R. Supplee" Date: Sat, 23 Jan 2021 18:04:26 +0200 Subject: [PATCH 03/16] start work on searching in comments --- client/components/main/globalSearch.js | 2 ++ models/boards.js | 1 + models/cardComments.js | 17 +++++++++++++++++ models/cards.js | 2 ++ 4 files changed, 22 insertions(+) diff --git a/client/components/main/globalSearch.js b/client/components/main/globalSearch.js index 69ccad64e..796a2562d 100644 --- a/client/components/main/globalSearch.js +++ b/client/components/main/globalSearch.js @@ -212,6 +212,7 @@ BlazeComponent.extendComponent({ 'operator-due': 'dueAt', 'operator-created': 'createdAt', 'operator-modified': 'modifiedAt', + 'operator-comment': 'comments', }; const operatorMap = {}; @@ -233,6 +234,7 @@ BlazeComponent.extendComponent({ dueAt: null, createdAt: null, modifiedAt: null, + comments: [], }; let text = ''; diff --git a/models/boards.js b/models/boards.js index 35b00ee75..f4f0d8042 100644 --- a/models/boards.js +++ b/models/boards.js @@ -1,3 +1,4 @@ +const escapeForRegex = require('escape-string-regexp'); Boards = new Mongo.Collection('boards'); /** diff --git a/models/cardComments.js b/models/cardComments.js index 39477e14a..ecfbc7516 100644 --- a/models/cardComments.js +++ b/models/cardComments.js @@ -1,3 +1,4 @@ +const escapeForRegex = require('escape-string-regexp'); CardComments = new Mongo.Collection('card_comments'); /** @@ -109,6 +110,22 @@ function commentCreation(userId, doc) { }); } +CardComments.textSearch = (userId, textArray) => { + const selector = { + boardId: { $in: Boards.userBoardIds() }, + $and: [], + }; + + for (const text of textArray) { + selector.$and.push({ text: new RegExp(escapeForRegex(text)) }); + } + + // eslint-disable-next-line no-console + console.log(textArray); + + return CardComments.find(selector); +}; + if (Meteor.isServer) { // Comments are often fetched within a card, so we create an index to make these // queries more efficient. diff --git a/models/cards.js b/models/cards.js index caacc93be..3457e5fc9 100644 --- a/models/cards.js +++ b/models/cards.js @@ -1,3 +1,5 @@ +const escapeForRegex = require('escape-string-regexp'); + Cards = new Mongo.Collection('cards'); // XXX To improve pub/sub performances a card document should include a From 907bf4ffdcb98e2344e1b908272d5836236804aa Mon Sep 17 00:00:00 2001 From: "John R. Supplee" Date: Sun, 24 Jan 2021 02:32:37 +0200 Subject: [PATCH 04/16] Pass found cards in sessionData cursor --- client/components/main/globalSearch.jade | 4 +- client/components/main/globalSearch.js | 55 ++++------------ i18n/en.i18n.json | 1 + models/cardComments.js | 9 ++- models/cards.js | 58 ++++++++++++++++- models/usersessiondata.js | 8 +++ server/publications/cards.js | 82 +++++++++++++++--------- 7 files changed, 137 insertions(+), 80 deletions(-) diff --git a/client/components/main/globalSearch.jade b/client/components/main/globalSearch.jade index 3dac5c9ef..0ffb1a807 100644 --- a/client/components/main/globalSearch.jade +++ b/client/components/main/globalSearch.jade @@ -28,9 +28,9 @@ template(name="globalSearch") .global-search-results-list-wrapper if hasQueryErrors.get div - each msg in errorMessages + each msg in queryErrors span.global-search-error-messages - | {{_ msg.tag msg.value }} + = msg else h1 = resultsHeading.get diff --git a/client/components/main/globalSearch.js b/client/components/main/globalSearch.js index 796a2562d..eeb5e2fc0 100644 --- a/client/components/main/globalSearch.js +++ b/client/components/main/globalSearch.js @@ -98,21 +98,26 @@ BlazeComponent.extendComponent({ // eslint-disable-next-line no-console // console.log('getting results'); if (this.queryParams) { - const results = Cards.globalSearch(this.queryParams); - this.queryErrors = results.errors; + const sessionData = SessionData.findOne({ userId: Meteor.userId() }); + const cards = Cards.find({ _id: { $in: sessionData.cards } }); + this.queryErrors = sessionData.errorMessages; // eslint-disable-next-line no-console // console.log('errors:', this.queryErrors); - if (this.errorMessages().length) { + if (this.parsingErrors.length) { + this.queryErrors = this.errorMessages(); + this.hasQueryErrors.set(true); + return null; + } + if (this.queryErrors.length) { this.hasQueryErrors.set(true); return null; } - if (results.cards) { - const sessionData = SessionData.findOne({ userId: Meteor.userId() }); + if (cards) { this.totalHits = sessionData.totalHits; - this.resultsCount = results.cards.count(); + this.resultsCount = cards.count(); this.resultsHeading.set(this.getResultsHeading()); - return results.cards; + return cards; } } this.resultsCount = 0; @@ -122,43 +127,9 @@ BlazeComponent.extendComponent({ errorMessages() { const messages = []; - if (this.queryErrors) { - this.queryErrors.notFound.boards.forEach(board => { - messages.push({ tag: 'board-title-not-found', value: board }); - }); - this.queryErrors.notFound.swimlanes.forEach(swim => { - messages.push({ tag: 'swimlane-title-not-found', value: swim }); - }); - this.queryErrors.notFound.lists.forEach(list => { - messages.push({ tag: 'list-title-not-found', value: list }); - }); - this.queryErrors.notFound.labels.forEach(label => { - const color = Object.entries(this.colorMap) - .filter(value => value[1] === label) - .map(value => value[0]); - if (color.length) { - messages.push({ - tag: 'label-color-not-found', - value: color[0], - }); - } else { - messages.push({ tag: 'label-not-found', value: label }); - } - }); - this.queryErrors.notFound.users.forEach(user => { - messages.push({ tag: 'user-username-not-found', value: user }); - }); - this.queryErrors.notFound.members.forEach(user => { - messages.push({ tag: 'user-username-not-found', value: user }); - }); - this.queryErrors.notFound.assignees.forEach(user => { - messages.push({ tag: 'user-username-not-found', value: user }); - }); - } - if (this.parsingErrors.length) { this.parsingErrors.forEach(err => { - messages.push(err); + messages.push(TAPi18n.__(err.tag, err.value)); }); } diff --git a/i18n/en.i18n.json b/i18n/en.i18n.json index 2079395be..3ab491ebb 100644 --- a/i18n/en.i18n.json +++ b/i18n/en.i18n.json @@ -900,6 +900,7 @@ "operator-created": "created", "operator-modified": "modified", "operator-sort": "sort", + "operator-comment": "comment", "operator-unknown-error": "%s is not an operator", "operator-number-expected": "operator __operator__ expected a number, got '__value__'", "operator-sort-invalid": "sort of '%s' is invalid", diff --git a/models/cardComments.js b/models/cardComments.js index ecfbc7516..fe49b9161 100644 --- a/models/cardComments.js +++ b/models/cardComments.js @@ -112,7 +112,7 @@ function commentCreation(userId, doc) { CardComments.textSearch = (userId, textArray) => { const selector = { - boardId: { $in: Boards.userBoardIds() }, + boardId: { $in: Boards.userBoardIds(userId) }, $and: [], }; @@ -121,9 +121,12 @@ CardComments.textSearch = (userId, textArray) => { } // eslint-disable-next-line no-console - console.log(textArray); + console.log('cardComments selector:', selector); - return CardComments.find(selector); + const comments = CardComments.find(selector); + // eslint-disable-next-line no-console + console.log('count:', comments.count()); + return comments; }; if (Meteor.isServer) { diff --git a/models/cards.js b/models/cards.js index 3457e5fc9..88f0b0942 100644 --- a/models/cards.js +++ b/models/cards.js @@ -1882,6 +1882,12 @@ Cards.globalSearch = queryParams => { assignees: [], is: [], }; + + this.colorMap = {}; + for (const color of Boards.simpleSchema()._schema['labels.$.color'] + .allowedValues) { + this.colorMap[TAPi18n.__(`color-${color}`)] = color; + } } hasErrors() { @@ -1892,6 +1898,41 @@ Cards.globalSearch = queryParams => { } return false; } + + errorMessages() { + const messages = []; + + this.notFound.boards.forEach(board => { + messages.push(TAPi18n.__('board-title-not-found', board)); + }); + this.notFound.swimlanes.forEach(swim => { + messages.push(TAPi18n.__('swimlane-title-not-found', swim)); + }); + this.notFound.lists.forEach(list => { + messages.push(TAPi18n.__('list-title-not-found', list)); + }); + this.notFound.labels.forEach(label => { + const color = Object.entries(this.colorMap) + .filter(value => value[1] === label) + .map(value => value[0]); + if (color.length) { + messages.push(TAPi18n.__('label-color-not-found', color[0])); + } else { + messages.push(TAPi18n.__('label-not-found', label)); + } + }); + this.notFound.users.forEach(user => { + messages.push(TAPi18n.__('user-username-not-found', user)); + }); + this.notFound.members.forEach(user => { + messages.push(TAPi18n.__('user-username-not-found', user)); + }); + this.notFound.assignees.forEach(user => { + messages.push(TAPi18n.__('user-username-not-found', user)); + }); + + return messages; + } })(); const selector = { @@ -1956,6 +1997,14 @@ Cards.globalSearch = queryParams => { selector.listId.$in = queryLists; } + if (queryParams.comments.length) { + selector._id = { + $in: CardComments.textSearch(userId, queryParams.comments).map(com => { + return com.cardId; + }), + }; + } + if (queryParams.dueAt !== null) { selector.dueAt = { $lte: new Date(queryParams.dueAt) }; } @@ -2089,6 +2138,13 @@ Cards.globalSearch = queryParams => { { title: regex }, { description: regex }, { customFields: { $elemMatch: { value: regex } } }, + { + _id: { + $in: CardComments.textSearch(userId, [queryParams.text]).map( + com => com.cardId, + ), + }, + }, ]; } @@ -2153,7 +2209,7 @@ Cards.globalSearch = queryParams => { const cards = Cards.find(selector, projection); // eslint-disable-next-line no-console - //console.log('count:', cards.count()); + console.log('count:', cards.count()); return { cards, errors }; }; diff --git a/models/usersessiondata.js b/models/usersessiondata.js index e93cde2bd..7053667fe 100644 --- a/models/usersessiondata.js +++ b/models/usersessiondata.js @@ -39,6 +39,14 @@ SessionData.attachSchema( type: Number, optional: true, }, + cards: { + type: [String], + optional: true, + }, + errorMessages: { + type: [String], + optional: true, + }, createdAt: { /** * creation date of the team diff --git a/server/publications/cards.js b/server/publications/cards.js index 6d5223c50..36156481a 100644 --- a/server/publications/cards.js +++ b/server/publications/cards.js @@ -179,53 +179,71 @@ Meteor.publish('globalSearch', function(queryParams) { // eslint-disable-next-line no-console // console.log('queryParams:', queryParams); - const cards = Cards.globalSearch(queryParams).cards; + const results = Cards.globalSearch(queryParams); + const cards = results.cards; - if (!cards) { - return []; + const update = { + $set: { + totalHits: 0, + lastHit: 0, + cards: [], + errorMessages: results.errors.errorMessages(), + }, + }; + + if (cards) { + update.$set.totalHits = cards.count(); + update.$set.lastHit = cards.count() > 50 ? 50 : cards.count(); + update.$set.cards = cards.map(card => { + return card._id; + }); } - SessionData.upsert( - { userId: this.userId }, - { - $set: { - totalHits: cards.count(), - lastHit: cards.count() > 50 ? 50 : cards.count(), - }, - }, - ); + SessionData.upsert({ userId: this.userId }, update); const boards = []; const swimlanes = []; const lists = []; const users = [this.userId]; - cards.forEach(card => { - if (card.boardId) boards.push(card.boardId); - if (card.swimlaneId) swimlanes.push(card.swimlaneId); - if (card.listId) lists.push(card.listId); - if (card.members) { - card.members.forEach(userId => { - users.push(userId); - }); - } - if (card.assignees) { - card.assignees.forEach(userId => { - users.push(userId); - }); - } - }); + if (cards) { + cards.forEach(card => { + if (card.boardId) boards.push(card.boardId); + if (card.swimlaneId) swimlanes.push(card.swimlaneId); + if (card.listId) lists.push(card.listId); + if (card.members) { + card.members.forEach(userId => { + users.push(userId); + }); + } + if (card.assignees) { + card.assignees.forEach(userId => { + users.push(userId); + }); + } + }); + } + const fields = { + _id: 1, + title: 1, + archived: 1, + }; // eslint-disable-next-line no-console // console.log('users:', users); - return [ - cards, - Boards.find({ _id: { $in: boards } }), - Swimlanes.find({ _id: { $in: swimlanes } }), - Lists.find({ _id: { $in: lists } }), + const cursors = [ + Boards.find({ _id: { $in: boards } }, { fields }), + Swimlanes.find({ _id: { $in: swimlanes } }, { fields }), + Lists.find({ _id: { $in: lists } }, { fields }), Users.find({ _id: { $in: users } }, { fields: Users.safeFields }), SessionData.find({ userId: this.userId }), ]; + + if (cards) { + cursors.push(cards); + } + + return cursors; }); Meteor.publish('brokenCards', function() { From 9b6288e49c7e078dac21a72d45e38b39ad36862c Mon Sep 17 00:00:00 2001 From: "John R. Supplee" Date: Sun, 24 Jan 2021 12:28:36 +0200 Subject: [PATCH 05/16] Add session id SessionData --- client/components/main/globalSearch.js | 11 +++++++++-- models/usersessiondata.js | 19 +++++++++++++++++++ server/publications/cards.js | 7 ++++--- 3 files changed, 32 insertions(+), 5 deletions(-) diff --git a/client/components/main/globalSearch.js b/client/components/main/globalSearch.js index eeb5e2fc0..1517742d0 100644 --- a/client/components/main/globalSearch.js +++ b/client/components/main/globalSearch.js @@ -98,7 +98,10 @@ BlazeComponent.extendComponent({ // eslint-disable-next-line no-console // console.log('getting results'); if (this.queryParams) { - const sessionData = SessionData.findOne({ userId: Meteor.userId() }); + const sessionData = SessionData.findOne({ + userId: Meteor.userId(), + sessionId: SessionData.getSessionId(), + }); const cards = Cards.find({ _id: { $in: sessionData.cards } }); this.queryErrors = sessionData.errorMessages; // eslint-disable-next-line no-console @@ -310,7 +313,11 @@ BlazeComponent.extendComponent({ this.queryParams = params; this.autorun(() => { - const handle = subManager.subscribe('globalSearch', params); + const handle = subManager.subscribe( + 'globalSearch', + SessionData.getSessionId(), + params, + ); Tracker.nonreactive(() => { Tracker.autorun(() => { // eslint-disable-next-line no-console diff --git a/models/usersessiondata.js b/models/usersessiondata.js index 7053667fe..775eacf14 100644 --- a/models/usersessiondata.js +++ b/models/usersessiondata.js @@ -25,6 +25,13 @@ SessionData.attachSchema( type: String, optional: false, }, + sessionId: { + /** + * unique session ID + */ + type: String, + optional: false, + }, totalHits: { /** * total number of hits in the last report query @@ -78,4 +85,16 @@ SessionData.attachSchema( }), ); +if (!Meteor.isServer) { + SessionData.getSessionId = () => { + let sessionId = Session.get('sessionId'); + if (!sessionId) { + sessionId = `${String(Meteor.userId())}-${String(Math.random())}`; + Session.set('sessionId', sessionId); + } + + return sessionId; + }; +} + export default SessionData; diff --git a/server/publications/cards.js b/server/publications/cards.js index 36156481a..838cedbfe 100644 --- a/server/publications/cards.js +++ b/server/publications/cards.js @@ -173,7 +173,8 @@ Meteor.publish('dueCards', function(allUsers = false) { ]; }); -Meteor.publish('globalSearch', function(queryParams) { +Meteor.publish('globalSearch', function(sessionId, queryParams) { + check(sessionId, String); check(queryParams, Object); // eslint-disable-next-line no-console @@ -199,7 +200,7 @@ Meteor.publish('globalSearch', function(queryParams) { }); } - SessionData.upsert({ userId: this.userId }, update); + SessionData.upsert({ userId: this.userId, sessionId }, update); const boards = []; const swimlanes = []; @@ -236,7 +237,7 @@ Meteor.publish('globalSearch', function(queryParams) { Swimlanes.find({ _id: { $in: swimlanes } }, { fields }), Lists.find({ _id: { $in: lists } }, { fields }), Users.find({ _id: { $in: users } }, { fields: Users.safeFields }), - SessionData.find({ userId: this.userId }), + SessionData.find({ userId: this.userId, sessionId }), ]; if (cards) { From 91ef8ca1ae96c17465f1ab1d6549da32de6b0793 Mon Sep 17 00:00:00 2001 From: "John R. Supplee" Date: Sun, 24 Jan 2021 15:38:44 +0200 Subject: [PATCH 06/16] Make results a reactive var --- client/components/main/globalSearch.jade | 2 +- client/components/main/globalSearch.js | 29 +++++++++++++++++------- 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/client/components/main/globalSearch.jade b/client/components/main/globalSearch.jade index 0ffb1a807..a4215e3d2 100644 --- a/client/components/main/globalSearch.jade +++ b/client/components/main/globalSearch.jade @@ -35,7 +35,7 @@ template(name="globalSearch") h1 = resultsHeading.get a.fa.fa-link(title="{{_ 'link-to-search' }}" href="{{ getSearchHref }}") - each card in results + each card in results.get +resultCard(card) else .global-search-instructions diff --git a/client/components/main/globalSearch.js b/client/components/main/globalSearch.js index 1517742d0..a0b591603 100644 --- a/client/components/main/globalSearch.js +++ b/client/components/main/globalSearch.js @@ -45,6 +45,7 @@ BlazeComponent.extendComponent({ this.myLists = new ReactiveVar([]); this.myLabelNames = new ReactiveVar([]); this.myBoardNames = new ReactiveVar([]); + this.results = new ReactiveVar([]); this.queryParams = null; this.parsingErrors = []; this.resultsCount = 0; @@ -85,6 +86,7 @@ BlazeComponent.extendComponent({ resetSearch() { this.searching.set(false); + this.results.set([]); this.hasResults.set(false); this.hasQueryErrors.set(false); this.resultsHeading.set(''); @@ -94,7 +96,7 @@ BlazeComponent.extendComponent({ this.queryErrors = null; }, - results() { + getResults() { // eslint-disable-next-line no-console // console.log('getting results'); if (this.queryParams) { @@ -102,6 +104,9 @@ BlazeComponent.extendComponent({ userId: Meteor.userId(), sessionId: SessionData.getSessionId(), }); + // eslint-disable-next-line no-console + console.log('session data:', sessionData); + const cards = Cards.find({ _id: { $in: sessionData.cards } }); this.queryErrors = sessionData.errorMessages; // eslint-disable-next-line no-console @@ -120,11 +125,11 @@ BlazeComponent.extendComponent({ this.totalHits = sessionData.totalHits; this.resultsCount = cards.count(); this.resultsHeading.set(this.getResultsHeading()); - return cards; + this.results.set(cards); } } this.resultsCount = 0; - return []; + return null; }, errorMessages() { @@ -141,6 +146,9 @@ BlazeComponent.extendComponent({ searchAllBoards(query) { query = query.trim(); + // eslint-disable-next-line no-console + console.log('query:', query); + this.query.set(query); this.resetSearch(); @@ -149,9 +157,6 @@ BlazeComponent.extendComponent({ return; } - // eslint-disable-next-line no-console - // console.log('query:', query); - this.searching.set(true); if (!this.colorMap) { @@ -195,7 +200,7 @@ BlazeComponent.extendComponent({ }); // eslint-disable-next-line no-console - console.log('operatorMap:', operatorMap); + // console.log('operatorMap:', operatorMap); const params = { boards: [], swimlanes: [], @@ -308,10 +313,17 @@ BlazeComponent.extendComponent({ params.text = text; // eslint-disable-next-line no-console - // console.log('params:', params); + console.log('params:', params); this.queryParams = params; + if (this.parsingErrors.length) { + this.searching.set(false); + this.queryErrors = this.errorMessages(); + this.hasQueryErrors.set(true); + return; + } + this.autorun(() => { const handle = subManager.subscribe( 'globalSearch', @@ -323,6 +335,7 @@ BlazeComponent.extendComponent({ // eslint-disable-next-line no-console // console.log('ready:', handle.ready()); if (handle.ready()) { + this.getResults(); this.searching.set(false); this.hasResults.set(true); } From c66a48e530efdc98f7a8e7b60f0ff7bb1b5af986 Mon Sep 17 00:00:00 2001 From: "John R. Supplee" Date: Mon, 25 Jan 2021 00:07:50 +0200 Subject: [PATCH 07/16] Move call to URL search to onRendered --- client/components/main/globalSearch.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/components/main/globalSearch.js b/client/components/main/globalSearch.js index a0b591603..ef32f2988 100644 --- a/client/components/main/globalSearch.js +++ b/client/components/main/globalSearch.js @@ -77,7 +77,9 @@ BlazeComponent.extendComponent({ this.myBoardNames.set(data); } }); + }, + onRendered() { Meteor.subscribe('setting'); if (Session.get('globalQuery')) { this.searchAllBoards(Session.get('globalQuery')); @@ -332,8 +334,6 @@ BlazeComponent.extendComponent({ ); Tracker.nonreactive(() => { Tracker.autorun(() => { - // eslint-disable-next-line no-console - // console.log('ready:', handle.ready()); if (handle.ready()) { this.getResults(); this.searching.set(false); From 211d27352adcccb328dfd26ab3ff6bbc3fca0aee Mon Sep 17 00:00:00 2001 From: "John R. Supplee" Date: Mon, 25 Jan 2021 01:35:44 +0200 Subject: [PATCH 08/16] get input value from input, not reactive var --- client/components/main/globalSearch.js | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/client/components/main/globalSearch.js b/client/components/main/globalSearch.js index ef32f2988..3db47a7a8 100644 --- a/client/components/main/globalSearch.js +++ b/client/components/main/globalSearch.js @@ -444,8 +444,9 @@ BlazeComponent.extendComponent({ }, 'click .js-label-color'(evt) { evt.preventDefault(); + const input = document.getElementById('global-search-input'); this.query.set( - `${this.query.get()} ${TAPi18n.__('operator-label')}:"${ + `${input.value} ${TAPi18n.__('operator-label')}:"${ evt.currentTarget.textContent }"`, ); @@ -453,8 +454,9 @@ BlazeComponent.extendComponent({ }, 'click .js-board-title'(evt) { evt.preventDefault(); + const input = document.getElementById('global-search-input'); this.query.set( - `${this.query.get()} ${TAPi18n.__('operator-board')}:"${ + `${input.value} ${TAPi18n.__('operator-board')}:"${ evt.currentTarget.textContent }"`, ); @@ -462,8 +464,9 @@ BlazeComponent.extendComponent({ }, 'click .js-list-title'(evt) { evt.preventDefault(); + const input = document.getElementById('global-search-input'); this.query.set( - `${this.query.get()} ${TAPi18n.__('operator-list')}:"${ + `${input.value} ${TAPi18n.__('operator-list')}:"${ evt.currentTarget.textContent }"`, ); @@ -471,8 +474,9 @@ BlazeComponent.extendComponent({ }, 'click .js-label-name'(evt) { evt.preventDefault(); + const input = document.getElementById('global-search-input'); this.query.set( - `${this.query.get()} ${TAPi18n.__('operator-label')}:"${ + `${input.value} ${TAPi18n.__('operator-label')}:"${ evt.currentTarget.textContent }"`, ); From 158a0807d9b7aaf9f8519175a48c2ce537dcd28b Mon Sep 17 00:00:00 2001 From: "John R. Supplee" Date: Mon, 25 Jan 2021 15:39:36 +0200 Subject: [PATCH 09/16] Development * Generate error when a comment text is not found * Save errors to SessionData as objects * Move all search code to globalSearch publication * Add more translation tags --- client/components/cards/resultCard.styl | 3 + client/components/main/globalSearch.jade | 2 +- client/components/main/globalSearch.js | 62 ++-- i18n/en.i18n.json | 8 + models/cardComments.js | 5 +- models/cards.js | 351 -------------------- models/usersessiondata.js | 29 ++ server/publications/cards.js | 401 +++++++++++++++++++++-- 8 files changed, 452 insertions(+), 409 deletions(-) diff --git a/client/components/cards/resultCard.styl b/client/components/cards/resultCard.styl index def39a4d3..7aa94e90f 100644 --- a/client/components/cards/resultCard.styl +++ b/client/components/cards/resultCard.styl @@ -19,3 +19,6 @@ .result-card-context-list margin-bottom: 0.7rem + +.result-card-block-wrapper + display: inline-block diff --git a/client/components/main/globalSearch.jade b/client/components/main/globalSearch.jade index a4215e3d2..896954743 100644 --- a/client/components/main/globalSearch.jade +++ b/client/components/main/globalSearch.jade @@ -28,7 +28,7 @@ template(name="globalSearch") .global-search-results-list-wrapper if hasQueryErrors.get div - each msg in queryErrors + each msg in errorMessages span.global-search-error-messages = msg else diff --git a/client/components/main/globalSearch.js b/client/components/main/globalSearch.js index 3db47a7a8..f842018ee 100644 --- a/client/components/main/globalSearch.js +++ b/client/components/main/globalSearch.js @@ -52,13 +52,6 @@ BlazeComponent.extendComponent({ this.totalHits = 0; this.queryErrors = null; this.colorMap = null; - // this.colorMap = {}; - // for (const color of Boards.simpleSchema()._schema['labels.$.color'] - // .allowedValues) { - // this.colorMap[TAPi18n.__(`color-${color}`)] = color; - // } - // // eslint-disable-next-line no-console - // console.log('colorMap:', this.colorMap); Meteor.call('myLists', (err, data) => { if (!err) { @@ -81,6 +74,15 @@ BlazeComponent.extendComponent({ onRendered() { Meteor.subscribe('setting'); + + this.colorMap = {}; + for (const color of Boards.simpleSchema()._schema['labels.$.color'] + .allowedValues) { + this.colorMap[TAPi18n.__(`color-${color}`)] = color; + } + // // eslint-disable-next-line no-console + // console.log('colorMap:', this.colorMap); + if (Session.get('globalQuery')) { this.searchAllBoards(Session.get('globalQuery')); } @@ -107,17 +109,10 @@ BlazeComponent.extendComponent({ sessionId: SessionData.getSessionId(), }); // eslint-disable-next-line no-console - console.log('session data:', sessionData); + // console.log('session data:', sessionData); const cards = Cards.find({ _id: { $in: sessionData.cards } }); - this.queryErrors = sessionData.errorMessages; - // eslint-disable-next-line no-console - // console.log('errors:', this.queryErrors); - if (this.parsingErrors.length) { - this.queryErrors = this.errorMessages(); - this.hasQueryErrors.set(true); - return null; - } + this.queryErrors = sessionData.errors; if (this.queryErrors.length) { this.hasQueryErrors.set(true); return null; @@ -135,6 +130,13 @@ BlazeComponent.extendComponent({ }, errorMessages() { + if (this.parsingErrors.length) { + return this.parsingErrorMessages(); + } + return this.queryErrorMessages(); + }, + + parsingErrorMessages() { const messages = []; if (this.parsingErrors.length) { @@ -146,6 +148,20 @@ BlazeComponent.extendComponent({ return messages; }, + queryErrorMessages() { + messages = []; + + this.queryErrors.forEach(err => { + let value = err.color ? TAPi18n.__(`color-${err.value}`) : err.value; + if (!value) { + value = err.value; + } + messages.push(TAPi18n.__(err.tag, value)); + }); + + return messages; + }, + searchAllBoards(query) { query = query.trim(); // eslint-disable-next-line no-console @@ -161,14 +177,6 @@ BlazeComponent.extendComponent({ this.searching.set(true); - if (!this.colorMap) { - this.colorMap = {}; - for (const color of Boards.simpleSchema()._schema['labels.$.color'] - .allowedValues) { - this.colorMap[TAPi18n.__(`color-${color}`)] = color; - } - } - const reOperator1 = /^((?\w+):|(?[#@]))(?\w+)(\s+|$)/; const reOperator2 = /^((?\w+):|(?[#@]))(?["']*)(?.*?)\k(\s+|$)/; const reText = /^(?\S+)(\s+|$)/; @@ -200,9 +208,9 @@ BlazeComponent.extendComponent({ Object.entries(operators).forEach(([key, value]) => { operatorMap[TAPi18n.__(key).toLowerCase()] = value; }); - // eslint-disable-next-line no-console // console.log('operatorMap:', operatorMap); + const params = { boards: [], swimlanes: [], @@ -315,13 +323,13 @@ BlazeComponent.extendComponent({ params.text = text; // eslint-disable-next-line no-console - console.log('params:', params); + // console.log('params:', params); this.queryParams = params; if (this.parsingErrors.length) { this.searching.set(false); - this.queryErrors = this.errorMessages(); + this.queryErrors = this.parsingErrorMessages(); this.hasQueryErrors.set(true); return; } diff --git a/i18n/en.i18n.json b/i18n/en.i18n.json index 3ab491ebb..417de5d05 100644 --- a/i18n/en.i18n.json +++ b/i18n/en.i18n.json @@ -876,6 +876,7 @@ "label-not-found": "Label '%s' not found.", "label-color-not-found": "Label color %s not found.", "user-username-not-found": "Username '%s' not found.", + "comment-not-found": "Card with comment containing text '%s' not found.", "globalSearch-title": "Search All Boards", "no-cards-found": "No Cards Found", "one-card-found": "One Card Found", @@ -901,6 +902,9 @@ "operator-modified": "modified", "operator-sort": "sort", "operator-comment": "comment", + "predicate-archived": "archived", + "predicate-active": "active", + "predicate-overdue": "overdue", "operator-unknown-error": "%s is not an operator", "operator-number-expected": "operator __operator__ expected a number, got '__value__'", "operator-sort-invalid": "sort of '%s' is invalid", @@ -911,12 +915,16 @@ "globalSearch-instructions-operator-board": "`__operator_board__:title` - cards in boards matching the specified title", "globalSearch-instructions-operator-list": "`__operator_list__:title` - cards in lists matching the specified title", "globalSearch-instructions-operator-swimlane": "`__operator_swimlane__:title` - cards in swimlanes matching the specified title", + "globalSearch-instructions-operator-comment": "`__operator_comment__:text` - cards with with a comment containing *text*.", "globalSearch-instructions-operator-label": "`__operator_label__:color` `__operator_label__:name` - cards that have a label matching the given color or name", "globalSearch-instructions-operator-hash": "`__operator_label_abbrev__label` - shorthand for `__operator_label__:label`", "globalSearch-instructions-operator-user": "`__operator_user__:username` - cards where the specified user is a *member* or *assignee*", "globalSearch-instructions-operator-at": "`__operator_user_abbrev__username` - shorthand for `user:username`", "globalSearch-instructions-operator-member": "`__operator_member__:username` - cards where the specified user is a *member*", "globalSearch-instructions-operator-assignee": "`__operator_assignee__:username` - cards where the specified user is an *assignee*", + "globalSearch-instructions-operator-due": "`__operator_due__:n` - cards which are due *n* days from now. `__operator_due__:__predicate_overdue__ lists all ", + "globalSearch-instructions-operator-created": "`__operator_created__:n` - cards which which were created *n* days ago", + "globalSearch-instructions-operator-modified": "`__operator_modified__:n` - cards which which were modified *n* days ago", "globalSearch-instructions-notes-1": "Multiple operators may be specified.", "globalSearch-instructions-notes-2": "Similar operators are *OR*ed together. Cards that match any of the conditions will be returned.\n`__operator_list__:Available __operator_list__:Blocked` would return cards contained in any list named *Blocked* or *Available*.", "globalSearch-instructions-notes-3": "Differing operators are *AND*ed together. Only cards that match all of the differing operators are returned.\n`__operator_list__:Available __operator_label__:red` returns only cards in the list *Available* with a *red* label.", diff --git a/models/cardComments.js b/models/cardComments.js index fe49b9161..b366fc57d 100644 --- a/models/cardComments.js +++ b/models/cardComments.js @@ -125,7 +125,10 @@ CardComments.textSearch = (userId, textArray) => { const comments = CardComments.find(selector); // eslint-disable-next-line no-console - console.log('count:', comments.count()); + // console.log('count:', comments.count()); + // eslint-disable-next-line no-console + // console.log('cards with comments:', comments.map(com => { return com.cardId })); + return comments; }; diff --git a/models/cards.js b/models/cards.js index 88f0b0942..74b7b8bfb 100644 --- a/models/cards.js +++ b/models/cards.js @@ -1,5 +1,3 @@ -const escapeForRegex = require('escape-string-regexp'); - Cards = new Mongo.Collection('cards'); // XXX To improve pub/sub performances a card document should include a @@ -1865,355 +1863,6 @@ Cards.mutations({ }, }); -Cards.globalSearch = queryParams => { - const userId = Meteor.userId(); - // eslint-disable-next-line no-console - // console.log('userId:', userId); - - const errors = new (class { - constructor() { - this.notFound = { - boards: [], - swimlanes: [], - lists: [], - labels: [], - users: [], - members: [], - assignees: [], - is: [], - }; - - this.colorMap = {}; - for (const color of Boards.simpleSchema()._schema['labels.$.color'] - .allowedValues) { - this.colorMap[TAPi18n.__(`color-${color}`)] = color; - } - } - - hasErrors() { - for (const prop in this.notFound) { - if (this.notFound[prop].length) { - return true; - } - } - return false; - } - - errorMessages() { - const messages = []; - - this.notFound.boards.forEach(board => { - messages.push(TAPi18n.__('board-title-not-found', board)); - }); - this.notFound.swimlanes.forEach(swim => { - messages.push(TAPi18n.__('swimlane-title-not-found', swim)); - }); - this.notFound.lists.forEach(list => { - messages.push(TAPi18n.__('list-title-not-found', list)); - }); - this.notFound.labels.forEach(label => { - const color = Object.entries(this.colorMap) - .filter(value => value[1] === label) - .map(value => value[0]); - if (color.length) { - messages.push(TAPi18n.__('label-color-not-found', color[0])); - } else { - messages.push(TAPi18n.__('label-not-found', label)); - } - }); - this.notFound.users.forEach(user => { - messages.push(TAPi18n.__('user-username-not-found', user)); - }); - this.notFound.members.forEach(user => { - messages.push(TAPi18n.__('user-username-not-found', user)); - }); - this.notFound.assignees.forEach(user => { - messages.push(TAPi18n.__('user-username-not-found', user)); - }); - - return messages; - } - })(); - - const selector = { - archived: false, - type: 'cardType-card', - boardId: { $in: Boards.userBoardIds(userId) }, - swimlaneId: { $nin: Swimlanes.archivedSwimlaneIds() }, - listId: { $nin: Lists.archivedListIds() }, - }; - - if (queryParams.boards.length) { - const queryBoards = []; - queryParams.boards.forEach(query => { - const boards = Boards.userSearch(userId, { - title: new RegExp(escapeForRegex(query), 'i'), - }); - if (boards.count()) { - boards.forEach(board => { - queryBoards.push(board._id); - }); - } else { - errors.notFound.boards.push(query); - } - }); - - selector.boardId.$in = queryBoards; - } - - if (queryParams.swimlanes.length) { - const querySwimlanes = []; - queryParams.swimlanes.forEach(query => { - const swimlanes = Swimlanes.find({ - title: new RegExp(escapeForRegex(query), 'i'), - }); - if (swimlanes.count()) { - swimlanes.forEach(swim => { - querySwimlanes.push(swim._id); - }); - } else { - errors.notFound.swimlanes.push(query); - } - }); - - selector.swimlaneId.$in = querySwimlanes; - } - - if (queryParams.lists.length) { - const queryLists = []; - queryParams.lists.forEach(query => { - const lists = Lists.find({ - title: new RegExp(escapeForRegex(query), 'i'), - }); - if (lists.count()) { - lists.forEach(list => { - queryLists.push(list._id); - }); - } else { - errors.notFound.lists.push(query); - } - }); - - selector.listId.$in = queryLists; - } - - if (queryParams.comments.length) { - selector._id = { - $in: CardComments.textSearch(userId, queryParams.comments).map(com => { - return com.cardId; - }), - }; - } - - if (queryParams.dueAt !== null) { - selector.dueAt = { $lte: new Date(queryParams.dueAt) }; - } - - if (queryParams.createdAt !== null) { - selector.createdAt = { $gte: new Date(queryParams.createdAt) }; - } - - if (queryParams.modifiedAt !== null) { - selector.modifiedAt = { $gte: new Date(queryParams.modifiedAt) }; - } - - const queryMembers = []; - const queryAssignees = []; - if (queryParams.users.length) { - queryParams.users.forEach(query => { - const users = Users.find({ - username: query, - }); - if (users.count()) { - users.forEach(user => { - queryMembers.push(user._id); - queryAssignees.push(user._id); - }); - } else { - errors.notFound.users.push(query); - } - }); - } - - if (queryParams.members.length) { - queryParams.members.forEach(query => { - const users = Users.find({ - username: query, - }); - if (users.count()) { - users.forEach(user => { - queryMembers.push(user._id); - }); - } else { - errors.notFound.members.push(query); - } - }); - } - - if (queryParams.assignees.length) { - queryParams.assignees.forEach(query => { - const users = Users.find({ - username: query, - }); - if (users.count()) { - users.forEach(user => { - queryAssignees.push(user._id); - }); - } else { - errors.notFound.assignees.push(query); - } - }); - } - - if (queryMembers.length && queryAssignees.length) { - selector.$or = [ - { members: { $in: queryMembers } }, - { assignees: { $in: queryAssignees } }, - ]; - } else if (queryMembers.length) { - selector.members = { $in: queryMembers }; - } else if (queryAssignees.length) { - selector.assignees = { $in: queryAssignees }; - } - - if (queryParams.labels.length) { - queryParams.labels.forEach(label => { - const queryLabels = []; - - let boards = Boards.userSearch(userId, { - labels: { $elemMatch: { color: label.toLowerCase() } }, - }); - - if (boards.count()) { - boards.forEach(board => { - // eslint-disable-next-line no-console - // console.log('board:', board); - // eslint-disable-next-line no-console - // console.log('board.labels:', board.labels); - board.labels - .filter(boardLabel => { - return boardLabel.color === label.toLowerCase(); - }) - .forEach(boardLabel => { - queryLabels.push(boardLabel._id); - }); - }); - } else { - // eslint-disable-next-line no-console - // console.log('label:', label); - const reLabel = new RegExp(escapeForRegex(label), 'i'); - // eslint-disable-next-line no-console - // console.log('reLabel:', reLabel); - boards = Boards.userSearch(userId, { - labels: { $elemMatch: { name: reLabel } }, - }); - - if (boards.count()) { - boards.forEach(board => { - board.labels - .filter(boardLabel => { - return boardLabel.name.match(reLabel); - }) - .forEach(boardLabel => { - queryLabels.push(boardLabel._id); - }); - }); - } else { - errors.notFound.labels.push(label); - } - } - - selector.labelIds = { $in: queryLabels }; - }); - } - - if (errors.hasErrors()) { - return { cards: null, errors }; - } - - if (queryParams.text) { - const regex = new RegExp(escapeForRegex(queryParams.text), 'i'); - - selector.$or = [ - { title: regex }, - { description: regex }, - { customFields: { $elemMatch: { value: regex } } }, - { - _id: { - $in: CardComments.textSearch(userId, [queryParams.text]).map( - com => com.cardId, - ), - }, - }, - ]; - } - - // eslint-disable-next-line no-console - console.log('selector:', selector); - - const projection = { - fields: { - _id: 1, - archived: 1, - boardId: 1, - swimlaneId: 1, - listId: 1, - title: 1, - type: 1, - sort: 1, - members: 1, - assignees: 1, - colors: 1, - dueAt: 1, - createdAt: 1, - modifiedAt: 1, - labelIds: 1, - }, - limit: 50, - }; - - if (queryParams.sort === 'due') { - projection.sort = { - dueAt: 1, - boardId: 1, - swimlaneId: 1, - listId: 1, - sort: 1, - }; - } else if (queryParams.sort === 'modified') { - projection.sort = { - modifiedAt: -1, - boardId: 1, - swimlaneId: 1, - listId: 1, - sort: 1, - }; - } else if (queryParams.sort === 'created') { - projection.sort = { - createdAt: -1, - boardId: 1, - swimlaneId: 1, - listId: 1, - sort: 1, - }; - } else if (queryParams.sort === 'system') { - projection.sort = { - boardId: 1, - swimlaneId: 1, - listId: 1, - modifiedAt: 1, - sort: 1, - }; - } - - const cards = Cards.find(selector, projection); - - // eslint-disable-next-line no-console - console.log('count:', cards.count()); - - return { cards, errors }; -}; - //FUNCTIONS FOR creation of Activities function updateActivities(doc, fieldNames, modifier) { diff --git a/models/usersessiondata.js b/models/usersessiondata.js index 775eacf14..59be52b3a 100644 --- a/models/usersessiondata.js +++ b/models/usersessiondata.js @@ -54,6 +54,35 @@ SessionData.attachSchema( type: [String], optional: true, }, + errors: { + type: [Object], + optional: true, + defaultValue: [], + }, + 'errors.$': { + type: new SimpleSchema({ + tag: { + /** + * i18n tag + */ + type: String, + optional: false, + }, + value: { + /** + * value for the tag + */ + type: String, + optional: true, + defaultValue: null, + }, + color: { + type: Boolean, + optional: true, + defaultValue: false, + }, + }), + }, createdAt: { /** * creation date of the team diff --git a/server/publications/cards.js b/server/publications/cards.js index 838cedbfe..bf152cbd4 100644 --- a/server/publications/cards.js +++ b/server/publications/cards.js @@ -1,3 +1,5 @@ +const escapeForRegex = require('escape-string-regexp'); + Meteor.publish('card', cardId => { check(cardId, String); return Cards.find({ _id: cardId }); @@ -177,18 +179,363 @@ Meteor.publish('globalSearch', function(sessionId, queryParams) { check(sessionId, String); check(queryParams, Object); + const userId = Meteor.userId(); // eslint-disable-next-line no-console - // console.log('queryParams:', queryParams); + // console.log('userId:', userId); - const results = Cards.globalSearch(queryParams); - const cards = results.cards; + const errors = new (class { + constructor() { + this.notFound = { + boards: [], + swimlanes: [], + lists: [], + labels: [], + users: [], + members: [], + assignees: [], + is: [], + comments: [], + }; + + this.colorMap = {}; + for (const color of Boards.simpleSchema()._schema['labels.$.color'] + .allowedValues) { + this.colorMap[TAPi18n.__(`color-${color}`)] = color; + } + } + + hasErrors() { + for (const prop in this.notFound) { + if (this.notFound[prop].length) { + return true; + } + } + return false; + } + + errorMessages() { + const messages = []; + + this.notFound.boards.forEach(board => { + messages.push({ tag: 'board-title-not-found', value: board }); + }); + this.notFound.swimlanes.forEach(swim => { + messages.push({ tag: 'swimlane-title-not-found', value: swim }); + }); + this.notFound.lists.forEach(list => { + messages.push({ tag: 'list-title-not-found', value: list }); + }); + this.notFound.comments.forEach(comments => { + comments.forEach(text => { + messages.push({ tag: 'comment-not-found', value: text }); + }); + }); + this.notFound.labels.forEach(label => { + messages.push({ tag: 'label-not-found', value: label, color: true }); + }); + this.notFound.users.forEach(user => { + messages.push({ tag: 'user-username-not-found', value: user }); + }); + this.notFound.members.forEach(user => { + messages.push({ tag: 'user-username-not-found', value: user }); + }); + this.notFound.assignees.forEach(user => { + messages.push({ tag: 'user-username-not-found', value: user }); + }); + + return messages; + } + })(); + + const selector = { + archived: false, + type: 'cardType-card', + boardId: { $in: Boards.userBoardIds(userId) }, + swimlaneId: { $nin: Swimlanes.archivedSwimlaneIds() }, + listId: { $nin: Lists.archivedListIds() }, + }; + + if (queryParams.boards.length) { + const queryBoards = []; + queryParams.boards.forEach(query => { + const boards = Boards.userSearch(userId, { + title: new RegExp(escapeForRegex(query), 'i'), + }); + if (boards.count()) { + boards.forEach(board => { + queryBoards.push(board._id); + }); + } else { + errors.notFound.boards.push(query); + } + }); + + selector.boardId.$in = queryBoards; + } + + if (queryParams.swimlanes.length) { + const querySwimlanes = []; + queryParams.swimlanes.forEach(query => { + const swimlanes = Swimlanes.find({ + title: new RegExp(escapeForRegex(query), 'i'), + }); + if (swimlanes.count()) { + swimlanes.forEach(swim => { + querySwimlanes.push(swim._id); + }); + } else { + errors.notFound.swimlanes.push(query); + } + }); + + selector.swimlaneId.$in = querySwimlanes; + } + + if (queryParams.lists.length) { + const queryLists = []; + queryParams.lists.forEach(query => { + const lists = Lists.find({ + title: new RegExp(escapeForRegex(query), 'i'), + }); + if (lists.count()) { + lists.forEach(list => { + queryLists.push(list._id); + }); + } else { + errors.notFound.lists.push(query); + } + }); + + selector.listId.$in = queryLists; + } + + if (queryParams.comments.length) { + const cardIds = CardComments.textSearch(userId, queryParams.comments).map( + com => { + return com.cardId; + }, + ); + if (cardIds.length) { + selector._id = { $in: cardIds }; + } else { + errors.notFound.comments.push(queryParams.comments); + } + } + + if (queryParams.dueAt !== null) { + selector.dueAt = { $lte: new Date(queryParams.dueAt) }; + } + + if (queryParams.createdAt !== null) { + selector.createdAt = { $gte: new Date(queryParams.createdAt) }; + } + + if (queryParams.modifiedAt !== null) { + selector.modifiedAt = { $gte: new Date(queryParams.modifiedAt) }; + } + + const queryMembers = []; + const queryAssignees = []; + if (queryParams.users.length) { + queryParams.users.forEach(query => { + const users = Users.find({ + username: query, + }); + if (users.count()) { + users.forEach(user => { + queryMembers.push(user._id); + queryAssignees.push(user._id); + }); + } else { + errors.notFound.users.push(query); + } + }); + } + + if (queryParams.members.length) { + queryParams.members.forEach(query => { + const users = Users.find({ + username: query, + }); + if (users.count()) { + users.forEach(user => { + queryMembers.push(user._id); + }); + } else { + errors.notFound.members.push(query); + } + }); + } + + if (queryParams.assignees.length) { + queryParams.assignees.forEach(query => { + const users = Users.find({ + username: query, + }); + if (users.count()) { + users.forEach(user => { + queryAssignees.push(user._id); + }); + } else { + errors.notFound.assignees.push(query); + } + }); + } + + if (queryMembers.length && queryAssignees.length) { + selector.$or = [ + { members: { $in: queryMembers } }, + { assignees: { $in: queryAssignees } }, + ]; + } else if (queryMembers.length) { + selector.members = { $in: queryMembers }; + } else if (queryAssignees.length) { + selector.assignees = { $in: queryAssignees }; + } + + if (queryParams.labels.length) { + queryParams.labels.forEach(label => { + const queryLabels = []; + + let boards = Boards.userSearch(userId, { + labels: { $elemMatch: { color: label.toLowerCase() } }, + }); + + if (boards.count()) { + boards.forEach(board => { + // eslint-disable-next-line no-console + // console.log('board:', board); + // eslint-disable-next-line no-console + // console.log('board.labels:', board.labels); + board.labels + .filter(boardLabel => { + return boardLabel.color === label.toLowerCase(); + }) + .forEach(boardLabel => { + queryLabels.push(boardLabel._id); + }); + }); + } else { + // eslint-disable-next-line no-console + // console.log('label:', label); + const reLabel = new RegExp(escapeForRegex(label), 'i'); + // eslint-disable-next-line no-console + // console.log('reLabel:', reLabel); + boards = Boards.userSearch(userId, { + labels: { $elemMatch: { name: reLabel } }, + }); + + if (boards.count()) { + boards.forEach(board => { + board.labels + .filter(boardLabel => { + return boardLabel.name.match(reLabel); + }) + .forEach(boardLabel => { + queryLabels.push(boardLabel._id); + }); + }); + } else { + errors.notFound.labels.push(label); + } + } + + selector.labelIds = { $in: queryLabels }; + }); + } + + let cards = null; + + if (!errors.hasErrors()) { + if (queryParams.text) { + const regex = new RegExp(escapeForRegex(queryParams.text), 'i'); + + selector.$or = [ + { title: regex }, + { description: regex }, + { customFields: { $elemMatch: { value: regex } } }, + { + _id: { + $in: CardComments.textSearch(userId, [queryParams.text]).map( + com => com.cardId, + ), + }, + }, + ]; + } + + // eslint-disable-next-line no-console + // console.log('selector:', selector); + // eslint-disable-next-line no-console + // console.log('selector.$or:', selector.$or); + + const projection = { + fields: { + _id: 1, + archived: 1, + boardId: 1, + swimlaneId: 1, + listId: 1, + title: 1, + type: 1, + sort: 1, + members: 1, + assignees: 1, + colors: 1, + dueAt: 1, + createdAt: 1, + modifiedAt: 1, + labelIds: 1, + }, + limit: 50, + }; + + if (queryParams.sort === 'due') { + projection.sort = { + dueAt: 1, + boardId: 1, + swimlaneId: 1, + listId: 1, + sort: 1, + }; + } else if (queryParams.sort === 'modified') { + projection.sort = { + modifiedAt: -1, + boardId: 1, + swimlaneId: 1, + listId: 1, + sort: 1, + }; + } else if (queryParams.sort === 'created') { + projection.sort = { + createdAt: -1, + boardId: 1, + swimlaneId: 1, + listId: 1, + sort: 1, + }; + } else if (queryParams.sort === 'system') { + projection.sort = { + boardId: 1, + swimlaneId: 1, + listId: 1, + modifiedAt: 1, + sort: 1, + }; + } + + cards = Cards.find(selector, projection); + + // eslint-disable-next-line no-console + // console.log('count:', cards.count()); + } const update = { $set: { totalHits: 0, lastHit: 0, cards: [], - errorMessages: results.errors.errorMessages(), + errors: errors.errorMessages(), }, }; @@ -202,12 +549,12 @@ Meteor.publish('globalSearch', function(sessionId, queryParams) { SessionData.upsert({ userId: this.userId, sessionId }, update); - const boards = []; - const swimlanes = []; - const lists = []; - const users = [this.userId]; - if (cards) { + const boards = []; + const swimlanes = []; + const lists = []; + const users = [this.userId]; + cards.forEach(card => { if (card.boardId) boards.push(card.boardId); if (card.swimlaneId) swimlanes.push(card.swimlaneId); @@ -223,28 +570,24 @@ Meteor.publish('globalSearch', function(sessionId, queryParams) { }); } }); + + const fields = { + _id: 1, + title: 1, + archived: 1, + }; + + return [ + cards, + Boards.find({ _id: { $in: boards } }, { fields }), + Swimlanes.find({ _id: { $in: swimlanes } }, { fields }), + Lists.find({ _id: { $in: lists } }, { fields }), + Users.find({ _id: { $in: users } }, { fields: Users.safeFields }), + SessionData.find({ userId: this.userId, sessionId }), + ]; } - const fields = { - _id: 1, - title: 1, - archived: 1, - }; - // eslint-disable-next-line no-console - // console.log('users:', users); - const cursors = [ - Boards.find({ _id: { $in: boards } }, { fields }), - Swimlanes.find({ _id: { $in: swimlanes } }, { fields }), - Lists.find({ _id: { $in: lists } }, { fields }), - Users.find({ _id: { $in: users } }, { fields: Users.safeFields }), - SessionData.find({ userId: this.userId, sessionId }), - ]; - - if (cards) { - cursors.push(cards); - } - - return cursors; + return [SessionData.find({ userId: this.userId, sessionId })]; }); Meteor.publish('brokenCards', function() { From 20ab2f039b20a9c731733491f50f22bc62035978 Mon Sep 17 00:00:00 2001 From: "John R. Supplee" Date: Mon, 25 Jan 2021 15:55:35 +0200 Subject: [PATCH 10/16] Send more fields to client --- server/publications/cards.js | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/server/publications/cards.js b/server/publications/cards.js index bf152cbd4..50ba202b4 100644 --- a/server/publications/cards.js +++ b/server/publications/cards.js @@ -575,12 +575,20 @@ Meteor.publish('globalSearch', function(sessionId, queryParams) { _id: 1, title: 1, archived: 1, + sort: 1, + type: 1, }; return [ cards, - Boards.find({ _id: { $in: boards } }, { fields }), - Swimlanes.find({ _id: { $in: swimlanes } }, { fields }), + Boards.find( + { _id: { $in: boards } }, + { fields: { ...fields, labels: 1, color: 1 } }, + ), + Swimlanes.find( + { _id: { $in: swimlanes } }, + { fields: { ...fields, color: 1 } }, + ), Lists.find({ _id: { $in: lists } }, { fields }), Users.find({ _id: { $in: users } }, { fields: Users.safeFields }), SessionData.find({ userId: this.userId, sessionId }), From f2e7296d19d9c3a8face892e2b95b31ce1e3f1b4 Mon Sep 17 00:00:00 2001 From: "John R. Supplee" Date: Mon, 25 Jan 2021 16:12:33 +0200 Subject: [PATCH 11/16] Remove old session data --- server/publications/cards.js | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/server/publications/cards.js b/server/publications/cards.js index 50ba202b4..b231cfe6c 100644 --- a/server/publications/cards.js +++ b/server/publications/cards.js @@ -547,7 +547,19 @@ Meteor.publish('globalSearch', function(sessionId, queryParams) { }); } - SessionData.upsert({ userId: this.userId, sessionId }, update); + SessionData.upsert({ userId, sessionId }, update); + + // remove old session data + SessionData.remove({ + userId, + modifiedAt: { + $lt: new Date( + moment() + .subtract(1, 'day') + .format(), + ), + }, + }); if (cards) { const boards = []; From a9ceba690e36108d6f15fae420cb854e6b693b1b Mon Sep 17 00:00:00 2001 From: "John R. Supplee" Date: Mon, 25 Jan 2021 19:01:07 +0200 Subject: [PATCH 12/16] Translatable predicates and UTF8 RegEx support * Add support for recognizing UTF8 operators * Make search predicates translatable * Add translations for Egyptian Arabic based on standard Arabic --- client/components/main/globalSearch.js | 43 +- i18n/ar-EG.i18n.json | 732 ++++++++++++------------- 2 files changed, 402 insertions(+), 373 deletions(-) diff --git a/client/components/main/globalSearch.js b/client/components/main/globalSearch.js index f842018ee..4c5d7c76b 100644 --- a/client/components/main/globalSearch.js +++ b/client/components/main/globalSearch.js @@ -177,10 +177,10 @@ BlazeComponent.extendComponent({ this.searching.set(true); - const reOperator1 = /^((?\w+):|(?[#@]))(?\w+)(\s+|$)/; - const reOperator2 = /^((?\w+):|(?[#@]))(?["']*)(?.*?)\k(\s+|$)/; - const reText = /^(?\S+)(\s+|$)/; - const reQuotedText = /^(?["'])(?\w+)\k(\s+|$)/; + const reOperator1 = /^((?[\w\p{L}]+):|(?[#@]))(?[\w\p{L}]+)(\s+|$)/iu; + const reOperator2 = /^((?[\w\p{L}]+):|(?[#@]))(?["']*)(?.*?)\k(\s+|$)/iu; + const reText = /^(?\S+)(\s+|$)/u; + const reQuotedText = /^(?["'])(?[\w\p{L}]+)\k(\s+|$)/u; const operators = { 'operator-board': 'boards', @@ -204,6 +204,35 @@ BlazeComponent.extendComponent({ 'operator-comment': 'comments', }; + const predicates = { + due: { + 'predicate-overdue': 'overdue', + 'predicate-day': 'day', + 'predicate-week': 'week', + 'predicate-month': 'month', + 'predicate-quarter': 'quarter', + 'predicate-year': 'year', + }, + date: { + 'predicate-day': 'day', + 'predicate-week': 'week', + 'predicate-month': 'month', + 'predicate-quarter': 'quarter', + 'predicate-year': 'year', + }, + is: { + 'predicate-archived': 'archived', + 'predicate-active': 'active', + }, + }; + const predicateTranslations = {}; + Object.entries(predicates, ([category, predicates]) => { + predicateTranslations[category] = {}; + Object.entries(predicates, ([tag, value]) => { + predicateTranslations[category][TAPi18n.__(tag)] = value; + }); + }); + const operatorMap = {}; Object.entries(operators).forEach(([key, value]) => { operatorMap[TAPi18n.__(key).toLowerCase()] = value; @@ -256,10 +285,10 @@ BlazeComponent.extendComponent({ let days = parseInt(value, 10); let duration = null; if (isNaN(days)) { - if (['day', 'week', 'month', 'quarter', 'year'].includes(value)) { - duration = value; + if (predicateTranslations.date.keys().includes(value)) { + duration = predicateTranslations.date[value]; value = moment(); - } else if (value === 'overdue') { + } else if (predicateTranslations.due[value] === 'overdue') { value = moment(); duration = 'days'; days = 0; diff --git a/i18n/ar-EG.i18n.json b/i18n/ar-EG.i18n.json index da6d8e09d..b6ea0248c 100644 --- a/i18n/ar-EG.i18n.json +++ b/i18n/ar-EG.i18n.json @@ -1,6 +1,6 @@ { - "accept": "Accept", - "act-activity-notify": "Activity Notification", + "accept": "قبول", + "act-activity-notify": "اشعار النشاط", "act-addAttachment": "added attachment __attachment__ to card __card__ at list __list__ at swimlane __swimlane__ at board __board__", "act-deleteAttachment": "deleted attachment __attachment__ at card __card__ at list __list__ at swimlane __swimlane__ at board __board__", "act-addSubtask": "added subtask __subtask__ to card __card__ at list __list__ at swimlane __swimlane__ at board __board__", @@ -40,133 +40,133 @@ "act-removeBoardMember": "removed member __member__ from board __board__", "act-restoredCard": "restored card __card__ to list __list__ at swimlane __swimlane__ at board __board__", "act-unjoinMember": "removed member __member__ from card __card__ at list __list__ at swimlane __swimlane__ at board __board__", - "act-withBoardTitle": "__board__", + "act-withBoardTitle": "__لوح__", "act-withCardTitle": "[__board__] __card__", - "actions": "Actions", - "activities": "Activities", - "activity": "Activity", - "activity-added": "added %s to %s", - "activity-archived": "%s moved to Archive", - "activity-attached": "attached %s to %s", - "activity-created": "created %s", - "activity-customfield-created": "created custom field %s", - "activity-excluded": "excluded %s from %s", + "actions": "الإجراءات", + "activities": "الأنشطة", + "activity": "النشاط", + "activity-added": "تمت إضافة %s ل %s", + "activity-archived": "%s انتقل الى الارشيف", + "activity-attached": "إرفاق %s ل %s", + "activity-created": "أنشأ %s", + "activity-customfield-created": "%s احدت حقل مخصص", + "activity-excluded": "استبعاد %s عن %s", "activity-imported": "imported %s into %s from %s", "activity-imported-board": "imported %s from %s", - "activity-joined": "joined %s", - "activity-moved": "moved %s from %s to %s", - "activity-on": "on %s", - "activity-removed": "removed %s from %s", - "activity-sent": "sent %s to %s", - "activity-unjoined": "unjoined %s", - "activity-subtask-added": "added subtask to %s", - "activity-checked-item": "checked %s in checklist %s of %s", - "activity-unchecked-item": "unchecked %s in checklist %s of %s", - "activity-checklist-added": "added checklist to %s", - "activity-checklist-removed": "removed a checklist from %s", + "activity-joined": "انضم %s", + "activity-moved": "تم نقل %s من %s إلى %s", + "activity-on": "على %s", + "activity-removed": "حذف %s إلى %s", + "activity-sent": "إرسال %s إلى %s", + "activity-unjoined": "غادر %s", + "activity-subtask-added": "تم اضافة مهمة فرعية الى %s", + "activity-checked-item": "تحقق %s في قائمة التحقق %s من %s", + "activity-unchecked-item": "ازالة تحقق %s من قائمة التحقق %s من %s", + "activity-checklist-added": "أضاف قائمة تحقق إلى %s", + "activity-checklist-removed": "ازالة قائمة التحقق من %s", "activity-checklist-completed": "completed checklist %s of %s", - "activity-checklist-uncompleted": "uncompleted the checklist %s of %s", - "activity-checklist-item-added": "added checklist item to '%s' in %s", - "activity-checklist-item-removed": "removed a checklist item from '%s' in %s", - "add": "Add", + "activity-checklist-uncompleted": "لم يتم انجاز قائمة التحقق %s من %s", + "activity-checklist-item-added": "تم اضافة عنصر قائمة التحقق الى '%s' في %s", + "activity-checklist-item-removed": "تم ازالة عنصر قائمة التحقق الى '%s' في %s", + "add": "أضف", "activity-checked-item-card": "checked %s in checklist %s", "activity-unchecked-item-card": "unchecked %s in checklist %s", "activity-checklist-completed-card": "completed checklist __checklist__ at card __card__ at list __list__ at swimlane __swimlane__ at board __board__", "activity-checklist-uncompleted-card": "uncompleted the checklist %s", "activity-editComment": "edited comment %s", - "activity-deleteComment": "deleted comment %s", + "activity-deleteComment": "تعليق محذوف %s", "activity-receivedDate": "edited received date to %s of %s", "activity-startDate": "edited start date to %s of %s", "activity-dueDate": "edited due date to %s of %s", "activity-endDate": "edited end date to %s of %s", - "add-attachment": "Add Attachment", - "add-board": "Add Board", - "add-card": "Add Card", + "add-attachment": "إضافة مرفق", + "add-board": "إضافة لوحة", + "add-card": "إضافة بطاقة", "add-swimlane": "Add Swimlane", - "add-subtask": "Add Subtask", - "add-checklist": "Add Checklist", - "add-checklist-item": "Add an item to checklist", - "add-cover": "Add Cover", - "add-label": "Add Label", - "add-list": "Add List", - "add-members": "Add Members", - "added": "Added", - "addMemberPopup-title": "Members", - "admin": "Admin", - "admin-desc": "Can view and edit cards, remove members, and change settings for the board.", - "admin-announcement": "Announcement", + "add-subtask": "إضافة مهمة فرعية", + "add-checklist": "إضافة قائمة تدقيق", + "add-checklist-item": "إضافة عنصر إلى قائمة التحقق", + "add-cover": "إضافة غلاف", + "add-label": "إضافة ملصق", + "add-list": "إضافة قائمة", + "add-members": "إضافة أعضاء", + "added": "أُضيف", + "addMemberPopup-title": "الأعضاء", + "admin": "المدير", + "admin-desc": "إمكانية مشاهدة و تعديل و حذف أعضاء ، و تعديل إعدادات اللوحة أيضا.", + "admin-announcement": "إعلان", "admin-announcement-active": "Active System-Wide Announcement", "admin-announcement-title": "Announcement from Administrator", - "all-boards": "All boards", - "and-n-other-card": "And __count__ other card", - "and-n-other-card_plural": "And __count__ other cards", - "apply": "Apply", + "all-boards": "كل اللوحات", + "and-n-other-card": "And __count__ other بطاقة", + "and-n-other-card_plural": "And __count__ other بطاقات", + "apply": "طبق", "app-is-offline": "Loading, please wait. Refreshing the page will cause data loss. If loading does not work, please check that server has not stopped.", - "archive": "Move to Archive", - "archive-all": "Move All to Archive", - "archive-board": "Move Board to Archive", - "archive-card": "Move Card to Archive", - "archive-list": "Move List to Archive", - "archive-swimlane": "Move Swimlane to Archive", - "archive-selection": "Move selection to Archive", - "archiveBoardPopup-title": "Move Board to Archive?", - "archived-items": "Archive", - "archived-boards": "Boards in Archive", - "restore-board": "Restore Board", - "no-archived-boards": "No Boards in Archive.", - "archives": "Archive", - "template": "Template", - "templates": "Templates", - "assign-member": "Assign member", - "attached": "attached", - "attachment": "Attachment", - "attachment-delete-pop": "Deleting an attachment is permanent. There is no undo.", - "attachmentDeletePopup-title": "Delete Attachment?", - "attachments": "Attachments", - "auto-watch": "Automatically watch boards when they are created", + "archive": "نقل الى الارشيف", + "archive-all": "نقل الكل الى الارشيف", + "archive-board": "نقل اللوح الى الارشيف", + "archive-card": "نقل البطاقة الى الارشيف", + "archive-list": "نقل القائمة الى الارشيف", + "archive-swimlane": "نقل خط السباحة الى الارشيف", + "archive-selection": "نقل التحديد إلى الأرشيف", + "archiveBoardPopup-title": "نقل الوح إلى الأرشيف", + "archived-items": "أرشيف", + "archived-boards": "الالواح في الأرشيف", + "restore-board": "استعادة اللوحة", + "no-archived-boards": "لا توجد لوحات في الأرشيف.", + "archives": "أرشيف", + "template": "نموذج", + "templates": "نماذج", + "assign-member": "تعيين عضو", + "attached": "أُرفق)", + "attachment": "مرفق", + "attachment-delete-pop": "حذف المرق هو حذف نهائي . لا يمكن التراجع إذا حذف.", + "attachmentDeletePopup-title": "تريد حذف المرفق ?", + "attachments": "المرفقات", + "auto-watch": "مراقبة لوحات تلقائيا عندما يتم إنشاؤها", "avatar-too-big": "The avatar is too large (520KB max)", - "back": "Back", - "board-change-color": "Change color", - "board-nb-stars": "%s stars", - "board-not-found": "Board not found", - "board-private-info": "This board will be private.", - "board-public-info": "This board will be public.", - "boardChangeColorPopup-title": "Change Board Background", - "boardChangeTitlePopup-title": "Rename Board", - "boardChangeVisibilityPopup-title": "Change Visibility", - "boardChangeWatchPopup-title": "Change Watch", + "back": "رجوع", + "board-change-color": "تغيير اللومr", + "board-nb-stars": "%s نجوم", + "board-not-found": "لوحة مفقودة", + "board-private-info": "سوف تصبح هذه اللوحة خاصة", + "board-public-info": "سوف تصبح هذه اللوحة عامّة.", + "boardChangeColorPopup-title": "تعديل خلفية الشاشة", + "boardChangeTitlePopup-title": "إعادة تسمية اللوحة", + "boardChangeVisibilityPopup-title": "تعديل وضوح الرؤية", + "boardChangeWatchPopup-title": "تغيير المتابعة", "boardMenuPopup-title": "Board Settings", - "boardChangeViewPopup-title": "Board View", - "boards": "Boards", - "board-view": "Board View", - "board-view-cal": "Calendar", - "board-view-swimlanes": "Swimlanes", + "boardChangeViewPopup-title": "عرض اللوحات", + "boards": "لوحات", + "board-view": "عرض اللوحات", + "board-view-cal": "التقويم", + "board-view-swimlanes": "خطوط السباحة", "board-view-collapse": "Collapse", "board-view-gantt": "Gantt", - "board-view-lists": "Lists", - "bucket-example": "Like “Bucket List” for example", - "cancel": "Cancel", - "card-archived": "This card is moved to Archive.", - "board-archived": "This board is moved to Archive.", - "card-comments-title": "This card has %s comment.", - "card-delete-notice": "Deleting is permanent. You will lose all actions associated with this card.", - "card-delete-pop": "All actions will be removed from the activity feed and you won't be able to re-open the card. There is no undo.", - "card-delete-suggest-archive": "You can move a card to Archive to remove it from the board and preserve the activity.", - "card-due": "Due", - "card-due-on": "Due on", - "card-spent": "Spent Time", - "card-edit-attachments": "Edit attachments", - "card-edit-custom-fields": "Edit custom fields", - "card-edit-labels": "Edit labels", - "card-edit-members": "Edit members", - "card-labels-title": "Change the labels for the card.", - "card-members-title": "Add or remove members of the board from the card.", - "card-start": "Start", - "card-start-on": "Starts on", - "cardAttachmentsPopup-title": "Attach From", - "cardCustomField-datePopup-title": "Change date", - "cardCustomFieldsPopup-title": "Edit custom fields", - "cardStartVotingPopup-title": "Start a vote", + "board-view-lists": "القائمات", + "bucket-example": "مثل « todo list » على سبيل المثال", + "cancel": "إلغاء", + "card-archived": "البطاقة منقولة الى الارشيف", + "board-archived": "اللوحات منقولة الى الارشيف", + "card-comments-title": "%s تعليقات لهذه البطاقة", + "card-delete-notice": "هذا حذف أبديّ . سوف تفقد كل الإجراءات المنوطة بهذه البطاقة", + "card-delete-pop": "سيتم إزالة جميع الإجراءات من تبعات النشاط، وأنك لن تكون قادرا على إعادة فتح البطاقة. لا يوجد التراجع.", + "card-delete-suggest-archive": "يمكنك نقل بطاقة إلى الأرشيف لإزالتها من اللوحة والمحافظة على النشاط.", + "card-due": "مستحق", + "card-due-on": "مستحق في", + "card-spent": "امضى وقتا", + "card-edit-attachments": "تعديل المرفقات", + "card-edit-custom-fields": "تعديل الحقل المعدل", + "card-edit-labels": "تعديل العلامات", + "card-edit-members": "تعديل الأعضاء", + "card-labels-title": "تعديل علامات البطاقة.", + "card-members-title": "إضافة او حذف أعضاء للبطاقة.", + "card-start": "بداية", + "card-start-on": "يبدأ في", + "cardAttachmentsPopup-title": "إرفاق من", + "cardCustomField-datePopup-title": "تغير التاريخ", + "cardCustomFieldsPopup-title": "تعديل الحقل المعدل", + "cardStartVotingPopup-title": "ابدأ تصويت", "positiveVoteMembersPopup-title": "Proponents", "negativeVoteMembersPopup-title": "Opponents", "card-edit-voting": "Edit voting", @@ -174,46 +174,46 @@ "allowNonBoardMembers": "Allow all logged in users", "vote-question": "Voting question", "vote-public": "Show who voted what", - "vote-for-it": "for it", - "vote-against": "against", + "vote-for-it": "مع", + "vote-against": "ضد", "deleteVotePopup-title": "Delete vote?", "vote-delete-pop": "Deleting is permanent. You will lose all actions associated with this vote.", - "cardDeletePopup-title": "Delete Card?", - "cardDetailsActionsPopup-title": "Card Actions", - "cardLabelsPopup-title": "Labels", - "cardMembersPopup-title": "Members", - "cardMorePopup-title": "More", + "cardDeletePopup-title": "حذف البطاقة ?", + "cardDetailsActionsPopup-title": "إجراءات على البطاقة", + "cardLabelsPopup-title": "علامات", + "cardMembersPopup-title": "أعضاء", + "cardMorePopup-title": "المزيد", "cardTemplatePopup-title": "Create template", - "cards": "Cards", - "cards-count": "Cards", - "casSignIn": "Sign In with CAS", - "cardType-card": "Card", - "cardType-linkedCard": "Linked Card", + "cards": "بطاقات", + "cards-count": "بطاقات", + "casSignIn": "تسجيل الدخول مع CAS", + "cardType-card": "بطاقة", + "cardType-linkedCard": "البطاقة المرتبطة", "cardType-linkedBoard": "Linked Board", "change": "Change", - "change-avatar": "Change Avatar", - "change-password": "Change Password", - "change-permissions": "Change permissions", - "change-settings": "Change Settings", - "changeAvatarPopup-title": "Change Avatar", - "changeLanguagePopup-title": "Change Language", - "changePasswordPopup-title": "Change Password", - "changePermissionsPopup-title": "Change Permissions", - "changeSettingsPopup-title": "Change Settings", - "subtasks": "Subtasks", - "checklists": "Checklists", - "click-to-star": "Click to star this board.", - "click-to-unstar": "Click to unstar this board.", + "change-avatar": "تعديل الصورة الشخصية", + "change-password": "تغيير كلمة المرور", + "change-permissions": "تعديل الصلاحيات", + "change-settings": "تغيير الاعدادات", + "changeAvatarPopup-title": "تعديل الصورة الشخصية", + "changeLanguagePopup-title": "تغيير اللغة", + "changePasswordPopup-title": "تغيير كلمة المرور", + "changePermissionsPopup-title": "تعديل الصلاحيات", + "changeSettingsPopup-title": "تغيير الاعدادات", + "subtasks": "المهمات الفرعية", + "checklists": "قوائم التّدقيق", + "click-to-star": "اضغط لإضافة اللوحة للمفضلة.", + "click-to-unstar": "اضغط لحذف اللوحة من المفضلة.", "clipboard": "Clipboard or drag & drop", - "close": "Close", - "close-board": "Close Board", + "close": "غلق", + "close-board": "غلق اللوحة", "close-board-pop": "You will be able to restore the board by clicking the “Archive” button from the home header.", "color-black": "black", "color-blue": "blue", "color-crimson": "crimson", - "color-darkgreen": "darkgreen", - "color-gold": "gold", - "color-gray": "gray", + "color-darkgreen": "اخضر غامق", + "color-gold": "ذهبي", + "color-gray": "رمادي", "color-green": "green", "color-indigo": "indigo", "color-lime": "lime", @@ -228,75 +228,75 @@ "color-purple": "purple", "color-red": "red", "color-saddlebrown": "saddlebrown", - "color-silver": "silver", + "color-silver": "فضي", "color-sky": "sky", "color-slateblue": "slateblue", - "color-white": "white", + "color-white": "أبيض", "color-yellow": "yellow", "unset-color": "Unset", - "comment": "Comment", - "comment-placeholder": "Write Comment", - "comment-only": "Comment only", - "comment-only-desc": "Can comment on cards only.", - "no-comments": "No comments", + "comment": "تعليق", + "comment-placeholder": "أكتب تعليق", + "comment-only": "التعليق فقط", + "comment-only-desc": "يمكن التعليق على بطاقات فقط.", + "no-comments": "لا يوجد تعليقات", "no-comments-desc": "Can not see comments and activities.", "worker": "Worker", "worker-desc": "Can only move cards, assign itself to card and comment.", - "computer": "Computer", + "computer": "حاسوب", "confirm-subtask-delete-dialog": "Are you sure you want to delete subtask?", "confirm-checklist-delete-dialog": "Are you sure you want to delete checklist?", - "copy-card-link-to-clipboard": "Copy card link to clipboard", - "linkCardPopup-title": "Link Card", - "searchElementPopup-title": "Search", - "copyCardPopup-title": "Copy Card", + "copy-card-link-to-clipboard": "نسخ رابط البطاقة إلى الحافظة", + "linkCardPopup-title": "ربط البطاقة", + "searchElementPopup-title": "بحث", + "copyCardPopup-title": "نسخ البطاقة", "copyChecklistToManyCardsPopup-title": "Copy Checklist Template to Many Cards", "copyChecklistToManyCardsPopup-instructions": "Destination Card Titles and Descriptions in this JSON format", "copyChecklistToManyCardsPopup-format": "[ {\"title\": \"First card title\", \"description\":\"First card description\"}, {\"title\":\"Second card title\",\"description\":\"Second card description\"},{\"title\":\"Last card title\",\"description\":\"Last card description\"} ]", - "create": "Create", - "createBoardPopup-title": "Create Board", - "chooseBoardSourcePopup-title": "Import board", - "createLabelPopup-title": "Create Label", - "createCustomField": "Create Field", - "createCustomFieldPopup-title": "Create Field", - "current": "current", + "create": "إنشاء", + "createBoardPopup-title": "إنشاء لوحة", + "chooseBoardSourcePopup-title": "استيراد لوحة", + "createLabelPopup-title": "إنشاء علامة", + "createCustomField": "انشاء حقل", + "createCustomFieldPopup-title": "انشاء حقل", + "current": "الحالي", "custom-field-delete-pop": "There is no undo. This will remove this custom field from all cards and destroy its history.", "custom-field-checkbox": "Checkbox", "custom-field-currency": "Currency", "custom-field-currency-option": "Currency Code", - "custom-field-date": "Date", + "custom-field-date": "تاريخ", "custom-field-dropdown": "Dropdown List", "custom-field-dropdown-none": "(none)", "custom-field-dropdown-options": "List Options", "custom-field-dropdown-options-placeholder": "Press enter to add more options", "custom-field-dropdown-unknown": "(unknown)", - "custom-field-number": "Number", - "custom-field-text": "Text", + "custom-field-number": "رقم", + "custom-field-text": "نص", "custom-fields": "Custom Fields", - "date": "Date", + "date": "تاريخ", "decline": "Decline", - "default-avatar": "Default avatar", - "delete": "Delete", + "default-avatar": "صورة شخصية افتراضية", + "delete": "حذف", "deleteCustomFieldPopup-title": "Delete Custom Field?", - "deleteLabelPopup-title": "Delete Label?", - "description": "Description", - "disambiguateMultiLabelPopup-title": "Disambiguate Label Action", - "disambiguateMultiMemberPopup-title": "Disambiguate Member Action", - "discard": "Discard", + "deleteLabelPopup-title": "حذف العلامة ?", + "description": "وصف", + "disambiguateMultiLabelPopup-title": "تحديد الإجراء على العلامة", + "disambiguateMultiMemberPopup-title": "تحديد الإجراء على العضو", + "discard": "التخلص منها", "done": "Done", - "download": "Download", - "edit": "Edit", - "edit-avatar": "Change Avatar", - "edit-profile": "Edit Profile", + "download": "تنزيل", + "edit": "تعديل", + "edit-avatar": "تعديل الصورة الشخصية", + "edit-profile": "تعديل الملف الشخصي", "edit-wip-limit": "Edit WIP Limit", "soft-wip-limit": "Soft WIP Limit", - "editCardStartDatePopup-title": "Change start date", - "editCardDueDatePopup-title": "Change due date", + "editCardStartDatePopup-title": "تغيير تاريخ البدء", + "editCardDueDatePopup-title": "تغيير تاريخ الاستحقاق", "editCustomFieldPopup-title": "Edit Field", "editCardSpentTimePopup-title": "Change spent time", - "editLabelPopup-title": "Change Label", - "editNotificationPopup-title": "Edit Notification", - "editProfilePopup-title": "Edit Profile", - "email": "Email", + "editLabelPopup-title": "تعديل العلامة", + "editNotificationPopup-title": "تصحيح الإشعار", + "editProfilePopup-title": "تعديل الملف الشخصي", + "email": "البريد الإلكتروني", "email-enrollAccount-subject": "An account created for you on __siteName__", "email-enrollAccount-text": "Hello __user__,\n\nTo start using the service, simply click the link below.\n\n__url__\n\nThanks.", "email-fail": "Sending email failed", @@ -319,10 +319,10 @@ "error-csv-schema": "Your CSV(Comma Separated Values)/TSV (Tab Separated Values) does not include the proper information in the correct format", "error-list-doesNotExist": "This list does not exist", "error-user-doesNotExist": "This user does not exist", - "error-user-notAllowSelf": "You can not invite yourself", + "error-user-notAllowSelf": "لا يمكنك دعوة نفسك", "error-user-notCreated": "This user is not created", - "error-username-taken": "This username is already taken", - "error-email-taken": "Email has already been taken", + "error-username-taken": "إسم المستخدم مأخوذ مسبقا", + "error-email-taken": "البريد الإلكتروني مأخوذ بالفعل", "export-board": "Export board", "export-board-json": "Export board to JSON", "export-board-csv": "Export board to CSV", @@ -340,289 +340,289 @@ "list-label-short-modifiedAt": "(L)", "list-label-short-title": "(N)", "list-label-short-sort": "(M)", - "filter": "Filter", + "filter": "تصفية", "filter-cards": "Filter Cards or Lists", "list-filter-label": "Filter List by Title", - "filter-clear": "Clear filter", + "filter-clear": "مسح التصفية", "filter-labels-label": "Filter by label", - "filter-no-label": "No label", + "filter-no-label": "لا يوجد ملصق", "filter-member-label": "Filter by member", - "filter-no-member": "No member", + "filter-no-member": "ليس هناك أي عضو", "filter-assignee-label": "Filter by assignee", "filter-no-assignee": "No assignee", "filter-custom-fields-label": "Filter by Custom Fields", "filter-no-custom-fields": "No Custom Fields", "filter-show-archive": "Show archived lists", "filter-hide-empty": "Hide empty lists", - "filter-on": "Filter is on", - "filter-on-desc": "You are filtering cards on this board. Click here to edit filter.", - "filter-to-selection": "Filter to selection", + "filter-on": "التصفية تشتغل", + "filter-on-desc": "أنت بصدد تصفية بطاقات هذه اللوحة. اضغط هنا لتعديل التصفية.", + "filter-to-selection": "تصفية بالتحديد", "other-filters-label": "Other Filters", "advanced-filter-label": "Advanced Filter", "advanced-filter-description": "Advanced Filter allows to write a string containing following operators: == != <= >= && || ( ) A space is used as a separator between the Operators. You can filter for all Custom Fields by typing their names and values. For Example: Field1 == Value1. Note: If fields or values contains spaces, you need to encapsulate them into single quotes. For Example: 'Field 1' == 'Value 1'. For single control characters (' \\/) to be skipped, you can use \\. For example: Field1 == I\\'m. Also you can combine multiple conditions. For Example: F1 == V1 || F1 == V2. Normally all operators are interpreted from left to right. You can change the order by placing brackets. For Example: F1 == V1 && ( F2 == V2 || F2 == V3 ). Also you can search text fields using regex: F1 == /Tes.*/i", - "fullname": "Full Name", - "header-logo-title": "Go back to your boards page.", - "hide-system-messages": "Hide system messages", - "headerBarCreateBoardPopup-title": "Create Board", - "home": "Home", + "fullname": "الإسم الكامل", + "header-logo-title": "الرجوع إلى صفحة اللوحات", + "hide-system-messages": "إخفاء رسائل النظام", + "headerBarCreateBoardPopup-title": "إنشاء لوحة", + "home": "الرئيسية", "import": "Import", "impersonate-user": "Impersonate user", - "link": "Link", - "import-board": "import board", - "import-board-c": "Import board", + "link": "رابط", + "import-board": "استيراد لوحة", + "import-board-c": "استيراد لوحة", "import-board-title-trello": "Import board from Trello", "import-board-title-wekan": "Import board from previous export", "import-board-title-csv": "Import board from CSV/TSV", - "from-trello": "From Trello", + "from-trello": "من تريلو", "from-wekan": "From previous export", "from-csv": "From CSV/TSV", - "import-board-instruction-trello": "In your Trello board, go to 'Menu', then 'More', 'Print and Export', 'Export JSON', and copy the resulting text.", + "import-board-instruction-trello": "In your Trello board, go to 'Menu', then 'More', 'Print and Export', 'Export JSON', and copy the resulting text", "import-board-instruction-csv": "Paste in your Comma Separated Values(CSV)/ Tab Separated Values (TSV) .", "import-board-instruction-wekan": "In your board, go to 'Menu', then 'Export board', and copy the text in the downloaded file.", "import-board-instruction-about-errors": "If you get errors when importing board, sometimes importing still works, and board is at All Boards page.", "import-json-placeholder": "Paste your valid JSON data here", "import-csv-placeholder": "Paste your valid CSV/TSV data here", - "import-map-members": "Map members", + "import-map-members": "رسم خريطة الأعضاء", "import-members-map": "Your imported board has some members. Please map the members you want to import to your users", "import-show-user-mapping": "Review members mapping", "import-user-select": "Pick your existing user you want to use as this member", "importMapMembersAddPopup-title": "Select member", - "info": "Version", - "initials": "Initials", - "invalid-date": "Invalid date", + "info": "الإصدار", + "initials": "أولية", + "invalid-date": "تاريخ غير صالح", "invalid-time": "Invalid time", "invalid-user": "Invalid user", - "joined": "joined", + "joined": "انضمّ", "just-invited": "You are just invited to this board", - "keyboard-shortcuts": "Keyboard shortcuts", - "label-create": "Create Label", - "label-default": "%s label (default)", - "label-delete-pop": "There is no undo. This will remove this label from all cards and destroy its history.", - "labels": "Labels", - "language": "Language", - "last-admin-desc": "You can’t change roles because there must be at least one admin.", - "leave-board": "Leave Board", + "keyboard-shortcuts": "اختصار لوحة المفاتيح", + "label-create": "إنشاء علامة", + "label-default": "%s علامة (افتراضية)", + "label-delete-pop": "لا يوجد تراجع. سيؤدي هذا إلى إزالة هذه العلامة من جميع بطاقات والقضاء على تأريخها", + "labels": "علامات", + "language": "لغة", + "last-admin-desc": "لا يمكن تعديل الأدوار لأن ذلك يتطلب صلاحيات المدير.", + "leave-board": "مغادرة اللوحة", "leave-board-pop": "Are you sure you want to leave __boardTitle__? You will be removed from all cards on this board.", - "leaveBoardPopup-title": "Leave Board ?", - "link-card": "Link to this card", + "leaveBoardPopup-title": "مغادرة اللوحة ؟", + "link-card": "ربط هذه البطاقة", "list-archive-cards": "Move all cards in this list to Archive", "list-archive-cards-pop": "This will remove all the cards in this list from the board. To view cards in Archive and bring them back to the board, click “Menu” > “Archive”.", - "list-move-cards": "Move all cards in this list", - "list-select-cards": "Select all cards in this list", + "list-move-cards": "نقل بطاقات هذه القائمة", + "list-select-cards": "تحديد بطاقات هذه القائمة", "set-color-list": "Set Color", - "listActionPopup-title": "List Actions", + "listActionPopup-title": "قائمة الإجراءات", "settingsUserPopup-title": "User Settings", "swimlaneActionPopup-title": "Swimlane Actions", "swimlaneAddPopup-title": "Add a Swimlane below", "listImportCardPopup-title": "Import a Trello card", "listImportCardsTsvPopup-title": "Import Excel CSV/TSV", - "listMorePopup-title": "More", - "link-list": "Link to this list", + "listMorePopup-title": "المزيد", + "link-list": "رابط إلى هذه القائمة", "list-delete-pop": "All actions will be removed from the activity feed and you won't be able to recover the list. There is no undo.", "list-delete-suggest-archive": "You can move a list to Archive to remove it from the board and preserve the activity.", - "lists": "Lists", - "swimlanes": "Swimlanes", - "log-out": "Log Out", - "log-in": "Log In", - "loginPopup-title": "Log In", - "memberMenuPopup-title": "Member Settings", - "members": "Members", - "menu": "Menu", + "lists": "القائمات", + "swimlanes": "خطوط السباحة", + "log-out": "تسجيل الخروج", + "log-in": "تسجيل الدخول", + "loginPopup-title": "تسجيل الدخول", + "memberMenuPopup-title": "أفضليات الأعضاء", + "members": "أعضاء", + "menu": "القائمة", "move-selection": "Move selection", - "moveCardPopup-title": "Move Card", - "moveCardToBottom-title": "Move to Bottom", - "moveCardToTop-title": "Move to Top", + "moveCardPopup-title": "نقل البطاقة", + "moveCardToBottom-title": "التحرك إلى القاع", + "moveCardToTop-title": "التحرك إلى الأعلى", "moveSelectionPopup-title": "Move selection", - "multi-selection": "Multi-Selection", + "multi-selection": "تحديد أكثر من واحدة", "multi-selection-label": "Set label for selection", "multi-selection-member": "Set member for selection", "multi-selection-on": "Multi-Selection is on", - "muted": "Muted", + "muted": "مكتوم", "muted-info": "You will never be notified of any changes in this board", - "my-boards": "My Boards", - "name": "Name", + "my-boards": "لوحاتي", + "name": "اسم", "no-archived-cards": "No cards in Archive.", "no-archived-lists": "No lists in Archive.", "no-archived-swimlanes": "No swimlanes in Archive.", - "no-results": "No results", - "normal": "Normal", - "normal-desc": "Can view and edit cards. Can't change settings.", + "no-results": "لا توجد نتائج", + "normal": "عادي", + "normal-desc": "يمكن مشاهدة و تعديل البطاقات. لا يمكن تغيير إعدادات الضبط.", "not-accepted-yet": "Invitation not accepted yet", "notify-participate": "Receive updates to any cards you participate as creater or member", "notify-watch": "Receive updates to any boards, lists, or cards you’re watching", - "optional": "optional", + "optional": "اختياري", "or": "or", - "page-maybe-private": "This page may be private. You may be able to view it by logging in.", - "page-not-found": "Page not found.", - "password": "Password", + "page-maybe-private": "قدتكون هذه الصفحة خاصة . قد تستطيع مشاهدتها ب تسجيل الدخول.", + "page-not-found": "صفحة غير موجودة", + "password": "كلمة المرور", "paste-or-dragdrop": "to paste, or drag & drop image file to it (image only)", - "participating": "Participating", + "participating": "المشاركة", "preview": "Preview", "previewAttachedImagePopup-title": "Preview", "previewClipboardImagePopup-title": "Preview", - "private": "Private", - "private-desc": "This board is private. Only people added to the board can view and edit it.", - "profile": "Profile", - "public": "Public", - "public-desc": "This board is public. It's visible to anyone with the link and will show up in search engines like Google. Only people added to the board can edit.", - "quick-access-description": "Star a board to add a shortcut in this bar.", - "remove-cover": "Remove Cover", - "remove-from-board": "Remove from Board", - "remove-label": "Remove Label", - "listDeletePopup-title": "Delete List ?", - "remove-member": "Remove Member", - "remove-member-from-card": "Remove from Card", - "remove-member-pop": "Remove __name__ (__username__) from __boardTitle__? The member will be removed from all cards on this board. They will receive a notification.", - "removeMemberPopup-title": "Remove Member?", - "rename": "Rename", - "rename-board": "Rename Board", - "restore": "Restore", - "save": "Save", - "search": "Search", + "private": "خاص", + "private-desc": "هذه اللوحة خاصة . لا يسمح إلا للأعضاء .", + "profile": "ملف شخصي", + "public": "عامّ", + "public-desc": "هذه اللوحة عامة: مرئية لكلّ من يحصل على الرابط ، و هي مرئية أيضا في محركات البحث مثل جوجل. التعديل مسموح به للأعضاء فقط.", + "quick-access-description": "أضف لوحة إلى المفضلة لإنشاء اختصار في هذا الشريط.", + "remove-cover": "حذف الغلاف", + "remove-from-board": "حذف من اللوحة", + "remove-label": "إزالة التصنيف", + "listDeletePopup-title": "حذف القائمة ؟", + "remove-member": "حذف العضو", + "remove-member-from-card": "حذف من البطاقة", + "remove-member-pop": "حذف __name__ (__username__) من __boardTitle__ ? سيتم حذف هذا العضو من جميع بطاقة اللوحة مع إرسال إشعار له بذاك.", + "removeMemberPopup-title": "حذف العضو ?", + "rename": "إعادة التسمية", + "rename-board": "إعادة تسمية اللوحة", + "restore": "استعادة", + "save": "حفظ", + "search": "بحث", "rules": "Rules", "search-cards": "Search from card/list titles, descriptions and custom fields on this board", "search-example": "Write text you search and press Enter", - "select-color": "Select Color", + "select-color": "اختيار اللون", "select-board": "Select Board", "set-wip-limit-value": "Set a limit for the maximum number of tasks in this list", "setWipLimitPopup-title": "Set WIP Limit", "shortcut-assign-self": "Assign yourself to current card", - "shortcut-autocomplete-emoji": "Autocomplete emoji", - "shortcut-autocomplete-members": "Autocomplete members", - "shortcut-clear-filters": "Clear all filters", - "shortcut-close-dialog": "Close Dialog", - "shortcut-filter-my-cards": "Filter my cards", - "shortcut-show-shortcuts": "Bring up this shortcuts list", + "shortcut-autocomplete-emoji": "الإكمال التلقائي للرموز التعبيرية", + "shortcut-autocomplete-members": "الإكمال التلقائي لأسماء الأعضاء", + "shortcut-clear-filters": "مسح التصفيات", + "shortcut-close-dialog": "غلق النافذة", + "shortcut-filter-my-cards": "تصفية بطاقاتي", + "shortcut-show-shortcuts": "عرض قائمة الإختصارات ،تلك", "shortcut-toggle-filterbar": "Toggle Filter Sidebar", "shortcut-toggle-searchbar": "Toggle Search Sidebar", - "shortcut-toggle-sidebar": "Toggle Board Sidebar", - "show-cards-minimum-count": "Show cards count if list contains more than", - "sidebar-open": "Open Sidebar", - "sidebar-close": "Close Sidebar", - "signupPopup-title": "Create an Account", - "star-board-title": "Click to star this board. It will show up at top of your boards list.", - "starred-boards": "Starred Boards", - "starred-boards-description": "Starred boards show up at the top of your boards list.", - "subscribe": "Subscribe", - "team": "Team", - "this-board": "this board", - "this-card": "this card", + "shortcut-toggle-sidebar": "إظهار-إخفاء الشريط الجانبي للوحة", + "show-cards-minimum-count": "إظهار عدد البطاقات إذا كانت القائمة تتضمن أكثر من", + "sidebar-open": "فتح الشريط الجانبي", + "sidebar-close": "إغلاق الشريط الجانبي", + "signupPopup-title": "إنشاء حساب", + "star-board-title": "اضغط لإضافة هذه اللوحة إلى المفضلة . سوف يتم إظهارها على رأس بقية اللوحات.", + "starred-boards": "اللوحات المفضلة", + "starred-boards-description": "تعرض اللوحات المفضلة على رأس بقية اللوحات.", + "subscribe": "اشتراك و متابعة", + "team": "فريق", + "this-board": "هذه اللوحة", + "this-card": "هذه البطاقة", "spent-time-hours": "Spent time (hours)", - "overtime-hours": "Overtime (hours)", - "overtime": "Overtime", + "overtime-hours": "وقت اضافي (ساعات)", + "overtime": "وقت اضافي", "has-overtime-cards": "Has overtime cards", "has-spenttime-cards": "Has spent time cards", - "time": "Time", - "title": "Title", - "tracking": "Tracking", + "time": "الوقت", + "title": "عنوان", + "tracking": "تتبع", "tracking-info": "You will be notified of any changes to those cards you are involved as creator or member.", - "type": "Type", - "unassign-member": "Unassign member", - "unsaved-description": "You have an unsaved description.", - "unwatch": "Unwatch", + "type": "النوع", + "unassign-member": "إلغاء تعيين العضو", + "unsaved-description": "لديك وصف غير محفوظ", + "unwatch": "غير مُشاهد", "upload": "Upload", - "upload-avatar": "Upload an avatar", - "uploaded-avatar": "Uploaded an avatar", + "upload-avatar": "رفع صورة شخصية", + "uploaded-avatar": "تم رفع الصورة الشخصية", "custom-top-left-corner-logo-image-url": "Custom Top Left Corner Logo Image URL", "custom-top-left-corner-logo-link-url": "Custom Top Left Corner Logo Link URL", "custom-top-left-corner-logo-height": "Custom Top Left Corner Logo Height. Default: 27", "custom-login-logo-image-url": "Custom Login Logo Image URL", "custom-login-logo-link-url": "Custom Login Logo Link URL", "text-below-custom-login-logo": "Text below Custom Login Logo", - "username": "Username", - "view-it": "View it", + "username": "اسم المستخدم", + "view-it": "شاهدها", "warn-list-archived": "warning: this card is in an list at Archive", - "watch": "Watch", - "watching": "Watching", + "watch": "مُشاهد", + "watching": "مشاهدة", "watching-info": "You will be notified of any change in this board", - "welcome-board": "Welcome Board", + "welcome-board": "لوحة التّرحيب", "welcome-swimlane": "Milestone 1", - "welcome-list1": "Basics", - "welcome-list2": "Advanced", + "welcome-list1": "المبادئ", + "welcome-list2": "متقدم", "card-templates-swimlane": "Card Templates", "list-templates-swimlane": "List Templates", "board-templates-swimlane": "Board Templates", - "what-to-do": "What do you want to do?", + "what-to-do": "ماذا تريد أن تنجز?", "wipLimitErrorPopup-title": "Invalid WIP Limit", "wipLimitErrorPopup-dialog-pt1": "The number of tasks in this list is higher than the WIP limit you've defined.", "wipLimitErrorPopup-dialog-pt2": "Please move some tasks out of this list, or set a higher WIP limit.", - "admin-panel": "Admin Panel", - "settings": "Settings", - "people": "People", - "registration": "Registration", + "admin-panel": "لوحة التحكم", + "settings": "الإعدادات", + "people": "الناس", + "registration": "تسجيل", "disable-self-registration": "Disable Self-Registration", - "invite": "Invite", - "invite-people": "Invite People", - "to-boards": "To board(s)", - "email-addresses": "Email Addresses", + "invite": "دعوة", + "invite-people": "الناس المدعوين", + "to-boards": "إلى اللوحات", + "email-addresses": "عناوين البريد الإلكتروني", "smtp-host-description": "The address of the SMTP server that handles your emails.", "smtp-port-description": "The port your SMTP server uses for outgoing emails.", - "smtp-tls-description": "Enable TLS support for SMTP server", - "smtp-host": "SMTP Host", - "smtp-port": "SMTP Port", - "smtp-username": "Username", - "smtp-password": "Password", - "smtp-tls": "TLS support", - "send-from": "From", + "smtp-tls-description": "تفعيل دعم TLS من اجل خادم SMTP", + "smtp-host": "مضيف SMTP", + "smtp-port": "منفذ SMTP", + "smtp-username": "اسم المستخدم", + "smtp-password": "كلمة المرور", + "smtp-tls": "دعم التي ال سي", + "send-from": "من", "send-smtp-test": "Send a test email to yourself", - "invitation-code": "Invitation Code", - "email-invite-register-subject": "__inviter__ sent you an invitation", + "invitation-code": "رمز الدعوة", + "email-invite-register-subject": "__inviter__ أرسل دعوة لك", "email-invite-register-text": "Dear __user__,\n\n__inviter__ invites you to kanban board for collaborations.\n\nPlease follow the link below:\n__url__\n\nAnd your invitation code is: __icode__\n\nThanks.", "email-smtp-test-subject": "SMTP Test Email", "email-smtp-test-text": "You have successfully sent an email", - "error-invitation-code-not-exist": "Invitation code doesn't exist", - "error-notAuthorized": "You are not authorized to view this page.", + "error-invitation-code-not-exist": "رمز الدعوة غير موجود", + "error-notAuthorized": "أنتَ لا تملك الصلاحيات لرؤية هذه الصفحة.", "webhook-title": "Webhook Name", "webhook-token": "Token (Optional for Authentication)", - "outgoing-webhooks": "Outgoing Webhooks", + "outgoing-webhooks": "الويبهوك الصادرة", "bidirectional-webhooks": "Two-Way Webhooks", - "outgoingWebhooksPopup-title": "Outgoing Webhooks", + "outgoingWebhooksPopup-title": "الويبهوك الصادرة", "boardCardTitlePopup-title": "Card Title Filter", "disable-webhook": "Disable This Webhook", "global-webhook": "Global Webhooks", - "new-outgoing-webhook": "New Outgoing Webhook", - "no-name": "(Unknown)", - "Node_version": "Node version", + "new-outgoing-webhook": "ويبهوك جديدة ", + "no-name": "(غير معروف)", + "Node_version": "إصدار النود", "Meteor_version": "Meteor version", "MongoDB_version": "MongoDB version", "MongoDB_storage_engine": "MongoDB storage engine", "MongoDB_Oplog_enabled": "MongoDB Oplog enabled", - "OS_Arch": "OS Arch", - "OS_Cpus": "OS CPU Count", - "OS_Freemem": "OS Free Memory", - "OS_Loadavg": "OS Load Average", - "OS_Platform": "OS Platform", - "OS_Release": "OS Release", - "OS_Totalmem": "OS Total Memory", - "OS_Type": "OS Type", - "OS_Uptime": "OS Uptime", - "days": "days", - "hours": "hours", - "minutes": "minutes", - "seconds": "seconds", + "OS_Arch": "معمارية نظام التشغيل", + "OS_Cpus": "استهلاك وحدة المعالجة المركزية لنظام التشغيل", + "OS_Freemem": "الذاكرة الحرة لنظام التشغيل", + "OS_Loadavg": "متوسط حمل نظام التشغيل", + "OS_Platform": "منصة نظام التشغيل", + "OS_Release": "إصدار نظام التشغيل", + "OS_Totalmem": "الذاكرة الكلية لنظام التشغيل", + "OS_Type": "نوع نظام التشغيل", + "OS_Uptime": "مدة تشغيل نظام التشغيل", + "days": "أيام", + "hours": "الساعات", + "minutes": "الدقائق", + "seconds": "الثواني", "show-field-on-card": "Show this field on card", "automatically-field-on-card": "Add field to new cards", "always-field-on-card": "Add field to all cards", "showLabel-field-on-card": "Show field label on minicard", - "yes": "Yes", - "no": "No", - "accounts": "Accounts", - "accounts-allowEmailChange": "Allow Email Change", + "yes": "نعم", + "no": "لا", + "accounts": "الحسابات", + "accounts-allowEmailChange": "السماح بتغيير البريد الإلكتروني", "accounts-allowUserNameChange": "Allow Username Change", "createdAt": "Created at", "modifiedAt": "Modified at", "verified": "Verified", - "active": "Active", + "active": "نشط", "card-received": "Received", "card-received-on": "Received on", "card-end": "End", "card-end-on": "Ends on", "editCardReceivedDatePopup-title": "Change received date", "editCardEndDatePopup-title": "Change end date", - "setCardColorPopup-title": "Set color", - "setCardActionsColorPopup-title": "Choose a color", - "setSwimlaneColorPopup-title": "Choose a color", - "setListColorPopup-title": "Choose a color", + "setCardColorPopup-title": "حدد اللون", + "setCardActionsColorPopup-title": "اختر لوناً", + "setSwimlaneColorPopup-title": "اختر لوناً", + "setListColorPopup-title": "اختر لوناً", "assigned-by": "Assigned By", "requested-by": "Requested By", "board-delete-notice": "Deleting is permanent. You will lose all lists, cards and actions associated with this board.", @@ -699,10 +699,10 @@ "r-top-of": "Top of", "r-bottom-of": "Bottom of", "r-its-list": "its list", - "r-archive": "Move to Archive", + "r-archive": "نقل الى الارشيف", "r-unarchive": "Restore from Archive", "r-card": "card", - "r-add": "Add", + "r-add": "أضف", "r-remove": "Remove", "r-label": "label", "r-member": "member", @@ -855,7 +855,7 @@ "website": "Website", "person": "Person", "my-cards": "My Cards", - "card": "Card", + "card": "بطاقة", "board": "Board", "context-separator": "/", "myCardsSortChange-title": "My Cards Sort", @@ -869,30 +869,30 @@ "dueCardsViewChange-choice-all": "All Users", "dueCardsViewChange-choice-all-description": "Shows all incomplete cards with a *Due* date from boards for which the user has permission.", "broken-cards": "Broken Cards", - "board-title-not-found": "Board '%s' not found.", - "swimlane-title-not-found": "Swimlane '%s' not found.", - "list-title-not-found": "List '%s' not found.", - "label-not-found": "Label '%s' not found.", + "board-title-not-found": "لوحة '%s' غير موجود.", + "swimlane-title-not-found": "صف '%s' غير موجود.", + "list-title-not-found": "لستة '%s' غير موجود.", + "label-not-found": "ختم '%s' غير موجود.", "label-color-not-found": "Label color %s not found.", "user-username-not-found": "Username '%s' not found.", - "globalSearch-title": "Search All Boards", + "globalSearch-title": "بحث في كل لوحة", "no-cards-found": "No Cards Found", "one-card-found": "One Card Found", - "n-cards-found": "%s Cards Found", - "n-n-of-n-cards-found": "__start__-__end__ of __total__ Cards Found", - "operator-board": "board", + "n-cards-found": "%s بطاقة", + "n-n-of-n-cards-found": "__start__-__end__ من __total__", + "operator-board": "لوحة", "operator-board-abbrev": "b", - "operator-swimlane": "swimlane", + "operator-swimlane": "صف", "operator-swimlane-abbrev": "s", - "operator-list": "list", + "operator-list": "لستة", "operator-list-abbrev": "l", - "operator-label": "label", + "operator-label": "ختم", "operator-label-abbrev": "#", - "operator-user": "user", + "operator-user": "مستخدم", "operator-user-abbrev": "@", - "operator-member": "member", + "operator-member": "مشارك", "operator-member-abbrev": "m", - "operator-assignee": "assignee", + "operator-assignee": "مسؤول", "operator-assignee-abbrev": "a", "operator-is": "is", "operator-due": "due", @@ -900,8 +900,8 @@ "operator-modified": "modified", "operator-unknown-error": "%s is not an operator", "operator-number-expected": "operator __operator__ expected a number, got '__value__'", - "heading-notes": "Notes", - "globalSearch-instructions-heading": "Search Instructions", + "heading-notes": "ملاحظات", + "globalSearch-instructions-heading": "تعليمات البحث", "globalSearch-instructions-description": "Searches can include operators to refine the search. Operators are specified by writing the operator name and value separated by a colon. For example, an operator specification of `list:Blocked` would limit the search to cards that are contained in a list named *Blocked*. If the value contains spaces or special characters it must be enclosed in quotation marks (e.g. `__operator_list__:\"To Review\"`).", "globalSearch-instructions-operators": "Available operators:", "globalSearch-instructions-operator-board": "`__operator_board__:title` - cards in boards matching the specified title", @@ -920,7 +920,7 @@ "globalSearch-instructions-notes-5": "Currently archived cards are not searched.", "link-to-search": "Link to this search", "excel-font": "Arial", - "number": "Number", - "label-colors": "Label Colors", - "label-names": "Label Names" + "number": "رقم", + "label-colors": "الوان الختم", + "label-names": "أسماء الختم" } From 6a32424a086e803a3da17295a3c716ead184fb23 Mon Sep 17 00:00:00 2001 From: "John R. Supplee" Date: Tue, 26 Jan 2021 18:39:09 +0200 Subject: [PATCH 13/16] Add support for searching archived cards * Add logic to search for archived or all cards * Add icons to board, swimlane and list titles to indicate if they are archived * Update search instructions --- client/components/cards/resultCard.jade | 21 ++++-- client/components/main/globalSearch.js | 87 ++++++++++++++++++------ i18n/ar-EG.i18n.json | 19 ++++-- i18n/en.i18n.json | 21 ++++-- models/boards.js | 38 +++++------ server/publications/cards.js | 89 +++++++++++++++++++------ 6 files changed, 198 insertions(+), 77 deletions(-) diff --git a/client/components/cards/resultCard.jade b/client/components/cards/resultCard.jade index 77f0473af..4a873b8fd 100644 --- a/client/components/cards/resultCard.jade +++ b/client/components/cards/resultCard.jade @@ -5,19 +5,28 @@ template(name="resultCard") //= card.title ul.result-card-context-list li.result-card-context(title="{{_ 'board'}}") - +viewer - = getBoard.title + .result-card-block-wrapper + +viewer + = getBoard.title + if getBoard.archived + i.fa.fa-archive li.result-card-context.result-card-context-separator = ' ' | {{_ 'context-separator'}} = ' ' li.result-card-context(title="{{_ 'swimlane'}}") - +viewer - = getSwimlane.title + .result-card-block-wrapper + +viewer + = getSwimlane.title + if getSwimlane.archived + i.fa.fa-archive li.result-card-context.result-card-context-separator = ' ' | {{_ 'context-separator'}} = ' ' li.result-card-context(title="{{_ 'list'}}") - +viewer - = getList.title + .result-card-block-wrapper + +viewer + = getList.title + if getList.archived + i.fa.fa-archive diff --git a/client/components/main/globalSearch.js b/client/components/main/globalSearch.js index 4c5d7c76b..908da2dcd 100644 --- a/client/components/main/globalSearch.js +++ b/client/components/main/globalSearch.js @@ -177,8 +177,8 @@ BlazeComponent.extendComponent({ this.searching.set(true); - const reOperator1 = /^((?[\w\p{L}]+):|(?[#@]))(?[\w\p{L}]+)(\s+|$)/iu; - const reOperator2 = /^((?[\w\p{L}]+):|(?[#@]))(?["']*)(?.*?)\k(\s+|$)/iu; + const reOperator1 = /^((?[\p{Letter}\p{Mark}]+):|(?[#@]))(?[\p{Letter}\p{Mark}]+)(\s+|$)/iu; + const reOperator2 = /^((?[\p{Letter}\p{Mark}]+):|(?[#@]))(?["']*)(?.*?)\k(\s+|$)/iu; const reText = /^(?\S+)(\s+|$)/u; const reQuotedText = /^(?["'])(?[\w\p{L}]+)\k(\s+|$)/u; @@ -197,7 +197,7 @@ BlazeComponent.extendComponent({ 'operator-member-abbrev': 'members', 'operator-assignee': 'assignees', 'operator-assignee-abbrev': 'assignees', - 'operator-is': 'is', + 'operator-status': 'status', 'operator-due': 'dueAt', 'operator-created': 'createdAt', 'operator-modified': 'modifiedAt', @@ -207,31 +207,33 @@ BlazeComponent.extendComponent({ const predicates = { due: { 'predicate-overdue': 'overdue', - 'predicate-day': 'day', + }, + durations: { 'predicate-week': 'week', 'predicate-month': 'month', 'predicate-quarter': 'quarter', 'predicate-year': 'year', }, - date: { - 'predicate-day': 'day', - 'predicate-week': 'week', - 'predicate-month': 'month', - 'predicate-quarter': 'quarter', - 'predicate-year': 'year', - }, - is: { + status: { 'predicate-archived': 'archived', - 'predicate-active': 'active', + 'predicate-all': 'all', + 'predicate-ended': 'ended', + }, + sorts: { + 'predicate-due': 'dueAt', + 'predicate-created': 'createdAt', + 'predicate-modified': 'modifiedAt', }, }; const predicateTranslations = {}; - Object.entries(predicates, ([category, predicates]) => { + Object.entries(predicates).forEach(([category, catPreds]) => { predicateTranslations[category] = {}; - Object.entries(predicates, ([tag, value]) => { + Object.entries(catPreds).forEach(([tag, value]) => { predicateTranslations[category][TAPi18n.__(tag)] = value; }); }); + // eslint-disable-next-line no-console + // console.log('predicateTranslations:', predicateTranslations); const operatorMap = {}; Object.entries(operators).forEach(([key, value]) => { @@ -248,7 +250,7 @@ BlazeComponent.extendComponent({ members: [], assignees: [], labels: [], - is: [], + status: [], dueAt: null, createdAt: null, modifiedAt: null, @@ -285,8 +287,8 @@ BlazeComponent.extendComponent({ let days = parseInt(value, 10); let duration = null; if (isNaN(days)) { - if (predicateTranslations.date.keys().includes(value)) { - duration = predicateTranslations.date[value]; + if (predicateTranslations.durations[value]) { + duration = predicateTranslations.durations[value]; value = moment(); } else if (predicateTranslations.due[value] === 'overdue') { value = moment(); @@ -312,11 +314,22 @@ BlazeComponent.extendComponent({ } } } else if (operatorMap[op] === 'sort') { - if (!['due', 'modified', 'created', 'system'].includes(value)) { + if (!predicateTranslations.sorts[value]) { this.parsingErrors.push({ tag: 'operator-sort-invalid', value, }); + } else { + value = predicateTranslations.sorts[value]; + } + } else if (operatorMap[op] === 'status') { + if (!predicateTranslations.status[value]) { + this.parsingErrors.push({ + tag: 'operator-status-invalid', + value, + }); + } else { + value = predicateTranslations.status[value]; } } if (Array.isArray(params[operatorMap[op]])) { @@ -359,12 +372,13 @@ BlazeComponent.extendComponent({ if (this.parsingErrors.length) { this.searching.set(false); this.queryErrors = this.parsingErrorMessages(); + this.hasResults.set(true); this.hasQueryErrors.set(true); return; } this.autorun(() => { - const handle = subManager.subscribe( + const handle = Meteor.subscribe( 'globalSearch', SessionData.getSessionId(), params, @@ -407,6 +421,7 @@ BlazeComponent.extendComponent({ operator_board: TAPi18n.__('operator-board'), operator_list: TAPi18n.__('operator-list'), operator_swimlane: TAPi18n.__('operator-swimlane'), + operator_comment: TAPi18n.__('operator-comment'), operator_label: TAPi18n.__('operator-label'), operator_label_abbrev: TAPi18n.__('operator-label-abbrev'), operator_user: TAPi18n.__('operator-user'), @@ -415,6 +430,18 @@ BlazeComponent.extendComponent({ operator_member_abbrev: TAPi18n.__('operator-member-abbrev'), operator_assignee: TAPi18n.__('operator-assignee'), operator_assignee_abbrev: TAPi18n.__('operator-assignee-abbrev'), + operator_due: TAPi18n.__('operator-due'), + operator_created: TAPi18n.__('operator-created'), + operator_modified: TAPi18n.__('operator-modified'), + operator_status: TAPi18n.__('operator-status'), + predicate_overdue: TAPi18n.__('predicate-overdue'), + predicate_archived: TAPi18n.__('predicate-archived'), + predicate_all: TAPi18n.__('predicate-all'), + predicate_ended: TAPi18n.__('predicate-ended'), + predicate_week: TAPi18n.__('predicate-week'), + predicate_month: TAPi18n.__('predicate-month'), + predicate_quarter: TAPi18n.__('predicate-quarter'), + predicate_year: TAPi18n.__('predicate-year'), }; text = `# ${TAPi18n.__('globalSearch-instructions-heading')}`; @@ -432,6 +459,10 @@ BlazeComponent.extendComponent({ 'globalSearch-instructions-operator-swimlane', tags, )}`; + text += `\n* ${TAPi18n.__( + 'globalSearch-instructions-operator-comment', + tags, + )}`; text += `\n* ${TAPi18n.__( 'globalSearch-instructions-operator-label', tags, @@ -453,11 +484,27 @@ BlazeComponent.extendComponent({ 'globalSearch-instructions-operator-assignee', tags, )}`; + text += `\n* ${TAPi18n.__('globalSearch-instructions-operator-due', tags)}`; + text += `\n* ${TAPi18n.__( + 'globalSearch-instructions-operator-created', + tags, + )}`; + text += `\n* ${TAPi18n.__( + 'globalSearch-instructions-operator-modified', + tags, + )}`; + text += `\n* ${TAPi18n.__( + 'globalSearch-instructions-status-archived', + tags, + )}`; + text += `\n* ${TAPi18n.__('globalSearch-instructions-status-all', tags)}`; + text += `\n* ${TAPi18n.__('globalSearch-instructions-status-ended', tags)}`; text += `\n## ${TAPi18n.__('heading-notes')}`; text += `\n* ${TAPi18n.__('globalSearch-instructions-notes-1', tags)}`; text += `\n* ${TAPi18n.__('globalSearch-instructions-notes-2', tags)}`; text += `\n* ${TAPi18n.__('globalSearch-instructions-notes-3', tags)}`; + text += `\n* ${TAPi18n.__('globalSearch-instructions-notes-3-2', tags)}`; text += `\n* ${TAPi18n.__('globalSearch-instructions-notes-4', tags)}`; text += `\n* ${TAPi18n.__('globalSearch-instructions-notes-5', tags)}`; diff --git a/i18n/ar-EG.i18n.json b/i18n/ar-EG.i18n.json index b6ea0248c..436d1871e 100644 --- a/i18n/ar-EG.i18n.json +++ b/i18n/ar-EG.i18n.json @@ -143,7 +143,7 @@ "board-view-swimlanes": "خطوط السباحة", "board-view-collapse": "Collapse", "board-view-gantt": "Gantt", - "board-view-lists": "القائمات", + "board-view-lists": "اللستات", "bucket-example": "مثل « todo list » على سبيل المثال", "cancel": "إلغاء", "card-archived": "البطاقة منقولة الى الارشيف", @@ -420,7 +420,7 @@ "link-list": "رابط إلى هذه القائمة", "list-delete-pop": "All actions will be removed from the activity feed and you won't be able to recover the list. There is no undo.", "list-delete-suggest-archive": "You can move a list to Archive to remove it from the board and preserve the activity.", - "lists": "القائمات", + "lists": "استات", "swimlanes": "خطوط السباحة", "log-out": "تسجيل الخروج", "log-in": "تسجيل الدخول", @@ -609,8 +609,8 @@ "accounts": "الحسابات", "accounts-allowEmailChange": "السماح بتغيير البريد الإلكتروني", "accounts-allowUserNameChange": "Allow Username Change", - "createdAt": "Created at", - "modifiedAt": "Modified at", + "createdAt": "تاريخ الإنشاء", + "modifiedAt": "تاريخ التعديل", "verified": "Verified", "active": "نشط", "card-received": "Received", @@ -900,6 +900,17 @@ "operator-modified": "modified", "operator-unknown-error": "%s is not an operator", "operator-number-expected": "operator __operator__ expected a number, got '__value__'", + "predicate-archived": "مؤرشف", + "predicate-ended": "ended", + "predicate-all": "كله", + "predicate-overdue": "متاخر", + "predicate-week": "اسبوع", + "predicate-month": "شهر", + "predicate-quarter": "ربع", + "predicate-year": "سنة", + "predicate-due": "due", + "predicate-modified": "متعديل", + "predicate-created": "created", "heading-notes": "ملاحظات", "globalSearch-instructions-heading": "تعليمات البحث", "globalSearch-instructions-description": "Searches can include operators to refine the search. Operators are specified by writing the operator name and value separated by a colon. For example, an operator specification of `list:Blocked` would limit the search to cards that are contained in a list named *Blocked*. If the value contains spaces or special characters it must be enclosed in quotation marks (e.g. `__operator_list__:\"To Review\"`).", diff --git a/i18n/en.i18n.json b/i18n/en.i18n.json index 417de5d05..f54c42e0e 100644 --- a/i18n/en.i18n.json +++ b/i18n/en.i18n.json @@ -896,18 +896,27 @@ "operator-member-abbrev": "m", "operator-assignee": "assignee", "operator-assignee-abbrev": "a", - "operator-is": "is", + "operator-status": "status", "operator-due": "due", "operator-created": "created", "operator-modified": "modified", "operator-sort": "sort", "operator-comment": "comment", "predicate-archived": "archived", - "predicate-active": "active", + "predicate-ended": "ended", + "predicate-all": "all", "predicate-overdue": "overdue", + "predicate-week": "week", + "predicate-month": "month", + "predicate-quarter": "quarter", + "predicate-year": "year", + "predicate-due": "due", + "predicate-modified": "modified", + "predicate-created": "created", "operator-unknown-error": "%s is not an operator", "operator-number-expected": "operator __operator__ expected a number, got '__value__'", "operator-sort-invalid": "sort of '%s' is invalid", + "operator-status-invalid": "'%s' is not a valid status", "heading-notes": "Notes", "globalSearch-instructions-heading": "Search Instructions", "globalSearch-instructions-description": "Searches can include operators to refine the search. Operators are specified by writing the operator name and value separated by a colon. For example, an operator specification of `list:Blocked` would limit the search to cards that are contained in a list named *Blocked*. If the value contains spaces or special characters it must be enclosed in quotation marks (e.g. `__operator_list__:\"To Review\"`).", @@ -922,12 +931,16 @@ "globalSearch-instructions-operator-at": "`__operator_user_abbrev__username` - shorthand for `user:username`", "globalSearch-instructions-operator-member": "`__operator_member__:username` - cards where the specified user is a *member*", "globalSearch-instructions-operator-assignee": "`__operator_assignee__:username` - cards where the specified user is an *assignee*", - "globalSearch-instructions-operator-due": "`__operator_due__:n` - cards which are due *n* days from now. `__operator_due__:__predicate_overdue__ lists all ", + "globalSearch-instructions-operator-due": "`__operator_due__:n` - cards which are due *n* days from now. `__operator_due__:__predicate_overdue__ lists all cards past their due date.", "globalSearch-instructions-operator-created": "`__operator_created__:n` - cards which which were created *n* days ago", "globalSearch-instructions-operator-modified": "`__operator_modified__:n` - cards which which were modified *n* days ago", + "globalSearch-instructions-status-archived": "`__operator_status__:__predicate_archived__` - cards that are archived.", + "globalSearch-instructions-status-all": "`__operator_status__:__predicate_all__` - all archived and unarchived cards.", + "globalSearch-instructions-status-ended": "`__operator_status__:__predicate_ended__` - cards with an end date.", "globalSearch-instructions-notes-1": "Multiple operators may be specified.", "globalSearch-instructions-notes-2": "Similar operators are *OR*ed together. Cards that match any of the conditions will be returned.\n`__operator_list__:Available __operator_list__:Blocked` would return cards contained in any list named *Blocked* or *Available*.", - "globalSearch-instructions-notes-3": "Differing operators are *AND*ed together. Only cards that match all of the differing operators are returned.\n`__operator_list__:Available __operator_label__:red` returns only cards in the list *Available* with a *red* label.", + "globalSearch-instructions-notes-3": "Differing operators are *AND*ed together. Only cards that match all of the differing operators are returned. `__operator_list__:Available __operator_label__:red` returns only cards in the list *Available* with a *red* label.", + "globalSearch-instructions-notes-3-2": "Days can be specified as an integer or using `__predicate_week__`, `__predicate_month__`, `__predicate_quarter__` or `__predicate_year__`", "globalSearch-instructions-notes-4": "Text searches are case insensitive.", "globalSearch-instructions-notes-5": "Currently archived cards are not searched.", "link-to-search": "Link to this search", diff --git a/models/boards.js b/models/boards.js index f4f0d8042..d1dabbc19 100644 --- a/models/boards.js +++ b/models/boards.js @@ -1278,37 +1278,33 @@ Boards.userSearch = ( userId, selector = {}, projection = {}, - includeArchived = false, + // includeArchived = false, ) => { - if (!includeArchived) { - selector.archived = false; - } - selector.$or = [ - { permission: 'public' }, - { members: { $elemMatch: { userId, isActive: true } } }, - ]; + // if (!includeArchived) { + // selector.archived = false; + // } + selector.$or = [{ permission: 'public' }]; + if (userId) { + selector.$or.push({ members: { $elemMatch: { userId, isActive: true } } }); + } return Boards.find(selector, projection); }; -Boards.userBoards = (userId, includeArchived = false, selector = {}) => { - check(userId, String); - - if (!includeArchived) { - selector = { - archived: false, - }; +Boards.userBoards = (userId, archived = false, selector = {}) => { + if (typeof archived === 'boolean') { + selector.archived = archived; } - selector.$or = [ - { permission: 'public' }, - { members: { $elemMatch: { userId, isActive: true } } }, - ]; + selector.$or = [{ permission: 'public' }]; + if (userId) { + selector.$or.push({ members: { $elemMatch: { userId, isActive: true } } }); + } return Boards.find(selector); }; -Boards.userBoardIds = (userId, includeArchived = false, selector = {}) => { - return Boards.userBoards(userId, includeArchived, selector).map(board => { +Boards.userBoardIds = (userId, archived = false, selector = {}) => { + return Boards.userBoards(userId, archived, selector).map(board => { return board._id; }); }; diff --git a/server/publications/cards.js b/server/publications/cards.js index b231cfe6c..12ceec90e 100644 --- a/server/publications/cards.js +++ b/server/publications/cards.js @@ -193,7 +193,7 @@ Meteor.publish('globalSearch', function(sessionId, queryParams) { users: [], members: [], assignees: [], - is: [], + status: [], comments: [], }; @@ -247,14 +247,51 @@ Meteor.publish('globalSearch', function(sessionId, queryParams) { } })(); + let archived = false; + let endAt = null; + if (queryParams.status.length) { + queryParams.status.forEach(status => { + if (status === 'archived') { + archived = true; + } else if (status === 'all') { + archived = null; + } else if (status === 'ended') { + endAt = { $nin: [null, ''] }; + } + }); + } const selector = { - archived: false, type: 'cardType-card', - boardId: { $in: Boards.userBoardIds(userId) }, - swimlaneId: { $nin: Swimlanes.archivedSwimlaneIds() }, - listId: { $nin: Lists.archivedListIds() }, + // boardId: { $in: Boards.userBoardIds(userId) }, + $and: [], }; + const boardsSelector = {}; + if (archived !== null) { + boardsSelector.archived = archived; + if (archived) { + selector.boardId = { $in: Boards.userBoardIds(userId, null) }; + selector.$and.push({ + $or: [ + { boardId: { $in: Boards.userBoardIds(userId, archived) } }, + { swimlaneId: { $in: Swimlanes.archivedSwimlaneIds() } }, + { listId: { $in: Lists.archivedListIds() } }, + { archived: true }, + ], + }); + } else { + selector.boardId = { $in: Boards.userBoardIds(userId, false) }; + selector.swimlaneId = { $nin: Swimlanes.archivedSwimlaneIds() }; + selector.listId = { $nin: Lists.archivedListIds() }; + selector.archived = false; + } + } else { + selector.boardId = { $in: Boards.userBoardIds(userId, null) }; + } + if (endAt !== null) { + selector.endAt = endAt; + } + if (queryParams.boards.length) { const queryBoards = []; queryParams.boards.forEach(query => { @@ -383,10 +420,12 @@ Meteor.publish('globalSearch', function(sessionId, queryParams) { } if (queryMembers.length && queryAssignees.length) { - selector.$or = [ - { members: { $in: queryMembers } }, - { assignees: { $in: queryAssignees } }, - ]; + selector.$and.push({ + $or: [ + { members: { $in: queryMembers } }, + { assignees: { $in: queryAssignees } }, + ], + }); } else if (queryMembers.length) { selector.members = { $in: queryMembers }; } else if (queryAssignees.length) { @@ -450,24 +489,30 @@ Meteor.publish('globalSearch', function(sessionId, queryParams) { if (queryParams.text) { const regex = new RegExp(escapeForRegex(queryParams.text), 'i'); - selector.$or = [ - { title: regex }, - { description: regex }, - { customFields: { $elemMatch: { value: regex } } }, - { - _id: { - $in: CardComments.textSearch(userId, [queryParams.text]).map( - com => com.cardId, - ), + selector.$and.push({ + $or: [ + { title: regex }, + { description: regex }, + { customFields: { $elemMatch: { value: regex } } }, + { + _id: { + $in: CardComments.textSearch(userId, [queryParams.text]).map( + com => com.cardId, + ), + }, }, - }, - ]; + ], + }); + } + + if (selector.$and.length === 0) { + delete selector.$and; } // eslint-disable-next-line no-console - // console.log('selector:', selector); + console.log('selector:', selector); // eslint-disable-next-line no-console - // console.log('selector.$or:', selector.$or); + console.log('selector.$and:', selector.$and); const projection = { fields: { From 4e8fc464755067f6e308f32dd091e7d76f41febe Mon Sep 17 00:00:00 2001 From: "John R. Supplee" Date: Wed, 27 Jan 2021 02:21:12 +0200 Subject: [PATCH 14/16] Start work on paging search results --- client/components/main/globalSearch.jade | 6 + client/components/main/globalSearch.js | 91 ++++- i18n/en.i18n.json | 2 + models/usersessiondata.js | 12 + server/publications/cards.js | 467 ++++++++++++----------- 5 files changed, 348 insertions(+), 230 deletions(-) diff --git a/client/components/main/globalSearch.jade b/client/components/main/globalSearch.jade index 896954743..c0351f4e4 100644 --- a/client/components/main/globalSearch.jade +++ b/client/components/main/globalSearch.jade @@ -37,6 +37,12 @@ template(name="globalSearch") a.fa.fa-link(title="{{_ 'link-to-search' }}" href="{{ getSearchHref }}") each card in results.get +resultCard(card) + if hasPreviousPage.get + button.js-previous-page + | {{_ 'previous-page' }} + if hasNextPage.get + button.js-next-page + | {{_ 'next-page' }} else .global-search-instructions h2 {{_ 'boards' }} diff --git a/client/components/main/globalSearch.js b/client/components/main/globalSearch.js index 908da2dcd..4da421e67 100644 --- a/client/components/main/globalSearch.js +++ b/client/components/main/globalSearch.js @@ -46,12 +46,15 @@ BlazeComponent.extendComponent({ this.myLabelNames = new ReactiveVar([]); this.myBoardNames = new ReactiveVar([]); this.results = new ReactiveVar([]); + this.hasNextPage = new ReactiveVar(false); + this.hasPreviousPage = new ReactiveVar(false); this.queryParams = null; this.parsingErrors = []; this.resultsCount = 0; this.totalHits = 0; this.queryErrors = null; this.colorMap = null; + this.resultsPerPage = 25; Meteor.call('myLists', (err, data) => { if (!err) { @@ -100,17 +103,21 @@ BlazeComponent.extendComponent({ this.queryErrors = null; }, + getSessionData() { + return SessionData.findOne({ + userId: Meteor.userId(), + sessionId: SessionData.getSessionId(), + }); + }, + getResults() { // eslint-disable-next-line no-console // console.log('getting results'); if (this.queryParams) { - const sessionData = SessionData.findOne({ - userId: Meteor.userId(), - sessionId: SessionData.getSessionId(), - }); + const sessionData = this.getSessionData(); // eslint-disable-next-line no-console + console.log('selector:', JSON.parse(sessionData.selector)); // console.log('session data:', sessionData); - const cards = Cards.find({ _id: { $in: sessionData.cards } }); this.queryErrors = sessionData.errors; if (this.queryErrors.length) { @@ -121,8 +128,14 @@ BlazeComponent.extendComponent({ if (cards) { this.totalHits = sessionData.totalHits; this.resultsCount = cards.count(); + this.resultsStart = sessionData.lastHit - this.resultsCount + 1; + this.resultsEnd = sessionData.lastHit; this.resultsHeading.set(this.getResultsHeading()); this.results.set(cards); + this.hasNextPage.set(sessionData.lastHit < sessionData.totalHits); + this.hasPreviousPage.set( + sessionData.lastHit - sessionData.resultsCount > 0, + ); } } this.resultsCount = 0; @@ -243,6 +256,7 @@ BlazeComponent.extendComponent({ // console.log('operatorMap:', operatorMap); const params = { + limit: this.resultsPerPage, boards: [], swimlanes: [], lists: [], @@ -395,6 +409,61 @@ BlazeComponent.extendComponent({ }); }, + nextPage() { + sessionData = this.getSessionData(); + + const params = { + limit: this.resultsPerPage, + selector: JSON.parse(sessionData.selector), + skip: sessionData.lastHit, + }; + + this.autorun(() => { + const handle = Meteor.subscribe( + 'globalSearch', + SessionData.getSessionId(), + params, + ); + Tracker.nonreactive(() => { + Tracker.autorun(() => { + if (handle.ready()) { + this.getResults(); + this.searching.set(false); + this.hasResults.set(true); + } + }); + }); + }); + }, + + previousPage() { + sessionData = this.getSessionData(); + + const params = { + limit: this.resultsPerPage, + selector: JSON.parse(sessionData.selector), + skip: + sessionData.lastHit - sessionData.resultsCount - this.resultsPerPage, + }; + + this.autorun(() => { + const handle = Meteor.subscribe( + 'globalSearch', + SessionData.getSessionId(), + params, + ); + Tracker.nonreactive(() => { + Tracker.autorun(() => { + if (handle.ready()) { + this.getResults(); + this.searching.set(false); + this.hasResults.set(true); + } + }); + }); + }); + }, + getResultsHeading() { if (this.resultsCount === 0) { return TAPi18n.__('no-cards-found'); @@ -405,8 +474,8 @@ BlazeComponent.extendComponent({ } return TAPi18n.__('n-n-of-n-cards-found', { - start: 1, - end: this.resultsCount, + start: this.resultsStart, + end: this.resultsEnd, total: this.totalHits, }); }, @@ -526,6 +595,14 @@ BlazeComponent.extendComponent({ evt.preventDefault(); this.searchAllBoards(evt.target.searchQuery.value); }, + 'click .js-next-page'(evt) { + evt.preventDefault(); + this.nextPage(); + }, + 'click .js-previous-page'(evt) { + evt.preventDefault(); + this.previousPage(); + }, 'click .js-label-color'(evt) { evt.preventDefault(); const input = document.getElementById('global-search-input'); diff --git a/i18n/en.i18n.json b/i18n/en.i18n.json index f54c42e0e..accf914f4 100644 --- a/i18n/en.i18n.json +++ b/i18n/en.i18n.json @@ -917,6 +917,8 @@ "operator-number-expected": "operator __operator__ expected a number, got '__value__'", "operator-sort-invalid": "sort of '%s' is invalid", "operator-status-invalid": "'%s' is not a valid status", + "next-page": "Next Page", + "previous-page": "Previous Page", "heading-notes": "Notes", "globalSearch-instructions-heading": "Search Instructions", "globalSearch-instructions-description": "Searches can include operators to refine the search. Operators are specified by writing the operator name and value separated by a colon. For example, an operator specification of `list:Blocked` would limit the search to cards that are contained in a list named *Blocked*. If the value contains spaces or special characters it must be enclosed in quotation marks (e.g. `__operator_list__:\"To Review\"`).", diff --git a/models/usersessiondata.js b/models/usersessiondata.js index 59be52b3a..8309cf038 100644 --- a/models/usersessiondata.js +++ b/models/usersessiondata.js @@ -39,6 +39,13 @@ SessionData.attachSchema( type: Number, optional: true, }, + resultsCount: { + /** + * number of results returned + */ + type: Number, + optional: true, + }, lastHit: { /** * the last hit returned from a report query @@ -50,6 +57,11 @@ SessionData.attachSchema( type: [String], optional: true, }, + selector: { + type: String, + optional: true, + blackbox: true, + }, errorMessages: { type: [String], optional: true, diff --git a/server/publications/cards.js b/server/publications/cards.js index 12ceec90e..7ed1bd597 100644 --- a/server/publications/cards.js +++ b/server/publications/cards.js @@ -205,8 +205,8 @@ Meteor.publish('globalSearch', function(sessionId, queryParams) { } hasErrors() { - for (const prop in this.notFound) { - if (this.notFound[prop].length) { + for (const value of Object.values(this.notFound)) { + if (value.length) { return true; } } @@ -247,245 +247,255 @@ Meteor.publish('globalSearch', function(sessionId, queryParams) { } })(); - let archived = false; - let endAt = null; - if (queryParams.status.length) { - queryParams.status.forEach(status => { - if (status === 'archived') { - archived = true; - } else if (status === 'all') { - archived = null; - } else if (status === 'ended') { - endAt = { $nin: [null, ''] }; - } - }); + let selector = {}; + let skip = 0; + if (queryParams.skip) { + skip = queryParams.skip; + } + let limit = 25; + if (queryParams.limit) { + limit = queryParams.limit; } - const selector = { - type: 'cardType-card', - // boardId: { $in: Boards.userBoardIds(userId) }, - $and: [], - }; - const boardsSelector = {}; - if (archived !== null) { - boardsSelector.archived = archived; - if (archived) { + if (queryParams.selector) { + selector = queryParams.selector; + } else { + let archived = false; + let endAt = null; + if (queryParams.status.length) { + queryParams.status.forEach(status => { + if (status === 'archived') { + archived = true; + } else if (status === 'all') { + archived = null; + } else if (status === 'ended') { + endAt = { $nin: [null, ''] }; + } + }); + } + selector = { + type: 'cardType-card', + // boardId: { $in: Boards.userBoardIds(userId) }, + $and: [], + }; + + const boardsSelector = {}; + if (archived !== null) { + boardsSelector.archived = archived; + if (archived) { + selector.boardId = { $in: Boards.userBoardIds(userId, null) }; + selector.$and.push({ + $or: [ + { boardId: { $in: Boards.userBoardIds(userId, archived) } }, + { swimlaneId: { $in: Swimlanes.archivedSwimlaneIds() } }, + { listId: { $in: Lists.archivedListIds() } }, + { archived: true }, + ], + }); + } else { + selector.boardId = { $in: Boards.userBoardIds(userId, false) }; + selector.swimlaneId = { $nin: Swimlanes.archivedSwimlaneIds() }; + selector.listId = { $nin: Lists.archivedListIds() }; + selector.archived = false; + } + } else { selector.boardId = { $in: Boards.userBoardIds(userId, null) }; + } + if (endAt !== null) { + selector.endAt = endAt; + } + + if (queryParams.boards.length) { + const queryBoards = []; + queryParams.boards.forEach(query => { + const boards = Boards.userSearch(userId, { + title: new RegExp(escapeForRegex(query), 'i'), + }); + if (boards.count()) { + boards.forEach(board => { + queryBoards.push(board._id); + }); + } else { + errors.notFound.boards.push(query); + } + }); + + selector.boardId.$in = queryBoards; + } + + if (queryParams.swimlanes.length) { + const querySwimlanes = []; + queryParams.swimlanes.forEach(query => { + const swimlanes = Swimlanes.find({ + title: new RegExp(escapeForRegex(query), 'i'), + }); + if (swimlanes.count()) { + swimlanes.forEach(swim => { + querySwimlanes.push(swim._id); + }); + } else { + errors.notFound.swimlanes.push(query); + } + }); + + selector.swimlaneId.$in = querySwimlanes; + } + + if (queryParams.lists.length) { + const queryLists = []; + queryParams.lists.forEach(query => { + const lists = Lists.find({ + title: new RegExp(escapeForRegex(query), 'i'), + }); + if (lists.count()) { + lists.forEach(list => { + queryLists.push(list._id); + }); + } else { + errors.notFound.lists.push(query); + } + }); + + selector.listId.$in = queryLists; + } + + if (queryParams.comments.length) { + const cardIds = CardComments.textSearch(userId, queryParams.comments).map( + com => { + return com.cardId; + }, + ); + if (cardIds.length) { + selector._id = { $in: cardIds }; + } else { + errors.notFound.comments.push(queryParams.comments); + } + } + + if (queryParams.dueAt !== null) { + selector.dueAt = { $lte: new Date(queryParams.dueAt) }; + } + + if (queryParams.createdAt !== null) { + selector.createdAt = { $gte: new Date(queryParams.createdAt) }; + } + + if (queryParams.modifiedAt !== null) { + selector.modifiedAt = { $gte: new Date(queryParams.modifiedAt) }; + } + + const queryMembers = []; + const queryAssignees = []; + if (queryParams.users.length) { + queryParams.users.forEach(query => { + const users = Users.find({ + username: query, + }); + if (users.count()) { + users.forEach(user => { + queryMembers.push(user._id); + queryAssignees.push(user._id); + }); + } else { + errors.notFound.users.push(query); + } + }); + } + + if (queryParams.members.length) { + queryParams.members.forEach(query => { + const users = Users.find({ + username: query, + }); + if (users.count()) { + users.forEach(user => { + queryMembers.push(user._id); + }); + } else { + errors.notFound.members.push(query); + } + }); + } + + if (queryParams.assignees.length) { + queryParams.assignees.forEach(query => { + const users = Users.find({ + username: query, + }); + if (users.count()) { + users.forEach(user => { + queryAssignees.push(user._id); + }); + } else { + errors.notFound.assignees.push(query); + } + }); + } + + if (queryMembers.length && queryAssignees.length) { selector.$and.push({ $or: [ - { boardId: { $in: Boards.userBoardIds(userId, archived) } }, - { swimlaneId: { $in: Swimlanes.archivedSwimlaneIds() } }, - { listId: { $in: Lists.archivedListIds() } }, - { archived: true }, + { members: { $in: queryMembers } }, + { assignees: { $in: queryAssignees } }, ], }); - } else { - selector.boardId = { $in: Boards.userBoardIds(userId, false) }; - selector.swimlaneId = { $nin: Swimlanes.archivedSwimlaneIds() }; - selector.listId = { $nin: Lists.archivedListIds() }; - selector.archived = false; + } else if (queryMembers.length) { + selector.members = { $in: queryMembers }; + } else if (queryAssignees.length) { + selector.assignees = { $in: queryAssignees }; } - } else { - selector.boardId = { $in: Boards.userBoardIds(userId, null) }; - } - if (endAt !== null) { - selector.endAt = endAt; - } - if (queryParams.boards.length) { - const queryBoards = []; - queryParams.boards.forEach(query => { - const boards = Boards.userSearch(userId, { - title: new RegExp(escapeForRegex(query), 'i'), - }); - if (boards.count()) { - boards.forEach(board => { - queryBoards.push(board._id); - }); - } else { - errors.notFound.boards.push(query); - } - }); + if (queryParams.labels.length) { + queryParams.labels.forEach(label => { + const queryLabels = []; - selector.boardId.$in = queryBoards; - } - - if (queryParams.swimlanes.length) { - const querySwimlanes = []; - queryParams.swimlanes.forEach(query => { - const swimlanes = Swimlanes.find({ - title: new RegExp(escapeForRegex(query), 'i'), - }); - if (swimlanes.count()) { - swimlanes.forEach(swim => { - querySwimlanes.push(swim._id); - }); - } else { - errors.notFound.swimlanes.push(query); - } - }); - - selector.swimlaneId.$in = querySwimlanes; - } - - if (queryParams.lists.length) { - const queryLists = []; - queryParams.lists.forEach(query => { - const lists = Lists.find({ - title: new RegExp(escapeForRegex(query), 'i'), - }); - if (lists.count()) { - lists.forEach(list => { - queryLists.push(list._id); - }); - } else { - errors.notFound.lists.push(query); - } - }); - - selector.listId.$in = queryLists; - } - - if (queryParams.comments.length) { - const cardIds = CardComments.textSearch(userId, queryParams.comments).map( - com => { - return com.cardId; - }, - ); - if (cardIds.length) { - selector._id = { $in: cardIds }; - } else { - errors.notFound.comments.push(queryParams.comments); - } - } - - if (queryParams.dueAt !== null) { - selector.dueAt = { $lte: new Date(queryParams.dueAt) }; - } - - if (queryParams.createdAt !== null) { - selector.createdAt = { $gte: new Date(queryParams.createdAt) }; - } - - if (queryParams.modifiedAt !== null) { - selector.modifiedAt = { $gte: new Date(queryParams.modifiedAt) }; - } - - const queryMembers = []; - const queryAssignees = []; - if (queryParams.users.length) { - queryParams.users.forEach(query => { - const users = Users.find({ - username: query, - }); - if (users.count()) { - users.forEach(user => { - queryMembers.push(user._id); - queryAssignees.push(user._id); - }); - } else { - errors.notFound.users.push(query); - } - }); - } - - if (queryParams.members.length) { - queryParams.members.forEach(query => { - const users = Users.find({ - username: query, - }); - if (users.count()) { - users.forEach(user => { - queryMembers.push(user._id); - }); - } else { - errors.notFound.members.push(query); - } - }); - } - - if (queryParams.assignees.length) { - queryParams.assignees.forEach(query => { - const users = Users.find({ - username: query, - }); - if (users.count()) { - users.forEach(user => { - queryAssignees.push(user._id); - }); - } else { - errors.notFound.assignees.push(query); - } - }); - } - - if (queryMembers.length && queryAssignees.length) { - selector.$and.push({ - $or: [ - { members: { $in: queryMembers } }, - { assignees: { $in: queryAssignees } }, - ], - }); - } else if (queryMembers.length) { - selector.members = { $in: queryMembers }; - } else if (queryAssignees.length) { - selector.assignees = { $in: queryAssignees }; - } - - if (queryParams.labels.length) { - queryParams.labels.forEach(label => { - const queryLabels = []; - - let boards = Boards.userSearch(userId, { - labels: { $elemMatch: { color: label.toLowerCase() } }, - }); - - if (boards.count()) { - boards.forEach(board => { - // eslint-disable-next-line no-console - // console.log('board:', board); - // eslint-disable-next-line no-console - // console.log('board.labels:', board.labels); - board.labels - .filter(boardLabel => { - return boardLabel.color === label.toLowerCase(); - }) - .forEach(boardLabel => { - queryLabels.push(boardLabel._id); - }); - }); - } else { - // eslint-disable-next-line no-console - // console.log('label:', label); - const reLabel = new RegExp(escapeForRegex(label), 'i'); - // eslint-disable-next-line no-console - // console.log('reLabel:', reLabel); - boards = Boards.userSearch(userId, { - labels: { $elemMatch: { name: reLabel } }, + let boards = Boards.userSearch(userId, { + labels: { $elemMatch: { color: label.toLowerCase() } }, }); if (boards.count()) { boards.forEach(board => { + // eslint-disable-next-line no-console + // console.log('board:', board); + // eslint-disable-next-line no-console + // console.log('board.labels:', board.labels); board.labels .filter(boardLabel => { - return boardLabel.name.match(reLabel); + return boardLabel.color === label.toLowerCase(); }) .forEach(boardLabel => { queryLabels.push(boardLabel._id); }); }); } else { - errors.notFound.labels.push(label); + // eslint-disable-next-line no-console + // console.log('label:', label); + const reLabel = new RegExp(escapeForRegex(label), 'i'); + // eslint-disable-next-line no-console + // console.log('reLabel:', reLabel); + boards = Boards.userSearch(userId, { + labels: { $elemMatch: { name: reLabel } }, + }); + + if (boards.count()) { + boards.forEach(board => { + board.labels + .filter(boardLabel => { + return boardLabel.name.match(reLabel); + }) + .forEach(boardLabel => { + queryLabels.push(boardLabel._id); + }); + }); + } else { + errors.notFound.labels.push(label); + } } - } - selector.labelIds = { $in: queryLabels }; - }); - } + selector.labelIds = { $in: queryLabels }; + }); + } - let cards = null; - - if (!errors.hasErrors()) { if (queryParams.text) { const regex = new RegExp(escapeForRegex(queryParams.text), 'i'); @@ -508,12 +518,16 @@ Meteor.publish('globalSearch', function(sessionId, queryParams) { if (selector.$and.length === 0) { delete selector.$and; } + } - // eslint-disable-next-line no-console - console.log('selector:', selector); - // eslint-disable-next-line no-console - console.log('selector.$and:', selector.$and); + // eslint-disable-next-line no-console + console.log('selector:', selector); + // eslint-disable-next-line no-console + console.log('selector.$and:', selector.$and); + let cards = null; + + if (!errors.hasErrors()) { const projection = { fields: { _id: 1, @@ -532,7 +546,8 @@ Meteor.publish('globalSearch', function(sessionId, queryParams) { modifiedAt: 1, labelIds: 1, }, - limit: 50, + skip, + limit, }; if (queryParams.sort === 'due') { @@ -569,27 +584,33 @@ Meteor.publish('globalSearch', function(sessionId, queryParams) { }; } + // eslint-disable-next-line no-console + console.log('projection:', projection); cards = Cards.find(selector, projection); // eslint-disable-next-line no-console - // console.log('count:', cards.count()); + console.log('count:', cards.count()); } const update = { $set: { totalHits: 0, lastHit: 0, + resultsCount: 0, cards: [], errors: errors.errorMessages(), + selector: JSON.stringify(selector), }, }; if (cards) { update.$set.totalHits = cards.count(); - update.$set.lastHit = cards.count() > 50 ? 50 : cards.count(); + update.$set.lastHit = + skip + limit < cards.count() ? skip + limit : cards.count(); update.$set.cards = cards.map(card => { return card._id; }); + update.$set.resultsCount = update.$set.cards.length; } SessionData.upsert({ userId, sessionId }, update); From 78a1d08a17077e6061c6ed5fa0c8a197df26521e Mon Sep 17 00:00:00 2001 From: "John R. Supplee" Date: Wed, 27 Jan 2021 16:15:54 +0200 Subject: [PATCH 15/16] Bug fixes and buttons for previous and next page * Use buttons for next and previous page in search results * Add custom routine for stringifying and parsing the selector to JSON so RegExp objects are preserved --- client/components/main/globalSearch.jade | 16 ++++++----- client/components/main/globalSearch.js | 6 ++--- client/components/main/globalSearch.styl | 12 +++++++++ models/cardComments.js | 2 +- models/usersessiondata.js | 34 ++++++++++++++++++++++++ server/publications/cards.js | 19 +++++++++---- 6 files changed, 74 insertions(+), 15 deletions(-) diff --git a/client/components/main/globalSearch.jade b/client/components/main/globalSearch.jade index c0351f4e4..61ef2f2c4 100644 --- a/client/components/main/globalSearch.jade +++ b/client/components/main/globalSearch.jade @@ -37,12 +37,16 @@ template(name="globalSearch") a.fa.fa-link(title="{{_ 'link-to-search' }}" href="{{ getSearchHref }}") each card in results.get +resultCard(card) - if hasPreviousPage.get - button.js-previous-page - | {{_ 'previous-page' }} - if hasNextPage.get - button.js-next-page - | {{_ 'next-page' }} + table.global-search-footer + tr + td.global-search-previous-page + if hasPreviousPage.get + button.js-previous-page + | {{_ 'previous-page' }} + td.global-search-next-page(align="right") + if hasNextPage.get + button.js-next-page + | {{_ 'next-page' }} else .global-search-instructions h2 {{_ 'boards' }} diff --git a/client/components/main/globalSearch.js b/client/components/main/globalSearch.js index 4da421e67..e17e33505 100644 --- a/client/components/main/globalSearch.js +++ b/client/components/main/globalSearch.js @@ -116,7 +116,7 @@ BlazeComponent.extendComponent({ if (this.queryParams) { const sessionData = this.getSessionData(); // eslint-disable-next-line no-console - console.log('selector:', JSON.parse(sessionData.selector)); + console.log('selector:', sessionData.getSelector()); // console.log('session data:', sessionData); const cards = Cards.find({ _id: { $in: sessionData.cards } }); this.queryErrors = sessionData.errors; @@ -414,7 +414,7 @@ BlazeComponent.extendComponent({ const params = { limit: this.resultsPerPage, - selector: JSON.parse(sessionData.selector), + selector: sessionData.getSelector(), skip: sessionData.lastHit, }; @@ -441,7 +441,7 @@ BlazeComponent.extendComponent({ const params = { limit: this.resultsPerPage, - selector: JSON.parse(sessionData.selector), + selector: sessionData.getSelector(), skip: sessionData.lastHit - sessionData.resultsCount - this.resultsPerPage, }; diff --git a/client/components/main/globalSearch.styl b/client/components/main/globalSearch.styl index b982f4eed..e460f506e 100644 --- a/client/components/main/globalSearch.styl +++ b/client/components/main/globalSearch.styl @@ -104,3 +104,15 @@ code .list-title background-color: darkgray + +.global-search-footer + border: none + width: 100% + +.global-search-next-page + border: none + text-align: right; + +.global-search-previous-page + border: none + text-align: left; diff --git a/models/cardComments.js b/models/cardComments.js index b366fc57d..88f384163 100644 --- a/models/cardComments.js +++ b/models/cardComments.js @@ -121,7 +121,7 @@ CardComments.textSearch = (userId, textArray) => { } // eslint-disable-next-line no-console - console.log('cardComments selector:', selector); + // console.log('cardComments selector:', selector); const comments = CardComments.find(selector); // eslint-disable-next-line no-console diff --git a/models/usersessiondata.js b/models/usersessiondata.js index 8309cf038..129fe56ba 100644 --- a/models/usersessiondata.js +++ b/models/usersessiondata.js @@ -126,6 +126,40 @@ SessionData.attachSchema( }), ); +SessionData.helpers({ + getSelector() { + return SessionData.unpickle(this.selector); + }, +}); + +SessionData.unpickle = pickle => { + return JSON.parse(pickle, (key, value) => { + if (typeof value === 'object') { + if (value.hasOwnProperty('$$class')) { + if (value.$$class === 'RegExp') { + return new RegExp(value.source, value.flags); + } + } + } + return value; + }); +}; + +SessionData.pickle = value => { + return JSON.stringify(value, (key, value) => { + if (typeof value === 'object') { + if (value.constructor.name === 'RegExp') { + return { + $$class: 'RegExp', + source: value.source, + flags: value.flags, + }; + } + } + return value; + }); +}; + if (!Meteor.isServer) { SessionData.getSessionId = () => { let sessionId = Session.get('sessionId'); diff --git a/server/publications/cards.js b/server/publications/cards.js index 7ed1bd597..d27204f77 100644 --- a/server/publications/cards.js +++ b/server/publications/cards.js @@ -179,6 +179,9 @@ Meteor.publish('globalSearch', function(sessionId, queryParams) { check(sessionId, String); check(queryParams, Object); + // eslint-disable-next-line no-console + // console.log('queryParams:', queryParams); + const userId = Meteor.userId(); // eslint-disable-next-line no-console // console.log('userId:', userId); @@ -338,6 +341,9 @@ Meteor.publish('globalSearch', function(sessionId, queryParams) { } }); + if (!selector.swimlaneId.hasOwnProperty('swimlaneId')) { + selector.swimlaneId = { $in: [] }; + } selector.swimlaneId.$in = querySwimlanes; } @@ -356,6 +362,9 @@ Meteor.publish('globalSearch', function(sessionId, queryParams) { } }); + if (!selector.hasOwnProperty('listId')) { + selector.listId = { $in: [] }; + } selector.listId.$in = queryLists; } @@ -521,9 +530,9 @@ Meteor.publish('globalSearch', function(sessionId, queryParams) { } // eslint-disable-next-line no-console - console.log('selector:', selector); + // console.log('selector:', selector); // eslint-disable-next-line no-console - console.log('selector.$and:', selector.$and); + // console.log('selector.$and:', selector.$and); let cards = null; @@ -585,11 +594,11 @@ Meteor.publish('globalSearch', function(sessionId, queryParams) { } // eslint-disable-next-line no-console - console.log('projection:', projection); + // console.log('projection:', projection); cards = Cards.find(selector, projection); // eslint-disable-next-line no-console - console.log('count:', cards.count()); + // console.log('count:', cards.count()); } const update = { @@ -599,7 +608,7 @@ Meteor.publish('globalSearch', function(sessionId, queryParams) { resultsCount: 0, cards: [], errors: errors.errorMessages(), - selector: JSON.stringify(selector), + selector: SessionData.pickle(selector), }, }; From 2df0a54cb8f7d96a949b63c00dbbbfe5618e1945 Mon Sep 17 00:00:00 2001 From: "John R. Supplee" Date: Wed, 27 Jan 2021 17:06:57 +0200 Subject: [PATCH 16/16] Return CustomFields data for display on minicard --- server/publications/cards.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/server/publications/cards.js b/server/publications/cards.js index d27204f77..5b93d9c7c 100644 --- a/server/publications/cards.js +++ b/server/publications/cards.js @@ -554,6 +554,7 @@ Meteor.publish('globalSearch', function(sessionId, queryParams) { createdAt: 1, modifiedAt: 1, labelIds: 1, + customFields: 1, }, skip, limit, @@ -640,6 +641,7 @@ Meteor.publish('globalSearch', function(sessionId, queryParams) { const boards = []; const swimlanes = []; const lists = []; + const customFieldIds = []; const users = [this.userId]; cards.forEach(card => { @@ -656,6 +658,11 @@ Meteor.publish('globalSearch', function(sessionId, queryParams) { users.push(userId); }); } + if (card.customFields) { + card.customFields.forEach(field => { + customFieldIds.push(field._id); + }); + } }); const fields = { @@ -677,6 +684,7 @@ Meteor.publish('globalSearch', function(sessionId, queryParams) { { fields: { ...fields, color: 1 } }, ), Lists.find({ _id: { $in: lists } }, { fields }), + CustomFields.find({ _id: { $in: customFieldIds } }), Users.find({ _id: { $in: users } }, { fields: Users.safeFields }), SessionData.find({ userId: this.userId, sessionId }), ];