From f3bb6ac62b884196ab32f36d0b0f0a030b42a952 Mon Sep 17 00:00:00 2001 From: blankie Date: Tue, 2 Jan 2024 23:36:58 +1100 Subject: [PATCH] Initial commit --- .gitignore | 1 + CMakeLists.txt | 34 ++++++++++ LICENSE | 19 ++++++ blankie/cliparse.cpp | 123 ++++++++++++++++++++++++++++++++++ blankie/cliparse.h | 142 +++++++++++++++++++++++++++++++++++++++ database.cpp | 17 +++++ database.h | 17 +++++ main.cpp | 91 +++++++++++++++++++++++++ sqlite_wrapper.cpp | 24 +++++++ sqlite_wrapper.h | 119 +++++++++++++++++++++++++++++++++ subcommand_create.cpp | 21 ++++++ subcommand_delete.cpp | 29 ++++++++ subcommand_edit.cpp | 150 ++++++++++++++++++++++++++++++++++++++++++ subcommand_get.cpp | 31 +++++++++ subcommand_info.cpp | 42 ++++++++++++ subcommand_prune.cpp | 38 +++++++++++ subcommand_search.cpp | 35 ++++++++++ subcommand_set.cpp | 34 ++++++++++ subcommands.h | 43 ++++++++++++ utils.cpp | 103 +++++++++++++++++++++++++++++ utils.h | 11 ++++ 21 files changed, 1124 insertions(+) create mode 100644 .gitignore create mode 100644 CMakeLists.txt create mode 100644 LICENSE create mode 100644 blankie/cliparse.cpp create mode 100644 blankie/cliparse.h create mode 100644 database.cpp create mode 100644 database.h create mode 100644 main.cpp create mode 100644 sqlite_wrapper.cpp create mode 100644 sqlite_wrapper.h create mode 100644 subcommand_create.cpp create mode 100644 subcommand_delete.cpp create mode 100644 subcommand_edit.cpp create mode 100644 subcommand_get.cpp create mode 100644 subcommand_info.cpp create mode 100644 subcommand_prune.cpp create mode 100644 subcommand_search.cpp create mode 100644 subcommand_set.cpp create mode 100644 subcommands.h create mode 100644 utils.cpp create mode 100644 utils.h diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..796b96d --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/build diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..4bdfea2 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,34 @@ +cmake_minimum_required(VERSION 3.16) + +project(mf C CXX) + + +find_package(SQLite3 REQUIRED) + + +if (CMAKE_BUILD_TYPE MATCHES "Debug") + if (NOT FLAGS) + list(APPEND FLAGS -fsanitize=undefined) + endif() + # https://sourceforge.net/p/valgrind/mailman/valgrind-users/thread/Ygze8PzaQAYWlKDj%40wildebeest.org/ + list(APPEND FLAGS -gdwarf-4) +endif() + +# https://t.me/NightShadowsHangout/670691 +list(APPEND FLAGS -Werror -Wall -Wextra -Wshadow -Wpedantic -Wno-gnu-anonymous-struct -fPIC -fno-rtti -Wconversion -Wno-unused-parameter -Wimplicit-fallthrough) + +# i have no idea why this hack wasn't needed before but it's needed if sanitizers are used +add_link_options(${FLAGS}) + + +add_executable(${PROJECT_NAME} main.cpp sqlite_wrapper.cpp database.cpp utils.cpp blankie/cliparse.cpp + subcommand_create.cpp subcommand_search.cpp subcommand_prune.cpp + subcommand_info.cpp subcommand_delete.cpp subcommand_edit.cpp subcommand_get.cpp subcommand_set.cpp) +set_target_properties(${PROJECT_NAME} + PROPERTIES + CXX_STANDARD 20 + CXX_STANDARD_REQUIRED YES + CXX_EXTENSIONS NO +) +target_link_libraries(${PROJECT_NAME} PRIVATE SQLite::SQLite3) +target_compile_options(${PROJECT_NAME} PRIVATE ${FLAGS}) diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c4314df --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2023 blankie + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/blankie/cliparse.cpp b/blankie/cliparse.cpp new file mode 100644 index 0000000..b8ab697 --- /dev/null +++ b/blankie/cliparse.cpp @@ -0,0 +1,123 @@ +#include +#include + +#include "cliparse.h" + +namespace blankie { +namespace cliparse { + +void Parser::parse(int argc, char** argv) { + for (Flag& flag : this->flags) { + flag.used = false; + flag.argument = nullptr; + } + this->arguments.clear(); + + int offset = 0; + bool parse_flags = true; + + if (offset == 0 && argc) { + this->program_name = argv[0]; + offset++; + } + + while (argc > offset) { + if (argv[offset][0] != '-' || argv[offset][1] == '\0' || !parse_flags) { + this->arguments.push_back(argv[offset]); + } else if (argv[offset][1] == '-') { + this->_handle_long_flag(argc, argv, offset, parse_flags); + } else { + this->_handle_short_flags(argc, argv, offset); + } + + offset++; + } + + auto unused_flag = std::find_if(this->flags.begin(), this->flags.end(), [](Flag& flag) { + return flag.required && !flag.used; + }); + if (unused_flag != this->flags.end()) { + throw missing_flag(unused_flag->long_flag); + } + + if (this->arguments_min > this->arguments.size() || this->arguments_max < this->arguments.size()) { + throw invalid_argument_count(this->arguments_min, this->arguments_max); + } +} + +void Parser::_handle_long_flag(int argc, char** argv, int& offset, bool& parse_flags) { + if (argv[offset][2] == '\0') { + parse_flags = false; + return; + } + + const char* long_flag = &argv[offset][2]; + const char* equal_offset = strchr(long_flag, '='); + size_t long_flag_length = equal_offset ? static_cast(equal_offset - long_flag) : strlen(long_flag); + auto flag = std::find_if(this->flags.begin(), this->flags.end(), [=](Flag& flag) { + return strncmp(flag.long_flag, long_flag, long_flag_length) == 0 && flag.long_flag[long_flag_length] == '\0'; + }); + + if (flag == this->flags.end()) { + throw unknown_flag(std::string(long_flag, long_flag_length)); + } + flag->used = true; + + if (flag->argument_required == ArgumentRequired::No) { + if (equal_offset) { + throw unnecessary_argument(flag->long_flag); + } + } else if (equal_offset) { + flag->argument = &equal_offset[1]; + } else if (flag->argument_required == ArgumentRequired::Yes) { + if (offset < argc - 1) { + flag->argument = argv[++offset]; + } else { + throw missing_argument(flag->long_flag); + } + } +} + +void Parser::_handle_short_flags(int argc, char** argv, int& offset) { + const char* options = &argv[offset][1]; + + while (options[0] != '\0') { + Flag* flag = this->flag(options[0]); + if (!flag) { + throw unknown_flag(options[0]); + } + + options++; + flag->used = true; + if (flag->argument_required == ArgumentRequired::No) { + continue; + } else if (options[0] != '\0') { + flag->argument = options[0] == '=' ? &options[1] : options; + break; + } else if (flag->argument_required == ArgumentRequired::Yes) { + if (offset < argc - 1) { + flag->argument = argv[++offset]; + } else { + throw missing_argument(flag->long_flag); + } + break; + } + } +} + + +Flag* Parser::flag(const char* long_flag) { + auto flag = std::find_if(this->flags.begin(), this->flags.end(), [=](Flag& flag) { + return strcmp(flag.long_flag, long_flag) == 0; + }); + return flag != this->flags.end() ? &*flag : nullptr; +} +Flag* Parser::flag(char short_flag) { + auto flag = std::find_if(this->flags.begin(), this->flags.end(), [=](Flag& flag) { + return flag.short_flag == short_flag; + }); + return flag != this->flags.end() ? &*flag : nullptr; +} + +} // namespace cliparse +} // namespace blankie diff --git a/blankie/cliparse.h b/blankie/cliparse.h new file mode 100644 index 0000000..35bbd2b --- /dev/null +++ b/blankie/cliparse.h @@ -0,0 +1,142 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace blankie { +namespace cliparse { + +enum ArgumentRequired { + Yes, + Maybe, + No, +}; + +struct Flag { + const char* long_flag; // must not be null + char short_flag; // may be '\0' + bool required; + ArgumentRequired argument_required; + + bool used; + const char* argument; // may be null + + Flag(const char* long_flag_, char short_flag_, bool required_ = false, ArgumentRequired argument_required_ = ArgumentRequired::No) + : long_flag(long_flag_), short_flag(short_flag_), required(required_), argument_required(argument_required_) { + assert(this->long_flag != nullptr); + } +}; + +struct Parser { + const char* program_name; // may be null + std::vector flags; + size_t arguments_min; + size_t arguments_max; + std::vector arguments; + + void parse(int argc, char** argv); + + Flag* flag(const char* long_flag); + Flag* flag(char short_flag); + + Parser(const char* program_name_, std::vector flags_, size_t arguments_min_ = 0, size_t arguments_max_ = SIZE_MAX) + : program_name(program_name_), flags(std::move(flags_)), arguments_min(arguments_min_), arguments_max(arguments_max_) { + assert(this->arguments_max >= this->arguments_min); + } + +private: + void _handle_long_flag(int argc, char** argv, int& offset, bool& parse_flags); + void _handle_short_flags(int argc, char** argv, int& offset); +}; + +class exception : public std::exception {}; + +class missing_flag : public exception { +public: + missing_flag(const char* long_flag_) : long_flag(long_flag_) { + using namespace std::string_literals; + this->_msg = "flag --"s + this->long_flag + " required, but not used"; + } + const char* what() const noexcept { + return this->_msg.c_str(); + } + + const char* long_flag; + +private: + std::string _msg; +}; + +class missing_argument : public exception { +public: + missing_argument(const char* long_flag_) : long_flag(long_flag_) { + using namespace std::string_literals; + this->_msg = "flag --"s + this->long_flag + " requires an argument, but one is not passed"; + } + const char* what() const noexcept { + return this->_msg.c_str(); + } + + const char* long_flag; + +private: + std::string _msg; +}; + +class invalid_argument_count : public exception { +public: + invalid_argument_count(size_t arguments_min_, size_t arguments_max_) : arguments_min(arguments_min_), arguments_max(arguments_max_) { + using namespace std::string_literals; + this->_msg = "too little or too many arguments provided, there must be "s + + std::to_string(this->arguments_min) + " to " + std::to_string(this->arguments_max) + " arguments"; + } + const char* what() const noexcept { + return this->_msg.c_str(); + } + + size_t arguments_min; + size_t arguments_max; + +private: + std::string _msg; +}; + +class unknown_flag : public exception { +public: + unknown_flag(std::string long_flag) { + using namespace std::string_literals; + this->_msg = "unknown flag --"s + std::move(long_flag); + } + unknown_flag(char short_flag) { + using namespace std::string_literals; + this->_msg = "unknown flag -"s + short_flag; + } + const char* what() const noexcept { + return this->_msg.c_str(); + } + +private: + std::string _msg; +}; + +class unnecessary_argument : public exception { +public: + unnecessary_argument(const char* long_flag_) : long_flag(long_flag_) { + using namespace std::string_literals; + this->_msg = "flag --"s + this->long_flag + " does not take in an argument, yet one is passed"; + } + const char* what() const noexcept { + return this->_msg.c_str(); + } + + const char* long_flag; + +private: + std::string _msg; +}; + +} // namespace cliparse +} // namespace blankie diff --git a/database.cpp b/database.cpp new file mode 100644 index 0000000..3abb901 --- /dev/null +++ b/database.cpp @@ -0,0 +1,17 @@ +#include + +#include "database.h" + +Database Database::find(bool readonly) { + std::filesystem::path directory = std::filesystem::current_path(); + do { + std::filesystem::path path = directory / "mf.db"; + if (std::filesystem::is_regular_file(path)) { + return Database(path, readonly, false); + } + directory = directory.parent_path(); + } while (directory != directory.root_path()); + + fprintf(stderr, "Failed to find a database; have you created one and are in a directory with it?\n"); + exit(1); +} diff --git a/database.h b/database.h new file mode 100644 index 0000000..4cf5425 --- /dev/null +++ b/database.h @@ -0,0 +1,17 @@ +#pragma once + +#include + +#include "sqlite_wrapper.h" + +struct Database { + Database(std::filesystem::path path_, bool readonly, bool create) : path(std::move(path_)), db(this->path.c_str(), readonly, create) {} + + static Database find(bool readonly); + static Database create_in_cwd() { + return Database(std::filesystem::current_path() / "mf.db", false, true); + } + + std::filesystem::path path; + Sqlite3 db; +}; diff --git a/main.cpp b/main.cpp new file mode 100644 index 0000000..60b3737 --- /dev/null +++ b/main.cpp @@ -0,0 +1,91 @@ +#include +#include +#include +#include + +#include "database.h" +#include "subcommands.h" +#include "blankie/cliparse.h" + +void sqlite_error_logger(void*, int error_code, const char* message) { + // For some reason, Sqlite3Statement::bind_text causes this + if (error_code == SQLITE_SCHEMA) { + return; + } + + fprintf(stderr, "sqlite: (%d) %s\n", error_code, message); +} + +void exit_handler() { + sqlite3_shutdown(); +} + +void real_main(int argc, char** argv); + +int main(int argc, char** argv) { + sqlite3_config(SQLITE_CONFIG_LOG, sqlite_error_logger, nullptr); + + int error_code = sqlite3_initialize(); + if (error_code != SQLITE_OK) { + throw Sqlite3Exception(error_code, nullptr); + } + + atexit(exit_handler); + + try { + real_main(argc, argv); + } catch (...) { + sqlite3_shutdown(); + throw; + } +} + + + +void real_main(int argc, char** argv) { + Parser parser("mf", { + {"directory", 'C', false, blankie::cliparse::ArgumentRequired::Yes}, + }, 1, ~static_cast(0)); + + try { + parser.parse(argc, argv); + } catch (const blankie::cliparse::invalid_argument_count& e) { + fprintf(stderr, HELP_TEXT, parser.program_name); + exit(1); + } catch (const blankie::cliparse::exception& e) { + fprintf(stderr, "failed to parse arguments: %s\n", e.what()); + exit(1); + } + + if (parser.flag('C')->used) { + if (chdir(parser.flag('C')->argument)) { + perror("failed to chdir"); + exit(1); + } + } + + auto is = [&](const char* subcommand) { + return strcmp(parser.arguments[0], subcommand) == 0; + }; + + if (is("create")) { + subcommand_create(parser); + } else if (is("search")) { + subcommand_search(parser); + } else if (is("prune")) { + subcommand_prune(parser); + } else if (is("info") || is("show")) { + subcommand_info(parser); + } else if (is("delete")) { + subcommand_delete(parser); + } else if (is("edit")) { + subcommand_edit(parser); + } else if (is("get")) { + subcommand_get(parser); + } else if (is("set")) { + subcommand_set(parser); + } else { + fprintf(stderr, HELP_TEXT, parser.program_name); + exit(1); + } +} diff --git a/sqlite_wrapper.cpp b/sqlite_wrapper.cpp new file mode 100644 index 0000000..28625df --- /dev/null +++ b/sqlite_wrapper.cpp @@ -0,0 +1,24 @@ +#include "sqlite_wrapper.h" + +Sqlite3::Sqlite3(const char* filename, bool readonly, bool create) { + int flags = readonly ? SQLITE_OPEN_READONLY : SQLITE_OPEN_READWRITE; + if (create) { + flags |= SQLITE_OPEN_CREATE; + } + + int error_code = sqlite3_open_v2(filename, &this->_db, flags, nullptr); + if (error_code != SQLITE_OK) { + Sqlite3Exception e(error_code, this); + sqlite3_close_v2(this->_db); + throw e; + } + + sqlite3_extended_result_codes(this->_db, 1); +} + +Sqlite3Statement::Sqlite3Statement(Sqlite3& db, const char* sql) { + int error_code = sqlite3_prepare_v2(db.get(), sql, -1, &this->_stmt, nullptr); + if (error_code != SQLITE_OK) { + throw Sqlite3Exception(error_code, &db); + } +} diff --git a/sqlite_wrapper.h b/sqlite_wrapper.h new file mode 100644 index 0000000..3d5f4bd --- /dev/null +++ b/sqlite_wrapper.h @@ -0,0 +1,119 @@ +#pragma once + +#include +#include +#include + +class Sqlite3Statement; + +class Sqlite3 { +public: + Sqlite3(const Sqlite3&) = delete; + Sqlite3& operator=(const Sqlite3&) = delete; + + Sqlite3(const char* filename, bool readonly, bool create); + ~Sqlite3() { + sqlite3_close_v2(this->_db); + } + + // https://stackoverflow.com/a/6458689 + template + constexpr void exec(Sqlite3Statement& stmt, F callback, size_t iterations = ~static_cast(0)); + constexpr void exec(Sqlite3Statement& stmt) { + this->exec(stmt, []() {}); + } + + constexpr int64_t changes() const noexcept { + return sqlite3_changes64(this->_db); + } + + constexpr sqlite3* get() const noexcept { + return this->_db; + } + +private: + sqlite3* _db; +}; + +class Sqlite3Statement { +public: + Sqlite3Statement(const Sqlite3Statement&) = delete; + Sqlite3Statement& operator=(const Sqlite3Statement&) = delete; + + Sqlite3Statement(Sqlite3& db, const char* sql); + ~Sqlite3Statement() { + sqlite3_finalize(this->_stmt); + } + + constexpr void reset() noexcept { + sqlite3_reset(this->_stmt); + } + + constexpr void bind_text(int column, const char* text, int bytes, void (*lifetime)(void*)); + constexpr void bind_text(int column, const char* text, void (*lifetime)(void*)) { + this->bind_text(column, text, -1, lifetime); + } + constexpr void bind_text(int column, const std::string& text, void (*lifetime)(void*)) { + this->bind_text(column, text.data(), static_cast(text.size()), lifetime); + } + + constexpr void bind_null(int column); + + const char* column_text(int column) const noexcept { + return reinterpret_cast(sqlite3_column_text(this->_stmt, column)); + } + constexpr int64_t column_int64(int column) const noexcept { + return sqlite3_column_int64(this->_stmt, column); + } + + friend Sqlite3; + +private: + sqlite3_stmt* _stmt; +}; + +class Sqlite3Exception : public std::exception { +public: + Sqlite3Exception(int error_code, const Sqlite3* db) { + this->_msg = db && db->get() + ? sqlite3_errmsg(db->get()) + : sqlite3_errstr(error_code); + } + + const char* what() const noexcept { + return this->_msg.c_str(); + } + +private: + std::string _msg; +}; + + + +template +constexpr void Sqlite3::exec(Sqlite3Statement& stmt, F callback, size_t iterations) { + while (iterations--) { + int error_code = sqlite3_step(stmt._stmt); + if (error_code == SQLITE_DONE) { + break; + } else if (error_code == SQLITE_ROW) { + callback(); + } else if (error_code != SQLITE_OK) { + throw Sqlite3Exception(error_code, this); + } + } +} + +constexpr void Sqlite3Statement::bind_text(int column, const char* text, int bytes, void (*lifetime)(void*)) { + int error_code = sqlite3_bind_text(this->_stmt, column, text, bytes, lifetime); + if (error_code != SQLITE_OK) { + throw Sqlite3Exception(error_code, nullptr); + } +} + +constexpr void Sqlite3Statement::bind_null(int column) { + int error_code = sqlite3_bind_null(this->_stmt, column); + if (error_code != SQLITE_OK) { + throw Sqlite3Exception(error_code, nullptr); + } +} diff --git a/subcommand_create.cpp b/subcommand_create.cpp new file mode 100644 index 0000000..e800f8a --- /dev/null +++ b/subcommand_create.cpp @@ -0,0 +1,21 @@ +#include "database.h" + +#include "subcommands.h" + +void subcommand_create(const Parser& parser) { + if (parser.arguments.size() != 1) { + fprintf(stderr, HELP_TEXT, parser.program_name); + exit(1); + } + + Database db = Database::create_in_cwd(); + + Sqlite3Statement stmt(db.db, R"EOF(CREATE VIRTUAL TABLE IF NOT EXISTS memes USING fts4( + path TEXT PRIMARY KEY NOT NULL, + source TEXT NOT NULL, + description TEXT NOT NULL, + miscinfo TEXT NOT NULL, + matchinfo=fts3 + ))EOF"); + db.db.exec(stmt); +} diff --git a/subcommand_delete.cpp b/subcommand_delete.cpp new file mode 100644 index 0000000..e592dc5 --- /dev/null +++ b/subcommand_delete.cpp @@ -0,0 +1,29 @@ +#include "utils.h" +#include "database.h" + +#include "subcommands.h" + +void subcommand_delete(const Parser& parser) { + if (parser.arguments.size() < 2) { + fprintf(stderr, HELP_TEXT, parser.program_name); + exit(1); + } + + int rc = 0; + Database db = Database::find(false); + std::vector paths = get_paths_relative_to_database(parser, db, &rc, false); + Sqlite3Statement stmt(db.db, "DELETE FROM memes WHERE path = ?"); + + for (const std::filesystem::path& i : paths) { + stmt.reset(); + stmt.bind_text(1, i.native(), SQLITE_STATIC); + db.db.exec(stmt); + + if (db.db.changes() <= 0) { + fprintf(stderr, "failed to delete %s: no such file\n", i.c_str()); + rc = 1; + } + } + + exit(rc); +} diff --git a/subcommand_edit.cpp b/subcommand_edit.cpp new file mode 100644 index 0000000..3a890e2 --- /dev/null +++ b/subcommand_edit.cpp @@ -0,0 +1,150 @@ +#include +#include +#include +#include + +#include "utils.h" +#include "database.h" + +#include "subcommands.h" + +using namespace std::string_literals; + +const std::regex contents_regex("# Path \\(immutable\\):\n\\s*[^\0]*?\\s*\0\n# Source:\n\\s*([^\0]*?)\\s*\0\n# Description:\n\\s*([^\0]*?)\\s*\0\n# Miscellaneous information:\n\\s*([^\0]*?)\\s*"s); +static inline const char* editor(); +static inline std::string read_file(int fd); + +struct TemporaryFile { + int fd; + char name[14] = "/tmp/mfXXXXXX"; + bool unlinked = false; + + TemporaryFile() { + this->fd = mkstemp(this->name); + if (this->fd == -1) { + throw std::system_error(errno, std::generic_category(), "mkstemp()"); + } + } + ~TemporaryFile() { + if (close(this->fd)) { + perror("close(temp_fd)"); + } + this->unlink(); + } + + void unlink() { + if (!this->unlinked && ::unlink(this->name)) { + perror("unlink(temp_name)"); + } + this->unlinked = true; + } +}; + +void subcommand_edit(const Parser& parser) { + // Sanity check the arguments passed + if (parser.arguments.size() != 2) { + fprintf(stderr, HELP_TEXT, parser.program_name); + exit(1); + } + + // Open the database and find the meme's path, relative to the database + Database db = Database::find(false); + std::filesystem::path path = get_path_relative_to_database(parser.arguments[1], db); + + // Select the meme, if any + bool meme_found = false; + Sqlite3Statement select_stmt(db.db, "SELECT source, description, miscinfo FROM memes WHERE path = ?"); + select_stmt.bind_text(1, path.native(), SQLITE_STATIC); + db.db.exec(select_stmt, [&]() { + meme_found = true; + }, 1); + + // Create a temporary file and write the meme's metadata to it + TemporaryFile tempfile; + output_meme( + path.c_str(), + meme_found ? select_stmt.column_text(0) : "", + meme_found ? select_stmt.column_text(1) : "", + meme_found ? select_stmt.column_text(2) : "", + tempfile.fd, + true + ); + select_stmt.reset(); + select_stmt.bind_null(1); + if (lseek(tempfile.fd, 0, SEEK_SET)) { + perror("lseek(temp_fd)"); + exit(1); + } + + // Spawn $VISUAL/$EDITOR/vi + pid_t editor_pid = fork(); + if (editor_pid == -1) { + perror("fork()"); + exit(1); + } else if (editor_pid == 0) { + execlp(editor(), editor(), tempfile.name, nullptr); + perror("execlp()"); + _exit(1); + } else if (waitpid(editor_pid, nullptr, 0) != editor_pid) { + perror("waitpid()"); + } + + // Delete the temporary file and read its contents + tempfile.unlink(); + std::string contents = read_file(tempfile.fd); + std::smatch sm; + if (!std::regex_match(contents, sm, contents_regex)) { + fprintf(stderr, "failed to parse file contents\n"); + exit(1); + } + + // Write new meme metadata to the database + std::string source = sm.str(1); + std::string description = sm.str(2); + std::string miscinfo = sm.str(3); + if (meme_found) { + Sqlite3Statement update_stmt(db.db, "UPDATE memes SET source = ?, description = ?, miscinfo = ? WHERE path = ?"); + update_stmt.bind_text(1, source, SQLITE_STATIC); + update_stmt.bind_text(2, description, SQLITE_STATIC); + update_stmt.bind_text(3, miscinfo, SQLITE_STATIC); + update_stmt.bind_text(4, path.native(), SQLITE_STATIC); + db.db.exec(update_stmt); + } else { + Sqlite3Statement insert_stmt(db.db, "INSERT INTO memes (path, source, description, miscinfo) VALUES (?, ?, ?, ?)"); + insert_stmt.bind_text(1, path.native(), SQLITE_STATIC); + insert_stmt.bind_text(2, source, SQLITE_STATIC); + insert_stmt.bind_text(3, description, SQLITE_STATIC); + insert_stmt.bind_text(4, miscinfo, SQLITE_STATIC); + db.db.exec(insert_stmt); + } +} + + + +static inline const char* editor() { + if (getenv("VISUAL")) { + return getenv("VISUAL"); + } + if (getenv("EDITOR")) { + return getenv("EDITOR"); + } + return "vi"; +} + +static inline std::string read_file(int fd) { + std::string res; + char buf[4096]; + + while (true) { + ssize_t read_size = read(fd, buf, 4096); + if (read_size == -1) { + throw std::system_error(errno, std::generic_category(), "read(temp_fd)"); + } else if (read_size == 0) { + break; + } else { + res.insert(res.end(), buf, &buf[read_size]); + } + } + + return res; +} diff --git a/subcommand_get.cpp b/subcommand_get.cpp new file mode 100644 index 0000000..47545b5 --- /dev/null +++ b/subcommand_get.cpp @@ -0,0 +1,31 @@ +#include "utils.h" +#include "database.h" + +#include "subcommands.h" + +void subcommand_get(const Parser& parser) { + // Sanity check the arguments passed + if (parser.arguments.size() != 2) { + fprintf(stderr, HELP_TEXT, parser.program_name); + exit(1); + } + + // Open the database and find the meme's path, relative to the database + Database db = Database::find(true); + std::filesystem::path path = get_path_relative_to_database(parser.arguments[1], db); + + // Select the meme, if any + bool meme_found = false; + Sqlite3Statement select_stmt(db.db, "SELECT source, description, miscinfo FROM memes WHERE path = ?"); + select_stmt.bind_text(1, path.native(), SQLITE_STATIC); + db.db.exec(select_stmt, [&]() { + meme_found = true; + }, 1); + + // Output the meme's metadata to stdout + printf("%s%c%s%c%s%c%s", + path.c_str(), 0, + meme_found ? select_stmt.column_text(0) : "", 0, + meme_found ? select_stmt.column_text(1) : "", 0, + meme_found ? select_stmt.column_text(2) : ""); +} diff --git a/subcommand_info.cpp b/subcommand_info.cpp new file mode 100644 index 0000000..0029fde --- /dev/null +++ b/subcommand_info.cpp @@ -0,0 +1,42 @@ +#include + +#include "utils.h" +#include "database.h" + +#include "subcommands.h" + +void subcommand_info(const Parser& parser) { + if (parser.arguments.size() < 2) { + fprintf(stderr, HELP_TEXT, parser.program_name); + exit(1); + } + + int rc = 0; + bool first_item = true; + Database db = Database::find(true); + std::vector paths = get_paths_relative_to_database(parser, db, &rc); + Sqlite3Statement stmt(db.db, "SELECT source, description, miscinfo FROM memes WHERE path = ?"); + + for (const std::filesystem::path& i : paths) { + bool item_found = false; + + stmt.reset(); + stmt.bind_text(1, i.native(), SQLITE_STATIC); + db.db.exec(stmt, [&]() { + if (!first_item) { + write(1, "================================================================================\n", 81); + } + first_item = false; + item_found = true; + + output_meme(i.c_str(), stmt.column_text(0), stmt.column_text(1), stmt.column_text(2)); + }); + + if (!item_found) { + fprintf(stderr, "failed to find %s\n", i.c_str()); + rc = 1; + } + } + + exit(first_item ? 1 : rc); +} diff --git a/subcommand_prune.cpp b/subcommand_prune.cpp new file mode 100644 index 0000000..8f60b8c --- /dev/null +++ b/subcommand_prune.cpp @@ -0,0 +1,38 @@ +#include "database.h" + +#include "subcommands.h" + +static inline void is_file(sqlite3_context* context, int argc, sqlite3_value** argv); + +void subcommand_prune(const Parser& parser) { + if (parser.arguments.size() != 1) { + fprintf(stderr, HELP_TEXT, parser.program_name); + exit(1); + } + + Database db = Database::find(false); + + // https://stackoverflow.com/a/4929486 + int error_code = sqlite3_create_function(db.db.get(), "is_file", 1, SQLITE_UTF8 | SQLITE_DETERMINISTIC | SQLITE_DIRECTONLY, &db.path, is_file, nullptr, nullptr); + if (error_code != SQLITE_OK) { + throw Sqlite3Exception(error_code, &db.db); + } + Sqlite3Statement delete_stmt(db.db, "DELETE FROM memes WHERE NOT is_file(path)"); + db.db.exec(delete_stmt); + sqlite3_create_function(db.db.get(), "is_file", 1, SQLITE_UTF8 | SQLITE_DETERMINISTIC | SQLITE_DIRECTONLY, &db.path, nullptr, nullptr, nullptr); + + Sqlite3Statement vacuum_stmt(db.db, "VACUUM"); + db.db.exec(vacuum_stmt); +} + +static inline void is_file(sqlite3_context* context, int argc, sqlite3_value** argv) { + if (argc != 1) { + sqlite3_result_error(context, "is_file() expects one argument", -1); + sqlite3_result_error_code(context, SQLITE_MISUSE); + return; + } + + const std::filesystem::path* db_path = static_cast(sqlite3_user_data(context)); + std::filesystem::path file_path = db_path->parent_path() / reinterpret_cast(sqlite3_value_text(argv[0])); + sqlite3_result_int(context, std::filesystem::is_regular_file(file_path)); +} diff --git a/subcommand_search.cpp b/subcommand_search.cpp new file mode 100644 index 0000000..088e717 --- /dev/null +++ b/subcommand_search.cpp @@ -0,0 +1,35 @@ +#include + +#include "utils.h" +#include "database.h" + +#include "subcommands.h" + +void subcommand_search(const Parser& parser) { + if (parser.arguments.size() != 2) { + fprintf(stderr, HELP_TEXT, parser.program_name); + exit(1); + } + + Database db = Database::find(true); + Sqlite3Statement stmt(db.db, "SELECT path, source, description, miscinfo FROM memes WHERE memes MATCH ?"); + bool first_item = true; + + stmt.bind_text(1, parser.arguments[1], SQLITE_STATIC); + try { + db.db.exec(stmt, [&]() { + if (!first_item) { + write(1, "================================================================================\n", 81); + } + first_item = false; + + output_meme(stmt.column_text(0), stmt.column_text(1), stmt.column_text(2), stmt.column_text(3)); + }); + } catch (const Sqlite3Exception& e) { + // it'll be printed to the error log, so do nothing + } + + if (first_item) { + exit(1); + } +} diff --git a/subcommand_set.cpp b/subcommand_set.cpp new file mode 100644 index 0000000..687eca3 --- /dev/null +++ b/subcommand_set.cpp @@ -0,0 +1,34 @@ +#include "utils.h" +#include "database.h" + +#include "subcommands.h" + +void subcommand_set(const Parser& parser) { + // Sanity check the arguments passed + if (parser.arguments.size() != 5) { + fprintf(stderr, HELP_TEXT, parser.program_name); + exit(1); + } + + // Open the database and find the meme's path, relative to the database + Database db = Database::find(false); + std::filesystem::path path = get_path_relative_to_database(parser.arguments[1], db); + + // Update an existing meme, if any + Sqlite3Statement update_stmt(db.db, "UPDATE memes SET source = ?, description = ?, miscinfo = ? WHERE path = ?"); + update_stmt.bind_text(1, parser.arguments[2], SQLITE_STATIC); + update_stmt.bind_text(2, parser.arguments[3], SQLITE_STATIC); + update_stmt.bind_text(3, parser.arguments[4], SQLITE_STATIC); + update_stmt.bind_text(4, path.native(), SQLITE_STATIC); + db.db.exec(update_stmt); + + // If there is not one, then create one + if (db.db.changes() <= 0) { + Sqlite3Statement insert_stmt(db.db, "INSERT INTO memes (path, source, description, miscinfo) VALUES (?, ?, ?, ?)"); + insert_stmt.bind_text(1, path.native(), SQLITE_STATIC); + insert_stmt.bind_text(2, parser.arguments[2], SQLITE_STATIC); + insert_stmt.bind_text(3, parser.arguments[3], SQLITE_STATIC); + insert_stmt.bind_text(4, parser.arguments[4], SQLITE_STATIC); + db.db.exec(insert_stmt); + } +} diff --git a/subcommands.h b/subcommands.h new file mode 100644 index 0000000..b7f1bd4 --- /dev/null +++ b/subcommands.h @@ -0,0 +1,43 @@ +#pragma once + +#include "blankie/cliparse.h" + +using Parser = blankie::cliparse::Parser; + +#define HELP_TEXT &R"EOF( +Usage: %1$s [-C=] create + or %1$s [-C=] search + or %1$s [-C=] prune + + or %1$s [-C=] {info|show} ... + or %1$s [-C=] delete ... + or %1$s [-C=] edit + or %1$s [-C=] get + or %1$s [-C=] set + +-C= changes the current working directory to before a +subcommand is executed. This causes the database to be looked up in +, all file paths are relative to and database creation +to be inside of + +create creates a database in the current directory (or ) + +prune removes all metadata entries that correspond to non-existent files, then +vaccums the database + +delete only deletes the metadata for the files specified, not the actual files +themselves + +get outputs the file's path, source, description, and miscellaneous information +delimited by a null-byte +)EOF"[1] + +void subcommand_create(const Parser& parser); +void subcommand_search(const Parser& parser); +void subcommand_prune(const Parser& parser); + +void subcommand_info(const Parser& parser); +void subcommand_delete(const Parser& parser); +void subcommand_edit(const Parser& parser); +void subcommand_get(const Parser& parser); +void subcommand_set(const Parser& parser); diff --git a/utils.cpp b/utils.cpp new file mode 100644 index 0000000..c7db337 --- /dev/null +++ b/utils.cpp @@ -0,0 +1,103 @@ +#include +#include + +#include "database.h" +#include "utils.h" + +static std::filesystem::path normalize(std::filesystem::path path); +static std::optional relative_path_from(const std::filesystem::path& from, const std::filesystem::path& path); + +void output_meme(const char* path, const char* source, const char* description, const char* miscinfo, int fd, bool edit) { + auto output = [=](const char* header, const char* contents, bool end = false) { + write(fd, "# ", 2); + write(fd, header, strlen(header)); + write(fd, ":\n", 2); + write(fd, contents, strlen(contents)); + write(fd, "\n", 1); + if (!end) { + write(fd, edit ? "\n\0\n" : "\n", edit ? 3 : 1); + } + }; + + output(edit ? "Path (immutable)" : "Path", path); + output("Source", source); + output("Description", description); + output("Miscellaneous information", miscinfo, true); +} + +std::vector get_paths_relative_to_database(const blankie::cliparse::Parser& parser, const Database& db, int* rc, bool needs_exist) { + std::vector paths; + + paths.reserve(parser.arguments.size() - 1); + for (size_t i = 1; i < parser.arguments.size(); i++) { + std::filesystem::path normalized_path = normalize(parser.arguments[i]); + std::optional relative_path = relative_path_from(db.path.parent_path(), normalized_path); + if (!relative_path) { + fprintf(stderr, "%s is not in %s\n", normalized_path.c_str(), db.path.parent_path().c_str()); + *rc = 1; + } else if (needs_exist && !std::filesystem::is_regular_file(normalized_path)) { + fprintf(stderr, "%s is not a file\n", normalized_path.c_str()); + *rc = 1; + } else { + paths.push_back(*relative_path); + } + } + + return paths; +} + +std::filesystem::path get_path_relative_to_database(const char* path, const Database& db) { + std::filesystem::path normalized_path = normalize(path); + std::optional relative_path = relative_path_from(db.path.parent_path(), normalized_path); + + if (!relative_path) { + fprintf(stderr, "%s is not in %s\n", normalized_path.c_str(), db.path.parent_path().c_str()); + exit(1); + } + if (!std::filesystem::is_regular_file(normalized_path)) { + fprintf(stderr, "%s is not a file\n", normalized_path.c_str()); + exit(1); + } + return *relative_path; +} + + + +std::filesystem::path normalize(std::filesystem::path path) { + if (path.is_relative()) { + path = std::filesystem::current_path() / path; + } + + std::filesystem::path normalized = path.root_path(); + for (auto it = path.begin(); it != path.end(); it++) { + if (*it == ".") { + // do nothing + } else if (*it == "..") { + normalized = normalized.parent_path(); + } else { + normalized /= *it; + } + } + + return normalized; +} + +std::optional relative_path_from(const std::filesystem::path& from, const std::filesystem::path& path) { + auto from_it = from.begin(); + auto path_it = path.begin(); + + for (; from_it != from.end() && path_it != path.end(); from_it++, path_it++) { + if (*from_it != *path_it) { + return std::nullopt; + } + } + if (from_it != from.end()) { + return std::nullopt; + } + + std::filesystem::path name; + for (; path_it != path.end(); path_it++) { + name /= *path_it; + } + return name; +} diff --git a/utils.h b/utils.h new file mode 100644 index 0000000..e9268c7 --- /dev/null +++ b/utils.h @@ -0,0 +1,11 @@ +#pragma once + +#include +#include + +struct Database; // forward declaration from database.h +#include "blankie/cliparse.h" + +void output_meme(const char* path, const char* source, const char* description, const char* miscinfo, int fd = 1, bool edit = false); +std::vector get_paths_relative_to_database(const blankie::cliparse::Parser& parser, const Database& db, int* rc, bool needs_exist = true); +std::filesystem::path get_path_relative_to_database(const char* path, const Database& db);