diff --git a/package.json b/package.json index 2073d50b4..9a33c2a82 100644 --- a/package.json +++ b/package.json @@ -105,6 +105,7 @@ "remove-markdown": "^0.3.0", "rgb-hex": "^3.0.0", "romans": "^2.0.4", + "stopwords": "^0.0.9", "stylelint-config-standard": "^24.0.0", "typescript": "^4.0.2", "unified": "^9.2.1", diff --git a/src/bibliography/domain/BibliographyEntry.ts b/src/bibliography/domain/BibliographyEntry.ts index c7e11fd1f..1913d3cfe 100644 --- a/src/bibliography/domain/BibliographyEntry.ts +++ b/src/bibliography/domain/BibliographyEntry.ts @@ -20,6 +20,7 @@ function getName(author: unknown): string { return particle ? `${particle} ${family}` : family } +// eslint-disable-next-line @typescript-eslint/no-explicit-any export type CslData = { readonly [key: string]: any } export default class BibliographyEntry { readonly [immerable] = true @@ -28,12 +29,12 @@ export default class BibliographyEntry { constructor(cslData?: CslData | null | undefined) { this.cslData = cslData ? produce(cslData, (draft) => { - _.keys(draft) + Object.keys(draft) .filter((key) => key.startsWith('_')) - .forEach(_.partial(_.unset, draft)) + .forEach((key) => delete draft[key]) if (draft.author) { - draft.author = draft.author.map( - _.partialRight(_.pick, authorProperties) + draft.author = draft.author.map((author) => + _.pick(author, authorProperties) ) } }) @@ -41,75 +42,72 @@ export default class BibliographyEntry { } get id(): string { - return _.get(this.cslData, 'id', '') + return this.cslData.id || '' } get primaryAuthor(): string { - return _.head(this.authors) || '' + return this.authors[0] || '' } get authors(): string[] { - return _.get(this.cslData, 'author', []).map(getName) + return (this.cslData.author || []).map(getName) } get year(): string { - const start = _.get(this.cslData, 'issued.date-parts.0.0', '') - const end = _.get(this.cslData, 'issued.date-parts.1.0', '') + const dates = _.get(this.cslData, 'issued.date-parts', []) + const start = dates[0]?.[0] || '' + const end = dates[1]?.[0] || '' return end ? `${start}–${end}` : String(start) } get title(): string { - return _.get(this.cslData, 'title', '') + return this.cslData.title || '' } get shortContainerTitle(): string { - return _.get(this.cslData, 'container-title-short', '') + return this.cslData['container-title-short'] || '' } get shortTitle(): string { - return _.get(this.cslData, 'title-short', '') + return this.cslData['title-short'] || '' } get collectionNumber(): string { - return _.get(this.cslData, 'collection-number', '') + return this.cslData['collection-number'] || '' } get volume(): string { - return _.get(this.cslData, 'volume', '') + return this.cslData.volume || '' } get link(): string { - const url = _.get(this.cslData, 'URL', '') - const doi = _.get(this.cslData, 'DOI', '') - return url || (doi ? `https://doi.org/${doi}` : '') + return ( + this.cslData.URL || + (this.cslData.DOI ? `https://doi.org/${this.cslData.DOI}` : '') + ) } get authorYearTitle(): string { return `${this.primaryAuthor} ${this.year} ${this.title}` } - get abberviationContainer(): string | undefined { - const containerTitleShort = this.shortContainerTitle - const collectionNumber = this.collectionNumber - ? ` ${this.collectionNumber}` - : '' - return containerTitleShort - ? `${containerTitleShort}${collectionNumber}` - : undefined + get abbreviationContainer(): string | undefined { + const container = this.shortContainerTitle + const number = this.collectionNumber ? ` ${this.collectionNumber}` : '' + return container ? `${container}${number}` : undefined } get abbreviationTitle(): string | undefined { - const { shortTitle } = this - const volume = this.volume ? ` ${this.volume}` : '' - return shortTitle ? `${shortTitle}${volume}` : undefined + const title = this.shortTitle + const vol = this.volume ? ` ${this.volume}` : '' + return title ? `${title}${vol}` : undefined } get abbreviations(): string | undefined { - const { abberviationContainer, abbreviationTitle } = this - if (abberviationContainer && abbreviationTitle) { - return `${abberviationContainer} = ${abbreviationTitle}` - } - return abberviationContainer ?? abbreviationTitle ?? undefined + const container = this.abbreviationContainer + const title = this.abbreviationTitle + if (container && title) return `${container} = ${title}` + return container || title } get label(): string { diff --git a/src/bibliography/domain/GenerateIds.test.ts b/src/bibliography/domain/GenerateIds.test.ts new file mode 100644 index 000000000..11d7958d2 --- /dev/null +++ b/src/bibliography/domain/GenerateIds.test.ts @@ -0,0 +1,66 @@ +import { generateIds } from './GenerateIds' +import { CslData } from 'bibliography/domain/BibliographyEntry' + +const testEntry: CslData = { + author: [{ family: 'Doe', given: 'John' }], + title: 'The Quick Brown Fox Jumps Over the Lazy Dog', + issued: { 'date-parts': [[2023]] }, + language: 'en', +} + +describe('generateIds', () => { + const testIdGeneration = ( + name: string, + modifiedEntry: Partial, + expectedId: string + ) => { + test(name, () => { + const result = generateIds({ ...testEntry, ...modifiedEntry }) + expect(result).toBe(expectedId) + }) + } + + test('basic ID generation', () => { + const result = generateIds(testEntry) + expect(result).toBe('doe2023quick') + }) + + testIdGeneration( + 'ID generation with missing author', + { author: undefined }, + 'unknownauthor2023quick' + ) + testIdGeneration( + 'ID generation with missing year', + { issued: undefined }, + 'doe9999quick' + ) + testIdGeneration( + 'ID generation with missing title', + { title: undefined }, + 'doe2023unknowntitle' + ) + testIdGeneration( + 'ID generation with all significant words as stop words', + { title: 'The Of And But Or Nor For' }, + 'doe2023unknowntitle' + ) + + testIdGeneration( + 'ID generation with different language (German)', + { + language: 'de', + title: 'Der Schnelle Braune Fuchs Springt Über den Faulen Hund', + }, + 'doe2023schnelle' + ) + + testIdGeneration( + 'ID generation with language not supported', + { + language: 'ru', + title: 'Экс-граф? Плюш изъят. Бьём чуждый цен хвощ!', + }, + 'doe2023экс-граф?' + ) +}) diff --git a/src/bibliography/domain/GenerateIds.ts b/src/bibliography/domain/GenerateIds.ts new file mode 100644 index 000000000..24c8f44fb --- /dev/null +++ b/src/bibliography/domain/GenerateIds.ts @@ -0,0 +1,27 @@ +import { CslData } from 'bibliography/domain/BibliographyEntry' +import stopwords from 'stopwords' + +const STOPWORDS = { + en: new Set(stopwords.english), + de: new Set(stopwords.german), + it: new Set(stopwords.italian), + es: new Set(stopwords.spanish), + fr: new Set(stopwords.french), +} + +export function generateIds(entry: CslData): string { + const language = entry.language || 'en' + const stopwordSet = STOPWORDS[language] || STOPWORDS.en + + const author = entry.author?.[0]?.family || 'unknownauthor' + const year = entry.issued?.['date-parts']?.[0]?.[0] || '9999' + + const titleWords = entry.title?.split(' ') || [] + const firstSignificantWord = + titleWords.find((word) => !stopwordSet.has(word.toLowerCase())) || + 'unknowntitle' + + return `${author}${year}${firstSignificantWord}` + .replace(/\s+/g, '') + .toLowerCase() +} diff --git a/src/bibliography/ui/BibliographyEditor.tsx b/src/bibliography/ui/BibliographyEditor.tsx index 406eeef44..0bf8b1bce 100644 --- a/src/bibliography/ui/BibliographyEditor.tsx +++ b/src/bibliography/ui/BibliographyEditor.tsx @@ -62,9 +62,15 @@ export default withData< BibliographyEntry >( BibliographyEditor, - (props) => props.bibliographyService.find(props.match.params['id']), + (props) => { + const decodedId = decodeURIComponent(props.match.params['id']) + return props.bibliographyService.find(decodedId) + }, { - watch: (props) => [props.create, props.match.params['id']], + watch: (props) => [ + props.create, + decodeURIComponent(props.match.params['id']), + ], filter: (props) => !props.create, defaultData: () => template, } diff --git a/src/bibliography/ui/BibliographyEntryForm.test.tsx b/src/bibliography/ui/BibliographyEntryForm.test.tsx index 1dcde8a13..4189557f0 100644 --- a/src/bibliography/ui/BibliographyEntryForm.test.tsx +++ b/src/bibliography/ui/BibliographyEntryForm.test.tsx @@ -1,34 +1,87 @@ import React from 'react' -import { render, screen } from '@testing-library/react' -import _ from 'lodash' - +import { render, screen, waitFor } from '@testing-library/react' import { changeValueByLabel, clickNth } from 'test-support/utils' import BibliographyEntryForm from './BibliographyEntryForm' import { bibliographyEntryFactory } from 'test-support/bibliography-fixtures' import BibliographyEntry from 'bibliography/domain/BibliographyEntry' -let json: string -let entry: BibliographyEntry -let onSubmit: () => void +let mockJson: string +let mockEntry: BibliographyEntry +let onSubmitMock: jest.Mock beforeEach(() => { - entry = bibliographyEntryFactory.build() - json = JSON.stringify(entry.toCslData(), null, 2) - onSubmit = jest.fn() + mockEntry = bibliographyEntryFactory.build() + mockJson = JSON.stringify(mockEntry.toCslData(), null, 2) + onSubmitMock = jest.fn() }) -test(`Changing document calls onChange with updated value.`, async () => { - render() - changeValueByLabel(screen, 'Data', json) - await screen.findByText(new RegExp(_.escapeRegExp(`(${entry.year})`))) +const waitForSaveButtonToBeEnabled = async () => { + await waitFor( + () => expect(screen.getByRole('button', { name: /Save/i })).toBeEnabled(), + { timeout: 1000 } + ) +} + +test('Form updates and submits entry with correct data', async () => { + render() + changeValueByLabel(screen, 'Data', mockJson) + await screen.findByText(new RegExp(`\\(${mockEntry.year}\\)`)) clickNth(screen, 'Save', 0) - expect(onSubmit).toHaveBeenCalledWith(entry) + expect(onSubmitMock).toHaveBeenCalledWith(mockEntry) }) -test(`Shows value as CSL-JSON.`, async () => { - render() - await screen.findByDisplayValue( - new RegExp(_.escapeRegExp(json).replace(/\s+/g, '\\s*')) - ) +test('Displays CSL-JSON input correctly', async () => { + render() + const textarea = screen.getByLabelText('Data') as HTMLTextAreaElement + await waitFor(() => { + expect(textarea.value.replace(/\s/g, '')).toContain( + JSON.stringify(mockEntry.toCslData()).replace(/\s/g, '') + ) + }) +}) + +test('Applies custom ID when no ID exists', async () => { + const entryWithoutId = bibliographyEntryFactory.build({ + toCslData: () => ({ ...mockEntry.toCslData(), id: undefined }), + }) + const jsonWithoutId = JSON.stringify(entryWithoutId.toCslData(), null, 2) + + render() + changeValueByLabel(screen, 'Data', jsonWithoutId) + + await waitForSaveButtonToBeEnabled() + + clickNth(screen, 'Save', 0) + + await waitFor(() => { + expect(onSubmitMock).toHaveBeenCalled() + + const submittedEntry = onSubmitMock.mock.calls[0][0] + + expect(submittedEntry.id).not.toBeUndefined() + expect(submittedEntry.id).not.toMatch(/^temp_id/) + }) +}) + +test('Preserves existing ID', async () => { + const entryWithValidId = bibliographyEntryFactory.build({ + toCslData: () => ({ ...mockEntry.toCslData(), id: 'validId123' }), + }) + const jsonWithValidId = JSON.stringify(entryWithValidId.toCslData(), null, 2) + + render() + changeValueByLabel(screen, 'Data', jsonWithValidId) + + await waitForSaveButtonToBeEnabled() + + clickNth(screen, 'Save', 0) + + await waitFor(() => { + expect(onSubmitMock).toHaveBeenCalled() + + const submittedEntry = onSubmitMock.mock.calls[0][0] + + expect(submittedEntry.id).toEqual('validId123') + }) }) diff --git a/src/bibliography/ui/BibliographyEntryForm.tsx b/src/bibliography/ui/BibliographyEntryForm.tsx index 1d50a1210..0de6615b7 100644 --- a/src/bibliography/ui/BibliographyEntryForm.tsx +++ b/src/bibliography/ui/BibliographyEntryForm.tsx @@ -10,24 +10,23 @@ import Spinner from 'common/Spinner' import BibliographyEntry, { CslData, } from 'bibliography/domain/BibliographyEntry' +import { generateIds } from 'bibliography/domain/GenerateIds' import './BibliographyEntryForm.css' -function BibliographyHelp() { - return ( -

