diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..e11e0d8 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,80 @@ +name: Build +on: [push, pull_request] +jobs: + # + # Build binaries + # + build_linux: + runs-on: ubuntu-18.04 + steps: + - uses: actions/checkout@v2 + - name: Test + run: make test + - name: Build + run: OUT=cronedit-linux make build + - name: Store binary + uses: actions/upload-artifact@v2 + with: + name: cronedit-linux + path: bin/cronedit-linux + build_macos: + runs-on: macos-10.15 + steps: + - uses: actions/checkout@v2 + - name: Test + run: make test + - name: Build + run: OUT=cronedit-macos make build + - name: Store binary + uses: actions/upload-artifact@v2 + with: + name: cronedit-macos + path: bin/cronedit-macos + # + # Prepare a release if current commit is tagged + # + create_release: + runs-on: ubuntu-18.04 + if: startsWith(github.ref, 'refs/tags/v') + needs: [build_linux, build_macos] + steps: + # Draft + - name: Create release draft + id: create_release + uses: actions/create-release@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: ${{ github.ref }} + release_name: Release ${{ github.ref }} + draft: true + # cronedit-linux + - name: Download cronedit-linux + uses: actions/download-artifact@v2 + with: + name: cronedit-linux + path: . + - name: Upload cronedit-linux + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ steps.create_release.outputs.upload_url }} + asset_path: ./cronedit-linux + asset_name: cronedit-linux + asset_content_type: application/octet-stream + # cronedit-macos + - name: Download cronedit-macos + uses: actions/download-artifact@v2 + with: + name: cronedit-macos + path: . + - name: Upload cronedit-macos + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ steps.create_release.outputs.upload_url }} + asset_path: ./cronedit-macos + asset_name: cronedit-macos + asset_content_type: application/octet-stream diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..111dafe --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +bin +.filesystem_test.tmp diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md new file mode 100644 index 0000000..894e748 --- /dev/null +++ b/DEVELOPMENT.md @@ -0,0 +1,51 @@ +# Development + +Cronedit is written in C++/C and without external dependencies. Supported platforms are debian and MacOS. + +## How to develop locally + +Clone the repository: + +```bash +git clone git@github.com:codeclown/cronedit.git +cd cronedit/ +``` + +Run tests: + +```bash +make test +``` + +Build binaries (written into `bin/`): + +```bash +make build +``` + +Run the build: + +```bash +./bin/cronedit lib/sample.txt +``` + +## Release process + +### CI builds + +For each commit and pull request, GitHub Actions is used to automatically build binaries on: + +- Ubuntu 18.04 +- MacOS 10.15 (Catalina) + +The resulting binaries are uploaded as artifacts into the respective GitHub workflow. + +See [`.github/workflows/build.yml`](.github/workflows/build.yml). + +### Making a release + +1. Bump the version in [`lib/version.h`](lib/version.h), e.g. `#define CRONEDIT_VERSION "0.1.1"` +2. Add a git tag, e.g. `git tag v0.1.1` +3. Push with tags `git push --tags` +4. GitHub action detects the new tag and creates a draft release with attached binaries. Review and publish it here: + https://github.com/codeclown/cronedit/releases diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..a63999a --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,24 @@ +Copyright (c) 2020, Martti Laine +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of the copyright holders nor the + names of its contributors may be used to endorse or promote products + derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL A COPYRIGHT HOLDER BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..a9a7568 --- /dev/null +++ b/Makefile @@ -0,0 +1,14 @@ +GXX ?= g++ +OUT ?= cronedit +libs = lib/parser.cpp lib/exec.cpp lib/filesystem.cpp lib/state.cpp lib/render.cpp lib/terminal.cpp lib/stringify.cpp lib/args.cpp + +build: + mkdir -p bin && $(GXX) $(libs) lib/cronedit.cpp -o bin/$(OUT) + +test: + mkdir -p bin && $(GXX) $(libs) lib/args_test.cpp -o bin/args_test && ./bin/args_test + mkdir -p bin && $(GXX) $(libs) lib/filesystem_test.cpp -o bin/filesystem_test && ./bin/filesystem_test + mkdir -p bin && $(GXX) $(libs) lib/parser_test.cpp -o bin/parser_test && ./bin/parser_test + mkdir -p bin && $(GXX) $(libs) lib/state_test.cpp -o bin/state_test && ./bin/state_test + mkdir -p bin && $(GXX) $(libs) lib/stringify_test.cpp -o bin/stringify_test && ./bin/stringify_test + mkdir -p bin && $(GXX) $(libs) lib/render_test.cpp -o bin/render_test && ./bin/render_test diff --git a/README.md b/README.md new file mode 100644 index 0000000..502ccfc --- /dev/null +++ b/README.md @@ -0,0 +1,53 @@ +# cronedit + +> an interactive command line editor for crontab + +## This project is in BETA! + +**Note that this is an early version of cronedit. Versions 0.X.X should be considered beta versions and probably not suited for use in critical production environments. Recommended for use only in testing environments.** + +If you want to be extra safe, or just play around, copy crontab contents into a file and edit that file with cronedit: + +```bash +# copy crontab contents to .txt file +crontab -l > foobar.txt +# edit that file via cronedit +./cronedit foobar.txt +``` + +## Usage + +```shell +$ cronedit --help +cronedit v0.1.0 + +Usage: + ./bin/cronedit + ./bin/cronedit + +Arguments: + file If given, this file is read and written to + instead of modifying crontab directly via + the 'crontab' command + +Options: + -h, --help Show this help message + -v, --version Show cronedit version + +Information about backups: + Upon saving changes, previous crontab version is backed up + into folder '$HOME/.cronedit'. + +Source: + This program is open source and available at: + https://github.com/codeclown/cronedit + +``` + +## Development + +See [DEVELOPMENT.md](DEVELOPMENT.md). + +## License + +Released under 3-clause BSD License, see [LICENSE.md](LICENSE.md). diff --git a/lib/args.cpp b/lib/args.cpp new file mode 100644 index 0000000..3797cfd --- /dev/null +++ b/lib/args.cpp @@ -0,0 +1,59 @@ +#include +#include +#include "args.h" + +Args parse_args(int argc, char* argv[]) { + Args args; + args.show_usage = false; + args.show_version = false; + args.filename = ""; + + std::vector filenames; + + for (int i = 1; i < argc; i++) { + std::string arg = argv[i]; + if (arg == "--help" || arg == "-h") { + args.show_usage = true; + return args; + } + if (arg == "--version" || arg == "-v") { + args.show_version = true; + return args; + } + filenames.push_back(arg); + } + + if (filenames.size() > 1) { + args.show_usage = true; + } else if (filenames.size() > 1) { + args.show_usage = true; + } else if (filenames.size() == 1) { + args.filename = filenames[0]; + } + + return args; + + // bool show_usage = false; + // if (argc >= 2) { + // for (int i = 1; i < argc; i++) { + // std::string arg = argv[i]; + // if (arg == "--help" || arg == "-h") { + // show_usage = true; + // break; + // } + // } + // } + // if (show_usage) { + // print_usage(argv[0]); + // exit(0); + // } else if (argc > 2) { + // print_usage(argv[0]); + // exit(1); + // } + // std::string file_arg = ""; + // if (argc == 2) { + // file_arg = argv[1]; + // } + + // return args; +} diff --git a/lib/args.h b/lib/args.h new file mode 100644 index 0000000..d2ecf1a --- /dev/null +++ b/lib/args.h @@ -0,0 +1,12 @@ +#ifndef ARGS_H +#define ARGS_H + +struct Args { + bool show_usage; + bool show_version; + std::string filename; +}; + +Args parse_args(int argc, char* argv[]); + +#endif diff --git a/lib/args_test.cpp b/lib/args_test.cpp new file mode 100644 index 0000000..a6331e7 --- /dev/null +++ b/lib/args_test.cpp @@ -0,0 +1,52 @@ +#include +#include +#include +#include +#include "args.h" + +int main() +{ + char *argv1[1] = { (char*)"./bin/cronedit" }; + Args args1 = parse_args(1, argv1); + assert(args1.show_usage == false); + assert(args1.show_version == false); + assert(args1.filename == ""); + + char *argv2[2] = { (char*)"./bin/cronedit", (char*)"-h" }; + Args args2 = parse_args(2, argv2); + assert(args2.show_usage == true); + assert(args2.show_version == false); + assert(args2.filename == ""); + + char *argv3[2] = { (char*)"./bin/cronedit", (char*)"--help" }; + Args args3 = parse_args(2, argv3); + assert(args3.show_usage == true); + assert(args3.show_version == false); + assert(args3.filename == ""); + + char *argv4[2] = { (char*)"./bin/cronedit", (char*)"-v" }; + Args args4 = parse_args(2, argv4); + assert(args4.show_usage == false); + assert(args4.show_version == true); + assert(args4.filename == ""); + + char *argv5[2] = { (char*)"./bin/cronedit", (char*)"--version" }; + Args args5 = parse_args(2, argv5); + assert(args5.show_usage == false); + assert(args5.show_version == true); + assert(args5.filename == ""); + + char *argv6[2] = { (char*)"./bin/cronedit", (char*)"foobar" }; + Args args6 = parse_args(2, argv6); + assert(args6.show_usage == false); + assert(args6.show_version == false); + assert(args6.filename == "foobar"); + + char *argv7[3] = { (char*)"./bin/cronedit", (char*)"foobar", (char*)"lorem" }; + Args args7 = parse_args(3, argv7); + assert(args7.show_usage == true); + assert(args7.show_version == false); + assert(args7.filename == ""); + + std::cout << "PASS" << std::endl; +} diff --git a/lib/cronedit.cpp b/lib/cronedit.cpp new file mode 100644 index 0000000..a369f7a --- /dev/null +++ b/lib/cronedit.cpp @@ -0,0 +1,207 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include "args.h" +#include "exec.h" +#include "filesystem.h" +#include "parser.h" +#include "render.h" +#include "state.h" +#include "stringify.h" +#include "terminal.h" +#include "version.h" + +int terminal_width = get_terminal_width(); +int terminal_height = get_terminal_height(); + +State state; + +void print_version() { + std::cout << "cronedit v" << CRONEDIT_VERSION << std::endl; +} + +void print_usage(char* program) { + print_version(); + std::cout << std::endl; + std::cout << "Usage: " << std::endl; + std::cout << " " << program << std::endl; + std::cout << " " << program << " " << std::endl; + std::cout << std::endl; + std::cout << "Arguments:" << std::endl; + std::cout << " file If given, this file is read and written to" << std::endl; + std::cout << " instead of modifying crontab directly via" << std::endl; + std::cout << " the 'crontab' command" << std::endl; + std::cout << std::endl; + std::cout << "Options:" << std::endl; + std::cout << " -h, --help Show this help message" << std::endl; + std::cout << " -v, --version Show cronedit version" << std::endl; + std::cout << std::endl; + std::cout << "Information about backups:" << std::endl; + std::cout << " Upon saving changes, previous crontab version is backed up" << std::endl; + std::cout << " into folder '$HOME/.cronedit'." << std::endl; + std::cout << std::endl; + std::cout << "Source:" << std::endl; + std::cout << " This program is open source and available at:" << std::endl; + std::cout << " https://github.com/codeclown/cronedit" << std::endl; + std::cout << std::endl; +} + +std::string ui; +std::string previous; +void render() { + // state.print_debug(); + ui = render_state(state, terminal_width, terminal_height); + std::cout << render_diff(ui, previous); + previous = ui; +} + +std::string saved_file; +std::string backup_file; +void quit(int code) { + state.on_quit(saved_file, backup_file); + render(); + revert_terminal(); + exit(code); +} + +void on_sigint(sig_atomic_t s) { + quit(1); +} + +void save_changes(std::string original, std::string file_arg) { + std::string cronedit_dir; + const char *homedir = getenv("HOME"); + if (homedir != NULL) { + std::string home = homedir; + cronedit_dir = home + "/.cronedit"; + make_dir_if_not_exists(cronedit_dir); + } + std::string stringified = stringify_items(state.get_items()); + if (file_arg != "") { + if (write_file(file_arg, stringified)) { + saved_file = file_arg; + } + } else { + if (cronedit_dir != "") { + std::string tmp_filename = cronedit_dir + "/tmp_" + std::to_string(std::time(nullptr)); + if (write_file(tmp_filename, stringified)) { + exec("crontab " + tmp_filename); + remove_file(tmp_filename); + saved_file = "crontab"; + } + } + } + if (saved_file != "" && cronedit_dir != "") { + std::string backup_filename = cronedit_dir + "/backup_" + std::to_string(std::time(nullptr)); + if (write_file(backup_filename, original)) { + backup_file = backup_filename; + } + } +} + +int main(int argc, char* argv[]) { + if (!is_tty()) { + std::cerr << "cronedit requires a TTY to function." << std::endl; + exit(1); + } + + int min_width = MIN_TERMINAL_WIDTH; + int min_height = MIN_TERMINAL_HEIGHT; + if (terminal_width < min_width || terminal_height < min_height) { + std::cerr << "cronedit expects terminal size to be at least "; + std::cerr << min_width << "x" << min_height; + std::cerr << " (current: "; + std::cerr << terminal_width << "x" << terminal_height; + std::cerr << ")"; + std::cerr << std::endl; + exit(1); + } + + Args args = parse_args(argc, argv); + if (args.show_usage) { + print_usage(argv[0]); + exit(0); + } + if (args.show_version) { + print_version(); + exit(0); + } + + signal(SIGINT, on_sigint); + + std::string crontab; + if (args.filename != "") { + crontab = read_file(args.filename); + } else { + crontab = exec("crontab -l 2>&1"); + if (crontab.substr(0, 23) == "crontab: no crontab for") { + crontab = ""; + } + } + + if (crontab.length() > 0) { + std::istringstream lines(crontab); + std::string line; + while (std::getline(lines, line)) { + state.parse_line(line); + } + } + + init_terminal(); + + int ch = 0; + bool editing_value; + std::string input; + while (true) { + render(); + + editing_value = state.is_editing_value(); + if (editing_value) { + prepare_for_input(); + input = ""; + std::getline(std::cin, input); + state.on_end_editing_value(input); + init_terminal(); + continue; + } + + ch = terminal_get_char(); + // std::cout << ch << std::endl; + + if (ch == KEY_UP) { + state.on_key_up(); + } else if (ch == KEY_DOWN) { + state.on_key_down(); + } else if (ch == KEY_LEFT) { + state.on_key_left(); + } else if (ch == KEY_RIGHT) { + state.on_key_right(); + } else if (ch == KEY_C) { + state.on_toggle_comment(); + } else if (ch == KEY_N) { + state.on_create_new(); + } else if (ch == KEY_D) { + state.on_duplicate(); + } else if (ch == KEY_R) { + state.on_remove(); + } else if (ch == KEY_S) { + save_changes(crontab, args.filename); + break; + } else if (ch == KEY_ENTER && !editing_value) { + if (state.on_begin_editing_value()) { + revert_terminal(); + } + } else if (ch == KEY_Q/* || ch == KEY_ESC*/) { + break; + } + } + + quit(0); + + return 0; +} diff --git a/lib/exec.cpp b/lib/exec.cpp new file mode 100644 index 0000000..250de36 --- /dev/null +++ b/lib/exec.cpp @@ -0,0 +1,28 @@ +#include +#include +#include +#include + +// Source: +// https://stackoverflow.com/a/478960/239527 + +std::string exec(const char* cmd) { + char buffer[128]; + std::string result = ""; + FILE* pipe = popen(cmd, "r"); + if (!pipe) throw std::runtime_error("popen() failed!"); + try { + while (fgets(buffer, sizeof buffer, pipe) != NULL) { + result += buffer; + } + } catch (...) { + pclose(pipe); + throw; + } + pclose(pipe); + return result; +} + +std::string exec(std::string cmd) { + return exec(cmd.c_str()); +} diff --git a/lib/exec.h b/lib/exec.h new file mode 100644 index 0000000..ca68e22 --- /dev/null +++ b/lib/exec.h @@ -0,0 +1,8 @@ +#ifndef EXEC_H +#define EXEC_H + +std::string exec(const char* cmd); + +std::string exec(std::string cmd); + +#endif diff --git a/lib/filesystem.cpp b/lib/filesystem.cpp new file mode 100644 index 0000000..143d998 --- /dev/null +++ b/lib/filesystem.cpp @@ -0,0 +1,37 @@ +#include +#include +#include +#include +#include +#include + +void make_dir_if_not_exists(std::string path) { + // read/write/search permissions for owner + // read/write/search permissions for group + // read/search permissions for others + mkdir(path.c_str(), S_IRWXU | S_IRWXG | S_IROTH | S_IXOTH); +} + +std::string read_file(std::string path) { + std::ifstream file_arg_handle(path); + std::stringstream buffer; + buffer << file_arg_handle.rdbuf(); + return buffer.str(); +} + +bool write_file(std::string path, std::string contents) { + std::ofstream file (path, std::ios::out); + if (file.is_open()) { + file << contents; + file.close(); + return true; + } + return false; +} + +bool remove_file(std::string path) { + if (unlink(path.c_str()) == 0) { + return true; + } + return false; +} diff --git a/lib/filesystem.h b/lib/filesystem.h new file mode 100644 index 0000000..4b0eb22 --- /dev/null +++ b/lib/filesystem.h @@ -0,0 +1,12 @@ +#ifndef FILESYSTEM_H +#define FILESYSTEM_H + +void make_dir_if_not_exists(std::string path); + +std::string read_file(std::string path); + +bool write_file(std::string path, std::string contents); + +bool remove_file(std::string path); + +#endif diff --git a/lib/filesystem_test.cpp b/lib/filesystem_test.cpp new file mode 100644 index 0000000..7334b90 --- /dev/null +++ b/lib/filesystem_test.cpp @@ -0,0 +1,17 @@ +#include +#include +#include +#include +#include "filesystem.h" + +int main() +{ + std::string path = ".filesystem_test.tmp"; + std::string contents = "test\nfoobar"; + + assert(write_file(path, contents) == true); + assert(read_file(path) == contents); + assert(remove_file(path) == true); + + std::cout << "PASS" << std::endl; +} diff --git a/lib/parser.cpp b/lib/parser.cpp new file mode 100644 index 0000000..84b15a9 --- /dev/null +++ b/lib/parser.cpp @@ -0,0 +1,131 @@ +#include +#include +#include +#include "parser.h" + +// +// JOBS +// + +bool is_crontab_number(char character) { + return isdigit(character) || character == '*'; +} + +bool is_valid_scheduling_segment(std::string segment) { + if (segment == "*") { + return true; + } + if (is_crontab_number(segment[0])) { + bool slash_encountered = false; + for(unsigned i = 0; i < segment.length(); ++i) { + char character = segment[i]; + if (slash_encountered && !is_crontab_number(character)) { + return false; + } + if (slash_encountered && character == '/') { + return false; + } + if (character == '/') { + slash_encountered = true; + } + if (is_crontab_number(character)) { + continue; + } + } + return true; + } + return false; +} + +CrontabJob parse_crontab_job(std::string line) { + // std::vector segments; + // std::string segment; + // segments.push_back(segment); + // for(unsigned i = 0; i < line.length(); ++i) { + // char character = line.at(i); + // if (character == ' ' && segment.size() > 1) { + // segment = ""; + // segments.push_back(segment); + // } else if (character == ' ') { + // // Skip over subsequent whitespace + // } else { + // segment += character; + // } + // } + // std::cout << segments << std::endl; + // CrontabJob job; + // return job; + + CrontabJob job; + job.commented = false; + int prop = 0; + if (line.length() == 0) { + throw NotACrontabJobException(); + } + for(unsigned i = 0; i < line.length(); ++i) { + char character = line.at(i); + if (character == '#' && job.minute.length() == 0) { + job.commented = true; + continue; + } else if (character == ' ' && (i < 1 || line[i - 1] != ' ') && job.minute.length() > 0 && prop < 5) { + prop++; + continue; + } else if (character != ' ' && prop == 0) { + job.minute.append(1, character); + } else if (character != ' ' && prop == 1) { + job.hour.append(1, character); + } else if (character != ' ' && prop == 2) { + job.day_of_month.append(1, character); + } else if (character != ' ' && prop == 3) { + job.month.append(1, character); + } else if (character != ' ' && prop == 4) { + job.day_of_week.append(1, character); + } else if (prop == 5) { + job.command.append(1, character); + } + } + if (!is_valid_scheduling_segment(job.minute)) { + throw NotACrontabJobException(); + } + if (!is_valid_scheduling_segment(job.hour)) { + throw NotACrontabJobException(); + } + if (!is_valid_scheduling_segment(job.day_of_month)) { + throw NotACrontabJobException(); + } + if (!is_valid_scheduling_segment(job.month)) { + throw NotACrontabJobException(); + } + if (!is_valid_scheduling_segment(job.day_of_week)) { + throw NotACrontabJobException(); + } + return job; +} + +void print_job(CrontabJob job) { + std::cout << "CrontabJob {" << std::endl; + std::cout << " minute: \"" << job.minute << "\"" << std::endl; + std::cout << " hour: \"" << job.hour << "\"" << std::endl; + std::cout << " day_of_month: \"" << job.day_of_month << "\"" << std::endl; + std::cout << " month: \"" << job.month << "\"" << std::endl; + std::cout << " day_of_week: \"" << job.day_of_week << "\"" << std::endl; + std::cout << " command: \"" << job.command << "\"" << std::endl; + std::cout << " commented: " << (job.commented ? "true" : "false") << std::endl; + std::cout << "}" << std::endl; +} + +// +// UNKNOWNS +// + +CrontabUnknown parse_crontab_unknown(std::string line) { + CrontabUnknown unknown; + unknown.text = line; + return unknown; +} + +void print_unknown(CrontabUnknown unknown) { + std::cout << "CrontabUnknown {" << std::endl; + std::cout << " text: \"" + unknown.text + "\"" << std::endl; + std::cout << "}" << std::endl; +} diff --git a/lib/parser.h b/lib/parser.h new file mode 100644 index 0000000..1857f50 --- /dev/null +++ b/lib/parser.h @@ -0,0 +1,38 @@ +#ifndef PARSER_H +#define PARSER_H + +// +// JOBS +// + +struct CrontabJob { + std::string minute; + std::string hour; + std::string day_of_month; + std::string month; + std::string day_of_week; + std::string command; + bool commented; +}; + +struct NotACrontabJobException : public std::exception {}; + +bool is_valid_scheduling_segment(std::string segment); + +CrontabJob parse_crontab_job(std::string line); + +void print_job(CrontabJob job); + +// +// UNKNOWNS +// + +struct CrontabUnknown { + std::string text; +}; + +CrontabUnknown parse_crontab_unknown(std::string line); + +void print_unknown(CrontabUnknown unknown); + +#endif diff --git a/lib/parser_test.cpp b/lib/parser_test.cpp new file mode 100644 index 0000000..47ebbc1 --- /dev/null +++ b/lib/parser_test.cpp @@ -0,0 +1,132 @@ +#include +#include +#include +#include +#include "parser.h" + +int main() +{ + // + // JOBS + // + + assert(is_valid_scheduling_segment("*") == true); + assert(is_valid_scheduling_segment("1") == true); + assert(is_valid_scheduling_segment("18") == true); + assert(is_valid_scheduling_segment("*/6") == true); + assert(is_valid_scheduling_segment("6/*") == true); + assert(is_valid_scheduling_segment("2/12") == true); + + CrontabJob job1 = parse_crontab_job("0 */6 * * * bash /asd/scripts/1password_backup.sh >/asd/scripts/1password_backup.log 2>&1"); + // print_job(job1); + assert(job1.minute == "0"); + assert(job1.hour == "*/6"); + assert(job1.day_of_month == "*"); + assert(job1.month == "*"); + assert(job1.day_of_week == "*"); + assert(job1.command == "bash /asd/scripts/1password_backup.sh >/asd/scripts/1password_backup.log 2>&1"); + assert(job1.commented == false); + + CrontabJob job2 = parse_crontab_job(" 0 */6 * * * bash /asd/scripts/1password_backup.sh >/asd/scripts/1password_backup.log 2>&1"); + // print_job(job2); + assert(job2.minute == "0"); + assert(job2.hour == "*/6"); + assert(job2.day_of_month == "*"); + assert(job2.month == "*"); + assert(job2.day_of_week == "*"); + assert(job2.command == "bash /asd/scripts/1password_backup.sh >/asd/scripts/1password_backup.log 2>&1"); + assert(job2.commented == false); + + CrontabJob job3 = parse_crontab_job("#0 */6 * * * bash /asd/scripts/1password_backup.sh >/asd/scripts/1password_backup.log 2>&1"); + // print_job(job3); + assert(job3.minute == "0"); + assert(job3.hour == "*/6"); + assert(job3.day_of_month == "*"); + assert(job3.month == "*"); + assert(job3.day_of_week == "*"); + assert(job3.command == "bash /asd/scripts/1password_backup.sh >/asd/scripts/1password_backup.log 2>&1"); + assert(job3.commented == true); + + CrontabJob job4 = parse_crontab_job("# 0 */6 * * * bash /asd/scripts/1password_backup.sh >/asd/scripts/1password_backup.log 2>&1"); + // print_job(job4); + assert(job4.minute == "0"); + assert(job4.hour == "*/6"); + assert(job4.day_of_month == "*"); + assert(job4.month == "*"); + assert(job4.day_of_week == "*"); + assert(job4.command == "bash /asd/scripts/1password_backup.sh >/asd/scripts/1password_backup.log 2>&1"); + assert(job4.commented == true); + + CrontabJob job5 = parse_crontab_job(" # 0 */6 * * * bash /asd/scripts/1password_backup.sh >/asd/scripts/1password_backup.log 2>&1"); + // print_job(job5); + assert(job5.minute == "0"); + assert(job5.hour == "*/6"); + assert(job5.day_of_month == "*"); + assert(job5.month == "*"); + assert(job5.day_of_week == "*"); + assert(job5.command == "bash /asd/scripts/1password_backup.sh >/asd/scripts/1password_backup.log 2>&1"); + assert(job5.commented == true); + + try { + CrontabJob job = parse_crontab_job(""); + assert(false); + } catch(NotACrontabJobException&) {} + + try { + CrontabJob job = parse_crontab_job(" "); + assert(false); + } catch(NotACrontabJobException&) {} + + try { + CrontabJob job = parse_crontab_job("asd"); + assert(false); + } catch(NotACrontabJobException&) {} + + try { + CrontabJob job = parse_crontab_job("0 */6 * * bash foobar.sh"); + assert(false); + } catch(NotACrontabJobException&) {} + + try { + CrontabJob job = parse_crontab_job("SSH_AUTH_SOCK=/private/tmp/com.apple.launchd.WvQxE0oGYh/Listeners"); + assert(false); + } catch(NotACrontabJobException&) {} + + try { + CrontabJob job = parse_crontab_job("# https://stackoverflow.com/a/5823331/239527"); + assert(false); + } catch(NotACrontabJobException&) {} + + try { + CrontabJob job = parse_crontab_job("#"); + assert(false); + } catch(NotACrontabJobException&) {} + + try { + CrontabJob job = parse_crontab_job("# "); + assert(false); + } catch(NotACrontabJobException&) {} + + try { + CrontabJob job = parse_crontab_job(" #"); + assert(false); + } catch(NotACrontabJobException&) {} + + // + // UNKNOWN + // + + CrontabUnknown unknown1 = parse_crontab_unknown(""); + // print_unknown(unknown1); + assert(unknown1.text == ""); + + CrontabUnknown unknown2 = parse_crontab_unknown(" "); + // print_unknown(unknown2); + assert(unknown2.text == " "); + + CrontabUnknown unknown3 = parse_crontab_unknown("foo bar 123"); + // print_unknown(unknown3); + assert(unknown3.text == "foo bar 123"); + + std::cout << "PASS" << std::endl; +} diff --git a/lib/render.cpp b/lib/render.cpp new file mode 100644 index 0000000..8e9bc5f --- /dev/null +++ b/lib/render.cpp @@ -0,0 +1,381 @@ +#include +#include +#include +#include +#include +#include "parser.h" +#include "state.h" +#include "render.h" + +std::string RESET = "\033[0m"; +std::string BLACK = "\033[30m"; +std::string RED_BG = "\033[41m"; +std::string GREEN = "\033[32m"; +std::string GREEN_BG = "\033[42m"; +std::string YELLOW = "\033[33m"; +std::string YELLOW_BG = "\033[43m"; +std::string MAGENTA = "\033[35m"; +std::string MAGENTA_BG = "\033[45m"; +std::string BOLD = "\033[1;37m"; +std::string DIM = "\033[2m"; + +std::string CLEAR_LINE = "\033[2K"; +std::string CURSOR_UP = "\033[1A"; + +std::string pad_right(std::string value, int amount) { + for (int i = value.length() + 1; i <= amount; i++) { + value += " "; + } + return value; +} + +std::string pad_left(std::string value, int amount) { + std::string final; + for (int i = value.length() + 1; i <= amount; i++) { + final += " "; + } + final += value; + return final; +} + +std::string render_logo() { + return BOLD + "cronedit" + RESET; +} + +std::string render_item_begin(int line_number, int width) { + return DIM + pad_left(std::to_string(line_number), width) + " | " + RESET; +} + +std::string render_quitting(std::string saved_file, std::string backup_file) { + std::stringstream output; + if (saved_file != "") { + output << "Changes saved in " << saved_file << std::endl; + } + if (backup_file != "") { + output << "Previous version was backed up in " << backup_file << std::endl; + } + output << RESET; + return output.str(); +} + +VisibleItemIndexes calculate_visible_item_indexes(int space_for, int active_item, int total_items) { + VisibleItemIndexes indexes; + if (total_items == 0) { + indexes.begin_at = -1; + indexes.end_at = -1; + } else { + indexes.begin_at = std::max(0, std::min(total_items - space_for, (int)(active_item - std::floor(space_for / 2)))); + indexes.end_at = std::min(indexes.begin_at + space_for, total_items); + } + return indexes; +} + +std::string get_column_value(CrontabJob job, int index) { + if (index == 0) { + return job.minute; + } + if (index == 1) { + return job.hour; + } + if (index == 2) { + return job.day_of_month; + } + if (index == 3) { + return job.month; + } + if (index == 4) { + return job.day_of_week; + } + return job.command; +} + +std::string get_column_name(int index) { + if (index == 0) { + return "minute"; + } + if (index == 1) { + return "hour"; + } + if (index == 2) { + return "day of month"; + } + if (index == 3) { + return "month"; + } + if (index == 4) { + return "day of week"; + } + if (index == 5) { + return "command"; + } + return ""; +} + +std::string render_job(CrontabJob job, int space_on_line, int* column_widths, int active_column) { + std::string output; + + std::string schedule_color = YELLOW; + std::string schedule_active = YELLOW_BG + BLACK; + std::string command_color = MAGENTA; + std::string command_active = MAGENTA_BG + BLACK; + + if (job.commented) { + schedule_color = GREEN; + schedule_active = GREEN_BG + BLACK; + command_color = GREEN; + command_active = GREEN_BG + BLACK; + } + + if (job.commented) { + output += GREEN + "#" + RESET; + } else { + output += DIM + ">" + RESET; + } + + output += " "; + + if (active_column > 0 || active_column == -1) { + output += schedule_color; + } + + int adjusted_column_widths[6]; + int total_column_width; + int available_space = space_on_line; + available_space -= 2; // "> " + for (int i = 0; i < 6; i++) { + total_column_width = column_widths[i]; + total_column_width += 2; // surrounding spaces + adjusted_column_widths[i] = std::min(total_column_width, available_space); + available_space -= adjusted_column_widths[i]; + } + + for (int i = 0; i < 6; i++) { + if (i == active_column && i < 5) { + if (i > 0) { + output += RESET; + } + output += schedule_active; + } else if (i == active_column && i == 5) { + output += RESET + command_active; + } else if (i == 5) { + output += RESET + command_color; + } + output += " "; + std::string value = get_column_value(job, i); + int limit = adjusted_column_widths[i]; + limit -= 2; // surrounding spaces + if (value.length() > limit) { + value = value.substr(0, limit - 1) + "…"; + } + output += pad_right(value, limit); + output += " "; + if (i == active_column && i < 5) { + output += RESET + schedule_color; + } + } + + output += RESET; + + return output; +} + +std::string render_unknown(CrontabUnknown unknown, int space_on_line) { + if (unknown.text.length() > space_on_line) { + return unknown.text.substr(0, space_on_line - 1) + "…"; + } + return unknown.text; +} + +std::string render_toolbar_line_1(int active_column) { + std::stringstream output; + output << DIM; + output << " move "; + output << " save and quit "; + output << " quit"; + if (active_column >= 0) { + output << " edit "; + output << RESET; + output << (active_column == 5 ? MAGENTA : YELLOW); + output << get_column_name(active_column); + } + output << RESET; + return output.str(); +} + +std::string render_toolbar_line_2(bool job_is_active, bool job_is_commented) { + std::stringstream output; + output << DIM; + output << " new"; + if (job_is_active) { + output << " "; + if (job_is_commented) { + output << RESET << GREEN << " uncomment " << RESET << DIM; + } else { + output << " comment "; + } + output << " duplicate "; + output << " remove "; + } + output << RESET; + return output.str(); +} + +std::string render_editing_notice(int active_column, std::string previous_value, int terminal_width) { + std::stringstream output; + output << DIM; + output << "editing "; + output << RESET; + output << (active_column == 5 ? MAGENTA : YELLOW); + std::string column_name = get_column_name(active_column); + output << column_name; + output << RESET << DIM; + output << " (leave empty to undo)"; + int until_previous_value = 30 + 7; + if (terminal_width > until_previous_value + 4) { + output << ", was: "; + if (previous_value.length() > terminal_width - until_previous_value) { + previous_value = previous_value.substr(0, terminal_width - until_previous_value - 1) + "…"; + } + output << previous_value; + } + output << RESET; + return output.str(); +} + +std::string render_error_message(std::string error_message) { + return RED_BG + BLACK + error_message + RESET; +} + +std::string render_state(State& state, int terminal_width, int terminal_height) { + if (state.is_quitting()) { + return render_quitting(state.get_saved_file(), state.get_backup_file()); + } + + std::stringstream output; + + std::vector items = state.get_items(); + bool is_editing_value = state.is_editing_value(); + std::string error_message = state.get_error_message(); + int active_item = state.get_active_item(); + int active_column = state.get_active_column(); + int* column_widths = state.get_column_widths(); + + int space_for = terminal_height - 5; + VisibleItemIndexes item_indexes = calculate_visible_item_indexes(space_for, active_item, (int)items.size()); + if (item_indexes.begin_at >= 0) { + int longest_line_number = std::to_string(item_indexes.end_at).length(); + int space_on_line = terminal_width - longest_line_number - 3; + for (int index = item_indexes.begin_at; index < item_indexes.end_at; ++index) { + output << render_item_begin(index + 1, longest_line_number); + if (items.at(index).type == "job") { + output << render_job(items.at(index).job, space_on_line, column_widths, index == active_item ? active_column : -1); + } else if (items.at(index).type == "unknown") { + output << render_unknown(items.at(index).unknown, space_on_line); + } + output << std::endl; + } + } + output << render_logo(); + if (is_editing_value) { + output << std::endl; + output << std::endl; + } else { + bool job_is_active = active_item >= 0 && items.at(active_item).type == "job"; + bool job_is_commented = job_is_active && items.at(active_item).job.commented; + output << " " << render_toolbar_line_1(job_is_active ? active_column : -1) << std::endl; + output << " " << render_toolbar_line_2(job_is_active, job_is_commented) << std::endl; + } + + // Getline produces a newline and we can't do anything about it... so when invoking it, + // we actually move the cursor up by one, so that the produced newline does not overflow + // what we already rendered. This newline accommodates for that. + output << std::endl; + + if (is_editing_value) { + output << render_editing_notice(active_column, get_column_value(items.at(active_item).job, active_column), terminal_width); + } else if (error_message != "") { + output << render_error_message(error_message); + } + return output.str(); +} + +std::string render_diff(std::string current, std::string previous) { + int lines = 0; + for(unsigned i = 0; i < previous.length(); ++i) { + char character = previous.at(i); + if (character == '\n') { + lines++; + } + } + std::stringstream output; + for(unsigned i = 0; i < lines; ++i) { + output << CLEAR_LINE << CURSOR_UP; + } + output << current; + return output.str(); +} + +std::string replace_ansi(std::string value, bool with_string) { + std::string final; + for (int i = 0; i < value.length(); i++) { + if (value[i] == '\033') { + if (value.substr(i, RESET.length()) == RESET) { + final += with_string ? "{RESET}" : ""; + i += RESET.length() - 1; + } else if (value.substr(i, BLACK.length()) == BLACK) { + final += with_string ? "{BLACK}" : ""; + i += BLACK.length() - 1; + } else if (value.substr(i, RED_BG.length()) == RED_BG) { + final += with_string ? "{RED_BG}" : ""; + i += RED_BG.length() - 1; + } else if (value.substr(i, GREEN_BG.length()) == GREEN_BG) { + final += with_string ? "{GREEN_BG}" : ""; + i += GREEN_BG.length() - 1; + } else if (value.substr(i, GREEN.length()) == GREEN) { + final += with_string ? "{GREEN}" : ""; + i += GREEN.length() - 1; + } else if (value.substr(i, YELLOW_BG.length()) == YELLOW_BG) { + final += with_string ? "{YELLOW_BG}" : ""; + i += YELLOW_BG.length() - 1; + } else if (value.substr(i, YELLOW.length()) == YELLOW) { + final += with_string ? "{YELLOW}" : ""; + i += YELLOW.length() - 1; + } else if (value.substr(i, MAGENTA_BG.length()) == MAGENTA_BG) { + final += with_string ? "{MAGENTA_BG}" : ""; + i += MAGENTA_BG.length() - 1; + } else if (value.substr(i, MAGENTA.length()) == MAGENTA) { + final += with_string ? "{MAGENTA}" : ""; + i += MAGENTA.length() - 1; + } else if (value.substr(i, BOLD.length()) == BOLD) { + final += with_string ? "{BOLD}" : ""; + i += BOLD.length() - 1; + } else if (value.substr(i, DIM.length()) == DIM) { + final += with_string ? "{DIM}" : ""; + i += DIM.length() - 1; + } else if (value.substr(i, CLEAR_LINE.length()) == CLEAR_LINE) { + final += with_string ? "{CLEAR_LINE}" : ""; + i += CLEAR_LINE.length() - 1; + } else if (value.substr(i, CURSOR_UP.length()) == CURSOR_UP) { + final += with_string ? "{CURSOR_UP}" : ""; + i += CURSOR_UP.length() - 1; + } else { + final += ""; + } + } else { + final += value[i]; + } + } + return final; +} + +void debug_print_ui(std::string value) { + std::cout << "---\n"; + for (int i = 0; i < value.length(); i++) { + if (value[i] == '\n') { + std::cout << "\\n"; + } else { + std::cout << value[i]; + } + } + std::cout << "\n---\n"; +} diff --git a/lib/render.h b/lib/render.h new file mode 100644 index 0000000..b6134d8 --- /dev/null +++ b/lib/render.h @@ -0,0 +1,39 @@ +#ifndef RENDER_H +#define RENDER_H + +#include "state.h" + +std::string render_logo(); + +std::string render_item_begin(int line_number, int width); + +std::string render_quitting(std::string saved_file, std::string backup_file); + +struct VisibleItemIndexes { + int begin_at; + int end_at; +}; + +VisibleItemIndexes calculate_visible_item_indexes(int space_for, int active_item, int total_items); + +std::string render_unknown(CrontabUnknown unknown, int space_on_line); + +std::string render_job(CrontabJob job, int space_on_line, int* column_widths, int active_column); + +std::string render_toolbar_line_1(int active_column); + +std::string render_toolbar_line_2(bool job_is_active, bool job_is_commented); + +std::string render_editing_notice(int active_column, std::string previous_value, int terminal_width); + +std::string render_error_message(std::string error_message); + +std::string render_state(State& state, int terminal_width, int terminal_height); + +std::string render_diff(std::string current, std::string previous); + +std::string replace_ansi(std::string value, bool with_string); + +void debug_print_ui(std::string value); + +#endif diff --git a/lib/render_test.cpp b/lib/render_test.cpp new file mode 100644 index 0000000..5c89fce --- /dev/null +++ b/lib/render_test.cpp @@ -0,0 +1,661 @@ +#include +#include +#include +#include +#include +#include "parser.h" +#include "state.h" +#include "render.h" +#include "version.h" + +void test_very_simple_ones() { + { + std::cout << "test_very_simple_ones" << std::endl; + + std::string rendered; + + rendered = replace_ansi(render_logo(), true); + debug_print_ui(rendered); + assert(rendered == + "{BOLD}cronedit{RESET}" + ); + + rendered = replace_ansi(render_item_begin(1, 1), true); + debug_print_ui(rendered); + assert(rendered == + "{DIM}1 | {RESET}" + ); + rendered = replace_ansi(render_item_begin(1, 2), true); + debug_print_ui(rendered); + assert(rendered == + "{DIM} 1 | {RESET}" + ); + + rendered = replace_ansi(render_error_message("foobar"), true); + debug_print_ui(rendered); + assert(rendered == + "{RED_BG}{BLACK}foobar{RESET}" + ); + } +} + +void test_calculate_visible_item_indexes() { + { + std::cout << "calculate_visible_item_indexes: BASIC" << std::endl; + + VisibleItemIndexes indexes; + int space_for; + int active_item; + int total_items; + + space_for = 3; + active_item = -1; + total_items = 0; + indexes = calculate_visible_item_indexes(space_for, active_item, total_items); + std::cout << indexes.begin_at << "->" << indexes.end_at << std::endl; + assert(indexes.begin_at == -1); + assert(indexes.end_at == -1); + + space_for = 3; + active_item = -1; + total_items = 3; + indexes = calculate_visible_item_indexes(space_for, active_item, total_items); + std::cout << indexes.begin_at << "->" << indexes.end_at << std::endl; + assert(indexes.begin_at == 0); + assert(indexes.end_at == 3); + + space_for = 3; + active_item = -1; + total_items = 5; + indexes = calculate_visible_item_indexes(space_for, active_item, total_items); + std::cout << indexes.begin_at << "->" << indexes.end_at << std::endl; + assert(indexes.begin_at == 0); + assert(indexes.end_at == 3); + + space_for = 3; + active_item = 0; + total_items = 5; + indexes = calculate_visible_item_indexes(space_for, active_item, total_items); + std::cout << indexes.begin_at << "->" << indexes.end_at << std::endl; + assert(indexes.begin_at == 0); + assert(indexes.end_at == 3); + active_item = 1; + indexes = calculate_visible_item_indexes(space_for, active_item, total_items); + std::cout << indexes.begin_at << "->" << indexes.end_at << std::endl; + assert(indexes.begin_at == 0); + assert(indexes.end_at == 3); + active_item = 2; + indexes = calculate_visible_item_indexes(space_for, active_item, total_items); + std::cout << indexes.begin_at << "->" << indexes.end_at << std::endl; + assert(indexes.begin_at == 1); + assert(indexes.end_at == 4); + active_item = 3; + indexes = calculate_visible_item_indexes(space_for, active_item, total_items); + std::cout << indexes.begin_at << "->" << indexes.end_at << std::endl; + assert(indexes.begin_at == 2); + assert(indexes.end_at == 5); + active_item = 4; + indexes = calculate_visible_item_indexes(space_for, active_item, total_items); + std::cout << indexes.begin_at << "->" << indexes.end_at << std::endl; + assert(indexes.begin_at == 2); + assert(indexes.end_at == 5); + } +} + +void test_render_quitting() { + { + std::cout << "render_quitting: BASIC" << std::endl; + + std::string rendered; + + rendered = replace_ansi(render_quitting("", ""), true); + debug_print_ui(rendered); + assert(rendered == + "{RESET}" + ); + + rendered = replace_ansi(render_quitting("test.txt", ""), true); + debug_print_ui(rendered); + assert(rendered == + "Changes saved in test.txt\n{RESET}" + ); + + rendered = replace_ansi(render_quitting("test.txt", "backup.txt"), true); + debug_print_ui(rendered); + assert(rendered == + "Changes saved in test.txt\nPrevious version was backed up in backup.txt\n{RESET}" + ); + + rendered = replace_ansi(render_quitting("", "backup.txt"), true); + debug_print_ui(rendered); + assert(rendered == + "Previous version was backed up in backup.txt\n{RESET}" + ); + } +} + +void test_render_job() { + { + std::cout << "render_job: BASIC" << std::endl; + + int terminal_width = 64; + int column_widths[6] = { 1, 1, 1, 1, 1, 25 }; + CrontabJob job = parse_crontab_job("* * * * * bash /asd/scripts/test.sh"); + + int active_column = -1; + std::string rendered = replace_ansi(render_job(job, terminal_width, column_widths, active_column), true); + std::cout << "active_column = " << active_column << std::endl; + debug_print_ui(rendered); + assert(rendered == + "{DIM}>{RESET} {YELLOW} * * * * * {RESET}{MAGENTA} bash /asd/scripts/test.sh {RESET}" + ); + + std::string expected[6] = { + "{DIM}>{RESET} {YELLOW_BG}{BLACK} * {RESET}{YELLOW} * * * * {RESET}{MAGENTA} bash /asd/scripts/test.sh {RESET}", + "{DIM}>{RESET} {YELLOW} * {RESET}{YELLOW_BG}{BLACK} * {RESET}{YELLOW} * * * {RESET}{MAGENTA} bash /asd/scripts/test.sh {RESET}", + "{DIM}>{RESET} {YELLOW} * * {RESET}{YELLOW_BG}{BLACK} * {RESET}{YELLOW} * * {RESET}{MAGENTA} bash /asd/scripts/test.sh {RESET}", + "{DIM}>{RESET} {YELLOW} * * * {RESET}{YELLOW_BG}{BLACK} * {RESET}{YELLOW} * {RESET}{MAGENTA} bash /asd/scripts/test.sh {RESET}", + "{DIM}>{RESET} {YELLOW} * * * * {RESET}{YELLOW_BG}{BLACK} * {RESET}{YELLOW}{RESET}{MAGENTA} bash /asd/scripts/test.sh {RESET}", + "{DIM}>{RESET} {YELLOW} * * * * * {RESET}{MAGENTA_BG}{BLACK} bash /asd/scripts/test.sh {RESET}" + }; + + for (int active_column = 0; active_column < 6; ++active_column) { + rendered = replace_ansi(render_job(job, terminal_width, column_widths, active_column), true); + std::cout << "active_column = " << active_column << std::endl; + debug_print_ui(rendered); + assert(rendered == expected[active_column]); + } + } + + { + std::cout << "render_job: COLUMN WIDTHS" << std::endl; + + int terminal_width = 64; + int column_widths[6] = { 2, 3, 1, 4, 1, 25 }; + CrontabJob job = parse_crontab_job("* */6 * 1234 * bash /asd/scripts/test.sh"); + + std::string expected[6] = { + "{DIM}>{RESET} {YELLOW_BG}{BLACK} * {RESET}{YELLOW} */6 * 1234 * {RESET}{MAGENTA} bash /asd/scripts/test.sh {RESET}", + "{DIM}>{RESET} {YELLOW} * {RESET}{YELLOW_BG}{BLACK} */6 {RESET}{YELLOW} * 1234 * {RESET}{MAGENTA} bash /asd/scripts/test.sh {RESET}", + "{DIM}>{RESET} {YELLOW} * */6 {RESET}{YELLOW_BG}{BLACK} * {RESET}{YELLOW} 1234 * {RESET}{MAGENTA} bash /asd/scripts/test.sh {RESET}", + "{DIM}>{RESET} {YELLOW} * */6 * {RESET}{YELLOW_BG}{BLACK} 1234 {RESET}{YELLOW} * {RESET}{MAGENTA} bash /asd/scripts/test.sh {RESET}", + "{DIM}>{RESET} {YELLOW} * */6 * 1234 {RESET}{YELLOW_BG}{BLACK} * {RESET}{YELLOW}{RESET}{MAGENTA} bash /asd/scripts/test.sh {RESET}", + "{DIM}>{RESET} {YELLOW} * */6 * 1234 * {RESET}{MAGENTA_BG}{BLACK} bash /asd/scripts/test.sh {RESET}" + }; + + std::string rendered; + for (int active_column = 0; active_column < 6; ++active_column) { + rendered = replace_ansi(render_job(job, terminal_width, column_widths, active_column), true); + std::cout << "active_column = " << active_column << std::endl; + debug_print_ui(rendered); + assert(rendered == expected[active_column]); + } + } + + { + std::cout << "render_job: COMMENTED" << std::endl; + + int terminal_width = 64; + int column_widths[6] = { 1, 1, 1, 1, 1, 25 }; + CrontabJob job = parse_crontab_job("* * * * * bash /asd/scripts/test.sh"); + job.commented = true; + + std::string expected[6] = { + "{GREEN}#{RESET} {GREEN_BG}{BLACK} * {RESET}{GREEN} * * * * {RESET}{GREEN} bash /asd/scripts/test.sh {RESET}", + "{GREEN}#{RESET} {GREEN} * {RESET}{GREEN_BG}{BLACK} * {RESET}{GREEN} * * * {RESET}{GREEN} bash /asd/scripts/test.sh {RESET}", + "{GREEN}#{RESET} {GREEN} * * {RESET}{GREEN_BG}{BLACK} * {RESET}{GREEN} * * {RESET}{GREEN} bash /asd/scripts/test.sh {RESET}", + "{GREEN}#{RESET} {GREEN} * * * {RESET}{GREEN_BG}{BLACK} * {RESET}{GREEN} * {RESET}{GREEN} bash /asd/scripts/test.sh {RESET}", + "{GREEN}#{RESET} {GREEN} * * * * {RESET}{GREEN_BG}{BLACK} * {RESET}{GREEN}{RESET}{GREEN} bash /asd/scripts/test.sh {RESET}", + "{GREEN}#{RESET} {GREEN} * * * * * {RESET}{GREEN_BG}{BLACK} bash /asd/scripts/test.sh {RESET}" + }; + + std::string rendered; + for (int active_column = 0; active_column < 6; ++active_column) { + rendered = replace_ansi(render_job(job, terminal_width, column_widths, active_column), true); + std::cout << "active_column = " << active_column << std::endl; + debug_print_ui(rendered); + assert(rendered == expected[active_column]); + } + } + + { + std::cout << "render_job: TRUNCATED" << std::endl; + + int terminal_width = 34; + int column_widths[6] = { 1, 1, 1, 1, 1, 25 }; + int active_column = 0; + CrontabJob job = parse_crontab_job("* * * * * bash /asd/scripts/test.sh"); + std::string rendered = replace_ansi(render_job(job, terminal_width, column_widths, active_column), true); + debug_print_ui(rendered); + assert(rendered == + "{DIM}>{RESET} {YELLOW_BG}{BLACK} * {RESET}{YELLOW} * * * * {RESET}{MAGENTA} bash /asd/scri… {RESET}" + ); + active_column = 5; + rendered = replace_ansi(render_job(job, terminal_width, column_widths, active_column), true); + debug_print_ui(rendered); + assert(rendered == + "{DIM}>{RESET} {YELLOW} * * * * * {RESET}{MAGENTA_BG}{BLACK} bash /asd/scri… {RESET}" + ); + } +} + +void test_render_unknown() { + { + std::cout << "render_unknown: BASIC" << std::endl; + + int terminal_width; + CrontabUnknown unknown; + std::string rendered; + + terminal_width = 64; + unknown = parse_crontab_unknown("* * * bash /asd/scripts/test.sh"); + rendered = render_unknown(unknown, terminal_width); + debug_print_ui(rendered); + assert(rendered == + "* * * bash /asd/scripts/test.sh" + ); + + terminal_width = 10; + unknown = parse_crontab_unknown("* * * bash /asd/scripts/test.sh"); + rendered = render_unknown(unknown, terminal_width); + debug_print_ui(rendered); + assert(rendered == + "* * * bas…" + ); + } +} + +void test_render_toolbar_line_1() { + { + std::cout << "render_toolbar_line_1: BASIC" << std::endl; + + int active_column = -1; + std::string rendered = replace_ansi(render_toolbar_line_1(active_column), true); + std::cout << "active_column = " << active_column << std::endl; + debug_print_ui(rendered); + assert(rendered == + "{DIM} move save and quit quit{RESET}" + ); + + std::string expected[6] = { + "{DIM} move save and quit quit edit {RESET}{YELLOW}minute{RESET}", + "{DIM} move save and quit quit edit {RESET}{YELLOW}hour{RESET}", + "{DIM} move save and quit quit edit {RESET}{YELLOW}day of month{RESET}", + "{DIM} move save and quit quit edit {RESET}{YELLOW}month{RESET}", + "{DIM} move save and quit quit edit {RESET}{YELLOW}day of week{RESET}", + "{DIM} move save and quit quit edit {RESET}{MAGENTA}command{RESET}" + }; + + for (int active_column = 0; active_column < 6; ++active_column) { + rendered = replace_ansi(render_toolbar_line_1(active_column), true); + std::cout << "active_column = " << active_column << std::endl; + debug_print_ui(rendered); + assert(rendered == expected[active_column]); + } + } +} + +void test_render_toolbar_line_2() { + { + std::cout << "render_toolbar_line_2: BASIC" << std::endl; + + bool job_is_active; + bool job_is_commented; + std::string rendered; + + job_is_active = false; + job_is_commented = false; + rendered = replace_ansi(render_toolbar_line_2(job_is_active, job_is_commented), true); + debug_print_ui(rendered); + assert(rendered == + "{DIM} new{RESET}" + ); + + job_is_active = true; + job_is_commented = false; + rendered = replace_ansi(render_toolbar_line_2(job_is_active, job_is_commented), true); + debug_print_ui(rendered); + assert(rendered == + "{DIM} new comment duplicate remove {RESET}" + ); + + job_is_active = true; + job_is_commented = true; + rendered = replace_ansi(render_toolbar_line_2(job_is_active, job_is_commented), true); + debug_print_ui(rendered); + assert(rendered == + "{DIM} new {RESET}{GREEN} uncomment {RESET}{DIM} duplicate remove {RESET}" + ); + } +} + +void test_render_editing_notice() { + { + std::cout << "render_editing_notice: BASIC" << std::endl; + + int active_column; + std::string previous_value = "foobar 123"; + int terminal_width = 100; + std::string rendered; + + std::string expected[6] = { + "{DIM}editing {RESET}{YELLOW}minute{RESET}{DIM} (leave empty to undo), was: foobar 123{RESET}", + "{DIM}editing {RESET}{YELLOW}hour{RESET}{DIM} (leave empty to undo), was: foobar 123{RESET}", + "{DIM}editing {RESET}{YELLOW}day of month{RESET}{DIM} (leave empty to undo), was: foobar 123{RESET}", + "{DIM}editing {RESET}{YELLOW}month{RESET}{DIM} (leave empty to undo), was: foobar 123{RESET}", + "{DIM}editing {RESET}{YELLOW}day of week{RESET}{DIM} (leave empty to undo), was: foobar 123{RESET}", + "{DIM}editing {RESET}{MAGENTA}command{RESET}{DIM} (leave empty to undo), was: foobar 123{RESET}", + }; + + for (int active_column = 0; active_column < 6; ++active_column) { + rendered = replace_ansi(render_editing_notice(active_column, previous_value, terminal_width), true); + std::cout << "active_column = " << active_column << std::endl; + debug_print_ui(rendered); + assert(rendered == expected[active_column]); + } + + active_column = 5; + terminal_width = 45; + rendered = replace_ansi(render_editing_notice(active_column, previous_value, terminal_width), true); + std::cout << "truncated previous value" << std::endl; + debug_print_ui(rendered); + assert(rendered == + "{DIM}editing {RESET}{MAGENTA}command{RESET}{DIM} (leave empty to undo), was: foobar …{RESET}" + ); + + active_column = 5; + terminal_width = 30; + rendered = replace_ansi(render_editing_notice(active_column, previous_value, terminal_width), true); + std::cout << "no room for previous value" << std::endl; + debug_print_ui(rendered); + assert(rendered == + "{DIM}editing {RESET}{MAGENTA}command{RESET}{DIM} (leave empty to undo){RESET}" + ); + } +} + +void test_render_state() { + { + std::cout << "render_state: EMPTY STATE" << std::endl; + + State state; + std::string rendered = replace_ansi(render_state(state, 100, 50), false); + debug_print_ui(rendered); + assert( + rendered == "\ +cronedit move save and quit quit\n\ + new\n\n\ +"); + } + + { + std::cout << "render_state: ONLY UNKNOWNS" << std::endl; + + State state; + state.parse_line("line 1"); + state.parse_line("line 2"); + state.parse_line("line 3"); + std::string rendered = replace_ansi(render_state(state, 100, 50), false); + debug_print_ui(rendered); + assert( + rendered == "\ +1 | line 1\n\ +2 | line 2\n\ +3 | line 3\n\ +cronedit move save and quit quit\n\ + new\n\n\ +"); + } + + { + std::cout << "render_state: VARIOUS" << std::endl; + + std::string rendered; + + State state; + state.parse_line("# lorem ipsum a dolor sit amet confusing long text on this line not a cronjob but long"); + state.parse_line("0 6/* * * * bash test2.sh"); + state.parse_line("* * * 24 * bash /path/to/foobar.sh"); + state.parse_line("* * * * * bash /path/to/very/long/path/but/foobar/maybe/foobar.sh 2>&1 > /dev/null"); + + rendered = replace_ansi(render_state(state, 100, 50), false); + debug_print_ui(rendered); + assert( + rendered == "\ +1 | # lorem ipsum a dolor sit amet confusing long text on this line not a cronjob but long\n\ +2 | > 0 6/* * * * bash test2.sh \n\ +3 | > * * * 24 * bash /path/to/foobar.sh \n\ +4 | > * * * * * bash /path/to/very/long/path/but/foobar/maybe/foobar.sh 2>&1 > /dev/null \n\ +cronedit move save and quit quit edit minute\n\ + new comment duplicate remove \n\n\ +"); + + rendered = replace_ansi(render_state(state, MIN_TERMINAL_WIDTH, 50), false); + debug_print_ui(rendered); + assert( + rendered == "\ +1 | # lorem ipsum a dolor sit amet confusing long text on this line not a cronjob bu…\n\ +2 | > 0 6/* * * * bash test2.sh \n\ +3 | > * * * 24 * bash /path/to/foobar.sh \n\ +4 | > * * * * * bash /path/to/very/long/path/but/foobar/maybe/foobar.sh 2>… \n\ +cronedit move save and quit quit edit minute\n\ + new comment duplicate remove \n\n\ +"); + + rendered = replace_ansi(render_state(state, 100, MIN_TERMINAL_HEIGHT), false); + debug_print_ui(rendered); + assert( + rendered == "\ +1 | # lorem ipsum a dolor sit amet confusing long text on this line not a cronjob but long\n\ +2 | > 0 6/* * * * bash test2.sh \n\ +3 | > * * * 24 * bash /path/to/foobar.sh \n\ +cronedit move save and quit quit edit minute\n\ + new comment duplicate remove \n\n\ +"); + } + + { + std::cout << "render_state: EDITING" << std::endl; + + std::string rendered; + State state; + + state.parse_line("0 6/* * * * bash test2.sh"); + state.on_begin_editing_value(); + rendered = replace_ansi(render_state(state, 100, 50), false); + std::cout << "Beginning to edit" << std::endl; + debug_print_ui(rendered); + assert( + rendered == "\ +1 | > 0 6/* * * * bash test2.sh \n\ +cronedit\n\ +\n\ +\n\ +editing minute (leave empty to undo), was: 0\ +"); + + state.on_end_editing_value("super invalid value 123"); + rendered = replace_ansi(render_state(state, 100, 50), false); + std::cout << "Inputting an invalid value" << std::endl; + debug_print_ui(rendered); + assert( + rendered == "\ +1 | > 0 6/* * * * bash test2.sh \n\ +cronedit move save and quit quit edit minute\n\ + new comment duplicate remove \n\ +\n\ +super invalid value 123 is not a valid value\ +"); + } + + { + std::cout << "render_state: QUITTING" << std::endl; + + State state; + state.on_quit("saved.txt", "backup.txt"); + std::string rendered = replace_ansi(render_state(state, 100, 50), false); + debug_print_ui(rendered); + assert( + rendered == "\ +Changes saved in saved.txt\n\ +Previous version was backed up in backup.txt\n\ +"); + } + + { + std::cout << "render_state: QUITTING" << std::endl; + + State state; + state.on_quit("saved.txt", "backup.txt"); + std::string rendered = replace_ansi(render_state(state, 100, 50), false); + debug_print_ui(rendered); + assert( + rendered == "\ +Changes saved in saved.txt\n\ +Previous version was backed up in backup.txt\n\ +"); + } +} + +void test_render_diff() { + { + std::cout << "render_diff: BASIC" << std::endl; + + std::string rendered; + std::string current; + std::string previous; + + current = "one\ntwo1"; + previous = "one\ntwo2"; + rendered = replace_ansi(render_diff(current, previous), true); + debug_print_ui(rendered); + assert(rendered == "{CLEAR_LINE}{CURSOR_UP}one\ntwo1"); + + current = "one\ntwo\nthree1"; + previous = "one\ntwo\nthree2"; + rendered = replace_ansi(render_diff(current, previous), true); + debug_print_ui(rendered); + assert(rendered == "{CLEAR_LINE}{CURSOR_UP}{CLEAR_LINE}{CURSOR_UP}one\ntwo\nthree1"); + + current = ""; + previous = "one\ntwo\nthree2"; + rendered = replace_ansi(render_diff(current, previous), true); + debug_print_ui(rendered); + assert(rendered == "{CLEAR_LINE}{CURSOR_UP}{CLEAR_LINE}{CURSOR_UP}"); + + current = "one\ntwo\nthree1"; + previous = ""; + rendered = replace_ansi(render_diff(current, previous), true); + debug_print_ui(rendered); + assert(rendered == "one\ntwo\nthree1"); + } +} + +int main() +{ + test_very_simple_ones(); + test_calculate_visible_item_indexes(); + test_render_quitting(); + test_render_job(); + test_render_unknown(); + test_render_toolbar_line_1(); + test_render_toolbar_line_2(); + test_render_editing_notice(); + test_render_state(); + test_render_diff(); + +// // Editable lines +// State state3; +// state3.parse_line("0 */6 * * * bash test.sh"); +// state3.parse_line("0 * * * * bash test2.sh"); +// std::string ui3 = replace_ansi(render_state(state3, 100, 50), true); +// // debug_print_ui(ui3); +// assert( +// ui3 == "\ +// {DIM}| {RESET}{DIM}> {RESET}{YELLOW_BG}{BLACK} 0 {RESET}{YELLOW} */6 {RESET}{YELLOW} * {RESET}{YELLOW} * {RESET}{YELLOW} * {RESET}{MAGENTA} bash test.sh {RESET}\n\ +// {DIM}| {RESET}{DIM}> {RESET}{YELLOW} 0 {RESET}{YELLOW} * {RESET}{YELLOW} * {RESET}{YELLOW} * {RESET}{YELLOW} * {RESET}{MAGENTA} bash test2.sh {RESET}\n\ +// {BOLD}cronedit {RESET}{DIM} move save and quit quit edit {RESET}{YELLOW}minute {RESET}{DIM}\n\ +// new comment {RESET}{DIM} duplicate remove {RESET}\n\n\ +// "); + +// // Moving around +// State state4; +// state4.parse_line("0 */6 * * * bash test.sh"); +// state4.parse_line("0 * * * * bash test2.sh"); +// state4.on_key_down(); +// state4.on_key_right(); +// state4.on_key_right(); +// std::string ui4 = replace_ansi(render_state(state4, 100, 50), true); +// // debug_print_ui(ui4); +// assert( +// ui4 == "\ +// {DIM}| {RESET}{DIM}> {RESET}{YELLOW} 0 {RESET}{YELLOW} */6 {RESET}{YELLOW} * {RESET}{YELLOW} * {RESET}{YELLOW} * {RESET}{MAGENTA} bash test.sh {RESET}\n\ +// {DIM}| {RESET}{DIM}> {RESET}{YELLOW} 0 {RESET}{YELLOW} * {RESET}{YELLOW_BG}{BLACK} * {RESET}{YELLOW} * {RESET}{YELLOW} * {RESET}{MAGENTA} bash test2.sh {RESET}\n\ +// {BOLD}cronedit {RESET}{DIM} move save and quit quit edit {RESET}{YELLOW}day of month{RESET}{DIM}\n\ +// new comment {RESET}{DIM} duplicate remove {RESET}\n\n\ +// "); + +// // Terminal width +// State state5; +// state5.parse_line("0 * * * * bash /path/to/very/long/path/yes/must/be/over/65/or/something/test1.sh"); +// state5.parse_line("0 * * * * bash /path/to/very/long/path/yes/must/be/over/65/or/something/test2.sh"); +// state5.parse_line("0 * * * * bash /path/to/very/long/path/yes/must/be/over/65/or/something/test3.sh"); +// state5.parse_line("0 * * * * bash /path/to/very/long/path/yes/must/be/over/65/or/something/test4.sh"); +// state5.parse_line("0 * * * * bash /path/to/very/long/path/yes/must/be/over/65/or/something/test5.sh"); +// std::string ui5_1 = replace_ansi(render_state(state5, 64, 8), false); +// // debug_print_ui(ui5_1); +// assert( +// ui5_1 == "\ +// | > 0 * * * * bash /path/to/very/long/path/yes/must/be/over/65/or/something/test1.sh \n\ +// | > 0 * * * * bash /path/to/very/long/path/yes/must/be/over/65/or/something/test2.sh \n\ +// | > 0 * * * * bash /path/to/very/long/path/yes/must/be/over/65/or/something/test3.sh \n\ +// cronedit move save and quit quit edit minute \n\ +// new comment duplicate remove \n\n\ +// "); +// state5.on_key_down(); +// std::string ui5_2 = replace_ansi(render_state(state5, 64, 8), false); +// // debug_print_ui(ui5_2); +// assert( +// ui5_2 == "\ +// | > 0 * * * * bash /path/to/very/long/path/yes/must/be/over/65/or/something/test1.sh \n\ +// | > 0 * * * * bash /path/to/very/long/path/yes/must/be/over/65/or/something/test2.sh \n\ +// | > 0 * * * * bash /path/to/very/long/path/yes/must/be/over/65/or/something/test3.sh \n\ +// cronedit move save and quit quit edit minute \n\ +// new comment duplicate remove \n\n\ +// "); +// state5.on_key_down(); +// std::string ui5_3 = replace_ansi(render_state(state5, 64, 8), false); +// // debug_print_ui(ui5_3); +// assert( +// ui5_3 == "\ +// | > 0 * * * * bash /path/to/very/long/path/yes/must/be/over/65/or/something/test2.sh \n\ +// | > 0 * * * * bash /path/to/very/long/path/yes/must/be/over/65/or/something/test3.sh \n\ +// | > 0 * * * * bash /path/to/very/long/path/yes/must/be/over/65/or/something/test4.sh \n\ +// cronedit move save and quit quit edit minute \n\ +// new comment duplicate remove \n\n\ +// "); +// state5.on_key_down(); +// std::string ui5_4 = replace_ansi(render_state(state5, 64, 8), false); +// // debug_print_ui(ui5_4); +// assert( +// ui5_4 == "\ +// | > 0 * * * * bash /path/to/very/long/path/yes/must/be/over/65/or/something/test3.sh \n\ +// | > 0 * * * * bash /path/to/very/long/path/yes/must/be/over/65/or/something/test4.sh \n\ +// | > 0 * * * * bash /path/to/very/long/path/yes/must/be/over/65/or/something/test5.sh \n\ +// cronedit move save and quit quit edit minute \n\ +// new comment duplicate remove \n\n\ +// "); +// state5.on_key_down(); +// std::string ui5_5 = replace_ansi(render_state(state5, 64, 8), false); +// debug_print_ui(ui5_5); +// assert( +// ui5_5 == "\ +// | > 0 * * * * bash /path/to/very/long/path/yes/must/be/over/65/or/something/test3.sh \n\ +// | > 0 * * * * bash /path/to/very/long/path/yes/must/be/over/65/or/something/test4.sh \n\ +// | > 0 * * * * bash /path/to/very/long/path/yes/must/be/over/65/or/something/test5.sh \n\ +// cronedit move save and quit quit edit minute \n\ +// new comment duplicate remove \n\n\ +// "); + + std::cout << "PASS" << std::endl; +} diff --git a/lib/sample.txt b/lib/sample.txt new file mode 100644 index 0000000..338c589 --- /dev/null +++ b/lib/sample.txt @@ -0,0 +1,6 @@ + +# https://stackoverflow.com/a/5823331/239527 +SSH_AUTH_SOCK=/private/tmp/com.apple.launchd.WvQxE0oPYh/Listeners +0 */6 * * * bash /asd/backup.sh >/asd/backup.log 2>&1 +#* * * * * bash /asd/backup.sh >/asd/backup.log 2>&1 + diff --git a/lib/state.cpp b/lib/state.cpp new file mode 100644 index 0000000..5a0de8f --- /dev/null +++ b/lib/state.cpp @@ -0,0 +1,291 @@ +#include +#include +#include +#include "parser.h" +#include "state.h" + +State::State(void) { + active_item = -1; + active_column = 0; + column_widths[0] = 1; + column_widths[1] = 1; + column_widths[2] = 1; + column_widths[3] = 1; + column_widths[4] = 1; + column_widths[5] = 1; + editing_value = false; + error_message = ""; + quitting = false; +} + +std::vector State::get_items() { + return items; +} + +int State::get_active_item() { + return active_item; +} + +int State::get_active_column() { + return active_column; +} + +int* State::get_column_widths() { + return column_widths; +} + +bool State::is_editing_value() { + return editing_value; +} + +bool State::is_quitting() { + return quitting; +} + +std::string State::get_saved_file() { + return saved_file; +} + +std::string State::get_backup_file() { + return backup_file; +} + +std::string State::get_error_message() { + return error_message; +} + +void State::update_column_widths() { + for (int i = 0; i < 6; i++) { + column_widths[i] = 1; + } + for (std::vector::iterator item = items.begin(); item != items.end(); ++item) { + if (item->type == "job") { + column_widths[0] = std::max(column_widths[0], int(item->job.minute.length())); + column_widths[1] = std::max(column_widths[1], int(item->job.hour.length())); + column_widths[2] = std::max(column_widths[2], int(item->job.day_of_month.length())); + column_widths[3] = std::max(column_widths[3], int(item->job.month.length())); + column_widths[4] = std::max(column_widths[4], int(item->job.day_of_week.length())); + column_widths[5] = std::max(column_widths[5], int(item->job.command.length())); + } + } +} + +int State::next_selectable_item(int step) { + if (items.size() == 0) { + return -1; + } + if (step > 0) { + for (int i = active_item + 1; i < items.size(); i += step) { + if (items[i].type != "unknown") { + return i; + } + } + } + if (step < 0) { + for (int i = active_item - 1; i >= 0; i += step) { + if (items[i].type != "unknown") { + return i; + } + } + } + return active_item; +} + +int State::next_selectable_column(int step) { + if (active_item == -1) { + return 0; + } + int max_column = items[active_item].type == "job" ? 5 : 0; + return std::max(0, std::min(max_column, active_column + step)); +} + +void State::parse_line(std::string line) { + try { + CrontabJob job = parse_crontab_job(line); + items.push_back(StateItem()); + items[items.size() - 1].type = "job"; + items[items.size() - 1].job = job; + if (active_item == -1) { + active_item = items.size() - 1; + } + update_column_widths(); + return; + } catch(NotACrontabJobException&) {} + + CrontabUnknown unknown = parse_crontab_unknown(line); + items.push_back(StateItem()); + items[items.size() - 1].type = "unknown"; + items[items.size() - 1].unknown = unknown; +} + +void State::trim_newlines() { + int leading_newlines = 0; + for (std::vector::iterator item = items.begin(); item != items.end(); ++item) { + if (item->type == "unknown" && item->unknown.text == "") { + leading_newlines++; + } else { + break; + } + } + int trailing_newlines = 0; + for (std::vector::reverse_iterator item = items.rbegin(); item != items.rend(); ++item) { + if (item->type == "unknown" && item->unknown.text == "") { + trailing_newlines++; + } else { + break; + } + } + std::vector trimmed; + for (std::vector::iterator item = items.begin() + leading_newlines; item != items.end() - trailing_newlines; ++item) { + trimmed.push_back(*item); + } + items = trimmed; +} + +void State::print_debug() { + for (std::vector::iterator item = items.begin(); item != items.end(); ++item) { + if (item->type == "job") { + print_job(item->job); + } else if (item->type == "unknown") { + print_unknown(item->unknown); + } else { + std::cout << "Unknown type: " + item->type << std::endl; + } + } +} + +void State::on_key_up() { + error_message = ""; + active_item = next_selectable_item(-1); +} + +void State::on_key_down() { + error_message = ""; + active_item = next_selectable_item(1); +} + +void State::on_key_left() { + error_message = ""; + active_column = next_selectable_column(-1); +} + +void State::on_key_right() { + error_message = ""; + active_column = next_selectable_column(1); +} + +void State::on_toggle_comment() { + error_message = ""; + if (active_item >= 0) { + items[active_item].job.commented = !items[active_item].job.commented; + } +} + +void State::on_create_new() { + error_message = ""; + parse_line("* * * * * echo \"change this\""); + update_column_widths(); +} + +void State::on_duplicate() { + if (active_item >= 0) { + CrontabJob job = items[active_item].job; + StateItem item = items[active_item]; + item.job = job; + items.push_back(item); + update_column_widths(); + } +} + +void State::on_remove() { + if (active_item >= 0) { + items.erase(items.begin() + active_item); + active_item = next_selectable_item(-1); + if (active_item == -1) { + active_item = next_selectable_item(1); + } + update_column_widths(); + } +} + +bool State::on_begin_editing_value() { + if (active_item >= 0) { + editing_value = true; + return true; + } + return false; +} + +std::string trim(std::string value) { + int leading = 0; + for(unsigned i = 0; i < value.length(); ++i) { + if (!std::isspace(value.at(i))) { + break; + } + leading++; + } + if (leading == value.length()) { + return ""; + } + int trailing = 0; + for(unsigned i = value.length() - 1; i >= 0; --i) { + if (!std::isspace(value.at(i))) { + break; + } + trailing++; + } + return value.substr(leading, value.length() - trailing - leading); +} + +void State::on_end_editing_value(std::string input) { + error_message = ""; + editing_value = false; + input = trim(input); + if (input.length() > 0) { + if (active_column == 0) { + if (!is_valid_scheduling_segment(input)) { + error_message = input + " is not a valid value"; + } else { + items[active_item].job.minute = input; + } + } + if (active_column == 1) { + if (!is_valid_scheduling_segment(input)) { + error_message = input + " is not a valid value"; + } else { + items[active_item].job.hour = input; + } + } + if (active_column == 2) { + if (!is_valid_scheduling_segment(input)) { + error_message = input + " is not a valid value"; + } else { + items[active_item].job.day_of_month = input; + } + } + if (active_column == 3) { + if (!is_valid_scheduling_segment(input)) { + error_message = input + " is not a valid value"; + } else { + items[active_item].job.month = input; + } + } + if (active_column == 4) { + if (!is_valid_scheduling_segment(input)) { + error_message = input + " is not a valid value"; + } else { + items[active_item].job.day_of_week = input; + } + } + if (active_column == 5) { + items[active_item].job.command = input; + } + } + update_column_widths(); +} + +void State::on_quit(std::string _saved_file, std::string _backup_file) { + quitting = true; + saved_file = _saved_file; + backup_file = _backup_file; +} diff --git a/lib/state.h b/lib/state.h new file mode 100644 index 0000000..90cba3e --- /dev/null +++ b/lib/state.h @@ -0,0 +1,50 @@ +#ifndef STATE_H +#define STATE_H + +struct StateItem { + std::string type; + CrontabJob job; + CrontabUnknown unknown; +}; + +class State { + std::vector items; + int active_item; + int next_selectable_item(int step); + int active_column; + int next_selectable_column(int step); + int column_widths[6]; + void update_column_widths(); + bool editing_value; + std::string error_message; + bool quitting; + std::string saved_file; + std::string backup_file; +public: + State(); + std::vector get_items(); + int get_active_item(); + int get_active_column(); + int* get_column_widths(); + bool is_editing_value(); + bool is_quitting(); + std::string get_saved_file(); + std::string get_backup_file(); + std::string get_error_message(); + void parse_line(std::string line); + void trim_newlines(); + void print_debug(); + void on_key_up(); + void on_key_down(); + void on_key_left(); + void on_key_right(); + void on_toggle_comment(); + void on_create_new(); + void on_duplicate(); + void on_remove(); + bool on_begin_editing_value(); + void on_end_editing_value(std::string input); + void on_quit(std::string saved_file, std::string backup_file); +}; + +#endif diff --git a/lib/state_test.cpp b/lib/state_test.cpp new file mode 100644 index 0000000..bc299d3 --- /dev/null +++ b/lib/state_test.cpp @@ -0,0 +1,78 @@ +#include +#include +#include +#include +#include +#include "parser.h" +#include "state.h" +#include "render.h" + +int main() +{ + { + std::cout << "Initial state" << std::endl; + State state; + assert(state.get_items().size() == 0); + assert(state.get_active_item() == -1); + assert(state.get_active_column() == 0); + assert(state.get_column_widths()[0] == 1); + assert(state.get_column_widths()[1] == 1); + assert(state.get_column_widths()[2] == 1); + assert(state.get_column_widths()[3] == 1); + assert(state.get_column_widths()[4] == 1); + assert(state.get_column_widths()[5] == 1); + assert(state.is_editing_value() == false); + assert(state.is_quitting() == false); + assert(state.get_saved_file() == ""); + assert(state.get_backup_file() == ""); + assert(state.get_error_message() == ""); + } + + { + std::cout << "Can call any event in initial state" << std::endl; + State state; + state.on_key_up(); + state.on_key_down(); + state.on_key_left(); + state.on_key_right(); + state.on_toggle_comment(); + state.on_duplicate(); + state.on_remove(); + state.on_begin_editing_value(); + state.on_end_editing_value("foobar"); + state.on_quit("saved_file.test", "backup_file.test"); + } + + { + std::cout << "Can create new items" << std::endl; + State state; + state.on_create_new(); + assert(state.get_items().size() == 1); + assert(state.get_active_item() == 0); + assert(state.get_active_column() == 0); + assert(state.get_column_widths()[0] == 1); + assert(state.get_column_widths()[1] == 1); + assert(state.get_column_widths()[2] == 1); + assert(state.get_column_widths()[3] == 1); + assert(state.get_column_widths()[4] == 1); + assert(state.get_column_widths()[5] == 18); + } + + { + std::cout << "Can't begin editing if active item is not a job" << std::endl; + State state; + state.parse_line("foobar"); + assert(state.on_begin_editing_value() == false); + assert(state.is_editing_value() == false); + } + + { + std::cout << "Can begin editing if active item is not a job" << std::endl; + State state; + state.parse_line("0 6/* * * * bash test2.sh"); + assert(state.on_begin_editing_value() == true); + assert(state.is_editing_value() == true); + } + + std::cout << "PASS" << std::endl; +} diff --git a/lib/stringify.cpp b/lib/stringify.cpp new file mode 100644 index 0000000..9e29a0d --- /dev/null +++ b/lib/stringify.cpp @@ -0,0 +1,31 @@ +#include +#include +#include +#include "parser.h" +#include "state.h" + +std::string stringify_items(std::vector items) { + std::stringstream output; + for (std::vector::iterator item = items.begin(); item != items.end(); ++item) { + if (item->type == "job") { + if (item->job.commented) { + output << "# "; + } + output << item->job.minute; + output << " "; + output << item->job.hour; + output << " "; + output << item->job.day_of_month; + output << " "; + output << item->job.month; + output << " "; + output << item->job.day_of_week; + output << " "; + output << item->job.command; + output << std::endl; + } else if (item->type == "unknown") { + output << item->unknown.text << std::endl; + } + } + return output.str(); +} diff --git a/lib/stringify.h b/lib/stringify.h new file mode 100644 index 0000000..9155df2 --- /dev/null +++ b/lib/stringify.h @@ -0,0 +1,6 @@ +#ifndef STRINGIFY_H +#define STRINGIFY_H + +std::string stringify_items(std::vector items); + +#endif diff --git a/lib/stringify_test.cpp b/lib/stringify_test.cpp new file mode 100644 index 0000000..018f3fa --- /dev/null +++ b/lib/stringify_test.cpp @@ -0,0 +1,36 @@ +#include +#include +#include +#include +#include +#include "parser.h" +#include "state.h" +#include "stringify.h" + +int main() +{ + State state; + state.parse_line(""); + state.parse_line("# https://stackoverflow.com/a/5823331/239527"); + state.parse_line("SSH_AUTH_SOCK=/private/tmp/com.apple.launchd.WvQxE0oPYh/Listeners"); + state.parse_line("0 */6 * * * bash /asd/backup.sh >/asd/backup.log 2>&1"); + state.parse_line("#* * * * * bash /asd/backup.sh >/asd/backup.log 2>&1"); + state.parse_line(""); + state.parse_line(""); + + std::string stringified = stringify_items(state.get_items()); + std::cout << "---" << std::endl; + std::cout << stringified << std::endl; + std::cout << "---" << std::endl; + assert(stringified == "\ +\n\ +# https://stackoverflow.com/a/5823331/239527\n\ +SSH_AUTH_SOCK=/private/tmp/com.apple.launchd.WvQxE0oPYh/Listeners\n\ +0 */6 * * * bash /asd/backup.sh >/asd/backup.log 2>&1\n\ +# * * * * * bash /asd/backup.sh >/asd/backup.log 2>&1\n\ +\n\ +\n\ +"); + + std::cout << "PASS" << std::endl; +} diff --git a/lib/terminal.cpp b/lib/terminal.cpp new file mode 100644 index 0000000..5f59274 --- /dev/null +++ b/lib/terminal.cpp @@ -0,0 +1,50 @@ +#include +#include +#include +#include +#include +#include + +#define HIDE_CURSOR "\e[?25l" +#define SHOW_CURSOR "\e[?25h" +#define BEGINNING_OF_PREVIOUS_LINE "\033[G\033[1A" + +bool is_tty() { + return isatty(fileno(stdin)); +} + +struct winsize win_size; +int get_terminal_width() { + ioctl(STDOUT_FILENO, TIOCGWINSZ, &win_size); + return win_size.ws_col; +} +int get_terminal_height() { + ioctl(STDOUT_FILENO, TIOCGWINSZ, &win_size); + return win_size.ws_row; +} + +// Did not have to bring in ncurses, thanks to this answer: +// https://stackoverflow.com/a/12710738/239527 + +struct termios oldt, newt; + +void init_terminal() { + tcgetattr(fileno(stdin), &oldt); + newt = oldt; + newt.c_lflag &= ~(ICANON | ECHO); + tcsetattr(fileno(stdin), TCSANOW, &newt); + std::cout << HIDE_CURSOR; +} + +int terminal_get_char() { + return getchar(); +} + +void revert_terminal() { + std::cout << SHOW_CURSOR; + tcsetattr(fileno(stdin), TCSANOW, &oldt); +} + +void prepare_for_input() { + std::cout << BEGINNING_OF_PREVIOUS_LINE; +} diff --git a/lib/terminal.h b/lib/terminal.h new file mode 100644 index 0000000..32237e7 --- /dev/null +++ b/lib/terminal.h @@ -0,0 +1,32 @@ +#ifndef TERMINAL_H +#define TERMINAL_H + +bool is_tty(); + +int get_terminal_width(); + +int get_terminal_height(); + +void init_terminal(); + +int terminal_get_char(); + +void revert_terminal(); + +void prepare_for_input(); + +int KEY_UP = 65; +int KEY_RIGHT = 67; +int KEY_DOWN = 66; +int KEY_LEFT = 68; + +int KEY_Q = 113; +int KEY_ESC = 27; +int KEY_ENTER = 10; +int KEY_C = 99; +int KEY_N = 110; +int KEY_D = 100; +int KEY_R = 114; +int KEY_S = 115; + +#endif diff --git a/lib/version.h b/lib/version.h new file mode 100644 index 0000000..803bd62 --- /dev/null +++ b/lib/version.h @@ -0,0 +1,9 @@ +#ifndef VERSION_H +#define VERSION_H + +#define CRONEDIT_VERSION "0.1.0" + +#define MIN_TERMINAL_WIDTH 85 +#define MIN_TERMINAL_HEIGHT 8 + +#endif