| @@ -0,0 +1,423 @@ |
| |
1 | import Foundation |
| |
2 | |
| |
3 | /// Citations as the `basic` processor (oc-basic.el, Org 9.8.7) exports them: author-year, |
| |
4 | /// author, noauthor, text, nocite and numeric styles with the bare and caps variants, from |
| |
5 | /// the BibTeX or CSL-JSON files `#+bibliography:` names, and the bibliography |
| |
6 | /// `#+print_bibliography:` asks for. Note styles, and `#+cite_export:` processors other than |
| |
7 | /// `basic`, leave citations as they are written. |
| |
8 | struct CiteProcessor { |
| |
9 | /// What a citation or bibliography entry exports as: text, Org markup (prefixes and |
| |
10 | /// suffixes) and an italic title. |
| |
11 | enum Piece: Equatable { |
| |
12 | case text(String) |
| |
13 | case org(String) |
| |
14 | case italic(String) |
| |
15 | } |
| |
16 | |
| |
17 | struct Reference { |
| |
18 | var prefix: String? |
| |
19 | var key: String |
| |
20 | var suffix: String? |
| |
21 | } |
| |
22 | |
| |
23 | struct Citation { |
| |
24 | var text: String |
| |
25 | var style: String? |
| |
26 | var variant: String? |
| |
27 | var prefix: String? |
| |
28 | var suffix: String? |
| |
29 | var references: [Reference] |
| |
30 | } |
| |
31 | |
| |
32 | typealias Entry = [String: String] |
| |
33 | |
| |
34 | /// Each citation's export by its text, in document order. |
| |
35 | private var outputs: [String: [[Piece]]] = [:] |
| |
36 | private(set) var bibliography: [[Piece]] = [] |
| |
37 | |
| |
38 | // MARK: - Reading |
| |
39 | |
| |
40 | static let keyPattern = "@([-.:?!`'/*@+|(){}<>&_^$#%~\\p{L}\\p{N}_]+)" |
| |
41 | |
| |
42 | /// `org-element-citation-parser`: style, the common prefix and suffix, and references. |
| |
43 | static func parse(_ text: String) -> Citation? { |
| |
44 | guard let m = text.firstMatch(of: #/^\[cite(?:/([^:/\]]*)(?:/([^:\]]*))?)?:(.*)\]$/#.dotMatchesNewlines()) else { return nil } |
| |
45 | let body = String(m.3) |
| |
46 | var parts = body.components(separatedBy: ";") |
| |
47 | let key = try! NSRegularExpression(pattern: keyPattern) |
| |
48 | func hasKey(_ s: String) -> Bool { key.firstMatch(in: s, range: NSRange(location: 0, length: (s as NSString).length)) != nil } |
| |
49 | guard let first = parts.firstIndex(where: hasKey), let last = parts.lastIndex(where: hasKey) else { return nil } |
| |
50 | let prefix = first > 0 ? parts[..<first].joined(separator: ";") : nil |
| |
51 | let suffix = last < parts.count - 1 ? parts[(last + 1)...].joined(separator: ";") : nil |
| |
52 | parts = Array(parts[first...last]) |
| |
53 | var references: [Reference] = [] |
| |
54 | for part in parts { |
| |
55 | let ns = part as NSString |
| |
56 | guard let k = key.firstMatch(in: part, range: NSRange(location: 0, length: ns.length)) else { continue } |
| |
57 | let before = ns.substring(to: k.range.location) |
| |
58 | let after = ns.substring(from: NSMaxRange(k.range)) |
| |
59 | references.append(Reference(prefix: before.isEmpty ? nil : before, key: ns.substring(with: k.range(at: 1)), suffix: after.isEmpty ? nil : after)) |
| |
60 | } |
| |
61 | func nonEmpty(_ s: String?) -> String? { s.flatMap { $0.isEmpty ? nil : $0 } } |
| |
62 | return Citation(text: text, style: m.1.map(String.init).flatMap { $0.isEmpty ? nil : $0 }, variant: m.2.map(String.init), |
| |
63 | prefix: nonEmpty(prefix), suffix: nonEmpty(suffix), references: references) |
| |
64 | } |
| |
65 | |
| |
66 | /// `#+cite_export:`: the processor, bibliography style and citation style. |
| |
67 | static func exportSettings(_ text: String) -> (processor: String, bibStyle: String?, citeStyle: String?)? { |
| |
68 | guard let line = text.matches(of: #/(?mi)^[ \t]*#\+cite_export:[ \t]*(.*?)[ \t]*$/#).last else { return nil } |
| |
69 | let words = line.1.split(whereSeparator: \.isWhitespace).map(String.init) |
| |
70 | guard let processor = words.first else { return nil } |
| |
71 | return (processor, words.count > 1 ? words[1] : nil, words.count > 2 ? words[2] : nil) |
| |
72 | } |
| |
73 | |
| |
74 | static func isNocite(_ citation: Citation, defaultStyle: String?) -> Bool { |
| |
75 | let style = citation.style ?? defaultStyle.map { String($0.split(separator: "/").first ?? "") } |
| |
76 | return style == "nocite" || style == "n" |
| |
77 | } |
| |
78 | |
| |
79 | /// The text with nocite citations removed, and the blanks before them, as |
| |
80 | /// `org-cite--set-previous-post-blank` leaves it. |
| |
81 | static func removingNocite(_ text: String) -> String { |
| |
82 | guard text.range(of: "[cite", options: .caseInsensitive) != nil else { return text } |
| |
83 | let defaultStyle = exportSettings(text)?.citeStyle |
| |
84 | let regex = try! NSRegularExpression(pattern: "[ \\t]*(\\[cite[/:][^\\]]*\\])") |
| |
85 | var out = text |
| |
86 | for m in regex.matches(in: text, range: NSRange(location: 0, length: (text as NSString).length)).reversed() { |
| |
87 | let ns = out as NSString |
| |
88 | guard let citation = parse(ns.substring(with: m.range(at: 1))), isNocite(citation, defaultStyle: defaultStyle) else { continue } |
| |
89 | // Blanks before a citation that starts its line are indentation. |
| |
90 | let lineStart = ns.lineRange(for: NSRange(location: m.range.location, length: 0)).location |
| |
91 | let blanksOnly = ns.substring(with: NSRange(location: lineStart, length: m.range(at: 1).location - lineStart)).allSatisfy { $0 == " " || $0 == "\t" } |
| |
92 | out = ns.replacingCharacters(in: blanksOnly ? m.range(at: 1) : m.range, with: "") |
| |
93 | } |
| |
94 | return out |
| |
95 | } |
| |
96 | |
| |
97 | // MARK: - Bibliographies |
| |
98 | |
| |
99 | /// `org-cite-basic--parse-bibtex`: entries with lower-case field names, `id` and `type`, |
| |
100 | /// values without delimiters, `@string`s expanded, blanks collapsed. |
| |
101 | static func parseBibTeX(_ text: String) -> [String: Entry] { |
| |
102 | var entries: [String: Entry] = [:] |
| |
103 | var strings: [String: String] = [:] |
| |
104 | let chars = Array(text.unicodeScalars) |
| |
105 | var i = 0 |
| |
106 | func skipSpace() { while i < chars.count, chars[i].properties.isWhitespace { i += 1 } } |
| |
107 | func word() -> String { |
| |
108 | var out = "" |
| |
109 | while i < chars.count, !chars[i].properties.isWhitespace, !"{}(),=#\"".unicodeScalars.contains(chars[i]) { |
| |
110 | out.unicodeScalars.append(chars[i]) |
| |
111 | i += 1 |
| |
112 | } |
| |
113 | return out |
| |
114 | } |
| |
115 | func braced() -> String { |
| |
116 | var depth = 0 |
| |
117 | var out = "" |
| |
118 | while i < chars.count { |
| |
119 | let c = chars[i] |
| |
120 | i += 1 |
| |
121 | if c == "{" { |
| |
122 | depth += 1 |
| |
123 | if depth == 1 { continue } |
| |
124 | } else if c == "}" { |
| |
125 | depth -= 1 |
| |
126 | if depth == 0 { break } |
| |
127 | } |
| |
128 | out.unicodeScalars.append(c) |
| |
129 | } |
| |
130 | return out |
| |
131 | } |
| |
132 | func quoted() -> String { |
| |
133 | var depth = 0 |
| |
134 | var out = "" |
| |
135 | i += 1 |
| |
136 | while i < chars.count { |
| |
137 | let c = chars[i] |
| |
138 | i += 1 |
| |
139 | if c == "{" { depth += 1 } else if c == "}" { depth -= 1 } else if c == "\"", depth == 0 { break } |
| |
140 | out.unicodeScalars.append(c) |
| |
141 | } |
| |
142 | return out |
| |
143 | } |
| |
144 | func value() -> String { |
| |
145 | var parts: [String] = [] |
| |
146 | while true { |
| |
147 | skipSpace() |
| |
148 | guard i < chars.count else { break } |
| |
149 | if chars[i] == "{" { |
| |
150 | parts.append(braced()) |
| |
151 | } else if chars[i] == "\"" { |
| |
152 | parts.append(quoted()) |
| |
153 | } else { |
| |
154 | let w = word() |
| |
155 | parts.append(strings[w.lowercased()] ?? w) |
| |
156 | } |
| |
157 | skipSpace() |
| |
158 | if i < chars.count, chars[i] == "#" { i += 1 } else { break } |
| |
159 | } |
| |
160 | return parts.joined().replacingOccurrences(of: "[ \\t\\n]+", with: " ", options: .regularExpression) |
| |
161 | } |
| |
162 | while i < chars.count { |
| |
163 | guard chars[i] == "@" else { i += 1; continue } |
| |
164 | i += 1 |
| |
165 | let type = word().lowercased() |
| |
166 | skipSpace() |
| |
167 | guard i < chars.count, chars[i] == "{" || chars[i] == "(" else { continue } |
| |
168 | let close: Unicode.Scalar = chars[i] == "{" ? "}" : ")" |
| |
169 | i += 1 |
| |
170 | if type == "comment" || type == "preamble" { |
| |
171 | var depth = 1 |
| |
172 | while i < chars.count, depth > 0 { |
| |
173 | if chars[i] == "{" || chars[i] == "(" { depth += 1 } else if chars[i] == "}" || chars[i] == ")" { depth -= 1 } |
| |
174 | i += 1 |
| |
175 | } |
| |
176 | continue |
| |
177 | } |
| |
178 | if type == "string" { |
| |
179 | skipSpace() |
| |
180 | let name = word().lowercased() |
| |
181 | skipSpace() |
| |
182 | if i < chars.count, chars[i] == "=" { i += 1 } |
| |
183 | strings[name] = value() |
| |
184 | skipSpace() |
| |
185 | if i < chars.count, chars[i] == close { i += 1 } |
| |
186 | continue |
| |
187 | } |
| |
188 | skipSpace() |
| |
189 | var key = "" |
| |
190 | while i < chars.count, chars[i] != ",", chars[i] != close, !chars[i].properties.isWhitespace { |
| |
191 | key.unicodeScalars.append(chars[i]) |
| |
192 | i += 1 |
| |
193 | } |
| |
194 | var entry: Entry = ["id": key, "type": type] |
| |
195 | while i < chars.count { |
| |
196 | skipSpace() |
| |
197 | if i < chars.count, chars[i] == "," { i += 1; skipSpace() } |
| |
198 | guard i < chars.count, chars[i] != close else { i += 1; break } |
| |
199 | let field = word().lowercased() |
| |
200 | skipSpace() |
| |
201 | guard !field.isEmpty, i < chars.count, chars[i] == "=" else { i += 1; continue } |
| |
202 | i += 1 |
| |
203 | entry[field] = value() |
| |
204 | } |
| |
205 | if entries[key] == nil { entries[key] = entry } |
| |
206 | } |
| |
207 | return entries |
| |
208 | } |
| |
209 | |
| |
210 | /// `org-cite-basic--parse-json`, for CSL-JSON: authors as "family given" joined by "and", |
| |
211 | /// and the year from `issued`. |
| |
212 | static func parseJSON(_ data: Data) -> [String: Entry] { |
| |
213 | guard let items = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else { return [:] } |
| |
214 | var entries: [String: Entry] = [:] |
| |
215 | for item in items { |
| |
216 | guard let id = item["id"] as? String else { continue } |
| |
217 | var entry: Entry = [:] |
| |
218 | for (field, value) in item { |
| |
219 | switch field { |
| |
220 | case "author", "editor": |
| |
221 | let people = (value as? [[String: Any]]) ?? [] |
| |
222 | entry[field] = people.map { ($0["family"] as? String ?? "") + " " + ($0["given"] as? String ?? "") }.joined(separator: " and ") |
| |
223 | case "issued": |
| |
224 | let issued = value as? [String: Any] ?? [:] |
| |
225 | if let parts = issued["date-parts"] as? [[Any]], let year = parts.first?.first { |
| |
226 | entry["year"] = (year as? Int).map(String.init) ?? year as? String |
| |
227 | } else if let raw = (issued["literal"] ?? issued["raw"]) as? String, let m = raw.firstMatch(of: /[0-9]{4}/) { |
| |
228 | entry["year"] = String(m.0) |
| |
229 | } |
| |
230 | default: |
| |
231 | if let s = value as? String { entry[field] = s } else if let n = value as? NSNumber { entry[field] = n.stringValue } |
| |
232 | } |
| |
233 | } |
| |
234 | entries[id] = entry |
| |
235 | } |
| |
236 | return entries |
| |
237 | } |
| |
238 | |
| |
239 | // MARK: - Formatting |
| |
240 | |
| |
241 | private var entries: [String: Entry] = [:] |
| |
242 | private var yearCache: [(author: String?, year: String?, keys: [(key: String, suffix: String)])] = [] |
| |
243 | |
| |
244 | func author(_ key: String) -> String? { entries[key].flatMap { $0["author"] ?? $0["editor"] } } |
| |
245 | |
| |
246 | /// `org-cite-basic--get-year`: the year, with a letter telling apart works by the same |
| |
247 | /// authors in the same year. |
| |
248 | mutating func year(_ key: String, noSuffix: Bool = false) -> String? { |
| |
249 | let entry = entries[key] |
| |
250 | var year = entry?["year"] |
| |
251 | if year == nil, let date = entry?["date"], let m = date.firstMatch(of: /^([0-9]{4})(?:[^0-9]|$)/) { year = String(m.1) } |
| |
252 | let author = self.author(key) |
| |
253 | guard let index = yearCache.firstIndex(where: { $0.author == author && $0.year == year }) else { |
| |
254 | yearCache.insert((author, year, [(key, "")]), at: 0) |
| |
255 | return year |
| |
256 | } |
| |
257 | let suffix: String |
| |
258 | if let known = yearCache[index].keys.first(where: { $0.key == key }) { |
| |
259 | suffix = known.suffix |
| |
260 | } else { |
| |
261 | suffix = Self.suffix(yearCache[index].keys.count - 1) |
| |
262 | yearCache[index].keys.insert((key, suffix), at: 0) |
| |
263 | } |
| |
264 | return noSuffix ? year : (year ?? "") + suffix |
| |
265 | } |
| |
266 | |
| |
267 | /// `org-cite-basic--number-to-suffix`. |
| |
268 | static func suffix(_ number: Int) -> String { |
| |
269 | var n = number |
| |
270 | var result: [Int] = [] |
| |
271 | while true { |
| |
272 | result.insert(n % 26, at: 0) |
| |
273 | n /= 26 |
| |
274 | if n == 0 { break } |
| |
275 | if n < 27 { result.insert(n - 1, at: 0); break } |
| |
276 | if n == 27 { result.insert(contentsOf: [0, 0], at: 0); break } |
| |
277 | } |
| |
278 | return String(String.UnicodeScalarView(result.compactMap { Unicode.Scalar(97 + $0) })) |
| |
279 | } |
| |
280 | |
| |
281 | /// `org-cite-basic--field-less-p` with the author: `org-string<` ignoring case. |
| |
282 | func authorLess(_ a: String, _ b: String) -> Bool { |
| |
283 | (author(a) ?? "nil").lowercased() < (author(b) ?? "nil").lowercased() |
| |
284 | } |
| |
285 | |
| |
286 | /// `org-cite-basic-export-citation`. Nil for a style it doesn't do. |
| |
287 | mutating func format(_ citation: Citation, keys: [String], defaultStyle: String?) -> [Piece]? { |
| |
288 | let defaults = defaultStyle.map { $0.split(separator: "/", omittingEmptySubsequences: false).map(String.init) } ?? [] |
| |
289 | let style = citation.style ?? defaults.first |
| |
290 | let variant = citation.variant.flatMap { $0.isEmpty ? nil : $0 } ?? (citation.style == nil && defaults.count > 1 ? defaults[1] : nil) |
| |
291 | let bare = ["bare", "bare-caps", "b", "bc"].contains(variant ?? "") |
| |
292 | let caps = ["caps", "bare-caps", "c", "bc"].contains(variant ?? "") |
| |
293 | func org(_ s: String?) -> [Piece] { s.map { [.org($0)] } ?? [] } |
| |
294 | func authorYear(_ cite: ([Piece], [Piece], [Piece]) -> [Piece], _ ref: (Reference, String, String) -> [Piece]) -> [Piece] { |
| |
295 | var contents: [Piece] = [] |
| |
296 | for (n, reference) in citation.references.enumerated() { |
| |
297 | if n > 0 { contents.append(.text(", ")) } |
| |
298 | let author = self.author(reference.key) ?? "??" |
| |
299 | let year = self.year(reference.key) ?? "????" |
| |
300 | contents += ref(reference, author, year) |
| |
301 | } |
| |
302 | return cite(org(citation.prefix), contents, org(citation.suffix)) |
| |
303 | } |
| |
304 | func capitalized(_ s: String) -> String { caps ? s.capitalized : s } |
| |
305 | switch style { |
| |
306 | case "author", "a": |
| |
307 | let caps = ["caps", "c"].contains(variant ?? "") |
| |
308 | return authorYear({ $0 + $1 + $2 }) { r, a, _ in org(r.prefix) + [.text(caps ? a.capitalized : a)] + org(r.suffix) } |
| |
309 | case "noauthor", "na": |
| |
310 | return authorYear({ (bare ? [] : [.text("(")]) + $0 + $1 + $2 + (bare ? [] : [.text(")")]) }) { r, _, y in org(r.prefix) + [.text(y)] + org(r.suffix) } |
| |
311 | case "nocite", "n": |
| |
312 | return [] |
| |
313 | case "note", "ft": |
| |
314 | return nil |
| |
315 | case "text", "t": |
| |
316 | return authorYear({ $0 + $1 + $2 }) { r, a, y in |
| |
317 | org(r.prefix) + [.text(capitalized(a) + (bare ? " " : " (") + y + (bare ? "" : ")"))] + org(r.suffix) |
| |
318 | } |
| |
319 | case "numeric", "nb": |
| |
320 | let single = citation.references.count == 1 ? citation.references[0] : nil |
| |
321 | let prefix = single.map { $0.prefix } ?? citation.prefix |
| |
322 | let suffix = single.map { $0.suffix } ?? citation.suffix |
| |
323 | let sorted = keys.sorted(by: authorLess) |
| |
324 | let numbers = citation.references.compactMap { r in sorted.firstIndex(of: r.key).map { $0 + 1 } }.sorted() |
| |
325 | return [.text("(")] + org(prefix) + [.text(Self.compact(numbers))] + org(suffix) + [.text(")")] |
| |
326 | default: |
| |
327 | return authorYear({ (bare ? [] : [.text("(")]) + $0 + $1 + $2 + (bare ? [] : [.text(")")]) }) { r, a, y in |
| |
328 | org(r.prefix) + [.text(capitalized(a) + ", " + y)] + org(r.suffix) |
| |
329 | } |
| |
330 | } |
| |
331 | } |
| |
332 | |
| |
333 | /// `org-cite-basic--citation-numbers`: runs of three or more as `1-3`. |
| |
334 | static func compact(_ numbers: [Int]) -> String { |
| |
335 | guard var last = numbers.first else { return "" } |
| |
336 | var result = [String(last)] |
| |
337 | var rest = Array(numbers.dropFirst()) |
| |
338 | while !rest.isEmpty { |
| |
339 | let current = rest.removeFirst() |
| |
340 | let next = rest.first |
| |
341 | if let next, current == last + 1, current == next - 1 { |
| |
342 | if result.last != "-" { result.append("-") } |
| |
343 | } else if result.last == "-" { |
| |
344 | result.append(String(current)) |
| |
345 | } else { |
| |
346 | result.append(", \(current)") |
| |
347 | } |
| |
348 | last = current |
| |
349 | } |
| |
350 | return result.joined() |
| |
351 | } |
| |
352 | |
| |
353 | /// `org-cite-basic--shorten-names`. |
| |
354 | static func shortenNames(_ names: String) -> String { |
| |
355 | names.components(separatedBy: " and ").map { name in |
| |
356 | name.count == 1 ? "" : String(name.components(separatedBy: ", ")[0]) |
| |
357 | }.joined(separator: ", ") |
| |
358 | } |
| |
359 | |
| |
360 | /// `org-cite-basic--print-entry`. `org-cite-basic--print-bibtex-string` strips braces only |
| |
361 | /// in the italic title, the one string inside an object. |
| |
362 | mutating func entry(_ key: String, style: String?, keys: [String]) -> [Piece]? { |
| |
363 | guard let entry = entries[key] else { return nil } |
| |
364 | func clean(_ s: String?) -> String { (s ?? "").replacingOccurrences(of: "[{}]", with: "", options: .regularExpression) } |
| |
365 | let author = self.author(key) ?? "" |
| |
366 | let title = entry["title"] |
| |
367 | let from = entry["publisher"] ?? entry["journal"] ?? entry["institution"] ?? entry["school"] |
| |
368 | let fromText = from.map { ", " + $0 } ?? "" |
| |
369 | switch style { |
| |
370 | case "plain": |
| |
371 | let year = self.year(key, noSuffix: true) ?? "" |
| |
372 | return [.text(Self.shortenNames(author) + ". " + (title ?? "") + fromText + ", " + year + ".")] |
| |
373 | case "numeric": |
| |
374 | let n = (keys.sorted(by: authorLess).firstIndex(of: key) ?? 0) + 1 |
| |
375 | let year = self.year(key, noSuffix: true) ?? "" |
| |
376 | return [.text("[\(n)] " + author + ", "), .italic(clean(title)), .text(fromText + ", " + year + ".")] |
| |
377 | default: |
| |
378 | let year = self.year(key) ?? "" |
| |
379 | return [.text(author + " (" + year + "). "), .italic(clean(title)), .text(fromText + ".")] |
| |
380 | } |
| |
381 | } |
| |
382 | |
| |
383 | // MARK: - Building |
| |
384 | |
| |
385 | /// The citations of `text` (as written, nocite citations included) worked out in document |
| |
386 | /// order, then the bibliography; nil when the export leaves citations alone. |
| |
387 | init?(text: String, directory: URL?) { |
| |
388 | guard text.range(of: "[cite", options: .caseInsensitive) != nil else { return nil } |
| |
389 | let settings = Self.exportSettings(text) |
| |
390 | if let settings, settings.processor != "basic" { return nil } |
| |
391 | // Each `#+bibliography:` names one file. |
| |
392 | for m in text.matches(of: #/(?mi)^[ \t]*#\+bibliography:[ \t]*(.*?)[ \t]*$/#) { |
| |
393 | for name in [String(m.1)].map({ $0.count > 1 && $0.hasPrefix("\"") && $0.hasSuffix("\"") ? String($0.dropFirst().dropLast()) : $0 }) where !name.isEmpty { |
| |
394 | let path = (name as NSString).expandingTildeInPath |
| |
395 | let url = path.hasPrefix("/") ? URL(fileURLWithPath: path) : (directory ?? URL(fileURLWithPath: "/")).appendingPathComponent(path) |
| |
396 | guard let data = try? Data(contentsOf: url) else { continue } |
| |
397 | let parsed = url.pathExtension.lowercased() == "json" ? Self.parseJSON(data) : Self.parseBibTeX(String(decoding: data, as: UTF8.self)) |
| |
398 | for (key, entry) in parsed where entries[key] == nil { entries[key] = entry } |
| |
399 | } |
| |
400 | } |
| |
401 | let tree = OrgParser.parse(text) |
| |
402 | let citations = tree.root.descendants().filter { $0.kind == .citation }.compactMap { Self.parse($0.text) } |
| |
403 | var keys: [String] = [] |
| |
404 | for citation in citations { for r in citation.references where !keys.contains(r.key) { keys.append(r.key) } } |
| |
405 | for citation in citations { |
| |
406 | guard let pieces = format(citation, keys: keys, defaultStyle: settings?.citeStyle) else { continue } |
| |
407 | outputs[citation.text, default: []].append(pieces) |
| |
408 | } |
| |
409 | let style = settings?.bibStyle |
| |
410 | // `sort` is stable: keys with the same author keep their order. |
| |
411 | bibliography = keys.enumerated().sorted { a, b in |
| |
412 | authorLess(a.element, b.element) || (!authorLess(b.element, a.element) && a.offset < b.offset) |
| |
413 | }.compactMap { entry($0.element, style: style, keys: keys) } |
| |
414 | } |
| |
415 | |
| |
416 | /// The export of the next citation written as `text`; nil leaves it as written. |
| |
417 | mutating func next(_ text: String) -> [Piece]? { |
| |
418 | guard var queue = outputs[text], !queue.isEmpty else { return nil } |
| |
419 | let first = queue.removeFirst() |
| |
420 | outputs[text] = queue |
| |
421 | return first |
| |
422 | } |
| |
423 | } |