mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-22 05:16:22 -07:00
- Add backend selection, setup gating, Anki integration, and external host support - Add launcher flags, documentation, packaging, and focused tests - Open on-demand overlay modals on the first attempt
1232 lines
42 KiB
C++
1232 lines
42 KiB
C++
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
//
|
|
// C ABI shim exposing the hoshidicts C++ engine to JavaScript. Everything that
|
|
// crosses into wasm is a NUL-terminated JSON string owned by a function-local
|
|
// static, valid until the next call to the same function.
|
|
|
|
#include <algorithm>
|
|
#include <array>
|
|
#include <bit>
|
|
#include <cerrno>
|
|
#include <cstddef>
|
|
#include <cstdint>
|
|
#include <cstring>
|
|
#include <exception>
|
|
#include <filesystem>
|
|
#include <fstream>
|
|
#include <optional>
|
|
#include <stdexcept>
|
|
#include <string>
|
|
#include <string_view>
|
|
#include <system_error>
|
|
#include <utility>
|
|
#include <vector>
|
|
|
|
#ifdef HACHIDORI_OPFS
|
|
#include <fcntl.h>
|
|
#include <unistd.h>
|
|
#endif
|
|
|
|
#include <emscripten/emscripten.h>
|
|
#ifdef HACHIDORI_OPFS
|
|
#include <emscripten/wasmfs.h>
|
|
#endif
|
|
#include <glaze/glaze.hpp>
|
|
#include <hoshidicts.h>
|
|
|
|
// Not part of the engine's public headers; see the include path added for them
|
|
// in CMakeLists.txt. hdw_import needs a Yomitan archive's declared title before
|
|
// the importer turns it into a directory path, and the MDict header sniff to
|
|
// know when there is no such archive.
|
|
#include "mdict/mdict_reader.hpp"
|
|
#include "zip/zip.hpp"
|
|
|
|
// Not an anonymous namespace: glaze's field-name reflection takes the address of
|
|
// an `extern const T` sentinel, which requires T to have external linkage.
|
|
namespace hdw {
|
|
|
|
constexpr size_t MAX_LOOKUP_TEXT_BYTES = 4 * 1024;
|
|
constexpr size_t MAX_GLOSSARY_BYTES = 8 * 1024 * 1024;
|
|
constexpr size_t MAX_LOOKUP_RESPONSE_BYTES = 32 * 1024 * 1024;
|
|
constexpr size_t MAX_TRACE_STEPS = 32;
|
|
constexpr size_t MAX_MEDIA_DICTIONARY_BYTES = 1024;
|
|
constexpr size_t MAX_MEDIA_PATH_BYTES = 4 * 1024;
|
|
constexpr size_t MAX_MEDIA_BYTES = 4 * 1024 * 1024;
|
|
|
|
// Wire structs deliberately use the camelCase names from the extension's JSON
|
|
// contract so glaze's aggregate reflection emits them verbatim: no rename layer.
|
|
struct WireTrace {
|
|
std::string name;
|
|
std::string description;
|
|
};
|
|
|
|
struct WireGlossary {
|
|
std::string dictionary;
|
|
std::string glossary;
|
|
std::string definitionTags;
|
|
std::string termTags;
|
|
};
|
|
|
|
struct WireFrequency {
|
|
int value = 0;
|
|
std::string displayValue;
|
|
std::string reading;
|
|
};
|
|
|
|
struct WireFrequencyQuery {
|
|
std::string term;
|
|
std::optional<std::string> reading;
|
|
};
|
|
|
|
struct WireTermFrequency {
|
|
std::string term;
|
|
std::optional<std::string> reading;
|
|
bool hasReading = false;
|
|
std::string dictionary;
|
|
int frequency = 0;
|
|
std::optional<std::string> displayValue;
|
|
};
|
|
|
|
struct WireFrequencyEntry {
|
|
std::string dictionary;
|
|
std::vector<WireFrequency> frequencies;
|
|
};
|
|
|
|
struct WirePitch {
|
|
int position = 0;
|
|
std::string pattern;
|
|
std::vector<int> nasal;
|
|
std::vector<int> devoice;
|
|
};
|
|
|
|
struct WirePitchEntry {
|
|
std::string dictionary;
|
|
std::vector<WirePitch> pitches;
|
|
std::vector<std::string> transcriptions;
|
|
};
|
|
|
|
struct WireTerm {
|
|
std::string expression;
|
|
std::string reading;
|
|
std::string rules;
|
|
// hoshidicts stores the score as a double since .hoshidicts_5 (a Yomitan
|
|
// score is any JSON number); older layouts still hold an int32.
|
|
double score = 0;
|
|
std::vector<WireGlossary> glossaries;
|
|
std::vector<WireFrequencyEntry> frequencies;
|
|
std::vector<WirePitchEntry> pitches;
|
|
};
|
|
|
|
struct WireLookupResult {
|
|
std::string matched;
|
|
std::string deinflected;
|
|
std::vector<WireTrace> trace;
|
|
WireTerm term;
|
|
int preprocessorSteps = 0;
|
|
};
|
|
|
|
struct WireLookupResponse {
|
|
std::vector<WireLookupResult> results;
|
|
size_t dictionaryCount = 0;
|
|
};
|
|
|
|
struct WireKanjiStat {
|
|
std::string name;
|
|
std::string value;
|
|
};
|
|
|
|
struct WireKanjiEntry {
|
|
std::string dictionary;
|
|
std::string onyomi;
|
|
std::string kunyomi;
|
|
std::string tags;
|
|
std::vector<std::string> definitions;
|
|
std::vector<WireKanjiStat> stats;
|
|
};
|
|
|
|
struct WireKanji {
|
|
std::string character;
|
|
std::vector<WireKanjiEntry> entries;
|
|
};
|
|
|
|
struct WireStyle {
|
|
std::string dictionary;
|
|
std::string styles;
|
|
};
|
|
|
|
struct WireImportReport {
|
|
bool success = false;
|
|
std::string title;
|
|
uint64_t termCount = 0;
|
|
uint64_t metaCount = 0;
|
|
uint64_t frequencyCount = 0;
|
|
uint64_t pitchCount = 0;
|
|
uint64_t kanjiCount = 0;
|
|
uint64_t mediaCount = 0;
|
|
std::string error;
|
|
};
|
|
|
|
std::string g_last_error;
|
|
int g_storage_mode = -1;
|
|
|
|
void clear_error() { g_last_error.clear(); }
|
|
|
|
void set_error(std::string message) { g_last_error = std::move(message); }
|
|
|
|
// Anything thrown past here aborts the whole module and takes the extension's
|
|
// offscreen document with it, so every ABI entry point funnels through this.
|
|
std::string describe_current_exception() {
|
|
try {
|
|
throw;
|
|
} catch (const std::exception& e) {
|
|
return e.what();
|
|
} catch (...) {
|
|
return "unknown error";
|
|
}
|
|
}
|
|
|
|
// The engine keeps raw references between its parts (Lookup borrows both the
|
|
// query and the deinflector), so the whole bundle lives or dies together and
|
|
// hdw_reset rebuilds it wholesale.
|
|
struct Engine {
|
|
DictionaryQuery query;
|
|
Deinflector deinflector;
|
|
Lookup lookup{query, deinflector};
|
|
size_t dictionary_count = 0;
|
|
std::vector<std::string> term_paths;
|
|
};
|
|
|
|
std::optional<Engine>& engine_slot() {
|
|
static std::optional<Engine> slot;
|
|
return slot;
|
|
}
|
|
|
|
Engine& engine() {
|
|
auto& slot = engine_slot();
|
|
if (!slot.has_value()) {
|
|
slot.emplace();
|
|
}
|
|
return *slot;
|
|
}
|
|
|
|
struct JsonWriteOptions : glz::opts {
|
|
bool escape_control_characters = true;
|
|
};
|
|
|
|
template <typename T, auto Options = JsonWriteOptions{}>
|
|
std::string to_json(const T& value) {
|
|
std::string out;
|
|
if (auto ec = glz::write<Options>(value, out)) {
|
|
throw std::runtime_error("json serialization failed: " + glz::format_error(ec, out));
|
|
}
|
|
return out;
|
|
}
|
|
|
|
void require_lookup_text_size(std::string_view value, std::string_view label) {
|
|
if (value.size() > MAX_LOOKUP_TEXT_BYTES) {
|
|
throw std::length_error(std::string{label} + " exceeds the 4096-byte lookup limit");
|
|
}
|
|
}
|
|
|
|
struct LookupCopyBudget {
|
|
size_t bytes = 0;
|
|
bool needs_control_escaping = false;
|
|
};
|
|
|
|
bool contains_control_byte(std::string_view value) {
|
|
// Detect any byte below 0x20 in eight-byte groups. memcpy permits unaligned
|
|
// input; the high-bit mask excludes multibyte UTF-8. A borrow can mark a
|
|
// neighbouring byte only when a control byte already exists in this word.
|
|
while (value.size() >= sizeof(uint64_t)) {
|
|
uint64_t word;
|
|
std::memcpy(&word, value.data(), sizeof(word));
|
|
if ((word - 0x2020202020202020ULL) & ~word & 0x8080808080808080ULL) return true;
|
|
value.remove_prefix(sizeof(word));
|
|
}
|
|
return std::ranges::any_of(value, [](unsigned char byte) { return byte < 0x20; });
|
|
}
|
|
|
|
std::string copy_lookup_string(std::string_view value, LookupCopyBudget& budget,
|
|
std::string_view label,
|
|
size_t maximum = MAX_LOOKUP_RESPONSE_BYTES) {
|
|
if (value.size() > maximum) {
|
|
throw std::length_error(std::string{label} + " exceeds the permitted lookup size");
|
|
}
|
|
// Claim before allocating the wire copy. Serialized JSON has a separate
|
|
// bound because quotes/control characters expand beyond these native bytes.
|
|
if (value.size() > MAX_LOOKUP_RESPONSE_BYTES - budget.bytes) {
|
|
throw std::length_error("native " + std::string{label} + " exceeds the aggregate response limit");
|
|
}
|
|
budget.bytes += value.size();
|
|
// Glaze's full-control mode reserves six bytes per source byte, even for
|
|
// ordinary text. Use its smaller fast path only after checking every copied
|
|
// wire string; the default writer cannot preserve unescaped control bytes.
|
|
if (!budget.needs_control_escaping) {
|
|
budget.needs_control_escaping = contains_control_byte(value);
|
|
}
|
|
return std::string{value};
|
|
}
|
|
|
|
template <typename T>
|
|
std::string lookup_json(const T& value, const LookupCopyBudget& budget) {
|
|
std::string out = budget.needs_control_escaping ? to_json(value) : to_json<T, glz::opts{}>(value);
|
|
if (out.size() > MAX_LOOKUP_RESPONSE_BYTES) {
|
|
throw std::length_error("serialized lookup response exceeds the 33554432-byte limit");
|
|
}
|
|
return out;
|
|
}
|
|
|
|
WireTerm convert_term(const TermResult& term, LookupCopyBudget& budget) {
|
|
WireTerm out;
|
|
out.expression = copy_lookup_string(term.expression, budget, "term expression");
|
|
out.reading = copy_lookup_string(term.reading, budget, "term reading");
|
|
out.rules = copy_lookup_string(term.rules, budget, "term rules");
|
|
out.score = term.score;
|
|
|
|
out.glossaries.reserve(term.glossaries.size());
|
|
for (const auto& g : term.glossaries) {
|
|
// glossary stays the raw Yomitan structured-content JSON string; the
|
|
// renderer is the only thing that understands it.
|
|
out.glossaries.emplace_back(
|
|
copy_lookup_string(g.dict_name, budget, "glossary dictionary"),
|
|
copy_lookup_string(g.glossary, budget, "glossary", MAX_GLOSSARY_BYTES),
|
|
copy_lookup_string(g.definition_tags, budget, "definition tags"),
|
|
copy_lookup_string(g.term_tags, budget, "term tags"));
|
|
}
|
|
|
|
out.frequencies.reserve(term.frequencies.size());
|
|
for (const auto& f : term.frequencies) {
|
|
WireFrequencyEntry entry;
|
|
entry.dictionary = copy_lookup_string(f.dict_name, budget, "frequency dictionary");
|
|
entry.frequencies.reserve(f.frequencies.size());
|
|
for (const auto& v : f.frequencies) {
|
|
entry.frequencies.emplace_back(v.value, copy_lookup_string(v.display_value, budget, "frequency display value"),
|
|
copy_lookup_string(v.reading, budget, "frequency reading"));
|
|
}
|
|
out.frequencies.push_back(std::move(entry));
|
|
}
|
|
|
|
out.pitches.reserve(term.pitches.size());
|
|
for (const auto& p : term.pitches) {
|
|
WirePitchEntry entry;
|
|
entry.dictionary = copy_lookup_string(p.dict_name, budget, "pitch dictionary");
|
|
entry.pitches.reserve(p.pitches.size());
|
|
for (const auto& pitch : p.pitches) {
|
|
entry.pitches.emplace_back(pitch.position, copy_lookup_string(pitch.pattern, budget, "pitch pattern"),
|
|
pitch.nasal, pitch.devoice);
|
|
}
|
|
entry.transcriptions.reserve(p.transcriptions.size());
|
|
for (const auto& transcription : p.transcriptions) {
|
|
entry.transcriptions.push_back(copy_lookup_string(transcription, budget, "pitch transcription"));
|
|
}
|
|
out.pitches.push_back(std::move(entry));
|
|
}
|
|
|
|
return out;
|
|
}
|
|
|
|
WireLookupResult convert_result(const LookupResult& result, LookupCopyBudget& budget) {
|
|
WireLookupResult out;
|
|
out.matched = copy_lookup_string(result.matched, budget, "matched text");
|
|
out.deinflected = copy_lookup_string(result.deinflected, budget, "deinflected text");
|
|
if (result.trace.size() > MAX_TRACE_STEPS) {
|
|
throw std::length_error("lookup trace exceeds the 32-step limit");
|
|
}
|
|
out.trace.reserve(result.trace.size());
|
|
for (const auto& t : result.trace) {
|
|
out.trace.emplace_back(copy_lookup_string(t.name, budget, "trace name"),
|
|
copy_lookup_string(t.description, budget, "trace description"));
|
|
}
|
|
out.term = convert_term(result.term, budget);
|
|
out.preprocessorSteps = result.preprocessor_steps;
|
|
return out;
|
|
}
|
|
|
|
std::vector<WireLookupResult> convert_results(const std::vector<LookupResult>& results, LookupCopyBudget& budget) {
|
|
std::vector<WireLookupResult> out;
|
|
out.reserve(results.size());
|
|
for (const auto& result : results) {
|
|
out.push_back(convert_result(result, budget));
|
|
}
|
|
return out;
|
|
}
|
|
|
|
struct WireOptions {
|
|
std::string frequencyDictionary;
|
|
std::string frequencyOrder;
|
|
std::string primaryReading;
|
|
};
|
|
|
|
LookupFrequencyOrder parse_frequency_order(std::string_view name) {
|
|
if (name == "ascending") {
|
|
return LookupFrequencyOrder::Ascending;
|
|
}
|
|
if (name == "descending") {
|
|
return LookupFrequencyOrder::Descending;
|
|
}
|
|
if (name == "disabled") {
|
|
return LookupFrequencyOrder::Disabled;
|
|
}
|
|
return LookupFrequencyOrder::Auto;
|
|
}
|
|
|
|
// Upstream models "unset" as a nullopt, the wire format models it as "".
|
|
LookupOptions parse_options(const char* options_json) {
|
|
LookupOptions options;
|
|
if (options_json == nullptr || *options_json == '\0') {
|
|
return options;
|
|
}
|
|
|
|
WireOptions wire;
|
|
if (auto ec = glz::read<glz::opts{.error_on_unknown_keys = false}>(wire, std::string_view{options_json})) {
|
|
throw std::runtime_error("invalid options json: " + glz::format_error(ec, std::string_view{options_json}));
|
|
}
|
|
|
|
require_lookup_text_size(wire.frequencyDictionary, "frequencyDictionary");
|
|
require_lookup_text_size(wire.primaryReading, "primaryReading");
|
|
if (!wire.frequencyDictionary.empty()) {
|
|
options.frequency_dictionary = wire.frequencyDictionary;
|
|
}
|
|
if (!wire.primaryReading.empty()) {
|
|
options.primary_reading = wire.primaryReading;
|
|
}
|
|
options.frequency_order = parse_frequency_order(wire.frequencyOrder);
|
|
return options;
|
|
}
|
|
|
|
bool non_empty_file(const std::filesystem::path& path) {
|
|
std::error_code error;
|
|
if (!std::filesystem::is_regular_file(path, error)) {
|
|
return false;
|
|
}
|
|
const auto size = std::filesystem::file_size(path, error);
|
|
return !error && size > 0;
|
|
}
|
|
|
|
// Highest marker first, as query.cpp picks it, because the marker decides how the
|
|
// glossaries are encoded. 0 means the directory is not a dictionary at all.
|
|
int dictionary_version(const std::filesystem::path& dir) {
|
|
for (const int version : {6, 5, 4, 3, 2, 1}) {
|
|
if (std::filesystem::is_regular_file(dir / (".hoshidicts_" + std::to_string(version)))) {
|
|
return version;
|
|
}
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
// The marker list must track the versions query.cpp still reads. dict.zstd
|
|
// belongs to exactly two of them: the importer writes .hoshidicts_4 (int32
|
|
// score) or .hoshidicts_6 (double score) only when it trained a zstd dictionary
|
|
// for the term banks, and then compresses every glossary against that
|
|
// dictionary, so a _4 or _6 directory missing it loads with an empty DDict and
|
|
// every glossary decompresses to "" -- add_dict cannot see that and reports
|
|
// success, which is worse than refusing the directory. _5, _3 and older never
|
|
// have one, which is also what every dictionary imported by an older engine
|
|
// looks like. A zero-length dict.zstd is exactly as unusable as a missing one,
|
|
// since ZSTD_createDDict() accepts an empty buffer without complaint.
|
|
struct WireIndexTitle {
|
|
std::string title;
|
|
};
|
|
|
|
bool valid_hash_table(const std::filesystem::path &path) {
|
|
std::error_code error;
|
|
const uintmax_t size = std::filesystem::file_size(path, error);
|
|
if (error || size < sizeof(uint32_t)) {
|
|
return false;
|
|
}
|
|
uint32_t capacity = 0;
|
|
std::ifstream input(path, std::ios::binary);
|
|
input.read(reinterpret_cast<char *>(&capacity), sizeof(capacity));
|
|
return input.good() && capacity >= 16 &&
|
|
size == sizeof(uint32_t) + static_cast<uintmax_t>(capacity) * 16;
|
|
}
|
|
|
|
bool valid_bloom_filter(const std::filesystem::path &path) {
|
|
std::error_code error;
|
|
const uintmax_t size = std::filesystem::file_size(path, error);
|
|
if (error || size < 2 * sizeof(uint64_t)) {
|
|
return false;
|
|
}
|
|
uint64_t num_bits = 0;
|
|
uint64_t num_hashes = 0;
|
|
std::ifstream input(path, std::ios::binary);
|
|
input.read(reinterpret_cast<char *>(&num_bits), sizeof(num_bits));
|
|
input.read(reinterpret_cast<char *>(&num_hashes), sizeof(num_hashes));
|
|
return input.good() && num_bits >= 64 && std::has_single_bit(num_bits) &&
|
|
num_hashes > 0 && size == 2 * sizeof(uint64_t) + num_bits / 8;
|
|
}
|
|
|
|
bool valid_dictionary_index(const std::filesystem::path &path) {
|
|
std::ifstream input(path, std::ios::binary);
|
|
if (!input) {
|
|
return false;
|
|
}
|
|
const std::string contents(std::istreambuf_iterator<char>(input), {});
|
|
WireIndexTitle index;
|
|
return !glz::read<glz::opts{.error_on_unknown_keys = false}>(
|
|
index, std::string_view{contents}) &&
|
|
!index.title.empty();
|
|
}
|
|
|
|
bool dictionary_files_present(const std::filesystem::path &dir) {
|
|
const int version = dictionary_version(dir);
|
|
if (version == 0) {
|
|
return false;
|
|
}
|
|
if ((version == 4 || version == 6) && !non_empty_file(dir / "dict.zstd")) {
|
|
return false;
|
|
}
|
|
return valid_dictionary_index(dir / "index.json") &&
|
|
valid_hash_table(dir / "hash.table") &&
|
|
valid_bloom_filter(dir / "bloom.filter") &&
|
|
non_empty_file(dir / "blobs.bin");
|
|
}
|
|
|
|
uint64_t meta_count(const SummaryMetaCount &counts, const std::string &mode) {
|
|
auto it = counts.find(mode);
|
|
return it == counts.end() ? 0 : it->second;
|
|
}
|
|
|
|
// dictionary_importer::import derives its output directory from the title
|
|
// inside the archive and remove_all()s that directory if anything later throws,
|
|
// so it must never be pointed straight at the directory holding the installed
|
|
// dictionaries: a title of ".." resolves to the parent of the output directory
|
|
// and takes everything under it with it, a title containing a separator lands
|
|
// somewhere nothing will ever load it from, and a re-import that fails partway
|
|
// truncates and then deletes the copy it was meant to replace. Everything below
|
|
// gives it a scratch directory instead and moves the finished dictionary into
|
|
// place afterwards.
|
|
constexpr std::string_view STAGING_DIR = ".hdw-import";
|
|
constexpr std::string_view REMOVAL_DIR = ".hdw-remove";
|
|
constexpr std::string_view STAGING_WORK = "new";
|
|
constexpr std::string_view STAGING_REPLACED = "replaced";
|
|
constexpr std::string_view BACKUP_READY = ".backup-ready";
|
|
constexpr std::string_view NEW_COMMITTED = ".new-committed";
|
|
|
|
struct RemoveOnExit {
|
|
std::filesystem::path path;
|
|
bool active = true;
|
|
|
|
void release() { active = false; }
|
|
|
|
~RemoveOnExit() {
|
|
if (!active) {
|
|
return;
|
|
}
|
|
std::error_code error;
|
|
std::filesystem::remove_all(path, error);
|
|
}
|
|
};
|
|
|
|
// Internal staging directories cannot also be dictionary destinations.
|
|
bool usable_as_directory_name(std::string_view title) {
|
|
return !title.empty() && title != "." && title != ".." &&
|
|
title != STAGING_DIR && title != REMOVAL_DIR &&
|
|
!title.contains('/') && !title.contains('\\') &&
|
|
!title.contains('\0');
|
|
}
|
|
|
|
std::string unusable_title_error(std::string_view title) {
|
|
if (title.empty()) {
|
|
return "the archive declares no dictionary title";
|
|
}
|
|
return "the dictionary title \"" + std::string{title} +
|
|
"\" cannot be used as a folder name";
|
|
}
|
|
|
|
// The importer decides the format from the file's first bytes (an MDict header
|
|
// or a ZIP), so the same sniff decides here whether there is an index.json to
|
|
// read at all.
|
|
bool looks_like_mdict(const std::string &path) {
|
|
std::array<uint8_t, 64> head{};
|
|
std::ifstream in(path, std::ios::binary);
|
|
in.read(reinterpret_cast<char *>(head.data()), static_cast<std::streamsize>(head.size()));
|
|
const auto read = static_cast<size_t>(std::max<std::streamsize>(0, in.gcount()));
|
|
return mdict::looks_like_mdict(head.data(), read);
|
|
}
|
|
|
|
bool peek_title(const std::string &zip_path, std::string &title,
|
|
std::string &error) {
|
|
Zip zip;
|
|
if (!zip.open(std::filesystem::path{zip_path})) {
|
|
error = zip.error.empty() ? "failed to open zip" : zip.error;
|
|
return false;
|
|
}
|
|
const int index_entry = zip.find("index.json");
|
|
if (index_entry < 0) {
|
|
error = "could not find index.json";
|
|
return false;
|
|
}
|
|
const std::string index_json = zip.read(index_entry);
|
|
WireIndexTitle index;
|
|
if (glz::read<glz::opts{.error_on_unknown_keys = false}>(
|
|
index, std::string_view{index_json})) {
|
|
error = "could not parse index.json before import";
|
|
return false;
|
|
}
|
|
title = std::move(index.title);
|
|
return true;
|
|
}
|
|
|
|
void move_dictionary_files(const std::filesystem::path &source,
|
|
const std::filesystem::path &destination) {
|
|
std::filesystem::create_directories(destination);
|
|
std::vector<std::filesystem::path> files;
|
|
for (const auto &entry : std::filesystem::directory_iterator(source)) {
|
|
if (!entry.is_regular_file()) {
|
|
throw std::runtime_error(
|
|
"an imported dictionary contains an unsupported nested path");
|
|
}
|
|
files.push_back(entry.path());
|
|
}
|
|
std::ranges::sort(files, [](const auto &left, const auto &right) {
|
|
const bool left_marker =
|
|
left.filename().string().starts_with(".hoshidicts_");
|
|
const bool right_marker =
|
|
right.filename().string().starts_with(".hoshidicts_");
|
|
if (left_marker != right_marker) {
|
|
return !left_marker;
|
|
}
|
|
return left.filename() < right.filename();
|
|
});
|
|
for (const auto &file : files) {
|
|
std::filesystem::rename(file, destination / file.filename());
|
|
}
|
|
}
|
|
|
|
void flush_file(const std::filesystem::path &path) {
|
|
#ifdef HACHIDORI_OPFS
|
|
const int fd = open(path.c_str(), O_RDWR);
|
|
if (fd < 0) {
|
|
throw std::system_error(errno, std::generic_category(),
|
|
"could not open " + path.string());
|
|
}
|
|
if (fsync(fd) != 0) {
|
|
const int error = errno;
|
|
close(fd);
|
|
throw std::system_error(error, std::generic_category(),
|
|
"could not flush " + path.string());
|
|
}
|
|
close(fd);
|
|
#else
|
|
static_cast<void>(path);
|
|
#endif
|
|
}
|
|
|
|
void flush_tree(const std::filesystem::path &root) {
|
|
if (!std::filesystem::exists(root)) {
|
|
return;
|
|
}
|
|
for (const auto &entry :
|
|
std::filesystem::recursive_directory_iterator(root)) {
|
|
if (entry.is_regular_file()) {
|
|
flush_file(entry.path());
|
|
}
|
|
}
|
|
}
|
|
|
|
void write_marker(const std::filesystem::path &path,
|
|
std::string_view error_message) {
|
|
std::ofstream marker(path, std::ios::binary | std::ios::trunc);
|
|
if (!marker) {
|
|
throw std::runtime_error(std::string{error_message});
|
|
}
|
|
marker.close();
|
|
if (!marker) {
|
|
throw std::runtime_error(std::string{error_message});
|
|
}
|
|
flush_file(path);
|
|
}
|
|
|
|
// OPFS does not support renaming directories. Move their flat file contents,
|
|
// writing the version marker last so an interrupted destination is never
|
|
// loaded. A previous import is kept under `aside` until the replacement is
|
|
// complete.
|
|
void install_dictionary(const std::filesystem::path &staged,
|
|
const std::filesystem::path &destination,
|
|
const std::filesystem::path &aside) {
|
|
const bool replacing = std::filesystem::exists(destination);
|
|
if (replacing) {
|
|
std::filesystem::remove(destination / NEW_COMMITTED);
|
|
std::filesystem::create_directories(aside.parent_path());
|
|
try {
|
|
move_dictionary_files(destination, aside);
|
|
write_marker(aside / BACKUP_READY,
|
|
"could not commit the previous dictionary backup");
|
|
} catch (...) {
|
|
const auto backup_error = std::current_exception();
|
|
try {
|
|
std::filesystem::remove(aside / BACKUP_READY);
|
|
move_dictionary_files(aside, destination);
|
|
} catch (const std::exception &rollback_error) {
|
|
throw std::runtime_error(std::string{"the previous dictionary could "
|
|
"not be backed up or restored: "} +
|
|
rollback_error.what());
|
|
}
|
|
std::rethrow_exception(backup_error);
|
|
}
|
|
}
|
|
try {
|
|
move_dictionary_files(staged, destination);
|
|
flush_tree(destination);
|
|
if (replacing) {
|
|
write_marker(destination / NEW_COMMITTED,
|
|
"could not commit the replacement dictionary");
|
|
}
|
|
} catch (...) {
|
|
const auto install_error = std::current_exception();
|
|
try {
|
|
std::filesystem::remove_all(destination);
|
|
if (replacing) {
|
|
std::filesystem::remove(aside / BACKUP_READY);
|
|
move_dictionary_files(aside, destination);
|
|
}
|
|
} catch (const std::exception &rollback_error) {
|
|
throw std::runtime_error(
|
|
std::string{"installation failed and the previous dictionary could "
|
|
"not be restored: "} +
|
|
rollback_error.what());
|
|
}
|
|
std::rethrow_exception(install_error);
|
|
}
|
|
if (replacing) {
|
|
std::error_code cleanup_error;
|
|
std::filesystem::remove_all(aside, cleanup_error);
|
|
if (!cleanup_error) {
|
|
std::filesystem::remove(destination / NEW_COMMITTED, cleanup_error);
|
|
}
|
|
}
|
|
}
|
|
|
|
bool directory_has_payload(const std::filesystem::path &directory) {
|
|
for (const auto &entry : std::filesystem::directory_iterator(directory)) {
|
|
const std::string name = entry.path().filename().string();
|
|
if (name != BACKUP_READY && name != NEW_COMMITTED) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
void recover_interrupted_install(const std::filesystem::path &root) {
|
|
const std::filesystem::path staging = root / STAGING_DIR;
|
|
const std::filesystem::path work = staging / STAGING_WORK;
|
|
const std::filesystem::path replaced = staging / STAGING_REPLACED;
|
|
if (std::filesystem::is_directory(replaced)) {
|
|
for (const auto &entry : std::filesystem::directory_iterator(replaced)) {
|
|
if (!entry.is_directory()) {
|
|
continue;
|
|
}
|
|
const std::filesystem::path destination = root / entry.path().filename();
|
|
const std::filesystem::path ready = entry.path() / BACKUP_READY;
|
|
const std::filesystem::path committed = destination / NEW_COMMITTED;
|
|
if (std::filesystem::exists(committed)) {
|
|
if (!dictionary_files_present(destination)) {
|
|
if (!dictionary_files_present(entry.path())) {
|
|
throw std::runtime_error("neither side of a committed dictionary "
|
|
"replacement is loadable");
|
|
}
|
|
std::filesystem::remove_all(destination);
|
|
std::filesystem::remove(ready);
|
|
move_dictionary_files(entry.path(), destination);
|
|
} else {
|
|
std::filesystem::remove_all(entry.path());
|
|
std::filesystem::remove(committed);
|
|
}
|
|
continue;
|
|
}
|
|
if (std::filesystem::exists(ready)) {
|
|
if (!dictionary_files_present(entry.path())) {
|
|
throw std::runtime_error(
|
|
"the committed previous dictionary backup is not loadable");
|
|
}
|
|
std::filesystem::remove_all(destination);
|
|
std::filesystem::remove(ready);
|
|
move_dictionary_files(entry.path(), destination);
|
|
continue;
|
|
}
|
|
if (!directory_has_payload(entry.path())) {
|
|
std::filesystem::remove_all(entry.path());
|
|
continue;
|
|
}
|
|
move_dictionary_files(entry.path(), destination);
|
|
}
|
|
}
|
|
if (std::filesystem::is_directory(work)) {
|
|
for (const auto &entry : std::filesystem::directory_iterator(work)) {
|
|
if (!entry.is_directory()) {
|
|
continue;
|
|
}
|
|
const std::filesystem::path destination = root / entry.path().filename();
|
|
if (!dictionary_files_present(destination)) {
|
|
std::filesystem::remove_all(destination);
|
|
}
|
|
}
|
|
}
|
|
if (std::filesystem::is_directory(root)) {
|
|
for (const auto &entry : std::filesystem::directory_iterator(root)) {
|
|
if (entry.is_directory() && entry.path().filename() != STAGING_DIR &&
|
|
dictionary_files_present(entry.path())) {
|
|
std::filesystem::remove(entry.path() / NEW_COMMITTED);
|
|
}
|
|
}
|
|
}
|
|
std::filesystem::remove_all(staging);
|
|
}
|
|
|
|
WireImportReport report_for(const ImportResult& result) {
|
|
WireImportReport report;
|
|
const auto& counts = result.summary.counts;
|
|
report.success = result.success;
|
|
report.title = result.title;
|
|
report.termCount = counts.terms.total;
|
|
report.metaCount = meta_count(counts.termMeta, "total");
|
|
report.frequencyCount = meta_count(counts.termMeta, "freq");
|
|
report.pitchCount = meta_count(counts.termMeta, "pitch") + meta_count(counts.termMeta, "ipa");
|
|
report.kanjiCount = counts.kanji.total;
|
|
report.mediaCount = counts.media.total;
|
|
report.error = result.error;
|
|
return report;
|
|
}
|
|
|
|
WireImportReport staged_import(const std::string& zip_path, const std::string& out_dir, bool low_ram) {
|
|
WireImportReport report;
|
|
const std::filesystem::path root{out_dir};
|
|
if (root.empty()) {
|
|
report.error = "no output directory";
|
|
return report;
|
|
}
|
|
|
|
const std::filesystem::path staging = root / STAGING_DIR;
|
|
const std::filesystem::path work = staging / STAGING_WORK;
|
|
// A Yomitan archive's title is whatever index.json says, so it is checked
|
|
// before the importer can turn it into a path. An MDict title comes from
|
|
// MdictSource::sanitize_title, which yields one plain path component (no
|
|
// separators, NUL, "." or ".."), so the importer cannot leave `work`; the
|
|
// post-import usable_as_directory_name check below still applies to it.
|
|
if (!looks_like_mdict(zip_path)) {
|
|
std::string title;
|
|
if (!peek_title(zip_path, title, report.error)) {
|
|
return report;
|
|
}
|
|
const std::filesystem::path staged = (work / title).lexically_normal();
|
|
if (!usable_as_directory_name(title) || staged.parent_path() != work.lexically_normal()) {
|
|
report.title = title;
|
|
report.error = unusable_title_error(title);
|
|
return report;
|
|
}
|
|
}
|
|
try {
|
|
recover_interrupted_install(root);
|
|
std::filesystem::create_directories(work);
|
|
} catch (const std::exception& e) {
|
|
report.error = std::string{"could not recover an interrupted dictionary installation: "} + e.what();
|
|
return report;
|
|
}
|
|
RemoveOnExit cleanup{staging};
|
|
|
|
report = report_for(dictionary_importer::import(zip_path, work.string(), low_ram));
|
|
if (!report.success) {
|
|
return report;
|
|
}
|
|
if (!usable_as_directory_name(report.title)) {
|
|
report.success = false;
|
|
report.error = unusable_title_error(report.title);
|
|
return report;
|
|
}
|
|
const std::filesystem::path imported = work / report.title;
|
|
if (!dictionary_files_present(imported)) {
|
|
report.success = false;
|
|
report.error = "the import produced no loadable dictionary";
|
|
return report;
|
|
}
|
|
try {
|
|
flush_tree(imported);
|
|
install_dictionary(imported, root / report.title, staging / STAGING_REPLACED / report.title);
|
|
} catch (const std::exception& e) {
|
|
cleanup.release();
|
|
report.success = false;
|
|
report.error = std::string{"could not install the imported dictionary: "} + e.what();
|
|
}
|
|
return report;
|
|
}
|
|
|
|
std::vector<uint8_t> g_media;
|
|
|
|
} // namespace hdw
|
|
|
|
using namespace hdw;
|
|
|
|
extern "C" {
|
|
|
|
EMSCRIPTEN_KEEPALIVE int hdw_init_storage(int persistent) {
|
|
clear_error();
|
|
const int requested_mode = persistent == 0 ? 0 : 1;
|
|
if (g_storage_mode >= 0) {
|
|
if (g_storage_mode == requested_mode) {
|
|
return 1;
|
|
}
|
|
set_error("storage is already initialized with a different backend");
|
|
return 0;
|
|
}
|
|
|
|
try {
|
|
if (requested_mode == 0) {
|
|
std::filesystem::create_directory("/dicts");
|
|
} else {
|
|
#ifdef HACHIDORI_OPFS
|
|
const backend_t backend = wasmfs_create_opfs_backend();
|
|
if (wasmfs_create_directory("/dicts", 0777, backend) != 0) {
|
|
throw std::runtime_error("could not mount OPFS at /dicts");
|
|
}
|
|
#else
|
|
throw std::runtime_error("this build has no OPFS backend");
|
|
#endif
|
|
}
|
|
recover_interrupted_install("/dicts");
|
|
g_storage_mode = requested_mode;
|
|
return 1;
|
|
} catch (...) {
|
|
set_error(describe_current_exception());
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
EMSCRIPTEN_KEEPALIVE const char* hdw_last_error(void) { return g_last_error.c_str(); }
|
|
|
|
EMSCRIPTEN_KEEPALIVE const char* hdw_import(const char* zip_path, const char* out_dir, int low_ram) {
|
|
static std::string out;
|
|
clear_error();
|
|
|
|
WireImportReport report;
|
|
try {
|
|
report = staged_import(zip_path == nullptr ? "" : zip_path, out_dir == nullptr ? "" : out_dir, low_ram != 0);
|
|
if (!report.success && report.error.empty()) {
|
|
report.error = "import failed";
|
|
}
|
|
if (!report.error.empty()) {
|
|
set_error(report.error);
|
|
}
|
|
} catch (...) {
|
|
report = WireImportReport{};
|
|
report.error = describe_current_exception();
|
|
set_error(report.error);
|
|
}
|
|
|
|
try {
|
|
out = to_json(report);
|
|
} catch (...) {
|
|
set_error(describe_current_exception());
|
|
out = R"({"success":false,"title":"","termCount":0,"metaCount":0,"frequencyCount":0,)"
|
|
R"("pitchCount":0,"kanjiCount":0,"mediaCount":0,"error":"report serialization failed"})";
|
|
}
|
|
return out.c_str();
|
|
}
|
|
|
|
EMSCRIPTEN_KEEPALIVE void hdw_reset(void) {
|
|
clear_error();
|
|
try {
|
|
engine_slot().reset();
|
|
engine_slot().emplace();
|
|
} catch (...) {
|
|
set_error(describe_current_exception());
|
|
}
|
|
}
|
|
|
|
EMSCRIPTEN_KEEPALIVE int hdw_add_dict(const char* path, int kind) {
|
|
clear_error();
|
|
if (path == nullptr || *path == '\0') {
|
|
set_error("empty dictionary path");
|
|
return 0;
|
|
}
|
|
try {
|
|
auto& e = engine();
|
|
const std::string dict_path{path};
|
|
if (kind < 0 || kind > 3) {
|
|
set_error("unknown dictionary kind " + std::to_string(kind));
|
|
return 0;
|
|
}
|
|
if (!dictionary_files_present(std::filesystem::path{dict_path})) {
|
|
set_error("not an imported dictionary directory: " + dict_path);
|
|
return 0;
|
|
}
|
|
switch (kind) {
|
|
case 0:
|
|
if (!e.query.add_term_dict(dict_path)) {
|
|
set_error("term dictionary rejected: " + dict_path);
|
|
return 0;
|
|
}
|
|
e.term_paths.push_back(dict_path);
|
|
break;
|
|
case 1:
|
|
if (!e.query.add_freq_dict(dict_path)) {
|
|
set_error("frequency dictionary rejected: " + dict_path);
|
|
return 0;
|
|
}
|
|
break;
|
|
case 2:
|
|
if (!e.query.add_pitch_dict(dict_path)) {
|
|
set_error("pitch dictionary rejected: " + dict_path);
|
|
return 0;
|
|
}
|
|
break;
|
|
default:
|
|
if (!e.query.add_kanji_dict(dict_path)) {
|
|
set_error("kanji dictionary rejected: " + dict_path);
|
|
return 0;
|
|
}
|
|
break;
|
|
}
|
|
e.dictionary_count += 1;
|
|
return 1;
|
|
} catch (...) {
|
|
set_error(describe_current_exception());
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
// Drops one package from the loaded set without rebuilding it. Returns the
|
|
// number of kinds removed; 0 with no error when the path was not loaded.
|
|
EMSCRIPTEN_KEEPALIVE int hdw_remove_dict(const char* path) {
|
|
clear_error();
|
|
if (path == nullptr || *path == '\0') {
|
|
set_error("empty dictionary path");
|
|
return 0;
|
|
}
|
|
try {
|
|
auto& e = engine();
|
|
const std::string dict_path{path};
|
|
const size_t removed = e.query.remove_dict(dict_path);
|
|
e.dictionary_count -= std::min(removed, e.dictionary_count);
|
|
std::erase(e.term_paths, dict_path);
|
|
g_media.clear();
|
|
return static_cast<int>(removed);
|
|
} catch (...) {
|
|
set_error(describe_current_exception());
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
// Reorders the loaded set to follow the JSON array of package paths in
|
|
// `order_json`. Returns 1 on success and 0, changing nothing, when the list is
|
|
// malformed or names a package that is not loaded.
|
|
EMSCRIPTEN_KEEPALIVE int hdw_set_dict_order(const char* order_json) {
|
|
clear_error();
|
|
try {
|
|
std::vector<std::string> order;
|
|
if (order_json == nullptr
|
|
|| glz::read<glz::opts{.error_on_unknown_keys = false}>(order, std::string_view{order_json})) {
|
|
set_error("malformed dictionary order");
|
|
return 0;
|
|
}
|
|
if (!engine().query.set_dict_order(order)) {
|
|
set_error("dictionary order names a package that is not loaded");
|
|
return 0;
|
|
}
|
|
return 1;
|
|
} catch (...) {
|
|
set_error(describe_current_exception());
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
// Query frequency dictionaries directly, including installations with no term
|
|
// dictionary. A null reading means all readings, preserving their provenance.
|
|
EMSCRIPTEN_KEEPALIVE const char* hdw_frequencies(const char* pairs_json) {
|
|
static std::string out;
|
|
clear_error();
|
|
try {
|
|
std::vector<WireFrequencyQuery> pairs;
|
|
if (pairs_json == nullptr || glz::read<glz::opts{.error_on_unknown_keys = false}>(pairs, std::string_view{pairs_json})
|
|
|| pairs.size() > 4096) {
|
|
throw std::runtime_error("invalid frequency query");
|
|
}
|
|
std::vector<WireTermFrequency> frequencies;
|
|
LookupCopyBudget budget;
|
|
for (const auto& pair : pairs) {
|
|
require_lookup_text_size(pair.term, "frequency term");
|
|
require_lookup_text_size(pair.reading.value_or(""), "frequency reading");
|
|
std::vector<TermResult> terms(1);
|
|
terms[0].expression = pair.term;
|
|
terms[0].reading = pair.reading.value_or("");
|
|
engine().query.query_freq(terms, pair.reading.has_value());
|
|
for (const auto& group : terms[0].frequencies) {
|
|
for (const auto& value : group.frequencies) {
|
|
frequencies.push_back({
|
|
copy_lookup_string(pair.term, budget, "frequency term"),
|
|
value.reading.empty() ? std::nullopt : std::optional{copy_lookup_string(value.reading, budget, "frequency reading")},
|
|
!value.reading.empty(),
|
|
copy_lookup_string(group.dict_name, budget, "frequency dictionary"),
|
|
value.value,
|
|
value.display_value.empty() ? std::nullopt : std::optional{copy_lookup_string(value.display_value, budget, "frequency display value")},
|
|
});
|
|
}
|
|
}
|
|
}
|
|
out = lookup_json(frequencies, budget);
|
|
} catch (...) {
|
|
set_error(describe_current_exception());
|
|
out = "[]";
|
|
}
|
|
return out.c_str();
|
|
}
|
|
|
|
EMSCRIPTEN_KEEPALIVE const char* hdw_lookup(const char* text, int max_results, int scan_length,
|
|
const char* options_json) {
|
|
static std::string out;
|
|
clear_error();
|
|
|
|
try {
|
|
auto& e = engine();
|
|
WireLookupResponse response;
|
|
response.dictionaryCount = e.dictionary_count;
|
|
LookupCopyBudget budget;
|
|
|
|
const std::string_view query_text{text == nullptr ? "" : text};
|
|
if (!query_text.empty() && max_results > 0 && scan_length > 0) {
|
|
require_lookup_text_size(query_text, "lookup text");
|
|
const LookupOptions options = parse_options(options_json);
|
|
// Four-argument overload: the sort preferences have to apply before the
|
|
// max_results cap, otherwise ranking is decided by an arbitrary prefix.
|
|
const auto results =
|
|
e.lookup.lookup(std::string{query_text}, max_results, scan_length, options);
|
|
response.results = convert_results(results, budget);
|
|
}
|
|
out = lookup_json(response, budget);
|
|
} catch (...) {
|
|
set_error(describe_current_exception());
|
|
out = R"({"results":[],"dictionaryCount":0})";
|
|
}
|
|
return out.c_str();
|
|
}
|
|
|
|
EMSCRIPTEN_KEEPALIVE const char* hdw_lookup_dictionary(const char* text, const char* dictionary_path,
|
|
int max_results, size_t scan_length,
|
|
const char* options_json) {
|
|
static std::string out;
|
|
clear_error();
|
|
|
|
try {
|
|
auto& e = engine();
|
|
WireLookupResponse response;
|
|
response.dictionaryCount = e.dictionary_count;
|
|
LookupCopyBudget budget;
|
|
|
|
const std::string_view query_text{text == nullptr ? "" : text};
|
|
const std::string selected_path{dictionary_path == nullptr ? "" : dictionary_path};
|
|
if (!query_text.empty() && max_results > 0 && scan_length > 0 &&
|
|
std::ranges::find(e.term_paths, selected_path) != e.term_paths.end()) {
|
|
require_lookup_text_size(query_text, "lookup text");
|
|
const LookupOptions options = parse_options(options_json);
|
|
const auto results = e.lookup.lookup_dictionary(
|
|
std::string{query_text}, selected_path, max_results, scan_length, options);
|
|
response.results = convert_results(results, budget);
|
|
}
|
|
out = lookup_json(response, budget);
|
|
} catch (...) {
|
|
set_error(describe_current_exception());
|
|
out = R"({"results":[],"dictionaryCount":0})";
|
|
}
|
|
return out.c_str();
|
|
}
|
|
|
|
EMSCRIPTEN_KEEPALIVE const char* hdw_kanji(const char* character) {
|
|
static std::string out;
|
|
clear_error();
|
|
|
|
try {
|
|
WireKanji wire;
|
|
LookupCopyBudget budget;
|
|
const std::string_view kanji{character == nullptr ? "" : character};
|
|
if (!kanji.empty()) {
|
|
require_lookup_text_size(kanji, "kanji text");
|
|
KanjiResult result = engine().query.query_kanji(std::string{kanji});
|
|
wire.entries.reserve(result.entries.size());
|
|
for (const auto& entry : result.entries) {
|
|
WireKanjiEntry out_entry;
|
|
out_entry.dictionary = copy_lookup_string(entry.dict_name, budget, "kanji dictionary");
|
|
out_entry.onyomi = copy_lookup_string(entry.onyomi, budget, "kanji onyomi");
|
|
out_entry.kunyomi = copy_lookup_string(entry.kunyomi, budget, "kanji kunyomi");
|
|
out_entry.tags = copy_lookup_string(entry.tags, budget, "kanji tags");
|
|
out_entry.definitions.reserve(entry.definitions.size());
|
|
for (const auto& definition : entry.definitions) {
|
|
out_entry.definitions.push_back(copy_lookup_string(definition, budget, "kanji definition"));
|
|
}
|
|
out_entry.stats.reserve(entry.stats.size());
|
|
for (const auto& [name, value] : entry.stats) {
|
|
out_entry.stats.emplace_back(copy_lookup_string(name, budget, "kanji stat name"),
|
|
copy_lookup_string(value, budget, "kanji stat value"));
|
|
}
|
|
// stats arrive from an unordered_map; sort so the rendered order is stable.
|
|
std::ranges::sort(out_entry.stats, {}, &WireKanjiStat::name);
|
|
wire.entries.push_back(std::move(out_entry));
|
|
}
|
|
// Empty character is the contract's "nothing matched" sentinel.
|
|
if (!wire.entries.empty()) {
|
|
wire.character = copy_lookup_string(result.character, budget, "kanji character");
|
|
}
|
|
}
|
|
out = lookup_json(wire, budget);
|
|
} catch (...) {
|
|
set_error(describe_current_exception());
|
|
out = R"({"character":"","entries":[]})";
|
|
}
|
|
return out.c_str();
|
|
}
|
|
|
|
EMSCRIPTEN_KEEPALIVE const char* hdw_styles(void) {
|
|
static std::string out;
|
|
clear_error();
|
|
|
|
try {
|
|
const auto styles = engine().query.get_styles();
|
|
std::vector<WireStyle> wire;
|
|
wire.reserve(styles.size());
|
|
for (const auto& s : styles) {
|
|
wire.push_back({s.dict_name, s.styles});
|
|
}
|
|
out = to_json(wire);
|
|
} catch (...) {
|
|
set_error(describe_current_exception());
|
|
out = "[]";
|
|
}
|
|
return out.c_str();
|
|
}
|
|
|
|
EMSCRIPTEN_KEEPALIVE int hdw_media(const char* dictionary, const char* path) {
|
|
clear_error();
|
|
g_media.clear();
|
|
if (dictionary == nullptr || path == nullptr) {
|
|
set_error("missing dictionary or path");
|
|
return 0;
|
|
}
|
|
try {
|
|
// Copied out of the mmap'd dictionary so the pointer handed to JS survives a
|
|
// later hdw_reset.
|
|
if (std::string_view{dictionary}.size() > MAX_MEDIA_DICTIONARY_BYTES) {
|
|
throw std::length_error("media dictionary exceeds the 1024-byte limit");
|
|
}
|
|
if (std::string_view{path}.size() > MAX_MEDIA_PATH_BYTES) {
|
|
throw std::length_error("media path exceeds the 4096-byte limit");
|
|
}
|
|
const MediaFileView view = engine().query.get_media_file_view(dictionary, path);
|
|
if (view.data == nullptr || view.size == 0) {
|
|
return 0;
|
|
}
|
|
if (view.size > MAX_MEDIA_BYTES) {
|
|
throw std::length_error("media exceeds the 4 MiB byte limit");
|
|
}
|
|
const auto* bytes = reinterpret_cast<const uint8_t*>(view.data);
|
|
g_media.assign(bytes, bytes + view.size);
|
|
return static_cast<int>(g_media.size());
|
|
} catch (...) {
|
|
set_error(describe_current_exception());
|
|
g_media.clear();
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
EMSCRIPTEN_KEEPALIVE const uint8_t* hdw_media_data(void) { return g_media.data(); }
|
|
|
|
} // extern "C"
|