diff --git a/README.md b/README.md index c80afc5..7fda72f 100644 --- a/README.md +++ b/README.md @@ -186,7 +186,7 @@ From [their privacy policy](https://www.quora.com/about/privacy) ## To-Do -- [ ] add missing routes like topics, profile, and search +- [x] add missing routes like topics, profile, and search - [ ] use redis - [x] serve images and other assets from Quetre - [x] implement a better installation method diff --git a/controllers/apiController.js b/controllers/apiController.js index 7261c46..881e95c 100644 --- a/controllers/apiController.js +++ b/controllers/apiController.js @@ -1,3 +1,4 @@ +/* eslint-disable no-unused-vars */ //////////////////////////////////////////////////////// // IMPORTS //////////////////////////////////////////////////////// @@ -6,6 +7,7 @@ import catchAsyncErrors from '../utils/catchAsyncErrors.js'; import getAnswers from '../fetchers/getAnswers.js'; import getTopic from '../fetchers/getTopic.js'; import getProfile from '../fetchers/getProfile.js'; +import getSearch from '../fetchers/getSearch.js'; //////////////////////////////////////////////////////// // EXPORTS @@ -33,6 +35,15 @@ export const profile = catchAsyncErrors(async (req, res, next) => { res.status(200).json({ status: 'success', data }); }); +export const search = catchAsyncErrors(async (req, res, next) => { + const searchText = req.urlObj.searchParams.get('q')?.trim(); // no search to perform if there isn't any query + + let searchData = null; + if (searchText) searchData = await getSearch(req.urlObj.search); + + res.status(200).json({ status: 'success', data: searchData }); +}); + export const unimplemented = (req, res, next) => { res.status(501).json({ status: 'fail', diff --git a/controllers/viewController.js b/controllers/viewController.js index 0dbe112..001d771 100644 --- a/controllers/viewController.js +++ b/controllers/viewController.js @@ -7,6 +7,7 @@ import getAnswers from '../fetchers/getAnswers.js'; import getTopic from '../fetchers/getTopic.js'; import { nonSlugRoutes } from '../utils/constants.js'; import getProfile from '../fetchers/getProfile.js'; +import getSearch from '../fetchers/getSearch.js'; //////////////////////////////////////////////////////// // EXPORTS @@ -83,20 +84,37 @@ export const profile = catchAsyncErrors(async (req, res, next) => { }); }); -export const unimplemented = (req, res, next) => { - const message = - "This route isn't yet implemented. Check back sometime later!"; - res.status(501).render('error', { - data: { - statusCode: 501, - message, +export const search = catchAsyncErrors(async (req, res, next) => { + const searchText = req.urlObj.searchParams.get('q')?.trim(); + + let searchData = null; + if (searchText) searchData = await getSearch(req.urlObj.search); + + res.status(200).render('search', { + data: searchData, + meta: { + title: searchText || 'Search', + url: req.urlObj.href, + imageUrl: `${req.urlObj.origin}/icon.svg`, + description: searchText ? `results for '${searchText}'` : 'search page', }, + }); +}); + +export const unimplemented = (req, res, next) => { + const data = { + message: "This route isn't yet implemented. Check back sometime later!", + statusCode: 501, + }; + + res.status(data.statusCode).render('error', { + data, meta: { title: 'Not yet implemented', url: `${req.urlObj.origin}${req.urlObj.pathname}`, imageUrl: `${req.urlObj.origin}/icon.svg`, urlObj: req.urlObj, - description: message, + description: data.message, }, }); }; diff --git a/fetchers/getSearch.js b/fetchers/getSearch.js new file mode 100644 index 0000000..20482c9 --- /dev/null +++ b/fetchers/getSearch.js @@ -0,0 +1,165 @@ +//////////////////////////////////////////////////////// +// IMPORTS +//////////////////////////////////////////////////////// +import AppError from '../utils/AppError.js'; +import fetcher from './fetcher.js'; + +//////////////////////////////////////////////////////// +// HELPER FUNCTIONS +//////////////////////////////////////////////////////// +const topicCleaner = topic => ({ + type: 'topic', + url: topic.url, + name: topic.name, + numFollowers: topic.numFollowers, + image: topic.photoUrl, + isSensitive: topic.isSensitive, +}); +const spaceCleaner = space => ({ + type: 'space', + numUsers: space.tribeUserCount, + url: space.url, + name: space.nameString, + description: space.descriptionString, + image: space.iconRetinaUrl, + isSensitive: space.isSensitive, +}); +const profileCleaner = profile => ({ + type: 'profile', + credential: profile.bestCredential?.translatedString, + isAnon: profile.isAnon, + name: `${profile.names[0]?.givenName} ${profile.names[0]?.familyName}`, + url: profile.profileUrl, + image: profile.profileImageUrl, + numFollowers: profile.followerCount, + isVerified: profile.isVerified, + isBusiness: profile.businessStatus, + isPlusUser: profile.consumerBundleActive, +}); +const questionCleaner = question => ({ + type: 'question', + text: JSON.parse(question.title).sections, + url: question.url, + isDeleted: question.isDeleted, + numFollowers: question.followerCount, + creationTime: question.creationTime, + numComments: question.numDisplayComments, + isSensitive: question.isSensitive, +}); +const answerCleaner = ({ question, previewAnswer: answer }) => ({ + type: 'answer', + question: { + ...questionCleaner(question), + }, + ...(answer.originalQuestionIfDifferent && { + originalQuestion: { + text: JSON.parse(answer.originalQuestionIfDifferent.question.title) + .sections, + url: answer.originalQuestionIfDifferent.question.url, + qid: answer.originalQuestionIfDifferent.question.qid, + }, + }), + isViewable: !!answer.viewerHasAccess, + text: JSON.parse(answer.content).sections, + creationTime: answer.creationTime, + updatedTime: answer.updatedTime, + numComments: answer.numDisplayComments, + numUpvotes: answer.numUpvotes, + numViews: answer.numViews, + numShares: answer.numSharers, + numAnswerRequests: answer.numRequesters, + isBusinessAnswer: answer.businessAnswer, + url: answer.url, + isSensitive: answer.isSensitive, + author: { + uid: answer.author.uid, + isAnon: answer.author.isAnon, + image: answer.author.profileImageUrl, + isVerified: answer.author.isVerified, + isPlusUser: answer.author.consumerBundleActive, + url: answer.author.profileUrl, + name: `${answer.author.names[0].givenName} ${answer.author.names[0].familyName}`, + credential: answer.authorCredential?.translatedString, + }, +}); +const postCleaner = post => ({ + type: 'post', + pid: post.pid, + isViewable: post.viewerHasAccess, + url: post.url, + title: JSON.parse(post.title).sections, + isDeleted: post.isDeleted, + isSensitive: post.isSensitive, + text: JSON.parse(post.content).sections, + creationTime: post.creationTime, + updatedTime: post.updatedTime, + numComments: post.numDisplayComments, + numUpvotes: post.numUpvotes, + numViews: post.numViews, + numShares: post.numSharers, + author: { + uid: post.author.uid, + isAnon: post.author.isAnon, + image: post.author.profileImageUrl, + isVerified: post.author.isVerified, + isPlusUser: post.author.consumerBundleActive, + url: post.author.profileUrl, + name: `${post.author.names[0].givenName} ${post.author.names[0].familyName}`, + credential: post.authorCredential?.translatedString, + }, + ...(post.tribeItem && { + space: { + isSensitive: post.tribeItem.tribe.isSensitive, + name: post.tribeItem.tribe.nameString, + url: post.tribeItem.tribe.url, + image: post.tribeItem.tribe.iconRetinaUrl, + description: post.tribeItem.descriptionString, + numFollowers: post.tribeItem.tribe.numFollowers, + }, + }), +}); + +const resultsCleaner = results => { + const cleanedResults = results.map(result => { + const resultToClean = result.node; + + if (resultToClean.topic) return topicCleaner(resultToClean.topic); + if (resultToClean.tribe) return spaceCleaner(resultToClean.tribe); + if (resultToClean.post) return postCleaner(resultToClean.post); + if (resultToClean.user) return profileCleaner(resultToClean.user); + if (resultToClean.previewAnswer) return answerCleaner(resultToClean); + if (resultToClean.question) return questionCleaner(resultToClean.question); + + return {}; + }); + + return cleanedResults; +}; + +//////////////////////////////////////////////////////// +// FUNCTION +//////////////////////////////////////////////////////// +const getSearch = async querySlug => { + const res = await fetcher(`search/${querySlug}`, 'searchConnection', false); + + const { + data: { searchConnection: rawData }, + } = JSON.parse(res); + + if (!rawData) + throw new AppError( + "Search couldn't be done. Recheck the URL, or resend the request if you believe the URL is correct.", + 404 + ); + + const data = { + results: resultsCleaner(rawData.edges), + }; + + return data; +}; + +//////////////////////////////////////////////////////// +// EXPORTS +//////////////////////////////////////////////////////// +export default getSearch; diff --git a/public/misc/sprite.svg b/public/misc/sprite.svg index 5931a8c..d6ddc92 100644 --- a/public/misc/sprite.svg +++ b/public/misc/sprite.svg @@ -1,4 +1,6 @@