Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
package com.pasich.mynotes.extendedEditor.utils;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.pasich.mynotes.extendedEditor.attach.EditorAttachmentBlocks;

/**
* Whether an Editor.js document holds anything the user actually put there.
*
* <p>Emptiness used to be decided by comparing the serialized string against {@code "[]"}, which an
* untouched editor never produces: opening a note writes a document with one empty paragraph in it,
* so a note nobody typed into read as meaningful, was saved, and counted in the statistics. The
* answer has to come from the parsed blocks, because what the editor serializes for an empty
* document (block ids, a {@code time} field, a trailing paragraph) keeps changing.
*
* <p>Deliberately free of {@code android.*}, so the rule is testable under ordinary JVM tests.
*/
public final class EditorDocument {

private EditorDocument() {}

/**
* Reports whether the document carries text or an attachment.
*
* <p>A block counts as content unless it is a text block whose text is blank: a paragraph or
* header with nothing in it, or a list with no non-blank items. Anything else — a delimiter, a
* table, a tool this build does not know — counts, because only the empty paragraph is written
* without the user asking for it; everything else got there by a deliberate insertion.
*
* @param valueJson raw Editor.js block array, may be {@code null}
* @return {@code false} only when the document is provably empty
*/
public static boolean hasContent(@Nullable String valueJson) {
if (valueJson == null || valueJson.trim().isEmpty()) {
return false;
}
JsonArray blocks;
try {
blocks = JsonParser.parseString(valueJson).getAsJsonArray();
} catch (RuntimeException unreadable) {
// Unreadable but not empty: keep the note rather than discard data we cannot read.
return true;
}
if (!EditorAttachmentBlocks.fileUrls(valueJson).isEmpty()) {
return true;
}
for (JsonElement element : blocks) {
if (!element.isJsonObject() || carriesContent(element.getAsJsonObject())) {
return true;
}
}
return false;
}

/** The negation of "a text block with blank text". */
private static boolean carriesContent(@NonNull JsonObject block) {
JsonElement dataElement = block.get("data");
if (dataElement != null && dataElement.isJsonObject()) {
JsonObject data = dataElement.getAsJsonObject();
JsonElement text = data.get("text");
if (isString(text) && !isBlank(text.getAsString())) {
return true;
}
JsonElement items = data.get("items");
if (items != null && items.isJsonArray() && anyItemFilled(items.getAsJsonArray())) {
return true;
}
}
// Nothing readable as text: only a text tool is allowed to be empty. Any other tool
// is in the document because someone inserted it.
return !isTextTool(block);
}

/** Walks list items, including the nested ones a checklist or sub-list can hold. */
private static boolean anyItemFilled(@NonNull JsonArray items) {
for (JsonElement element : items) {
if (element.isJsonPrimitive()) {
// The pre-2.x list tool stored plain strings instead of item objects.
if (!isBlank(element.getAsString())) return true;
continue;
}
if (!element.isJsonObject()) continue;
JsonObject item = element.getAsJsonObject();
JsonElement content = item.get("content");
if (isString(content) && !isBlank(content.getAsString())) {
return true;
}
JsonElement nested = item.get("items");
if (nested != null && nested.isJsonArray() && anyItemFilled(nested.getAsJsonArray())) {
return true;
}
}
return false;
}

/** Tools whose whole content is the text this class already read and found blank. */
private static boolean isTextTool(@NonNull JsonObject block) {
JsonElement type = block.get("type");
if (!isString(type)) return false;
switch (type.getAsString()) {
case "paragraph":
case "header":
case "Headers":
case "list":
case "checklist":
return true;
default:
return false;
}
}

/** Blank once the markup an empty editor line leaves behind is taken out. */
private static boolean isBlank(@Nullable String html) {
if (html == null) return true;
String text =
html.replaceAll("(?i)<br\\s*/?>", " ")
.replaceAll("<[^>]+>", "")
.replace("&nbsp;", " ")
.replace('\u00a0', ' ')
.replace("\ufeff", "")
.replace("\u200b", "");
return text.trim().isEmpty();
}

private static boolean isString(@Nullable JsonElement element) {
return element != null
&& element.isJsonPrimitive()
&& element.getAsJsonPrimitive().isString();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import com.pasich.mynotes.data.DataManager;
import com.pasich.mynotes.data.model.Note;
import com.pasich.mynotes.extendedEditor.models.ParsedNote;
import com.pasich.mynotes.extendedEditor.utils.EditorDocument;
import com.pasich.mynotes.extendedEditor.utils.EditorJsonUtils;
import com.pasich.mynotes.ui.contract.NoteContract;
import com.pasich.mynotes.utils.constants.AutoSave;
Expand Down Expand Up @@ -320,12 +321,6 @@ public void closeActivity() {

pendingClose = true;

// Extended editor "empty JSON" check is WRONG — replace with targetNote
if (extendedEditor && !hasMeaningfulContent(targetNote)) {
if (!isViewDead()) getView().closeNoteActivity();
return;
}

saveNote(
targetNote,
new NoteContract.AutoSaveCallback() {
Expand Down Expand Up @@ -391,14 +386,18 @@ private boolean hasMeaningfulContent(Note note) {
if (!note.getTitle().trim().isEmpty()) return true;
if (!note.getValue().trim().isEmpty()) return true;

// Attachments (extended editor too)
if (note.getAttachments() != null && !note.getAttachments().trim().isEmpty()) {
// Attachments (extended editor too). Every extended-editor change writes the parsed
// attachment list, so a note without attachments carries the string "[]" here — ask the
// note whether the list holds anything instead of whether the field was written.
if (note.isAttachments()) {
return true;
}

// Extended editor JSON
String json = note.getValueJson();
return json != null && !json.trim().isEmpty() && !json.equals("[]");
// Extended editor document: emptiness is decided by the parsed blocks. An untouched
// editor still serializes a document — an empty paragraph is enough — so comparing the
// string against "[]" made a note nobody typed into read as meaningful, saved it and
// counted it in the statistics.
return EditorDocument.hasContent(note.getValueJson());
}

@Override
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
package com.pasich.mynotes.extendedEditor.utils;

import static com.google.common.truth.Truth.assertThat;

import org.junit.Test;

/**
* What counts as an empty rich note.
*
* <p>Emptiness used to be "the serialized document is not the string {@code []}", so a note the
* user only opened — the editor writes one empty paragraph into it — was saved and counted in the
* statistics. The shapes below are the ones the editor actually produces for a document nobody
* typed into, plus the ones that must survive.
*/
public class EditorDocumentTest {

@Test
public void noDocumentAtAllIsEmpty() {
assertThat(EditorDocument.hasContent(null)).isFalse();
assertThat(EditorDocument.hasContent("")).isFalse();
assertThat(EditorDocument.hasContent(" ")).isFalse();
assertThat(EditorDocument.hasContent("[]")).isFalse();
}

@Test
public void singleEmptyParagraphIsEmpty() {
// The document an untouched extended editor serializes.
assertThat(
EditorDocument.hasContent(
"[{\"id\":\"a1\",\"type\":\"paragraph\",\"data\":{\"text\":\"\"}}]"))
.isFalse();
}

@Test
public void paragraphOfWhitespaceAndEmptyMarkupIsEmpty() {
assertThat(
EditorDocument.hasContent(
"[{\"type\":\"paragraph\",\"data\":{\"text\":\" \"}},"
+ "{\"type\":\"paragraph\",\"data\":{\"text\":\"<br>\"}},"
+ "{\"type\":\"paragraph\",\"data\":{\"text\":\"&nbsp;\"}},"
+ "{\"type\":\"paragraph\",\"data\":{\"text\":\"<b></b>\"}}]"))
.isFalse();
}

@Test
public void emptyHeaderAndEmptyListAreEmpty() {
assertThat(
EditorDocument.hasContent(
"[{\"type\":\"header\",\"data\":{\"text\":\"\",\"level\":2}},"
+ "{\"type\":\"list\",\"data\":{\"style\":\"unordered\",\"items\":[]}},"
+ "{\"type\":\"list\",\"data\":{\"style\":\"checklist\",\"items\":[{\"content\":\"\",\"meta\":{\"checked\":false}}]}}]"))
.isFalse();
}

@Test
public void typedTextIsContent() {
assertThat(
EditorDocument.hasContent(
"[{\"type\":\"paragraph\",\"data\":{\"text\":\"hello\"}}]"))
.isTrue();
}

@Test
public void textHiddenBehindMarkupIsContent() {
assertThat(
EditorDocument.hasContent(
"[{\"type\":\"paragraph\",\"data\":{\"text\":\"<b>bold</b>\"}}]"))
.isTrue();
}

@Test
public void filledListItemIsContent() {
assertThat(
EditorDocument.hasContent(
"[{\"type\":\"list\",\"data\":{\"style\":\"unordered\",\"items\":[{\"content\":\"\"},{\"content\":\"milk\"}]}}]"))
.isTrue();
}

@Test
public void nestedListItemIsContent() {
assertThat(
EditorDocument.hasContent(
"[{\"type\":\"list\",\"data\":{\"style\":\"unordered\",\"items\":[{\"content\":\"\",\"items\":[{\"content\":\"deep\"}]}]}}]"))
.isTrue();
}

@Test
public void attachmentAloneIsContent() {
// A note that is only a file has no text at all and must still be kept.
assertThat(
EditorDocument.hasContent(
"[{\"type\":\"paragraph\",\"data\":{\"text\":\"\"}},"
+ "{\"type\":\"attaches\",\"data\":{\"file\":{\"url\":\"editorjs://attachments/note_5/one.pdf\",\"name\":\"one.pdf\"}}}]"))
.isTrue();
}

@Test
public void imageAndGalleryBlocksAreContent() {
assertThat(
EditorDocument.hasContent(
"[{\"type\":\"image\",\"data\":{\"file\":{\"url\":\"editorjs://attachments/note_5/a.png\"}}}]"))
.isTrue();
assertThat(
EditorDocument.hasContent(
"[{\"type\":\"gallery\",\"data\":{\"files\":[{\"url\":\"editorjs://attachments/note_5/b.png\"}]}}]"))
.isTrue();
}

@Test
public void aToolWithNoTextOfItsOwnIsContent() {
// Nothing but an empty paragraph is written without the user asking for it, so a
// delimiter, a table, or a tool this build cannot read is kept.
assertThat(EditorDocument.hasContent("[{\"type\":\"delimiter\",\"data\":{}}]")).isTrue();
assertThat(
EditorDocument.hasContent(
"[{\"type\":\"table\",\"data\":{\"content\":[[\"a\",\"b\"]]}}]"))
.isTrue();
assertThat(EditorDocument.hasContent("[{\"type\":\"someFutureTool\",\"data\":{}}]"))
.isTrue();
}

@Test
public void unreadableDocumentIsKept() {
// Discarding a note we simply failed to parse would lose the user's data.
assertThat(EditorDocument.hasContent("{not json")).isTrue();
assertThat(EditorDocument.hasContent("{\"blocks\":[]}")).isTrue();
}
}
Loading
Loading