mirror of
https://github.com/GothenburgBitFactory/taskwarrior.git
synced 2025-06-26 10:54:26 +02:00
Merge pull request #228 from taskchampion/issue164-edit
Support editing config file from the command line
This commit is contained in:
commit
fd4b35f3fe
13 changed files with 600 additions and 227 deletions
46
Cargo.lock
generated
46
Cargo.lock
generated
|
@ -324,6 +324,12 @@ version = "0.5.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b"
|
checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ascii"
|
||||||
|
version = "0.9.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "eab1c04a571841102f5345a8fc0f6bb3d31c315dec879b5c6e42e40ce7ffa34e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "assert_cmd"
|
name = "assert_cmd"
|
||||||
version = "1.0.3"
|
version = "1.0.3"
|
||||||
|
@ -573,6 +579,19 @@ dependencies = [
|
||||||
"vec_map",
|
"vec_map",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "combine"
|
||||||
|
version = "3.8.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "da3da6baa321ec19e1cc41d31bf599f00c783d0517095cdaf0332e3fe8d20680"
|
||||||
|
dependencies = [
|
||||||
|
"ascii",
|
||||||
|
"byteorder",
|
||||||
|
"either",
|
||||||
|
"memchr",
|
||||||
|
"unreachable",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "const_fn"
|
name = "const_fn"
|
||||||
version = "0.4.6"
|
version = "0.4.6"
|
||||||
|
@ -2158,6 +2177,7 @@ dependencies = [
|
||||||
"termcolor",
|
"termcolor",
|
||||||
"textwrap 0.13.4",
|
"textwrap 0.13.4",
|
||||||
"toml",
|
"toml",
|
||||||
|
"toml_edit",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
@ -2404,6 +2424,17 @@ dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_edit"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "09391a441b373597cf0888d2b052dcf82c5be4fee05da3636ae30fb57aad8484"
|
||||||
|
dependencies = [
|
||||||
|
"chrono",
|
||||||
|
"combine",
|
||||||
|
"linked-hash-map",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tracing"
|
name = "tracing"
|
||||||
version = "0.1.25"
|
version = "0.1.25"
|
||||||
|
@ -2522,6 +2553,15 @@ version = "0.2.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f7fe0bb3479651439c9112f72b6c505038574c9fbb575ed1bf3b797fa39dd564"
|
checksum = "f7fe0bb3479651439c9112f72b6c505038574c9fbb575ed1bf3b797fa39dd564"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unreachable"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "382810877fe448991dfc7f0dd6e3ae5d58088fd0ea5e35189655f84e6814fa56"
|
||||||
|
dependencies = [
|
||||||
|
"void",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "untrusted"
|
name = "untrusted"
|
||||||
version = "0.7.1"
|
version = "0.7.1"
|
||||||
|
@ -2578,6 +2618,12 @@ version = "0.9.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5fecdca9a5291cc2b8dcf7dc02453fee791a280f3743cb0905f8822ae463b3fe"
|
checksum = "5fecdca9a5291cc2b8dcf7dc02453fee791a280f3743cb0905f8822ae463b3fe"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "void"
|
||||||
|
version = "1.0.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wait-timeout"
|
name = "wait-timeout"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
|
|
|
@ -15,6 +15,7 @@ textwrap = { version="^0.13.4", features=["terminal_size"] }
|
||||||
termcolor = "^1.1.2"
|
termcolor = "^1.1.2"
|
||||||
atty = "^0.2.14"
|
atty = "^0.2.14"
|
||||||
toml = "^0.5.8"
|
toml = "^0.5.8"
|
||||||
|
toml_edit = "^0.2.0"
|
||||||
|
|
||||||
[dependencies.taskchampion]
|
[dependencies.taskchampion]
|
||||||
path = "../taskchampion"
|
path = "../taskchampion"
|
||||||
|
|
36
cli/src/argparse/config.rs
Normal file
36
cli/src/argparse/config.rs
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
use super::args::{any, arg_matching, literal};
|
||||||
|
use super::ArgList;
|
||||||
|
use crate::usage;
|
||||||
|
use nom::{combinator::*, sequence::*, IResult};
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq)]
|
||||||
|
/// A config operation
|
||||||
|
pub(crate) enum ConfigOperation {
|
||||||
|
/// Set a configuration value
|
||||||
|
Set(String, String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ConfigOperation {
|
||||||
|
pub(super) fn parse(input: ArgList) -> IResult<ArgList, ConfigOperation> {
|
||||||
|
fn set_to_op(input: (&str, &str, &str)) -> Result<ConfigOperation, ()> {
|
||||||
|
Ok(ConfigOperation::Set(input.1.to_owned(), input.2.to_owned()))
|
||||||
|
}
|
||||||
|
map_res(
|
||||||
|
tuple((
|
||||||
|
arg_matching(literal("set")),
|
||||||
|
arg_matching(any),
|
||||||
|
arg_matching(any),
|
||||||
|
)),
|
||||||
|
set_to_op,
|
||||||
|
)(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn get_usage(u: &mut usage::Usage) {
|
||||||
|
u.subcommands.push(usage::Subcommand {
|
||||||
|
name: "config set",
|
||||||
|
syntax: "config set <key> <value>",
|
||||||
|
summary: "Set a configuration value",
|
||||||
|
description: "Update Taskchampion configuration file to set key = value",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -18,12 +18,14 @@ That is, they contain no references, and have no methods to aid in their executi
|
||||||
*/
|
*/
|
||||||
mod args;
|
mod args;
|
||||||
mod command;
|
mod command;
|
||||||
|
mod config;
|
||||||
mod filter;
|
mod filter;
|
||||||
mod modification;
|
mod modification;
|
||||||
mod subcommand;
|
mod subcommand;
|
||||||
|
|
||||||
pub(crate) use args::TaskId;
|
pub(crate) use args::TaskId;
|
||||||
pub(crate) use command::Command;
|
pub(crate) use command::Command;
|
||||||
|
pub(crate) use config::ConfigOperation;
|
||||||
pub(crate) use filter::{Condition, Filter};
|
pub(crate) use filter::{Condition, Filter};
|
||||||
pub(crate) use modification::{DescriptionMod, Modification};
|
pub(crate) use modification::{DescriptionMod, Modification};
|
||||||
pub(crate) use subcommand::Subcommand;
|
pub(crate) use subcommand::Subcommand;
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
use super::args::*;
|
use super::args::*;
|
||||||
use super::{ArgList, DescriptionMod, Filter, Modification};
|
use super::{ArgList, ConfigOperation, DescriptionMod, Filter, Modification};
|
||||||
use crate::usage;
|
use crate::usage;
|
||||||
use nom::{branch::alt, combinator::*, sequence::*, IResult};
|
use nom::{branch::alt, combinator::*, sequence::*, IResult};
|
||||||
use taskchampion::Status;
|
use taskchampion::Status;
|
||||||
|
@ -25,6 +25,11 @@ pub(crate) enum Subcommand {
|
||||||
summary: bool,
|
summary: bool,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/// Manipulate configuration
|
||||||
|
Config {
|
||||||
|
config_operation: ConfigOperation,
|
||||||
|
},
|
||||||
|
|
||||||
/// Add a new task
|
/// Add a new task
|
||||||
Add {
|
Add {
|
||||||
modification: Modification,
|
modification: Modification,
|
||||||
|
@ -61,6 +66,7 @@ impl Subcommand {
|
||||||
all_consuming(alt((
|
all_consuming(alt((
|
||||||
Version::parse,
|
Version::parse,
|
||||||
Help::parse,
|
Help::parse,
|
||||||
|
Config::parse,
|
||||||
Add::parse,
|
Add::parse,
|
||||||
Modify::parse,
|
Modify::parse,
|
||||||
Info::parse,
|
Info::parse,
|
||||||
|
@ -74,6 +80,7 @@ impl Subcommand {
|
||||||
pub(super) fn get_usage(u: &mut usage::Usage) {
|
pub(super) fn get_usage(u: &mut usage::Usage) {
|
||||||
Version::get_usage(u);
|
Version::get_usage(u);
|
||||||
Help::get_usage(u);
|
Help::get_usage(u);
|
||||||
|
Config::get_usage(u);
|
||||||
Add::get_usage(u);
|
Add::get_usage(u);
|
||||||
Modify::get_usage(u);
|
Modify::get_usage(u);
|
||||||
Info::get_usage(u);
|
Info::get_usage(u);
|
||||||
|
@ -131,6 +138,26 @@ impl Help {
|
||||||
fn get_usage(_u: &mut usage::Usage) {}
|
fn get_usage(_u: &mut usage::Usage) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct Config;
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
fn parse(input: ArgList) -> IResult<ArgList, Subcommand> {
|
||||||
|
fn to_subcommand(input: (&str, ConfigOperation)) -> Result<Subcommand, ()> {
|
||||||
|
Ok(Subcommand::Config {
|
||||||
|
config_operation: input.1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
map_res(
|
||||||
|
tuple((arg_matching(literal("config")), ConfigOperation::parse)),
|
||||||
|
to_subcommand,
|
||||||
|
)(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_usage(u: &mut usage::Usage) {
|
||||||
|
ConfigOperation::get_usage(u);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct Add;
|
struct Add;
|
||||||
|
|
||||||
impl Add {
|
impl Add {
|
||||||
|
@ -427,6 +454,19 @@ mod test {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_config_set() {
|
||||||
|
assert_eq!(
|
||||||
|
Subcommand::parse(argv!["config", "set", "x", "y"]).unwrap(),
|
||||||
|
(
|
||||||
|
&EMPTY[..],
|
||||||
|
Subcommand::Config {
|
||||||
|
config_operation: ConfigOperation::Set("x".to_owned(), "y".to_owned())
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_add_description() {
|
fn test_add_description() {
|
||||||
let subcommand = Subcommand::Add {
|
let subcommand = Subcommand::Add {
|
||||||
|
|
62
cli/src/invocation/cmd/config.rs
Normal file
62
cli/src/invocation/cmd/config.rs
Normal file
|
@ -0,0 +1,62 @@
|
||||||
|
use crate::argparse::ConfigOperation;
|
||||||
|
use crate::settings::Settings;
|
||||||
|
use termcolor::{ColorSpec, WriteColor};
|
||||||
|
|
||||||
|
pub(crate) fn execute<W: WriteColor>(
|
||||||
|
w: &mut W,
|
||||||
|
config_operation: ConfigOperation,
|
||||||
|
settings: &Settings,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
match config_operation {
|
||||||
|
ConfigOperation::Set(key, value) => {
|
||||||
|
let filename = settings.set(&key, &value)?;
|
||||||
|
write!(w, "Set configuration value ")?;
|
||||||
|
w.set_color(ColorSpec::new().set_bold(true))?;
|
||||||
|
write!(w, "{}", &key)?;
|
||||||
|
w.set_color(ColorSpec::new().set_bold(false))?;
|
||||||
|
write!(w, " in ")?;
|
||||||
|
w.set_color(ColorSpec::new().set_bold(true))?;
|
||||||
|
writeln!(w, "{:?}.", filename)?;
|
||||||
|
w.set_color(ColorSpec::new().set_bold(false))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod test {
|
||||||
|
use super::*;
|
||||||
|
use crate::invocation::test::*;
|
||||||
|
use std::fs;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_config_set() {
|
||||||
|
let cfg_dir = TempDir::new().unwrap();
|
||||||
|
let cfg_file = cfg_dir.path().join("foo.toml");
|
||||||
|
fs::write(
|
||||||
|
cfg_file.clone(),
|
||||||
|
"# store data everywhere\ndata_dir = \"/nowhere\"\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let settings = Settings::load_from_file(cfg_file.clone(), true).unwrap();
|
||||||
|
|
||||||
|
let mut w = test_writer();
|
||||||
|
|
||||||
|
execute(
|
||||||
|
&mut w,
|
||||||
|
ConfigOperation::Set("data_dir".to_owned(), "/somewhere".to_owned()),
|
||||||
|
&settings,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(w.into_string().starts_with("Set configuration value "));
|
||||||
|
|
||||||
|
let updated_toml = fs::read_to_string(cfg_file.clone()).unwrap();
|
||||||
|
dbg!(&updated_toml);
|
||||||
|
assert_eq!(
|
||||||
|
updated_toml,
|
||||||
|
"# store data everywhere\ndata_dir = \"/somewhere\"\n"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,6 +1,7 @@
|
||||||
//! Responsible for executing commands as parsed by [`crate::argparse`].
|
//! Responsible for executing commands as parsed by [`crate::argparse`].
|
||||||
|
|
||||||
pub(crate) mod add;
|
pub(crate) mod add;
|
||||||
|
pub(crate) mod config;
|
||||||
pub(crate) mod gc;
|
pub(crate) mod gc;
|
||||||
pub(crate) mod help;
|
pub(crate) mod help;
|
||||||
pub(crate) mod info;
|
pub(crate) mod info;
|
||||||
|
|
|
@ -35,6 +35,10 @@ pub(crate) fn invoke(command: Command, settings: Settings) -> anyhow::Result<()>
|
||||||
subcommand: Subcommand::Help { summary },
|
subcommand: Subcommand::Help { summary },
|
||||||
command_name,
|
command_name,
|
||||||
} => return cmd::help::execute(&mut w, command_name, summary),
|
} => return cmd::help::execute(&mut w, command_name, summary),
|
||||||
|
Command {
|
||||||
|
subcommand: Subcommand::Config { config_operation },
|
||||||
|
..
|
||||||
|
} => return cmd::config::execute(&mut w, config_operation, &settings),
|
||||||
Command {
|
Command {
|
||||||
subcommand: Subcommand::Version,
|
subcommand: Subcommand::Version,
|
||||||
..
|
..
|
||||||
|
@ -90,6 +94,10 @@ pub(crate) fn invoke(command: Command, settings: Settings) -> anyhow::Result<()>
|
||||||
subcommand: Subcommand::Help { .. },
|
subcommand: Subcommand::Help { .. },
|
||||||
..
|
..
|
||||||
} => unreachable!(),
|
} => unreachable!(),
|
||||||
|
Command {
|
||||||
|
subcommand: Subcommand::Config { .. },
|
||||||
|
..
|
||||||
|
} => unreachable!(),
|
||||||
Command {
|
Command {
|
||||||
subcommand: Subcommand::Version,
|
subcommand: Subcommand::Version,
|
||||||
..
|
..
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
#![deny(clippy::all)]
|
#![deny(clippy::all)]
|
||||||
#![allow(clippy::unnecessary_wraps)] // for Rust 1.50, https://github.com/rust-lang/rust-clippy/pull/6765
|
#![allow(clippy::unnecessary_wraps)] // for Rust 1.50, https://github.com/rust-lang/rust-clippy/pull/6765
|
||||||
|
#![allow(clippy::module_inception)] // we use re-exports to shorten stuttering paths like settings::settings::Settings
|
||||||
/*!
|
/*!
|
||||||
This crate implements the command-line interface to TaskChampion.
|
This crate implements the command-line interface to TaskChampion.
|
||||||
|
|
||||||
|
|
|
@ -4,230 +4,8 @@
|
||||||
//! startup and not just when those values are used.
|
//! startup and not just when those values are used.
|
||||||
|
|
||||||
mod report;
|
mod report;
|
||||||
|
mod settings;
|
||||||
mod util;
|
mod util;
|
||||||
|
|
||||||
use crate::argparse::{Condition, Filter};
|
|
||||||
use anyhow::{anyhow, Context, Result};
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::convert::TryFrom;
|
|
||||||
use std::env;
|
|
||||||
use std::fs;
|
|
||||||
use std::path::PathBuf;
|
|
||||||
use taskchampion::Status;
|
|
||||||
use toml::value::Table;
|
|
||||||
use util::table_with_keys;
|
|
||||||
|
|
||||||
pub(crate) use report::{Column, Property, Report, Sort, SortBy};
|
pub(crate) use report::{Column, Property, Report, Sort, SortBy};
|
||||||
|
pub(crate) use settings::Settings;
|
||||||
#[derive(Debug)]
|
|
||||||
pub(crate) struct Settings {
|
|
||||||
// replica
|
|
||||||
pub(crate) data_dir: PathBuf,
|
|
||||||
|
|
||||||
// remote sync server
|
|
||||||
pub(crate) server_client_key: Option<String>,
|
|
||||||
pub(crate) server_origin: Option<String>,
|
|
||||||
pub(crate) encryption_secret: Option<String>,
|
|
||||||
|
|
||||||
// local sync server
|
|
||||||
pub(crate) server_dir: PathBuf,
|
|
||||||
|
|
||||||
// reports
|
|
||||||
pub(crate) reports: HashMap<String, Report>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Settings {
|
|
||||||
pub(crate) fn read() -> Result<Self> {
|
|
||||||
if let Some(config_file) = env::var_os("TASKCHAMPION_CONFIG") {
|
|
||||||
log::debug!("Loading configuration from {:?}", config_file);
|
|
||||||
env::remove_var("TASKCHAMPION_CONFIG");
|
|
||||||
Self::load_from_file(config_file.into(), true)
|
|
||||||
} else if let Some(mut dir) = dirs_next::config_dir() {
|
|
||||||
dir.push("taskchampion.toml");
|
|
||||||
log::debug!("Loading configuration from {:?} (optional)", dir);
|
|
||||||
Self::load_from_file(dir, false)
|
|
||||||
} else {
|
|
||||||
Ok(Default::default())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn load_from_file(config_file: PathBuf, required: bool) -> Result<Self> {
|
|
||||||
let mut settings = Self::default();
|
|
||||||
|
|
||||||
let config_toml = match fs::read_to_string(config_file.clone()) {
|
|
||||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
|
||||||
return if required {
|
|
||||||
Err(e.into())
|
|
||||||
} else {
|
|
||||||
Ok(settings)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
Err(e) => return Err(e.into()),
|
|
||||||
Ok(s) => s,
|
|
||||||
};
|
|
||||||
|
|
||||||
let config_toml = config_toml
|
|
||||||
.parse::<toml::Value>()
|
|
||||||
.with_context(|| format!("error while reading {:?}", config_file))?;
|
|
||||||
settings
|
|
||||||
.update_from_toml(&config_toml)
|
|
||||||
.with_context(|| format!("error while parsing {:?}", config_file))?;
|
|
||||||
|
|
||||||
Ok(settings)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Update this object with configuration from the given config file. This is
|
|
||||||
/// broken out mostly for convenience in error handling
|
|
||||||
fn update_from_toml(&mut self, config_toml: &toml::Value) -> Result<()> {
|
|
||||||
let table_keys = [
|
|
||||||
"data_dir",
|
|
||||||
"server_client_key",
|
|
||||||
"server_origin",
|
|
||||||
"encryption_secret",
|
|
||||||
"server_dir",
|
|
||||||
"reports",
|
|
||||||
];
|
|
||||||
let table = table_with_keys(&config_toml, &table_keys)?;
|
|
||||||
|
|
||||||
fn get_str_cfg<F: FnOnce(String)>(
|
|
||||||
table: &Table,
|
|
||||||
name: &'static str,
|
|
||||||
setter: F,
|
|
||||||
) -> Result<()> {
|
|
||||||
if let Some(v) = table.get(name) {
|
|
||||||
setter(
|
|
||||||
v.as_str()
|
|
||||||
.ok_or_else(|| anyhow!(".{}: not a string", name))?
|
|
||||||
.to_owned(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
get_str_cfg(table, "data_dir", |v| {
|
|
||||||
self.data_dir = v.into();
|
|
||||||
})?;
|
|
||||||
|
|
||||||
get_str_cfg(table, "server_client_key", |v| {
|
|
||||||
self.server_client_key = Some(v);
|
|
||||||
})?;
|
|
||||||
|
|
||||||
get_str_cfg(table, "server_origin", |v| {
|
|
||||||
self.server_origin = Some(v);
|
|
||||||
})?;
|
|
||||||
|
|
||||||
get_str_cfg(table, "encryption_secret", |v| {
|
|
||||||
self.encryption_secret = Some(v);
|
|
||||||
})?;
|
|
||||||
|
|
||||||
get_str_cfg(table, "server_dir", |v| {
|
|
||||||
self.server_dir = v.into();
|
|
||||||
})?;
|
|
||||||
|
|
||||||
if let Some(v) = table.get("reports") {
|
|
||||||
let report_cfgs = v
|
|
||||||
.as_table()
|
|
||||||
.ok_or_else(|| anyhow!(".reports: not a table"))?;
|
|
||||||
for (name, cfg) in report_cfgs {
|
|
||||||
let report = Report::try_from(cfg).map_err(|e| anyhow!("reports.{}{}", name, e))?;
|
|
||||||
self.reports.insert(name.clone(), report);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for Settings {
|
|
||||||
fn default() -> Self {
|
|
||||||
let data_dir;
|
|
||||||
let server_dir;
|
|
||||||
|
|
||||||
if let Some(dir) = dirs_next::data_local_dir() {
|
|
||||||
data_dir = dir.join("taskchampion");
|
|
||||||
server_dir = dir.join("taskchampion-sync-server");
|
|
||||||
} else {
|
|
||||||
// fallback
|
|
||||||
data_dir = PathBuf::from(".");
|
|
||||||
server_dir = PathBuf::from(".");
|
|
||||||
}
|
|
||||||
|
|
||||||
// define the default reports
|
|
||||||
let mut reports = HashMap::new();
|
|
||||||
|
|
||||||
reports.insert(
|
|
||||||
"list".to_owned(),
|
|
||||||
Report {
|
|
||||||
sort: vec![Sort {
|
|
||||||
ascending: true,
|
|
||||||
sort_by: SortBy::Uuid,
|
|
||||||
}],
|
|
||||||
columns: vec![
|
|
||||||
Column {
|
|
||||||
label: "id".to_owned(),
|
|
||||||
property: Property::Id,
|
|
||||||
},
|
|
||||||
Column {
|
|
||||||
label: "description".to_owned(),
|
|
||||||
property: Property::Description,
|
|
||||||
},
|
|
||||||
Column {
|
|
||||||
label: "active".to_owned(),
|
|
||||||
property: Property::Active,
|
|
||||||
},
|
|
||||||
Column {
|
|
||||||
label: "tags".to_owned(),
|
|
||||||
property: Property::Tags,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
filter: Default::default(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
reports.insert(
|
|
||||||
"next".to_owned(),
|
|
||||||
Report {
|
|
||||||
sort: vec![
|
|
||||||
Sort {
|
|
||||||
ascending: true,
|
|
||||||
sort_by: SortBy::Id,
|
|
||||||
},
|
|
||||||
Sort {
|
|
||||||
ascending: true,
|
|
||||||
sort_by: SortBy::Uuid,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
columns: vec![
|
|
||||||
Column {
|
|
||||||
label: "id".to_owned(),
|
|
||||||
property: Property::Id,
|
|
||||||
},
|
|
||||||
Column {
|
|
||||||
label: "description".to_owned(),
|
|
||||||
property: Property::Description,
|
|
||||||
},
|
|
||||||
Column {
|
|
||||||
label: "active".to_owned(),
|
|
||||||
property: Property::Active,
|
|
||||||
},
|
|
||||||
Column {
|
|
||||||
label: "tags".to_owned(),
|
|
||||||
property: Property::Tags,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
filter: Filter {
|
|
||||||
conditions: vec![Condition::Status(Status::Pending)],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
Self {
|
|
||||||
data_dir,
|
|
||||||
server_client_key: None,
|
|
||||||
server_origin: None,
|
|
||||||
encryption_secret: None,
|
|
||||||
server_dir,
|
|
||||||
reports,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
364
cli/src/settings/settings.rs
Normal file
364
cli/src/settings/settings.rs
Normal file
|
@ -0,0 +1,364 @@
|
||||||
|
use super::util::table_with_keys;
|
||||||
|
use super::{Column, Property, Report, Sort, SortBy};
|
||||||
|
use crate::argparse::{Condition, Filter};
|
||||||
|
use anyhow::{anyhow, bail, Context, Result};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::convert::TryFrom;
|
||||||
|
use std::env;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use taskchampion::Status;
|
||||||
|
use toml::value::Table;
|
||||||
|
use toml_edit::Document;
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq)]
|
||||||
|
pub(crate) struct Settings {
|
||||||
|
// filename from which this configuration was loaded, if any
|
||||||
|
pub(crate) filename: Option<PathBuf>,
|
||||||
|
|
||||||
|
// replica
|
||||||
|
pub(crate) data_dir: PathBuf,
|
||||||
|
|
||||||
|
// remote sync server
|
||||||
|
pub(crate) server_client_key: Option<String>,
|
||||||
|
pub(crate) server_origin: Option<String>,
|
||||||
|
pub(crate) encryption_secret: Option<String>,
|
||||||
|
|
||||||
|
// local sync server
|
||||||
|
pub(crate) server_dir: PathBuf,
|
||||||
|
|
||||||
|
// reports
|
||||||
|
pub(crate) reports: HashMap<String, Report>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Settings {
|
||||||
|
pub(crate) fn read() -> Result<Self> {
|
||||||
|
if let Some(config_file) = env::var_os("TASKCHAMPION_CONFIG") {
|
||||||
|
log::debug!("Loading configuration from {:?}", config_file);
|
||||||
|
env::remove_var("TASKCHAMPION_CONFIG");
|
||||||
|
Self::load_from_file(config_file.into(), true)
|
||||||
|
} else if let Some(filename) = Settings::default_filename() {
|
||||||
|
log::debug!("Loading configuration from {:?} (optional)", filename);
|
||||||
|
Self::load_from_file(filename, false)
|
||||||
|
} else {
|
||||||
|
Ok(Default::default())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the default filename for the configuration, or None if that cannot
|
||||||
|
/// be determined.
|
||||||
|
fn default_filename() -> Option<PathBuf> {
|
||||||
|
if let Some(dir) = dirs_next::config_dir() {
|
||||||
|
Some(dir.join("taskchampion.toml"))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update this settings object with the contents of the given TOML file. Top-level settings
|
||||||
|
/// are overwritten, and reports are overwritten by name.
|
||||||
|
pub(crate) fn load_from_file(config_file: PathBuf, required: bool) -> Result<Self> {
|
||||||
|
let mut settings = Self::default();
|
||||||
|
|
||||||
|
let config_toml = match fs::read_to_string(config_file.clone()) {
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||||
|
return if required {
|
||||||
|
Err(e.into())
|
||||||
|
} else {
|
||||||
|
settings.filename = Some(config_file);
|
||||||
|
Ok(settings)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
Err(e) => return Err(e.into()),
|
||||||
|
Ok(s) => s,
|
||||||
|
};
|
||||||
|
|
||||||
|
let config_toml = config_toml
|
||||||
|
.parse::<toml::Value>()
|
||||||
|
.with_context(|| format!("error while reading {:?}", config_file))?;
|
||||||
|
|
||||||
|
settings.filename = Some(config_file.clone());
|
||||||
|
settings
|
||||||
|
.update_from_toml(&config_toml)
|
||||||
|
.with_context(|| format!("error while parsing {:?}", config_file))?;
|
||||||
|
|
||||||
|
Ok(settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update this object with configuration from the given config file. This is
|
||||||
|
/// broken out mostly for convenience in error handling
|
||||||
|
fn update_from_toml(&mut self, config_toml: &toml::Value) -> Result<()> {
|
||||||
|
let table_keys = [
|
||||||
|
"data_dir",
|
||||||
|
"server_client_key",
|
||||||
|
"server_origin",
|
||||||
|
"encryption_secret",
|
||||||
|
"server_dir",
|
||||||
|
"reports",
|
||||||
|
];
|
||||||
|
let table = table_with_keys(&config_toml, &table_keys)?;
|
||||||
|
|
||||||
|
fn get_str_cfg<F: FnOnce(String)>(
|
||||||
|
table: &Table,
|
||||||
|
name: &'static str,
|
||||||
|
setter: F,
|
||||||
|
) -> Result<()> {
|
||||||
|
if let Some(v) = table.get(name) {
|
||||||
|
setter(
|
||||||
|
v.as_str()
|
||||||
|
.ok_or_else(|| anyhow!(".{}: not a string", name))?
|
||||||
|
.to_owned(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
get_str_cfg(table, "data_dir", |v| {
|
||||||
|
self.data_dir = v.into();
|
||||||
|
})?;
|
||||||
|
|
||||||
|
get_str_cfg(table, "server_client_key", |v| {
|
||||||
|
self.server_client_key = Some(v);
|
||||||
|
})?;
|
||||||
|
|
||||||
|
get_str_cfg(table, "server_origin", |v| {
|
||||||
|
self.server_origin = Some(v);
|
||||||
|
})?;
|
||||||
|
|
||||||
|
get_str_cfg(table, "encryption_secret", |v| {
|
||||||
|
self.encryption_secret = Some(v);
|
||||||
|
})?;
|
||||||
|
|
||||||
|
get_str_cfg(table, "server_dir", |v| {
|
||||||
|
self.server_dir = v.into();
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if let Some(v) = table.get("reports") {
|
||||||
|
let report_cfgs = v
|
||||||
|
.as_table()
|
||||||
|
.ok_or_else(|| anyhow!(".reports: not a table"))?;
|
||||||
|
for (name, cfg) in report_cfgs {
|
||||||
|
let report = Report::try_from(cfg).map_err(|e| anyhow!("reports.{}{}", name, e))?;
|
||||||
|
self.reports.insert(name.clone(), report);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set a value in the config file, modifying it in place. Returns the filename.
|
||||||
|
pub(crate) fn set(&self, key: &str, value: &str) -> Result<PathBuf> {
|
||||||
|
let allowed_keys = [
|
||||||
|
"data_dir",
|
||||||
|
"server_client_key",
|
||||||
|
"server_origin",
|
||||||
|
"encryption_secret",
|
||||||
|
"server_dir",
|
||||||
|
// reports is not allowed, since it is not a string
|
||||||
|
];
|
||||||
|
if !allowed_keys.contains(&key) {
|
||||||
|
bail!("No such configuration key {}", key);
|
||||||
|
}
|
||||||
|
|
||||||
|
let filename = if let Some(ref f) = self.filename {
|
||||||
|
f.clone()
|
||||||
|
} else {
|
||||||
|
Settings::default_filename()
|
||||||
|
.ok_or_else(|| anyhow!("Could not determine config file name"))?
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut document = fs::read_to_string(filename.clone())
|
||||||
|
.context("Could not read existing configuration file")?
|
||||||
|
.parse::<Document>()
|
||||||
|
.context("Could not parse existing configuration file")?;
|
||||||
|
|
||||||
|
document[key] = toml_edit::value(value);
|
||||||
|
|
||||||
|
fs::write(filename.clone(), document.to_string())
|
||||||
|
.context("Could not write updated configuration file")?;
|
||||||
|
|
||||||
|
Ok(filename)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Settings {
|
||||||
|
fn default() -> Self {
|
||||||
|
let data_dir;
|
||||||
|
let server_dir;
|
||||||
|
|
||||||
|
if let Some(dir) = dirs_next::data_local_dir() {
|
||||||
|
data_dir = dir.join("taskchampion");
|
||||||
|
server_dir = dir.join("taskchampion-sync-server");
|
||||||
|
} else {
|
||||||
|
// fallback
|
||||||
|
data_dir = PathBuf::from(".");
|
||||||
|
server_dir = PathBuf::from(".");
|
||||||
|
}
|
||||||
|
|
||||||
|
// define the default reports
|
||||||
|
let mut reports = HashMap::new();
|
||||||
|
|
||||||
|
reports.insert(
|
||||||
|
"list".to_owned(),
|
||||||
|
Report {
|
||||||
|
sort: vec![Sort {
|
||||||
|
ascending: true,
|
||||||
|
sort_by: SortBy::Uuid,
|
||||||
|
}],
|
||||||
|
columns: vec![
|
||||||
|
Column {
|
||||||
|
label: "id".to_owned(),
|
||||||
|
property: Property::Id,
|
||||||
|
},
|
||||||
|
Column {
|
||||||
|
label: "description".to_owned(),
|
||||||
|
property: Property::Description,
|
||||||
|
},
|
||||||
|
Column {
|
||||||
|
label: "active".to_owned(),
|
||||||
|
property: Property::Active,
|
||||||
|
},
|
||||||
|
Column {
|
||||||
|
label: "tags".to_owned(),
|
||||||
|
property: Property::Tags,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
filter: Default::default(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
reports.insert(
|
||||||
|
"next".to_owned(),
|
||||||
|
Report {
|
||||||
|
sort: vec![
|
||||||
|
Sort {
|
||||||
|
ascending: true,
|
||||||
|
sort_by: SortBy::Id,
|
||||||
|
},
|
||||||
|
Sort {
|
||||||
|
ascending: true,
|
||||||
|
sort_by: SortBy::Uuid,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
columns: vec![
|
||||||
|
Column {
|
||||||
|
label: "id".to_owned(),
|
||||||
|
property: Property::Id,
|
||||||
|
},
|
||||||
|
Column {
|
||||||
|
label: "description".to_owned(),
|
||||||
|
property: Property::Description,
|
||||||
|
},
|
||||||
|
Column {
|
||||||
|
label: "active".to_owned(),
|
||||||
|
property: Property::Active,
|
||||||
|
},
|
||||||
|
Column {
|
||||||
|
label: "tags".to_owned(),
|
||||||
|
property: Property::Tags,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
filter: Filter {
|
||||||
|
conditions: vec![Condition::Status(Status::Pending)],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
Self {
|
||||||
|
filename: None,
|
||||||
|
data_dir,
|
||||||
|
server_client_key: None,
|
||||||
|
server_origin: None,
|
||||||
|
encryption_secret: None,
|
||||||
|
server_dir,
|
||||||
|
reports,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod test {
|
||||||
|
use super::*;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
use toml::toml;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_load_from_file_not_required() {
|
||||||
|
let cfg_dir = TempDir::new().unwrap();
|
||||||
|
let cfg_file = cfg_dir.path().join("foo.toml");
|
||||||
|
|
||||||
|
let settings = Settings::load_from_file(cfg_file.clone(), false).unwrap();
|
||||||
|
|
||||||
|
let mut expected = Settings::default();
|
||||||
|
expected.filename = Some(cfg_file.clone());
|
||||||
|
assert_eq!(settings, expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_load_from_file_required() {
|
||||||
|
let cfg_dir = TempDir::new().unwrap();
|
||||||
|
|
||||||
|
assert!(Settings::load_from_file(cfg_dir.path().join("foo.toml"), true).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_load_from_file_exists() {
|
||||||
|
let cfg_dir = TempDir::new().unwrap();
|
||||||
|
let cfg_file = cfg_dir.path().join("foo.toml");
|
||||||
|
fs::write(cfg_file.clone(), "data_dir = \"/nowhere\"").unwrap();
|
||||||
|
|
||||||
|
let settings = Settings::load_from_file(cfg_file.clone(), true).unwrap();
|
||||||
|
assert_eq!(settings.data_dir, PathBuf::from("/nowhere"));
|
||||||
|
assert_eq!(settings.filename, Some(cfg_file));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_update_from_toml_top_level_keys() {
|
||||||
|
let val = toml! {
|
||||||
|
data_dir = "/data"
|
||||||
|
server_client_key = "sck"
|
||||||
|
server_origin = "so"
|
||||||
|
encryption_secret = "es"
|
||||||
|
server_dir = "/server"
|
||||||
|
};
|
||||||
|
let mut settings = Settings::default();
|
||||||
|
settings.update_from_toml(&val).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(settings.data_dir, PathBuf::from("/data"));
|
||||||
|
assert_eq!(settings.server_client_key, Some("sck".to_owned()));
|
||||||
|
assert_eq!(settings.server_origin, Some("so".to_owned()));
|
||||||
|
assert_eq!(settings.encryption_secret, Some("es".to_owned()));
|
||||||
|
assert_eq!(settings.server_dir, PathBuf::from("/server"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_update_from_toml_report() {
|
||||||
|
let val = toml! {
|
||||||
|
[reports.foo]
|
||||||
|
sort = [ { sort_by = "id" } ]
|
||||||
|
columns = [ { label = "ID", property = "id" } ]
|
||||||
|
};
|
||||||
|
let mut settings = Settings::default();
|
||||||
|
settings.update_from_toml(&val).unwrap();
|
||||||
|
|
||||||
|
assert!(settings.reports.get("foo").is_some());
|
||||||
|
// beyond existence of this report, we can rely on Report's unit tests
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_set_valid_key() {
|
||||||
|
let cfg_dir = TempDir::new().unwrap();
|
||||||
|
let cfg_file = cfg_dir.path().join("foo.toml");
|
||||||
|
fs::write(cfg_file.clone(), "server_dir = \"/srv\"").unwrap();
|
||||||
|
|
||||||
|
let settings = Settings::load_from_file(cfg_file.clone(), true).unwrap();
|
||||||
|
assert_eq!(settings.filename, Some(cfg_file.clone()));
|
||||||
|
settings.set("data_dir", "/data").unwrap();
|
||||||
|
|
||||||
|
// load the file again and see the change
|
||||||
|
let settings = Settings::load_from_file(cfg_file.clone(), true).unwrap();
|
||||||
|
assert_eq!(settings.data_dir, PathBuf::from("/data"));
|
||||||
|
assert_eq!(settings.server_dir, PathBuf::from("/srv"));
|
||||||
|
assert_eq!(settings.filename, Some(cfg_file));
|
||||||
|
}
|
||||||
|
}
|
|
@ -40,7 +40,15 @@ If using a remote server:
|
||||||
* `server_client_key` - Client key to identify this replica to the sync server (a UUID)
|
* `server_client_key` - Client key to identify this replica to the sync server (a UUID)
|
||||||
If not set, then sync is done to a local server.
|
If not set, then sync is done to a local server.
|
||||||
|
|
||||||
# Reports
|
## Reports
|
||||||
|
|
||||||
* `reports` - a mapping of each report's name to its definition.
|
* `reports` - a mapping of each report's name to its definition.
|
||||||
See [Reports](./reports.md) for details.
|
See [Reports](./reports.md) for details.
|
||||||
|
|
||||||
|
## Editing
|
||||||
|
|
||||||
|
As a shortcut, the simple, top-level configuration values can be edited from the command line:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
ta config set data_dir /home/myuser/.taskchampion
|
||||||
|
```
|
||||||
|
|
|
@ -11,13 +11,39 @@ Synchronization is quick, especially if no changes have occurred.
|
||||||
Each replica expects to be synchronized frequently, even if no server is involved.
|
Each replica expects to be synchronized frequently, even if no server is involved.
|
||||||
Without periodic syncs, the storage space used for the task database will grow quickly, and performance will suffer.
|
Without periodic syncs, the storage space used for the task database will grow quickly, and performance will suffer.
|
||||||
|
|
||||||
|
## Local Sync
|
||||||
|
|
||||||
By default, TaskChampion syncs to a "local server", as specified by the `server_dir` configuration parameter.
|
By default, TaskChampion syncs to a "local server", as specified by the `server_dir` configuration parameter.
|
||||||
|
This defaults to `taskchampion-sync-server` in your [data directory](https://docs.rs/dirs-next/2.0.0/dirs_next/fn.data_dir.html), but can be customized in the configuration file.
|
||||||
|
|
||||||
|
## Remote Sync
|
||||||
|
|
||||||
|
For remote synchronization, you will need a few pieces of information.
|
||||||
|
From the server operator, you will need an origin and a client key.
|
||||||
|
Configure these with
|
||||||
|
|
||||||
|
```shell
|
||||||
|
ta config set server_origin "<origin from server operator>"
|
||||||
|
ta config set server_client_key "<client key from server operator>"
|
||||||
|
```
|
||||||
|
|
||||||
|
You will need to generate your own encryption secret.
|
||||||
|
This is used to encrypt your task history, so treat it as a password.
|
||||||
|
The following will use the `openssl` utility to generate a suitable value:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
ta config set encryption_secret $(openssl rand -hex 35)
|
||||||
|
```
|
||||||
|
|
||||||
Every replica sharing a task history should have precisely the same configuration for `server_origin`, `server_client_key`, and `encryption_secret`.
|
Every replica sharing a task history should have precisely the same configuration for `server_origin`, `server_client_key`, and `encryption_secret`.
|
||||||
|
|
||||||
|
### Adding a New Replica
|
||||||
|
|
||||||
Synchronizing a new replica to an existing task history is easy: begin with an empty replica, configured for the remote server, and run `ta sync`.
|
Synchronizing a new replica to an existing task history is easy: begin with an empty replica, configured for the remote server, and run `ta sync`.
|
||||||
The replica will download the entire task history.
|
The replica will download the entire task history.
|
||||||
|
|
||||||
|
### Upgrading a Locally-Sync'd Replica
|
||||||
|
|
||||||
It is possible to switch a single replica to a remote server by simply configuring for the remote server and running `ta sync`.
|
It is possible to switch a single replica to a remote server by simply configuring for the remote server and running `ta sync`.
|
||||||
The replica will upload the entire task history to the server.
|
The replica will upload the entire task history to the server.
|
||||||
Once this is complete, additional replicas can be configured with the same settings in order to share the task history.
|
Once this is complete, additional replicas can be configured with the same settings in order to share the task history.
|
||||||
|
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue