| @@ -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 | } |