diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e5c86709aa..ad8305ca503 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Note that this project **does not** adhere to [Semantic Versioning](https://semv ### Added +- We added a custom exporter for academicpages and added the layout format for academic pages. [#12727](https://github.com/JabRef/jabref/issues/12727) - We added a drop-down menu to those custom fields in the main table for which content selector values exists. [#14087](https://github.com/JabRef/jabref/issues/14087) - We added a "Jump to Field" dialog (`Ctrl+J`) to quickly search for and navigate to any field across all tabs. [#12276](https://github.com/JabRef/jabref/issues/12276). - We added "IEEE" as another option for parsing plain text citations. [#14233](github.com/JabRef/jabref/pull/14233) diff --git a/jablib/src/main/java/org/jabref/logic/exporter/AcademicPagesExporter.java b/jablib/src/main/java/org/jabref/logic/exporter/AcademicPagesExporter.java new file mode 100644 index 00000000000..8163143bde5 --- /dev/null +++ b/jablib/src/main/java/org/jabref/logic/exporter/AcademicPagesExporter.java @@ -0,0 +1,108 @@ +package org.jabref.logic.exporter; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +import org.jabref.logic.journals.JournalAbbreviationLoader; +import org.jabref.logic.journals.JournalAbbreviationRepository; +import org.jabref.logic.layout.LayoutFormatterPreferences; +import org.jabref.logic.layout.format.HTMLChars; +import org.jabref.logic.layout.format.RemoveLatexCommandsFormatter; +import org.jabref.logic.layout.format.Replace; +import org.jabref.logic.layout.format.SafeFileName; +import org.jabref.logic.util.StandardFileType; +import org.jabref.logic.util.io.FileUtil; +import org.jabref.model.database.BibDatabaseContext; +import org.jabref.model.entry.BibEntry; +import org.jabref.model.metadata.SelfContainedSaveOrder; + +import org.jspecify.annotations.NonNull; + +/** + * A custom exporter to write multiple bib entries as AcademicPages Markdown format. + */ +public class AcademicPagesExporter extends Exporter { + private final String layoutFileFileName; + private final String directory; + private final LayoutFormatterPreferences layoutPreferences; + private final SelfContainedSaveOrder saveOrder; + + private final Replace replaceFormatter = new Replace(); + private final RemoveLatexCommandsFormatter commandsFormatter = new RemoveLatexCommandsFormatter(); + private final HTMLChars htmlFormatter = new HTMLChars(); + private final SafeFileName safeFormatter = new SafeFileName(); + + private TemplateExporter academicPagesTemplate; + + /** + * Initialize another export format based on templates stored in directory. + * + */ + public AcademicPagesExporter(LayoutFormatterPreferences layoutPreferences, SelfContainedSaveOrder saveOrder) { + super("academicpages", "academic pages markdowns", StandardFileType.MARKDOWN); + this.layoutFileFileName = "academicpages"; + this.directory = "academicpages"; + this.layoutPreferences = layoutPreferences; + this.saveOrder = saveOrder; + String consoleName = "academicpages"; + this.academicPagesTemplate = new TemplateExporter("academicpages", consoleName, layoutFileFileName, directory, StandardFileType.MARKDOWN, layoutPreferences, saveOrder); + } + + @Override + public void export(@NonNull final BibDatabaseContext databaseContext, + @NonNull final Path exportDirectory, + @NonNull List entries) throws SaveException { + export(databaseContext, exportDirectory, entries, List.of(), JournalAbbreviationLoader.loadBuiltInRepository()); + } + + /** + * The method that performs the export of all entries by iterating on the entries. + * + * @param databaseContext the database to export from + * @param file the directory to write to + * @param entries a list containing all entries that should be exported + * @param abbreviationRepository the built-in repository + * @throws SaveException Exception thrown if saving goes wrong + */ + @Override + public void export(@NonNull final BibDatabaseContext databaseContext, + @NonNull final Path file, + @NonNull List entries, + List fileDirForDataBase, + JournalAbbreviationRepository abbreviationRepository) throws SaveException { + if (entries.isEmpty()) { // Only export if entries exist + return; + } + try { + // convert what the ExportCommand gives as a file parameter to a directory + Path baseDir = file; + String exportDirectoryString = FileUtil.getBaseName(file); + Path exportDirectory = baseDir.getParent().resolve(exportDirectoryString); + + // Ensure the directory exists. This is important: AtomicFileWriter will fail if parent dirs are missing. + Files.createDirectories(exportDirectory); + + for (BibEntry entry : entries) { + // formatting the title of each entry to match the file names format demanded by academic pages (applying the same formatters applied to the title in the academicpages.layout) + Path path = getPath(entry, exportDirectory); + + List individualEntry = new ArrayList<>(); + individualEntry.add(entry); + academicPagesTemplate.export(databaseContext, path, individualEntry, fileDirForDataBase, abbreviationRepository); + } + } catch (IOException e) { + throw new SaveException("could not export", e); + } + } + + private @NonNull Path getPath(BibEntry entry, Path exportDirectory) { + replaceFormatter.setArgument(" ,-"); // expects an expression that has the character to remove and the replacement character separated by a comma. + String title = entry.getTitle().get(); + String formattedTitle = commandsFormatter.format(htmlFormatter.format(replaceFormatter.format(title))); + String safeTitle = safeFormatter.format(formattedTitle); + return exportDirectory.resolve(safeTitle + ".md"); + } +} diff --git a/jablib/src/main/java/org/jabref/logic/exporter/ExporterFactory.java b/jablib/src/main/java/org/jabref/logic/exporter/ExporterFactory.java index b9125600c9e..820a3d063e1 100644 --- a/jablib/src/main/java/org/jabref/logic/exporter/ExporterFactory.java +++ b/jablib/src/main/java/org/jabref/logic/exporter/ExporterFactory.java @@ -63,6 +63,7 @@ public static ExporterFactory create(CliPreferences preferences) { exporters.add(new EmbeddedBibFilePdfExporter(bibDatabaseMode, preferences.getCustomEntryTypesRepository(), fieldPreferences)); exporters.add(new CffExporter()); exporters.add(new EndnoteXmlExporter(preferences.getBibEntryPreferences())); + exporters.add(new AcademicPagesExporter(layoutPreferences, saveOrder)); // Now add custom export formats exporters.addAll(customFormats); diff --git a/jablib/src/main/java/org/jabref/logic/layout/LayoutEntry.java b/jablib/src/main/java/org/jabref/logic/layout/LayoutEntry.java index e2df268311d..03ce96ed7d6 100644 --- a/jablib/src/main/java/org/jabref/logic/layout/LayoutEntry.java +++ b/jablib/src/main/java/org/jabref/logic/layout/LayoutEntry.java @@ -66,6 +66,7 @@ import org.jabref.logic.layout.format.NonSpaceWhitespaceRemover; import org.jabref.logic.layout.format.NotFoundFormatter; import org.jabref.logic.layout.format.Number; +import org.jabref.logic.layout.format.NumberMonthFormatter; import org.jabref.logic.layout.format.Ordinal; import org.jabref.logic.layout.format.RTFChars; import org.jabref.logic.layout.format.RemoveBrackets; @@ -77,6 +78,7 @@ import org.jabref.logic.layout.format.RisAuthors; import org.jabref.logic.layout.format.RisKeywords; import org.jabref.logic.layout.format.RisMonth; +import org.jabref.logic.layout.format.SafeFileName; import org.jabref.logic.layout.format.ShortMonthFormatter; import org.jabref.logic.layout.format.ToLowerCase; import org.jabref.logic.layout.format.ToUpperCase; @@ -565,6 +567,10 @@ private LayoutFormatter getLayoutFormatterByName(String name) { new ReplaceWithEscapedDoubleQuotes(); case "HayagrivaType" -> new HayagrivaType(); + case "NumberMonth" -> + new NumberMonthFormatter(); + case "SafeFileName" -> + new SafeFileName(); default -> null; }; diff --git a/jablib/src/main/java/org/jabref/logic/layout/format/NumberMonthFormatter.java b/jablib/src/main/java/org/jabref/logic/layout/format/NumberMonthFormatter.java new file mode 100644 index 00000000000..da2aae5254e --- /dev/null +++ b/jablib/src/main/java/org/jabref/logic/layout/format/NumberMonthFormatter.java @@ -0,0 +1,15 @@ +package org.jabref.logic.layout.format; + +import org.jabref.logic.layout.LayoutFormatter; +import org.jabref.model.entry.Month; + +/** + * Convert the month name into the corresponding number and return 01 by default + */ +public class NumberMonthFormatter implements LayoutFormatter { + + @Override + public String format(String fieldText) { + return Month.parse(fieldText).map(Month::getTwoDigitNumber).orElse("01"); + } +} diff --git a/jablib/src/main/java/org/jabref/logic/layout/format/SafeFileName.java b/jablib/src/main/java/org/jabref/logic/layout/format/SafeFileName.java new file mode 100644 index 00000000000..081b65e47c4 --- /dev/null +++ b/jablib/src/main/java/org/jabref/logic/layout/format/SafeFileName.java @@ -0,0 +1,14 @@ +package org.jabref.logic.layout.format; + +import org.jabref.logic.layout.LayoutFormatter; + +/** + * Remove all the characters that are not allowed by the OS in file names + */ +public class SafeFileName implements LayoutFormatter { + + @Override + public String format(String fieldText) { + return fieldText.replaceAll("[\\\\/:*?\"<>|]", ""); + } +} diff --git a/jablib/src/main/resources/resource/layout/academicpages/academicpages.layout b/jablib/src/main/resources/resource/layout/academicpages/academicpages.layout new file mode 100644 index 00000000000..8cb46cc21de --- /dev/null +++ b/jablib/src/main/resources/resource/layout/academicpages/academicpages.layout @@ -0,0 +1,14 @@ +--- +title: "\format[RemoveLatexCommands,HTMLChars]{\title}" +collection: publications +category: \format{\entrytype} +permalink: /publication/\format[RemoveLatexCommands,HTMLChars,Replace(\s,-),SafeFileName]{\title} +excerpt: '\begin{note}\format[RemoveLatexCommands,HTMLChars]{\note}\end{note}' +date: \format{\year}-\begin{month}\format[NumberMonth]{\month}\end{month}\begin{!month}01\end{!month}-\begin{day}\format{\day}\end{day}\begin{!day}01\end{!day} +venue: '\format[RemoveLatexCommands,HTMLChars]{\journal}\begin{!journal}Unknown\end{!journal}' +slidesurl: '\begin{file}\format[FileLink(pdf)]{\file}\end{file}\begin{!file}https://[insert username].github.io/files/[insert filename].pdf\end{!file}' +paperurl: '\begin{file}\format[FileLink(pdf)]{\file}\end{file}\begin{!file}https://[insert username].github.io/files/[insert filename].pdf\end{!file}' +bibtexurl: 'https://[insert username].github.io/files/[insert filename].bib' +citation: '\format[HTMLChars]{\author}. (\format{\year}). ""\format[RemoveLatexCommands,HTMLChars]{\title}." \format[RemoveLatexCommands,HTMLChars]{\journal}.' +--- +\begin{abstract}\format{\abstract}\end{abstract} diff --git a/jablib/src/test/java/org/jabref/logic/exporter/AcademicPagesExporterTest.java b/jablib/src/test/java/org/jabref/logic/exporter/AcademicPagesExporterTest.java new file mode 100644 index 00000000000..968ecd1b283 --- /dev/null +++ b/jablib/src/test/java/org/jabref/logic/exporter/AcademicPagesExporterTest.java @@ -0,0 +1,298 @@ +package org.jabref.logic.exporter; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import org.jabref.logic.layout.LayoutFormatterPreferences; +import org.jabref.model.database.BibDatabaseContext; +import org.jabref.model.entry.BibEntry; +import org.jabref.model.entry.field.StandardField; +import org.jabref.model.entry.types.StandardEntryType; +import org.jabref.model.metadata.SaveOrder; +import org.jabref.model.metadata.SelfContainedSaveOrder; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.Answers; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; + +class AcademicPagesExporterTest { + private AcademicPagesExporter exporter; + private BibDatabaseContext databaseContext; + + @BeforeEach + void setUp() { + exporter = new AcademicPagesExporter(mock(LayoutFormatterPreferences.class, Answers.RETURNS_DEEP_STUBS), new SelfContainedSaveOrder(SaveOrder.OrderType.SPECIFIED, List.of())); + databaseContext = new BibDatabaseContext(); + } + + @Test + void exportArticleWithFullDateAndRequiredFieldsGeneratesCorrectFileNameAndContent(@TempDir Path tempDir) throws Exception { + BibEntry entry = new BibEntry(StandardEntryType.Article).withCitationKey("testKey") + .withField(StandardField.TITLE, "Test Title") + .withField(StandardField.AUTHOR, "Test Author") + .withField(StandardField.YEAR, "2023") + .withField(StandardField.MONTH, "05") + .withField(StandardField.DAY, "12") + .withField(StandardField.JOURNAL, "Test Journal"); + + exporter.export(databaseContext, tempDir, List.of(entry)); + + // Verify file name follows pattern: title.md (SafeFileName) + Path expectedFile = tempDir.resolve("Test-Title.md"); + assertTrue(Files.exists(expectedFile)); + + List expected = List.of("---", + "title: \"Test Title\"", + "collection: publications", + "category: Article", + "permalink: /publication/Test-Title", + "excerpt: ''", + "date: 2023-05-12", + "venue: 'Test Journal'", + "slidesurl: 'https://[insert username].github.io/files/[insert filename].pdf'", + "paperurl: 'https://[insert username].github.io/files/[insert filename].pdf'", + "bibtexurl: 'https://[insert username].github.io/files/[insert filename].bib'", + "citation: 'Test Author. (2023). \""Test Title." Test Journal.'", + "---", + ""); + assertEquals(expected, Files.readAllLines(expectedFile)); + } + + @Test + void exportArticleWithMissingMonthAndDayDefaultsToJanuaryFirst(@TempDir Path tempDir) throws Exception { + BibEntry entry = new BibEntry(StandardEntryType.Article).withCitationKey("testKey") + .withField(StandardField.TITLE, "No Date") + .withField(StandardField.YEAR, "2023"); + exporter.export(databaseContext, tempDir, List.of(entry)); + + // Expect default date 2023-01-01 + Path expectedFile = tempDir.resolve("No-Date.md"); + assertTrue(Files.exists(expectedFile)); + + List expected = List.of("---", + "title: \"No Date\"", + "collection: publications", + "category: Article", + "permalink: /publication/No-Date", + "excerpt: ''", + "date: 2023-01-01", + "venue: 'Unknown'", + "slidesurl: 'https://[insert username].github.io/files/[insert filename].pdf'", + "paperurl: 'https://[insert username].github.io/files/[insert filename].pdf'", + "bibtexurl: 'https://[insert username].github.io/files/[insert filename].bib'", + "citation: '. (2023). \""No Date." .'", + "---", + ""); + assertEquals(expected, Files.readAllLines(expectedFile)); + } + + @Test + void exportArticleWithAbstractAppendsAbstractAfterYamlFrontMatter(@TempDir Path tempDir) throws Exception { + BibEntry entry = new BibEntry(StandardEntryType.Article).withCitationKey("testKey") + .withField(StandardField.TITLE, "Abstract Paper") + .withField(StandardField.YEAR, "2023") + .withField(StandardField.ABSTRACT, "This is a test abstract."); + exporter.export(databaseContext, tempDir, List.of(entry)); + + Path expectedFile = tempDir.resolve("Abstract-Paper.md"); + assertTrue(Files.exists(expectedFile)); + + List expected = List.of("---", + "title: \"Abstract Paper\"", + "collection: publications", + "category: Article", + "permalink: /publication/Abstract-Paper", + "excerpt: ''", + "date: 2023-01-01", + "venue: 'Unknown'", + "slidesurl: 'https://[insert username].github.io/files/[insert filename].pdf'", + "paperurl: 'https://[insert username].github.io/files/[insert filename].pdf'", + "bibtexurl: 'https://[insert username].github.io/files/[insert filename].bib'", + "citation: '. (2023). \""Abstract Paper." .'", + "---", + "This is a test abstract."); + + assertEquals(expected, Files.readAllLines(expectedFile)); + } + + @Test + void exportMultipleEntriesGeneratesMultipleIndividualMarkdownFiles(@TempDir Path tempDir) throws Exception { + BibEntry entry1 = new BibEntry(StandardEntryType.Article).withCitationKey("key1") + .withField(StandardField.TITLE, "Paper One") + .withField(StandardField.YEAR, "2023"); + + BibEntry entry2 = new BibEntry(StandardEntryType.Book).withCitationKey("key2") + .withField(StandardField.TITLE, "Book Two") + .withField(StandardField.YEAR, "2022"); + + exporter.export(databaseContext, tempDir, List.of(entry1, entry2)); + + // Verify both files exist + Path file1 = tempDir.resolve("Paper-One.md"); + Path file2 = tempDir.resolve("Book-Two.md"); + assertTrue(Files.exists(file1)); + assertTrue(Files.exists(file2)); + + List expected1 = List.of("---", + "title: \"Paper One\"", + "collection: publications", + "category: Article", + "permalink: /publication/Paper-One", + "excerpt: ''", + "date: 2023-01-01", + "venue: 'Unknown'", + "slidesurl: 'https://[insert username].github.io/files/[insert filename].pdf'", + "paperurl: 'https://[insert username].github.io/files/[insert filename].pdf'", + "bibtexurl: 'https://[insert username].github.io/files/[insert filename].bib'", + "citation: '. (2023). \""Paper One." .'", + "---", + ""); + assertEquals(expected1, Files.readAllLines(file1)); + + List expected2 = List.of("---", + "title: \"Book Two\"", + "collection: publications", + "category: Book", + "permalink: /publication/Book-Two", + "excerpt: ''", + "date: 2022-01-01", + "venue: 'Unknown'", + "slidesurl: 'https://[insert username].github.io/files/[insert filename].pdf'", + "paperurl: 'https://[insert username].github.io/files/[insert filename].pdf'", + "bibtexurl: 'https://[insert username].github.io/files/[insert filename].bib'", + "citation: '. (2022). \""Book Two." .'", + "---", + ""); + assertEquals(expected2, Files.readAllLines(file2)); + } + + @Test + void exportInProceedingsWithBooktitleUsesBooktitleAsVenueAlias(@TempDir Path tempDir) throws Exception { + BibEntry entry = new BibEntry(StandardEntryType.InProceedings).withCitationKey("testKey") + .withField(StandardField.TITLE, "Conference Paper") + .withField(StandardField.YEAR, "2023") + .withField(StandardField.BOOKTITLE, "Conference Proceedings"); + + exporter.export(databaseContext, tempDir, List.of(entry)); + + Path expectedFile = tempDir.resolve("Conference-Paper.md"); + assertTrue(Files.exists(expectedFile)); + + List expected = List.of("---", + "title: \"Conference Paper\"", + "collection: publications", + "category: InProceedings", + "permalink: /publication/Conference-Paper", + "excerpt: ''", + "date: 2023-01-01", + "venue: 'Unknown'", + "slidesurl: 'https://[insert username].github.io/files/[insert filename].pdf'", + "paperurl: 'https://[insert username].github.io/files/[insert filename].pdf'", + "bibtexurl: 'https://[insert username].github.io/files/[insert filename].bib'", + "citation: '. (2023). \""Conference Paper." .'", + "---", + ""); + assertEquals(expected, Files.readAllLines(expectedFile)); + } + + @Test + void exportArticleWithSpecialCharactersInTitleGeneratesSafeFileName(@TempDir Path tempDir) throws Exception { + BibEntry entry = new BibEntry(StandardEntryType.Article).withCitationKey("testKey") + .withField(StandardField.TITLE, "test title \\/:*?\"<>|") + .withField(StandardField.YEAR, "2024"); + + exporter.export(databaseContext, tempDir, List.of(entry)); + + // Resulting file name should be safe: test-title-.md + Path expectedFile = tempDir.resolve("test-title-.md"); + assertTrue(Files.exists(expectedFile)); + + List expected = List.of("---", + "title: \"test title /:*?\"<>|\"", + "collection: publications", + "category: Article", + "permalink: /publication/test-title-", + "excerpt: ''", + "date: 2024-01-01", + "venue: 'Unknown'", + "slidesurl: 'https://[insert username].github.io/files/[insert filename].pdf'", + "paperurl: 'https://[insert username].github.io/files/[insert filename].pdf'", + "bibtexurl: 'https://[insert username].github.io/files/[insert filename].bib'", + "citation: '. (2024). \""test title /:*?\"<>|." .'", + "---", + ""); + assertEquals(expected, Files.readAllLines(expectedFile)); + } + + @Test + void exportArticleWithFileFieldGeneratesSlidesAndPaperUrls(@TempDir Path tempDir) throws Exception { + BibEntry entry = new BibEntry(StandardEntryType.Article).withCitationKey("testKey") + .withField(StandardField.TITLE, "test title") + .withField(StandardField.YEAR, "2024") + .withField(StandardField.FILE, ":arxiv.pdf:PDF"); + exporter.export(databaseContext, tempDir, List.of(entry)); + + Path expectedFile = tempDir.resolve("test-title.md"); + assertTrue(Files.exists(expectedFile)); + + List expected = List.of("---", + "title: \"test title\"", + "collection: publications", + "category: Article", + "permalink: /publication/test-title", + "excerpt: ''", + "date: 2024-01-01", + "venue: 'Unknown'", + "slidesurl: 'arxiv.pdf'", + "paperurl: 'arxiv.pdf'", + "bibtexurl: 'https://[insert username].github.io/files/[insert filename].bib'", + "citation: '. (2024). \""test title." .'", + "---", + ""); + assertEquals(expected, Files.readAllLines(expectedFile)); + } + + @Test + void exportArticleWithNoTitleThrowsSaveException(@TempDir Path tempDir) { + BibEntry entry = new BibEntry(StandardEntryType.Article).withCitationKey("testKey"); + assertThrows(SaveException.class, () -> exporter.export(databaseContext, tempDir, List.of(entry))); + } + + @Test + void exportArticleWithSpacesInTitleReplacesSpacesWithDashesInPermalink(@TempDir Path tempDir) throws Exception { + BibEntry entry = new BibEntry(StandardEntryType.Article).withCitationKey("testKey") + .withField(StandardField.TITLE, "test title") + .withField(StandardField.YEAR, "2024") + .withField(StandardField.MONTH, "01") + .withField(StandardField.DAY, "01"); + exporter.export(databaseContext, tempDir, List.of(entry)); + + // Resulting file name should replace spaces with dashes + Path expectedFile = tempDir.resolve("test---title.md"); + assertTrue(Files.exists(expectedFile)); + + List expected = List.of("---", + "title: \"test title\"", + "collection: publications", + "category: Article", + "permalink: /publication/test---title", + "excerpt: ''", + "date: 2024-01-01", + "venue: 'Unknown'", + "slidesurl: 'https://[insert username].github.io/files/[insert filename].pdf'", + "paperurl: 'https://[insert username].github.io/files/[insert filename].pdf'", + "bibtexurl: 'https://[insert username].github.io/files/[insert filename].bib'", + "citation: '. (2024). \""test title." .'", + "---", + ""); + + assertEquals(expected, Files.readAllLines(expectedFile)); + } +}