Add basic readability highlighting

Personally, I'm a terrible writer and I've found simple aids really help keep my prose tight.
These changes will highlight sentences that are hard to read, based on the number of syllables they contain.
Here's what happens based on syllable count:
- less than 25 syllables: its easy to read (heuristically speaking), and has no background colour
- between 25 and 35 syllables, it's a bit hard to understand, and has a yellow background colour
- over 35 syllables, its quite hard to read, and has a red background color

This might be well outside the scope of what you had in mind, but I personally find it usefull.
At the moment it's on by default, in a seperate observer.
Maybe you could add add a setting for it
This commit is contained in:
colugo 2019-08-02 03:00:29 +00:00 committed by William Brawner
parent 86edda5e24
commit a4d9a9b9d7
14 changed files with 378 additions and 54 deletions

4
.gitignore vendored
View file

@ -5,13 +5,9 @@
/.idea/libraries
.DS_Store
/build
IAP5Helper/build
/captures
.externalNativeBuild
.idea/
app/standard
app/samsung
*~
*.log
app/acra.properties
keystore.properties

38
.gitlab-ci.yml Normal file
View file

@ -0,0 +1,38 @@
image: openjdk:8-jdk
variables:
ANDROID_COMPILE_SDK: "28"
ANDROID_BUILD_TOOLS: "28.0.3"
ANDROID_SDK_TOOLS: "4333796"
before_script:
- export GRADLE_USER_HOME=cache/.gradle
- apt-get --quiet update --yes
- apt-get --quiet install --yes wget tar unzip lib32stdc++6 lib32z1
- wget --quiet --output-document=android-sdk.zip https://dl.google.com/android/repository/sdk-tools-linux-${ANDROID_SDK_TOOLS}.zip
- unzip -d android-sdk-linux android-sdk.zip
- echo y | android-sdk-linux/tools/bin/sdkmanager "platforms;android-${ANDROID_COMPILE_SDK}" >/dev/null
- echo y | android-sdk-linux/tools/bin/sdkmanager "platform-tools" >/dev/null
- echo y | android-sdk-linux/tools/bin/sdkmanager "build-tools;${ANDROID_BUILD_TOOLS}" >/dev/null
- export ANDROID_HOME=$PWD/android-sdk-linux
- export PATH=$PATH:$PWD/android-sdk-linux/platform-tools/
- chmod +x ./gradlew
# temporarily disable checking for EPIPE error and use yes to accept all licenses
- set +o pipefail
- yes | android-sdk-linux/tools/bin/sdkmanager --licenses
- set -o pipefail
stages:
- test
test:
stage: test
script:
- ./gradlew --stacktrace --console=plain :app:lintDebug jacocoTestReport
- cat app/build/reports/jacoco/jacocoTestReport/html/index.html
artifacts:
reports:
junit: app/build/test-results/testDebugUnitTest/TEST-*.xml
paths:
- app/build/reports/jacoco/jacocoTestReport/
- app/build/outputs/

View file

