diff --git a/CHANGELOG.md b/CHANGELOG.md index 96380c327..e9b665bc7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,14 @@ The release run heads these entries with the version and opens a fresh ## Unreleased +- The wasm `Document.edit`, `save`, `isEditable` and `isSavable` work for a + plain text file, which saves as UTF-8. + +- `TextFile::is_savable` is false for a json file. + +- `TextFile::edit`, `save` and `save_to_memory`, also in Python, Java, + Objective-C and Swift. `write_edited` is deprecated. + ## v7.0.0 - 2026-09-13 - **Breaking**: `DocumentPath`, `Element::document_path()` and diff --git a/apple/include/OdrCoreObjC/ODRFile.h b/apple/include/OdrCoreObjC/ODRFile.h index 6d3ae4bde..2b5f75035 100644 --- a/apple/include/OdrCoreObjC/ODRFile.h +++ b/apple/include/OdrCoreObjC/ODRFile.h @@ -382,8 +382,19 @@ NS_SWIFT_NAME(TextFile) /// `NO` where the file type is one this library does not write, or the /// encoding cannot be decoded. @property(nonatomic, readonly) BOOL isSavable; +/// Applies the operations our browser-side editor produces, in order. The text +/// is UTF-8 afterwards, whatever the source encoding was. +- (BOOL)edit:(NSString *)operations + error:(NSError **)error NS_SWIFT_NAME(edit(operations:)); +/// Writes the text, with every edit applied, as UTF-8. +- (BOOL)saveTo:(NSString *)path error:(NSError **)error; +/// The saved file as bytes. +- (nullable NSData *)saveToMemoryWithError:(NSError **)error + NS_SWIFT_NAME(saveToMemory()); /// Applies the operations and returns the result, as UTF-8 whatever the source -/// encoding was. +/// encoding was. The file stays as it is. +/// +/// Deprecated: `edit(operations:)`, then `saveToMemory()`. - (nullable NSData *)writeEdited:(NSString *)operations error:(NSError **)error NS_SWIFT_NAME(writeEdited(operations:)); diff --git a/apple/src/ODRFile.mm b/apple/src/ODRFile.mm index db8eab719..185f216a1 100644 --- a/apple/src/ODRFile.mm +++ b/apple/src/ODRFile.mm @@ -603,6 +603,29 @@ - (BOOL)isSavable { [&] { return self.handle.as_text_file().is_savable() ? YES : NO; }, NO); } +- (BOOL)edit:(NSString *)operations error:(NSError **)error { + return guarded(error, [&] { + self.handle.as_text_file().edit(to_string(operations)); + return YES; + }); +} + +- (BOOL)saveTo:(NSString *)path error:(NSError **)error { + return guarded(error, [&] { + self.handle.as_text_file().save(to_string(path)); + return YES; + }); +} + +- (nullable NSData *)saveToMemoryWithError:(NSError **)error { + return guarded(error, [&]() -> NSData * { + std::ostringstream out; + self.handle.as_text_file().save(out); + const std::string bytes = out.str(); + return [NSData dataWithBytes:bytes.data() length:bytes.size()]; + }); +} + - (nullable NSData *)writeEdited:(NSString *)operations error:(NSError **)error { return guarded(error, [&]() -> NSData * { diff --git a/apple/tests/OdrCoreTests.swift b/apple/tests/OdrCoreTests.swift index c9243b9d9..5412b68a6 100644 --- a/apple/tests/OdrCoreTests.swift +++ b/apple/tests/OdrCoreTests.swift @@ -526,6 +526,20 @@ final class TextFileEditTests: XCTestCase { XCTAssertEqual(String(data: edited, encoding: .utf8), "rewritten") } + + func testEditsAndSavesLikeADocument() throws { + let path = try write("hello text file\n", as: "note.txt") + let file = try DecodedFile.decode(path: path).asTextFile() + + try file.edit( + operations: #"{"version":2,"ops":[{"op":"setContent","text":"rewritten"}]}"#) + + XCTAssertEqual(try file.text(), "rewritten") + XCTAssertEqual(String(data: try file.saveToMemory(), encoding: .utf8), "rewritten") + let saved = (path as NSString).deletingLastPathComponent + "/saved.txt" + try file.save(to: saved) + XCTAssertEqual(try String(contentsOfFile: saved, encoding: .utf8), "rewritten") + } } final class PdfAnnotationTests: XCTestCase { diff --git a/docs/design/editing.md b/docs/design/editing.md index 095f0ce6c..679bb2461 100644 --- a/docs/design/editing.md +++ b/docs/design/editing.md @@ -32,8 +32,8 @@ file — with no live connection between the browser and C++. - `Document::edit` replays the envelope: `setCell` for a sheet, and `setText`, `setTextStyle`, `insertText`, `removeElement`, `splitParagraph`, `mergeParagraph` and `insertParagraph` for a document. - `TextFile::write_edited` is the plain-text counterpart, since a `.txt` is - not a document. + `TextFile::edit` is the plain-text counterpart, with the same name, since a + `.txt` is not a document. - `back_translate` CLI replays an envelope onto a source document and `save`s it. Inline formatting — bold, italic, underline, strikethrough, highlight, colour diff --git a/docs/design/txt-editing.md b/docs/design/txt-editing.md index f07006c21..f8efeb083 100644 --- a/docs/design/txt-editing.md +++ b/docs/design/txt-editing.md @@ -64,27 +64,32 @@ does not have. UTF-16 code units and `std::string` counts bytes. **What it costs:** the whole file crosses the bridge on every save. Less than -it looks, because `write_edited` produces the complete bytes either way, so a +it looks, because `TextFile::save` writes the complete bytes either way, so a finer log would only be reassembled before writing; the saving would be one hop. Where it does bite is a large file, and the answer there is **one** `replaceLines {from, to, text}` computed as a single diff hunk at emit time — still one operation, still applied to the file as it was, so still nothing positional to go stale. That needs no schema change to reach. -### 3. The write path is `PdfFile::annotate`'s shape, not `Document::save`'s +### 3. The write path has `Document`'s names ```cpp [[nodiscard]] bool TextFile::is_savable() const noexcept; -void TextFile::write_edited(std::string_view operations, std::ostream &out, - const Logger & = Logger::null()) const; +void TextFile::edit(std::string_view operations, + const Logger & = Logger::null()) const; +void TextFile::save(std::ostream &out) const; ``` -One call taking the envelope and a stream, leaving the handle unchanged. +`edit` keeps the edit in the file, so every handle over it and the next render +see it. `save` writes the text. -**Why:** a `TextFile` is an immutable handle over bytes, and there is no -document to mutate and later serialise. `PdfFile::annotate` is the precedent — -the other non-document file with a write path of its own — and the shape suits -for the same reason: nothing is held between the edit and the write. +**Why:** a host saves a `.txt` with the calls it already makes for a document, +and learns no second API because of what the file turned out to be — decision 9 +of [`editing.md`](editing.md) again. `TextFile::write_edited` stays, deprecated. + +**What it costs:** a `TextFile` handle is no longer immutable. The edit lives in +the text engine's file, which every handle shares, as a document's edit lives +in its shared tree. ### 4. What it writes is UTF-8, whatever the source was diff --git a/jni/java/app/opendocument/core/TextFile.java b/jni/java/app/opendocument/core/TextFile.java index fb04cab90..edf1efb08 100644 --- a/jni/java/app/opendocument/core/TextFile.java +++ b/jni/java/app/opendocument/core/TextFile.java @@ -35,15 +35,42 @@ public boolean isSavable() { } /** - * Applies the operations and returns the result, as UTF-8 whatever the source - * encoding was. + * Applies the operations our browser-side editor produces. The text is UTF-8 afterwards, + * whatever the source encoding was. */ + public void edit(String operations) { + editNative(handle(), operations); + } + + /** Writes the text, with every edit applied, as UTF-8. */ + public void save(String path) { + saveNative(handle(), path); + } + + /** The saved file as bytes. */ + public byte[] saveToMemory() { + return saveToMemoryNative(handle()); + } + + /** + * Applies the operations and returns the result, as UTF-8 whatever the source encoding was. The + * file stays as it is. + * + * @deprecated use {@link #edit(String)}, then {@link #saveToMemory()} + */ + @Deprecated public byte[] writeEdited(String operations) { return writeEditedNative(handle(), operations); } private native int encodingNative(long handle); + private native void editNative(long handle, String operations); + + private native void saveNative(long handle, String path); + + private native byte[] saveToMemoryNative(long handle); + private native boolean isSavableNative(long handle); private native byte[] writeEditedNative(long handle, String operations); diff --git a/jni/src/jni_file.cpp b/jni/src/jni_file.cpp index 5c8dd89d1..4f3205608 100644 --- a/jni/src/jni_file.cpp +++ b/jni/src/jni_file.cpp @@ -385,6 +385,32 @@ Java_app_opendocument_core_TextFile_writeEditedNative(JNIEnv *env, jobject, }); } +extern "C" JNIEXPORT void JNICALL +Java_app_opendocument_core_TextFile_editNative(JNIEnv *env, jobject, + jlong handle, + jstring operations) { + guarded(env, [&] { + decoded(handle).as_text_file().edit(to_string(env, operations)); + }); +} + +extern "C" JNIEXPORT void JNICALL +Java_app_opendocument_core_TextFile_saveNative(JNIEnv *env, jobject, + jlong handle, jstring path) { + guarded(env, + [&] { decoded(handle).as_text_file().save(to_string(env, path)); }); +} + +extern "C" JNIEXPORT jbyteArray JNICALL +Java_app_opendocument_core_TextFile_saveToMemoryNative(JNIEnv *env, jobject, + jlong handle) { + return guarded(env, [&] { + std::ostringstream out; + decoded(handle).as_text_file().save(out); + return to_jbytes(env, out.str()); + }); +} + // app.opendocument.core.ImageFile extern "C" JNIEXPORT jbyteArray JNICALL diff --git a/jni/tests/app/opendocument/core/FileTest.java b/jni/tests/app/opendocument/core/FileTest.java index 4f506770f..5a98a2aba 100644 --- a/jni/tests/app/opendocument/core/FileTest.java +++ b/jni/tests/app/opendocument/core/FileTest.java @@ -69,6 +69,7 @@ void openCsv() throws IOException { } @Test + @SuppressWarnings("deprecation") void textFileWritesAnEditBack() throws IOException { Path txt = TestFiles.txtFile(tempDir); try (DecodedFile file = Odr.open(txt.toString())) { @@ -83,6 +84,22 @@ void textFileWritesAnEditBack() throws IOException { } } + @Test + void textFileEditsAndSavesLikeADocument() throws IOException { + Path txt = TestFiles.txtFile(tempDir); + try (DecodedFile file = Odr.open(txt.toString())) { + TextFile text = file.asTextFile(); + text.edit("{\"version\":2,\"ops\":[{\"op\":\"setContent\",\"text\":\"rewritten\"}]}"); + + assertEquals("rewritten", text.text()); + assertEquals( + "rewritten", new String(text.saveToMemory(), java.nio.charset.StandardCharsets.UTF_8)); + Path saved = tempDir.resolve("saved.txt"); + text.save(saved.toString()); + assertEquals("rewritten", Files.readString(saved)); + } + } + @Test void fileReadMatchesSize() throws IOException { Path odt = TestFiles.odtFile(tempDir); diff --git a/python/src/bind_file.cpp b/python/src/bind_file.cpp index 1bb02647a..14264df8b 100644 --- a/python/src/bind_file.cpp +++ b/python/src/bind_file.cpp @@ -317,6 +317,29 @@ void odr_python::bind_file(py::module_ &m) { .def("is_savable", &odr::TextFile::is_savable, "False where the file type is one this library does not write, or " "the encoding cannot be decoded.") + .def( + "edit", + [](const odr::TextFile &file, const std::string &operations) { + file.edit(operations); + }, + py::arg("operations"), + "Apply the operations our browser-side editor produces. The text is " + "UTF-8 afterwards, whatever the source encoding was.") + .def("save", + py::overload_cast(&odr::TextFile::save, + py::const_), + py::arg("path"), py::call_guard()) + .def( + "save_to_memory", + [](const odr::TextFile &file) { + std::ostringstream out; + { + py::gil_scoped_release release; + file.save(out); + } + return py::bytes(out.str()); + }, + "Save the file and return its bytes.") .def( "write_edited", [](const odr::TextFile &file, const std::string &operations) { @@ -328,8 +351,9 @@ void odr_python::bind_file(py::module_ &m) { return py::bytes(out.str()); }, py::arg("operations"), - "Apply the operations and return the result, as UTF-8 whatever the " - "source encoding was."); + "Deprecated: use edit, then save_to_memory. Apply the operations and " + "return the result, as UTF-8 whatever the source encoding was. The " + "file stays as it is."); py::class_(m, "ImageFile") .def("read", [](const odr::ImageFile &file) { diff --git a/python/tests/test_file.py b/python/tests/test_file.py index e614c3aa8..c3b68ee48 100644 --- a/python/tests/test_file.py +++ b/python/tests/test_file.py @@ -251,6 +251,17 @@ def test_text_file_writes_an_edit_back(txt_path): assert edited == b"rewritten\n" +def test_text_file_edits_and_saves_like_a_document(txt_path, tmp_path): + text_file = pyodr.open(str(txt_path)).as_text_file() + text_file.edit('{"version":2,"ops":[{"op":"setContent","text":"rewritten\\n"}]}') + + assert text_file.text() == "rewritten\n" + assert text_file.save_to_memory() == b"rewritten\n" + saved = tmp_path / "saved.txt" + text_file.save(str(saved)) + assert saved.read_bytes() == b"rewritten\n" + + def test_a_csv_holds_a_text_file_rather_than_being_one(csv_path): file = pyodr.open(str(csv_path)) assert not file.is_text_file() diff --git a/src/odr/file.cpp b/src/odr/file.cpp index 195ef93e6..3b45d24ea 100644 --- a/src/odr/file.cpp +++ b/src/odr/file.cpp @@ -11,13 +11,16 @@ #include #include #include +#include #include #include #include +#include #include #include +#include #include namespace odr { @@ -291,18 +294,19 @@ std::string TextFile::text() const { } bool TextFile::is_savable() const noexcept { + // json is a text file too, and one this library does not write + if (file_type() != FileType::text_file) { + return false; + } const TextEncoding encoding = this->encoding(); return encoding == TextEncoding::unknown || text_encoding_is_decodable(encoding); } -void TextFile::write_edited(const std::string_view operations, - std::ostream &out, - const Logger & /*logger*/) const { - if (!is_savable()) { - throw UnsupportedOperation(); - } +namespace { +/// The text @p operations set, or none where they state no operation. +std::optional content_of(const std::string_view operations) { const nlohmann::json json = nlohmann::json::parse(operations); if (json.value("version", 0) != 2) { throw std::invalid_argument("unsupported edit version"); @@ -316,6 +320,56 @@ void TextFile::write_edited(const std::string_view operations, } content = operation.at("text").get(); } + return content; +} + +} // namespace + +void TextFile::edit(const std::string_view operations, + const Logger & /*logger*/) const { + if (!is_savable()) { + throw UnsupportedOperation(); + } + std::optional content = content_of(operations); + if (!content.has_value()) { + return; + } + const auto text_file = + std::dynamic_pointer_cast(m_impl); + if (text_file == nullptr) { + throw UnsupportedOperation(); + } + text_file->set_text(std::move(*content)); +} + +void TextFile::save(const std::string &path) const { + if (!is_savable()) { + throw UnsupportedOperation(); + } + std::ofstream out = internal::util::file::create(path); + out << text(); +} + +void TextFile::save(std::ostream &out) const { + if (!is_savable()) { + throw UnsupportedOperation(); + } + out << text(); +} + +File TextFile::save_to_memory() const { + std::ostringstream out; + save(out); + return File::from_memory(std::move(out).str()); +} + +void TextFile::write_edited(const std::string_view operations, + std::ostream &out, + const Logger & /*logger*/) const { + if (!is_savable()) { + throw UnsupportedOperation(); + } + const std::optional content = content_of(operations); // an envelope stating any operation replaces every byte, so the file is // only read where it states none diff --git a/src/odr/file.hpp b/src/odr/file.hpp index f0eeb75ac..319a66406 100644 --- a/src/odr/file.hpp +++ b/src/odr/file.hpp @@ -439,13 +439,27 @@ class TextFile final : public DecodedFile { /// the raw bytes where it is not. [[nodiscard]] std::string text() const; - /// False where @ref encoding cannot be decoded: the view hands those bytes - /// to the browser as they are, so what comes back cannot be put back. + /// False where the file type is not `text_file`, and where @ref encoding + /// cannot be decoded: the view hands those bytes to the browser as they are, + /// so what comes back cannot be put back. [[nodiscard]] bool is_savable() const noexcept; /// Applies @p operations - `{"version": 2, "ops": [{"op": "setContent", - /// "text": "…"}]}` - and writes the result to @p out, as UTF-8 whatever - /// @ref encoding the source was. See `docs/design/txt-editing.md`. + /// "text": "…"}]}` - to the file, and every handle over it sees the edit. The + /// text is UTF-8 afterwards. See `docs/design/txt-editing.md`. + /// @throws UnsupportedOperation where @ref is_savable is false. + void edit(std::string_view operations, + const Logger &logger = Logger::null()) const; + + /// Writes the text as UTF-8, with every @ref edit applied. + /// @throws UnsupportedOperation where @ref is_savable is false. + void save(const std::string &path) const; + void save(std::ostream &out) const; + /// The saved file in memory. + [[nodiscard]] File save_to_memory() const; + + /// Deprecated: @ref edit, then @ref save. Writes what @p operations make of + /// the file to @p out, and leaves the file as it is. /// @throws UnsupportedOperation where @ref is_savable is false. void write_edited(std::string_view operations, std::ostream &out, const Logger &logger = Logger::null()) const; diff --git a/src/odr/internal/text/text_file.cpp b/src/odr/internal/text/text_file.cpp index 9f703e2e7..6ae32cc04 100644 --- a/src/odr/internal/text/text_file.cpp +++ b/src/odr/internal/text/text_file.cpp @@ -55,4 +55,9 @@ std::string TextFile::text() const { return encoding::to_utf8(util::stream::read(*in), m_encoding); } +void TextFile::set_text(std::string utf8) { + m_file = File::from_memory(std::move(utf8), m_file->name()).impl(); + m_encoding = TextEncoding::utf8; +} + } // namespace odr::internal::text diff --git a/src/odr/internal/text/text_file.hpp b/src/odr/internal/text/text_file.hpp index 4d55564bf..c5868e304 100644 --- a/src/odr/internal/text/text_file.hpp +++ b/src/odr/internal/text/text_file.hpp @@ -29,6 +29,9 @@ class TextFile final : public abstract::TextFile { /// @throws UnsupportedTextEncoding if the encoding cannot be decoded. [[nodiscard]] std::string text() const; + /// Replaces the bytes with @p utf8, keeping the file's name. + void set_text(std::string utf8); + private: std::shared_ptr m_file; TextEncoding m_encoding{TextEncoding::unknown}; diff --git a/test/src/internal/text/text_file_test.cpp b/test/src/internal/text/text_file_test.cpp index 39e5c42bf..a6b91311e 100644 --- a/test/src/internal/text/text_file_test.cpp +++ b/test/src/internal/text/text_file_test.cpp @@ -166,6 +166,37 @@ TEST(TextFile, a_file_we_cannot_decode_is_not_savable) { std::ostringstream out; EXPECT_THROW(shift_jis.write_edited(set_content("x"), out), UnsupportedOperation); + EXPECT_THROW(shift_jis.edit(set_content("x")), UnsupportedOperation); + EXPECT_THROW(shift_jis.save(out), UnsupportedOperation); +} + +TEST(TextFile, an_edit_stays_in_the_file_until_it_is_saved) { + const DecodedFile file = + open(File::from_memory(std::string("one\ntwo\n"), "notes.txt"), + DecodeOptions::as(FileType::text_file)); + file.as_text_file().edit(set_content("one\nTWO\n")); + + // a second handle over the same file sees the edit + const odr::TextFile text = file.as_text_file(); + EXPECT_EQ(text.text(), "one\nTWO\n"); + EXPECT_EQ(text.encoding(), TextEncoding::utf8); + EXPECT_EQ(text.file().name(), "notes.txt"); + + std::ostringstream out; + text.save(out); + EXPECT_EQ(std::move(out).str(), "one\nTWO\n"); + EXPECT_EQ(text.save_to_memory().size(), 8U); +} + +TEST(TextFile, json_is_not_savable) { + const DecodedFile json = + open(File::from_memory(std::string(R"({"a": 1})")), + DecodeOptions::as(FileType::javascript_object_notation)); + ASSERT_TRUE(json.is_text_file()); + EXPECT_FALSE(json.as_text_file().is_savable()); + std::ostringstream out; + EXPECT_THROW(json.as_text_file().write_edited(set_content("x"), out), + UnsupportedOperation); } /// A decodable encoding that is not utf-8 saves, and saves as utf-8. diff --git a/wasm/AGENTS.md b/wasm/AGENTS.md index f2054a013..4a8063df1 100644 --- a/wasm/AGENTS.md +++ b/wasm/AGENTS.md @@ -42,8 +42,10 @@ Worker**, where every value that crosses is structured-cloned. out — `Session` owns file, document, service and views together. The document is the *one* tree the render, the edit and the save all go through: `DocumentFile::document()` decodes a fresh one per call, so a `save` that - opened its own would write the document nobody edited. Handle `0` is never - issued, so a zeroed handle is always invalid. + opened its own would write the document nobody edited. A plain text file + has no tree, and `TextFile::edit` keeps the edit in the file itself, so the + same calls answer for it. Handle `0` is never issued, so a zeroed handle + is always invalid. This is why the structural edits are addressed **by element id**, not by an element: an id is a plain number, and the render already writes it into the diff --git a/wasm/README.md b/wasm/README.md index 2594c78eb..b235e869b 100644 --- a/wasm/README.md +++ b/wasm/README.md @@ -95,7 +95,8 @@ refuses the rest with code 1010, `outOfScope`. `isEditable()` and `isSavable()` answer for this document, where `capabilities()` answers for the format. ODF, docx, pptx, xlsx and txt can be -saved; anything else throws `UnsupportedOperation`. +saved; anything else throws `UnsupportedOperation`. A txt saves as UTF-8, +whatever encoding it was read in. Encrypted documents: diff --git a/wasm/js/index.d.ts b/wasm/js/index.d.ts index 5f8d725f4..a20106256 100644 --- a/wasm/js/index.d.ts +++ b/wasm/js/index.d.ts @@ -176,8 +176,9 @@ export declare class Document { /** `capabilities()` narrowed to this document. */ isEditable(): boolean; isSavable(encrypted?: boolean): boolean; - /** Applies what the rendered page's `odr.generateDiff()` collected. - * @throws OdrError `NoDocumentFile` */ + /** Applies what the rendered page's `odr.generateDiff()` collected. A plain + * text file takes its `setContent` envelope, and the next render shows it. + * @throws OdrError `NoDocumentFile` for a file that is neither */ edit(diff: string): this; /** @@ -217,7 +218,8 @@ export declare class Document { * `odr.annotation.getAnnotations()` collected. */ annotate(annotations: string): Uint8Array; - /** The document's bytes, not the rendered html. + /** The document's bytes, not the rendered html. A plain text file saves as + * UTF-8, whatever its source encoding. * @throws OdrError `UnsupportedOperation` where the format cannot be saved */ save(password?: string): Uint8Array; diff --git a/wasm/src/wasm_document.cpp b/wasm/src/wasm_document.cpp index 9bb8422eb..129dbf266 100644 --- a/wasm/src/wasm_document.cpp +++ b/wasm/src/wasm_document.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include @@ -14,17 +15,25 @@ namespace odr::wasm { namespace { -/// `capabilities()` narrowed to this document. +/// `capabilities()` narrowed to this document, or `TextFile::is_savable`. emscripten::val is_editable(const Handle handle) { return guarded([&] { - return ok(emscripten::val(document_of(session(handle)).is_editable())); + Session &s = session(handle); + if (s.file.is_text_file()) { + return ok(emscripten::val(s.file.as_text_file().is_savable())); + } + return ok(emscripten::val(document_of(s).is_editable())); }); } emscripten::val is_savable(const Handle handle, const bool encrypted) { return guarded([&] { - return ok( - emscripten::val(document_of(session(handle)).is_savable(encrypted))); + Session &s = session(handle); + if (s.file.is_text_file()) { + return ok( + emscripten::val(!encrypted && s.file.as_text_file().is_savable())); + } + return ok(emscripten::val(document_of(s).is_savable(encrypted))); }); } @@ -131,8 +140,13 @@ emscripten::val insert_paragraph_after(const Handle handle, /// The document's bytes; there is no filesystem to save to. emscripten::val save(const Handle handle) { return guarded([&] { + Session &s = session(handle); std::ostringstream out; - document_of(session(handle)).save(out); + if (s.file.is_text_file()) { + s.file.as_text_file().save(out); + } else { + document_of(s).save(out); + } return ok(to_uint8_array(out.str())); }); } @@ -140,8 +154,12 @@ emscripten::val save(const Handle handle) { emscripten::val save_encrypted(const Handle handle, const std::string &password) { return guarded([&] { + Session &s = session(handle); + if (s.file.is_text_file()) { + throw UnsupportedOperation(); + } std::ostringstream out; - document_of(session(handle)).save(out, password); + document_of(s).save(out, password); return ok(to_uint8_array(out.str())); }); } diff --git a/wasm/src/wasm_html.cpp b/wasm/src/wasm_html.cpp index 41431c962..21dbf94e1 100644 --- a/wasm/src/wasm_html.cpp +++ b/wasm/src/wasm_html.cpp @@ -144,7 +144,11 @@ emscripten::val read_path(const Handle handle, const std::string &path) { emscripten::val edit(const Handle handle, const std::string &diff) { return guarded([&] { Session &s = session(handle); - document_of(s).edit(diff, s.logger); + if (s.file.is_text_file()) { + s.file.as_text_file().edit(diff, s.logger); + } else { + document_of(s).edit(diff, s.logger); + } return ok(); }); } diff --git a/wasm/tests/edit.test.mjs b/wasm/tests/edit.test.mjs index ee1e732ec..677c7aa4f 100644 --- a/wasm/tests/edit.test.mjs +++ b/wasm/tests/edit.test.mjs @@ -3,7 +3,7 @@ import assert from 'node:assert/strict'; import { after, before, describe, it } from 'node:test'; -import { Odr, OdrError, minimalOds, minimalOdt } from './helper.mjs'; +import { Odr, OdrError, minimalOds, minimalOdt, minimalPdf } from './helper.mjs'; // Read out of the html rather than spelled, as the browser does. The runs // carry the ids an op names; a paragraph carries one too, so the tag counts. @@ -168,8 +168,62 @@ describe('edit', () => { } }); + it('edits and saves a plain text file through the same calls', () => { + const doc = odr.open(new TextEncoder().encode('lorem ipsum'), { + editable: true, + name: 'notes.txt', + }); + try { + assert.equal(doc.isEditable(), true); + assert.equal(doc.isSavable(), true); + assert.equal(doc.isSavable(true), false); + assert.equal(new TextDecoder().decode(doc.save()), 'lorem ipsum'); + + assert.match(doc.render(0).html, /lorem ipsum/); + doc.edit(JSON.stringify({ + version: 2, + ops: [{ op: 'setContent', text: 'edited in the browser' }], + })); + assert.equal(doc.fileName, 'notes.txt'); + assert.match(doc.render(0).html, /edited in the browser/); + + const saved = doc.save(); + assert.equal(new TextDecoder().decode(saved), 'edited in the browser'); + const reopened = odr.open(saved); + try { + assert.equal(reopened.fileType, odr.enums.FileType.txt); + } finally { + reopened.close(); + } + + assert.throws(() => doc.save('secret'), (error) => { + assert.equal(error.name, 'UnsupportedOperation'); + return true; + }); + } finally { + doc.close(); + } + }); + + it('refuses a text file of a type it does not write', () => { + const doc = odr.open(new TextEncoder().encode('{"a": 1}'), { editable: true }); + try { + assert.equal(doc.isEditable(), false); + assert.equal(doc.isSavable(), false); + assert.match(doc.render(0).html, /data-odr-editable="readOnly"/); + for (const call of [() => doc.save(), () => doc.edit('{"version":2,"ops":[]}')]) { + assert.throws(call, (error) => { + assert.equal(error.name, 'UnsupportedOperation'); + return true; + }); + } + } finally { + doc.close(); + } + }); + it('refuses a file that is not a document', () => { - const doc = odr.open(new TextEncoder().encode('lorem ipsum dolor sit amet')); + const doc = odr.open(minimalPdf()); try { assert.throws(() => doc.save(), (error) => { assert.ok(error instanceof OdrError);