- You can enter a DOI, CSL-JSON, BibTeX, or any{' '} - - supported input format - - . BibTeX can be generated with{' '} - - BibTeX Online Editor - - . -

- ) -} +const BibliographyHelp = () => ( +

+ You can enter a DOI, CSL-JSON, BibTeX, or any{' '} + + supported input format + + . BibTeX can be generated with{' '} + + BibTeX Online Editor + + . +

+) interface Props { value?: BibliographyEntry | null @@ -40,22 +39,31 @@ interface State { value: string cslData: ReadonlyArray | null loading: boolean + customId: string isInvalid: boolean } export default class BibliographyEntryForm extends Component { - static defaultProps: { value: null; disabled: false } + static defaultProps = { value: null, disabled: false } + private promise: Promise private doLoad: (value: string) => Promise | undefined constructor(props: Props) { super(props) - this.state = props.value + this.state = this.getInitialState(props.value) + this.promise = Promise.resolve() + this.doLoad = _.debounce(this.load, 500, { leading: false, trailing: true }) + } + + private getInitialState(value?: BibliographyEntry | null): State { + return value ? { - citation: props.value.toHtml(), - cslData: [props.value.toCslData()], - value: JSON.stringify(props.value.toCslData(), null, 2), + citation: value.toHtml(), + cslData: [value.toCslData()], + value: JSON.stringify(value.toCslData(), null, 2), loading: false, + customId: '', isInvalid: false, } : { @@ -63,20 +71,16 @@ export default class BibliographyEntryForm extends Component { cslData: null, value: '', loading: false, + customId: '', isInvalid: false, } - this.promise = Promise.resolve() - this.doLoad = _.debounce(this.load, 500, { - leading: false, - trailing: true, - }) } - get isValid(): boolean { + private get isValid(): boolean { return _.isArray(this.state.cslData) && this.state.cslData.length === 1 } - get isInvalid(): boolean { + private get isInvalid(): boolean { return ( !this.state.loading && !_.isEmpty(this.state.value) && @@ -84,11 +88,11 @@ export default class BibliographyEntryForm extends Component { ) } - get isDisabled(): boolean { + private get isDisabled(): boolean { return !this.isValid || this.props.disabled } - handleChange = (event: React.ChangeEvent): void => { + private handleChange = (event: React.ChangeEvent): void => { this.setState({ ...this.state, value: event.target.value, @@ -98,44 +102,69 @@ export default class BibliographyEntryForm extends Component { this.promise = this.doLoad(event.target.value) || this.promise } - load = (value: string): Promise => { + private load = (value: string): Promise => { this.promise.cancel() - return new Promise((resolve, reject) => { - Cite.async(value).then(resolve).catch(reject) - }) - .then((cite: Cite) => { - this.setState({ - ...this.state, - citation: cite.format('bibliography', { - format: 'html', - template: 'citation-apa', - lang: 'de-DE', - }), - cslData: cite.get({ - format: 'real', - type: 'json', - style: 'csl', - }), - loading: false, - }) + + const handleSuccess = (cite: Cite): void => { + const cslData = cite.get({ format: 'real', type: 'json', style: 'csl' }) + const customId = generateIds(cslData[0]) + + this.setState({ + ...this.state, + citation: this.formatCitation(cite), + cslData, + customId, + loading: false, }) - .catch(() => { - this.setState({ - ...this.state, - citation: '', - cslData: null, - loading: false, - isInvalid: true, - }) + } + + const handleError = (): void => { + this.setState({ + ...this.state, + citation: '', + cslData: null, + loading: false, + isInvalid: true, }) + } + + return new Promise((resolve, reject) => { + Cite.async(value) + .then((cite: Cite) => { + handleSuccess(cite) + resolve() + }) + .catch(() => { + handleError() + reject() + }) + }) } - handleSubmit = (event: React.FormEvent): void => { + private formatCitation = (cite: Cite): string => { + return cite.format('bibliography', { + format: 'html', + template: 'citation-apa', + lang: 'de-DE', + }) + } + + private handleSubmit = (event: React.FormEvent): void => { event.preventDefault() - const entry = new BibliographyEntry( - this.state.cslData && this.state.cslData[0] - ) - this.props.onSubmit(entry) + if (this.state.cslData && this.state.cslData[0]) { + const entryData = this.applyCustomIdIfNeeded(this.state.cslData[0]) + const entry = new BibliographyEntry(entryData) + this.props.onSubmit(entry) + } + } + + private applyCustomIdIfNeeded = ( + cslData: CslData + ): CslData & { id: string } => { + const id = cslData.id?.trim() + return !id || id.startsWith('temp_id') + ? { ...cslData, id: this.state.customId } + : { ...cslData, id } } render(): JSX.Element { diff --git a/yarn.lock b/yarn.lock index 8b606aafa..bd2a2ff27 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13497,6 +13497,11 @@ stdout-stream@^1.4.0: dependencies: readable-stream "^2.0.1" +stopwords@^0.0.9: + version "0.0.9" + resolved "https://registry.yarnpkg.com/stopwords/-/stopwords-0.0.9.tgz#d2ceb30c778b3b12896d476a0ca5e9946141b5e3" + integrity sha512-2OF7/9f5buXrBB8De5ZAC9+g5nIw9SRx9ZsL3Qpz6znSfPEihOmaMnwJxXehynZ9AfRNroHIKEIcsEmRywVXVQ== + stream-browserify@^2.0.1: version "2.0.2" resolved "https://registry.yarnpkg.com/stream-browserify/-/stream-browserify-2.0.2.tgz#87521d38a44aa7ee91ce1cd2a47df0cb49dd660b"