Merge pull request #303 from spencerwooo/search-for-od-business

This commit is contained in:
Spencer Woo
2022-01-24 21:06:18 +08:00
committed by GitHub
10 changed files with 212 additions and 62 deletions
+121 -44
View File
@@ -1,49 +1,62 @@
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, FC, 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 { 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 siteConfig from '../config/site.json'
import { fetcher } from '../utils/fetchWithSWR'
/**
* 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<OdSearchResult>(`/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 => {
// 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
}
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])
@@ -54,17 +67,95 @@ function useDriveItemSearch() {
}
}
function SearchModal({
function SearchResultItemTemplate({
driveItem,
driveItemPath,
itemDescription,
disabled,
}: {
driveItem: OdSearchResult[number]
driveItemPath: string
itemDescription: string
disabled: boolean
}) {
return (
<Link href={driveItemPath} passHref>
<a
className={`flex items-center space-x-4 border-b border-gray-400/30 px-4 py-1.5 hover:bg-gray-50 dark:hover:bg-gray-850 ${
disabled ? 'cursor-not-allowed pointer-events-none' : 'cursor-pointer'
}`}
>
<FontAwesomeIcon icon={driveItem.file ? getFileIcon(driveItem.name) : ['far', 'folder']} />
<div>
<div className="text-sm font-medium leading-8">{driveItem.name}</div>
<div
className={`text-xs font-mono opacity-60 truncate overflow-hidden ${
itemDescription === 'Loading ...' && 'animate-pulse'
}`}
>
{itemDescription}
</div>
</div>
</a>
</Link>
)
}
function SearchResultItemLoadRemote({ result }: { result: OdSearchResult[number] }) {
const { data, error }: SWRResponse<OdDriveItem, string> = useSWR(`/api/item?id=${result.id}`, fetcher)
if (error) {
return <SearchResultItemTemplate driveItem={result} driveItemPath={''} itemDescription={error} disabled={true} />
}
if (!data) {
return (
<SearchResultItemTemplate driveItem={result} driveItemPath={''} itemDescription={'Loading ...'} disabled={true} />
)
}
const driveItemPath = `${mapAbsolutePath(data.parentReference.path)}/${encodeURIComponent(data.name)}`
return (
<SearchResultItemTemplate
driveItem={result}
driveItemPath={driveItemPath}
itemDescription={decodeURIComponent(driveItemPath)}
disabled={false}
/>
)
}
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 <SearchResultItemLoadRemote result={result} />
} 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 (
<SearchResultItemTemplate
driveItem={result}
driveItemPath={driveItemPath}
itemDescription={driveItemPath}
disabled={false}
/>
)
}
}
export default function SearchModal({
searchOpen,
setSearchOpen,
}: {
searchOpen: boolean
setSearchOpen: Dispatch<SetStateAction<boolean>>
}) {
const closeSearchBox = () => setSearchOpen(false)
const { query, setQuery, results } = useDriveItemSearch()
const closeSearchBox = () => {
setSearchOpen(false)
setQuery('')
}
return (
<Transition appear show={searchOpen} as={Fragment}>
<Dialog as="div" className="inset-0 z-[200] fixed overflow-y-auto" onClose={closeSearchBox}>
@@ -104,10 +195,13 @@ function SearchModal({
value={query}
onChange={e => setQuery(e.target.value)}
/>
<div className="px-2 py-1 rounded-lg bg-gray-100 dark:bg-gray-700 font-medium text-xs">ESC</div>
<div className="px-2 py-1 rounded-lg bg-gray-200 dark:bg-gray-700 font-medium text-xs">ESC</div>
</Dialog.Title>
<div className="bg-white dark:text-white dark:bg-gray-900 max-h-[80vh] overflow-x-hidden overflow-y-scroll">
<div
className="bg-white dark:text-white dark:bg-gray-900 max-h-[80vh] overflow-x-hidden overflow-y-scroll"
onClick={closeSearchBox}
>
{results.loading && (
<div className="text-center px-4 py-12 text-sm font-medium">
<LoadingIcon className="animate-spin w-4 h-4 mr-2 inline-block svg-inline--fa" />
@@ -122,22 +216,7 @@ function SearchModal({
{results.result.length === 0 ? (
<div className="text-center px-4 py-12 text-sm font-medium">Nothing here.</div>
) : (
results.result.map(result => (
<Link href={result.path} key={result.id} passHref>
<div
className="flex items-center space-x-4 border-b cursor-pointer border-gray-400/30 px-4 py-1.5 hover:bg-gray-50 dark:hover:bg-gray-850"
onClick={closeSearchBox}
>
<FontAwesomeIcon icon={result.file ? getFileIcon(result.name) : ['far', 'folder']} />
<div>
<div className="text-sm font-medium leading-8">{result.name}</div>
<div className="text-xs font-mono opacity-60 truncate overflow-hidden">
{decodeURIComponent(result.path)}
</div>
</div>
</div>
</Link>
))
results.result.map(result => <SearchResultItem key={result.id} result={result} />)
)}
</>
)}
@@ -149,5 +228,3 @@ function SearchModal({
</Transition>
)
}
export default SearchModal
+2 -2
View File
@@ -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 { response: content, error, validating } = useAxiosGet(file['@microsoft.graph.downloadUrl'])
useEffect(() => {
if (typeof window !== 'undefined') {
+2 -2
View File
@@ -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 { 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('/'))
+2 -2
View File
@@ -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 { response: content, error, validating } = useAxiosGet(file['@microsoft.graph.downloadUrl'])
if (error) {
return (
<PreviewContainer>
+2 -2
View File
@@ -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 { response: content, error, validating } = useAxiosGet(file['@microsoft.graph.downloadUrl'])
if (error) {
return (
<PreviewContainer>
+11
View File
@@ -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<string> {
const { accessToken, refreshToken } = await getOdAuthTokens()
+32
View File
@@ -0,0 +1,32 @@
import axios from 'axios'
import type { NextApiRequest, NextApiResponse } from 'next'
import { 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
}
+18 -3
View File
@@ -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)
+15
View File
@@ -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
}
}
+7 -7
View File
@@ -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 useFileContent(odRawUrl: string): { content: string; 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(odRawUrl)
.then(res => setContent(res.data))
.get(fetchUrl)
.then(res => setResponse(res.data))
.catch(e => setError(e.message))
.finally(() => {
setValidating(false)
})
}, [odRawUrl])
return { content, error, validating }
}, [fetchUrl])
return { response, error, validating }
}