Initial commit

This commit is contained in:
blankie 2024-01-02 23:36:58 +11:00
commit f3bb6ac62b
Signed by: blankie
GPG Key ID: CC15FC822C7F61F5
21 changed files with 1124 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/build

34
CMakeLists.txt Normal file
View File

@ -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})

19
LICENSE Normal file
View File

@ -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.

123
blankie/cliparse.cpp Normal file
View File

@ -0,0 +1,123 @@
#include <algorithm>
#include <cstring>
#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<size_t>(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

142
blankie/cliparse.h Normal file
View File

@ -0,0 +1,142 @@
#pragma once
#include <string>
#include <vector>
#include <cassert>
#include <cstdint>
#include <exception>
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<Flag> flags;
size_t arguments_min;
size_t arguments_max;
std::vector<const char*> 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<Flag> 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

17
database.cpp Normal file
View File

@ -0,0 +1,17 @@
#include <cstdio>
#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);
}

17
database.h Normal file
View File

@ -0,0 +1,17 @@
#pragma once
#include <filesystem>
#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;
};

91
main.cpp Normal file
View File

@ -0,0 +1,91 @@
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <unistd.h>
#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<size_t>(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);
}
}

24
sqlite_wrapper.cpp Normal file
View File

@ -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);
}
}

119
sqlite_wrapper.h Normal file
View File

@ -0,0 +1,119 @@
#pragma once
#include <string>
#include <exception>
#include <sqlite3.h>
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<typename F>
constexpr void exec(Sqlite3Statement& stmt, F callback, size_t iterations = ~static_cast<size_t>(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<int>(text.size()), lifetime);
}
constexpr void bind_null(int column);
const char* column_text(int column) const noexcept {
return reinterpret_cast<const char*>(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<typename F>
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);
}
}

21
subcommand_create.cpp Normal file
View File

@ -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);
}

29
subcommand_delete.cpp Normal file
View File

@ -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<std::filesystem::path> 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);
}

150
subcommand_edit.cpp Normal file
View File

@ -0,0 +1,150 @@
#include <regex>
#include <system_error>
#include <unistd.h>
#include <sys/wait.h>
#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;
}

31
subcommand_get.cpp Normal file
View File

@ -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) : "");
}

42
subcommand_info.cpp Normal file
View File

@ -0,0 +1,42 @@
#include <unistd.h>
#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<std::filesystem::path> 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);
}

38
subcommand_prune.cpp Normal file
View File

@ -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<const std::filesystem::path*>(sqlite3_user_data(context));
std::filesystem::path file_path = db_path->parent_path() / reinterpret_cast<const char*>(sqlite3_value_text(argv[0]));
sqlite3_result_int(context, std::filesystem::is_regular_file(file_path));
}

35
subcommand_search.cpp Normal file
View File

@ -0,0 +1,35 @@
#include <unistd.h>
#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);
}
}

34
subcommand_set.cpp Normal file
View File

@ -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);
}
}

43
subcommands.h Normal file
View File

@ -0,0 +1,43 @@
#pragma once
#include "blankie/cliparse.h"
using Parser = blankie::cliparse::Parser;
#define HELP_TEXT &R"EOF(
Usage: %1$s [-C=<directory>] create
or %1$s [-C=<directory>] search <search query>
or %1$s [-C=<directory>] prune
or %1$s [-C=<directory>] {info|show} <file>...
or %1$s [-C=<directory>] delete <file>...
or %1$s [-C=<directory>] edit <file>
or %1$s [-C=<directory>] get <file>
or %1$s [-C=<directory>] set <file> <source> <description> <misc info>
-C=<directory> changes the current working directory to <directory> before a
subcommand is executed. This causes the database to be looked up in
<directory>, all file paths are relative to <directory> and database creation
to be inside of <directory>
create creates a database in the current directory (or <directory>)
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);

103
utils.cpp Normal file
View File

@ -0,0 +1,103 @@
#include <cstring>
#include <unistd.h>
#include "database.h"
#include "utils.h"
static std::filesystem::path normalize(std::filesystem::path path);
static std::optional<std::filesystem::path> 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<std::filesystem::path> get_paths_relative_to_database(const blankie::cliparse::Parser& parser, const Database& db, int* rc, bool needs_exist) {
std::vector<std::filesystem::path> 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<std::filesystem::path> 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<std::filesystem::path> 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<std::filesystem::path> 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;
}

11
utils.h Normal file
View File

@ -0,0 +1,11 @@
#pragma once
#include <optional>
#include <filesystem>
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<std::filesystem::path> 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);