Open named database OR default to in memory database if no name provided
Rearrange workspace to allow import of mentat crate in cli crate Create store object inside repl when started for connecting to mentat Use provided DB name to open connection in store Accept DB name as command line arg. Open on CLI start Implement '.open' command to open desired DB from inside CLI
This commit is contained in:
parent
5a6c3f6598
commit
f3d39d4194
7 changed files with 313 additions and 41 deletions
|
@ -11,7 +11,7 @@ version = "0.4.0"
|
||||||
build = "build/version.rs"
|
build = "build/version.rs"
|
||||||
|
|
||||||
[workspace]
|
[workspace]
|
||||||
members = []
|
members = ["tools/cli"]
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
rustc_version = "0.1.7"
|
rustc_version = "0.1.7"
|
||||||
|
@ -61,6 +61,3 @@ path = "query-translator"
|
||||||
|
|
||||||
[dependencies.mentat_tx_parser]
|
[dependencies.mentat_tx_parser]
|
||||||
path = "tx-parser"
|
path = "tx-parser"
|
||||||
|
|
||||||
[dependencies.mentat_cli]
|
|
||||||
path = "tools/cli"
|
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "mentat_cli"
|
name = "mentat_cli"
|
||||||
version = "0.0.1"
|
version = "0.0.1"
|
||||||
workspace = "../.."
|
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
name = "mentat_cli"
|
name = "mentat_cli"
|
||||||
|
@ -18,3 +17,16 @@ env_logger = "0.3"
|
||||||
linefeed = "0.1"
|
linefeed = "0.1"
|
||||||
log = "0.3"
|
log = "0.3"
|
||||||
tempfile = "1.1"
|
tempfile = "1.1"
|
||||||
|
combine = "2.2.2"
|
||||||
|
lazy_static = "0.2.2"
|
||||||
|
|
||||||
|
[dependencies.rusqlite]
|
||||||
|
version = "0.11"
|
||||||
|
# System sqlite might be very old.
|
||||||
|
features = ["bundled", "limits"]
|
||||||
|
|
||||||
|
[dependencies.mentat]
|
||||||
|
path = "../.."
|
||||||
|
|
||||||
|
[dependencies.mentat_parser_utils]
|
||||||
|
path = "../../parser-utils"
|
||||||
|
|
159
tools/cli/src/mentat_cli/command_parser.rs
Normal file
159
tools/cli/src/mentat_cli/command_parser.rs
Normal file
|
@ -0,0 +1,159 @@
|
||||||
|
// Copyright 2017 Mozilla
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License"); you may not use
|
||||||
|
// this file except in compliance with the License. You may obtain a copy of the
|
||||||
|
// License at http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
// Unless required by applicable law or agreed to in writing, software distributed
|
||||||
|
// under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
|
||||||
|
// CONDITIONS OF ANY KIND, either express or implied. See the License for the
|
||||||
|
// specific language governing permissions and limitations under the License.
|
||||||
|
|
||||||
|
use combine::{eof, many, many1, sep_by, skip_many, token, Parser};
|
||||||
|
use combine::combinator::{choice, try};
|
||||||
|
use combine::char::{alpha_num, space, string};
|
||||||
|
|
||||||
|
pub static HELP_COMMAND: &'static str = &"help";
|
||||||
|
pub static OPEN_COMMAND: &'static str = &"open";
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub enum Command {
|
||||||
|
Transact(Vec<String>),
|
||||||
|
Query(Vec<String>),
|
||||||
|
Help(Vec<String>),
|
||||||
|
Open(String),
|
||||||
|
Err(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Command {
|
||||||
|
pub fn is_complete(&self) -> bool {
|
||||||
|
match self {
|
||||||
|
&Command::Query(_) |
|
||||||
|
&Command::Transact(_) => false,
|
||||||
|
_ => true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn command(s: &str) -> Command {
|
||||||
|
let help_parser = string(HELP_COMMAND).and(skip_many(space())).and(sep_by::<Vec<_>, _, _>(many1::<Vec<_>, _>(alpha_num()), token(' '))).map(|x| {
|
||||||
|
let args: Vec<String> = x.1.iter().map(|v| v.iter().collect() ).collect();
|
||||||
|
Command::Help(args)
|
||||||
|
});
|
||||||
|
|
||||||
|
let open_parser = string(OPEN_COMMAND).and(space()).and(many1::<Vec<_>, _>(alpha_num()).and(eof())).map(|x| {
|
||||||
|
let arg: String = (x.1).0.iter().collect();
|
||||||
|
Command::Open(arg)
|
||||||
|
});
|
||||||
|
|
||||||
|
token('.')
|
||||||
|
.and(choice::<[&mut Parser<Input = _, Output = Command>; 2], _>
|
||||||
|
([&mut try(help_parser),
|
||||||
|
&mut try(open_parser),]))
|
||||||
|
.parse(s)
|
||||||
|
.map(|x| x.0)
|
||||||
|
.unwrap_or(('0', Command::Err(format!("Invalid command {:?}", s)))).1
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_help_parser_multiple_args() {
|
||||||
|
let input = ".help command1 command2";
|
||||||
|
let cmd = command(&input);
|
||||||
|
match cmd {
|
||||||
|
Command::Help(args) => {
|
||||||
|
assert_eq!(args, vec!["command1", "command2"]);
|
||||||
|
},
|
||||||
|
_ => assert!(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_help_parser_no_args() {
|
||||||
|
let input = ".help";
|
||||||
|
let cmd = command(&input);
|
||||||
|
match cmd {
|
||||||
|
Command::Help(args) => {
|
||||||
|
let empty: Vec<String> = vec![];
|
||||||
|
assert_eq!(args, empty);
|
||||||
|
},
|
||||||
|
_ => assert!(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_help_parser_no_args_trailing_whitespace() {
|
||||||
|
let input = ".help ";
|
||||||
|
let cmd = command(&input);
|
||||||
|
match cmd {
|
||||||
|
Command::Help(args) => {
|
||||||
|
let empty: Vec<String> = vec![];
|
||||||
|
assert_eq!(args, empty);
|
||||||
|
},
|
||||||
|
_ => assert!(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_open_parser_multiple_args() {
|
||||||
|
let input = ".open database1 database2";
|
||||||
|
let cmd = command(&input);
|
||||||
|
match cmd {
|
||||||
|
Command::Err(message) => {
|
||||||
|
assert_eq!(message, format!("Invalid command {:?}", input));
|
||||||
|
},
|
||||||
|
_ => assert!(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_open_parser_single_arg() {
|
||||||
|
let input = ".open database1";
|
||||||
|
let cmd = command(&input);
|
||||||
|
match cmd {
|
||||||
|
Command::Open(arg) => {
|
||||||
|
assert_eq!(arg, "database1".to_string());
|
||||||
|
},
|
||||||
|
_ => assert!(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_open_parser_no_args() {
|
||||||
|
let input = ".open";
|
||||||
|
let cmd = command(&input);
|
||||||
|
match cmd {
|
||||||
|
Command::Err(message) => {
|
||||||
|
assert_eq!(message, format!("Invalid command {:?}", input));
|
||||||
|
},
|
||||||
|
_ => assert!(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_command_parser_no_dot() {
|
||||||
|
let input = "help command1 command2";
|
||||||
|
let cmd = command(&input);
|
||||||
|
match cmd {
|
||||||
|
Command::Err(message) => {
|
||||||
|
assert_eq!(message, format!("Invalid command {:?}", input));
|
||||||
|
},
|
||||||
|
_ => assert!(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_command_parser_invalid_cmd() {
|
||||||
|
let input = ".foo command1";
|
||||||
|
let cmd = command(&input);
|
||||||
|
match cmd {
|
||||||
|
Command::Err(message) => {
|
||||||
|
assert_eq!(message, format!("Invalid command {:?}", input));
|
||||||
|
},
|
||||||
|
_ => assert!(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
|
@ -8,24 +8,29 @@
|
||||||
// CONDITIONS OF ANY KIND, either express or implied. See the License for the
|
// CONDITIONS OF ANY KIND, either express or implied. See the License for the
|
||||||
// specific language governing permissions and limitations under the License.
|
// specific language governing permissions and limitations under the License.
|
||||||
|
|
||||||
use std::io::{self, stdin, BufRead, BufReader};
|
use std::io::{stdin};
|
||||||
|
|
||||||
use linefeed::Reader;
|
use linefeed::Reader;
|
||||||
use linefeed::terminal::DefaultTerminal;
|
use linefeed::terminal::DefaultTerminal;
|
||||||
|
|
||||||
use self::InputResult::*;
|
use self::InputResult::*;
|
||||||
|
|
||||||
|
use command_parser::{Command, command};
|
||||||
|
|
||||||
/// Possible results from reading input from `InputReader`
|
/// Possible results from reading input from `InputReader`
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub enum InputResult {
|
pub enum InputResult {
|
||||||
/// rusti command as input; (name, rest of line)
|
/// mentat command as input; (name, rest of line)
|
||||||
Command(String, Option<String>),
|
MetaCommand(Command),
|
||||||
/// An empty line
|
/// An empty line
|
||||||
Empty,
|
Empty,
|
||||||
/// Needs more input; i.e. there is an unclosed delimiter
|
/// Needs more input; i.e. there is an unclosed delimiter
|
||||||
More,
|
More(Command),
|
||||||
/// End of file reached
|
/// End of file reached
|
||||||
Eof,
|
Eof,
|
||||||
|
/// Error while parsing input; a Rust parsing error will have printed out
|
||||||
|
/// error messages and therefore contain no error message.
|
||||||
|
InputError(Option<String>),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reads input from `stdin`
|
/// Reads input from `stdin`
|
||||||
|
@ -73,14 +78,18 @@ impl InputReader {
|
||||||
|
|
||||||
self.add_history(&line);
|
self.add_history(&line);
|
||||||
|
|
||||||
let res = More;
|
let cmd = command(&self.buffer);
|
||||||
|
|
||||||
match res {
|
match cmd {
|
||||||
More => (),
|
Command::Query(_) |
|
||||||
_ => self.buffer.clear(),
|
Command::Transact(_) if !cmd.is_complete() => {
|
||||||
};
|
More(cmd)
|
||||||
|
},
|
||||||
res
|
_ => {
|
||||||
|
self.buffer.clear();
|
||||||
|
InputResult::MetaCommand(cmd)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read_line(&mut self, prompt: &str) -> Option<String> {
|
fn read_line(&mut self, prompt: &str) -> Option<String> {
|
||||||
|
@ -88,7 +97,7 @@ impl InputReader {
|
||||||
Some(ref mut r) => {
|
Some(ref mut r) => {
|
||||||
r.set_prompt(prompt);
|
r.set_prompt(prompt);
|
||||||
r.read_line().ok().and_then(|line| line)
|
r.read_line().ok().and_then(|line| line)
|
||||||
}
|
},
|
||||||
None => self.read_stdin()
|
None => self.read_stdin()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,16 +7,24 @@
|
||||||
// under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
|
// under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
|
||||||
// CONDITIONS OF ANY KIND, either express or implied. See the License for the
|
// CONDITIONS OF ANY KIND, either express or implied. See the License for the
|
||||||
// specific language governing permissions and limitations under the License.
|
// specific language governing permissions and limitations under the License.
|
||||||
|
|
||||||
#![crate_name = "mentat_cli"]
|
#![crate_name = "mentat_cli"]
|
||||||
|
|
||||||
#[macro_use] extern crate log;
|
#[macro_use] extern crate log;
|
||||||
|
#[macro_use] extern crate lazy_static;
|
||||||
|
|
||||||
|
extern crate combine;
|
||||||
extern crate env_logger;
|
extern crate env_logger;
|
||||||
extern crate getopts;
|
extern crate getopts;
|
||||||
extern crate linefeed;
|
extern crate linefeed;
|
||||||
|
extern crate rusqlite;
|
||||||
|
|
||||||
|
extern crate mentat;
|
||||||
|
|
||||||
use getopts::Options;
|
use getopts::Options;
|
||||||
|
|
||||||
|
pub mod command_parser;
|
||||||
|
pub mod store;
|
||||||
pub mod input;
|
pub mod input;
|
||||||
pub mod repl;
|
pub mod repl;
|
||||||
|
|
||||||
|
@ -26,6 +34,7 @@ pub fn run() -> i32 {
|
||||||
let args = std::env::args().collect::<Vec<_>>();
|
let args = std::env::args().collect::<Vec<_>>();
|
||||||
let mut opts = Options::new();
|
let mut opts = Options::new();
|
||||||
|
|
||||||
|
opts.optopt("d", "", "The path to a database to open", "DATABASE");
|
||||||
opts.optflag("h", "help", "Print this help message and exit");
|
opts.optflag("h", "help", "Print this help message and exit");
|
||||||
opts.optflag("v", "version", "Print version and exit");
|
opts.optflag("v", "version", "Print version and exit");
|
||||||
|
|
||||||
|
@ -41,12 +50,15 @@ pub fn run() -> i32 {
|
||||||
print_version();
|
print_version();
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
if matches.opt_present("help") {
|
if matches.opt_present("help") {
|
||||||
print_usage(&args[0], &opts);
|
print_usage(&args[0], &opts);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut repl = repl::Repl::new();
|
let db_name = matches.opt_str("d");
|
||||||
|
|
||||||
|
let mut repl = repl::Repl::new(db_name);
|
||||||
repl.run();
|
repl.run();
|
||||||
|
|
||||||
0
|
0
|
||||||
|
|
|
@ -7,63 +7,101 @@
|
||||||
// under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
|
// under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
|
||||||
// CONDITIONS OF ANY KIND, either express or implied. See the License for the
|
// CONDITIONS OF ANY KIND, either express or implied. See the License for the
|
||||||
// specific language governing permissions and limitations under the License.
|
// specific language governing permissions and limitations under the License.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use command_parser::{Command, HELP_COMMAND, OPEN_COMMAND};
|
||||||
use input::{InputReader};
|
use input::{InputReader};
|
||||||
use input::InputResult::{Command, Empty, More, Eof};
|
use input::InputResult::{MetaCommand, Empty, More, Eof, InputError};
|
||||||
|
use store::Store;
|
||||||
|
|
||||||
/// Starting prompt
|
/// Starting prompt
|
||||||
const DEFAULT_PROMPT: &'static str = "mentat=> ";
|
const DEFAULT_PROMPT: &'static str = "mentat=> ";
|
||||||
/// Prompt when further input is being read
|
/// Prompt when further input is being read
|
||||||
|
// TODO: Should this actually reflect the current open brace?
|
||||||
const MORE_PROMPT: &'static str = "mentat.> ";
|
const MORE_PROMPT: &'static str = "mentat.> ";
|
||||||
/// Prompt when a `.block` command is in effect
|
|
||||||
const BLOCK_PROMPT: &'static str = "mentat+> ";
|
lazy_static! {
|
||||||
|
static ref COMMAND_HELP: HashMap<&'static str, &'static str> = {
|
||||||
|
let mut map = HashMap::new();
|
||||||
|
map.insert(HELP_COMMAND, "Show help for commands.");
|
||||||
|
map.insert(OPEN_COMMAND, "Open a database at path.");
|
||||||
|
map
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/// Executes input and maintains state of persistent items.
|
/// Executes input and maintains state of persistent items.
|
||||||
pub struct Repl {
|
pub struct Repl {
|
||||||
|
store: Store,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Repl {
|
impl Repl {
|
||||||
/// Constructs a new `Repl`.
|
/// Constructs a new `Repl`.
|
||||||
pub fn new() -> Repl {
|
pub fn new(db_name: Option<String>) -> Repl {
|
||||||
Repl{}
|
Repl{
|
||||||
|
store: Store::new(db_name),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// Runs the REPL interactively.
|
/// Runs the REPL interactively.
|
||||||
pub fn run(&mut self) {
|
pub fn run(&mut self) {
|
||||||
let mut more = false;
|
let mut more: Option<Command> = None;
|
||||||
let mut input = InputReader::new();
|
let mut input = InputReader::new();
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
let res = input.read_input(if more { MORE_PROMPT } else { DEFAULT_PROMPT });
|
let res = input.read_input(if more.is_some() { MORE_PROMPT } else { DEFAULT_PROMPT });
|
||||||
// let res = if self.read_block {
|
|
||||||
// self.read_block = false;
|
|
||||||
// input.read_block_input(BLOCK_PROMPT)
|
|
||||||
// } else {
|
|
||||||
// input.read_input(if more { MORE_PROMPT } else { DEFAULT_PROMPT })
|
|
||||||
// };
|
|
||||||
|
|
||||||
match res {
|
match res {
|
||||||
Command(name, args) => {
|
MetaCommand(cmd) => {
|
||||||
debug!("read command: {} {:?}", name, args);
|
debug!("read command: {:?}", cmd);
|
||||||
|
more = None;
|
||||||
more = false;
|
self.handle_command(cmd);
|
||||||
self.handle_command(name, args);
|
|
||||||
},
|
},
|
||||||
Empty => (),
|
Empty => (),
|
||||||
More => { more = true; },
|
More(cmd) => { more = Some(cmd); },
|
||||||
Eof => {
|
Eof => {
|
||||||
if input.is_tty() {
|
if input.is_tty() {
|
||||||
println!("");
|
println!("");
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
},
|
||||||
|
InputError(err) => {
|
||||||
|
if let Some(err) = err {
|
||||||
|
println!("{}", err);
|
||||||
}
|
}
|
||||||
|
more = None;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Runs a single command input.
|
/// Runs a single command input.
|
||||||
fn handle_command(&mut self, cmd: String, args: Option<String>) {
|
fn handle_command(&mut self, cmd: Command) {
|
||||||
println!("{:?} {:?}", cmd, args);
|
match cmd {
|
||||||
|
Command::Help(args) => self.help_command(args),
|
||||||
|
Command::Open(db) => {
|
||||||
|
self.store.open(Some(db));
|
||||||
|
},
|
||||||
|
Command::Err(message) => println!("{}", message),
|
||||||
|
_ => unimplemented!(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn help_command(&self, args: Vec<String>) {
|
||||||
|
if args.is_empty() {
|
||||||
|
for (cmd, msg) in COMMAND_HELP.iter() {
|
||||||
|
println!(".{} - {}", cmd, msg);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for arg in args {
|
||||||
|
let msg = COMMAND_HELP.get(arg.as_str());
|
||||||
|
if msg.is_some() {
|
||||||
|
println!(".{} - {}", arg, msg.unwrap());
|
||||||
|
} else {
|
||||||
|
println!("Unrecognised command {}", arg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
45
tools/cli/src/mentat_cli/store.rs
Normal file
45
tools/cli/src/mentat_cli/store.rs
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
// Copyright 2017 Mozilla
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License"); you may not use
|
||||||
|
// this file except in compliance with the License. You may obtain a copy of the
|
||||||
|
// License at http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
// Unless required by applicable law or agreed to in writing, software distributed
|
||||||
|
// under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
|
||||||
|
// CONDITIONS OF ANY KIND, either express or implied. See the License for the
|
||||||
|
// specific language governing permissions and limitations under the License.
|
||||||
|
|
||||||
|
use rusqlite;
|
||||||
|
use mentat::{
|
||||||
|
new_connection,
|
||||||
|
};
|
||||||
|
|
||||||
|
use mentat::conn::Conn;
|
||||||
|
|
||||||
|
pub struct Store {
|
||||||
|
handle: rusqlite::Connection,
|
||||||
|
conn: Conn,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn db_output_name(db_name: &String) -> String {
|
||||||
|
if db_name.is_empty() { "in memory db".to_string() } else { db_name.clone() }
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Store {
|
||||||
|
pub fn new(database: Option<String>) -> Store {
|
||||||
|
let db_name = database.unwrap_or("".to_string());
|
||||||
|
let output_name = db_output_name(&db_name);
|
||||||
|
let mut handle = new_connection(db_name).expect("Couldn't open conn.");
|
||||||
|
let conn = Conn::connect(&mut handle).expect("Couldn't open DB.");
|
||||||
|
println!("Database {:?} opened", output_name);
|
||||||
|
Store { handle, conn }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn open(&mut self, database: Option<String>) {
|
||||||
|
let db_name = database.unwrap_or("".to_string());
|
||||||
|
let output_name = db_output_name(&db_name);
|
||||||
|
self.handle = new_connection(db_name).expect("Couldn't open conn.");
|
||||||
|
self.conn = Conn::connect(&mut self.handle).expect("Couldn't open DB.");
|
||||||
|
println!("Database {:?} opened", output_name);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
Loading…
Reference in a new issue