diff --git a/modules/citation/cff.go b/modules/citation/cff.go new file mode 100644 index 00000000000..3074a50da3d --- /dev/null +++ b/modules/citation/cff.go @@ -0,0 +1,468 @@ +// Copyright 2026 The Gitea Authors. +// Copyright 2018-2024 The Ruby Citation File Format Developers. Licensed under the Apache License, Version 2.0 +// SPDX-License-Identifier: Apache-2.0 + +// Package citation formats CITATION.cff files, ported from the formatters of ruby-cff 1.3.0 +package citation + +import ( + "cmp" + "maps" + "regexp" + "slices" + "strconv" + "strings" + "time" + "unicode" + "unicode/utf8" + + "gitea.dev/modules/util" + + "go.yaml.in/yaml/v4" + "golang.org/x/text/runes" + "golang.org/x/text/transform" + "golang.org/x/text/unicode/norm" +) + +type date struct{ time.Time } + +var dateSeparators = strings.NewReplacer("/", "-", ". ", " ", ".", "-", ",", "") + +func (d *date) UnmarshalYAML(node *yaml.Node) error { + value := dateSeparators.Replace(node.Value) + for _, layout := range []string{"2006-1-2", "2-1-2006", "2 Jan 2006", "2 January 2006", "Jan 2 2006", "January 2 2006"} { + if parsed, err := time.Parse(layout, value); err == nil { + d.Time = parsed + break + } + } + return nil +} + +type license string + +func (l *license) UnmarshalYAML(node *yaml.Node) error { + *l = license(node.Value) + if node.Kind != yaml.ScalarNode { + *l = license(inspectNode(node)) + } + return nil +} + +type actor struct { + Name string `yaml:"name"` + Alias string `yaml:"alias"` + FamilyNames string `yaml:"family-names"` + GivenNames string `yaml:"given-names"` + NameParticle string `yaml:"name-particle"` + NameSuffix string `yaml:"name-suffix"` + Affiliation string `yaml:"affiliation"` + City string `yaml:"city"` + Region string `yaml:"region"` + Country string `yaml:"country"` + DateStart date `yaml:"date-start"` + DateEnd date `yaml:"date-end"` +} + +func (a *actor) UnmarshalYAML(node *yaml.Node) error { + switch node.Kind { + case yaml.ScalarNode: + a.Name = node.Value + return nil + case yaml.SequenceNode: + return nil + } + type plainActor actor + return node.Load((*plainActor)(a), yaml.WithUniqueKeys(false)) +} + +type metadata struct { + Type string `yaml:"type"` + Title string `yaml:"title"` + Authors []actor `yaml:"authors"` + Version string `yaml:"version"` + DOI string `yaml:"doi"` + URL string `yaml:"url"` + RepositoryCode string `yaml:"repository-code"` + License license `yaml:"license"` + DateReleased date `yaml:"date-released"` +} + +type reference struct { + metadata `yaml:",inline"` + isTopLevel bool + Editors []actor `yaml:"editors"` + EditorsSeries []actor `yaml:"editors-series"` + DatePublished date `yaml:"date-published"` + Year string `yaml:"year"` + Month string `yaml:"month"` + Status string `yaml:"status"` + Journal string `yaml:"journal"` + Volume string `yaml:"volume"` + Issue string `yaml:"issue"` + Start string `yaml:"start"` + End string `yaml:"end"` + ISBN string `yaml:"isbn"` + Notes string `yaml:"notes"` + CollectionTitle string `yaml:"collection-title"` + ThesisType string `yaml:"thesis-type"` + Publisher actor `yaml:"publisher"` + Institution *actor `yaml:"institution"` + Conference actor `yaml:"conference"` +} + +// FormatCFF returns the APA and BibTeX citations of a CITATION.cff file, both empty if it has no title or authors +func FormatCFF(content string) (apa, bibtex string) { + var node yaml.Node + if yaml.Unmarshal([]byte(content), &node) != nil { + return "", "" + } + retagTimestamps(&node) + var file struct { + TopLevel metadata `yaml:",inline"` + PreferredCitation *reference `yaml:"preferred-citation"` + } + if node.Load(&file, yaml.WithUniqueKeys(false)) != nil { + return "", "" + } + ref := file.PreferredCitation + if ref == nil { + ref = &reference{metadata: file.TopLevel, isTopLevel: true} + } + if ref.Title == "" || len(ref.Authors) == 0 { + return "", "" + } + return ref.formatAPA(), ref.formatBibTeX() +} + +func retagTimestamps(node *yaml.Node) { + if node.ShortTag() == "!!timestamp" { + node.Tag = "!!str" + } + for _, child := range node.Content { + retagTimestamps(child) + } +} + +func inspectNode(node *yaml.Node) string { + var parts []string + switch node.Kind { + case yaml.AliasNode: + if node.Alias.Kind == yaml.ScalarNode { // collection aliases can be recursive + return inspectNode(node.Alias) + } + case yaml.SequenceNode: + for _, child := range node.Content { + parts = append(parts, inspectNode(child)) + } + return "[" + strings.Join(parts, ", ") + "]" + case yaml.MappingNode: + for i := 0; i < len(node.Content); i += 2 { + parts = append(parts, inspectNode(node.Content[i])+" => "+inspectNode(node.Content[i+1])) + } + return "{" + strings.Join(parts, ", ") + "}" + } + if node.ShortTag() == "!!null" { + return "nil" + } + return strconv.Quote(node.Value) +} + +var statusNotes = map[string]string{ + "advance-online": "Advance online publication", + "in-preparation": "Manuscript in preparation.", + "submitted": "Manuscript submitted for publication.", +} + +func joinNonEmpty(sep string, parts ...string) string { + return strings.Join(util.SliceRemoveAll(parts, ""), sep) +} + +func (r *reference) conferenceDates() (start, end date) { + if r.Type == "conference-paper" { + return r.Conference.DateStart, r.Conference.DateEnd + } + return date{}, date{} +} + +func (r *reference) monthAndYear() (month, year string) { + when, _ := r.conferenceDates() + if when.IsZero() { + if r.Status == "in-press" { + return "", "in press" + } + if r.Year != "" { + return r.Month, r.Year + } + when = cmp.Or(r.DateReleased, r.DatePublished) + } + if when.IsZero() { + return "", "" + } + return strconv.Itoa(int(when.Month())), strconv.Itoa(when.Year()) +} + +func (r *reference) pages(dash string) string { + if r.Start == "" || r.End == "" || r.Start == r.End { + return r.Start + } + return r.Start + dash + r.End +} + +func (r *reference) volume() string { + if r.Volume == "" || r.Issue == "" { + return r.Volume + } + return r.Volume + "(" + r.Issue + ")" +} + +func (r *reference) institution() string { + if r.Institution == nil { + return r.Authors[0].Affiliation + } + return r.Institution.Name +} + +func (r *reference) formatAPA() string { + authors := make([]string, 0, len(r.Authors)) + for _, author := range r.Authors { + authors = append(authors, apaAuthor(author)) + } + date := r.apaDate() + if date != "" { + date = "(" + date + ")" + } + version := "" + if r.Version != "" { + version = " (Version " + r.Version + ")" + } + url := cmp.Or(r.RepositoryCode, r.URL) + if r.DOI != "" { + url = "https://doi.org/" + r.DOI + } + return joinNonEmpty(". ", combineAuthors(authors), date, r.Title+version+r.apaTypeLabel(), r.apaPublicationData(), url) +} + +func apaAuthor(author actor) string { + if author.Name != "" { + return author.Name + } + name := cmp.Or(author.FamilyNames, author.GivenNames, author.Alias) + if author.FamilyNames != "" && author.GivenNames != "" { + name += ", " + initials(author.GivenNames) + "." + } + if author.NameParticle != "" { + name = author.NameParticle + " " + name + } + if author.NameSuffix != "" { + name += ", " + author.NameSuffix + } + return name +} + +func initials(names string) string { + parts := splitWords(names) + for i, part := range parts { + first, _ := utf8.DecodeRuneInString(part) + parts[i] = util.ToTitleCase(string(first)) + } + return strings.Join(parts, ". ") +} + +func splitWords(text string) []string { + return strings.FieldsFunc(text, func(char rune) bool { return char <= unicode.MaxASCII && unicode.IsSpace(char) }) +} + +func combineAuthors(authors []string) string { + if len(authors) == 1 { + return strings.TrimSuffix(authors[0], ".") + } + return strings.TrimSuffix(strings.Join(authors[:len(authors)-1], ", ")+", & "+authors[len(authors)-1], ".") +} + +func (r *reference) apaDate() string { + start, end := r.conferenceDates() + if start.IsZero() || end.IsZero() || !start.Before(end.Time) { + _, year := r.monthAndYear() + return year + } + endLayout := "2" + if end.Month() != start.Month() { + endLayout = "January 2" + } + if end.Year() != start.Year() { + endLayout = "2006, " + endLayout + } + return start.Format("2006, January 2") + "–" + end.Format(endLayout) +} + +func (r *reference) apaTypeLabel() string { + switch { + case strings.Contains(r.Type, "data"): + return " [Data set]" + case strings.Contains(r.Type, "conference"): + return " [Conference paper]" + case !r.isTopLevel && !strings.Contains(r.Type, "software"): + return "" + } + return " [Computer software]" +} + +func (r *reference) apaPublicationData() string { + switch r.Type { + case "article": + return joinNonEmpty(", ", r.Journal, r.volume(), r.pages("–"), statusNotes[r.Status]) + case "book": + return r.Publisher.Name + case "conference-paper": + return joinNonEmpty(", ", r.CollectionTitle, r.volume(), r.pages("–")) + case "report": + return r.institution() + case "phdthesis": + return "[" + cmp.Or(r.ThesisType, "Doctoral dissertation") + ", " + r.institution() + "]" + case "mastersthesis": + return "[" + cmp.Or(r.ThesisType, "Master's thesis") + ", " + r.institution() + "]" + case "unpublished": + return statusNotes[r.Status] + } + return "" +} + +var bibtexTypeFields = map[string][]string{ + "article": {"journal", "note", "number", "pages", "volume"}, + "book": {"address", "editor", "isbn", "number", "pages", "publisher", "volume"}, + "booklet": {"address"}, + "inproceedings": {"address", "booktitle", "editor", "pages", "publisher", "series"}, + "manual": {"address"}, + "mastersthesis": {"address", "school", "type"}, + "misc": {"pages"}, + "phdthesis": {"address", "school", "type"}, + "proceedings": {"address", "booktitle", "editor", "pages", "publisher", "series"}, + "software": {"license", "version"}, + "techreport": {"address", "institution", "number"}, + "unpublished": {"note"}, +} + +var ( + bibtexEscaper = strings.NewReplacer("&", `\&`, "%", `\%`, "$", `\$`, "#", `\#`, "_", `\_`, "{", `\{`, "}", `\}`) + keyLetters = strings.NewReplacer( + "Æ", "AE", "æ", "ae", "Ð", "D", "ð", "d", "Ø", "O", "ø", "o", "Þ", "Th", "þ", "th", "ß", "ss", "×", "x", + "Đ", "D", "đ", "d", "Ħ", "H", "ħ", "h", "ı", "i", "IJ", "IJ", "ij", "ij", "ĸ", "k", "Ŀ", "L", "ŀ", "l", + "Ł", "L", "ł", "l", "ʼn", "'n", "Ŋ", "NG", "ŋ", "ng", "Œ", "OE", "œ", "oe", "Ŧ", "T", "ŧ", "t", + ) + keyUnsafeChars = regexp.MustCompile(`[^a-zA-Z0-9-]+`) +) + +func keyToASCII() transform.Transformer { + return transform.Chain( + runes.Remove(runes.Predicate(func(char rune) bool { + return char > unicode.MaxASCII && (char < 'À' || char > 'ž') && char != 'ệ' + })), + norm.NFD, + runes.Remove(runes.Predicate(func(char rune) bool { return char > unicode.MaxASCII })), + ) +} + +func (r *reference) formatBibTeX() string { + place := r.Publisher + if r.Type == "conference-paper" { + place = r.Conference + } + editors := r.Editors + if len(editors) == 0 { + editors = r.EditorsSeries + } + typeFields := map[string]string{ + "address": joinNonEmpty(", ", place.City, place.Region, place.Country), + "booktitle": bibtexEscaper.Replace(r.CollectionTitle), + "editor": bibtexActors(editors), + "institution": bibtexEscaper.Replace(r.institution()), + "isbn": bibtexEscaper.Replace(r.ISBN), + "journal": bibtexEscaper.Replace(r.Journal), + "license": bibtexEscaper.Replace(string(r.License)), + "note": statusNotes[r.Status], + "number": r.Issue, + "pages": r.pages("--"), + "publisher": bibtexEscaper.Replace(r.Publisher.Name), + "school": bibtexEscaper.Replace(r.institution()), + "series": bibtexEscaper.Replace(r.Conference.Name), + "type": r.ThesisType, + "version": bibtexEscaper.Replace(r.Version), + "volume": bibtexEscaper.Replace(r.Volume), + } + entryType := bibtexType(r.Type) + fields := map[string]string{ + "author": bibtexActors(r.Authors), + "title": "{" + bibtexEscaper.Replace(r.Title) + "}", + "doi": bibtexEscaper.Replace(r.DOI), + } + for _, name := range bibtexTypeFields[entryType] { + fields[name] = typeFields[name] + } + month, year := r.monthAndYear() + if num, _ := strconv.Atoi(month); num >= 1 && num <= 12 { + fields["month"] = strings.ToLower(time.Month(num).String()[:3]) + } + fields["year"] = year + fields["url"] = cmp.Or(r.RepositoryCode, r.URL) + fields["note"] = cmp.Or(fields["note"], r.Notes) + maps.DeleteFunc(fields, func(_, value string) bool { return value == "" }) + + lines := []string{bibtexKey(fields)} + for _, name := range slices.Sorted(maps.Keys(fields)) { + value := fields[name] + if name != "month" { + value = "{" + value + "}" + } + lines = append(lines, name+" = "+value) + } + return "@" + entryType + "{" + strings.Join(lines, ",\n") + "\n}" +} + +func bibtexType(cffType string) string { + if cffType == "" || strings.Contains(cffType, "software") { + return "software" + } + switch cffType { + case "article", "book", "manual", "unpublished", "phdthesis", "mastersthesis": + return cffType + case "conference", "proceedings": + return "proceedings" + case "conference-paper": + return "inproceedings" + case "magazine-article", "newspaper-article": + return "article" + case "pamphlet": + return "booklet" + case "report": + return "techreport" + } + return "misc" +} + +func bibtexActors(actors []actor) string { + names := make([]string, 0, len(actors)) + for _, entry := range actors { + switch { + case entry.Name != "": + names = append(names, "{"+bibtexEscaper.Replace(entry.Name)+"}") + case entry.FamilyNames == "" && entry.GivenNames == "": + names = append(names, bibtexEscaper.Replace(entry.Alias)) + default: + family := entry.FamilyNames + if entry.NameParticle != "" { + family = entry.NameParticle + " " + family + } + names = append(names, joinNonEmpty(", ", family, entry.NameSuffix, entry.GivenNames)) + } + } + return strings.Join(names, " and ") +} + +func bibtexKey(fields map[string]string) string { + author, _, _ := strings.Cut(fields["author"], ",") + titleWords := splitWords(fields["title"]) + key := joinNonEmpty("_", author, strings.Join(titleWords[:min(3, len(titleWords))], "_"), fields["year"]) + key, _, _ = transform.String(keyToASCII(), keyLetters.Replace(key)) + return strings.Trim(keyUnsafeChars.ReplaceAllString(key, "_"), "_") +} diff --git a/modules/citation/cff_test.go b/modules/citation/cff_test.go new file mode 100644 index 00000000000..077736a2554 --- /dev/null +++ b/modules/citation/cff_test.go @@ -0,0 +1,137 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package citation + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestFormatCFF(t *testing.T) { + cases := []struct{ cff, apa, bibtex string }{ + { + cff: `title: Overridden +message: &mit MIT +authors: [{family-names: Haines, given-names: Robert}, {name: "The {curly_braces} Collective"}] +title: "Software that uses the following symbols: &, %, $, #" +version: 2024-01-16 +license: [*mit, Apache-2.0] +date-released: 2024-01-16 +`, + apa: "Haines, R., & The {curly_braces} Collective. (2024). Software that uses the following symbols: &, %, $, # (Version 2024-01-16) [Computer software]", + bibtex: `@software{Haines_Software_that_uses_2024, +author = {Haines, Robert and {The \{curly\_braces\} Collective}}, +license = {["MIT", "Apache-2.0"]}, +month = jan, +title = {{Software that uses the following symbols: \&, \%, \$, \#}}, +version = {2024-01-16}, +year = {2024} +}`, + }, + { + cff: `authors: + - family-names: Smith + given-names: Arfon M. +title: "Software citation principles" +license: MIT +preferred-citation: + authors: + - family-names: Smith + given-names: A. M. + - name: "FORCE11 Software Citation Working Group" + doi: "10.7717/peerj-cs.86" + journal: "PeerJ Computer Science" + month: 9 + start: e86 + title: "Software citation principles" + type: article + volume: 2 + issue: 123 + year: 2016 + publisher: + - name: The Open Journal +`, + apa: "Smith, A. M., & FORCE11 Software Citation Working Group. (2016). Software citation principles. PeerJ Computer Science, 2(123), e86. https://doi.org/10.7717/peerj-cs.86", + bibtex: `@article{Smith_Software_citation_principles_2016, +author = {Smith, A. M. and {FORCE11 Software Citation Working Group}}, +doi = {10.7717/peerj-cs.86}, +journal = {PeerJ Computer Science}, +month = sep, +number = {123}, +pages = {e86}, +title = {{Software citation principles}}, +volume = {2}, +year = {2016} +}`, + }, + { + cff: `preferred-citation: + type: conference-paper + version: 1.10 + title: "Über tools" + authors: + - family-names: Ørsted + given-names: ǰan christian + name-particle: van + name-suffix: Jr. + collection-title: "Proceedings of X & Y" + conference: + name: "Conf_2020" + city: Berlin + country: DE + date-start: 30.06.2020 + date-end: 2020-07-02 + start: 10 + end: 20 + editors: + - affiliation: Press + editors-series: + - family-names: Editor + given-names: Eve +`, + apa: "van Ørsted, J̌. C., Jr. (2020, June 30–July 2). Über tools (Version 1.10) [Conference paper]. Proceedings of X & Y, 10–20", + bibtex: `@inproceedings{van_Orsted_Uber_tools_2020, +address = {Berlin, DE}, +author = {van Ørsted, Jr., ǰan christian}, +booktitle = {Proceedings of X \& Y}, +month = jun, +pages = {10--20}, +series = {Conf\_2020}, +title = {{Über tools}}, +year = {2020} +}`, + }, + { + cff: `thesis: &thesis {type: phdthesis, title: Thesis} +preferred-citation: + <<: *thesis + authors: + - family-names: Nguyễn + given-names: Sam + affiliation: "Uni_A" + institution: {city: Hanoi} + license: &loop [*loop] + status: in-press + notes: A note +`, + apa: "Nguyễn, S. (in press). Thesis. [Doctoral dissertation, ]", + bibtex: `@phdthesis{Nguyn_Thesis_in_press, +author = {Nguyễn, Sam}, +note = {A note}, +title = {{Thesis}}, +year = {in press} +}`, + }, + {cff: "title: No authors\nauthor:\n - name: Typo\n"}, + } + for _, tc := range cases { + t.Run("", func(t *testing.T) { + t.Parallel() + apa, bibtex := FormatCFF(tc.cff) + assert.Equal(t, tc.apa, apa) + assert.Equal(t, tc.bibtex, bibtex) + }) + } +} diff --git a/package.json b/package.json index ee4a0416e24..ea46af56331 100644 --- a/package.json +++ b/package.json @@ -6,10 +6,6 @@ "pnpm": ">= 11.0.0" }, "dependencies": { - "@citation-js/core": "0.7.21", - "@citation-js/plugin-bibtex": "0.7.21", - "@citation-js/plugin-csl": "0.7.22", - "@citation-js/plugin-software-formats": "0.6.2", "@codemirror/autocomplete": "6.20.3", "@codemirror/commands": "6.11.1", "@codemirror/lang-json": "6.0.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2021be74443..cc407c6051b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -166,18 +166,6 @@ importers: .: dependencies: - '@citation-js/core': - specifier: 0.7.21 - version: 0.7.21 - '@citation-js/plugin-bibtex': - specifier: 0.7.21 - version: 0.7.21(@citation-js/core@0.7.21) - '@citation-js/plugin-csl': - specifier: 0.7.22 - version: 0.7.22(@citation-js/core@0.7.21) - '@citation-js/plugin-software-formats': - specifier: 0.6.2 - version: 0.6.2 '@codemirror/autocomplete': specifier: 6.20.3 version: 6.20.3 @@ -535,54 +523,6 @@ packages: '@chevrotain/utils@11.1.2': resolution: {integrity: sha512-4mudFAQ6H+MqBTfqLmU7G1ZwRzCLfJEooL/fsF6rCX5eePMbGhoy5n4g+G4vlh2muDcsCTJtL+uKbOzWxs5LHA==} - '@citation-js/core@0.7.21': - resolution: {integrity: sha512-Vobv2/Yfnn6C6BVO/pvj7madQ7Mfzl83/jAWwixbemGF6ZThhGMz8++FD9hWHyHXDMYuLGa6fK68c2VsolZmTA==} - engines: {node: '>=16.0.0'} - - '@citation-js/date@0.5.1': - resolution: {integrity: sha512-1iDKAZ4ie48PVhovsOXQ+C6o55dWJloXqtznnnKy6CltJBQLIuLLuUqa8zlIvma0ZigjVjgDUhnVaNU1MErtZw==} - engines: {node: '>=10.0.0'} - - '@citation-js/name@0.4.2': - resolution: {integrity: sha512-brSPsjs2fOVzSnARLKu0qncn6suWjHVQtrqSUrnqyaRH95r/Ad4wPF5EsoWr+Dx8HzkCGb/ogmoAzfCsqlTwTQ==} - engines: {node: '>=6'} - - '@citation-js/plugin-bibtex@0.7.21': - resolution: {integrity: sha512-O008pSsJgiYKn4+7gAWrbNpNdUH++aMeYmZaJ2oFQ8X1tcY5jNBxJcr0zZojNtUi5CVOaXXHQ0yIifoUhuF2Vg==} - engines: {node: '>=16.0.0'} - peerDependencies: - '@citation-js/core': ^0.7.0 - - '@citation-js/plugin-cff@0.6.2': - resolution: {integrity: sha512-jvERDFbtQQOBb9s+E8VbRIYsEIb2YEbcLH3yVDxXK0xqBGQDE5m8JZAYUkENy4FmbaD979l0+xJTWAsYN1pV/w==} - engines: {node: '>=14.0.0'} - - '@citation-js/plugin-csl@0.7.22': - resolution: {integrity: sha512-/rGdtbeP3nS4uZDdEbQUHT8PrUcIs0da2t+sWMKYXoOhXQqfw3oJJ7p4tUD+R8lptyIR5Eq20/DFk/kQDdLpYg==} - engines: {node: '>=16.0.0'} - peerDependencies: - '@citation-js/core': ^0.7.0 - - '@citation-js/plugin-github@0.6.2': - resolution: {integrity: sha512-NKq/1Ja060o4II1Z4p1+utwpvMsx+XIWdNiFvnJDfR2Z9E1xGETjByPpdobGBsteUTpJPEe9OVfF8Dee/Q7zLQ==} - engines: {node: '>=14.0.0'} - - '@citation-js/plugin-npm@0.6.2': - resolution: {integrity: sha512-mbQg/N9HM+gOqHJCdDZEElSW+h/oM94snKCl3llXuZ4MEH3tHraElS6CYRW/vW7s8KUTTHhgE62Q6ua5aRml8g==} - engines: {node: '>=14.0.0'} - - '@citation-js/plugin-software-formats@0.6.2': - resolution: {integrity: sha512-x1IG0LBKglBU6SuiiKfvOtn7g7o7s+YhQhB44o7zrFaKEO8jkyQ5qMKtM5VFdCBL7teLfzZLjpjNkdJXtZ6XZw==} - engines: {node: '>=14.0.0'} - - '@citation-js/plugin-yaml@0.6.2': - resolution: {integrity: sha512-qw53Uy2fDekKAzNhv8pkAWpIccIxyKQ3nQuClMgzDPdyeWg34ElIs4bDub9ZZup15fy+X//2gP8k12RJqNo4lA==} - engines: {node: '>=14.0.0'} - - '@citation-js/plugin-zenodo@0.6.2': - resolution: {integrity: sha512-3XQOO3u4WXY/7AWZyQ+9SuBzS8bYTlJ+NF1uCgrZO64g36nK5iIc5YV9cBl2TL2QhHF6S36nvAsXsj5fX9FeHw==} - engines: {node: '>=14.0.0'} - '@codemirror/autocomplete@6.20.3': resolution: {integrity: sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==} @@ -1958,9 +1898,6 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} - base64-js@1.5.1: - resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - baseline-browser-mapping@2.11.21: resolution: {integrity: sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ==} engines: {node: '>=6.0.0'} @@ -1993,9 +1930,6 @@ packages: resolution: {integrity: sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==} engines: {node: '>=4.0'} - buffer@5.7.1: - resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} - builtin-modules@5.2.0: resolution: {integrity: sha512-02yxLeyxF4dNl6SlY6/5HfRSrSdZ/sCPoxy2kZNP5dZZX8LSAD9aE2gtJIUgWrsQTiMPl3mxESyrobSwvRGisQ==} engines: {node: '>=18.20'} @@ -2073,9 +2007,6 @@ packages: resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} engines: {node: '>=8'} - citeproc@2.4.63: - resolution: {integrity: sha512-68F95Bp4UbgZU/DBUGQn0qV3HDZLCdI9+Bb2ByrTaNJDL5VEm9LqaiNaxljsvoaExSLEXe1/r6n2Z06SCzW3/Q==} - clippie@4.2.1: resolution: {integrity: sha512-+Shk4WLaaBtxTwgaspGnFm/WrtjZCVCKsl66mPglNRj6B+OzgIYnp4LX7EjW1UTG5NfJsW60jxwV8EvqtyrDUA==} @@ -2789,9 +2720,6 @@ packages: picomatch: optional: true - fetch-ponyfill@7.1.0: - resolution: {integrity: sha512-FhbbL55dj/qdVO3YNK7ZEkshvj3eQ7EuIGV2I6ic/2YiocvyWv+7jg2s4AyS0wdRU75s3tA8ZxI/xPigb0v5Aw==} - fflate@0.8.2: resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} @@ -2985,9 +2913,6 @@ packages: idiomorph@0.8.0: resolution: {integrity: sha512-R47JBL+6jHDQvcnbppIUtXXcxi5h6+wuCEEZXnRhUltsgzPFKzThL5PuHppP9ypJuy+7WawcY1TsSBD/iQbMhw==} - ieee754@1.2.1: - resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} - ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -3565,9 +3490,6 @@ packages: minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} - moo@0.5.3: - resolution: {integrity: sha512-m2fmM2dDm7GZQsY7KK2cme8agi+AAljILjQnof7p1ZMDe6dQ4bdnSMx0cPppudoeNv5hEFQirN6u+O4fDE0IWA==} - mrmime@2.0.1: resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} engines: {node: '>=10'} @@ -3601,24 +3523,6 @@ packages: resolution: {integrity: sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==} engines: {node: '>= 0.4'} - node-fetch@2.6.13: - resolution: {integrity: sha512-StxNAxh15zr77QvvkmveSQ8uCQ4+v5FkvNTj0OESmiHu+VRi/gXArXtkWMElOsOUNLtUEvI4yS+rdtOHZTwlQA==} - engines: {node: 4.x || >=6.0.0} - peerDependencies: - encoding: ^0.1.0 - peerDependenciesMeta: - encoding: - optional: true - - node-fetch@2.7.0: - resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} - engines: {node: 4.x || >=6.0.0} - peerDependencies: - encoding: ^0.1.0 - peerDependenciesMeta: - encoding: - optional: true - node-releases@2.0.54: resolution: {integrity: sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==} engines: {node: '>=18'} @@ -4236,10 +4140,6 @@ packages: symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} - sync-fetch@0.4.5: - resolution: {integrity: sha512-esiWJ7ixSKGpd9DJPBTC4ckChqdOjIwJfYhVHkcQ2Gnm41323p1TRmEI+esTQ9ppD+b5opps2OTEGTCGX5kF+g==} - engines: {node: '>=14'} - table@6.9.0: resolution: {integrity: sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==} engines: {node: '>=10.0.0'} @@ -4293,9 +4193,6 @@ packages: resolution: {integrity: sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==} engines: {node: '>=6'} - tr46@0.0.3: - resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} - tr46@3.0.0: resolution: {integrity: sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==} engines: {node: '>=12'} @@ -4523,9 +4420,6 @@ packages: resolution: {integrity: sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==} engines: {node: '>=14'} - webidl-conversions@3.0.1: - resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} - webidl-conversions@7.0.0: resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} engines: {node: '>=12'} @@ -4543,9 +4437,6 @@ packages: resolution: {integrity: sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==} engines: {node: '>=12'} - whatwg-url@5.0.0: - resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} - which-boxed-primitive@1.1.1: resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} engines: {node: '>= 0.4'} @@ -4679,64 +4570,6 @@ snapshots: '@chevrotain/utils@11.1.2': {} - '@citation-js/core@0.7.21': - dependencies: - '@citation-js/date': 0.5.1 - '@citation-js/name': 0.4.2 - fetch-ponyfill: 7.1.0 - sync-fetch: 0.4.5 - transitivePeerDependencies: - - encoding - - '@citation-js/date@0.5.1': {} - - '@citation-js/name@0.4.2': {} - - '@citation-js/plugin-bibtex@0.7.21(@citation-js/core@0.7.21)': - dependencies: - '@citation-js/core': 0.7.21 - '@citation-js/date': 0.5.1 - '@citation-js/name': 0.4.2 - moo: 0.5.3 - - '@citation-js/plugin-cff@0.6.2': - dependencies: - '@citation-js/date': 0.5.1 - '@citation-js/plugin-yaml': 0.6.2 - - '@citation-js/plugin-csl@0.7.22(@citation-js/core@0.7.21)': - dependencies: - '@citation-js/core': 0.7.21 - '@citation-js/date': 0.5.1 - citeproc: 2.4.63 - - '@citation-js/plugin-github@0.6.2': - dependencies: - '@citation-js/date': 0.5.1 - '@citation-js/name': 0.4.2 - - '@citation-js/plugin-npm@0.6.2': - dependencies: - '@citation-js/date': 0.5.1 - '@citation-js/name': 0.4.2 - - '@citation-js/plugin-software-formats@0.6.2': - dependencies: - '@citation-js/plugin-cff': 0.6.2 - '@citation-js/plugin-github': 0.6.2 - '@citation-js/plugin-npm': 0.6.2 - '@citation-js/plugin-yaml': 0.6.2 - '@citation-js/plugin-zenodo': 0.6.2 - - '@citation-js/plugin-yaml@0.6.2': - dependencies: - js-yaml: 4.2.0 - - '@citation-js/plugin-zenodo@0.6.2': - dependencies: - '@citation-js/date': 0.5.1 - '@citation-js/name': 0.4.2 - '@codemirror/autocomplete@6.20.3': dependencies: '@codemirror/language': 6.12.4 @@ -6309,8 +6142,6 @@ snapshots: balanced-match@4.0.4: {} - base64-js@1.5.1: {} - baseline-browser-mapping@2.11.21: {} binary-extensions@2.3.0: {} @@ -6344,11 +6175,6 @@ snapshots: '@types/node': 26.6.2 optional: true - buffer@5.7.1: - dependencies: - base64-js: 1.5.1 - ieee754: 1.2.1 - builtin-modules@5.2.0: {} cacheable@2.5.0: @@ -6434,8 +6260,6 @@ snapshots: ci-info@4.4.0: {} - citeproc@2.4.63: {} - clippie@4.2.1: {} codemirror-lang-elixir@4.0.1: @@ -7285,12 +7109,6 @@ snapshots: optionalDependencies: picomatch: 4.0.7 - fetch-ponyfill@7.1.0: - dependencies: - node-fetch: 2.6.13 - transitivePeerDependencies: - - encoding - fflate@0.8.2: {} fflate@0.8.3: {} @@ -7507,8 +7325,6 @@ snapshots: idiomorph@0.8.0: {} - ieee754@1.2.1: {} - ignore@5.3.2: {} ignore@7.0.6: {} @@ -8200,8 +8016,6 @@ snapshots: minimist@1.2.8: {} - moo@0.5.3: {} - mrmime@2.0.1: {} ms@2.1.3: {} @@ -8230,14 +8044,6 @@ snapshots: semver: 6.3.1 optional: true - node-fetch@2.6.13: - dependencies: - whatwg-url: 5.0.0 - - node-fetch@2.7.0: - dependencies: - whatwg-url: 5.0.0 - node-releases@2.0.54: {} normalize-path@3.0.0: {} @@ -8926,13 +8732,6 @@ snapshots: symbol-tree@3.2.4: {} - sync-fetch@0.4.5: - dependencies: - buffer: 5.7.1 - node-fetch: 2.7.0 - transitivePeerDependencies: - - encoding - table@6.9.0: dependencies: ajv: 8.20.0 @@ -9009,8 +8808,6 @@ snapshots: universalify: 0.2.0 url-parse: 1.5.10 - tr46@0.0.3: {} - tr46@3.0.0: dependencies: punycode: 2.3.1 @@ -9231,8 +9028,6 @@ snapshots: dependencies: xml-name-validator: 4.0.0 - webidl-conversions@3.0.1: {} - webidl-conversions@7.0.0: {} whatwg-encoding@2.0.0: @@ -9246,11 +9041,6 @@ snapshots: tr46: 3.0.0 webidl-conversions: 7.0.0 - whatwg-url@5.0.0: - dependencies: - tr46: 0.0.3 - webidl-conversions: 3.0.1 - which-boxed-primitive@1.1.1: dependencies: is-bigint: 1.1.0 diff --git a/renovate.json5 b/renovate.json5 index ba9a258c82e..12a89b7a2b2 100644 --- a/renovate.json5 +++ b/renovate.json5 @@ -78,10 +78,6 @@ "matchPackageNames": ["tailwindcss"], "allowedVersions": "^3", // need to migrate }, - { - "matchPackageNames": ["@citation-js/core", "@citation-js/plugin-bibtex", "@citation-js/plugin-csl"], - "allowedVersions": "<0.8", // https://github.com/citation-js/citation-js/pull/277 - }, { "matchPackageNames": ["typescript"], "allowedVersions": "^6", // typescript-eslint is not yet compatible with typescript 7 diff --git a/routers/web/repo/view_home.go b/routers/web/repo/view_home.go index e829520ce62..25a1ac07db5 100644 --- a/routers/web/repo/view_home.go +++ b/routers/web/repo/view_home.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "net/http" + "slices" "strconv" "strings" "time" @@ -17,9 +18,11 @@ import ( repo_model "gitea.dev/models/repo" unit_model "gitea.dev/models/unit" user_model "gitea.dev/models/user" + "gitea.dev/modules/citation" "gitea.dev/modules/git" "gitea.dev/modules/htmlutil" "gitea.dev/modules/httplib" + "gitea.dev/modules/lfs" "gitea.dev/modules/log" repo_module "gitea.dev/modules/repository" "gitea.dev/modules/setting" @@ -93,32 +96,43 @@ func prepareClonePanel(ctx *context.Context) { } } -func prepareHomeSidebarCitationFile(entry *git.TreeEntry) func(ctx *context.Context) { - return func(ctx *context.Context) { - if entry.Name() != "" { - return +func prepareHomeSidebarCitationFile(ctx *context.Context) { + allEntries, err := ctx.Repo.Commit.Tree().ListEntries(ctx, ctx.Repo.GitRepo) + if err != nil { + ctx.ServerError("ListEntries", err) + return + } + isBlob := func(entry *git.TreeEntry) bool { return !entry.IsDir() && !entry.IsSubModule() } + for _, name := range []string{"CITATION.cff", "CITATION.bib"} { + idx := slices.IndexFunc(allEntries, func(entry *git.TreeEntry) bool { return isBlob(entry) && strings.EqualFold(entry.Name(), name) }) + if idx == -1 { + continue } - tree, err := ctx.Repo.Commit.SubTree(ctx, ctx.Repo.GitRepo, ctx.Repo.TreePath) - if err != nil { - HandleGitError(ctx, "Repo.Commit.SubTree", err) - return - } - allEntries, err := tree.ListEntries(ctx, ctx.Repo.GitRepo) - if err != nil { - ctx.ServerError("ListEntries", err) - return - } - for _, entry := range allEntries { - if entry.Name() == "CITATION.cff" || entry.Name() == "CITATION.bib" { - // Read Citation file contents - if content, err := entry.Blob(ctx.Repo.GitRepo).GetBlobContent(ctx, setting.UI.MaxDisplayFileSize); err != nil { - log.Error("checkCitationFile: GetBlobContent: %v", err) - } else { - ctx.Data["CitiationExist"] = true - ctx.PageData["citationFileContent"] = content - break - } + entry := allEntries[idx] + if entry.IsLink() { + res, err := git.EntryFollowLinks(ctx, ctx.Repo.GitRepo, ctx.Repo.Commit, entry.Name(), entry) + if err != nil || !isBlob(res.TargetEntry) { + continue } + entry = res.TargetEntry + } + content, err := entry.Blob(ctx.Repo.GitRepo).GetBlobContent(ctx, setting.UI.MaxDisplayFileSize) + if err != nil { + log.Error("prepareHomeSidebarCitationFile: GetBlobContent: %v", err) + continue + } + if pointer, _ := lfs.ReadPointerFromBuffer([]byte(content)); pointer.IsValid() { + continue + } + apa, bibtex := "", content + if name == "CITATION.cff" { + apa, bibtex = citation.FormatCFF(content) + } + if bibtex != "" { + ctx.Data["CitationFileName"] = allEntries[idx].Name() + ctx.Data["CitationAPA"] = apa + ctx.Data["CitationBibTeX"] = bibtex + return } } } @@ -470,7 +484,7 @@ func Home(ctx *context.Context) { checkOutdatedBranch, prepareUpstreamDivergingInfo, prepareHomeSidebarLicenses, - prepareHomeSidebarCitationFile(entry), + prepareHomeSidebarCitationFile, prepareHomeSidebarLanguageStats, prepareHomeSidebarLatestRelease, ) diff --git a/templates/repo/cite/cite_buttons.tmpl b/templates/repo/cite/cite_buttons.tmpl deleted file mode 100644 index b80fc47e05a..00000000000 --- a/templates/repo/cite/cite_buttons.tmpl +++ /dev/null @@ -1,11 +0,0 @@ - - - - - diff --git a/templates/repo/cite/cite_modal.tmpl b/templates/repo/cite/cite_modal.tmpl index 59da3b5ae54..4496b33cc70 100644 --- a/templates/repo/cite/cite_modal.tmpl +++ b/templates/repo/cite/cite_modal.tmpl @@ -4,9 +4,20 @@