"""Normalização de dc.date para o elemento 1.3 da NOBRADE. Saída de `interpreta(texto)`: forma — como a NOBRADE manda exibir: `1914`, `[1860]`, `[1902?]`, `[ca. 1905]`, `[193-]`, `[18--?]`, `8/2/1890-18/4/1890` iso — ISO 8601-2 (EDTF), para indexação e intercâmbio (proc. 1.3.2.7): `1914`, `1902?`, `1905~`, `193X`, `18XX?`, `1931/1937`, `../1921` inicio, fim — anos inteiros (ou None) para filtrar e ordenar qualidade — `exata` | `atribuida` | `incerta` | `aproximada` tipo — `produção` (padrão) ou `publicação` motivo — quando não deu para interpretar: `sem data`, `registro de sistema`, `ilegível` A regra geral: tira do texto as marcas (colchetes, `?`, `circa`, qualificadores entre parênteses), interpreta o que sobra como ponto ou intervalo e remonta a forma com as marcas no padrão da norma (proc. 1.3.2.1–1.3.2.4). """ from __future__ import annotations import re import unicodedata from dataclasses import dataclass, field from datetime import date MESES = { 'jan': 1, 'janeiro': 1, 'fev': 2, 'fevereiro': 2, 'mar': 3, 'março': 3, 'marco': 3, 'abr': 4, 'abril': 4, 'mai': 5, 'maio': 5, 'jun': 6, 'junho': 6, 'jul': 7, 'julho': 7, 'ago': 8, 'agosto': 8, 'set': 9, 'setembro': 9, 'out': 10, 'outubro': 10, 'nov': 11, 'novembro': 11, 'dez': 12, 'dezembro': 12, } MES_RE = '(' + '|'.join(sorted(MESES, key=len, reverse=True)) + r')\.?' SEM_DATA = re.compile(r'^(s\.?\s*/?\s*d\.?|s/data|sem data|s\.?\s*n\.?|n[aã]o identificad[ao])$', re.I) @dataclass class Ponto: ano: int mes: int | None = None dia: int | None = None prec: str = 'ano' # dia | mes | ano | decada | seculo def forma(self) -> str: if self.prec == 'dia': return f'{self.dia}/{self.mes}/{self.ano}' if self.prec == 'mes': return f'{self.mes}/{self.ano}' if self.prec == 'decada': return f'{self.ano // 10}-' if self.prec == 'seculo': return f'{self.ano // 100}--' return str(self.ano) def iso(self) -> str: if self.prec == 'dia': return f'{self.ano:04d}-{self.mes:02d}-{self.dia:02d}' if self.prec == 'mes': return f'{self.ano:04d}-{self.mes:02d}' if self.prec == 'decada': return f'{self.ano // 10}X' if self.prec == 'seculo': return f'{self.ano // 100}XX' return f'{self.ano:04d}' def limites(self) -> tuple[int, int]: if self.prec == 'decada': return self.ano, self.ano + 9 if self.prec == 'seculo': return self.ano, self.ano + 99 return self.ano, self.ano @dataclass class Data: original: str inicio_p: Ponto | None = None fim_p: Ponto | None = None relacao: str = '' # '' | 'antes' | 'depois' colchete: bool = False incerta: bool = False aproximada: bool = False tipo: str = 'produção' motivo: str = '' notas: list[str] = field(default_factory=list) @property def ok(self) -> bool: return self.inicio_p is not None @property def qualidade(self) -> str: if self.aproximada: return 'aproximada' if self.incerta: return 'incerta' if self.colchete: return 'atribuida' return 'exata' def forma(self) -> str: if not self.ok: return '[s.d.]' a = self.inicio_p.forma() if self.relacao == 'antes': s = f'antes de {a}' elif self.relacao == 'depois': s = f'depois de {a}' elif self.fim_p and self.fim_p != self.inicio_p: s = f'{a}-{self.fim_p.forma()}' else: s = a if self.aproximada: s = 'ca. ' + s if self.incerta: s += '?' if self.colchete or self.aproximada or self.incerta or self.relacao: s = f'[{s}]' return s def iso(self) -> str | None: if not self.ok: return None q = ('%' if self.incerta else '~') if self.aproximada else ('?' if self.incerta else '') a = self.inicio_p.iso() + q if self.relacao == 'antes': return f'../{a}' if self.relacao == 'depois': return f'{a}/..' if self.fim_p and self.fim_p != self.inicio_p: return f'{a}/{self.fim_p.iso()}{q}' return a def anos(self) -> tuple[int | None, int | None]: if not self.ok: return None, None i, f = self.inicio_p.limites() if self.fim_p: f = self.fim_p.limites()[1] if self.relacao == 'antes': return None, f if self.relacao == 'depois': return i, None return i, f def registro(self) -> dict: i, f = self.anos() return { 'original': self.original, 'forma': self.forma(), 'iso': self.iso(), 'inicio': i, 'fim': f, 'qualidade': self.qualidade if self.ok else None, 'tipo': self.tipo, 'motivo': self.motivo or None, 'notas': self.notas or None, } def _sem_acento(s: str) -> str: return ''.join(c for c in unicodedata.normalize('NFD', s) if unicodedata.category(c) != 'Mn') def _ano(s: str, base: int | None = None) -> int: n = int(s) if len(s) == 2: return (base // 100 * 100 if base else 1900) + n return n def _ponto(s: str) -> tuple[Ponto | None, bool]: """Um ponto no tempo. Devolve (ponto, incerto) — incerto quando o próprio formato carrega a dúvida, como `193?` (década) ou `?/08/1938` (dia).""" s = s.strip(' .,:;') s = re.sub(r'^[-–]\s*', '', s) if not s: return None, False if m := re.fullmatch(r'(\d{4})', s): return Ponto(int(m[1])), False if m := re.fullmatch(r'(\d{3})-', s): return Ponto(int(m[1]) * 10, prec='decada'), False if m := re.fullmatch(r'(\d{3})-?\?', s): return Ponto(int(m[1]) * 10, prec='decada'), True if m := re.fullmatch(r'(\d{2})(?:-{2,3}|_)', s): return Ponto(int(m[1]) * 100, prec='seculo'), False if m := re.fullmatch(r'(\d{2})(?:-{2})?\?', s): return Ponto(int(m[1]) * 100, prec='seculo'), True if m := re.fullmatch(r'(\d{3})--', s): # [186--]: década escrita com traço a mais return Ponto(int(m[1]) * 10, prec='decada'), False # dia/mês/ano, com separadores tortos: 22-12--1938, 14 -07-1928, 05/12-1934 if m := re.fullmatch(r'(\d{1,2})\s*[-/.]\s*(\d{1,2})\s*[-/.]+\s*(\d{4}|\d{2})', s): d, mo, a = int(m[1]), int(m[2]), _ano(m[3]) if 1 <= mo <= 12 and 1 <= d <= 31: return Ponto(a, mo, d, 'dia'), len(m[3]) == 2 if m := re.fullmatch(r'(\d{4})\s*[-/]\s*(\d{1,2})\s*[-/]\s*(\d{1,2})', s): a, mo, d = int(m[1]), int(m[2]), int(m[3]) if 1 <= mo <= 12 and 1 <= d <= 31: return Ponto(a, mo, d, 'dia'), False # dia desconhecido: 2-/12/1923, ?/08/1938 if m := re.fullmatch(r'(?:\d?-|\?)\s*/\s*(\d{1,2})\s*/\s*(\d{4})', s): return Ponto(int(m[2]), int(m[1]), prec='mes'), False if m := re.fullmatch(r'(\d{1,2})\s*/\s*(\d{4})', s): if 1 <= int(m[1]) <= 12: return Ponto(int(m[2]), int(m[1]), prec='mes'), False if m := re.fullmatch(r'(\d{4})\s*/\s*(\d{1,2})', s): if 1 <= int(m[2]) <= 12: return Ponto(int(m[1]), int(m[2]), prec='mes'), False t = _sem_acento(s.lower()) if m := re.fullmatch(rf'(\d{{1,2}})\s*(?:de\s+)?{MES_RE}\s*,?\s*(?:de\s+)?(\d{{4}})', t): return Ponto(int(m[3]), MESES[m[2]], int(m[1]), 'dia'), False if m := re.fullmatch(rf'{MES_RE}\s*,?\s*(?:de\s+)?(\d{{4}})', t): return Ponto(int(m[2]), MESES[m[1]], prec='mes'), False if m := re.fullmatch(r'(?:decada de )?(\d{3})\d(?: decada)?( provavel)?', t): if 'decada' in t: return Ponto(int(m[1]) * 10, prec='decada'), bool(m[2]) if m := re.fullmatch(r'(\d{2}) seculo', t): return Ponto((int(m[1]) - 1) * 100, prec='seculo'), False return None, False def _intervalo(s: str) -> tuple[Ponto | None, Ponto | None, bool]: t = _sem_acento(s.lower()).strip(' .,:;') t = re.sub(r'^entre\s+', '', t) p, inc = _ponto(t) if p: return p, None, inc # dias ou meses coordenados: "15 e 16 de outubro de 1930", "outubro e novembro de 1930" if m := re.fullmatch(rf'(\d{{1,2}})\s+e\s+(\d{{1,2}})\s+(?:de\s+)?{MES_RE}\s+(?:de\s+)?(\d{{4}})', t): mo, a = MESES[m[3]], int(m[4]) return Ponto(a, mo, int(m[1]), 'dia'), Ponto(a, mo, int(m[2]), 'dia'), False if m := re.fullmatch(rf'{MES_RE}\s+e\s+{MES_RE}\s+(?:de\s+)?(\d{{4}})', t): a = int(m[3]) return Ponto(a, MESES[m[1]], prec='mes'), Ponto(a, MESES[m[2]], prec='mes'), False # 1859-61: segundo ano abreviado if m := re.fullmatch(r'(\d{4})\s*-\s*(\d{2})', t): a = int(m[1]) b = _ano(m[2], a) if b > a: return Ponto(a), Ponto(b), False # o traço separa só entre um ano e o início do outro — `193-` é década, não intervalo for sep in (r'\s+e\s+', r'\s+a\s+', r'(?<=[\d?])\s*[-–]\s*(?=\d)', r'\s*/\s*'): partes = re.split(sep, t) if len(partes) != 2: continue # "/" só separa anos inteiros (1952/1953); com meses é data (1910/12) if sep == r'\s*/\s*' and not all(re.fullmatch(r'\d{4}', p) for p in partes): continue a, ia = _ponto(partes[0]) b, ib = _ponto(partes[1]) if a and b and b.limites()[1] >= a.limites()[0]: return a, b, ia or ib return None, None, False ANO_MIN, ANO_MAX = 1500, date.today().year def _plausivel(d: Data) -> Data: """Um ano fora do possível ("20-12-1395") é erro de digitação na origem: não vira data.""" anos = [x for x in d.anos() if x is not None] if anos and (min(anos) < ANO_MIN or max(anos) > ANO_MAX): d.notas.append(f'ano implausível na origem ({d.original}); data desconsiderada') d.inicio_p = d.fim_p = None d.relacao = '' d.motivo = 'ilegível' return d def interpreta(texto: str | None) -> Data: original = (texto or '').strip() d = Data(original) s = original if not s: d.motivo = 'sem data' return d if re.fullmatch(r'\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z', s): # carimbo de registro no DSpace gravado no campo errado — não é produção d.motivo = 'registro de sistema' d.notas.append(f'dc.date traz a data de registro no sistema de origem ({s}), não a de produção') return d # partes separadas por ";" com qualificador: fica a de produção, o resto vira nota partes = [p.strip() for p in re.split(r';(?![^(]*\))', s) if p.strip()] if len(partes) > 1: principal = next((p for p in partes if re.search(r'produ', p, re.I)), partes[0]) d.notas += [f'dc.date também registra: {p}' for p in partes if p is not principal] s = principal if m := re.search(r'\(([^)]*)\)\s*$', s): q = m[1].lower() if 'publica' in q: d.tipo = 'publicação' if 'atribu' in q: d.colchete = True s = s[:m.start()].strip() if re.search(r'[\[\]{]', s): d.colchete = True s = re.sub(r'[\[\]{}]', '', s) s = re.sub(r'^\s*-\s*', '', s) s = re.sub(r'^(RJ|[A-Z]{2}),\s*', '', s) # [RJ, ca. 1860]: lugar vai na data tópica s = re.sub(r'(\d)\.(\d{3})\b', r'\1\2', s) # 1.885 → 1885 s = re.sub(r'\s*:?\.\s*$', '', s).strip() if SEM_DATA.fullmatch(s): d.motivo = 'sem data' return d s, n = re.subn(r'\b(circa|cica|cira|ca\.?|c\.)\s*|(?<=\[)c(?=\d)|^c(?=\d)', '', s, flags=re.I) if n: d.aproximada = True s, n = re.subn(r'\s*\b(anterior|antes de)\b\s*', ' ', s, flags=re.I) if n: d.relacao = 'antes' s, n = re.subn(r'\s*\b(posterior|depois de)\b\s*', ' ', s, flags=re.I) if n: d.relacao = 'depois' s = s.strip() # interrogação no fim vale para a data inteira; dentro de um ponto (193?) é o ponto que diz if re.search(r'\d{4}\s*\?\s*$', s) or re.search(r'\d{4}\?\s*\]?$', original): d.incerta = True s = re.sub(r'\s*\?\s*$', '', s) # "Foto aérea da Fábrica ... - SP - 23-06-1933": só a data do fim interessa if not re.match(r'[\d?]', s) and not re.match(rf'(?i){MES_RE}|entre|decada|década', _sem_acento(s)): if m := re.search(r'(\d{1,2}-\d{1,2}-\d{4}|\d{4})\s*$', s): d.notas.append(f'data extraída do texto: {original}') s = m[1] p, inc = _ponto(s) if p: d.inicio_p, d.incerta = p, d.incerta or inc return _plausivel(d) a, b, inc = _intervalo(s) if a: d.inicio_p, d.fim_p, d.incerta = a, b, d.incerta or inc return _plausivel(d) # "1900-" (colchete aberto): dali em diante if m := re.fullmatch(r'(\d{4})\s*-', s): d.inicio_p, d.relacao = Ponto(int(m[1])), 'depois' return d d.motivo = 'ilegível' return d def atribuida(inicio: int, fim: int, base: str) -> dict: """Data atribuída pelo arquivista a partir de outra fonte (proc. 1.3.3.2).""" d = Data('', Ponto(inicio), Ponto(fim) if fim != inicio else None, incerta=True, colchete=True) r = d.registro() r['original'] = None r['notas'] = [f'data atribuída pelo arquivista: {base}'] return r