Revamp documents

Include new citation hash function, search by citation hash, toasts, and QR codes
This commit is contained in:
Ananth Venkatesh 2024-02-16 19:39:26 +00:00
parent 80fc777e1e
commit ce107072a4
7 changed files with 147 additions and 47 deletions

22
package-lock.json generated
View file

@ -9,9 +9,11 @@
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@tanstack/react-query": "^5.20.2", "@tanstack/react-query": "^5.20.2",
"fnv-plus": "^1.3.1",
"million": "^3.0.3", "million": "^3.0.3",
"minisearch": "^6.3.0", "minisearch": "^6.3.0",
"next": "14.1.0", "next": "14.1.0",
"qrcode.react": "^3.1.0",
"react": "^18", "react": "^18",
"react-dom": "^18", "react-dom": "^18",
"react-icons": "^5.0.1", "react-icons": "^5.0.1",
@ -23,6 +25,7 @@
"devDependencies": { "devDependencies": {
"@redux-devtools/extension": "^3.3.0", "@redux-devtools/extension": "^3.3.0",
"@types/file-saver": "^2.0.7", "@types/file-saver": "^2.0.7",
"@types/fnv-plus": "^1.3.2",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^18", "@types/react": "^18",
"@types/react-dom": "^18", "@types/react-dom": "^18",
@ -1042,6 +1045,12 @@
"integrity": "sha512-dNKVfHd/jk0SkR/exKGj2ggkB45MAkzvWCaqLUUgkyjITkGNzH8H+yUwr+BLJUBjZOe9w8X3wgmXhZDRg1ED6A==", "integrity": "sha512-dNKVfHd/jk0SkR/exKGj2ggkB45MAkzvWCaqLUUgkyjITkGNzH8H+yUwr+BLJUBjZOe9w8X3wgmXhZDRg1ED6A==",
"dev": true "dev": true
}, },
"node_modules/@types/fnv-plus": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@types/fnv-plus/-/fnv-plus-1.3.2.tgz",
"integrity": "sha512-Bgr5yn2dph2q8HZKDS002Pob6vaRTRfhqN9E+TOhjKsJvnfZXULPR3ihH8dL5ZjgxbNhqhTn9hijpbAMPtKZzw==",
"dev": true
},
"node_modules/@types/json5": { "node_modules/@types/json5": {
"version": "0.0.29", "version": "0.0.29",
"resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz",
@ -2654,6 +2663,11 @@
"integrity": "sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ==", "integrity": "sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ==",
"dev": true "dev": true
}, },
"node_modules/fnv-plus": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/fnv-plus/-/fnv-plus-1.3.1.tgz",
"integrity": "sha512-Gz1EvfOneuFfk4yG458dJ3TLJ7gV19q3OM/vVvvHf7eT02Hm1DleB4edsia6ahbKgAYxO9gvyQ1ioWZR+a00Yw=="
},
"node_modules/for-each": { "node_modules/for-each": {
"version": "0.3.3", "version": "0.3.3",
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz",
@ -4340,6 +4354,14 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/qrcode.react": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-3.1.0.tgz",
"integrity": "sha512-oyF+Urr3oAMUG/OiOuONL3HXM+53wvuH3mtIWQrYmsXoAq0DkvZp2RYUWFSMFtbdOpuS++9v+WAkzNVkMlNW6Q==",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/queue-microtask": { "node_modules/queue-microtask": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",

View file

@ -11,9 +11,11 @@
}, },
"dependencies": { "dependencies": {
"@tanstack/react-query": "^5.20.2", "@tanstack/react-query": "^5.20.2",
"fnv-plus": "^1.3.1",
"million": "^3.0.3", "million": "^3.0.3",
"minisearch": "^6.3.0", "minisearch": "^6.3.0",
"next": "14.1.0", "next": "14.1.0",
"qrcode.react": "^3.1.0",
"react": "^18", "react": "^18",
"react-dom": "^18", "react-dom": "^18",
"react-icons": "^5.0.1", "react-icons": "^5.0.1",
@ -25,6 +27,7 @@
"devDependencies": { "devDependencies": {
"@redux-devtools/extension": "^3.3.0", "@redux-devtools/extension": "^3.3.0",
"@types/file-saver": "^2.0.7", "@types/file-saver": "^2.0.7",
"@types/fnv-plus": "^1.3.2",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^18", "@types/react": "^18",
"@types/react-dom": "^18", "@types/react-dom": "^18",

View file

@ -14,6 +14,7 @@ import generateHash from '@/app/utils/hash'
import { Suspense } from 'react' import { Suspense } from 'react'
import { loadDocument } from '@/app/db/loaders' import { loadDocument } from '@/app/db/loaders'
import { useSuspenseQuery } from '@tanstack/react-query' import { useSuspenseQuery } from '@tanstack/react-query'
import QRCode from 'qrcode.react'
const zillaSlab = Zilla_Slab({ subsets: ['latin'], weight: ['500'] }) const zillaSlab = Zilla_Slab({ subsets: ['latin'], weight: ['500'] })
@ -48,15 +49,20 @@ const DocumentViewer = ({ slug }: Readonly<{ slug: string }>) => {
return ( return (
<div className='max-w-4xl lg:max-w-6xl mx-auto'> <div className='max-w-4xl lg:max-w-6xl mx-auto'>
<h1 <div>
className={` <div className='float-left mr-8 mb-4 mt-4'>
text-slate-800 text-5xl mb-4 <QRCode value={citation ?? `eeXiv:${generateHash(slug)}`} />
${zillaSlab.className} </div>
text-wrap break-words hyphens-auto <h1
`} className={`
> text-slate-800 text-5xl mb-4
{title} ${zillaSlab.className}
</h1> text-wrap break-words hyphens-auto inline
`}
>
{title}
</h1>
</div>
<p className={`text-slate-800 mt-2`}> <p className={`text-slate-800 mt-2`}>
<Authors authors={authors} /> <Authors authors={authors} />
</p> </p>

View file

@ -6,6 +6,7 @@ import { loadAuthors } from '@/app/db/loaders'
import { useSuspenseQuery } from '@tanstack/react-query' import { useSuspenseQuery } from '@tanstack/react-query'
import { epoch2date } from '@/app/utils/epoch2datestring' import { epoch2date } from '@/app/utils/epoch2datestring'
import { toast } from 'react-toastify' import { toast } from 'react-toastify'
import generateHash from '@/app/utils/hash'
const VersionChooser = ({ const VersionChooser = ({
doc, doc,
@ -26,7 +27,7 @@ const VersionChooser = ({
const fileEnding = file === 'other' ? '' : `.${file}` const fileEnding = file === 'other' ? '' : `.${file}`
const [selectedRevision, setSelectedRevision] = useState<number>(latest) // Initialize the selected revision with the latest revision const [selectedRevision, setSelectedRevision] = useState<number>(latest) // Initialize the selected revision with the latest revision
const notifyCopied = () => toast('BibTeX copied to clipboard!') const notifyCopied = (content: string) => toast.success(`${content} copied to clipboard!`)
const handleClick = () => { const handleClick = () => {
const bibtex = `@article{ const bibtex = `@article{
@ -45,7 +46,13 @@ const VersionChooser = ({
url={${window.location.href}} url={${window.location.href}}
}` }`
navigator.clipboard.writeText(bibtex) navigator.clipboard.writeText(bibtex)
notifyCopied() notifyCopied('BibTeX')
}
const handleCopy = () => {
const id = doc.citation ? doc.citation : generateHash(slug)
navigator.clipboard.writeText(`eeXiv:${id}`)
notifyCopied('Citation')
} }
return ( return (
@ -75,6 +82,9 @@ const VersionChooser = ({
<button className='button-alternate' onClick={handleClick}> <button className='button-alternate' onClick={handleClick}>
Export BibTeX Export BibTeX
</button> </button>
<button className='button-alternate' onClick={handleCopy}>
Copy citation
</button>
<select <select
className='select-default' className='select-default'
value={`v${selectedRevision}`} value={`v${selectedRevision}`}

View file

@ -7,6 +7,9 @@ import { epoch2datestring } from '../utils/epoch2datestring'
import searchDocs, { CustomSearchResult } from '@/app/utils/searchDocs' import searchDocs, { CustomSearchResult } from '@/app/utils/searchDocs'
import { useSuspenseQuery } from '@tanstack/react-query' import { useSuspenseQuery } from '@tanstack/react-query'
import cardEffects from '@/app/styles/cardEffects.module.css' import cardEffects from '@/app/styles/cardEffects.module.css'
import { toast } from 'react-toastify'
import { useEffect } from 'react'
import { hash as fnv } from 'fnv-plus'
const zillaSlab = Zilla_Slab({ subsets: ['latin'], weight: ['500', '700'] }) const zillaSlab = Zilla_Slab({ subsets: ['latin'], weight: ['500', '700'] })
@ -71,6 +74,30 @@ export default function Page() {
const router = useRouter() const router = useRouter()
const searchParams = useSearchParams() const searchParams = useSearchParams()
const search = decodeURIComponent(searchParams.get('q') as string) const search = decodeURIComponent(searchParams.get('q') as string)
let invalid = false
if (search.toLowerCase().startsWith('eexiv')) {
const id = search.slice(6)
if (id.length !== 12) {
invalid = true
} else {
const base = id.slice(0, 10)
const ending = id.slice(10, 12)
const e_ending = fnv(base).str().substring(0, 2)
if (ending !== e_ending) {
invalid = true
}
}
}
useEffect(() => {
if (invalid) {
toast.error('Invalid eeXiv identifier')
}
}, [invalid])
const { data, error } = useSuspenseQuery({ const { data, error } = useSuspenseQuery({
queryKey: [search], queryKey: [search],
@ -93,7 +120,7 @@ export default function Page() {
{search} {search}
{`"`} {`"`}
</h1> </h1>
{data.length === 0 ? ( {(data.length === 0 || invalid) ? (
<p className='text-lg px-2'>No results found.</p> <p className='text-lg px-2'>No results found.</p>
) : ( ) : (
data.map((result) => ( data.map((result) => (

View file

@ -1,6 +1,8 @@
import crypto from 'crypto' import { hash as fnv } from 'fnv-plus'
export default function hash(key: string) { export default function hash(key: string) {
// git style hash // fast non-cryptographic hash with error correction
return crypto.createHash('sha256').update(key).digest('hex').substring(0, 7) const base = fnv(key).str().substring(0, 10)
const ending = fnv(base).str().substring(0, 2)
return `${base}${ending}`
} }

View file

@ -1,11 +1,37 @@
import { documents, authors, affiliations, topics } from '@/app/db/data' import { documents, authors, affiliations, topics } from '@/app/db/data'
import MiniSearch from 'minisearch' import MiniSearch from 'minisearch'
import { CustomSearchResult } from './searchDocs' import { CustomSearchResult } from './searchDocs'
import crypto from 'crypto'
import hash from './hash' import hash from './hash'
import { hash as fnv } from 'fnv-plus'
import { Document } from '../db/data'
// converting the documents object into an array that can be index by minisearch const miniSearch = new MiniSearch({
const docs = Object.entries(documents).map(([key, value]) => ({ fields: [
'abstract',
'keywords',
'topics',
'authors',
'title',
'type',
'affiliations',
'citation',
'doi',
],
storeFields: ['key', 'abstract', 'manifest'],
searchOptions: {
boost: {
title: 3,
keywords: 2,
topics: 1,
authors: 2,
abstract: 0.3,
},
fuzzy: 0.2,
prefix: true,
},
})
const serializeDoc = (key: string, value: Document) => ({
id: key, id: key,
keywords: value.manifest.keywords.join(' '), keywords: value.manifest.keywords.join(' '),
abstract: value.abstract, abstract: value.abstract,
@ -36,37 +62,41 @@ const docs = Object.entries(documents).map(([key, value]) => ({
) )
.join(' '), .join(' '),
key: key, key: key,
citation: value.citation ? value.citation : hash(key), doi: value.doi ? value.doi : undefined
doi: value.doi ? value.doi : undefined,
}))
const miniSearch = new MiniSearch({
fields: [
'abstract',
'keywords',
'topics',
'authors',
'title',
'type',
'affiliations',
'citation',
'doi',
],
storeFields: ['key', 'abstract', 'manifest'],
searchOptions: {
boost: {
title: 3,
keywords: 2,
topics: 1,
authors: 2,
abstract: 0.3,
},
fuzzy: 0.2,
prefix: true,
},
}) })
miniSearch.addAll(docs)
onmessage = (e: MessageEvent<string>) => { onmessage = (e: MessageEvent<string>) => {
// check if searching for eeXiv citation
if (e.data.toLowerCase().startsWith('eexiv:')) {
const id = e.data.slice(6)
if (id.length !== 12) {
postMessage([])
} else {
const base = id.slice(0, 10)
const ending = id.slice(10, 12)
const e_ending = fnv(base).str().substring(0, 2)
if (ending !== e_ending) {
postMessage([])
}
}
Object.keys(documents).forEach((key) => {
if (documents[key].citation === id) {
postMessage([serializeDoc(key, documents[key])])
}
const hashed = hash(key)
if (hashed === id) {
postMessage([serializeDoc(key, documents[key])])
}
})
}
// converting the documents object into an array that can be index by minisearch
const docs = Object.entries(documents).map(([key, value]) => (serializeDoc(key, value)))
miniSearch.addAll(docs)
postMessage(miniSearch.search(e.data) as CustomSearchResult[]) postMessage(miniSearch.search(e.data) as CustomSearchResult[])
} }