diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/README.md b/README.md new file mode 100644 index 0000000..1be41c4 --- /dev/null +++ b/README.md @@ -0,0 +1,24 @@ +This is a simple UI to manage notes on your desktop. +While there are many Notes taking Apps out there, I didn't find anything simple enough on the desktop. + +*Features:* + +- Simple UI. Fire up the App and start writing plain text notes + - Cross Platform. Written with JavaFX and runs on Windows,Mac and Linux + - Attach arbitrary files to a Note + - Include notes to each attachment + - Note Text, Attachment file names and attachment notes are all searchable + - You do not need Java installed on your desktop to run it (See Below) + +*Internals:* + +It's written in Kotlin. Uses Java FX, JDK11 and Lucene internally. In order to build it, you'd need JDK 11 installed. +Build Steps: + - Install JDK 11 in a directory (eg c:\jdk11) + - set JAVA_HOME=c:\jdk11 + - set PATH=%JAVA_HOME%/bin;%PATH% + - gradlew runtime + - the runnable image is built in build\image + +Running the Image: + - Once built, simply execute run-cmd.bat from Windows command line OR goto download [prebuilt zip file](https://github.com/praveenray/my-notes/releases/tag/1.0.0) and follow instruction to download and run the prebuilt zip file. diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..b7e9f69 --- /dev/null +++ b/build.gradle @@ -0,0 +1,102 @@ +plugins { + id 'org.jetbrains.kotlin.jvm' version "1.5.0" + id "org.jetbrains.kotlin.plugin.allopen" version "1.5.0" + id ("org.openjfx.javafxplugin") version "0.0.9" + id 'org.beryx.runtime' version '1.11.3' +} + + + +repositories { + mavenCentral() + jcenter() +} + + + +javafx { + version = "13" + modules = ["javafx.controls", "javafx.fxml", "javafx.graphics", "javafx.web"] +} + +dependencies { + implementation group: 'org.slf4j', name: 'slf4j-api', version: '1.7.30' + + implementation "com.fasterxml.jackson.datatype:jackson-datatype-jdk8:2.12.0" + implementation "com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.12.0" + implementation "com.fasterxml.jackson.module:jackson-module-kotlin" + implementation enforcedPlatform("${quarkusPlatformGroupId}:${quarkusPlatformArtifactId}:${quarkusPlatformVersion}") + implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk8' + implementation group: 'com.google.inject', name: 'guice', version: '4.2.3' + implementation 'org.apache.commons:commons-exec:1.3' + + implementation("org.openjfx:javafx-base") + implementation("org.openjfx:javafx-graphics") + implementation("org.openjfx:javafx-fxml") + implementation("com.jfoenix:jfoenix:9.0.10") + implementation("org.controlsfx:controlsfx:11.0.3") + implementation "org.apache.lucene:lucene-core:8.7.0" + implementation "org.apache.lucene:lucene-queryparser:8.7.0" + implementation "org.apache.lucene:lucene-analyzers-common:8.7.0" + implementation platform('org.kordamp.ikonli:ikonli-bom:12.2.0') + implementation 'org.kordamp.ikonli:ikonli-javafx' + implementation 'org.kordamp.ikonli:ikonli-fontawesome5-pack' + + implementation files('libs/scenicview.jar') + + testImplementation 'io.rest-assured:kotlin-extensions' +} + +group 'my-groupId' +version 'my-version' + +allOpen { + annotation("javax.ws.rs.Path") + annotation("javax.enterprise.context.ApplicationScoped") + annotation("io.quarkus.test.junit.QuarkusTest") +} + +java { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 +} + +compileKotlin { + kotlinOptions.jvmTarget = JavaVersion.VERSION_11 + kotlinOptions.javaParameters = true +} + +compileTestKotlin { + kotlinOptions.jvmTarget = JavaVersion.VERSION_11 +} +test { + systemProperty "java.util.logging.manager", "org.jboss.logmanager.LogManager" +} + +runtime { + options = ['--strip-debug', '--compress', '2', '--no-header-files', '--no-man-pages', '--bind-services'] + imageZip = file("$buildDir/my-notes.zip") + launcher { + noConsole = true + } + + jpackage { + def currentOs = org.gradle.internal.os.OperatingSystem.current() + def imgType = currentOs.windows ? 'ico' : currentOs.macOsX ? 'icns' : 'png' + imageOptions += ['--icon', "src/main/resources/hellofx.$imgType"] + installerOptions += ['--resource-dir', "src/main/resources"] + installerOptions += ['--vendor', 'BRC'] + if(currentOs.windows) { + installerOptions += ['--win-per-user-install', '--win-dir-chooser', '--win-menu', '--win-shortcut'] + } + else if (currentOs.linux) { + installerOptions += ['--linux-package-name', 'hellofx','--linux-shortcut'] + } + else if (currentOs.macOsX) { + installerOptions += ['--mac-package-name', 'hellofx'] + } + } + } + +mainClassName = 'com.praveenray.notes.Launcher' +applicationName = 'notes' diff --git a/gradle.properties b/gradle.properties new file mode 100755 index 0000000..2b2728c --- /dev/null +++ b/gradle.properties @@ -0,0 +1,8 @@ +#Gradle properties +#Wed Dec 09 22:16:21 EST 2020 +quarkusPluginVersion=1.9.2.Final +quarkusPlatformArtifactId=quarkus-universe-bom +quarkusPlatformGroupId=io.quarkus +quarkusPlatformVersion=1.9.2.Final +org.gradle.logging.level=INFO +org.gradle.jvmargs=-Xmx5g diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..f3d88b1 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100755 index 0000000..84878c3 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Mon Dec 21 17:05:54 EST 2020 +distributionUrl=https\://services.gradle.org/distributions/gradle-6.1-all.zip +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStorePath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..2fe81a7 --- /dev/null +++ b/gradlew @@ -0,0 +1,183 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=`expr $i + 1` + done + case $i in + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=`save "$@"` + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100755 index 0000000..9618d8d --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,100 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windows variants + +if not "%OS%" == "Windows_NT" goto win9xME_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/libs/scenicview.jar b/libs/scenicview.jar new file mode 100755 index 0000000..cd5c40d Binary files /dev/null and b/libs/scenicview.jar differ diff --git a/run.cmd b/run.cmd new file mode 100755 index 0000000..a2ffdfd --- /dev/null +++ b/run.cmd @@ -0,0 +1 @@ +start /b build\image\bin\notes --app.data.dir=data \ No newline at end of file diff --git a/scenicView.properties b/scenicView.properties new file mode 100644 index 0000000..558ded9 --- /dev/null +++ b/scenicView.properties @@ -0,0 +1,19 @@ +#ScenicView properties +#Thu Jun 03 20:55:56 EDT 2021 +stageWidth=800.0 +showBaseline=false +ignoreMouseTransparentNodes=true +splitPaneDividerPosition=0.27380952380952384 +showNodesIdInTree=false +showDefaultProperties=true +showCSSProperties=false +collapseControls=true +showSearchBar=true +showFilteredNodesInTree=true +autoRefreshStyleSheets=false +showBounds=true +collapseContainerControls=false +automaticScenegraphStructureRefreshing=true +registerShortcuts=true +stageHeight=800.0 +showInvisibleNodes=false diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..29591e8 --- /dev/null +++ b/settings.gradle @@ -0,0 +1,6 @@ +pluginManagement { + repositories { + gradlePluginPortal() + } +} +rootProject.name='notes' \ No newline at end of file diff --git a/src/main/java/com/praveenray/notes/ApplicationMain.java b/src/main/java/com/praveenray/notes/ApplicationMain.java new file mode 100755 index 0000000..7f9281a --- /dev/null +++ b/src/main/java/com/praveenray/notes/ApplicationMain.java @@ -0,0 +1,19 @@ +package com.praveenray.notes; + +import javafx.application.Application; +import javafx.scene.image.Image; +import javafx.stage.Stage; + +public class ApplicationMain extends Application { + @Override + public void start(Stage primaryStage) { + Parameters params = getParameters(); + primaryStage.getIcons().add(new Image("/icons/app_icon.png")); + (new ApplicationMainKot(params)).start(primaryStage); + } + + public static void appMain(String[] args) { + launch(args); + } +} + diff --git a/src/main/java/com/praveenray/notes/Launcher.java b/src/main/java/com/praveenray/notes/Launcher.java new file mode 100755 index 0000000..f92f40e --- /dev/null +++ b/src/main/java/com/praveenray/notes/Launcher.java @@ -0,0 +1,7 @@ +package com.praveenray.notes; + +public class Launcher { + public static void main(String[] args) { + ApplicationMain.appMain(args); + } +} diff --git a/src/main/kotlin/com/praveenray/notes/ApplicationMainKot.kt b/src/main/kotlin/com/praveenray/notes/ApplicationMainKot.kt new file mode 100755 index 0000000..cde6c53 --- /dev/null +++ b/src/main/kotlin/com/praveenray/notes/ApplicationMainKot.kt @@ -0,0 +1,90 @@ +package com.praveenray.notes + +import com.google.inject.AbstractModule +import com.google.inject.Guice +import com.google.inject.Injector +import com.google.inject.Provider +import com.google.inject.name.Names +import com.praveenray.notes.models.AppEventBus +import com.praveenray.notes.models.ChangeSceneForStage +import com.praveenray.notes.ui.FXUtils +import com.praveenray.notes.ui.SCENES +import javafx.application.Application +import javafx.stage.Stage +import java.nio.file.Files +import java.nio.file.Paths +import java.nio.file.StandardOpenOption +import java.util.Properties +import javax.inject.Inject + +class ApplicationMainKot(private val params: Application.Parameters? = null) { + fun start(primaryStage: Stage) { + primaryStage.title = "My Notes" + val injector = Guice.createInjector(AppConfigModule(params)) + val eventBus = injector.getInstance(AppEventBus::class.java) + val fxUtils = injector.getInstance(FXUtils::class.java) + fxUtils.createNewScene(SCENES.LAYOUT, primaryStage) + primaryStage.isResizable = true; + primaryStage.show() + + eventBus.post(ChangeSceneForStage(SCENES.SEARCH, primaryStage)) + } +} + +class AppConfigModule(val params: Application.Parameters?) : AbstractModule() { + override fun configure() { + bind(FXUtils::class.java).toProvider(FXUtilsProvider::class.java) + + loadProperties().let { props -> + if (props.size > 0) { + Names.bindProperties(binder(), props) + } + } + } + + private fun loadProperties(): Properties { + val dbg = Paths.get("debug.txt") + if (Files.exists(dbg)) Files.delete(dbg) + Files.write(dbg, "starting\n".toByteArray(), StandardOpenOption.CREATE) + + val props = Properties() + this.javaClass.getResourceAsStream("/application.properties").use { props.load(it) } + if (params != null) { + val rawParams = parseCommandLineArgs(params.raw) + val profile = rawParams["profile"] + val propsResource = "/application-$profile.properties" + try { + javaClass.getResourceAsStream(propsResource)?.use { props.load(it) } + } catch (e: Exception) { + println("The resource $propsResource is not found or not parseable") + } + rawParams.minus("profile").forEach { (key, value) -> props[key] = value } + } + return props + } + + private fun parseCommandLineArgs(args: List): Map { + return args.associate { argument -> + val arg = argument.trim().replaceFirst(Regex("^--"), "") + if (arg.contains("=")) { + val tokens = arg.split("=").map { it.trim() } + Pair(tokens[0], tokens[1]) + } else { + Pair(arg, "true") + } + } + } +} + +class FXUtilsProvider : Provider { + @Inject + lateinit var injector: Injector + override fun get(): FXUtils { + return FXUtils(injector) + } +} + +/* +set CLASSPATH=%CLASSPATH%;%DIRNAME% +echo %CLASSPATH% +*/ \ No newline at end of file diff --git a/src/main/kotlin/com/praveenray/notes/lib/JacksonMapper.kt b/src/main/kotlin/com/praveenray/notes/lib/JacksonMapper.kt new file mode 100755 index 0000000..efb8479 --- /dev/null +++ b/src/main/kotlin/com/praveenray/notes/lib/JacksonMapper.kt @@ -0,0 +1,46 @@ +package com.praveenray.notes.lib + +import com.fasterxml.jackson.core.type.TypeReference +import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.KotlinModule +import java.nio.file.Path +import javax.inject.Singleton + +@Singleton +class JacksonMapper: ObjectMapper() { + init { + registerModule(KotlinModule()) + findAndRegisterModules() + configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + } + + fun readTree(file: Path): JsonNode { + return this.readTree(file.toFile()) ?: throw IllegalArgumentException("File $file is not readable as json") + } + + fun toJsonString(obj: Any?) = if (obj == null) null else this.writeValueAsString(obj) + fun toJsonPrettyString(obj: Any?): String? { + return if (obj != null) { + this.writerWithDefaultPrettyPrinter().writeValueAsString(obj) + } else null + } + + fun parseToJsonNode(jsonString: String) = this.readTree(jsonString) + + fun parseToObject(jsonString: String, valueType: Class): T { + return this.readValue(jsonString, valueType) + } + + fun parseToMap(jsonString: String): Map { + return readValue(jsonString) + } + + fun writeToFile(jsonObj: Any, file: Path) { + this.writeValue(file.toFile(), jsonObj) + } + + inline fun readValue(content: String): T = readValue(content, object: TypeReference(){}) + fun readValue(src: Path): T = readValue(src.toFile(), object: TypeReference(){}) +} \ No newline at end of file diff --git a/src/main/kotlin/com/praveenray/notes/lib/SystemUtils.kt b/src/main/kotlin/com/praveenray/notes/lib/SystemUtils.kt new file mode 100644 index 0000000..00f43ff --- /dev/null +++ b/src/main/kotlin/com/praveenray/notes/lib/SystemUtils.kt @@ -0,0 +1,11 @@ +package com.praveenray.notes.lib + +import java.nio.file.Paths +import java.util.* +import javax.inject.Singleton + +@Singleton +class SystemUtils { + fun currentDir() = Paths.get(System.getProperty("user.dir")).toAbsolutePath() + fun tempDir() = currentDir().resolve(UUID.randomUUID().toString()) +} \ No newline at end of file diff --git a/src/main/kotlin/com/praveenray/notes/lib/Try.kt b/src/main/kotlin/com/praveenray/notes/lib/Try.kt new file mode 100644 index 0000000..220307a --- /dev/null +++ b/src/main/kotlin/com/praveenray/notes/lib/Try.kt @@ -0,0 +1,65 @@ +package com.praveenray.notes.lib + +sealed class Try { + + companion object { + operator fun invoke(body: () -> T): Try { + return try { + Success(body()) + } catch (e: Exception) { + Failure(e) + } + } + } + + abstract fun isSuccess(): Boolean + + abstract fun isFailure(): Boolean + + fun map(f: (T) -> U): Try { + return when (this) { + is Success -> Try { + f(this.value) + } + is Failure -> this as Failure + } + } + + fun flatMap(f: (T) -> Try): Try { + return when (this) { + is Success -> f(this.value) + is Failure -> this as Failure + } + } + + abstract fun get(): T + + abstract fun getOrElse(default: @UnsafeVariance T): T + + abstract fun orElse(default: Try<@UnsafeVariance T>): Try + + abstract fun fold(fa: (Throwable) -> U, fb: (T) -> U): U + +} + +data class Success(val value: T) : Try() { + override fun isSuccess(): Boolean = true + override fun isFailure(): Boolean = false + override fun getOrElse(default: @UnsafeVariance T): T = value + override fun get() = value + override fun orElse(default: Try<@UnsafeVariance T>): Try = this + override fun fold(fa: (Throwable) -> U, fb: (T) -> U): U = try { + fb(value) + } catch (e: Exception) { + fa(e) + } +} + +data class Failure(val e: Throwable) : Try() { + override fun isSuccess(): Boolean = false + override fun isFailure(): Boolean = true + override fun getOrElse(default: @UnsafeVariance T): T = default + override fun get(): T = throw e + override fun orElse(default: Try<@UnsafeVariance T>): Try = default + override fun fold(fa: (Throwable) -> U, fb: (T) -> U): U = fa(e) +} \ No newline at end of file diff --git a/src/main/kotlin/com/praveenray/notes/models/Events.kt b/src/main/kotlin/com/praveenray/notes/models/Events.kt new file mode 100755 index 0000000..4729af0 --- /dev/null +++ b/src/main/kotlin/com/praveenray/notes/models/Events.kt @@ -0,0 +1,13 @@ +package com.praveenray.notes.models + +import com.google.common.eventbus.EventBus +import com.praveenray.notes.ui.SCENES +import javafx.stage.Stage +import java.util.EventObject +import javax.inject.Singleton + +sealed class EventBase(val params: Any? = null) +class ChangeScene(scene: SCENES, event: EventObject, params: Any? = null): EventBase(Triple(scene, event, params)) +class ChangeSceneForStage(scene: SCENES, stage: Stage, params: Any? = null): EventBase(Triple(scene, stage, params)) +@Singleton +class AppEventBus: EventBus() \ No newline at end of file diff --git a/src/main/kotlin/com/praveenray/notes/models/Note.kt b/src/main/kotlin/com/praveenray/notes/models/Note.kt new file mode 100644 index 0000000..e487fc4 --- /dev/null +++ b/src/main/kotlin/com/praveenray/notes/models/Note.kt @@ -0,0 +1,28 @@ +package com.praveenray.notes.models + +import com.fasterxml.jackson.annotation.JsonFormat +import java.net.URL +import java.nio.file.Path +import java.time.LocalDate +import java.util.* + +data class Note( + val id: String? = null, + val description: String, + val tags: List = emptyList(), + val attachments: List = emptyList(), + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd") + val createDate: LocalDate = LocalDate.now(), + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd") + val modifyDate: LocalDate = LocalDate.now() +) { + companion object { + fun createID() = UUID.randomUUID().toString() + } +} + +data class NoteAttachment( + val httpUrl: URL? = null, + val filePath: Path? = null, + val note: String? = null, +) \ No newline at end of file diff --git a/src/main/kotlin/com/praveenray/notes/models/NoteSearchResult.kt b/src/main/kotlin/com/praveenray/notes/models/NoteSearchResult.kt new file mode 100644 index 0000000..2977684 --- /dev/null +++ b/src/main/kotlin/com/praveenray/notes/models/NoteSearchResult.kt @@ -0,0 +1,7 @@ +package com.praveenray.notes.models + +data class NoteSearchResult( + val notes: List, + val count: Int, + val millisTaken: Long +) \ No newline at end of file diff --git a/src/main/kotlin/com/praveenray/notes/models/SearchParams.kt b/src/main/kotlin/com/praveenray/notes/models/SearchParams.kt new file mode 100644 index 0000000..31ba192 --- /dev/null +++ b/src/main/kotlin/com/praveenray/notes/models/SearchParams.kt @@ -0,0 +1,7 @@ +package com.praveenray.notes.models + +data class SearchParams( + val description: String = "", + val phraseSearch: Boolean = true, + val tags: List = emptyList(), +) \ No newline at end of file diff --git a/src/main/kotlin/com/praveenray/notes/service/LuceneSearch.kt b/src/main/kotlin/com/praveenray/notes/service/LuceneSearch.kt new file mode 100644 index 0000000..c54b148 --- /dev/null +++ b/src/main/kotlin/com/praveenray/notes/service/LuceneSearch.kt @@ -0,0 +1,278 @@ +package com.praveenray.notes.service + +import com.praveenray.notes.lib.Failure +import com.praveenray.notes.lib.Success +import com.praveenray.notes.lib.SystemUtils +import com.praveenray.notes.lib.Try +import com.praveenray.notes.models.Note +import com.praveenray.notes.models.NoteAttachment +import org.apache.lucene.analysis.en.EnglishAnalyzer +import org.apache.lucene.document.* +import org.apache.lucene.index.* +import org.apache.lucene.queryparser.classic.QueryParser +import org.apache.lucene.search.* +import org.apache.lucene.store.Directory +import org.apache.lucene.store.MMapDirectory +import org.slf4j.LoggerFactory +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths +import java.time.Instant +import java.time.ZoneId +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton + +@Singleton +class LuceneSearch @Inject constructor( + val systemUtils: SystemUtils, + val noteDB: NoteDB, + @Named("app.data.directory") private val dataDirectory: String, + @Named("app.lucene.index.recreate") private val recreateLuceneIndex: Boolean, + @Named("app.lucene.search.phraseSlop") private val phraseSlop: Int, + @Named("app.max_attachments_count") private val maxAttachmentsCount: Int, +) { + companion object { + private val DESC_FIELD = "description" + private val TAG_FIELD = "tag" + private val ID_FIELD = "id" + private val ATTACHMENT_COUNT_FIELD = "attach-count" + private val ATTACHMENT_FILE_FIELD_PREFIX = "attach-file" + private val ATTACHMENT_NOTE_FIELD_PREFIX = "attach-note" + private val CREATED_DATE_FIELD = "createDate" + private val MODIFIED_DATE_FIELD = "modifiedDate" + private val logger = LoggerFactory.getLogger(LuceneSearch::class.java) + } + var noteLuceneIndex: Directory? = null + + init { + val dataDirectoryPath = Paths.get(dataDirectory) + val dataDir = if (dataDirectoryPath.isAbsolute) dataDirectoryPath else systemUtils.currentDir().resolve(dataDirectory) + val indexDirectory = dataDir.resolve("lucene-index") + val recreating = if (recreateLuceneIndex) { + logger.warn("Deleting index Directory $indexDirectory. To Disable set app.lucene.index.recreate=false") + if (Files.exists(indexDirectory)) { + indexDirectory.toFile().deleteRecursively() + } + val attachmentsDir = dataDir.resolve(noteDB.attachmentsDirName) + if (Files.exists(attachmentsDir)) { + attachmentsDir.toFile().deleteRecursively() + } + true + } else false + if (!Files.exists(indexDirectory)) { + Files.createDirectories(indexDirectory) + } + noteLuceneIndex = if (recreating) { + val notes = noteDB.getNotesFromDB() + val dir = createIndexOfNotes(notes, indexDirectory) + logger.info("Indexed ${notes.size} Notes from notes.json at $noteLuceneIndex") + dir + } else openIndexDirectory(indexDirectory) + } + + fun uniqueTags(): List { + val query = WildcardQuery(Term("tag", "*")) + return when (val tagValues = Try { + DirectoryReader.open(noteLuceneIndex).use { indexReader -> + val searcher = IndexSearcher(indexReader) + val tagSet = setOf("tag") + searcher.search(query, 1000).scoreDocs.map(ScoreDoc::doc).map { docId -> + val doc = searcher.doc(docId, tagSet) + doc.getFields("tag").map(IndexableField::stringValue) + }.flatten() + }.toSet().filter { !it.isNullOrBlank() } + }) { + is Success -> tagValues.value.sorted() + is Failure -> { + logger.warn("Failed to retrieve Tags: ${tagValues.e.message}") + emptyList() + } + } + } + + private fun openIndexDirectory(indexDirectory: Path) = MMapDirectory(indexDirectory) + + private fun createIndexOfNotes(notes: List, indexDirectory: Path = systemUtils.currentDir()): Directory { + val directory = openIndexDirectory(indexDirectory) + createIndexWriter(directory).use { writer -> + notes.forEach { writeNoteToWriter(writer, it, false) } + } + return directory + } + + fun writeNoteToIndex(note: Note, isUpdate: Boolean) { + createIndexWriter(noteLuceneIndex!!).use { writeNoteToWriter(it, note, isUpdate) } + noteDB.writeAttachmentsForNote(note) + } + + fun deleteNote(note: Note) { + createIndexWriter(noteLuceneIndex!!).use { index -> + index.deleteDocuments(Term(ID_FIELD, note.id)) + val attachDir = noteDB.defaultAttachmentsDir().resolve(note.id) + if (Files.isDirectory(attachDir)) { + attachDir.toFile().deleteRecursively() + } + } + } + + private fun createIndexWriter(directory: Directory): IndexWriter { + val indexWriterConfig = IndexWriterConfig(EnglishAnalyzer()) + return IndexWriter(directory, indexWriterConfig) + } + + private fun writeNoteToWriter(writer: IndexWriter, note: Note, isUpdate: Boolean) { + val doc = Document() + doc.add(TextField(DESC_FIELD, note.description, Field.Store.YES)) + doc.add(StringField(ID_FIELD, note.id, Field.Store.YES)) + note.tags.forEach { tag -> + doc.add(StringField(TAG_FIELD, tag, Field.Store.YES)) + } + val epoch = note.createDate.atStartOfDay().toEpochSecond( + ZoneId.systemDefault().rules.getOffset(Instant.now()) + ) + val tsfield = if (isUpdate) MODIFIED_DATE_FIELD else CREATED_DATE_FIELD + doc.add(LongPoint(tsfield, epoch)) + doc.add(StringField(ATTACHMENT_COUNT_FIELD, note.attachments.size.toString(), Field.Store.YES)) + note.attachments.forEachIndexed { index, attach -> + if (attach.filePath != null) { + doc.add(TextField("${ATTACHMENT_FILE_FIELD_PREFIX}_$index", attach.filePath.fileName.toString(), Field.Store.YES)) + if (!attach.note.isNullOrBlank()) { + doc.add(TextField("${ATTACHMENT_NOTE_FIELD_PREFIX}_$index", attach.note, Field.Store.YES)) + } + } + } + if (isUpdate) { + writer.updateDocument(Term(ID_FIELD, note.id), doc) + } else { + writer.addDocument(doc) + } + } + + private fun convertDescriptionToHTML(desc: String): String { + val re = Regex("[\n\r]+") + return desc.split(re).joinToString("
") + } + + fun searchForDescription(desc: String?, isPhraseSearch: Boolean, tags: List = emptyList()): Try> { + if (noteLuceneIndex == null) { + throw IllegalStateException("note index has not been initialized") + } + + logger.info("Lucene searching for desc [$desc] and tags: $tags") + return Try { + when (val descQueryAttempt = createQueryFromDescription(desc, isPhraseSearch)) { + is Success -> { + val tagsQuery = createTagsQuery(tags) + val descQuery = descQueryAttempt.value + val query = combineQueries(descQuery, tagsQuery) + runFinalQuery(query) + } + is Failure -> { + throw IllegalArgumentException("Error with Query") + } + } + } + } + + private fun combineQueries(descQuery: BooleanQuery?, tagsQuery: BooleanQuery?) = when { + (descQuery != null && tagsQuery != null) -> { + BooleanQuery.Builder().add(descQuery, BooleanClause.Occur.MUST) + .add(tagsQuery, BooleanClause.Occur.MUST) + .build() + } + (descQuery != null) -> descQuery + (tagsQuery != null) -> tagsQuery + else -> MatchAllDocsQuery() + } + + private fun runFinalQuery(query: Query): List { + return DirectoryReader.open(noteLuceneIndex).use { indexReader -> + val indexSearcher = IndexSearcher(indexReader) + val collector = TopScoreDocCollector.create(10_000, 100) + indexSearcher.search(query, collector) + val docs = collector.topDocs().scoreDocs.map { indexSearcher.doc(it.doc) }.map { doc -> + val tagValues = doc.getFields(TAG_FIELD).map { it.stringValue().trim() }.filter { !it.isNullOrBlank() } + val description = doc.getField(DESC_FIELD).stringValue() + val id = doc.getField(ID_FIELD).stringValue() + Note( + id = id, + description = description, + tags = tagValues, + attachments = readNoteAttachmentsFromIndex(id, doc) + ) + } + logger.info("Lucene search found ${docs.size} documents") + docs + } + } + + private fun createTagsQuery(tags: List): BooleanQuery? { + return if (tags.isNotEmpty()) { + BooleanQuery.Builder().let { bldr -> + tags.map { TermQuery(Term(TAG_FIELD, it)) }.forEach { + bldr.add(it, BooleanClause.Occur.MUST) + } + bldr.build() + } + } else null + } + + private fun createQueryFromDescription(desc: String?, isPhraseSearch: Boolean): Try { + val english = EnglishAnalyzer() + val attachmentRange = (0 until maxAttachmentsCount) + val queryParser = QueryParser(DESC_FIELD, english) + return Try { + when { + desc.isNullOrBlank() -> null + isPhraseSearch -> { + val descQ = queryParser.createPhraseQuery(DESC_FIELD, desc, phraseSlop) + val noteQs = attachmentRange.map { index -> + queryParser.createPhraseQuery("${ATTACHMENT_NOTE_FIELD_PREFIX}_$index", desc, phraseSlop) + } + val nameQs = attachmentRange.map { index -> + queryParser.createPhraseQuery("${ATTACHMENT_FILE_FIELD_PREFIX}_$index", desc, phraseSlop) + } + + BooleanQuery.Builder().let { bldr -> +// bldr.add(descQ, BooleanClause.Occur.MUST) + noteQs.plus(nameQs).plus(descQ).forEach { bldr.add(it, BooleanClause.Occur.SHOULD) } + bldr.build() + } + } + else -> { + val descQ = queryParser.parse(desc) + val noteQs = attachmentRange.map { index -> + val noteParser = QueryParser("${ATTACHMENT_NOTE_FIELD_PREFIX}_$index", english) + noteParser.parse(desc) + } + + val nameQs = attachmentRange.map { index -> + val nameParser = QueryParser("${ATTACHMENT_FILE_FIELD_PREFIX}_$index", english) + nameParser.parse(desc) + } + + BooleanQuery.Builder().let { bldr -> + bldr.add(descQ, BooleanClause.Occur.MUST) + noteQs.plus(nameQs).forEach { bldr.add(it, BooleanClause.Occur.SHOULD) } + bldr.build() + } + } + } + } + } + + private fun readNoteAttachmentsFromIndex(noteID: String, doc: Document): List { + val sizeField = doc.getField(ATTACHMENT_COUNT_FIELD).stringValue() + return if (!sizeField.isNullOrBlank()) { + val size = sizeField.toInt() + (0 until size).mapNotNull { index -> + val filename = doc.getField("${ATTACHMENT_FILE_FIELD_PREFIX}_$index")?.stringValue() + if (!filename.isNullOrBlank()) { + val note = doc.getField("${ATTACHMENT_NOTE_FIELD_PREFIX}_$index")?.stringValue() + noteDB.createNoteAttachment(noteID, note, filename) + } else null + } + } else emptyList() + } +} diff --git a/src/main/kotlin/com/praveenray/notes/service/NoteDB.kt b/src/main/kotlin/com/praveenray/notes/service/NoteDB.kt new file mode 100644 index 0000000..ab49d2b --- /dev/null +++ b/src/main/kotlin/com/praveenray/notes/service/NoteDB.kt @@ -0,0 +1,132 @@ +package com.praveenray.notes.service + +import com.praveenray.notes.lib.JacksonMapper +import com.praveenray.notes.lib.SystemUtils +import com.praveenray.notes.models.Note +import com.praveenray.notes.models.NoteAttachment +import org.slf4j.LoggerFactory +import java.io.IOException +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths +import java.nio.file.StandardCopyOption +import java.nio.file.StandardOpenOption +import java.util.UUID +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton +import kotlin.streams.toList + +@Singleton +class NoteDB @Inject constructor( + val systemUtils: SystemUtils, + val jackson: JacksonMapper, + @Named("app.data.directory") private val dataDirectory: String, +) { + private val logger = LoggerFactory.getLogger(NoteDB::class.java) + + val attachmentsDirName = "attachments" + + fun getNoteJsonFile(): Path { + val dataDirectoryPath = Paths.get(dataDirectory) + val dataDir = if (dataDirectoryPath.isAbsolute) dataDirectoryPath else systemUtils.currentDir().resolve(dataDirectory) + return dataDir.resolve("notes.json") + } + + fun getNotesFromDB(): List { + val notesFile = getNoteJsonFile() + val notes: List = if (Files.exists(notesFile)) { + jackson.readValue(notesFile.toFile(), jackson.typeFactory.constructCollectionType(List::class.java, Note::class.java)) + } else emptyList() + return notes + } + + fun exportNotes(directory: Path, notes: List): String? { + return if (Files.list(directory).toList().isNotEmpty()) { + "Directory $directory must be empty" + } else { + val json = directory.resolve("notes.json") + try { + Files.write(json, jackson.writeValueAsBytes(notes), StandardOpenOption.CREATE_NEW) + val attachmentsDir = createAttachmentsDir(directory) + notes.forEach { writeAttachmentsForNote(it, attachmentsDir)} + null + } catch (e: IOException) { + logger.warn("Error creating $json", e) + "Error writing to $directory" + } + } + } + + fun writeAttachmentsForNote(note: Note) { + writeAttachmentsForNote(note, defaultAttachmentsDir()) + } + + fun createNoteAttachment(noteID: String, note: String? = null, attachmentName: String): NoteAttachment { + val fullPath = defaultAttachmentsDir().resolve(noteID) + return NoteAttachment( + note = note, + filePath = fullPath.resolve(attachmentName), + ) + } + + fun defaultAttachmentsDir() = createAttachmentsDir(Paths.get(dataDirectory)) + + private fun createAttachmentsDir(parent: Path): Path { + val attachementsDir = parent.resolve(attachmentsDirName) + if (!Files.exists(attachementsDir)) { + Files.createDirectories(attachementsDir) + } + return attachementsDir + } + + private fun writeAttachmentsForNote(note: Note, dir: Path) { + // first copy all attachments to a temp directory + val tempDir = systemUtils.tempDir() + try { + Files.createDirectories(tempDir) + val tempAttachments = note.attachments.map { attachment -> + val newPath = if (attachment.filePath != null) { + val dest = tempDir.resolve(attachment.filePath.fileName.toString()) + Files.copy(attachment.filePath, dest, StandardCopyOption.REPLACE_EXISTING) + dest + } else null + attachment.copy(filePath = newPath) + } + val tempNote = note.copy(attachments = tempAttachments) + logger.info("All attachments copied to temp folder: $tempDir for temp note $tempNote") + + val noteDir = dir.resolve(note.id) + if (Files.exists(noteDir)) { + noteDir.toFile().deleteRecursively() + } + Files.createDirectories(noteDir) + tempNote.attachments.forEach { attachment -> + if (attachment.filePath != null) { + val destFile = noteDir.resolve(attachment.filePath.fileName) + if (attachment.filePath != destFile) { + Files.copy(attachment.filePath, destFile, StandardCopyOption.REPLACE_EXISTING) + } + } + } + } finally { + if (Files.exists(tempDir)) { + tempDir.toFile().deleteRecursively() + } + } + } + + fun fixIDs() { + val notes = getNotesFromDB() + val notesWithIds = notes.map { note -> + note.copy(id = UUID.randomUUID().toString()) + } + getNoteJsonFile().toFile().writeBytes(jackson.writeValueAsBytes(notesWithIds)) + } + + fun cleanExportFolder(dir: Path) { + if (Files.exists(dir) && Files.isDirectory(dir)) { + Files.list(dir).forEach { it.toFile().deleteRecursively() } + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/praveenray/notes/ui/ApplicationLayoutController.kt b/src/main/kotlin/com/praveenray/notes/ui/ApplicationLayoutController.kt new file mode 100755 index 0000000..81dd1b5 --- /dev/null +++ b/src/main/kotlin/com/praveenray/notes/ui/ApplicationLayoutController.kt @@ -0,0 +1,62 @@ +package com.praveenray.notes.ui + +import com.google.common.eventbus.Subscribe +import com.praveenray.notes.models.AppEventBus +import com.praveenray.notes.models.ChangeScene +import com.praveenray.notes.models.ChangeSceneForStage +import javafx.event.ActionEvent +import javafx.fxml.FXML +import javafx.scene.layout.Region +import javafx.scene.layout.VBox +import javafx.stage.Stage +import org.scenicview.ScenicView +import org.slf4j.LoggerFactory +import java.util.EventObject +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class ApplicationLayoutController @Inject constructor( + private val eventBus: AppEventBus, + private val fxUtils: FXUtils, +) { + @FXML lateinit var appRoot: VBox + @FXML lateinit var container: VBox + + private val logger = LoggerFactory.getLogger(ApplicationLayoutController::class.java) + + @FXML + fun initialize() { + eventBus.register(this) + } + + @Subscribe + fun changeScene(event: ChangeScene) { + val (scene, event, sceneParams) = event.params as Triple + setContainerContent(scene, fxUtils.stageFromEvent(event), sceneParams) + } + + private fun setContainerContent( + scene: SCENES, + stage: Stage, + sceneParams: Any?, + ) { + val (fxmlParent, _) = fxUtils.loadFXML(scene, sceneParams) + container.children.setAll(fxmlParent) + if (fxmlParent is Region) { + logger.debug("Setting Stage Height to ${fxmlParent.prefHeight}") + stage.height = fxmlParent.prefHeight + } + } + + @Subscribe + fun changeScene(event: ChangeSceneForStage) { + val (scene, stage, sceneParams) = event.params as Triple + setContainerContent(scene, stage, sceneParams) + } + + @FXML + fun showDebug(event: ActionEvent) { + ScenicView.show(appRoot); + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/praveenray/notes/ui/ControllerBase.kt b/src/main/kotlin/com/praveenray/notes/ui/ControllerBase.kt new file mode 100644 index 0000000..02724ea --- /dev/null +++ b/src/main/kotlin/com/praveenray/notes/ui/ControllerBase.kt @@ -0,0 +1,5 @@ +package com.praveenray.notes.ui + +open class ControllerBase { + open fun initData(params: Any?) {} +} diff --git a/src/main/kotlin/com/praveenray/notes/ui/CreateNote.kt b/src/main/kotlin/com/praveenray/notes/ui/CreateNote.kt new file mode 100644 index 0000000..136180e --- /dev/null +++ b/src/main/kotlin/com/praveenray/notes/ui/CreateNote.kt @@ -0,0 +1,266 @@ +package com.praveenray.notes.ui + +import com.jfoenix.controls.JFXButton +import com.jfoenix.controls.JFXTextArea +import com.jfoenix.controls.JFXTextField +import com.praveenray.notes.models.AppEventBus +import com.praveenray.notes.models.ChangeScene +import com.praveenray.notes.models.Note +import com.praveenray.notes.models.NoteAttachment +import com.praveenray.notes.models.SearchParams +import com.praveenray.notes.service.LuceneSearch +import com.praveenray.notes.service.NoteDB +import javafx.application.Platform +import javafx.collections.FXCollections +import javafx.collections.ObservableList +import javafx.event.ActionEvent +import javafx.event.EventHandler +import javafx.fxml.FXML +import javafx.scene.control.Hyperlink +import javafx.scene.control.Label +import javafx.scene.control.ListCell +import javafx.scene.control.ListView +import javafx.scene.layout.BorderPane +import javafx.stage.FileChooser +import javafx.stage.Modality +import javafx.stage.Stage +import javafx.util.Callback +import org.slf4j.LoggerFactory +import java.nio.file.Path +import java.nio.file.Paths +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton + +class CreateNote @Inject constructor( + private val lucene: LuceneSearch, + private val fxUtils: FXUtils, + private val noteDB: NoteDB, + private val uiUtils: UIUtils, + private val eventBus: AppEventBus, + @Named("app.max_attachments_count") private val maxAttachmentsCount: Int = 5, +) : ControllerBase() { + private val logger = LoggerFactory.getLogger(CreateNote::class.java) + @FXML lateinit var root: BorderPane + @FXML lateinit var header: Label + @FXML lateinit var descriptionText: JFXTextArea + @FXML lateinit var addNewAttachmentBtn: JFXButton + @FXML lateinit var attachmentsTable: ListView + @FXML lateinit var tagsLabel: Label + @FXML lateinit var tagsDropdown: TagsDropdown + @FXML lateinit var newTagsLabel: Label + @FXML lateinit var newTags: JFXTextField + @FXML lateinit var saveNoteButton: JFXButton + @FXML lateinit var backToSearchButton: JFXButton + + private val attachmentList: ObservableList = FXCollections.observableArrayList() + private var noteID: String = "" + private var searchParams: SearchParams? = null + private var noteToEdit: Note? = null + + @FXML + fun initialize() { + attachmentList.clear() + attachmentsTable.items = attachmentList + attachmentsTable.cellFactory = Callback { _ -> + AttachmentTableCell(fxUtils) { index, note, isDeleted -> + logger.info("Got back new note: $note for index $index. Deleted? $isDeleted") + if (isDeleted) { + if (!attachmentList.remove(attachmentList[index])) { + logger.warn("Note at Index $index not present in the Notes list so it wasn't removed. This should not be possible!") + } + } else { + attachmentList[index] = attachmentList[index].copy(note = note ?: "") + } + } + } + tagsDropdown.initialize(lucene) + } + + override fun initData(params: Any?) { + if (params is Pair<*,*>) { + searchParams = params.first as SearchParams? + noteToEdit = params.second as Note? + setHeaderText() + descriptionText.text = noteToEdit?.description ?: "" + tagsDropdown.setSelections(noteToEdit?.tags ?: emptyList()) + attachmentList.setAll((noteToEdit?.attachments ?: emptyList()).map { + Attachment( + file = it.filePath ?: Paths.get("/"), + note = it.note ?: "" + ) + }) + } + } + + private fun setHeaderText() { + header.text = if (noteToEdit == null) "Create New Note" else "Update Note" + } + + fun backToSearch(event: ActionEvent) { + eventBus.post(ChangeScene(SCENES.SEARCH, event, searchParams)) + } + + fun saveNote(event: ActionEvent) { + val newTagsList = newTags.text.trim().split(",").map(String::trim) + val tags = tagsDropdown.getSelection().plus(newTagsList).toSet().toList() + val desc = descriptionText.text.trim() + if (desc.isNullOrBlank()) { + uiUtils.showError("Text must be provided", root) + } else { + val noteAttachments = attachmentList.map { attachment -> + NoteAttachment( + filePath = attachment.file.toAbsolutePath(), + note = attachment.note + ) + } + val note = Note( + description = desc, + tags = tags, + attachments = noteAttachments, + ) + if (noteToEdit == null) { + val noteToSave = note.copy(id = Note.createID()) + lucene.writeNoteToIndex(noteToSave, false) + noteToEdit = noteToSave + } else { + val noteToSave = note.copy(id = noteToEdit?.id) + lucene.writeNoteToIndex(noteToSave, true) + saveNoteButton.text = "Update" + noteToEdit = noteToSave + } + setHeaderText() + uiUtils.showSuccess("Note Saved", root) + } + } + + fun addNewAttachment(event: ActionEvent) { + val stage = fxUtils.stageFromEvent(event) + val fileChooser = FileChooser() + fileChooser.title = "Pick Files to Attach (max 5)" + val selections = fileChooser.showOpenMultipleDialog(stage) + if (selections != null) { + if (selections.size > maxAttachmentsCount) { + uiUtils.showError("Only ($maxAttachmentsCount) attachments allowed", root) + } else { + val attachments = selections.map { file -> + Attachment(file.toPath()) + } + + val uniques = findUniqueAttachments(attachmentList.toList(), attachments) + + if (uniques.isNotEmpty()) { + attachmentList.setAll(uniques) + } + } + } + } + + private fun findUniqueAttachments(existing: List, attachments: List): List { + val setOfExisting = existing.toSet() + val newAttachments = attachments.filter { !setOfExisting.contains(it) } + return existing.plus(newAttachments) + } + +} + +@Singleton +class AttachmentNoteDialog(val fxUtils: FXUtils, var savedNote: String? = null): ControllerBase() { + private val logger = LoggerFactory.getLogger(AttachmentNoteDialog::class.java) + @FXML lateinit var noteText: JFXTextField + @FXML lateinit var filePath: Label + + private var updated: Boolean = false + private var deleted: Boolean = false + + @FXML + fun initialize() { + + } + + fun deleteAttachment(event: ActionEvent) { + logger.warn("Deleting this Note") + savedNote = null + deleted = true + close(event) + } + + fun isUpdated() = updated + fun isDeleted() = deleted + + fun updateNote(event: ActionEvent) { + savedNote = noteText.text.trim() + updated = true + close(event) + } + + private fun close(event: ActionEvent) = fxUtils.stageFromEvent(event).close() + + override fun initData(params: Any?) { + val attachment = params as Attachment + Platform.runLater { + noteText.text = attachment.note + filePath.text = attachment.file.toAbsolutePath().toString() + } + } +} + +class AttachmentTableCell(private val fxUtils: FXUtils, private val cellChangeCallback: ((index: Int, newNote: String?, isDeleted: Boolean) -> Unit)): ListCell() { + private val logger = LoggerFactory.getLogger(AttachmentTableCell::class.java) + + override fun updateItem(attachment: Attachment?, empty: Boolean) { + super.updateItem(attachment, empty) + if (attachment == null || empty) { + text = null + graphic = null + } else { + val link = Hyperlink(attachment.file.fileName.toString()) + link.onAction = EventHandler(this::linkClicked) + graphic = link + text = if (!attachment.note.isNullOrBlank()) "(${attachment.note})" else null + } + } + + fun linkClicked(event: ActionEvent) { + if (item != null) { + val primaryStage = fxUtils.stageFromEvent(event) + val stage = Stage() + stage.initModality(Modality.WINDOW_MODAL) + stage.initOwner(primaryStage) + stage.title = "Update Note" + val controller = AttachmentNoteDialog(fxUtils) + fxUtils.createNewScene(SCENES.ATTACHMENT_NOTE, stage, item, controller) + stage.isResizable = false + fxUtils.centerToParent(primaryStage, stage) + stage.showAndWait() + if (controller.isUpdated()) { + logger.info("Updated Note: ${controller.savedNote}, isUpdated: ${controller.isUpdated()}") + cellChangeCallback.invoke(this.index, controller.savedNote, false) + } else if (controller.isDeleted()) { + logger.info("User Deleted note for index: ${this.index}") + cellChangeCallback.invoke(this.index, null, true) + } + } + } +} + +data class Attachment( + val file: Path, + val note: String = "", +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as Attachment + + if (!file.equals(other.file)) return false + + return true + } + + override fun hashCode(): Int { + return file.hashCode() + } +} + diff --git a/src/main/kotlin/com/praveenray/notes/ui/FXUtils.kt b/src/main/kotlin/com/praveenray/notes/ui/FXUtils.kt new file mode 100644 index 0000000..f94872c --- /dev/null +++ b/src/main/kotlin/com/praveenray/notes/ui/FXUtils.kt @@ -0,0 +1,83 @@ +package com.praveenray.notes.ui + +import com.google.inject.Injector +import javafx.event.ActionEvent +import javafx.fxml.FXMLLoader +import javafx.scene.Node +import javafx.scene.Parent +import javafx.scene.Scene +import javafx.scene.layout.Pane +import javafx.stage.Stage +import org.slf4j.LoggerFactory +import java.util.EventObject +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class FXUtils @Inject constructor(val injector: Injector) { + + private val logger = LoggerFactory.getLogger(FXUtils::class.java) + + fun createNewScene(fxml: SCENES, stage: Stage, data: Any? = null, controller: ControllerBase? = null): Pair { + val (fxmlParent, myController) = loadFXML(fxml.toString(), data, controller) + stage.sizeToScene() + val scene = Scene(fxmlParent) + stage.scene = scene + return Pair(fxmlParent, myController) + } + + fun createNewScene(fxml: SCENES, event: ActionEvent, data: Any? = null): Pair { + val stage = stageFromEvent(event) + return createNewScene(fxml, stage, data) + } + + fun stageFromEvent(event: EventObject): Stage { + val source = event.source + val stage = (if (source is Node) { + val stage = source.scene.window + if (stage is Stage) { + stage + } else null + } else null) ?: throw IllegalStateException("Cannot get Stage from Event") + return stage + } + + fun loadFXML(scene: SCENES, controllerParams: Any? = null, controller: ControllerBase? = null, root: Node? = null): Pair { + return loadFXML(scene.toString(), controllerParams, controller, root) + } + + fun loadFXML(classpath: String, controllerParams: Any? = null, controller: ControllerBase? = null, root: Node? = null): Pair { + val javaClass = this.javaClass + return javaClass.getResourceAsStream(classpath).use { resource -> + val fxLoader = FXMLLoader() + if (controller == null) { + fxLoader.setControllerFactory { injector.getInstance(it) } + } else { + fxLoader.setController(controller) + } + fxLoader.location = javaClass.getResource(classpath) + logger.info("Location URL: ${fxLoader.location}") + if (root != null) { + fxLoader.setRoot(root) + } + val fx: Parent = fxLoader.load(resource) + val myController: Any? = fxLoader.getController() + if (myController != null) { + if (myController is ControllerBase) { + myController.initData(controllerParams) + } + } + Pair(fx, myController) + } + } + + fun centerToParent(parent: Stage, dialog: Stage) { + val root: Pane = dialog.scene.root as Pane + val dialogX: Double = (parent.x + parent.width / 2 + - root.getPrefWidth() / 2) + val dialogY: Double = (parent.y + parent.height / 2 + - root.getPrefHeight() / 2) + dialog.x = dialogX /*from w w w .j a va 2s . c o m*/ + dialog.y = dialogY + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/praveenray/notes/ui/Scenes.kt b/src/main/kotlin/com/praveenray/notes/ui/Scenes.kt new file mode 100644 index 0000000..8df1265 --- /dev/null +++ b/src/main/kotlin/com/praveenray/notes/ui/Scenes.kt @@ -0,0 +1,12 @@ +package com.praveenray.notes.ui + +enum class SCENES(val fxml: String) { + LAYOUT("application-layout"), + SEARCH("search"), + SEARCH_RESULTS("search-results"), + CREATE_NOTE("create-note"), + ATTACHMENT_NOTE("attachment-note"), + CREATE_NEW_NOTE("create-note"); + + override fun toString() = "/scenes/${this.fxml}.fxml" +} \ No newline at end of file diff --git a/src/main/kotlin/com/praveenray/notes/ui/SearchController.kt b/src/main/kotlin/com/praveenray/notes/ui/SearchController.kt new file mode 100644 index 0000000..b034a39 --- /dev/null +++ b/src/main/kotlin/com/praveenray/notes/ui/SearchController.kt @@ -0,0 +1,178 @@ +package com.praveenray.notes.ui + +import com.jfoenix.controls.JFXButton +import com.praveenray.notes.lib.Failure +import com.praveenray.notes.lib.Success +import com.praveenray.notes.models.AppEventBus +import com.praveenray.notes.models.ChangeScene +import com.praveenray.notes.models.SearchParams +import com.praveenray.notes.service.LuceneSearch +import com.praveenray.notes.service.NoteDB +import javafx.application.Platform +import javafx.concurrent.Service +import javafx.concurrent.Task +import javafx.event.ActionEvent +import javafx.fxml.FXML +import javafx.scene.control.Dialog +import javafx.scene.control.Label +import javafx.scene.control.ProgressBar +import javafx.scene.control.TextArea +import javafx.scene.control.TextField +import javafx.scene.layout.BorderPane +import javafx.scene.layout.Pane +import javafx.scene.text.Text +import javafx.stage.DirectoryChooser +import javafx.stage.Modality +import org.slf4j.LoggerFactory +import java.nio.file.Path +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class SearchController @Inject constructor( + private val fxUtils: FXUtils, + private val lucene: LuceneSearch, + private val noteDB: NoteDB, + private val uiUtils: UIUtils, + private val eventBus: AppEventBus, +) : ControllerBase() { + private val logger = LoggerFactory.getLogger(SearchController::class.java) + + @FXML lateinit var panel: BorderPane + @FXML lateinit var header: Text + @FXML lateinit var descriptionBox: TextField + @FXML lateinit var exportBtn: JFXButton + @FXML lateinit var newNoteBtn: JFXButton + @FXML lateinit var searchNowButton: JFXButton + @FXML lateinit var tagsLabel: Label + @FXML lateinit var tagsCombo: TagsDropdown + + @FXML + fun initialize() { + } + + override fun initData(searchParams: Any?) { + val params = if (searchParams is SearchParams) { + searchParams + } else SearchParams() + descriptionBox.text = params.description + Platform.runLater { + tagsCombo.initialize(lucene) + tagsCombo.setSelections(params.tags) + } + } + + fun exportToFile(event: ActionEvent) { + val dirChooser = DirectoryChooser() + dirChooser.title = "Pick Export Directory" + val dirFile = dirChooser.showDialog(fxUtils.stageFromEvent(event)) + if (dirFile != null) { + val dir = dirFile.toPath() + val dlg = ProgressDialog(fxUtils, lucene, noteDB, "Exporting") + val error = dlg.start(dir) + val exportError = if (error.isNullOrBlank()) { + if (dlg.canceled()) { + noteDB.cleanExportFolder(dir) + "Export canceled" + } else error + } else error + if (!exportError.isNullOrBlank()) { + uiUtils.showError(exportError, panel) + } else { + uiUtils.showSuccess("successfully exported to $dirFile", panel) + } + } + } + + fun createNewNote(event: ActionEvent) { + eventBus.post(ChangeScene(scene = SCENES.CREATE_NEW_NOTE, event = event, params = createSearchParams())) + } + + fun searchNow(event: ActionEvent) { + eventBus.post(ChangeScene(scene = SCENES.SEARCH_RESULTS, event = event, params = createSearchParams())) + } + + private fun createSearchParams() = SearchParams( + tags = tagsCombo.getSelection(), + description = descriptionBox.text.trim(), + ) +} + +class ProgressDialog( + val fxUtils: FXUtils, + val lucene: LuceneSearch, + val noteDB: NoteDB, + val labelText: String = "" +): ControllerBase() { + @FXML lateinit var progressBar: ProgressBar + @FXML lateinit var label: Label + @FXML lateinit var root: Pane + + private lateinit var dlg: Dialog + private lateinit var service: Service + private var isCanceled: Boolean = false + private var exportError: String? = null + + @FXML + fun initialize() { + progressBar.progress = 0.0 + label.text = labelText + } + + fun canceled() = isCanceled + + fun start(dirFile: Path): String? { + val (parent, _) = fxUtils.loadFXML(classpath = "/fragments/progress-dialog.fxml", controller = this) + dlg = Dialog() + dlg.title = "Please Wait" + dlg.dialogPane.content = parent + dlg.initModality(Modality.APPLICATION_MODAL) + + val task = object: Task() { + override fun call(): String? { + updateProgress(0.5, 1.0) + Thread.sleep(2000) // todo : remove + return when (val searched = lucene.searchForDescription(null, false)) { + is Success -> noteDB.exportNotes(dirFile, searched.value) + is Failure -> searched.e.message + } + } + } + + progressBar.progressProperty().bind(task.progressProperty()) + service = object: Service() { + override fun createTask(): Task { + return task + } + + override fun succeeded() { + exportError = task.get() + closeDialog() + } + + override fun cancelled() { + super.cancelled() + closeDialog() + } + + override fun failed() { + exportError = task.get() + closeDialog() + } + } + + service.start() + dlg.showAndWait() + return exportError + } + + private fun closeDialog() { + dlg.result = "fake" // without this, dialog won't close + dlg.close() + } + + fun cancel(event: ActionEvent) { + isCanceled = true + service!!.cancel() + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/praveenray/notes/ui/SearchResults.kt b/src/main/kotlin/com/praveenray/notes/ui/SearchResults.kt new file mode 100644 index 0000000..062fd95 --- /dev/null +++ b/src/main/kotlin/com/praveenray/notes/ui/SearchResults.kt @@ -0,0 +1,210 @@ +package com.praveenray.notes.ui + +import com.jfoenix.controls.JFXButton +import com.jfoenix.controls.JFXListView +import com.jfoenix.controls.JFXTextArea +import com.praveenray.notes.lib.Failure +import com.praveenray.notes.lib.Success +import com.praveenray.notes.lib.SystemUtils +import com.praveenray.notes.models.AppEventBus +import com.praveenray.notes.models.ChangeScene +import com.praveenray.notes.models.Note +import com.praveenray.notes.models.NoteAttachment +import com.praveenray.notes.models.SearchParams +import com.praveenray.notes.service.LuceneSearch +import javafx.collections.FXCollections +import javafx.collections.ObservableList +import javafx.event.ActionEvent +import javafx.event.EventHandler +import javafx.fxml.FXML +import javafx.scene.control.Hyperlink +import javafx.scene.control.Label +import javafx.scene.control.ListCell +import javafx.scene.control.ToggleButton +import javafx.scene.layout.BorderPane +import javafx.scene.layout.GridPane +import javafx.scene.web.WebView +import javafx.stage.FileChooser +import javafx.stage.Window +import javafx.util.Callback +import org.apache.lucene.index.IndexNotFoundException +import org.slf4j.LoggerFactory +import java.nio.file.Files +import java.nio.file.StandardCopyOption +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class SearchResults @Inject constructor( + private val fxUtils: FXUtils, + private val lucene: LuceneSearch, + private val systemUtils: SystemUtils, + private val eventBus: AppEventBus, + private val uiUtils: UIUtils, + +) : ControllerBase() { + + private val logger = LoggerFactory.getLogger(SearchResults::class.java) + @FXML lateinit var root: BorderPane + @FXML lateinit var countLabel: Label + @FXML lateinit var descText: JFXTextArea + @FXML lateinit var descTextHtml: WebView + @FXML lateinit var textHtmlToggle: ToggleButton + @FXML lateinit var noteIDLabel: Label + @FXML lateinit var noteID: Label + @FXML lateinit var noteTagLabel: Label + @FXML lateinit var noteTags: Label + @FXML lateinit var attachmentLabel: Label + @FXML lateinit var attachmentListView: JFXListView + @FXML lateinit var previousButton: JFXButton + @FXML lateinit var nextButton: JFXButton + @FXML lateinit var backToSearchBtn: JFXButton + @FXML lateinit var updateButton: JFXButton + @FXML lateinit var deleteButton: JFXButton + @FXML lateinit var attachmentPane: GridPane + + private var searchParams: SearchParams = SearchParams() + private var searchResults: List = emptyList() + private var currentNoteIndex: Int = 0 + private val attachmentList: ObservableList = FXCollections.observableArrayList() + + @FXML + fun initialize() { + attachmentListView.cellFactory = Callback { + LinkAttachmentCell(systemUtils, root.scene.window) + } + + attachmentListView.items = attachmentList + attachmentListView.prefWidthProperty().bind(attachmentPane.widthProperty()) + } + + override fun initData(params: Any?) { + searchParams = params as SearchParams + val note = when(val searched = lucene.searchForDescription(params.description, params.phraseSearch, searchParams.tags)) { + is Success -> { + searchResults = searched.value + countLabel.text = "Found ${searchResults.size} Notes" + if (searchResults.isNotEmpty()) { + currentNoteIndex = 0 + searchResults[currentNoteIndex] + } else { + currentNoteIndex = -1 + listOf(updateButton, nextButton, deleteButton, previousButton).forEach {it.isDisable = true} + null + } + } + is Failure -> { + countLabel.text = "Found 0 Notes" + if (searched.e !is IndexNotFoundException) { + uiUtils.showError(searched.e.message ?: "Error with Search", root) + } + null + } + } + renderNote(note) + } + + private fun renderNote(note: Note?) { + logger.info("Note Description: ${note?.description}") + descText.text = note?.description + descTextHtml.engine.loadContent(note?.description ?: "") + noteTags.text = note?.tags?.joinToString(",") + noteID.text = note?.id + val attachments = note?.attachments?.map { attachment -> + val file = attachment.filePath?.fileName.toString() + if (attachment.note.isNullOrBlank()) file else "$file(${attachment.note})" + } + attachmentList.setAll(note?.attachments ?: emptyList()) + } + + fun onPrevious(event: ActionEvent) { + logger.info("onPrev: CURRENT INDEX: $currentNoteIndex") + val index = currentNoteIndex - 1 + if (index >= 0) { + renderNote(searchResults[index]) + nextButton.isDisable = false + updateButton.isDisable = false + } else { + descText.text = "End Reached" + previousButton.isDisable = true + updateButton.isDisable = true + } + currentNoteIndex = index + } + + fun onNext(event: ActionEvent) { + logger.info("CURRENT INDEX: $currentNoteIndex") + val index = currentNoteIndex + 1 + if (index < searchResults.size) { + renderNote(searchResults[index]) + previousButton.isDisable = false + updateButton.isDisable = false + } else { + descText.text= "End Reached" + noteID.text = null + noteTags.text = null + attachmentList.clear() + nextButton.isDisable = true + updateButton.isDisable = true + logger.info("CURRENT INDEX: $currentNoteIndex") + } + currentNoteIndex = index + } + + fun backToSearch(event: ActionEvent) { + eventBus.post(ChangeScene(SCENES.SEARCH, event, searchParams)) + } + + fun onUpdateNote(event: ActionEvent) { + eventBus.post(ChangeScene(SCENES.CREATE_NEW_NOTE, event, Pair(searchParams, searchResults[currentNoteIndex]))) + } + + fun showAsHtml(event: ActionEvent) { + val html = textHtmlToggle.isSelected + descTextHtml.isVisible = html + descTextHtml.isManaged = html + + descText.isVisible = !html + descText.isManaged = !html + } + + fun onDeleteNote(event: ActionEvent) { + uiUtils.confirm("Delete Note?") { + lucene.deleteNote(searchResults[currentNoteIndex]) + eventBus.post(ChangeScene(SCENES.SEARCH, event, searchParams)) + } + } +} + +class LinkAttachmentCell( + val systemUtils: SystemUtils, + val window: Window +): ListCell() { + override fun updateItem(item: NoteAttachment?, empty: Boolean) { + super.updateItem(item, empty) + graphic = null + text = null + + if (!empty && item != null && item.filePath != null) { + val filename = item.filePath.fileName.toString() + val linkText = if (item.note.isNullOrBlank()) { + filename + } else { + "$filename (${item.note})" + } + val link = Hyperlink(linkText) + link.onAction = EventHandler { e -> + + val fileChooser = FileChooser() + fileChooser.title = "Save Attachment" + fileChooser.initialFileName = filename + fileChooser.initialDirectory = systemUtils.currentDir().toFile() + val saveFile = fileChooser.showSaveDialog(window) + if (saveFile != null) { + Files.copy(item.filePath, saveFile.toPath(), StandardCopyOption.REPLACE_EXISTING) + } + } + graphic = link + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/praveenray/notes/ui/TagsDropdown.kt b/src/main/kotlin/com/praveenray/notes/ui/TagsDropdown.kt new file mode 100644 index 0000000..271268a --- /dev/null +++ b/src/main/kotlin/com/praveenray/notes/ui/TagsDropdown.kt @@ -0,0 +1,39 @@ +package com.praveenray.notes.ui + +import com.praveenray.notes.service.LuceneSearch +import javafx.fxml.FXML +import javafx.fxml.FXMLLoader +import javafx.scene.Parent +import javafx.scene.layout.Pane +import org.controlsfx.control.CheckComboBox +import org.slf4j.LoggerFactory +import javax.inject.Singleton + +@Singleton +class TagsDropdown: Pane() { + private val logger = LoggerFactory.getLogger(TagsDropdown::class.java) + @FXML lateinit var choices: CheckComboBox + + init { + val javaClass = this.javaClass + val fx = FXMLLoader(javaClass.getResource("/scenes/tags-combo.fxml")) + fx.setRoot(this) + fx.setController(this) + fx.load() + logger.info("Loaded tags-combo.fxml. Choice: $choices") + } + + fun getSelection(): List { + return choices.checkModel.checkedItems.map(Any::toString).map(String::trim) + } + + fun setSelections(tags: List) { + tags.forEach { + choices.checkModel.check(it) + } + } + + fun initialize(lucene: LuceneSearch) { + choices.items.setAll(lucene.uniqueTags()) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/praveenray/notes/ui/UIUtils.kt b/src/main/kotlin/com/praveenray/notes/ui/UIUtils.kt new file mode 100755 index 0000000..00225b9 --- /dev/null +++ b/src/main/kotlin/com/praveenray/notes/ui/UIUtils.kt @@ -0,0 +1,46 @@ +package com.praveenray.notes.ui + +import com.jfoenix.controls.JFXButton +import com.jfoenix.controls.JFXSnackbar +import com.jfoenix.controls.JFXSnackbar.SnackbarEvent +import javafx.scene.control.Alert +import javafx.scene.control.ButtonType +import javafx.scene.input.MouseButton +import javafx.scene.input.MouseEvent +import javafx.scene.layout.Pane +import javafx.util.Duration +import javax.inject.Singleton + +typealias CLOSE_CALLBACK = (root: Pane) -> Unit + +@Singleton +class UIUtils { + fun isDoubleClick(event: MouseEvent) = event.getButton() == MouseButton.PRIMARY && event.getClickCount() == 2 + + fun showError(msg: String, root: Pane, pause: Boolean = false, closeCallback: CLOSE_CALLBACK = {})= showSnack(msg, root, pause,"button-warn", closeCallback) + fun showSuccess(msg: String, root: Pane, pause: Boolean = false, closeCallback: CLOSE_CALLBACK = {}) = showSnack(msg, root, pause,"button-success", closeCallback) + + private fun showSnack(msg: String, root: Pane, pause: Boolean, cssClass: String, closeCallback: CLOSE_CALLBACK): JFXSnackbar { + val bar = JFXSnackbar(root) + bar.visibleProperty().addListener {_, _, isVisible -> + if (!isVisible) { + closeCallback.invoke(root) + } + } + val jfxButton = JFXButton(msg) + jfxButton.styleClass.add(cssClass) + jfxButton.minWidth = 300.0 + val event = if (pause) SnackbarEvent(jfxButton, Duration.INDEFINITE) else SnackbarEvent(jfxButton, Duration.seconds(3.0)) + bar.enqueue(event) + return bar + } + + fun confirm(msg: String, block: () -> Unit) { + val alert = Alert(Alert.AlertType.CONFIRMATION) + alert.headerText = msg + val response = alert.showAndWait() + if (response.get() == ButtonType.OK) { + block() + } + } +} \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties new file mode 100644 index 0000000..2743ccb --- /dev/null +++ b/src/main/resources/application.properties @@ -0,0 +1,8 @@ +# Configuration file +# key = value + +app.data.directory=data +app.lucene.index.recreate=false +app.lucene.search.phraseSlop=5 +app.max_attachments_count=5 + diff --git a/src/main/resources/fragments/progress-dialog.fxml b/src/main/resources/fragments/progress-dialog.fxml new file mode 100755 index 0000000..bb68dea --- /dev/null +++ b/src/main/resources/fragments/progress-dialog.fxml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/scenes/application-main.fxml b/src/main/resources/scenes/application-main.fxml new file mode 100644 index 0000000..e970589 --- /dev/null +++ b/src/main/resources/scenes/application-main.fxml @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/scenes/attachment-note.fxml b/src/main/resources/scenes/attachment-note.fxml new file mode 100755 index 0000000..a6acb3f --- /dev/null +++ b/src/main/resources/scenes/attachment-note.fxml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/scenes/create-note.fxml b/src/main/resources/scenes/create-note.fxml new file mode 100644 index 0000000..5341b8c --- /dev/null +++ b/src/main/resources/scenes/create-note.fxml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + +
+
diff --git a/src/main/resources/scenes/search-results.fxml b/src/main/resources/scenes/search-results.fxml new file mode 100644 index 0000000..8b13128 --- /dev/null +++ b/src/main/resources/scenes/search-results.fxml @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + +
+
diff --git a/src/main/resources/scenes/search.fxml b/src/main/resources/scenes/search.fxml new file mode 100644 index 0000000..575900c --- /dev/null +++ b/src/main/resources/scenes/search.fxml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + +
+
diff --git a/src/main/resources/scenes/stylesheet.css b/src/main/resources/scenes/stylesheet.css new file mode 100755 index 0000000..3556c41 --- /dev/null +++ b/src/main/resources/scenes/stylesheet.css @@ -0,0 +1,86 @@ +* { + -fx-primary-color: derive(darkgray, 80%); + -fx-secondary-color: derive(darkgray, -50%); + -fx-font-size: 10pt; +} + +.application-window-padding { + -fx-padding: 15px 10px 5px 10px; +} + +.button-warn { + -fx-background-color: LIGHTCORAL; + -fx-font-weight: bold; + -jfx-button-type: RAISED; +} + +.button-success { + -fx-background-color: #D0D5C3; + -fx-font-weight: bold; +} + +.button-normal { + -fx-background-color: #8d918e; + -jfx-button-type: RAISED; + -fx-font-weight: bold; + -fx-text-fill: WHITE; +} + +.button-small { + -fx-font-size: 9pt; +} + + +.ikonli-font-icon-queues { + -fx-icon-color: chocolate; + -fx-icon-size: 21; +} + +.ikonli-font-icon-small { + -fx-icon-size: 13; +} + +.list-cell:filled:hover { + -fx-background-color: -fx-focus-color; + -fx-text-fill: white; +} + +.table-view-no-hscroll .scroll-bar * { + -fx-min-height: 0; + -fx-pref-height: 0; + -fx-max-height: 0; +} + +.button-transparent { + -fx-background-color: transparent; + -fx-cursor: hand; +} + +.uri-column-size { + -fx-pref-width:500; +} + +.standalone-menu-button > .arrow-button { + -fx-padding: 0; +} + +.standalone-menu-button > .arrow-button > .arrow { + -fx-padding: 0; +} + +.title-text { + -fx-font-size: 16px; + -fx-font-weight: bold; +} + +.menu-item:focused { + -fx-background-color: #969A9F; +} + +.table-row-cell:selected .table-cell .hyperlink { + -fx-text-fill: -fx-text-background-color ; +} + +.list-cell:selected .hyperlink, .list-cell:hover .hyperlink { + -fx-text-fill: -fx-text-background-color ; +} \ No newline at end of file diff --git a/src/main/resources/scenes/tags-combo.fxml b/src/main/resources/scenes/tags-combo.fxml new file mode 100755 index 0000000..1b2460e --- /dev/null +++ b/src/main/resources/scenes/tags-combo.fxml @@ -0,0 +1,7 @@ + + + + + + +