From bfbe4eb04299515c3a4ca55c0c661e6d4a73fc1b Mon Sep 17 00:00:00 2001 From: spencerwooo Date: Mon, 24 Jan 2022 16:18:55 +0800 Subject: [PATCH 1/5] search support for od business --- components/SearchModal.tsx | 14 ++++++-------- pages/api/index.ts | 11 +++++++++++ pages/api/item.ts | 32 ++++++++++++++++++++++++++++++++ pages/api/search.ts | 21 ++++++++++++++++++--- 4 files changed, 67 insertions(+), 11 deletions(-) create mode 100644 pages/api/item.ts diff --git a/components/SearchModal.tsx b/components/SearchModal.tsx index 38aa27f..8c0f951 100644 --- a/components/SearchModal.tsx +++ b/components/SearchModal.tsx @@ -25,14 +25,12 @@ function useDriveItemSearch() { // Map parentReference to the absolute path of the search result data.map(item => { - // TODO: supporting sharepoint search where the path is not returned in parentReference - if ('path' in item.parentReference) { - item['path'] = `${mapAbsolutePath(item.parentReference.path)}/${encodeURIComponent(item.name)}` - } else { - throw Error( - 'We currently only support search in OneDrive international. SharePoint instances are not supported yet. See issue: https://github.com/spencerwooo/onedrive-vercel-index/issues/299' - ) - } + item['path'] = + 'path' in item.parentReference + ? // OneDrive International have the path returned in the parentReference field + `${mapAbsolutePath(item.parentReference.path)}/${encodeURIComponent(item.name)}` + : // OneDrive for Business/Education does not, so we need extra steps here + '' }) return data diff --git a/pages/api/index.ts b/pages/api/index.ts index 5547def..ef98722 100644 --- a/pages/api/index.ts +++ b/pages/api/index.ts @@ -12,6 +12,12 @@ import { getOdAuthTokens, storeOdAuthTokens } from '../../utils/odAuthTokenStore const basePath = pathPosix.resolve('/', siteConfig.baseDirectory) const clientSecret = revealObfuscatedToken(apiConfig.obfuscatedClientSecret) +/** + * Encode the path of the file relative to the base directory + * + * @param path Relative path of the file to the base directory + * @returns Absolute path of the file inside OneDrive + */ export function encodePath(path: string): string { let encodedPath = pathPosix.join(basePath, pathPosix.resolve('/', path)) if (encodedPath === '/' || encodedPath === '') { @@ -21,6 +27,11 @@ export function encodePath(path: string): string { return `:${encodeURIComponent(encodedPath)}` } +/** + * Fetch the access token from Redis storage and check if the token requires a renew + * + * @returns Access token for OneDrive API + */ export async function getAccessToken(): Promise { const { accessToken, refreshToken } = await getOdAuthTokens() diff --git a/pages/api/item.ts b/pages/api/item.ts new file mode 100644 index 0000000..9ae9176 --- /dev/null +++ b/pages/api/item.ts @@ -0,0 +1,32 @@ +import axios from 'axios' +import type { NextApiRequest, NextApiResponse } from 'next' + +import { encodePath, getAccessToken } from '.' +import apiConfig from '../../config/api.json' + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + // Get access token from storage + const accessToken = await getAccessToken() + + // Get item details (specifically, its path) by its unique ID in OneDrive + const { id = '' } = req.query + + if (typeof id === 'string') { + const itemApi = `${apiConfig.driveApi}/items/${id}` + + try { + const { data } = await axios.get(itemApi, { + headers: { Authorization: `Bearer ${accessToken}` }, + params: { + select: 'id,name,parentReference', + }, + }) + res.status(200).json(data) + } catch (error: any) { + res.status(error.response.status).json({ error: error.response.data }) + } + } else { + res.status(400).json({ error: 'Invalid driveItem ID.' }) + } + return +} diff --git a/pages/api/search.ts b/pages/api/search.ts index 1a9cff6..b626691 100644 --- a/pages/api/search.ts +++ b/pages/api/search.ts @@ -3,6 +3,18 @@ import type { NextApiRequest, NextApiResponse } from 'next' import { encodePath, getAccessToken } from '.' import apiConfig from '../../config/api.json' +import siteConfig from '../../config/site.json' + +/** + * Sanitize the search query + * + * @param query User search query, which may contain special characters + * @returns Sanitised query string which replaces non-alphanumeric characters with ' ' + */ +function sanitiseQuery(query: string): string { + const sanitisedQuery = query.replace(/[^a-zA-Z0-9]/g, ' ') + return encodeURIComponent(sanitisedQuery) +} export default async function handler(req: NextApiRequest, res: NextApiResponse) { // Get access token from storage @@ -12,15 +24,18 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) const { q: searchQuery = '' } = req.query if (typeof searchQuery === 'string') { - // Construct Microsoft Graph Search API URL, and perform search only under the base dir - const encodedPath = encodePath('/') === '' ? encodePath('/') : encodePath('/') + ':' - const searchApi = `${apiConfig.driveApi}/root${encodedPath}/search(q='${encodeURIComponent(searchQuery)}')` + // Construct Microsoft Graph Search API URL, and perform search only under the base directory + const searchRootPath = encodePath('/') + const encodedPath = searchRootPath === '' ? searchRootPath : searchRootPath + ':' + + const searchApi = `${apiConfig.driveApi}/root${encodedPath}/search(q='${sanitiseQuery(searchQuery)}')` try { const { data } = await axios.get(searchApi, { headers: { Authorization: `Bearer ${accessToken}` }, params: { select: 'id,name,file,folder,parentReference', + top: siteConfig.maxItems, }, }) res.status(200).json(data.value) From 0b55fe5fc796ed150d695bda7d0a302d1a7a9223 Mon Sep 17 00:00:00 2001 From: spencerwooo Date: Mon, 24 Jan 2022 19:16:32 +0800 Subject: [PATCH 2/5] rename useAxiosFetch hook for universal fetch usage --- components/previews/CodePreview.tsx | 4 ++-- components/previews/MarkdownPreview.tsx | 4 ++-- components/previews/TextPreview.tsx | 4 ++-- components/previews/URLPreview.tsx | 4 ++-- utils/fetchOnMount.ts | 6 +++--- 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/components/previews/CodePreview.tsx b/components/previews/CodePreview.tsx index 98bd892..2e65a52 100644 --- a/components/previews/CodePreview.tsx +++ b/components/previews/CodePreview.tsx @@ -2,14 +2,14 @@ import { useEffect, FC } from 'react' import Prism from 'prismjs' import { getExtension } from '../../utils/getFileIcon' -import useFileContent from '../../utils/fetchOnMount' +import useAxiosGet from '../../utils/fetchOnMount' import FourOhFour from '../FourOhFour' import Loading from '../Loading' import DownloadButtonGroup from '../DownloadBtnGtoup' import { DownloadBtnContainer, PreviewContainer } from './Containers' const CodePreview: FC<{ file: any }> = ({ file }) => { - const { content, error, validating } = useFileContent(file['@microsoft.graph.downloadUrl']) + const { content, error, validating } = useAxiosGet(file['@microsoft.graph.downloadUrl']) useEffect(() => { if (typeof window !== 'undefined') { diff --git a/components/previews/MarkdownPreview.tsx b/components/previews/MarkdownPreview.tsx index 5e3b473..2bb8047 100644 --- a/components/previews/MarkdownPreview.tsx +++ b/components/previews/MarkdownPreview.tsx @@ -11,7 +11,7 @@ import 'katex/dist/katex.min.css' import FourOhFour from '../FourOhFour' import Loading from '../Loading' import DownloadButtonGroup from '../DownloadBtnGtoup' -import useFileContent from '../../utils/fetchOnMount' +import useAxiosGet from '../../utils/fetchOnMount' import { DownloadBtnContainer, PreviewContainer } from './Containers' const MarkdownPreview: FC<{ file: any; path: string; standalone?: boolean }> = ({ @@ -19,7 +19,7 @@ const MarkdownPreview: FC<{ file: any; path: string; standalone?: boolean }> = ( path, standalone = true, }) => { - const { content, error, validating } = useFileContent(file['@microsoft.graph.downloadUrl']) + const { content, error, validating } = useAxiosGet(file['@microsoft.graph.downloadUrl']) // The parent folder of the markdown file, which is also the relative image folder const parentPath = path.substring(0, path.lastIndexOf('/')) diff --git a/components/previews/TextPreview.tsx b/components/previews/TextPreview.tsx index 83d44b5..70f15b5 100644 --- a/components/previews/TextPreview.tsx +++ b/components/previews/TextPreview.tsx @@ -1,11 +1,11 @@ import FourOhFour from '../FourOhFour' import Loading from '../Loading' import DownloadButtonGroup from '../DownloadBtnGtoup' -import useFileContent from '../../utils/fetchOnMount' +import useAxiosGet from '../../utils/fetchOnMount' import { DownloadBtnContainer, PreviewContainer } from './Containers' const TextPreview = ({ file }) => { - const { content, error, validating } = useFileContent(file['@microsoft.graph.downloadUrl']) + const { content, error, validating } = useAxiosGet(file['@microsoft.graph.downloadUrl']) if (error) { return ( diff --git a/components/previews/URLPreview.tsx b/components/previews/URLPreview.tsx index 461528c..6555747 100644 --- a/components/previews/URLPreview.tsx +++ b/components/previews/URLPreview.tsx @@ -1,7 +1,7 @@ import FourOhFour from '../FourOhFour' import Loading from '../Loading' import { DownloadButton } from '../DownloadBtnGtoup' -import useFileContent from '../../utils/fetchOnMount' +import useAxiosGet from '../../utils/fetchOnMount' import { DownloadBtnContainer, PreviewContainer } from './Containers' const parseDotUrl = (content: string): string | undefined => { @@ -12,7 +12,7 @@ const parseDotUrl = (content: string): string | undefined => { } const TextPreview = ({ file }) => { - const { content, error, validating } = useFileContent(file['@microsoft.graph.downloadUrl']) + const { content, error, validating } = useAxiosGet(file['@microsoft.graph.downloadUrl']) if (error) { return ( diff --git a/utils/fetchOnMount.ts b/utils/fetchOnMount.ts index bc4ada2..116df31 100644 --- a/utils/fetchOnMount.ts +++ b/utils/fetchOnMount.ts @@ -2,19 +2,19 @@ import axios from 'axios' import { useEffect, useState } from 'react' // Custom hook to fetch raw file content on mount -export default function useFileContent(odRawUrl: string): { content: string; error: string; validating: boolean } { +export default function useAxiosGet(fetchUrl: string): { content: string; error: string; validating: boolean } { const [content, setContent] = useState('') const [validating, setValidating] = useState(true) const [error, setError] = useState('') useEffect(() => { axios - .get(odRawUrl) + .get(fetchUrl) .then(res => setContent(res.data)) .catch(e => setError(e.message)) .finally(() => { setValidating(false) }) - }, [odRawUrl]) + }, [fetchUrl]) return { content, error, validating } } From 17263feea95eb862d0331897a86b65fd2c443cba Mon Sep 17 00:00:00 2001 From: spencerwooo Date: Mon, 24 Jan 2022 20:31:17 +0800 Subject: [PATCH 3/5] load item path on result mount --- components/SearchModal.tsx | 148 +++++++++++++++++++++++++++++-------- types/index.d.ts | 15 ++++ utils/fetchOnMount.ts | 2 +- 3 files changed, 132 insertions(+), 33 deletions(-) diff --git a/components/SearchModal.tsx b/components/SearchModal.tsx index 8c0f951..4983442 100644 --- a/components/SearchModal.tsx +++ b/components/SearchModal.tsx @@ -4,25 +4,39 @@ import { useAsync } from 'react-async-hook' import useConstant from 'use-constant' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import { Dispatch, FC, Fragment, SetStateAction, useState } from 'react' +import { Dispatch, Fragment, SetStateAction, useState } from 'react' import { Dialog, Transition } from '@headlessui/react' import Link from 'next/link' -import { OdSearchResult } from '../types' -import { getFileIcon } from '../utils/getFileIcon' -import siteConfig from '../config/site.json' +import { OdDriveItem, OdSearchResult } from '../types' import { LoadingIcon } from './Loading' +import { getFileIcon } from '../utils/getFileIcon' +import useAxiosGet from '../utils/fetchOnMount' +import siteConfig from '../config/site.json' + +/** + * Extract the searched item's path in field 'parentReference' and convert it to the + * absolute path represented in onedrive-vercel-index + * + * @param path Path returned from the parentReference field of the driveItem + * @returns The absolute path of the driveItem in the search result + */ +function mapAbsolutePath(path: string): string { + return path.split(siteConfig.baseDirectory === '/' ? 'root:' : siteConfig.baseDirectory)[1] +} + +/** + * Implements a debounced search function that returns a promise that resolves to an array of + * search results. + * + * @returns A react hook for a debounced async search of the drive + */ function useDriveItemSearch() { const [query, setQuery] = useState('') const searchDriveItem = async (q: string) => { const { data } = await axios.get(`/api/search?q=${q}`) - // Extract the searched item's path and convert it to the absolute path in onedrive-vercel-index - function mapAbsolutePath(path: string): string { - return siteConfig.baseDirectory === '/' ? path.split('root:')[1] : path.split(siteConfig.baseDirectory)[1] - } - // Map parentReference to the absolute path of the search result data.map(item => { item['path'] = @@ -36,12 +50,12 @@ function useDriveItemSearch() { return data } - const debouncedNotionSearch = useConstant(() => AwesomeDebouncePromise(searchDriveItem, 1000)) + const debouncedDriveItemSearch = useConstant(() => AwesomeDebouncePromise(searchDriveItem, 1000)) const results = useAsync(async () => { if (query.length === 0) { return [] } else { - return debouncedNotionSearch(query) + return debouncedDriveItemSearch(query) } }, [query]) @@ -52,7 +66,91 @@ function useDriveItemSearch() { } } -function SearchModal({ +function SearchResultItemTemplate({ + driveItem, + driveItemPath, + itemDescription, + disabled, +}: { + driveItem: OdSearchResult[number] + driveItemPath: string + itemDescription: string + disabled: boolean +}) { + return ( + + + +
+
{driveItem.name}
+
+ {itemDescription} +
+
+
+ + ) +} + +function SearchResultItemLoadRemote({ result }: { result: OdSearchResult[number] }) { + const { + content, + error, + validating, + }: { + content: OdDriveItem + error: string + validating: boolean + } = useAxiosGet(`/api/item?id=${result.id}`) + + if (error) { + return + } + + if (validating) { + return ( + + ) + } + + const driveItemPath = `${mapAbsolutePath(content.parentReference.path)}/${encodeURIComponent(content.name)}` + return ( + + ) +} + +function SearchResultItem({ result }: { result: OdSearchResult[number] }) { + if (result.path === '') { + // path is empty, which means we need to fetch the parentReference to get the path + return + } else { + // path is not an empty string in the search result, such that we can directly render the component as is + const driveItemPath = decodeURIComponent(result.path) + return ( + + ) + } +} + +export default function SearchModal({ searchOpen, setSearchOpen, }: { @@ -102,10 +200,13 @@ function SearchModal({ value={query} onChange={e => setQuery(e.target.value)} /> -
ESC
+
ESC
-
+
{results.loading && (
@@ -120,22 +221,7 @@ function SearchModal({ {results.result.length === 0 ? (
Nothing here.
) : ( - results.result.map(result => ( - -
- -
-
{result.name}
-
- {decodeURIComponent(result.path)} -
-
-
- - )) + results.result.map(result => ) )} )} @@ -147,5 +233,3 @@ function SearchModal({ ) } - -export default SearchModal diff --git a/types/index.d.ts b/types/index.d.ts index f93f54d..d03c7c1 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -32,6 +32,7 @@ export type OdFolderObject = { }> } +// Search result type which is returned by /api/search?q={query} export type OdSearchResult = Array<{ id: string name: string @@ -44,3 +45,17 @@ export type OdSearchResult = Array<{ path: string } }> + +// driveItem type which is returned by /api/item?id={id} +export type OdDriveItem = { + '@odata.context': string + '@odata.etag': string + id: string + name: string + parentReference: { + driveId: string + driveType: string + id: string + path: string + } +} diff --git a/utils/fetchOnMount.ts b/utils/fetchOnMount.ts index 116df31..05595d7 100644 --- a/utils/fetchOnMount.ts +++ b/utils/fetchOnMount.ts @@ -2,7 +2,7 @@ import axios from 'axios' import { useEffect, useState } from 'react' // Custom hook to fetch raw file content on mount -export default function useAxiosGet(fetchUrl: string): { content: string; error: string; validating: boolean } { +export default function useAxiosGet(fetchUrl: string): { content: any; error: string; validating: boolean } { const [content, setContent] = useState('') const [validating, setValidating] = useState(true) const [error, setError] = useState('') From b47dd88d1a4b972e73b0379b2d82081125210007 Mon Sep 17 00:00:00 2001 From: spencerwooo Date: Mon, 24 Jan 2022 20:50:43 +0800 Subject: [PATCH 4/5] useSWR instead of custom useAxiosGet hook for cache reuse --- components/SearchModal.tsx | 24 ++++++++---------------- components/previews/CodePreview.tsx | 2 +- components/previews/MarkdownPreview.tsx | 2 +- components/previews/TextPreview.tsx | 2 +- components/previews/URLPreview.tsx | 2 +- pages/api/item.ts | 2 +- utils/fetchOnMount.ts | 10 +++++----- 7 files changed, 18 insertions(+), 26 deletions(-) diff --git a/components/SearchModal.tsx b/components/SearchModal.tsx index 4983442..ab0a63f 100644 --- a/components/SearchModal.tsx +++ b/components/SearchModal.tsx @@ -1,19 +1,20 @@ import axios from 'axios' +import useSWR, { SWRResponse } from 'swr' +import { Dispatch, Fragment, SetStateAction, useState } from 'react' import AwesomeDebouncePromise from 'awesome-debounce-promise' import { useAsync } from 'react-async-hook' import useConstant from 'use-constant' -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import { Dispatch, Fragment, SetStateAction, useState } from 'react' -import { Dialog, Transition } from '@headlessui/react' import Link from 'next/link' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { Dialog, Transition } from '@headlessui/react' import { OdDriveItem, OdSearchResult } from '../types' import { LoadingIcon } from './Loading' import { getFileIcon } from '../utils/getFileIcon' -import useAxiosGet from '../utils/fetchOnMount' import siteConfig from '../config/site.json' +import { fetcher } from '../utils/fetchWithSWR' /** * Extract the searched item's path in field 'parentReference' and convert it to the @@ -101,27 +102,18 @@ function SearchResultItemTemplate({ } function SearchResultItemLoadRemote({ result }: { result: OdSearchResult[number] }) { - const { - content, - error, - validating, - }: { - content: OdDriveItem - error: string - validating: boolean - } = useAxiosGet(`/api/item?id=${result.id}`) + const { data, error }: SWRResponse = useSWR(`/api/item?id=${result.id}`, fetcher) if (error) { return } - - if (validating) { + if (!data) { return ( ) } - const driveItemPath = `${mapAbsolutePath(content.parentReference.path)}/${encodeURIComponent(content.name)}` + const driveItemPath = `${mapAbsolutePath(data.parentReference.path)}/${encodeURIComponent(data.name)}` return ( = ({ file }) => { - const { content, error, validating } = useAxiosGet(file['@microsoft.graph.downloadUrl']) + const { response: content, error, validating } = useAxiosGet(file['@microsoft.graph.downloadUrl']) useEffect(() => { if (typeof window !== 'undefined') { diff --git a/components/previews/MarkdownPreview.tsx b/components/previews/MarkdownPreview.tsx index 2bb8047..6a4209e 100644 --- a/components/previews/MarkdownPreview.tsx +++ b/components/previews/MarkdownPreview.tsx @@ -19,7 +19,7 @@ const MarkdownPreview: FC<{ file: any; path: string; standalone?: boolean }> = ( path, standalone = true, }) => { - const { content, error, validating } = useAxiosGet(file['@microsoft.graph.downloadUrl']) + const { response: content, error, validating } = useAxiosGet(file['@microsoft.graph.downloadUrl']) // The parent folder of the markdown file, which is also the relative image folder const parentPath = path.substring(0, path.lastIndexOf('/')) diff --git a/components/previews/TextPreview.tsx b/components/previews/TextPreview.tsx index 70f15b5..15226a9 100644 --- a/components/previews/TextPreview.tsx +++ b/components/previews/TextPreview.tsx @@ -5,7 +5,7 @@ import useAxiosGet from '../../utils/fetchOnMount' import { DownloadBtnContainer, PreviewContainer } from './Containers' const TextPreview = ({ file }) => { - const { content, error, validating } = useAxiosGet(file['@microsoft.graph.downloadUrl']) + const { response: content, error, validating } = useAxiosGet(file['@microsoft.graph.downloadUrl']) if (error) { return ( diff --git a/components/previews/URLPreview.tsx b/components/previews/URLPreview.tsx index 6555747..5bf459f 100644 --- a/components/previews/URLPreview.tsx +++ b/components/previews/URLPreview.tsx @@ -12,7 +12,7 @@ const parseDotUrl = (content: string): string | undefined => { } const TextPreview = ({ file }) => { - const { content, error, validating } = useAxiosGet(file['@microsoft.graph.downloadUrl']) + const { response: content, error, validating } = useAxiosGet(file['@microsoft.graph.downloadUrl']) if (error) { return ( diff --git a/pages/api/item.ts b/pages/api/item.ts index 9ae9176..8385c47 100644 --- a/pages/api/item.ts +++ b/pages/api/item.ts @@ -1,7 +1,7 @@ import axios from 'axios' import type { NextApiRequest, NextApiResponse } from 'next' -import { encodePath, getAccessToken } from '.' +import { getAccessToken } from '.' import apiConfig from '../../config/api.json' export default async function handler(req: NextApiRequest, res: NextApiResponse) { diff --git a/utils/fetchOnMount.ts b/utils/fetchOnMount.ts index 05595d7..3a0a67f 100644 --- a/utils/fetchOnMount.ts +++ b/utils/fetchOnMount.ts @@ -1,20 +1,20 @@ import axios from 'axios' import { useEffect, useState } from 'react' -// Custom hook to fetch raw file content on mount -export default function useAxiosGet(fetchUrl: string): { content: any; error: string; validating: boolean } { - const [content, setContent] = useState('') +// Custom hook to axios get a URL or API endpoint on mount +export default function useAxiosGet(fetchUrl: string): { response: any; error: string; validating: boolean } { + const [response, setResponse] = useState('') const [validating, setValidating] = useState(true) const [error, setError] = useState('') useEffect(() => { axios .get(fetchUrl) - .then(res => setContent(res.data)) + .then(res => setResponse(res.data)) .catch(e => setError(e.message)) .finally(() => { setValidating(false) }) }, [fetchUrl]) - return { content, error, validating } + return { response, error, validating } } From 3476c652a396a80fbb135dfe4049bc88f7c5b1e3 Mon Sep 17 00:00:00 2001 From: spencerwooo Date: Mon, 24 Jan 2022 20:56:02 +0800 Subject: [PATCH 5/5] clear input box on search modal close --- components/SearchModal.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/components/SearchModal.tsx b/components/SearchModal.tsx index ab0a63f..8054997 100644 --- a/components/SearchModal.tsx +++ b/components/SearchModal.tsx @@ -149,10 +149,13 @@ export default function SearchModal({ searchOpen: boolean setSearchOpen: Dispatch> }) { - const closeSearchBox = () => setSearchOpen(false) - const { query, setQuery, results } = useDriveItemSearch() + const closeSearchBox = () => { + setSearchOpen(false) + setQuery('') + } + return (