use indoc::indoc;
use log::debug;
use pulldown_cmark_to_cmark::Options as CmarkOptions;
use std::{collections::HashMap, env, fs::read_to_string};
use toml_edit::{value, DocumentMut, Item};
use serde::{Deserialize, Serialize};
use crate::graph::GraphContext;
use super::{node::NodeIter, NodeId};
const CONFIG_FILE_NAME: &str = "config.toml";
const IWE_MARKER: &str = ".iwe";
pub const DEFAULT_KEY_DATE_FORMAT: &str = "%Y-%m-%d";
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
pub struct MarkdownOptions {
#[serde(default)]
pub refs_extension: String,
pub date_format: Option<String>,
pub time_format: Option<String>,
pub locale: Option<String>,
#[serde(default)]
pub formatting: FormattingOptions,
}
impl Default for MarkdownOptions {
fn default() -> Self {
Self {
refs_extension: String::new(),
date_format: Some("%b %d, %Y".into()),
time_format: None,
locale: None,
formatting: FormattingOptions::default(),
}
}
}
#[derive(Debug, Clone, PartialEq, Default, Deserialize, Serialize)]
pub struct FormattingOptions {
pub emphasis_token: Option<String>,
pub strong_token: Option<String>,
pub list_token: Option<String>,
pub ordered_list_token: Option<String>,
pub code_block_token: Option<String>,
pub code_block_token_count: Option<usize>,
pub increment_ordered_list_bullets: Option<bool>,
pub rule_token: Option<String>,
pub rule_token_count: Option<usize>,
}
impl FormattingOptions {
pub fn validated(self) -> Self {
Self {
emphasis_token: self
.emphasis_token
.filter(|v| matches!(v.as_str(), "*" | "_")),
strong_token: self
.strong_token
.filter(|v| matches!(v.as_str(), "**" | "__")),
list_token: self
.list_token
.filter(|v| matches!(v.as_str(), "*" | "-" | "+")),
ordered_list_token: self
.ordered_list_token
.filter(|v| matches!(v.as_str(), "." | ")")),
code_block_token: self
.code_block_token
.filter(|v| matches!(v.as_str(), "`" | "~")),
code_block_token_count: self.code_block_token_count.filter(|&v| v >= 3),
increment_ordered_list_bullets: self.increment_ordered_list_bullets,
rule_token: self
.rule_token
.filter(|v| matches!(v.as_str(), "-" | "*" | "_")),
rule_token_count: self.rule_token_count.filter(|&v| v >= 3),
}
}
pub fn emphasis_token(&self) -> &str {
self.emphasis_token.as_deref().unwrap_or("*")
}
pub fn strong_token(&self) -> &str {
self.strong_token.as_deref().unwrap_or("**")
}
pub fn list_token(&self) -> &str {
self.list_token.as_deref().unwrap_or("-")
}
pub fn list_token_char(&self) -> char {
self.list_token().chars().next().unwrap_or('-')
}
pub fn ordered_list_token(&self) -> &str {
self.ordered_list_token.as_deref().unwrap_or(".")
}
pub fn ordered_list_token_char(&self) -> char {
self.ordered_list_token().chars().next().unwrap_or('.')
}
pub fn code_block_token(&self) -> &str {
self.code_block_token.as_deref().unwrap_or("`")
}
pub fn code_block_token_char(&self) -> char {
self.code_block_token().chars().next().unwrap_or('`')
}
pub fn code_block_token_count(&self) -> usize {
self.code_block_token_count.unwrap_or(3)
}
pub fn increment_ordered_list_bullets(&self) -> bool {
self.increment_ordered_list_bullets.unwrap_or(true)
}
pub fn rule_token(&self) -> &str {
self.rule_token.as_deref().unwrap_or("-")
}
pub fn rule_token_count(&self) -> usize {
self.rule_token_count.unwrap_or(72)
}
pub fn to_cmark_options(&self) -> CmarkOptions<'_> {
CmarkOptions {
newlines_after_headline: 2,
newlines_after_paragraph: 2,
newlines_after_codeblock: 2,
newlines_after_htmlblock: 1,
newlines_after_table: 2,
newlines_after_rule: 2,
newlines_after_list: 2,
newlines_after_blockquote: 2,
newlines_after_rest: 1,
newlines_after_metadata: 1,
code_block_token_count: self.code_block_token_count(),
code_block_token: self.code_block_token_char(),
list_token: self.list_token_char(),
ordered_list_token: self.ordered_list_token_char(),
increment_ordered_list_bullets: self.increment_ordered_list_bullets(),
emphasis_token: self.emphasis_token_char(),
strong_token: self.strong_token(),
use_html_for_super_sub_script: false,
}
}
fn emphasis_token_char(&self) -> char {
self.emphasis_token().chars().next().unwrap_or('*')
}
}
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
pub struct LibraryOptions {
#[serde(default)]
pub path: String,
pub date_format: Option<String>,
pub time_format: Option<String>,
pub default_template: Option<String>,
pub frontmatter_document_title: Option<String>,
pub locale: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Default, Deserialize, Serialize)]
pub struct CompletionOptions {
pub link_format: Option<LinkType>,
pub min_prefix_length: Option<usize>,
}
impl Default for LibraryOptions {
fn default() -> Self {
Self {
path: String::new(),
date_format: Some(DEFAULT_KEY_DATE_FORMAT.into()),
time_format: None,
default_template: None,
frontmatter_document_title: None,
locale: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
pub struct Configuration {
pub version: Option<u32>,
#[serde(default)]
pub markdown: MarkdownOptions,
#[serde(default)]
pub library: LibraryOptions,
#[serde(default)]
pub completion: CompletionOptions,
#[serde(default)]
pub commands: HashMap<String, Command>,
#[serde(default)]
pub actions: HashMap<String, ActionDefinition>,
#[serde(default)]
pub templates: HashMap<String, NoteTemplate>,
}
#[derive(Debug, Clone, PartialEq, Default, Deserialize, Serialize)]
pub struct Command {
pub run: String,
pub args: Option<Vec<String>>,
pub cwd: Option<String>,
pub env: Option<HashMap<String, String>>,
pub shell: Option<bool>,
pub timeout_seconds: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
#[serde(tag = "type")]
pub enum ActionDefinition {
#[serde(rename = "transform")]
Transform(Transform),
#[serde(rename = "attach")]
Attach(Attach),
#[serde(rename = "sort")]
Sort(Sort),
#[serde(rename = "inline")]
Inline(Inline),
#[serde(rename = "extract")]
Extract(Extract),
#[serde(rename = "extract_all")]
ExtractAll(ExtractAll),
#[serde(rename = "link")]
Link(Link),
}
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
pub struct Transform {
pub title: String,
pub command: String,
pub input_template: String,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
pub struct Attach {
pub title: String,
pub key_template: String,
pub document_template: String,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
pub struct Sort {
pub title: String,
pub reverse: Option<bool>,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
pub struct Inline {
pub title: String,
pub inline_type: InlineType,
pub keep_target: Option<bool>,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
pub enum InlineType {
#[serde(rename = "section")]
Section,
#[serde(rename = "quote")]
Quote,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
pub struct Extract {
pub title: String,
pub link_type: Option<LinkType>,
pub key_template: String,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
pub struct ExtractAll {
pub title: String,
pub link_type: Option<LinkType>,
pub key_template: String,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
pub struct Link {
pub title: String,
pub link_type: Option<LinkType>,
pub key_template: String,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
pub enum LinkType {
#[serde(rename = "markdown")]
Markdown,
#[serde(rename = "wiki")]
WikiLink,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
pub struct NoteTemplate {
pub key_template: String,
pub document_template: String,
}
impl Default for Configuration {
fn default() -> Self {
Self {
version: Some(1),
markdown: Default::default(),
library: Default::default(),
completion: Default::default(),
commands: Default::default(),
actions: Default::default(),
templates: Default::default(),
}
}
}
impl Configuration {
pub fn template() -> Self {
let mut template = Self {
version: Some(3),
..Default::default()
};
template.commands.insert(
"default".into(),
Command {
run: "claude -p".to_string(),
timeout_seconds: Some(120),
..Default::default()
},
);
template.actions.insert(
"today".into(),
ActionDefinition::Attach(Attach {
title: "Add Date".into(),
key_template: "{{today}}".into(),
document_template: "# {{today}}\n\n{{content}}\n".into(),
}),
);
template.actions.insert(
"rewrite".into(),
ActionDefinition::Transform(
Transform {
title: "Rewrite".into(),
command: "default".into(),
input_template: indoc! {r##"
Here's a text that I'm going to ask you to edit. The text is marked with {{context_start}}{{context_end}} tag.
The part you'll need to update is marked with {{update_start}}{{update_end}}.
{{context_start}}
{{context}}
{{context_end}}
- You can't replace entire text, your answer will be inserted in place of the {{update_start}}{{update_end}}. Don't include the {{context_start}}{{context_end}} and {{context_start}}{{context_end}} tags in your output.
- Preserve the links in the text. Do not return list item "-" or header "#" prefix
Your goal is to rewrite a given text to improve its clarity and readability. Ensure the language remains personable and not overly formal. Focus on simplifying language, organizing sentences logically, and removing ambiguity while maintaining a conversational tone.
"##}.to_string(),
}
),
);
template.actions.insert (
"expand".to_string(),
ActionDefinition::Transform(
Transform {
title: "Expand".to_string(),
command: "default".to_string(),
input_template: indoc! {r##"
Here's a text that I'm going to ask you to edit. The text is marked with {{context_start}}{{context_end}} tag.
The part you'll need to update is marked with {{update_start}}{{update_end}}.
{{context_start}}
{{context}}
{{context_end}}
- You can't replace entire text, your answer will be inserted in place of the {{update_start}}{{update_end}}. Don't include the {{context_start}}{{context_end}} and {{context_start}}{{context_end}} tags in your output.
- Preserve the links in the text. Do not return list item "-" or header "#" prefix
Expand the text you need to update, generate a couple paragraphs.
"##}.to_string(),
}
),
);
template.actions.insert (
"keywords".into(),
ActionDefinition::Transform(
Transform {
title: "Keywords".to_string(),
command: "default".to_string(),
input_template: indoc! {r##"
Here's a text that I'm going to ask you to edit. The text is marked with {{context_start}}{{context_end}} tag.
The part you'll need to update is marked with {{update_start}}{{update_end}}.
{{context_start}}
{{context}}
{{context_end}}
- You can't replace entire text, your answer will be inserted in place of the {{update_start}}{{update_end}}. Don't include the {{context_start}}{{context_end}} and {{context_start}}{{context_end}} tags in your output.
Mark most important keywords with bold using ** markdown syntax. Keep the text unchanged!
"##}.to_string(),
}
),
);
template.actions.insert(
"emoji".into(),
ActionDefinition::Transform(
Transform {
title: "Emojify".to_string(),
command: "default".to_string(),
input_template: indoc! {r##"
Here's a text that I'm going to ask you to edit. The text is marked with {{context_start}} {{context_end}} tags.
- The part you'll need to update is marked with {{update_start}} {{update_end}} tags.
- You can't replace entire text, your answer will be inserted in between {{update_start}} {{update_end}} tags.
- Add a relevant emoji one per list item (prior to list item text), header (prior to header text) or paragraph. Keep the text otherwise unchanged.
- Don't include the {{update_start}} {{update_end}} tags in your answer.
{{context_start}}
{{context}}
{{context_end}}
"##}.to_string(),
}
)
);
template.actions.insert(
"sort".into(),
ActionDefinition::Sort(Sort {
title: "Sort A-Z".into(),
reverse: Some(false),
}),
);
template.actions.insert(
"sort_desc".into(),
ActionDefinition::Sort(Sort {
title: "Sort Z-A".into(),
reverse: Some(true),
}),
);
template.actions.insert(
"inline_section".into(),
ActionDefinition::Inline(Inline {
title: "Inline section".into(),
inline_type: InlineType::Section,
keep_target: Some(false),
}),
);
template.actions.insert(
"inline_quote".into(),
ActionDefinition::Inline(Inline {
title: "Inline quote".into(),
inline_type: InlineType::Quote,
keep_target: Some(false),
}),
);
template.actions.insert(
"extract".into(),
ActionDefinition::Extract(Extract {
title: "Extract".into(),
link_type: Some(LinkType::Markdown),
key_template: "{{id}}".into(),
}),
);
template.actions.insert(
"extract_all".into(),
ActionDefinition::ExtractAll(ExtractAll {
title: "Extract all subsections".into(),
link_type: Some(LinkType::Markdown),
key_template: "{{id}}".into(),
}),
);
template.actions.insert(
"link".into(),
ActionDefinition::Link(Link {
title: "Link".into(),
link_type: Some(LinkType::Markdown),
key_template: "{{id}}".into(),
}),
);
template.templates.insert(
"default".into(),
NoteTemplate {
key_template: "{{slug}}".into(),
document_template: "# {{title}}\n\n{{content}}".into(),
},
);
template
}
}
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
pub enum Operation {
Replace,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
pub enum TargetType {
Block,
Paragraph,
List,
}
impl TargetType {
pub fn acceptable_target(&self, id: NodeId, context: impl GraphContext) -> Option<NodeId> {
match self {
TargetType::Block => Some(id).filter(|id| !context.node(*id).is_section()),
TargetType::Paragraph => Some(id).filter(|id| context.node(*id).is_leaf()),
TargetType::List => Some(id).filter(|id| context.node(*id).is_leaf()),
}
}
}
pub fn load_config() -> Configuration {
let current_dir = env::current_dir().expect("to get current dir");
let mut config_path = current_dir.clone();
config_path.push(IWE_MARKER);
config_path.push(CONFIG_FILE_NAME);
if config_path.exists() {
debug!("reading config from path: {:?}", config_path);
let configuration = migrate(&read_to_string(config_path).expect("to read config file"));
let mut config =
toml::from_str::<Configuration>(&configuration).expect("to parse config file");
config.markdown.formatting = config.markdown.formatting.validated();
config
} else {
debug!("using default configuration");
Configuration::template()
}
}
fn migrate(config: &str) -> String {
let doc = config.parse::<DocumentMut>().expect("valid TOML");
let current_version = doc
.get("version")
.and_then(|v| v.as_value())
.and_then(|v| v.as_integer())
.unwrap_or(0);
let mut updated = config.to_string();
let mut needs_update = false;
if current_version < 1 {
debug!("applying migrations from version 0 to 1");
updated = add_default_type_to_actions(&updated);
updated = add_default_code_actions(&updated);
updated = set_config_version(&updated, 1);
needs_update = true;
}
if current_version < 2 {
debug!("applying migrations from version 1 to 2");
updated = add_refs_extension_field(&updated);
updated = add_link_action(&updated);
updated = set_config_version(&updated, 2);
needs_update = true;
}
if current_version < 3 {
debug!("applying migrations from version 2 to 3");
updated = migrate_v2_to_v3(&updated);
updated = set_config_version(&updated, 3);
needs_update = true;
}
if needs_update {
debug!("configuration file migration applied");
let current_dir = env::current_dir().expect("to get current dir");
let mut config_path = current_dir.clone();
config_path.push(IWE_MARKER);
config_path.push(CONFIG_FILE_NAME);
debug!("updating configuration file");
std::fs::write(config_path, &updated).expect("to write updated config file");
}
updated
}
fn add_default_type_to_actions(input: &str) -> String {
let mut doc = input.parse::<DocumentMut>().expect("valid TOML");
if let Some(Item::Table(actions)) = doc.get_mut("actions") {
for (_, action) in actions.iter_mut() {
if let Item::Table(action_table) = action {
action_table.entry("type").or_insert(value("transform"));
}
}
}
doc.to_string()
}
fn add_refs_extension_field(input: &str) -> String {
let mut doc = input.parse::<DocumentMut>().expect("valid TOML");
if doc.get("markdown").is_none() {
doc["markdown"] = Item::Table(toml_edit::Table::new());
}
if let Some(Item::Table(markdown)) = doc.get_mut("markdown") {
if markdown.get("refs_extension").is_none() {
markdown.insert("refs_extension", value(""));
}
}
doc.to_string()
}
fn add_link_action(input: &str) -> String {
let mut doc = input.parse::<DocumentMut>().expect("valid TOML");
if doc.get("actions").is_none() {
doc["actions"] = Item::Table(toml_edit::Table::new());
}
if let Some(Item::Table(actions)) = doc.get_mut("actions") {
let has_link = actions.iter().any(|(_, action)| {
if let Item::Table(action_table) = action {
if let Some(Item::Value(action_type)) = action_table.get("type") {
if let Some(type_str) = action_type.as_str() {
return type_str == "link";
}
}
}
false
});
if !has_link {
let mut link_table = toml_edit::Table::new();
link_table.insert("type", value("link"));
link_table.insert("title", value("Link word"));
link_table.insert("link_type", value("markdown"));
link_table.insert("key_template", value("{{id}}"));
actions.insert("link", Item::Table(link_table));
}
}
doc.to_string()
}
fn set_config_version(input: &str, version: i64) -> String {
let mut doc = input.parse::<DocumentMut>().expect("valid TOML");
doc.insert("version", value(version));
doc.to_string()
}
fn add_default_code_actions(input: &str) -> String {
let mut doc = input.parse::<DocumentMut>().expect("valid TOML");
if doc.get("actions").is_none() {
doc["actions"] = Item::Table(toml_edit::Table::new());
}
if let Some(Item::Table(actions)) = doc.get_mut("actions") {
let mut has_extract = false;
let mut has_extract_all = false;
let mut has_inline = false;
for (_, action) in actions.iter() {
if let Item::Table(action_table) = action {
if let Some(Item::Value(action_type)) = action_table.get("type") {
if let Some(type_str) = action_type.as_str() {
match type_str {
"extract" => has_extract = true,
"extract_all" => has_extract_all = true,
"inline" => has_inline = true,
_ => {}
}
}
}
}
}
if !has_extract {
let mut extract_table = toml_edit::Table::new();
extract_table.insert("type", value("extract"));
extract_table.insert("title", value("Extract"));
extract_table.insert("link_type", value("markdown"));
extract_table.insert("key_template", value("{{id}}"));
actions.insert("extract", Item::Table(extract_table));
}
if !has_extract_all {
let mut extract_all_table = toml_edit::Table::new();
extract_all_table.insert("type", value("extract_all"));
extract_all_table.insert("title", value("Extract all subsections"));
extract_all_table.insert("link_type", value("markdown"));
extract_all_table.insert("key_template", value("{{id}}"));
actions.insert("extract_all", Item::Table(extract_all_table));
}
if !has_inline {
let mut inline_section_table = toml_edit::Table::new();
inline_section_table.insert("type", value("inline"));
inline_section_table.insert("title", value("Inline section"));
inline_section_table.insert("inline_type", value("section"));
inline_section_table.insert("keep_target", value(false));
actions.insert("inline_section", Item::Table(inline_section_table));
let mut inline_quote_table = toml_edit::Table::new();
inline_quote_table.insert("type", value("inline"));
inline_quote_table.insert("title", value("Inline quote"));
inline_quote_table.insert("inline_type", value("quote"));
inline_quote_table.insert("keep_target", value(false));
actions.insert("inline_quote", Item::Table(inline_quote_table));
}
}
doc.to_string()
}
pub fn migrate_v2_to_v3(input: &str) -> String {
let mut doc = input.parse::<DocumentMut>().expect("valid TOML");
if let Some(Item::Table(models)) = doc.remove("models") {
let mut commands = toml_edit::Table::new();
for (name, model) in models.iter() {
if let Item::Table(_) = model {
let mut cmd = toml_edit::Table::new();
cmd.insert("run", value(""));
commands.insert(name, Item::Table(cmd));
}
}
doc.insert("commands", Item::Table(commands));
}
if let Some(Item::Table(actions)) = doc.get_mut("actions") {
for (_, action) in actions.iter_mut() {
if let Item::Table(action_table) = action {
let is_transform = action_table
.get("type")
.and_then(|v| v.as_value())
.and_then(|v| v.as_str())
.map(|s| s == "transform")
.unwrap_or(false);
if is_transform {
if let Some(model_val) = action_table.remove("model") {
action_table.insert("command", model_val);
}
if let Some(prompt_val) = action_table.remove("prompt_template") {
action_table.insert("input_template", prompt_val);
}
action_table.remove("context");
}
}
}
}
doc.to_string()
}