@ -1,6 +1,7 @@
# [Simple Markdown](https://wbrawner.com/portfolio/simple-markdown/)
[![Build Status](https://ci.wbrawner.com/job/Simple%20Markdown/badge/icon)](https://ci.wbrawner.com/job/Simple%20Markdown/)
[![pipeline status](https://gitlab.com/billybrawner/SimpleMarkdown/badges/master/pipeline.svg)](https://gitlab.com/billybrawner/SimpleMarkdown/commits/master)
[![coverage report](https://gitlab.com/billybrawner/SimpleMarkdown/badges/master/coverage.svg)](https://gitlab.com/billybrawner/SimpleMarkdown/commits/master)
Simple Markdown is simply a Markdown editor :) I wrote it to offer up an open source alternative to
the other Markdown editors available on the Play Store. I also wanted to get some practice in
@ -27,8 +28,7 @@ Using Android Studio is the preferred way to build the project. To build from th
### Crashlytics
SimpleMarkdown makes use of Firebase Crashlytics for error reporting. You'll need to follow the
[Get started with Firebase Crashlytics](https://firebase.google
.com/docs/crashlytics/get-started?platform=android) guide in order to build the project.
[Get started with Firebase Crashlytics](https://firebase.google.com/docs/crashlytics/get-started?platform=android) guide in order to build the project.
## Contributing

2
app/.gitignore vendored
View file

@ -1,4 +1,2 @@
/build
crashlytics.properties
*.apk
google-services.json

View file

@ -3,10 +3,20 @@ apply plugin: 'kotlin-android-extensions'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-kapt'
apply plugin: 'io.fabric'
apply plugin: 'jacoco'
def keystorePropertiesFile = rootProject.file("keystore.properties")
def keystoreProperties = new Properties()
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
try {
def keystorePropertiesFile = rootProject.file("keystore.properties")
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
} catch (FileNotFoundException e) {
logger.warn("Unable to load keystore properties. Automatic signing won't be available")
keystoreProperties['keyAlias'] = ""
keystoreProperties['keyPassword'] = ""
keystoreProperties['storeFile'] = File.createTempFile("temp", ".tmp").absolutePath
keystoreProperties['storePassword'] = ""
}
android {
configurations.all {
@ -44,6 +54,9 @@ android {
}
}
buildTypes {
debug {
testCoverageEnabled true
}
release {
minifyEnabled true
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
@ -96,9 +109,36 @@ dependencies {
implementation "androidx.core:core-ktx:1.0.2"
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation 'androidx.lifecycle:lifecycle-extensions:2.0.0'
implementation 'eu.crydee:syllable-counter:4.0.2'
}
apply plugin: 'com.google.gms.google-services'
repositories {
mavenCentral()
}
jacoco {
toolVersion = '0.8.0'
}
tasks.withType(Test) {
jacoco.includeNoLocationClasses = true
}
task jacocoTestReport(type: JacocoReport, dependsOn: ['testDebugUnitTest']) {
reports {
xml.enabled = true
html.enabled = true
}
def fileFilter = [ '**/R.class', '**/R$*.class', '**/BuildConfig.*', '**/Manifest*.*', '**/*Test*.*', 'android/**/*.*' ]
def javaDebugTree = fileTree(dir: "$project.buildDir/intermediates/javac/debug/compileDebugJavaWithJavac/classes", excludes: fileFilter)
def kotlinDebugTree = fileTree(dir: "$project.buildDir/tmp/kotlin-classes/debug", excludes: fileFilter)
def mainSrc = "$project.projectDir/src/main/java"
sourceDirectories = files([mainSrc])
classDirectories = files([javaDebugTree, kotlinDebugTree])
executionData = fileTree(dir: project.buildDir, includes: [
'jacoco/testDebugUnitTest.exec', 'outputs/code-coverage/connected/*coverage.ec'
])
}

78
app/google-services.json Normal file
View file

@ -0,0 +1,78 @@
{
"project_info": {
"project_number": "318641233555",
"firebase_url": "https://simplemarkdown.firebaseio.com",
"project_id": "simplemarkdown",
"storage_bucket": "simplemarkdown.appspot.com"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:318641233555:android:09d865cad87e3b5f",
"android_client_info": {
"package_name": "com.wbrawner.simplemarkdown"
}
},
"oauth_client": [
{
"client_id": "318641233555-83n2k1mqhokf0b7lhccqiva9pspgripq.apps.googleusercontent.com",
"client_type": 1,
"android_info": {
"package_name": "com.wbrawner.simplemarkdown",
"certificate_hash": "710e37c230689bc259fc6e2e032e2f56956f8d33"
}
},
{
"client_id": "318641233555-a5rm2cqqf66jc9j5nmg3ttepdt2iaeno.apps.googleusercontent.com",
"client_type": 3
}
],
"api_key": [
{
"current_key": "AIzaSyBDMcXg-10NsXLDKJRtj5WnXoHrwg3m9Os"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": [
{
"client_id": "318641233555-a5rm2cqqf66jc9j5nmg3ttepdt2iaeno.apps.googleusercontent.com",
"client_type": 3
}
]
}
},
"admob_app_id": "ca-app-pub-3319579963502409~4576405307"
},
{
"client_info": {
"mobilesdk_app_id": "1:318641233555:android:5dfb62206717437e",
"android_client_info": {
"package_name": "com.wbrawner.simplemarkdown.samsung"
}
},
"oauth_client": [
{
"client_id": "318641233555-a5rm2cqqf66jc9j5nmg3ttepdt2iaeno.apps.googleusercontent.com",
"client_type": 3
}
],
"api_key": [
{
"current_key": "AIzaSyBDMcXg-10NsXLDKJRtj5WnXoHrwg3m9Os"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": [
{
"client_id": "318641233555-a5rm2cqqf66jc9j5nmg3ttepdt2iaeno.apps.googleusercontent.com",
"client_type": 3
}
]
}
}
}
],
"configuration_version": "1"
}

View file

@ -0,0 +1,35 @@
package com.wbrawner.simplemarkdown.model;
import java.util.ArrayList;
import java.util.List;
public class Readability {
private String content;
private static final String DELIMS = ".!?\n";
public Readability(String content) {
this.content = content;
}
public List<Sentence> sentences() {
ArrayList<Sentence> list = new ArrayList<>();
int startOfSentance = 0;
StringBuilder lineBuilder = new StringBuilder();
for (int i = 0; i < content.length(); i++) {
String c = content.charAt(i) + "";
if (DELIMS.contains(c)) {
list.add(new Sentence(content, startOfSentance, i));
startOfSentance = i + 1;
lineBuilder = new StringBuilder();
} else {
lineBuilder.append(c);
}
}
String line = lineBuilder.toString();
if (!line.isEmpty()) list.add(new Sentence(content, startOfSentance, content.length()));
return list;
}
}

View file

@ -0,0 +1,48 @@
package com.wbrawner.simplemarkdown.model;
import eu.crydee.syllablecounter.SyllableCounter;
public class Sentence {
private String sentence = "";
private int start = 0;
private int end = 0;
private final static SyllableCounter sc = new SyllableCounter();
public Sentence(String content, int start, int end){
this.start = start;
this.end = end;
this.sentence = content.substring(start, end);
trimStart();
}
public Sentence(String sentence){
this.sentence = sentence;
}
private void trimStart() {
while(sentence.startsWith(" ")){
this.start++;
sentence = sentence.substring(1);
}
}
@Override
public String toString() {
return sentence;
}
public int start(){
return start;
}
public int end(){
return end;
}
public int syllableCount(){
return sc.count(sentence);
}
}

View file

@ -0,0 +1,59 @@
package com.wbrawner.simplemarkdown.utility;
import android.graphics.Color;
import android.text.SpannableString;
import android.text.style.BackgroundColorSpan;
import android.util.Log;
import android.widget.EditText;
import android.widget.TextView;
import com.wbrawner.simplemarkdown.model.Readability;
import com.wbrawner.simplemarkdown.model.Sentence;
import io.reactivex.Observer;
import io.reactivex.disposables.Disposable;
public class ReadabilityObserver implements Observer<String> {
private EditText text;
private String previousValue = "";
public ReadabilityObserver(EditText text) {
this.text = text;
}
@Override
public void onSubscribe(Disposable d) {
}
@Override
public void onNext(String markdown) {
long start = System.currentTimeMillis();
if (markdown.length() < 1) return;
if (previousValue.equals(markdown)) return;
Readability readability = new Readability(markdown);
SpannableString span = new SpannableString(markdown);
for (Sentence sentence : readability.sentences()) {
int color = Color.TRANSPARENT;
if (sentence.syllableCount() > 25) color = Color.argb(100, 229, 232, 42);
if (sentence.syllableCount() > 35) color = Color.argb(100, 193, 66, 66);
span.setSpan(new BackgroundColorSpan(color), sentence.start(), sentence.end(), 0);
}
text.setTextKeepState(span, TextView.BufferType.SPANNABLE);
previousValue = markdown;
long timeTakenMs = System.currentTimeMillis() - start;
Log.d("SimpleMarkdown", "Handled markdown in " + timeTakenMs + "ms");
}
@Override
public void onError(Throwable e) {
System.err.println("An error occurred while handling the markdown");
e.printStackTrace();
// TODO: report this?
}
@Override
public void onComplete() {
}
}

View file

@ -3,6 +3,7 @@ package com.wbrawner.simplemarkdown.view.fragment
import android.annotation.SuppressLint
import android.content.Context
import android.os.Bundle
import android.preference.PreferenceManager
import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View
@ -17,6 +18,7 @@ import com.wbrawner.simplemarkdown.MarkdownApplication
import com.wbrawner.simplemarkdown.R
import com.wbrawner.simplemarkdown.presentation.MarkdownPresenter
import com.wbrawner.simplemarkdown.utility.MarkdownObserver
import com.wbrawner.simplemarkdown.utility.ReadabilityObserver
import com.wbrawner.simplemarkdown.view.MarkdownEditView
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.schedulers.Schedulers
@ -48,6 +50,16 @@ class EditFragment : Fragment(), MarkdownEditView {
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
obs.subscribe(MarkdownObserver(presenter, obs))
val sharedPrefs = PreferenceManager.getDefaultSharedPreferences(requireContext())
val enableReadability = sharedPrefs.getBoolean(getString(R.string.readability_enabled), false)
if (enableReadability) {
val readabilityObserver = RxTextView.textChanges(markdownEditor!!)
.debounce(250, TimeUnit.MILLISECONDS)
.map { it.toString() }
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
readabilityObserver.subscribe(ReadabilityObserver(markdownEditor))
}
return view
}
@ -89,7 +101,7 @@ class EditFragment : Fragment(), MarkdownEditView {
}
override fun setMarkdown(markdown: String) {
markdownEditor!!.setText(markdown)
markdownEditor?.setText(markdown)
}
override fun setTitle(title: String) {

View file

@ -40,6 +40,10 @@
<string name="pref_title_error_reports">Enable automated error reports</string>
<string name="pref_error_reports_off">Error reports will not be sent</string>
<string name="pref_error_reports_on">Error reports will be sent</string>
<string name="readability_enabled">readability.enable</string>
<string name="pref_title_readability">Enable readability highlighting (experimental)</string>
<string name="pref_readability_off">Readability highlighting is off</string>
<string name="pref_readability_on">Readability highlighting is on</string>
<string name="pref_autosave_on">Files will automatically save</string>
<string name="pref_autosave_off">Files will not be automatically saved</string>
<string name="pref_custom_css">pref.custom_css</string>

View file

@ -23,5 +23,11 @@
android:summaryOff="@string/pref_error_reports_off"
android:summaryOn="@string/pref_error_reports_on"
android:title="@string/pref_title_error_reports" />
<SwitchPreference
android:defaultValue="false"
android:key="@string/readability_enabled"
android:summaryOff="@string/pref_readability_off"
android:summaryOn="@string/pref_readability_on"
android:title="@string/pref_title_readability" />
</PreferenceScreen>

View file

@ -0,0 +1,52 @@
package com.wbrawner.simplemarkdown;
import com.wbrawner.simplemarkdown.model.Readability;
import com.wbrawner.simplemarkdown.model.Sentence;
import eu.crydee.syllablecounter.SyllableCounter;
import org.junit.Test;
import java.util.List;
import static org.junit.Assert.assertEquals;
public class ReadabilityTest {
@Test
public void break_content_into_sentances() {
SyllableCounter sc = new SyllableCounter();
assertEquals(4, sc.count("facility"));
}
@Test
public void can_break_text_into_sentences_with_indexes(){
String content = "Hop on pop. I am a fish. This is a test.";
Readability readability = new Readability(content);
List<Sentence> sentenceList = readability.sentences();
assertEquals(3, sentenceList.size());
Sentence hopOnPop = sentenceList.get(0);
assertEquals(hopOnPop.toString(), "Hop on pop");
assertEquals(0, hopOnPop.start());
assertEquals(10, hopOnPop.end());
Sentence iAmAFish = sentenceList.get(1);
assertEquals(iAmAFish.toString(), "I am a fish");
assertEquals(12, iAmAFish.start());
assertEquals(23, iAmAFish.end());
Sentence thisIsATest = sentenceList.get(2);
assertEquals(thisIsATest.toString(), "This is a test");
assertEquals(25, thisIsATest.start());
assertEquals(39, thisIsATest.end());
}
@Test
public void get_syllable_count_for_sentence(){
assertEquals(8, new Sentence("This is the song that never ends").syllableCount());
assertEquals(10, new Sentence("facility facility downing").syllableCount());
}
}

View file

@ -15,9 +15,7 @@ import org.robolectric.RobolectricTestRunner;
import org.robolectric.RuntimeEnvironment;
import java.io.File;
import java.io.IOException;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
@ -42,46 +40,6 @@ public class UtilsTest {
rmdir(new File(rootDir));
}
@Test
public void getDocsPath() throws Exception {
sharedPreferences.edit().putString(Constants.KEY_DOCS_PATH, rootDir).apply();
assertEquals(rootDir, Utils.getDocsPath(context));
}
@Test
public void getDefaultFileName() throws Exception {
sharedPreferences.edit().putString(Constants.KEY_DOCS_PATH, rootDir).apply();
new File(rootDir, "dummy.md").createNewFile();
new File(rootDir, "dummy1.md").createNewFile();
new File(rootDir, "Untitled-a.md").createNewFile();
String firstDefaultName = Utils.getDefaultFileName(context);
assertEquals("Untitled.md", firstDefaultName);
File firstFile = new File(rootDir, firstDefaultName);
firstFile.createNewFile();
String secondDefaultName = Utils.getDefaultFileName(context);
assertEquals("Untitled-1.md", secondDefaultName);
File secondFile = new File(rootDir, secondDefaultName);
secondFile.createNewFile();
String thirdDefaultName = Utils.getDefaultFileName(context);
assertEquals("Untitled-2.md", thirdDefaultName);
}
@Test
public void getDefaultFileNameDoubleDigitTest() throws IOException {
sharedPreferences.edit().putString(Constants.KEY_DOCS_PATH, rootDir).apply();
for (int i = 0; i < 11; i++) {
new File(rootDir, "Untitled-" + i + ".md").createNewFile();
}
assertTrue(new File(rootDir, "Untitled-10.md").exists());
String defaultName = Utils.getDefaultFileName(context);
assertEquals("Untitled-11.md", defaultName);
}
@Test
public void isAutosaveEnabled() throws Exception {
assertTrue(Utils.isAutosaveEnabled(context));