use std::collections::BTreeMap;
use brain_brew_core::{CanonicalDeck, FieldValue, StableId};
use crate::canonical_yaml;
use crate::csv_note_source::{
CsvCellProvenance, CsvNoteSourceDeclaration, CsvNoteSourceDescriptor,
CsvNoteSourceMaterializer, CsvSourceFile, CsvSourceRequest, CsvSourceRequestKind,
NoteSourceExpression, NoteSourceItem,
};
use crate::source_document::{
EditLocation, ImageConversionReport, IncludeRequest, IncludeState, IncludedSource,
SourceDocumentEmission, SourceDocumentError, SourceFile, SourceProvenance,
convert_text_to_images, prepare_source,
};
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum CanonicalScalarTarget {
DeckName,
DeckDescription,
DeckVariable(String),
NoteTypeName {
note_type_id: StableId,
},
NoteTypeVariable {
note_type_id: StableId,
key: String,
},
NoteTypeStyling {
note_type_id: StableId,
},
FieldName {
note_type_id: StableId,
field_id: StableId,
},
CardTemplateName {
note_type_id: StableId,
template_id: StableId,
},
CardTemplateQuestion {
note_type_id: StableId,
template_id: StableId,
},
CardTemplateAnswer {
note_type_id: StableId,
template_id: StableId,
},
CardTemplateVariable {
note_type_id: StableId,
template_id: StableId,
key: String,
},
NoteVariable {
note_id: StableId,
key: String,
},
NoteField {
note_id: StableId,
field_id: StableId,
},
}
impl CanonicalScalarTarget {
fn schema_path(&self) -> String {
match self {
Self::DeckName => "deck.name".to_owned(),
Self::DeckDescription => "deck.description".to_owned(),
Self::DeckVariable(key) => format!("deck.variables.{key}"),
Self::NoteTypeName { note_type_id } => format!("note_types.{note_type_id}.name"),
Self::NoteTypeVariable { note_type_id, key } => {
format!("note_types.{note_type_id}.variables.{key}")
}
Self::NoteTypeStyling { note_type_id } => {
format!("note_types.{note_type_id}.styling")
}
Self::FieldName {
note_type_id,
field_id,
} => format!("note_types.{note_type_id}.fields.{field_id}.name"),
Self::CardTemplateName {
note_type_id,
template_id,
} => format!("note_types.{note_type_id}.card_templates.{template_id}.name"),
Self::CardTemplateQuestion {
note_type_id,
template_id,
} => format!("note_types.{note_type_id}.card_templates.{template_id}.question_format"),
Self::CardTemplateAnswer {
note_type_id,
template_id,
} => format!("note_types.{note_type_id}.card_templates.{template_id}.answer_format"),
Self::CardTemplateVariable {
note_type_id,
template_id,
key,
} => format!("note_types.{note_type_id}.card_templates.{template_id}.variables.{key}"),
Self::NoteVariable { note_id, key } => format!("notes.{note_id}.variables.{key}"),
Self::NoteField { note_id, field_id } => {
format!("notes.{note_id}.fields.{field_id}")
}
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum NoteAuthoringSourceKind {
Inline,
Csv,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct NoteAuthoringLocation {
source_kind: NoteAuthoringSourceKind,
root_declaration: SourceProvenance,
declaration_path: String,
descriptor: Option<SourceProvenance>,
table: Option<String>,
file: Option<SourceProvenance>,
logical_row: Option<u64>,
header: Option<String>,
column: Option<usize>,
canonical_path: String,
}
impl NoteAuthoringLocation {
pub(crate) fn csv_field(
root_declaration: SourceProvenance,
declaration_path: String,
descriptor: SourceProvenance,
cell: &CsvCellProvenance,
canonical_path: String,
) -> Self {
Self {
source_kind: NoteAuthoringSourceKind::Csv,
root_declaration,
declaration_path,
descriptor: Some(descriptor),
table: Some(cell.table_alias.clone()),
file: Some(cell.source.clone()),
logical_row: cell.logical_row,
header: Some(cell.header.clone()),
column: Some(cell.column),
canonical_path,
}
}
pub fn source_kind(&self) -> NoteAuthoringSourceKind {
self.source_kind
}
pub fn root_declaration(&self) -> &SourceProvenance {
&self.root_declaration
}
pub fn declaration_path(&self) -> &str {
&self.declaration_path
}
pub fn descriptor(&self) -> Option<&SourceProvenance> {
self.descriptor.as_ref()
}
pub fn table(&self) -> Option<&str> {
self.table.as_deref()
}
pub fn file(&self) -> Option<&SourceProvenance> {
self.file.as_ref()
}
pub fn logical_row(&self) -> Option<u64> {
self.logical_row
}
pub fn header(&self) -> Option<&str> {
self.header.as_deref()
}
pub fn column(&self) -> Option<usize> {
self.column
}
pub fn canonical_path(&self) -> &str {
&self.canonical_path
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct NoteAuthoringProvenance {
notes: BTreeMap<StableId, NoteAuthoringLocation>,
fields: BTreeMap<(StableId, StableId), NoteAuthoringLocation>,
}
impl NoteAuthoringProvenance {
pub(crate) fn insert_field(
&mut self,
note_id: StableId,
field_id: StableId,
location: NoteAuthoringLocation,
) -> Option<NoteAuthoringLocation> {
self.fields.insert((note_id, field_id), location)
}
pub fn note(&self, note_id: &StableId) -> Option<&NoteAuthoringLocation> {
self.notes.get(note_id)
}
pub fn field(&self, note_id: &StableId, field_id: &StableId) -> Option<&NoteAuthoringLocation> {
self.fields.get(&(note_id.clone(), field_id.clone()))
}
pub fn notes(&self) -> impl Iterator<Item = (&StableId, &NoteAuthoringLocation)> {
self.notes.iter()
}
pub fn fields(&self) -> impl Iterator<Item = (&(StableId, StableId), &NoteAuthoringLocation)> {
self.fields.iter()
}
}
#[derive(Clone)]
pub struct CanonicalSourceDocument {
provenance: SourceProvenance,
deck: CanonicalDeck,
resolved_deck: CanonicalDeck,
includes: IncludeState,
note_sources: Option<NoteSourceExpression>,
authoring_provenance: NoteAuthoringProvenance,
csv_sources: Vec<(CsvSourceRequestKind, CsvSourceFile)>,
original_sources: BTreeMap<SourceProvenance, SourceFile>,
}
impl std::fmt::Debug for CanonicalSourceDocument {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("CanonicalSourceDocument")
.field("provenance", &self.provenance)
.field("deck", &self.deck)
.field("note_sources", &self.note_sources)
.finish_non_exhaustive()
}
}
impl CanonicalSourceDocument {
pub fn parse(source: SourceFile) -> Result<Self, SourceDocumentError> {
Self::parse_with_includes(source, |request| {
Err(format!(
"no include loader was provided for {:?}",
request.target()
))
})
}
pub fn parse_with_includes(
source: SourceFile,
mut loader: impl FnMut(&IncludeRequest) -> Result<SourceFile, String>,
) -> Result<Self, SourceDocumentError> {
Self::parse_with_loaders(source, &mut loader, &mut |request| {
Err(format!(
"no CSV source loader was provided for {:?}",
request.target()
))
})
}
pub fn parse_with_csv_sources(
source: SourceFile,
mut include_loader: impl FnMut(&IncludeRequest) -> Result<SourceFile, String>,
mut csv_loader: impl FnMut(&CsvSourceRequest) -> Result<CsvSourceFile, String>,
) -> Result<Self, SourceDocumentError> {
Self::parse_with_loaders(source, &mut include_loader, &mut csv_loader)
}
fn parse_with_loaders(
source: SourceFile,
include_loader: &mut impl FnMut(&IncludeRequest) -> Result<SourceFile, String>,
csv_loader: &mut impl FnMut(&CsvSourceRequest) -> Result<CsvSourceFile, String>,
) -> Result<Self, SourceDocumentError> {
let prepared = prepare_source(source, true, include_loader)?;
let root_yaml = yaml_with_included_structures_for_validation(
&prepared.yaml_without_directives,
prepared.includes.note_types(),
prepared.includes.media(),
)?;
let (root_yaml, note_sources) =
strip_note_source_expression(&root_yaml, prepared.root.provenance())?;
let mut deck = canonical_yaml::from_str(&root_yaml).map_err(|error| {
SourceDocumentError::source(prepared.root.provenance(), error.to_string())
})?;
if let Some(media) = prepared.includes.media() {
deck.media = media.clone();
}
for id in prepared.includes.media_asset_sources().keys() {
if !deck.media.contains_key(id) {
return Err(SourceDocumentError::at(
prepared.root.provenance(),
format!("media.{id}.source"),
"media source requires a complete path and sha256 declaration",
));
}
}
let materialized_yaml = yaml_with_included_structures_for_validation(
&prepared.materialized_yaml,
prepared.includes.resolved_note_types(),
prepared.includes.media(),
)?;
let (materialized_yaml, materialized_note_sources) =
strip_note_source_expression(&materialized_yaml, prepared.root.provenance())?;
if materialized_note_sources != note_sources {
return Err(SourceDocumentError::at(
prepared.root.provenance(),
"notes",
"scalar include materialization changed the notes source declarations",
));
}
let mut resolved_deck = canonical_yaml::from_str(&materialized_yaml).map_err(|error| {
SourceDocumentError::source(prepared.root.provenance(), error.to_string())
})?;
if let Some(media) = prepared.includes.media() {
resolved_deck.media = media.clone();
}
let (authoring_provenance, csv_sources) = materialize_note_sources(
&mut resolved_deck,
note_sources.as_ref(),
prepared.root.provenance(),
csv_loader,
)?;
canonical_yaml::to_string(&resolved_deck).map_err(|error| {
SourceDocumentError::source(prepared.root.provenance(), error.to_string())
})?;
canonical_yaml::to_string(&deck).map_err(|error| {
SourceDocumentError::source(prepared.root.provenance(), error.to_string())
})?;
let original_sources = prepared.original_sources()?;
Ok(Self {
provenance: prepared.root.provenance().clone(),
deck,
resolved_deck,
includes: prepared.includes,
note_sources,
authoring_provenance,
csv_sources,
original_sources,
})
}
pub fn from_deck(
provenance: SourceProvenance,
deck: CanonicalDeck,
) -> Result<Self, SourceDocumentError> {
canonical_yaml::to_string(&deck)
.map_err(|error| SourceDocumentError::source(&provenance, error.to_string()))?;
let authoring_provenance = inline_provenance(&deck, &provenance, "notes");
Ok(Self {
provenance,
resolved_deck: deck.clone(),
deck,
includes: IncludeState::default(),
note_sources: None,
authoring_provenance,
csv_sources: Vec::new(),
original_sources: BTreeMap::new(),
})
}
pub fn provenance(&self) -> &SourceProvenance {
&self.provenance
}
pub fn csv_note_source(&self) -> Option<&CsvNoteSourceDeclaration> {
self.note_sources
.as_ref()
.and_then(NoteSourceExpression::direct_csv)
}
pub fn authoring_provenance(&self) -> &NoteAuthoringProvenance {
&self.authoring_provenance
}
pub fn csv_sources(&self) -> &[(CsvSourceRequestKind, CsvSourceFile)] {
&self.csv_sources
}
pub fn included_sources(&self) -> Vec<IncludedSource> {
self.includes.source_provenance()
}
pub fn media_asset_sources(&self) -> &BTreeMap<StableId, String> {
self.includes.media_asset_sources()
}
pub fn deck(&self) -> &CanonicalDeck {
&self.deck
}
pub fn resolved_deck(&self) -> &CanonicalDeck {
&self.resolved_deck
}
pub fn set_scalar(
&mut self,
target: CanonicalScalarTarget,
expected: &str,
replacement: &str,
) -> Result<EditLocation, SourceDocumentError> {
let path = target.schema_path();
let mut next = self.clone();
if let Some(location) =
next.includes
.edit_scalar(&path, expected, replacement, &next.provenance)?
{
let value = scalar_mut(&mut next.resolved_deck, &target).ok_or_else(|| {
SourceDocumentError::at(
&next.provenance,
&path,
"typed scalar target is not present in the resolved Canonical Deck",
)
})?;
*value = replacement.to_owned();
if path.starts_with("note_types.") && next.includes.note_types().is_some() {
next.includes
.replace_resolved_note_types(next.resolved_deck.note_types.clone());
}
next.validate()?;
*self = next;
return Ok(location);
}
let value = scalar_mut(&mut next.deck, &target).ok_or_else(|| {
SourceDocumentError::at(
&next.provenance,
&path,
"typed scalar target is not present in this Canonical Deck",
)
})?;
if value != expected {
return Err(SourceDocumentError::at(
&next.provenance,
&path,
format!("expected {expected:?}, found {value:?}"),
));
}
*value = replacement.to_owned();
if path.starts_with("note_types.") && next.includes.note_types().is_some() {
let resolved_value = scalar_mut(&mut next.resolved_deck, &target).ok_or_else(|| {
SourceDocumentError::at(
&next.provenance,
&path,
"typed scalar target is not present in the resolved Canonical Deck",
)
})?;
*resolved_value = replacement.to_owned();
next.includes.replace_note_types(
next.deck.note_types.clone(),
next.resolved_deck.note_types.clone(),
);
let location = next
.includes
.note_types_source()
.map(EditLocation::Included)
.expect("note-types include source exists");
next.validate()?;
*self = next;
return Ok(location);
}
next.validate()?;
*self = next;
Ok(EditLocation::Root)
}
pub fn set_media_hash(
&mut self,
media_id: &StableId,
expected_path: &str,
sha256: &str,
) -> Result<EditLocation, SourceDocumentError> {
let path = format!("media.{media_id}.sha256");
let mut next = self.clone();
if next.includes.media().is_some() {
let (media, dirty) = next.includes.media_mut().expect("media include exists");
let reference = media.get_mut(media_id).ok_or_else(|| {
SourceDocumentError::at(
&next.provenance,
&path,
format!("media ID {media_id} is not declared in the included media map"),
)
})?;
ensure_media_path(
&next.provenance,
&path,
reference.path.as_str(),
expected_path,
)?;
reference.sha256 = sha256.to_owned();
*dirty = true;
next.deck.media = media.clone();
let location = next
.includes
.media_source()
.map(EditLocation::Included)
.expect("media source exists");
next.validate()?;
*self = next;
return Ok(location);
}
let reference = next.deck.media.get_mut(media_id).ok_or_else(|| {
SourceDocumentError::at(
&next.provenance,
&path,
format!("media ID {media_id} is not declared"),
)
})?;
ensure_media_path(
&next.provenance,
&path,
reference.path.as_str(),
expected_path,
)?;
reference.sha256 = sha256.to_owned();
next.validate()?;
*self = next;
Ok(EditLocation::Root)
}
pub fn convert_strict_image_fields(
&mut self,
lookup: &BTreeMap<String, Option<StableId>>,
) -> Result<ImageConversionReport, SourceDocumentError> {
let mut next = self.clone();
let mut report = ImageConversionReport::default();
for note in next.deck.notes.values_mut() {
let field_ids = note.fields.keys().cloned().collect::<Vec<_>>();
for field_id in field_ids {
let Some(text) = note.fields[&field_id].as_scalar() else {
continue;
};
if let Some(images) = convert_text_to_images(text, lookup, &mut report) {
note.fields.insert(field_id, FieldValue::Images(images));
}
}
}
next.validate()?;
*self = next;
Ok(report)
}
pub fn emit(&self) -> Result<SourceDocumentEmission, SourceDocumentError> {
self.validate()?;
let canonical = canonical_yaml::to_string(&self.deck)
.map_err(|error| SourceDocumentError::source(&self.provenance, error.to_string()))?;
let canonical = if let Some(expression) = &self.note_sources {
expression
.restore(canonical, &self.deck)
.map_err(|message| SourceDocumentError::at(&self.provenance, "notes", message))?
} else {
canonical
};
let canonical = self.includes.restore_directives(canonical)?;
crate::strict_yaml::reject_duplicate_keys(&canonical)
.map_err(|error| SourceDocumentError::source(&self.provenance, error.to_string()))?;
let root = SourceFile::new(self.provenance.clone(), canonical);
Ok(SourceDocumentEmission::new(
root,
self.includes.changed_sources()?,
self.original_sources.clone(),
))
}
fn validate(&self) -> Result<(), SourceDocumentError> {
canonical_yaml::to_string(&self.deck)
.and_then(|_| canonical_yaml::to_string(&self.resolved_deck))
.map(|_| ())
.map_err(|error| SourceDocumentError::source(&self.provenance, error.to_string()))
}
}
fn materialize_note_sources(
resolved_deck: &mut CanonicalDeck,
expression: Option<&NoteSourceExpression>,
root: &SourceProvenance,
csv_loader: &mut impl FnMut(&CsvSourceRequest) -> Result<CsvSourceFile, String>,
) -> Result<
(
NoteAuthoringProvenance,
Vec<(CsvSourceRequestKind, CsvSourceFile)>,
),
SourceDocumentError,
> {
let mut provenance = NoteAuthoringProvenance::default();
let mut owners = BTreeMap::<StableId, String>::new();
let mut declarations = Vec::new();
let mut loaded_sources = Vec::new();
match expression {
None => {
provenance = inline_provenance(resolved_deck, root, "notes");
owners.extend(
resolved_deck
.notes
.keys()
.cloned()
.map(|id| (id, "notes".to_owned())),
);
}
Some(NoteSourceExpression::Csv(declaration)) => {
resolved_deck.notes.clear();
declarations.push(("notes".to_owned(), declaration));
}
Some(NoteSourceExpression::Sequence(sources)) => {
for (index, source) in sources.iter().enumerate() {
let declaration_path = format!("notes[{index}]");
match source {
NoteSourceItem::Csv(declaration) => {
declarations.push((declaration_path, declaration));
}
NoteSourceItem::Inline { note_ids } => {
for note_id in note_ids {
let note = resolved_deck.notes.get(note_id).ok_or_else(|| {
SourceDocumentError::at(
root,
&declaration_path,
format!("inline-owned note {note_id} did not materialize"),
)
})?;
insert_inline_provenance(
&mut provenance,
note,
root,
&declaration_path,
);
owners.insert(note_id.clone(), declaration_path.clone());
}
}
}
}
}
}
let mut exclusions = Vec::<(StableId, String)>::new();
for (declaration_path, declaration) in declarations {
let descriptor_path = format!("{declaration_path}.descriptor");
let descriptor_request = CsvSourceRequest::descriptor(
root.clone(),
descriptor_path.clone(),
declaration.descriptor().to_owned(),
);
let descriptor_bytes = csv_loader(&descriptor_request).map_err(|message| {
SourceDocumentError::at(
root,
&descriptor_path,
format!(
"could not load CSV note descriptor {:?}: {message}",
declaration.descriptor()
),
)
})?;
loaded_sources.push((CsvSourceRequestKind::Descriptor, descriptor_bytes.clone()));
let descriptor_text = std::str::from_utf8(descriptor_bytes.bytes()).map_err(|error| {
SourceDocumentError::at(
descriptor_bytes.provenance(),
&descriptor_path,
format!("descriptor is not valid UTF-8: {error}"),
)
})?;
let descriptor = CsvNoteSourceDescriptor::parse(SourceFile::new(
descriptor_bytes.provenance().clone(),
descriptor_text,
))
.map_err(|error| SourceDocumentError::at(root, &declaration_path, error.to_string()))?;
let descriptor_provenance = descriptor.provenance().clone();
let materializer = CsvNoteSourceMaterializer::new(descriptor)
.with_parameters(declaration.parameters())
.map_err(|error| SourceDocumentError::at(root, &declaration_path, error.to_string()))?;
let table_requests = materializer
.table_paths()
.map(|(alias, target)| {
CsvSourceRequest::table(
descriptor_provenance.clone(),
format!("{declaration_path}.tables.{alias}"),
alias.to_owned(),
target.to_owned(),
)
})
.collect::<Vec<_>>();
let mut tables = BTreeMap::new();
for request in table_requests {
let alias = match request.kind() {
crate::csv_note_source::CsvSourceRequestKind::Table { alias } => alias.clone(),
crate::csv_note_source::CsvSourceRequestKind::Descriptor => unreachable!(),
};
let table = csv_loader(&request).map_err(|message| {
SourceDocumentError::at(
root,
&declaration_path,
format!("could not load CSV table {:?}: {message}", request.target()),
)
})?;
loaded_sources.push((request.kind().clone(), table.clone()));
tables.insert(alias, table);
}
let mut materialized = materializer
.materialize_with_provenance(&tables, &resolved_deck.note_types)
.map_err(|error| SourceDocumentError::at(root, &declaration_path, error.to_string()))?;
for note_id in declaration.excluded_note_ids() {
if materialized.notes.remove(note_id).is_none() {
return Err(SourceDocumentError::at(
root,
&declaration_path,
format!(
"unknown excluded note ID {note_id}; this CSV source does not materialize it"
),
));
}
materialized.note_provenance.remove(note_id);
materialized
.field_provenance
.retain(|(owned_note_id, _), _| owned_note_id != note_id);
materialized
.adapter_provenance
.retain(|(owned_note_id, _), _| owned_note_id != note_id);
exclusions.push((note_id.clone(), declaration_path.clone()));
}
for (note_id, note) in materialized.notes {
if let Some(previous) = owners.get(¬e_id) {
return Err(SourceDocumentError::at(
root,
"notes",
format!(
"duplicate ownership of note ID {note_id} by {previous} and {declaration_path}; source order never overrides"
),
));
}
let cell = &materialized.note_provenance[¬e_id];
provenance.notes.insert(
note_id.clone(),
csv_authoring_location(
root,
&declaration_path,
&descriptor_provenance,
cell,
format!("notes.{note_id}"),
),
);
for ((owned_note_id, field_id), cell) in &materialized.field_provenance {
if owned_note_id == ¬e_id {
provenance.fields.insert(
(note_id.clone(), field_id.clone()),
csv_authoring_location(
root,
&declaration_path,
&descriptor_provenance,
cell,
format!("notes.{note_id}.fields.{field_id}"),
),
);
}
}
owners.insert(note_id.clone(), declaration_path.clone());
resolved_deck.notes.insert(note_id, note);
}
}
for (note_id, declaration_path) in exclusions {
if !owners.contains_key(¬e_id) {
return Err(SourceDocumentError::at(
root,
declaration_path,
format!(
"excluded note ID {note_id} is not owned by another source; ownership transfer is missing"
),
));
}
}
Ok((provenance, loaded_sources))
}
fn inline_provenance(
deck: &CanonicalDeck,
root: &SourceProvenance,
declaration_path: &str,
) -> NoteAuthoringProvenance {
let mut provenance = NoteAuthoringProvenance::default();
for note in deck.notes.values() {
insert_inline_provenance(&mut provenance, note, root, declaration_path);
}
provenance
}
fn insert_inline_provenance(
provenance: &mut NoteAuthoringProvenance,
note: &brain_brew_core::Note,
root: &SourceProvenance,
declaration_path: &str,
) {
provenance.notes.insert(
note.id.clone(),
inline_authoring_location(root, declaration_path, format!("notes.{}", note.id)),
);
for field_id in note.fields.keys() {
provenance.fields.insert(
(note.id.clone(), field_id.clone()),
inline_authoring_location(
root,
declaration_path,
format!("notes.{}.fields.{field_id}", note.id),
),
);
}
}
fn inline_authoring_location(
root: &SourceProvenance,
declaration_path: &str,
canonical_path: String,
) -> NoteAuthoringLocation {
NoteAuthoringLocation {
source_kind: NoteAuthoringSourceKind::Inline,
root_declaration: root.clone(),
declaration_path: declaration_path.to_owned(),
descriptor: None,
table: None,
file: None,
logical_row: None,
header: None,
column: None,
canonical_path,
}
}
fn csv_authoring_location(
root: &SourceProvenance,
declaration_path: &str,
descriptor: &SourceProvenance,
cell: &CsvCellProvenance,
canonical_path: String,
) -> NoteAuthoringLocation {
NoteAuthoringLocation {
source_kind: NoteAuthoringSourceKind::Csv,
root_declaration: root.clone(),
declaration_path: declaration_path.to_owned(),
descriptor: Some(descriptor.clone()),
table: Some(cell.table_alias.clone()),
file: Some(cell.source.clone()),
logical_row: cell.logical_row,
header: Some(cell.header.clone()),
column: Some(cell.column),
canonical_path,
}
}
fn strip_note_source_expression(
yaml: &str,
provenance: &SourceProvenance,
) -> Result<(String, Option<NoteSourceExpression>), SourceDocumentError> {
let mut value = serde_yaml::from_str::<serde_yaml::Value>(yaml)
.map_err(|error| SourceDocumentError::source(provenance, error.to_string()))?;
let expression = NoteSourceExpression::take_from_root(&mut value, provenance)
.map_err(|error| SourceDocumentError::at(provenance, "notes", error.to_string()))?;
let Some(expression) = expression else {
return Ok((yaml.to_owned(), None));
};
let yaml = serde_yaml::to_string(&value)
.map_err(|error| SourceDocumentError::source(provenance, error.to_string()))?;
Ok((yaml, Some(expression)))
}
fn yaml_with_included_structures_for_validation(
yaml: &str,
note_types: Option<&BTreeMap<StableId, brain_brew_core::NoteType>>,
media: Option<&BTreeMap<StableId, brain_brew_core::MediaReference>>,
) -> Result<String, SourceDocumentError> {
let mut yaml = yaml.to_owned();
if let Some(note_types) = note_types
&& !note_types.is_empty()
{
let body = crate::note_type_map::to_string(note_types)
.map_err(|error| {
SourceDocumentError::source(
&SourceProvenance::new("canonical deck"),
format!("could not materialize included note types: {error}"),
)
})?
.lines()
.map(|line| format!(" {line}\n"))
.collect::<String>();
yaml = replace_structural_placeholder(&yaml, "note_types", &body)?;
}
if let Some(media) = media
&& !media.is_empty()
{
let body = crate::media_map::to_string(media)
.lines()
.map(|line| format!(" {line}\n"))
.collect::<String>();
yaml = replace_structural_placeholder(&yaml, "media", &body)?;
}
Ok(yaml)
}
fn replace_structural_placeholder(
yaml: &str,
key: &str,
body: &str,
) -> Result<String, SourceDocumentError> {
let invalid_placeholder = || {
SourceDocumentError::source(
&SourceProvenance::new("canonical deck"),
format!("expected one empty {key} placeholder while loading structural include"),
)
};
let start = crate::strict_yaml::top_level_mapping_key_offset(yaml, key)
.ok_or_else(invalid_placeholder)?;
let line_end = yaml[start..]
.find('\n')
.map_or(yaml.len(), |offset| start + offset + 1);
let line = yaml[start..line_end]
.strip_suffix('\n')
.unwrap_or(&yaml[start..line_end]);
let line = line.strip_suffix('\r').unwrap_or(line);
if line != format!("{key}: {{}}") {
return Err(invalid_placeholder());
}
let mut materialized = yaml.to_owned();
materialized.replace_range(start..line_end, &format!("{key}:\n{body}"));
Ok(materialized)
}
fn scalar_mut<'a>(
deck: &'a mut CanonicalDeck,
target: &CanonicalScalarTarget,
) -> Option<&'a mut String> {
match target {
CanonicalScalarTarget::DeckName => Some(&mut deck.name),
CanonicalScalarTarget::DeckDescription => Some(&mut deck.description),
CanonicalScalarTarget::DeckVariable(key) => deck.variables.get_mut(key),
CanonicalScalarTarget::NoteTypeName { note_type_id } => {
Some(&mut deck.note_types.get_mut(note_type_id)?.name)
}
CanonicalScalarTarget::NoteTypeVariable { note_type_id, key } => deck
.note_types
.get_mut(note_type_id)?
.variables
.get_mut(key),
CanonicalScalarTarget::NoteTypeStyling { note_type_id } => {
Some(&mut deck.note_types.get_mut(note_type_id)?.styling)
}
CanonicalScalarTarget::FieldName {
note_type_id,
field_id,
} => deck
.note_types
.get_mut(note_type_id)?
.fields
.iter_mut()
.find(|field| &field.id == field_id)
.map(|field| &mut field.name),
CanonicalScalarTarget::CardTemplateName {
note_type_id,
template_id,
} => template_mut(deck, note_type_id, template_id).map(|template| &mut template.name),
CanonicalScalarTarget::CardTemplateQuestion {
note_type_id,
template_id,
} => template_mut(deck, note_type_id, template_id)
.map(|template| &mut template.question_format),
CanonicalScalarTarget::CardTemplateAnswer {
note_type_id,
template_id,
} => template_mut(deck, note_type_id, template_id)
.map(|template| &mut template.answer_format),
CanonicalScalarTarget::CardTemplateVariable {
note_type_id,
template_id,
key,
} => template_mut(deck, note_type_id, template_id)?
.variables
.get_mut(key),
CanonicalScalarTarget::NoteVariable { note_id, key } => {
deck.notes.get_mut(note_id)?.variables.get_mut(key)
}
CanonicalScalarTarget::NoteField { note_id, field_id } => deck
.notes
.get_mut(note_id)?
.fields
.get_mut(field_id)?
.as_scalar_mut(),
}
}
fn template_mut<'a>(
deck: &'a mut CanonicalDeck,
note_type_id: &StableId,
template_id: &StableId,
) -> Option<&'a mut brain_brew_core::CardTemplate> {
deck.note_types
.get_mut(note_type_id)?
.card_templates
.iter_mut()
.find(|template| &template.id == template_id)
}
fn ensure_media_path(
provenance: &SourceProvenance,
schema_path: &str,
actual: &str,
expected: &str,
) -> Result<(), SourceDocumentError> {
if actual == expected {
Ok(())
} else {
Err(SourceDocumentError::at(
provenance,
schema_path,
format!("expected media path {expected:?}, found {actual:?}"),
))
}
}
#[cfg(test)]
mod csv_note_declaration_tests {
use super::*;
#[test]
fn ordinary_yaml_is_not_reserialized_when_no_csv_declaration_exists() {
let yaml = "deck: {id: deck.test, name: Test}\nnotes: {}\n";
let (stripped, declaration) =
strip_note_source_expression(yaml, &SourceProvenance::new("deck.yaml")).unwrap();
assert!(declaration.is_none());
assert_eq!(stripped, yaml);
}
}