use std::collections::BTreeMap;
use std::fmt;
use std::path::{Path, PathBuf};
use onetaskgraph_plugin_api::{
Asset, AssetPayload, AssetUploads, DependencyEdge, DependencyEndpoint, DependencyKind,
Document, ItemKind, ItemWrite, Label, MetadataKey, MetadataRecord, NativeId, Priority, Project,
Repository, SourceName, Status, StatusCategory, Task, TaskRef,
};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use super::assets;
use super::copy::{Level, forward_edges};
use super::delivery::{qualified_task, source_failed, targets};
use super::{Delivered, Engine, EngineError, Qualified};
use crate::GlobalId;
use crate::resolve::ResolvedSource;
use crate::template::{
Answers, RenderedTemplate, Sha256Digest, Template, TemplateError, TemplateInput,
TemplateProvenance,
};
#[derive(Debug, Clone, PartialEq)]
pub struct Body(Content);
#[derive(Debug, Clone, PartialEq)]
enum Content {
Plain(String),
Rendered {
rendered: RenderedTemplate,
provenance: TemplateProvenance,
},
}
impl Body {
#[must_use]
pub fn plain(text: impl Into<String>) -> Self {
Self(Content::Plain(text.into()))
}
pub fn rendered(
template: &TemplateInput,
rendered: RenderedTemplate,
) -> Result<Self, TemplateError> {
Self::rendered_as(template.reference(), rendered)
}
pub fn rendered_as(
template: impl Into<String>,
rendered: RenderedTemplate,
) -> Result<Self, TemplateError> {
let provenance = TemplateProvenance::of(template, &rendered)?;
Ok(Self(Content::Rendered {
rendered,
provenance,
}))
}
fn parts(&self, metadata: &BTreeMap<MetadataKey, Value>) -> Parts<'_> {
let mut carried: BTreeMap<String, Value> = metadata
.iter()
.map(|(key, value)| (key.as_str().to_owned(), value.clone()))
.collect();
match &self.0 {
Content::Plain(content) => Parts {
content: content.clone(),
metadata: carried,
answers: None,
},
Content::Rendered {
rendered,
provenance,
} => {
carried.insert(TemplateProvenance::KEY.to_owned(), provenance.to_value());
Parts {
content: rendered.body.clone(),
metadata: carried,
answers: Some(&rendered.answers),
}
}
}
}
}
struct Parts<'a> {
content: String,
metadata: BTreeMap<String, Value>,
answers: Option<&'a BTreeMap<String, Value>>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct TaskCreate {
pub source: SourceName,
pub project: NativeId,
pub title: String,
pub body: Body,
pub status: Option<StatusCategory>,
pub labels: Vec<String>,
pub repositories: Vec<Repository>,
pub depends_on: Vec<GlobalId>,
pub delivers: Vec<GlobalId>,
pub metadata: BTreeMap<MetadataKey, Value>,
pub assets: Vec<AssetPayload>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct DocumentCreate {
pub source: SourceName,
pub project: NativeId,
pub title: String,
pub id: Option<NativeId>,
pub body: Body,
pub labels: Vec<String>,
pub repositories: Vec<Repository>,
pub metadata: BTreeMap<MetadataKey, Value>,
pub assets: Vec<AssetPayload>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ProjectCreate {
pub source: SourceName,
pub id: NativeId,
pub title: String,
pub body: Body,
pub status: Option<StatusCategory>,
pub labels: Option<Vec<String>>,
pub repositories: Option<Vec<Repository>>,
pub metadata: BTreeMap<MetadataKey, Value>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct TaskCreated {
pub task: Qualified<Task>,
pub delivered: Vec<Delivered>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RenderedRecord {
Task,
Document,
Project,
}
impl fmt::Display for RenderedRecord {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.noun())
}
}
impl RenderedRecord {
fn noun(self) -> &'static str {
match self {
Self::Task => "task",
Self::Document => "document",
Self::Project => "project",
}
}
fn no_such(self, id: &GlobalId) -> EngineError {
match self {
Self::Task => EngineError::NoSuchTask { id: id.to_string() },
Self::Document => EngineError::NoSuchDocument { id: id.to_string() },
Self::Project => EngineError::NoSuchProject { id: id.to_string() },
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum RenderTemplate {
Given(TemplateInput),
Recorded {
search_path: Vec<PathBuf>,
},
}
impl Default for RenderTemplate {
fn default() -> Self {
Self::Recorded {
search_path: Vec::new(),
}
}
}
#[derive(Debug, Clone, PartialEq, Default)]
pub struct RenderRequest {
pub template: RenderTemplate,
pub answers: Answers,
pub dry_run: bool,
pub assets: Vec<AssetPayload>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct Regenerated {
pub id: GlobalId,
pub digest: Sha256Digest,
pub body_digest: Sha256Digest,
pub changed: bool,
pub body: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(transparent)]
pub struct TemplateAnswers(pub BTreeMap<String, Value>);
impl TemplateAnswers {
pub fn to_yaml(&self) -> Result<String, String> {
serde_norway::to_string(&self.0).map_err(|error| error.to_string())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum UnusedAnswers {
NoProvenance,
NoneStored {
source: String,
},
OutOfStep,
MalformedProvenance {
problem: String,
},
}
impl fmt::Display for UnusedAnswers {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::NoProvenance => formatter.write_str(
"it records no template provenance, so no stored answers are known to be its",
),
Self::NoneStored { source } => write!(
formatter,
"source {source} holds no stored answers for it — only a source that keeps an \
authoring file, such as local-md, stores them"
),
Self::OutOfStep => formatter.write_str(
"the answers stored beside it do not hash to the answers_digest its provenance \
records, so they changed after it was rendered and are not trusted",
),
Self::MalformedProvenance { problem } => write!(
formatter,
"{problem}, so nothing trusted says which stored answers are its"
),
}
}
}
#[derive(Debug, Clone)]
pub struct Regeneration {
id: GlobalId,
record: RenderedRecord,
template: Template,
reference: String,
base: Answers,
stored: Stored,
keeps_answers: bool,
content: String,
provenance: Option<TemplateProvenance>,
held_assets: Vec<Asset>,
recorded_assets: Option<AssetUploads>,
given_assets: Vec<AssetPayload>,
}
#[derive(Debug, Clone)]
enum Stored {
Trusted(BTreeMap<String, Value>),
Unused {
reason: UnusedAnswers,
held: Option<BTreeMap<String, Value>>,
},
}
impl Stored {
fn held(&self) -> Option<&BTreeMap<String, Value>> {
match self {
Self::Trusted(answers) => Some(answers),
Self::Unused { held, .. } => held.as_ref(),
}
}
}
impl Regeneration {
#[must_use]
pub fn id(&self) -> &GlobalId {
&self.id
}
#[must_use]
pub fn template(&self) -> &Template {
&self.template
}
#[must_use]
pub fn base(&self) -> &Answers {
&self.base
}
pub fn settled(&self) -> impl Iterator<Item = &str> {
let trusted = match &self.stored {
Stored::Trusted(answers) => Some(answers),
Stored::Unused { .. } => None,
};
trusted
.into_iter()
.flat_map(BTreeMap::keys)
.map(String::as_str)
}
#[must_use]
pub fn unused(&self) -> Option<&UnusedAnswers> {
match &self.stored {
Stored::Trusted(_) => None,
Stored::Unused { reason, .. } => Some(reason),
}
}
pub fn render(&self, answers: &Answers) -> Result<RenderedTemplate, EngineError> {
let mut base = self.base.clone();
loop {
match self.template.render(&base.overlay(answers)) {
Err(TemplateError::UnknownAnswer { names })
if names.iter().all(|name| {
base.names().any(|held| held == name)
&& !answers.names().any(|given| given == name)
}) =>
{
for name in names {
base.unset(name);
}
}
Err(TemplateError::MissingRequired { names }) => {
return Err(EngineError::MissingAnswers {
id: self.id.to_string(),
names,
reason: self.unused().map_or_else(
|| {
"the stored answers were used and do not answer them: an \
answer unset falls back to its default, and these have none"
.to_owned()
},
|unused| format!("the stored answers were not used because {unused}"),
),
});
}
Err(error) => return Err(EngineError::Template { error }),
Ok(rendered) => return Ok(rendered),
}
}
}
}
impl Engine {
pub async fn create_task(&self, request: &TaskCreate) -> Result<TaskCreated, EngineError> {
let record = format!("task {:?}", request.title);
let carried = assets::settled(
&record,
&request.body.parts(&request.metadata).content,
&request.assets,
&[],
)?;
let placement = self.place(&request.source, &request.repositories);
let near = &placement.destination;
let source = self.creatable(near, MetadataRecord::Task)?;
if let Some(first) = carried.first() {
assets::stores(source, &record, &first.name)?;
}
let filed = if near == &request.source {
None
} else {
let home = GlobalId::new(request.source.clone(), request.project.clone());
self.creatable(&request.source, MetadataRecord::Task)?;
let filed = self.member_project(&home, near).await?;
if filed.project.is_none() {
return Err(EngineError::NoSuchProject {
id: home.to_string(),
});
}
Some(filed)
};
let project = filed
.as_ref()
.and_then(|filed| filed.project.clone())
.unwrap_or_else(|| request.project.clone());
let Parts {
content,
metadata,
answers,
} = request.body.parts(&request.metadata);
let category = request.status.unwrap_or(StatusCategory::Todo);
let id = NativeId::from(slug(&request.title, "task").as_str());
let depends_on = request
.depends_on
.iter()
.map(|far| DependencyEdge {
from: DependencyEndpoint::from_native(id.clone(), ItemKind::Task),
to: endpoint(far, near),
kind: DependencyKind::Blocks,
})
.collect();
let write = ItemWrite {
target: None,
item: Task {
id,
key: None,
title: request.title.clone(),
content: Some(content),
status: Status {
category,
name: category_word(category),
},
priority: Priority::None,
labels: labels(&request.labels),
project: Some(project),
url: None,
location: None,
created_at: None,
updated_at: None,
metadata,
repositories: request.repositories.clone(),
delivers: request
.delivers
.iter()
.map(|task| TaskRef::qualified(&task.source, &task.native))
.collect(),
delivered_by: Vec::new(),
},
depends_on,
};
let written = match match (answers, carried.is_empty()) {
(answers, false) => source
.source()
.write_task_with_assets(&write, answers, &assets::write_of(carried, None))
.await
.map(|written| written.id),
(Some(answers), true) => source.source().write_task_rendered(&write, answers).await,
(None, true) => source.source().write_task(&write).await,
} {
Ok(written) => written,
Err(error) => {
let error = source_failed(source, error);
return Err(match filed {
Some(filed) => self.unfile(filed, error).await,
None => error,
});
}
};
let id = GlobalId::new(near.clone(), written);
let task = source
.source()
.get_task(&id.native)
.await
.map_err(|error| source_failed(source, error))?
.ok_or_else(|| EngineError::NoSuchTask { id: id.to_string() })?;
let delivers = targets(&task.delivers, near);
let delivered = self
.deliver(&id, task.status.category, &delivers, &[])
.await;
Ok(TaskCreated {
task: qualified_task(id, task),
delivered,
})
}
pub async fn create_document(
&self,
request: &DocumentCreate,
) -> Result<Qualified<Document>, EngineError> {
let record = format!("document {:?}", request.title);
let carried = assets::settled(
&record,
&request.body.parts(&request.metadata).content,
&request.assets,
&[],
)?;
let source = self.creatable(&request.source, MetadataRecord::Document)?;
documentary(source)?;
if let Some(first) = carried.first() {
assets::stores(source, &record, &first.name)?;
}
let held = match &request.id {
Some(id) => source
.source()
.get_document(id)
.await
.map_err(|error| source_failed(source, error))?,
None => None,
};
let target = held.as_ref().map(|held| held.id.clone());
let held_assets = match &target {
Some(target) => source
.source()
.document_assets(target)
.await
.map_err(|error| source_failed(source, error))?,
None => Vec::new(),
};
let recorded = match &held {
Some(held) => {
assets::recorded(&held.metadata).map_err(|error| source_failed(source, error))?
}
None => None,
};
let Parts {
content,
metadata,
answers,
} = request.body.parts(&request.metadata);
let write = ItemWrite {
target,
item: Document {
id: request
.id
.clone()
.unwrap_or_else(|| NativeId::from(slug(&request.title, "document").as_str())),
title: request.title.clone(),
content: Some(content),
project: Some(request.project.clone()),
labels: labels(&request.labels),
url: None,
location: None,
created_at: None,
updated_at: None,
metadata,
repositories: request.repositories.clone(),
},
depends_on: Vec::new(),
};
let written = match (
answers,
carried.is_empty() && !assets::holds_any(&held_assets, recorded.as_ref()),
) {
(answers, false) => source
.source()
.write_document_with_assets(&write, answers, &assets::write_of(carried, recorded))
.await
.map(|written| written.id),
(Some(answers), true) => {
source
.source()
.write_document_rendered(&write, answers)
.await
}
(None, true) => source.source().write_document(&write).await,
}
.map_err(|error| source_failed(source, error))?;
let id = GlobalId::new(request.source.clone(), written);
let document = source
.source()
.get_document(&id.native)
.await
.map_err(|error| source_failed(source, error))?
.ok_or_else(|| EngineError::NoSuchDocument { id: id.to_string() })?;
Ok(Qualified { id, item: document })
}
pub async fn create_project(
&self,
request: &ProjectCreate,
) -> Result<Qualified<Project>, EngineError> {
let source = self.creatable(&request.source, MetadataRecord::Project)?;
let held = source
.source()
.get_project(&request.id)
.await
.map_err(|error| source_failed(source, error))?;
let Parts {
content,
metadata: given,
answers,
} = request.body.parts(&request.metadata);
let (target, depends_on, mut metadata, status, labels, repositories) = match held {
Some(held) => {
let edges = forward_edges(source, &held.id, Level::Project).await?;
let mut kept = held.metadata;
kept.remove(TemplateProvenance::KEY);
(
Some(held.id),
edges,
kept,
held.status,
held.labels,
held.repositories,
)
}
None => {
let category = StatusCategory::Todo;
(
None,
Vec::new(),
BTreeMap::new(),
Status {
category,
name: category_word(category),
},
Vec::new(),
Vec::new(),
)
}
};
metadata.extend(given);
let status = request.status.map_or(status, |category| Status {
category,
name: category_word(category),
});
let write = ItemWrite {
target,
item: Project {
id: request.id.clone(),
title: request.title.clone(),
content: Some(content),
status,
labels: request.labels.as_deref().map_or(labels, self::labels),
url: None,
location: None,
created_at: None,
updated_at: None,
metadata,
repositories: request.repositories.clone().unwrap_or(repositories),
},
depends_on,
};
let written = match answers {
Some(answers) => {
source
.source()
.write_project_rendered(&write, answers)
.await
}
None => source.source().write_project(&write).await,
}
.map_err(|error| source_failed(source, error))?;
let id = GlobalId::new(request.source.clone(), written);
let project = source
.source()
.get_project(&id.native)
.await
.map_err(|error| source_failed(source, error))?
.ok_or_else(|| EngineError::NoSuchProject { id: id.to_string() })?;
Ok(Qualified { id, item: project })
}
pub async fn template_answers(
&self,
record: RenderedRecord,
id: &GlobalId,
) -> Result<TemplateAnswers, EngineError> {
let source = self.built(&id.source)?;
if record == RenderedRecord::Document {
documentary(source)?;
}
self.read_item(source, record, id).await?;
self.stored(source, record, id)
.await?
.map(TemplateAnswers)
.ok_or_else(|| EngineError::NoStoredAnswers {
record,
id: id.to_string(),
reason: UnusedAnswers::NoneStored {
source: id.source.to_string(),
}
.to_string(),
})
}
pub async fn regeneration(
&self,
record: RenderedRecord,
id: &GlobalId,
request: &RenderRequest,
) -> Result<Regeneration, EngineError> {
let source = self.built(&id.source)?;
if record == RenderedRecord::Document {
documentary(source)?;
}
if let (RenderedRecord::Project, Some(given)) = (record, request.assets.first()) {
return Err(EngineError::AssetNotReferenced {
record: format!("project {id}"),
asset: given.name.to_string(),
});
}
let (content, metadata) = self.read_item(source, record, id).await?;
let read = TemplateProvenance::read(&metadata);
let held_assets = match record {
RenderedRecord::Task => source.source().task_assets(&id.native).await,
RenderedRecord::Document => source.source().document_assets(&id.native).await,
RenderedRecord::Project => Ok(Vec::new()),
}
.map_err(|error| source_failed(source, error))?;
let recorded_assets =
assets::recorded(&metadata).map_err(|error| source_failed(source, error))?;
let template = match (&request.template, &read) {
(RenderTemplate::Given(given), _) => given.clone(),
(RenderTemplate::Recorded { .. }, Err(problem)) => {
return Err(EngineError::MalformedProvenance {
record,
id: id.to_string(),
problem: problem.clone(),
});
}
(RenderTemplate::Recorded { search_path }, Ok(Some(recorded)))
if Path::new(&recorded.template).is_file() =>
{
TemplateInput::File {
path: PathBuf::from(&recorded.template),
search_path: search_path.clone(),
}
}
(RenderTemplate::Recorded { .. }, Ok(Some(recorded))) => {
return Err(EngineError::TemplateNotAFile {
record,
id: id.to_string(),
reference: recorded.template.clone(),
});
}
(RenderTemplate::Recorded { .. }, Ok(None)) => {
return Err(EngineError::NoTemplate {
record,
id: id.to_string(),
});
}
};
let reference = template.reference();
let template = template
.load()
.map_err(|error| EngineError::Template { error })?;
let held = self.stored(source, record, id).await?;
let unused = |reason| Stored::Unused {
reason,
held: held.clone(),
};
let stored = match (&read, &held) {
(Err(problem), _) => unused(UnusedAnswers::MalformedProvenance {
problem: problem.clone(),
}),
(Ok(None), _) => unused(UnusedAnswers::NoProvenance),
(Ok(Some(_)), None) => unused(UnusedAnswers::NoneStored {
source: id.source.to_string(),
}),
(Ok(Some(recorded)), Some(answers))
if crate::template::answers_digest(answers) != recorded.answers_digest.as_str() =>
{
unused(UnusedAnswers::OutOfStep)
}
(Ok(Some(_)), Some(answers)) => Stored::Trusted(answers.clone()),
};
let mut base = Answers::new();
if let Stored::Trusted(answers) = &stored {
for (name, value) in answers.iter().filter(|(_, value)| !value.is_null()) {
base.set(name.clone(), value.clone());
}
}
Ok(Regeneration {
id: id.clone(),
record,
template,
reference,
base,
stored,
keeps_answers: source.source().keeps_template_answers(),
content,
provenance: read.ok().flatten(),
held_assets,
recorded_assets,
given_assets: request.assets.clone(),
})
}
pub async fn regenerate(
&self,
regeneration: &Regeneration,
answers: &Answers,
dry_run: bool,
) -> Result<Regenerated, EngineError> {
let rendered = regeneration.render(answers)?;
let provenance = TemplateProvenance::of(regeneration.reference.clone(), &rendered)
.map_err(|error| EngineError::Template { error })?;
let carried = self.carried_assets(regeneration, &rendered.body).await?;
let with_assets = !carried.is_empty()
|| assets::holds_any(
®eneration.held_assets,
regeneration.recorded_assets.as_ref(),
);
let changed = regeneration.content != rendered.body
|| !assets::same_set(®eneration.held_assets, &carried)
|| regeneration.provenance.as_ref() != Some(&provenance)
|| regeneration
.stored
.held()
.map_or(regeneration.keeps_answers, |stored| {
*stored != rendered.answers
});
let id = ®eneration.id;
if changed && !dry_run {
let source = self.built(&id.source)?;
if !source.source().writes().is_supported() {
return Err(EngineError::RenderingNotWritable {
name: source.name().to_string(),
kind: source.kind().to_owned(),
record: regeneration.record,
});
}
let value = provenance.to_value();
match (regeneration.record, with_assets) {
(RenderedRecord::Task, true) => source
.source()
.set_task_rendering_with_assets(
&id.native,
&rendered.body,
&value,
&rendered.answers,
&assets::write_of(carried, regeneration.recorded_assets.clone()),
)
.await
.map(|written| written.map(|_| ())),
(RenderedRecord::Document, true) => source
.source()
.set_document_rendering_with_assets(
&id.native,
&rendered.body,
&value,
&rendered.answers,
&assets::write_of(carried, regeneration.recorded_assets.clone()),
)
.await
.map(|written| written.map(|_| ())),
(RenderedRecord::Task, false) => {
source
.source()
.set_task_rendering(&id.native, &rendered.body, &value, &rendered.answers)
.await
}
(RenderedRecord::Document, false) => {
source
.source()
.set_document_rendering(
&id.native,
&rendered.body,
&value,
&rendered.answers,
)
.await
}
(RenderedRecord::Project, _) => {
source
.source()
.set_project_rendering(
&id.native,
&rendered.body,
&value,
&rendered.answers,
)
.await
}
}
.map_err(|error| source_failed(source, error))?
.ok_or_else(|| regeneration.record.no_such(id))?;
}
Ok(Regenerated {
id: id.clone(),
digest: provenance.digest,
body_digest: provenance.body_digest,
changed,
body: rendered.body,
})
}
pub async fn render_task(
&self,
id: &GlobalId,
request: &RenderRequest,
) -> Result<Regenerated, EngineError> {
let regeneration = self.regeneration(RenderedRecord::Task, id, request).await?;
self.regenerate(®eneration, &request.answers, request.dry_run)
.await
}
pub async fn render_project(
&self,
id: &GlobalId,
request: &RenderRequest,
) -> Result<Regenerated, EngineError> {
let regeneration = self
.regeneration(RenderedRecord::Project, id, request)
.await?;
self.regenerate(®eneration, &request.answers, request.dry_run)
.await
}
pub async fn render_document(
&self,
id: &GlobalId,
request: &RenderRequest,
) -> Result<Regenerated, EngineError> {
let regeneration = self
.regeneration(RenderedRecord::Document, id, request)
.await?;
self.regenerate(®eneration, &request.answers, request.dry_run)
.await
}
async fn carried_assets(
&self,
regeneration: &Regeneration,
content: &str,
) -> Result<Vec<AssetPayload>, EngineError> {
if regeneration.record == RenderedRecord::Project {
return Ok(Vec::new());
}
let id = ®eneration.id;
let record = format!("{} {id}", regeneration.record);
let source = self.built(&id.source)?;
let mut kept = Vec::new();
for held in ®eneration.held_assets {
let still = onetaskgraph_plugin_api::asset_references(content).contains(&held.name);
let given = regeneration
.given_assets
.iter()
.any(|payload| payload.name == held.name);
if !still || given {
continue;
}
let bytes = match regeneration.record {
RenderedRecord::Task => source.source().task_asset(&id.native, &held.name).await,
_ => source.source().document_asset(&id.native, &held.name).await,
}
.map_err(|error| source_failed(source, error))?;
kept.push(AssetPayload {
name: held.name.clone(),
sha256: held.sha256.clone(),
content_type: held.content_type,
bytes,
});
}
let carried = assets::settled(&record, content, ®eneration.given_assets, &kept)?;
if let Some(first) = carried.first() {
assets::stores(source, &record, &first.name)?;
}
Ok(carried)
}
fn creatable(
&self,
name: &SourceName,
record: MetadataRecord,
) -> Result<&ResolvedSource, EngineError> {
let source = self.built(name)?;
if source.source().writes().is_supported() {
return Ok(source);
}
Err(EngineError::NotCreatable {
name: source.name().to_string(),
kind: source.kind().to_owned(),
record,
})
}
async fn read_item(
&self,
source: &ResolvedSource,
record: RenderedRecord,
id: &GlobalId,
) -> Result<(String, BTreeMap<String, Value>), EngineError> {
let read = match record {
RenderedRecord::Task => source
.source()
.get_task(&id.native)
.await
.map(|task| task.map(|task| (task.content, task.metadata))),
RenderedRecord::Document => source
.source()
.get_document(&id.native)
.await
.map(|document| document.map(|document| (document.content, document.metadata))),
RenderedRecord::Project => source
.source()
.get_project(&id.native)
.await
.map(|project| project.map(|project| (project.content, project.metadata))),
}
.map_err(|error| source_failed(source, error))?;
let (content, metadata) = read.ok_or_else(|| record.no_such(id))?;
Ok((content.unwrap_or_default(), metadata))
}
async fn stored(
&self,
source: &ResolvedSource,
record: RenderedRecord,
id: &GlobalId,
) -> Result<Option<BTreeMap<String, Value>>, EngineError> {
match record {
RenderedRecord::Task => source.source().task_template_answers(&id.native).await,
RenderedRecord::Document => source.source().document_template_answers(&id.native).await,
RenderedRecord::Project => source.source().project_template_answers(&id.native).await,
}
.map_err(|error| source_failed(source, error))
}
}
fn documentary(source: &ResolvedSource) -> Result<(), EngineError> {
if source.source().capabilities().documents.is_native() {
return Ok(());
}
Err(EngineError::NoDocuments {
name: source.name().to_string(),
kind: source.kind().to_owned(),
})
}
fn endpoint(far: &GlobalId, near: &SourceName) -> DependencyEndpoint {
if &far.source == near {
return DependencyEndpoint::from_native(far.native.clone(), ItemKind::Task);
}
DependencyEndpoint::new(far.to_string(), ItemKind::Task)
.unwrap_or_else(|_| DependencyEndpoint::from_native(far.native.clone(), ItemKind::Task))
}
fn labels(names: &[String]) -> Vec<Label> {
names
.iter()
.map(|name| Label {
id: NativeId::from(name.as_str()),
name: name.clone(),
color: None,
})
.collect()
}
fn category_word(category: StatusCategory) -> String {
serde_json::to_value(category)
.ok()
.and_then(|word| word.as_str().map(str::to_owned))
.unwrap_or_else(|| "todo".to_owned())
}
fn slug(title: &str, fallback: &str) -> String {
let mut slug = String::with_capacity(title.len());
for character in title.chars() {
if character.is_ascii_alphanumeric() {
slug.push(character.to_ascii_lowercase());
} else if !slug.is_empty() && !slug.ends_with('-') {
slug.push('-');
}
}
let slug = slug.trim_end_matches('-');
if slug.is_empty() {
fallback.to_owned()
} else {
slug.to_owned()
}
}