├── .github ├── FUNDING.yml └── workflows │ └── ci.yml ├── .gitignore ├── .jshintrc ├── .scalafmt.conf ├── Dockerfile.test ├── LICENSE ├── README.md ├── app ├── js │ ├── client │ │ └── src │ │ │ ├── main │ │ │ └── scala │ │ │ │ └── Main.scala │ │ │ └── test │ │ │ └── scala │ │ │ ├── app │ │ │ ├── common │ │ │ │ └── testing │ │ │ │ │ ├── FakeScalaJsApiClient.scala │ │ │ │ │ ├── JsTestObjects.scala │ │ │ │ │ └── TestModule.scala │ │ │ ├── flux │ │ │ │ ├── react │ │ │ │ │ └── app │ │ │ │ │ │ └── document │ │ │ │ │ │ ├── DesktopTaskEditorTest.scala │ │ │ │ │ │ ├── EditHistoryTest.scala │ │ │ │ │ │ ├── MobileTaskEditorTest.scala │ │ │ │ │ │ └── TaskEditorUtilsTest.scala │ │ │ │ └── stores │ │ │ │ │ └── document │ │ │ │ │ └── DocumentSelectionStoreTest.scala │ │ │ ├── models │ │ │ │ └── document │ │ │ │ │ ├── DocumentEditTest.scala │ │ │ │ │ ├── DocumentTest.scala │ │ │ │ │ ├── TaskTest.scala │ │ │ │ │ └── TextWithMarkupTest.scala │ │ │ └── scala2js │ │ │ │ └── AppConvertersTest.scala │ │ │ └── hydro │ │ │ ├── common │ │ │ ├── JsI18nTest.scala │ │ │ ├── ListenableTest.scala │ │ │ ├── SerializingTaskQueueJsTest.scala │ │ │ ├── StringUtilsJsTest.scala │ │ │ └── testing │ │ │ │ ├── FakeJsEntityAccess.scala │ │ │ │ ├── FakeLocalDatabase.scala │ │ │ │ ├── FakeRouterContext.scala │ │ │ │ ├── ModificationsBuffer.scala │ │ │ │ └── ReactTestWrapper.scala │ │ │ ├── flux │ │ │ ├── action │ │ │ │ └── DispatcherTest.scala │ │ │ ├── react │ │ │ │ └── uielements │ │ │ │ │ └── input │ │ │ │ │ └── bootstrap │ │ │ │ │ └── TextInputTest.scala │ │ │ └── stores │ │ │ │ └── UserStoreTest.scala │ │ │ ├── models │ │ │ └── access │ │ │ │ ├── FutureLocalDatabaseTest.scala │ │ │ │ ├── JsEntityAccessImplTest.scala │ │ │ │ ├── SingletonKeyTest.scala │ │ │ │ └── webworker │ │ │ │ ├── LocalDatabaseWebWorkerApiConvertersTest.scala │ │ │ │ └── LocalDatabaseWebWorkerApiImplTest.scala │ │ │ └── scala2js │ │ │ └── StandardConvertersTest.scala │ ├── shared │ │ └── src │ │ │ └── main │ │ │ ├── npm-packages │ │ │ └── global-mousetrap │ │ │ │ ├── index.js │ │ │ │ └── package.json │ │ │ └── scala │ │ │ ├── app │ │ │ ├── api │ │ │ │ ├── Module.scala │ │ │ │ └── ScalaJsApiClient.scala │ │ │ ├── flux │ │ │ │ ├── ClientApp.scala │ │ │ │ ├── ClientAppModule.scala │ │ │ │ ├── action │ │ │ │ │ └── AppActions.scala │ │ │ │ ├── react │ │ │ │ │ ├── app │ │ │ │ │ │ ├── Layout.scala │ │ │ │ │ │ ├── Menu.scala │ │ │ │ │ │ ├── Module.scala │ │ │ │ │ │ └── document │ │ │ │ │ │ │ ├── DesktopTaskEditor.scala │ │ │ │ │ │ │ ├── DocumentAdministration.scala │ │ │ │ │ │ │ ├── EditHistory.scala │ │ │ │ │ │ │ ├── MobileTaskEditor.scala │ │ │ │ │ │ │ ├── Module.scala │ │ │ │ │ │ │ ├── TaskEditorUtils.scala │ │ │ │ │ │ │ └── TaskList.scala │ │ │ │ │ └── uielements │ │ │ │ │ │ ├── ResizingTextArea.scala │ │ │ │ │ │ └── SelectPrompt.scala │ │ │ │ ├── router │ │ │ │ │ ├── AppPages.scala │ │ │ │ │ ├── Module.scala │ │ │ │ │ └── RouterFactory.scala │ │ │ │ └── stores │ │ │ │ │ ├── GlobalMessagesStore.scala │ │ │ │ │ ├── Module.scala │ │ │ │ │ ├── PendingModificationsStore.scala │ │ │ │ │ └── document │ │ │ │ │ ├── AllDocumentsStore.scala │ │ │ │ │ ├── DocumentSelectionStore.scala │ │ │ │ │ ├── DocumentStore.scala │ │ │ │ │ └── DocumentStoreFactory.scala │ │ │ ├── models │ │ │ │ ├── access │ │ │ │ │ └── Module.scala │ │ │ │ └── document │ │ │ │ │ ├── Document.scala │ │ │ │ │ ├── DocumentEdit.scala │ │ │ │ │ ├── Task.scala │ │ │ │ │ ├── TaskIdAndIndex.scala │ │ │ │ │ └── TextWithMarkup.scala │ │ │ └── scala2js │ │ │ │ └── AppConverters.scala │ │ │ └── hydro │ │ │ ├── common │ │ │ ├── BrowserUtils.scala │ │ │ ├── DesktopKeyCombination.scala │ │ │ ├── DomNodeUtils.scala │ │ │ ├── JsI18n.scala │ │ │ ├── JsLoggingUtils.scala │ │ │ ├── Listenable.scala │ │ │ ├── Module.scala │ │ │ ├── Unique.scala │ │ │ ├── testing │ │ │ │ └── Awaiter.scala │ │ │ ├── time │ │ │ │ ├── JsClock.scala │ │ │ │ └── Module.scala │ │ │ └── websocket │ │ │ │ ├── SerialWebsocketClient.scala │ │ │ │ ├── SerialWebsocketClientParallelizer.scala │ │ │ │ └── WebsocketClient.scala │ │ │ ├── flux │ │ │ ├── action │ │ │ │ ├── Action.scala │ │ │ │ ├── Dispatcher.scala │ │ │ │ ├── Module.scala │ │ │ │ └── StandardActions.scala │ │ │ ├── react │ │ │ │ ├── HydroReactComponent.scala │ │ │ │ ├── ReactVdomUtils.scala │ │ │ │ └── uielements │ │ │ │ │ ├── ApplicationDisconnectedIcon.scala │ │ │ │ │ ├── Bootstrap.scala │ │ │ │ │ ├── BootstrapTags.scala │ │ │ │ │ ├── GlobalMessages.scala │ │ │ │ │ ├── HalfPanel.scala │ │ │ │ │ ├── LocalDatabaseHasBeenLoadedIcon.scala │ │ │ │ │ ├── Module.scala │ │ │ │ │ ├── PageHeader.scala │ │ │ │ │ ├── PageLoadingSpinner.scala │ │ │ │ │ ├── Panel.scala │ │ │ │ │ ├── PendingModificationsCounter.scala │ │ │ │ │ ├── SbadminLayout.scala │ │ │ │ │ ├── SbadminMenu.scala │ │ │ │ │ ├── Table.scala │ │ │ │ │ ├── WaitForFuture.scala │ │ │ │ │ ├── dbexplorer │ │ │ │ │ ├── DatabaseExplorer.scala │ │ │ │ │ ├── DatabaseTableView.scala │ │ │ │ │ └── Module.scala │ │ │ │ │ ├── input │ │ │ │ │ ├── InputBase.scala │ │ │ │ │ ├── InputValidator.scala │ │ │ │ │ ├── TextInput.scala │ │ │ │ │ └── bootstrap │ │ │ │ │ │ ├── InputComponent.scala │ │ │ │ │ │ ├── SelectInput.scala │ │ │ │ │ │ ├── TextAreaInput.scala │ │ │ │ │ │ └── TextInput.scala │ │ │ │ │ └── usermanagement │ │ │ │ │ ├── AddUserForm.scala │ │ │ │ │ ├── AllUsersList.scala │ │ │ │ │ ├── Module.scala │ │ │ │ │ ├── UpdatePasswordForm.scala │ │ │ │ │ ├── UserAdministration.scala │ │ │ │ │ └── UserProfile.scala │ │ │ ├── router │ │ │ │ ├── Page.scala │ │ │ │ ├── RouterContext.scala │ │ │ │ └── StandardPages.scala │ │ │ └── stores │ │ │ │ ├── ApplicationIsOnlineStore.scala │ │ │ │ ├── AsyncEntityDerivedStateStore.scala │ │ │ │ ├── CombiningStateStore.scala │ │ │ │ ├── CombiningStateStore3.scala │ │ │ │ ├── DatabaseExplorerStoreFactory.scala │ │ │ │ ├── FixedStateStore.scala │ │ │ │ ├── LocalDatabaseHasBeenLoadedStore.scala │ │ │ │ ├── PageLoadingStateStore.scala │ │ │ │ ├── StateStore.scala │ │ │ │ ├── StoreFactory.scala │ │ │ │ └── UserStore.scala │ │ │ ├── jsfacades │ │ │ ├── Bootbox.scala │ │ │ ├── ClipboardPolyfill.scala │ │ │ ├── LokiJs.scala │ │ │ ├── Mousetrap.scala │ │ │ ├── ReactAutosuggest.scala │ │ │ ├── ReactBeautifulDnd.scala │ │ │ ├── ReactTagInput.scala │ │ │ ├── Recharts.scala │ │ │ └── escapeHtml.scala │ │ │ ├── models │ │ │ └── access │ │ │ │ ├── EntitySyncLogic.scala │ │ │ │ ├── FutureLocalDatabase.scala │ │ │ │ ├── HybridRemoteDatabaseProxy.scala │ │ │ │ ├── HydroPushSocketClientFactory.scala │ │ │ │ ├── JsEntityAccess.scala │ │ │ │ ├── JsEntityAccessImpl.scala │ │ │ │ ├── LocalDatabase.scala │ │ │ │ ├── LocalDatabaseImpl.scala │ │ │ │ ├── PendingModifications.scala │ │ │ │ ├── PendingModificationsListener.scala │ │ │ │ ├── RemoteDatabaseProxy.scala │ │ │ │ ├── SingletonKey.scala │ │ │ │ ├── webworker │ │ │ │ ├── LocalDatabaseWebWorkerApi.scala │ │ │ │ ├── LocalDatabaseWebWorkerApiConverters.scala │ │ │ │ ├── LocalDatabaseWebWorkerApiImpl.scala │ │ │ │ ├── LocalDatabaseWebWorkerApiMultiDbImpl.scala │ │ │ │ ├── LocalDatabaseWebWorkerApiStub.scala │ │ │ │ ├── LocalDatabaseWebWorkerScript.scala │ │ │ │ └── Module.scala │ │ │ │ └── worker │ │ │ │ ├── JsWorkerClientFacade.scala │ │ │ │ ├── JsWorkerServerFacade.scala │ │ │ │ └── impl │ │ │ │ ├── DedicatedWorkerFacadeImpl.scala │ │ │ │ └── SharedWorkerFacadeImpl.scala │ │ │ └── scala2js │ │ │ ├── Scala2Js.scala │ │ │ └── StandardConverters.scala │ ├── webpack.dev.js │ ├── webpack.prod.js │ └── webworker │ │ └── src │ │ └── main │ │ └── scala │ │ └── Main.scala ├── jvm │ └── src │ │ ├── main │ │ ├── assets │ │ │ ├── images │ │ │ │ ├── document_shortcut_96x96.png │ │ │ │ ├── favicon192x192.png │ │ │ │ ├── favicon48x48.png │ │ │ │ └── favicon512x512.png │ │ │ ├── lib │ │ │ │ └── fontello │ │ │ │ │ ├── LICENSE.txt │ │ │ │ │ ├── README.txt │ │ │ │ │ ├── config.json │ │ │ │ │ ├── css │ │ │ │ │ ├── animation.css │ │ │ │ │ ├── fontello-codes.css │ │ │ │ │ ├── fontello-embedded.css │ │ │ │ │ ├── fontello-ie7-codes.css │ │ │ │ │ ├── fontello-ie7.css │ │ │ │ │ └── fontello.css │ │ │ │ │ ├── demo.html │ │ │ │ │ └── font │ │ │ │ │ ├── fontello.eot │ │ │ │ │ ├── fontello.svg │ │ │ │ │ ├── fontello.ttf │ │ │ │ │ ├── fontello.woff │ │ │ │ │ └── fontello.woff2 │ │ │ └── stylesheets │ │ │ │ └── main.less │ │ ├── resources │ │ │ ├── messages │ │ │ ├── routes │ │ │ └── serviceWorker.template.js │ │ ├── scala │ │ │ ├── app │ │ │ │ ├── Module.scala │ │ │ │ ├── api │ │ │ │ │ ├── ApiModule.scala │ │ │ │ │ ├── AppEntityPermissions.scala │ │ │ │ │ └── ScalaJsApiServerFactory.scala │ │ │ │ ├── common │ │ │ │ │ └── CommonModule.scala │ │ │ │ ├── controllers │ │ │ │ │ ├── ControllersModule.scala │ │ │ │ │ ├── ExternalApi.scala │ │ │ │ │ ├── Webmanifest.scala │ │ │ │ │ └── helpers │ │ │ │ │ │ └── ScalaJsApiCallerImpl.scala │ │ │ │ ├── models │ │ │ │ │ ├── ModelsModule.scala │ │ │ │ │ ├── access │ │ │ │ │ │ └── JvmEntityAccess.scala │ │ │ │ │ ├── slick │ │ │ │ │ │ └── SlickEntityTableDefs.scala │ │ │ │ │ └── user │ │ │ │ │ │ └── Users.scala │ │ │ │ └── tools │ │ │ │ │ └── ApplicationStartHook.scala │ │ │ └── hydro │ │ │ │ ├── api │ │ │ │ └── EntityPermissions.scala │ │ │ │ ├── common │ │ │ │ ├── PlayI18n.scala │ │ │ │ ├── ResourceFiles.scala │ │ │ │ ├── UpdateTokens.scala │ │ │ │ ├── ValidatingYamlParser.scala │ │ │ │ ├── publisher │ │ │ │ │ ├── Publishers.scala │ │ │ │ │ └── TriggerablePublisher.scala │ │ │ │ └── time │ │ │ │ │ └── JvmClock.scala │ │ │ │ ├── controllers │ │ │ │ ├── Auth.scala │ │ │ │ ├── InternalApi.scala │ │ │ │ ├── JavascriptFiles.scala │ │ │ │ ├── StandardActions.scala │ │ │ │ └── helpers │ │ │ │ │ └── AuthenticatedAction.scala │ │ │ │ └── models │ │ │ │ ├── access │ │ │ │ ├── InMemoryEntityDatabase.scala │ │ │ │ └── JvmEntityAccessBase.scala │ │ │ │ ├── modification │ │ │ │ └── EntityModificationEntity.scala │ │ │ │ └── slick │ │ │ │ ├── SlickEntityManager.scala │ │ │ │ ├── SlickEntityTableDef.scala │ │ │ │ ├── SlickUtils.scala │ │ │ │ └── StandardSlickEntityTableDefs.scala │ │ └── twirl │ │ │ └── views │ │ │ ├── Base.scala.html │ │ │ ├── doneAndClose.scala.html │ │ │ ├── login.scala.html │ │ │ ├── reactApp.scala.html │ │ │ └── utils │ │ │ └── ImportScalaJsProject.scala.html │ │ └── test │ │ ├── resources │ │ └── test-application.conf │ │ └── scala │ │ ├── app │ │ ├── api │ │ │ ├── PicklersTest.scala │ │ │ └── ScalaJsApiServerFactoryTest.scala │ │ ├── common │ │ │ ├── CaseFormatsTest.scala │ │ │ ├── MarkdownConverterTest.scala │ │ │ └── testing │ │ │ │ ├── TestModule.scala │ │ │ │ └── TestUtils.scala │ │ └── models │ │ │ └── user │ │ │ └── UsersTest.scala │ │ └── hydro │ │ ├── api │ │ ├── PicklableDbQueryTest.scala │ │ └── StandardPicklersTest.scala │ │ ├── common │ │ ├── CollectionUtilsTest.scala │ │ ├── FormattingTest.scala │ │ ├── GuavaReplacementTest.scala │ │ ├── OrderTokenTest.scala │ │ ├── ScalaUtilsTest.scala │ │ ├── SerializingTaskQueueJvmTest.scala │ │ ├── StringUtilsJvmTest.scala │ │ ├── TagsTest.scala │ │ ├── UpdateTokensTest.scala │ │ ├── testing │ │ │ ├── Awaiter.scala │ │ │ ├── FakePlayI18n.scala │ │ │ └── HookedSpecification.scala │ │ └── time │ │ │ ├── JvmClockTest.scala │ │ │ ├── LocalDateTimeTest.scala │ │ │ └── TimeUtilsTest.scala │ │ └── models │ │ ├── UpdatableEntityTest.scala │ │ └── access │ │ ├── InMemoryEntityDatabaseTest.scala │ │ └── JvmEntityAccessBaseTest.scala └── shared │ └── src │ └── main │ └── scala │ ├── app │ ├── AppVersion.scala │ ├── api │ │ ├── Picklers.scala │ │ └── ScalaJsApi.scala │ ├── common │ │ ├── CaseFormats.scala │ │ ├── MarkdownConverter.scala │ │ ├── document │ │ │ └── UserDocument.scala │ │ └── testing │ │ │ └── TestObjects.scala │ └── models │ │ ├── access │ │ └── ModelFields.scala │ │ ├── document │ │ ├── DocumentEntity.scala │ │ ├── DocumentPermissionAndPlacement.scala │ │ └── TaskEntity.scala │ │ ├── modification │ │ └── EntityTypes.scala │ │ └── user │ │ └── User.scala │ └── hydro │ ├── api │ ├── PicklableDbQuery.scala │ ├── PicklableModelField.scala │ ├── ScalaJsApiRequest.scala │ └── StandardPicklers.scala │ ├── common │ ├── Annotations.scala │ ├── CollectionUtils.scala │ ├── Formatting.scala │ ├── GlobalStopwatch.scala │ ├── GuavaReplacement.scala │ ├── I18n.scala │ ├── LoggingUtils.scala │ ├── OrderToken.scala │ ├── Require.scala │ ├── ScalaUtils.scala │ ├── SerializingTaskQueue.scala │ ├── StringUtils.scala │ ├── Tags.scala │ ├── testing │ │ ├── FakeClock.scala │ │ └── FakeI18n.scala │ └── time │ │ ├── Clock.scala │ │ ├── JavaTimeImplicits.scala │ │ ├── LocalDateTime.scala │ │ ├── LocalDateTimes.scala │ │ └── TimeUtils.scala │ └── models │ ├── Entity.scala │ ├── UpdatableEntity.scala │ ├── access │ ├── DbQuery.scala │ ├── DbQueryExecutor.scala │ ├── DbQueryImplicits.scala │ ├── DbResultSet.scala │ ├── EntityAccess.scala │ └── ModelField.scala │ └── modification │ ├── EntityModification.scala │ └── EntityType.scala ├── build.sbt ├── conf └── application.conf ├── docker-compose-for-tests.yaml ├── docker-compose.yml ├── project ├── BuildSettings.scala ├── build.properties └── plugins.sbt ├── scalastyle-config.xml ├── screenshot.png └── unix-service.sh /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [nymanjens] 2 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Build and tests 2 | on: [pull_request, push] 3 | jobs: 4 | test: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - name: Checkout 8 | uses: actions/checkout@v2 9 | - name: Downgrade NPM version 10 | uses: actions/setup-node@v2 11 | with: 12 | node-version: '8' 13 | - name: Setup JDK 14 | uses: actions/setup-java@v2 15 | with: 16 | distribution: temurin 17 | java-version: 11 18 | - name: Setup sbt launcher 19 | uses: sbt/setup-sbt@v1 20 | - name: Build and Test 21 | run: sbt -v +test 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .cache 3 | .history 4 | .tmpBin 5 | /*.iml 6 | /.classpath 7 | /.idea 8 | /.idea/workspace.xml 9 | /.idea_modules 10 | /.project 11 | /.settings 12 | /out 13 | /RUNNING_PID 14 | bin/ 15 | database.db 16 | logs 17 | project/project 18 | project/target 19 | target 20 | tmp 21 | node_modules/ 22 | package-lock.json 23 | package.json 24 | .bsp 25 | -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "esversion":6 3 | } 4 | -------------------------------------------------------------------------------- /.scalafmt.conf: -------------------------------------------------------------------------------- 1 | project.git = true 2 | project.excludeFilters = [ 3 | project/BuildSettings.scala 4 | build.sbt 5 | ] 6 | 7 | style = default 8 | maxColumn = 110 9 | align.openParenCallSite = false 10 | rewrite.rules = [RedundantParens, PreferCurlyFors, ExpandImportSelectors] 11 | trailingCommas = multiple 12 | docstrings.style = Asterisk 13 | -------------------------------------------------------------------------------- /Dockerfile.test: -------------------------------------------------------------------------------- 1 | FROM openlaw/scala-builder:node AS builder 2 | 3 | WORKDIR /src 4 | RUN apk add --update nodejs npm 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This software is licensed under the Apache 2 license, quoted below. 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); you may not use this project except in compliance with 4 | the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0. 5 | 6 | Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an 7 | "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific 8 | language governing permissions and limitations under the License. -------------------------------------------------------------------------------- /app/js/client/src/main/scala/Main.scala: -------------------------------------------------------------------------------- 1 | import app.flux.ClientApp 2 | 3 | object Main { 4 | def main(args: Array[String]): Unit = { 5 | ClientApp.main() 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /app/js/client/src/test/scala/app/common/testing/JsTestObjects.scala: -------------------------------------------------------------------------------- 1 | package app.common.testing 2 | 3 | import hydro.common.OrderToken 4 | import app.common.testing.TestObjects._ 5 | import app.models.document.Document.DetachedCursor 6 | import app.models.document.Document.DetachedSelection 7 | import app.models.document.Document.IndexedCursor 8 | import app.models.document.Document.IndexedSelection 9 | import app.models.document.Document 10 | import app.models.document.Task 11 | import app.models.document.TextWithMarkup 12 | import app.models.document.TextWithMarkup.Formatting 13 | import hydro.common.time.LocalDateTime 14 | 15 | import scala.collection.immutable.Seq 16 | 17 | object JsTestObjects { 18 | 19 | val taskA = newTask("Task A", orderToken = orderTokenA, indentation = 2) 20 | val taskB = newTask("Task B", orderToken = orderTokenB, indentation = 3) 21 | val taskC = newTask("Task C", orderToken = orderTokenC) 22 | val taskD = newTask("Task D", orderToken = orderTokenD) 23 | val taskE = newTask("Task E", orderToken = orderTokenE) 24 | def testTask = taskA 25 | 26 | def testSelection = IndexedSelection.singleton(IndexedCursor(2, 123)) 27 | def testDetachedCursor = DetachedCursor(taskA, 12938) 28 | def testDetachedSelection = DetachedSelection(testDetachedCursor, testDetachedCursor) 29 | 30 | def textWithMarkup(string: String, formatting: Formatting = Formatting.none): TextWithMarkup = { 31 | TextWithMarkup.create(string, formatting, alreadySanitized = true) 32 | } 33 | 34 | def newTask( 35 | contentString: String = null, 36 | content: TextWithMarkup = null, 37 | orderToken: OrderToken = orderTokenB, 38 | indentation: Int = 2, 39 | collapsed: Boolean = false, 40 | checked: Boolean = false, 41 | delayedUntil: Option[LocalDateTime] = Some(testDate), 42 | tags: Seq[String] = Seq("test-tag"), 43 | ): Task = { 44 | require(content != null || contentString != null) 45 | implicit val document = newDocument() 46 | implicit val user = TestObjects.testUser 47 | 48 | Task.withRandomId( 49 | content = Option(content) getOrElse TextWithMarkup 50 | .create(contentString, formatting = Formatting.none, alreadySanitized = true), 51 | orderToken = orderToken, 52 | indentation = indentation, 53 | collapsed = collapsed, 54 | checked = checked, 55 | delayedUntil = delayedUntil, 56 | tags = tags, 57 | ) 58 | } 59 | 60 | def newDocument(tasks: Task*): Document = 61 | new Document(id = 12873, name = "test document", tasks = Seq(tasks: _*)) 62 | } 63 | -------------------------------------------------------------------------------- /app/js/client/src/test/scala/app/common/testing/TestModule.scala: -------------------------------------------------------------------------------- 1 | package app.common.testing 2 | 3 | import app.api.ScalaJsApi.GetInitialDataResponse 4 | import app.flux.react.app.document.EditHistory 5 | import app.flux.stores.document.AllDocumentsStore 6 | import app.flux.stores.document.DocumentSelectionStore 7 | import app.flux.stores.document.DocumentStoreFactory 8 | import app.flux.stores.GlobalMessagesStore 9 | import hydro.common.testing.FakeClock 10 | import hydro.common.testing.FakeI18n 11 | import hydro.common.testing.FakeJsEntityAccess 12 | import hydro.common.testing.FakeRouterContext 13 | import hydro.flux.action.Dispatcher 14 | import hydro.models.access.HydroPushSocketClientFactory 15 | 16 | import scala.collection.immutable.Seq 17 | 18 | class TestModule { 19 | 20 | // ******************* Fake implementations ******************* // 21 | implicit lazy val fakeEntityAccess = new FakeJsEntityAccess 22 | implicit lazy val fakeClock = new FakeClock 23 | implicit lazy val fakeDispatcher = new Dispatcher.Fake 24 | implicit lazy val fakeI18n = new FakeI18n 25 | implicit lazy val testUser = TestObjects.testUser 26 | implicit lazy val fakeScalaJsApiClient = new FakeScalaJsApiClient 27 | implicit lazy val fakeRouterContext = new FakeRouterContext 28 | implicit val fakeGetInitialDataResponse = GetInitialDataResponse( 29 | user = testUser, 30 | allAccessibleDocuments = Seq(), 31 | i18nMessages = Map(), 32 | nextUpdateToken = "", 33 | ) 34 | 35 | // ******************* Non-fake implementations ******************* // 36 | implicit lazy val hydroPushSocketClientFactory: HydroPushSocketClientFactory = 37 | new HydroPushSocketClientFactory 38 | implicit lazy val documentSelectionStore: DocumentSelectionStore = new DocumentSelectionStore 39 | implicit lazy val documentStoreFactory: DocumentStoreFactory = new DocumentStoreFactory 40 | implicit lazy val editHistory: EditHistory = new EditHistory 41 | implicit lazy val allDocumentsStore: AllDocumentsStore = new AllDocumentsStore 42 | implicit lazy val gobalMessagesStore: GlobalMessagesStore = new GlobalMessagesStore 43 | } 44 | -------------------------------------------------------------------------------- /app/js/client/src/test/scala/app/flux/react/app/document/MobileTaskEditorTest.scala: -------------------------------------------------------------------------------- 1 | package app.flux.react.app.document 2 | 3 | import utest._ 4 | 5 | object MobileTaskEditorTest extends TestSuite { 6 | 7 | override def tests = TestSuite { 8 | val editor = (new Module).mobileTaskEditor 9 | 10 | "deriveReplacementString" - { 11 | editor.deriveReplacementString(oldContent = "abc", newContent = "def") ==> "" 12 | editor.deriveReplacementString(oldContent = "abc", newContent = "abcd") ==> "d" 13 | editor.deriveReplacementString(oldContent = "abc", newContent = "abc def") ==> " def" 14 | } 15 | } 16 | 17 | private class Module extends app.common.testing.TestModule { 18 | val mobileTaskEditor = new MobileTaskEditor 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /app/js/client/src/test/scala/app/flux/react/app/document/TaskEditorUtilsTest.scala: -------------------------------------------------------------------------------- 1 | package app.flux.react.app.document 2 | 3 | import app.common.testing.JsTestObjects._ 4 | import app.flux.react.app.document.TaskEditorUtils.TaskInSeq 5 | import utest._ 6 | 7 | import scala.collection.immutable.Seq 8 | 9 | object TaskEditorUtilsTest extends TestSuite { 10 | 11 | override def tests = TestSuite { 12 | 13 | "applyCollapsedProperty" - { 14 | "empty Seq" - { 15 | TaskEditorUtils.applyCollapsedProperty(Seq()) ==> Stream.empty 16 | } 17 | "no collapsed tasks" - { 18 | val task1 = taskA.copyForTests(indentation = 0) 19 | val task2 = taskB.copyForTests(indentation = 2) 20 | val task3 = taskC.copyForTests(indentation = 0) 21 | TaskEditorUtils.applyCollapsedProperty(Seq(task1, task2, task3)).toVector ==> Seq( 22 | TaskInSeq(task1, index = 0, maybeAmountCollapsed = None, isRoot = true, isLeaf = false), 23 | TaskInSeq(task2, index = 1, maybeAmountCollapsed = None, isRoot = false, isLeaf = true), 24 | TaskInSeq(task3, index = 2, maybeAmountCollapsed = None, isRoot = true, isLeaf = true), 25 | ) 26 | } 27 | "collapsed tasks" - { 28 | val task1 = taskA.copyForTests(indentation = 0, collapsed = true) 29 | val task2 = taskB.copyForTests(indentation = 0, collapsed = true) 30 | val task3 = taskC.copyForTests(indentation = 2) 31 | val task4 = taskD.copyForTests(indentation = 0) 32 | TaskEditorUtils.applyCollapsedProperty(Seq(task1, task2, task3, task4)).toVector ==> Seq( 33 | TaskInSeq(task1, index = 0, maybeAmountCollapsed = Some(0), isRoot = true, isLeaf = true), 34 | TaskInSeq(task2, index = 1, maybeAmountCollapsed = Some(1), isRoot = true, isLeaf = false), 35 | TaskInSeq(task4, index = 3, maybeAmountCollapsed = None, isRoot = true, isLeaf = true), 36 | ) 37 | } 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /app/js/client/src/test/scala/app/flux/stores/document/DocumentSelectionStoreTest.scala: -------------------------------------------------------------------------------- 1 | package app.flux.stores.document 2 | 3 | import app.models.document.Document.IndexedCursor 4 | import app.models.document.Document.IndexedSelection 5 | import utest._ 6 | 7 | object DocumentSelectionStoreTest extends TestSuite { 8 | 9 | override def tests = TestSuite { 10 | 11 | val store: DocumentSelectionStore = new DocumentSelectionStore() 12 | 13 | "serialize and deserialize" - { 14 | def testBackAndForth(selection: IndexedSelection) = { 15 | store.deserialize(store.serialize(selection)) ==> selection 16 | } 17 | 18 | testBackAndForth(IndexedSelection.singleton(IndexedCursor(0, 0))) 19 | testBackAndForth(IndexedSelection.singleton(IndexedCursor(10, 11))) 20 | testBackAndForth(IndexedSelection(IndexedCursor(10, 11), IndexedCursor(100, 121))) 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /app/js/client/src/test/scala/app/scala2js/AppConvertersTest.scala: -------------------------------------------------------------------------------- 1 | package app.scala2js 2 | 3 | import java.time.Month.MARCH 4 | 5 | import app.common.testing.TestObjects._ 6 | import app.models.document.DocumentEntity 7 | import app.models.document.DocumentPermissionAndPlacement 8 | import app.models.document.TaskEntity 9 | import app.models.user.User 10 | import app.scala2js.AppConverters._ 11 | import hydro.common.time.LocalDateTime 12 | import hydro.scala2js.Scala2Js 13 | import utest._ 14 | 15 | object AppConvertersTest extends TestSuite { 16 | val dateTime = LocalDateTime.of(2022, MARCH, 13, 12, 13) 17 | 18 | override def tests = TestSuite { 19 | 20 | "fromEntityType" - { 21 | fromEntityType(User.Type) ==> UserConverter 22 | } 23 | 24 | "UserConverter: testToJsAndBack" - { 25 | testToJsAndBack[User](testUserRedacted) 26 | } 27 | "DocumentEntityConverter: testToJsAndBack" - { 28 | testToJsAndBack[DocumentEntity](testDocumentEntity) 29 | } 30 | "DocumentPermissionAndPlacementConverter: testToJsAndBack" - { 31 | testToJsAndBack[DocumentPermissionAndPlacement](testDocumentPermissionAndPlacement) 32 | } 33 | "TaskEntityConverter: testToJsAndBack" - { 34 | testToJsAndBack[TaskEntity](testTaskEntity) 35 | } 36 | } 37 | 38 | private def testToJsAndBack[T: Scala2Js.Converter](value: T) = { 39 | val jsValue = Scala2Js.toJs[T](value) 40 | val generated = Scala2Js.toScala[T](jsValue) 41 | generated ==> value 42 | 43 | // Test a second time, to check that `jsValue` is not mutated 44 | val generated2 = Scala2Js.toScala[T](jsValue) 45 | generated2 ==> value 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /app/js/client/src/test/scala/hydro/common/JsI18nTest.scala: -------------------------------------------------------------------------------- 1 | package hydro.common 2 | 3 | import utest.TestSuite 4 | import utest._ 5 | 6 | object JsI18nTest extends TestSuite { 7 | 8 | override def tests = TestSuite { 9 | 10 | "apply()" - { 11 | val jsI18n = new JsI18n( 12 | Map( 13 | "test.message" -> "This is a test message.", 14 | "test.message.with.args" -> "This is a test message with args {0} and {1}.", 15 | ) 16 | ) 17 | 18 | jsI18n("test.message") ==> "This is a test message." 19 | jsI18n("test.message.with.args", "arg1", 2.2) ==> "This is a test message with args arg1 and 2.2." 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /app/js/client/src/test/scala/hydro/common/testing/FakeRouterContext.scala: -------------------------------------------------------------------------------- 1 | package hydro.common.testing 2 | 3 | import hydro.common.JsLoggingUtils.LogExceptionsCallback 4 | import hydro.flux.router.Page 5 | import hydro.flux.router.RouterContext 6 | import hydro.flux.router.StandardPages 7 | import japgolly.scalajs.react.extra.router.Path 8 | import japgolly.scalajs.react.vdom.html_<^.VdomTagOf 9 | import japgolly.scalajs.react.vdom.html_<^._ 10 | import org.scalajs.dom.html 11 | 12 | import scala.collection.mutable 13 | 14 | class FakeRouterContext extends RouterContext { 15 | private val allowedPagesToNavigateTo: mutable.Set[Page] = mutable.Set() 16 | private var _currentPage: Page = StandardPages.UserProfile 17 | 18 | // **************** API implementation: Getters **************** // 19 | override def currentPage = _currentPage 20 | override def toPath(page: Page): Path = Path("/app/" + page.getClass.getSimpleName) 21 | override def anchorWithHrefTo(page: Page): VdomTagOf[html.Anchor] = 22 | <.a(^.onClick --> LogExceptionsCallback(setPage(page))) 23 | 24 | // **************** API implementation: Setters **************** // 25 | override def setPath(path: Path): Unit = ??? 26 | override def setPage(target: Page) = { 27 | if (!(allowedPagesToNavigateTo contains target)) { 28 | throw new AssertionError(s"Not allowed to navigate to $target") 29 | } 30 | } 31 | 32 | // **************** Helper methods for tests **************** // 33 | def allowNavigationTo(page: Page): Unit = { 34 | allowedPagesToNavigateTo.add(page) 35 | } 36 | 37 | def setCurrentPage(page: Page): Unit = { 38 | _currentPage = page 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /app/js/client/src/test/scala/hydro/common/testing/ModificationsBuffer.scala: -------------------------------------------------------------------------------- 1 | package hydro.common.testing 2 | 3 | import app.api.ScalaJsApi.UpdateToken 4 | import hydro.models.modification.EntityModification 5 | import hydro.models.modification.EntityType 6 | import hydro.models.Entity 7 | 8 | import scala.collection.immutable.Seq 9 | import scala.collection.mutable 10 | 11 | final class ModificationsBuffer { 12 | 13 | private val buffer: mutable.Buffer[ModificationWithToken] = mutable.Buffer() 14 | 15 | // **************** Getters ****************// 16 | def getModifications(updateToken: UpdateToken = ModificationsBuffer.startToken): Seq[EntityModification] = 17 | Seq({ 18 | for (m <- buffer if m.updateToken.toLong >= updateToken.toLong) yield m.modification 19 | }: _*) 20 | 21 | def getAllEntitiesOfType[E <: Entity](implicit entityType: EntityType[E]): Seq[E] = { 22 | val entitiesMap = mutable.LinkedHashMap[Long, E]() 23 | for (m <- buffer if m.modification.entityType == entityType) { 24 | m.modification match { 25 | case EntityModification.Add(entity) => entitiesMap.put(entity.id, entityType.checkRightType(entity)) 26 | case EntityModification.Update(entity) => 27 | entitiesMap.put(entity.id, entityType.checkRightType(entity)) 28 | case EntityModification.Remove(entityId) => entitiesMap.remove(entityId) 29 | } 30 | } 31 | entitiesMap.values.toVector 32 | } 33 | 34 | def nextUpdateToken: UpdateToken = { 35 | if (buffer.isEmpty) { 36 | ModificationsBuffer.startToken 37 | } else { 38 | (buffer.map(_.updateToken.toLong).max + 1).toString 39 | } 40 | } 41 | 42 | def isEmpty: Boolean = buffer.isEmpty 43 | 44 | // **************** Setters ****************// 45 | def addModifications(modifications: Seq[EntityModification]): Unit = { 46 | for (modification <- modifications) { 47 | buffer += ModificationWithToken(modification, nextUpdateToken) 48 | } 49 | } 50 | 51 | def addEntities[E <: Entity: EntityType](entities: Seq[E]): Unit = { 52 | addModifications(entities.map(e => EntityModification.Add(e))) 53 | } 54 | 55 | def clear(): Unit = { 56 | buffer.clear() 57 | } 58 | 59 | // **************** Inner types ****************// 60 | private case class ModificationWithToken(modification: EntityModification, updateToken: UpdateToken) 61 | } 62 | 63 | object ModificationsBuffer { 64 | private val startToken: UpdateToken = "0" 65 | } 66 | -------------------------------------------------------------------------------- /app/js/client/src/test/scala/hydro/flux/action/DispatcherTest.scala: -------------------------------------------------------------------------------- 1 | package hydro.flux.action 2 | 3 | import app.common.testing.TestObjects.testUserPrototype 4 | import utest._ 5 | 6 | import scala.async.Async.async 7 | import scala.async.Async.await 8 | import scala.collection.mutable 9 | import scala.concurrent.Future 10 | import scala.scalajs.concurrent.JSExecutionContext.Implicits.queue 11 | 12 | object DispatcherTest extends TestSuite { 13 | 14 | override def tests = TestSuite { 15 | val dispatcher: Dispatcher.Impl = new Dispatcher.Impl() 16 | val testAction = StandardActions.UpsertUser(testUserPrototype) 17 | 18 | "dispatches actions to listeners, including Done action" - async { 19 | val dispatchedActions: mutable.Buffer[Action] = mutable.Buffer() 20 | dispatcher.registerAsync(action => { 21 | dispatchedActions += action 22 | Future.successful((): Unit) 23 | }) 24 | 25 | await(dispatcher.dispatch(testAction)) 26 | 27 | dispatchedActions ==> mutable.Buffer(testAction, StandardActions.Done(testAction)) 28 | } 29 | 30 | "does not allow dispatching during the sync part of a callback" - async { 31 | var dispatched = false 32 | dispatcher.registerAsync(action => { 33 | try { 34 | dispatcher.dispatch(testAction) 35 | throw new java.lang.AssertionError("expected IllegalArgumentException") 36 | } catch { 37 | case e: IllegalArgumentException => // expected 38 | } 39 | dispatched = true 40 | Future.successful((): Unit) 41 | }) 42 | 43 | await(dispatcher.dispatch(testAction)) 44 | 45 | dispatched ==> true 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /app/js/client/src/test/scala/hydro/models/access/SingletonKeyTest.scala: -------------------------------------------------------------------------------- 1 | package hydro.models.access 2 | 3 | import app.common.testing.TestObjects._ 4 | import hydro.models.access.SingletonKey.DbStatus 5 | import hydro.models.access.SingletonKey.DbStatusKey 6 | import hydro.scala2js.Scala2Js 7 | import utest._ 8 | 9 | import scala.language.reflectiveCalls 10 | 11 | object SingletonKeyTest extends TestSuite { 12 | override def tests = TestSuite { 13 | "DbStatusKey.valueConverter" - { 14 | "to JS and back" - { 15 | testToJsAndBack[DbStatus](DbStatus.Ready)(DbStatusKey.valueConverter) 16 | testToJsAndBack[DbStatus](DbStatus.Populating(testInstant))(DbStatusKey.valueConverter) 17 | } 18 | } 19 | } 20 | 21 | private def testToJsAndBack[T: Scala2Js.Converter](value: T) = { 22 | val jsValue = Scala2Js.toJs[T](value) 23 | val generated = Scala2Js.toScala[T](jsValue) 24 | generated ==> value 25 | 26 | // Test a second time, to check that `jsValue` is not mutated 27 | val generated2 = Scala2Js.toScala[T](jsValue) 28 | generated2 ==> value 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /app/js/shared/src/main/npm-packages/global-mousetrap/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var Mousetrap = require("mousetrap") 4 | 5 | // Fork of mousetrap-global-bind 6 | // (https://github.com/ccampbell/mousetrap/blob/1.6.1/plugins/global-bind/mousetrap-global-bind.js) 7 | // making it into an npm package. 8 | 9 | if(Mousetrap.prototype) { 10 | var _globalCallbacks = {}; 11 | var _originalStopCallback = Mousetrap.prototype.stopCallback; 12 | 13 | Mousetrap.prototype.stopCallback = function(e, element, combo, sequence) { 14 | var self = this; 15 | if (self.paused) { 16 | return true; 17 | } 18 | if (_globalCallbacks[combo] || _globalCallbacks[sequence]) { 19 | return false; 20 | } 21 | return _originalStopCallback.call(self, e, element, combo); 22 | }; 23 | 24 | Mousetrap.init(); 25 | 26 | module.exports = { 27 | bindGlobal: function(keys, callback, action) { 28 | Mousetrap.bind(keys, callback, action); 29 | 30 | if (keys instanceof Array) { 31 | for (var i = 0; i < keys.length; i++) { 32 | _globalCallbacks[keys[i]] = true; 33 | } 34 | return; 35 | } 36 | 37 | _globalCallbacks[keys] = true; 38 | } 39 | }; 40 | } else { 41 | module.exports = { 42 | bindGlobal: function() { 43 | console.log( 44 | "Error: Called bindGlobal but Mousetrap was undefined", Mousetrap); 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /app/js/shared/src/main/npm-packages/global-mousetrap/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "global-mousetrap", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "dependencies": { 6 | "mousetrap": "^1.6.2" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /app/js/shared/src/main/scala/app/api/Module.scala: -------------------------------------------------------------------------------- 1 | package app.api 2 | 3 | final class Module { 4 | 5 | implicit lazy val scalaJsApiClient: ScalaJsApiClient = new ScalaJsApiClient.Impl 6 | } 7 | -------------------------------------------------------------------------------- /app/js/shared/src/main/scala/app/flux/ClientAppModule.scala: -------------------------------------------------------------------------------- 1 | package app.flux 2 | 3 | import app.api.ScalaJsApi.GetInitialDataResponse 4 | import app.api.ScalaJsApiClient 5 | import app.models.user.User 6 | import hydro.flux.action.Module 7 | import hydro.flux.router.Page 8 | import japgolly.scalajs.react.extra.router.Router 9 | 10 | final class ClientAppModule(implicit 11 | getInitialDataResponse: GetInitialDataResponse, 12 | scalaJsApiClient: ScalaJsApiClient, 13 | ) { 14 | 15 | // Unpack arguments 16 | implicit private val user: User = getInitialDataResponse.user 17 | 18 | // Create and unpack common modules 19 | private val commonTimeModule = new hydro.common.time.Module 20 | implicit private val clock = commonTimeModule.clock 21 | private val commonModule = new hydro.common.Module 22 | implicit private val i18n = commonModule.i18n 23 | 24 | // Create and unpack Models Access module 25 | val modelsAccessModule = new app.models.access.Module 26 | implicit val entityAccess = modelsAccessModule.entityAccess 27 | implicit val hydroPushSocketClientFactory = modelsAccessModule.hydroPushSocketClientFactory 28 | 29 | // Create and unpack Flux action module 30 | private val fluxActionModule = new Module 31 | implicit private val dispatcher = fluxActionModule.dispatcher 32 | 33 | // Create and unpack Flux store module 34 | private val fluxStoresModule = new app.flux.stores.Module 35 | implicit private val globalMessagesStore = fluxStoresModule.globalMessagesStore 36 | implicit private val pageLoadingStateStore = fluxStoresModule.pageLoadingStateStore 37 | implicit private val pendingModificationsStore = fluxStoresModule.pendingModificationsStore 38 | implicit private val applicationIsOnlineStore = fluxStoresModule.applicationIsOnlineStore 39 | implicit private val localDatabaseHasBeenLoadedStore = fluxStoresModule.localDatabaseHasBeenLoadedStore 40 | implicit private val userStore = fluxStoresModule.userStore 41 | implicit private val databaseExplorerStoreFactory = fluxStoresModule.databaseExplorerStoreFactory 42 | implicit private val allDocumentsStore = fluxStoresModule.allDocumentsStore 43 | implicit private val documentStoreFactory = fluxStoresModule.documentStoreFactory 44 | implicit private val documentSelectionStore = fluxStoresModule.documentSelectionStore 45 | 46 | // Create other Flux modules 47 | implicit private val reactAppModule = new app.flux.react.app.Module 48 | implicit private val routerModule = new app.flux.router.Module 49 | 50 | val router: Router[Page] = routerModule.router 51 | } 52 | -------------------------------------------------------------------------------- /app/js/shared/src/main/scala/app/flux/action/AppActions.scala: -------------------------------------------------------------------------------- 1 | package app.flux.action 2 | 3 | import app.common.document.UserDocument 4 | import hydro.common.OrderToken 5 | import app.models.document.DocumentEntity 6 | import hydro.flux.action.Action 7 | 8 | import scala.collection.immutable.Seq 9 | 10 | object AppActions { 11 | 12 | // **************** Document-related actions **************** // 13 | case class AddEmptyDocument(name: String, orderToken: OrderToken) extends Action 14 | case class UpdateDocuments(documents: Seq[UserDocument]) extends Action 15 | case class RemoveDocument(existingDocument: UserDocument) extends Action 16 | } 17 | -------------------------------------------------------------------------------- /app/js/shared/src/main/scala/app/flux/react/app/Layout.scala: -------------------------------------------------------------------------------- 1 | package app.flux.react.app 2 | 3 | import hydro.flux.react.uielements.SbadminLayout 4 | import hydro.flux.router.RouterContext 5 | import japgolly.scalajs.react._ 6 | import japgolly.scalajs.react.vdom.html_<^._ 7 | 8 | final class Layout(implicit menu: Menu, sbadminLayout: SbadminLayout) { 9 | 10 | private val component = ScalaComponent 11 | .builder[Props](getClass.getSimpleName) 12 | .renderPC { (_, props, children) => 13 | implicit val router = props.router 14 | sbadminLayout(title = "Task Keeper", leftMenu = menu(), pageContent = <.span(children)) 15 | } 16 | .build 17 | 18 | // **************** API ****************// 19 | def apply(router: RouterContext)(children: VdomNode*): VdomElement = { 20 | component(Props(router))(children: _*) 21 | } 22 | 23 | // **************** Private inner types ****************// 24 | private case class Props(router: RouterContext) 25 | } 26 | -------------------------------------------------------------------------------- /app/js/shared/src/main/scala/app/flux/react/app/Menu.scala: -------------------------------------------------------------------------------- 1 | package app.flux.react.app 2 | 3 | import app.common.document.UserDocument 4 | import app.flux.router.AppPages 5 | import app.flux.stores.document.AllDocumentsStore 6 | import app.models.document.DocumentEntity 7 | import hydro.common.I18n 8 | import hydro.flux.react.uielements.SbadminMenu 9 | import hydro.flux.react.uielements.SbadminMenu.MenuItem 10 | import hydro.flux.react.HydroReactComponent 11 | import hydro.flux.router.RouterContext 12 | import japgolly.scalajs.react._ 13 | import japgolly.scalajs.react.vdom.html_<^._ 14 | 15 | import scala.collection.immutable.Seq 16 | 17 | private[app] final class Menu(implicit 18 | i18n: I18n, 19 | allDocumentsStore: AllDocumentsStore, 20 | sbadminMenu: SbadminMenu, 21 | ) extends HydroReactComponent { 22 | 23 | // **************** API ****************// 24 | def apply()(implicit router: RouterContext): VdomElement = { 25 | component(Props(router)) 26 | } 27 | 28 | // **************** Implementation of HydroReactComponent methods ****************// 29 | override protected val config = ComponentConfig(backendConstructor = new Backend(_), initialState = State()) 30 | .withStateStoresDependency(allDocumentsStore, _.copy(allDocuments = allDocumentsStore.state.allDocuments)) 31 | 32 | // **************** Implementation of HydroReactComponent types ****************// 33 | protected case class Props(router: RouterContext) 34 | protected case class State(allDocuments: Seq[UserDocument] = allDocumentsStore.state.allDocuments) 35 | 36 | protected class Backend($ : BackendScope[Props, State]) extends BackendBase($) { 37 | 38 | override def render(props: Props, state: State): VdomElement = { 39 | sbadminMenu( 40 | menuItems = Seq( 41 | for (document <- state.allDocuments) 42 | yield MenuItem(document.name, AppPages.TaskList(document.documentId)), 43 | Seq( 44 | MenuItem( 45 | i18n("app.document-administration.html"), 46 | AppPages.DocumentAdministration, 47 | shortcuts = Seq("shift+alt+d"), 48 | ) 49 | ), 50 | ), 51 | enableSearch = false, 52 | router = props.router, 53 | ) 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /app/js/shared/src/main/scala/app/flux/react/app/document/Module.scala: -------------------------------------------------------------------------------- 1 | package app.flux.react.app.document 2 | 3 | import app.flux.stores.document.AllDocumentsStore 4 | import app.flux.stores.document.DocumentSelectionStore 5 | import app.flux.stores.document.DocumentStoreFactory 6 | import app.models.user.User 7 | import hydro.common.I18n 8 | import hydro.common.time.Clock 9 | import hydro.flux.action.Dispatcher 10 | import hydro.flux.react.uielements.PageHeader 11 | import hydro.models.access.JsEntityAccess 12 | import app.flux.stores.GlobalMessagesStore 13 | 14 | final class Module(implicit 15 | i18n: I18n, 16 | user: User, 17 | dispatcher: Dispatcher, 18 | clock: Clock, 19 | globalMessagesStore: GlobalMessagesStore, 20 | entityAccess: JsEntityAccess, 21 | documentStoreFactory: DocumentStoreFactory, 22 | documentSelectionStore: DocumentSelectionStore, 23 | allDocumentsStore: AllDocumentsStore, 24 | pageHeader: PageHeader, 25 | ) { 26 | 27 | private implicit lazy val editHistory = new EditHistory 28 | 29 | private implicit lazy val desktopTaskEditor = new DesktopTaskEditor 30 | private implicit lazy val mobileTaskEditor = new MobileTaskEditor 31 | 32 | implicit lazy val taskList = new TaskList 33 | implicit lazy val documentAdministration = new DocumentAdministration 34 | } 35 | -------------------------------------------------------------------------------- /app/js/shared/src/main/scala/app/flux/react/app/document/TaskEditorUtils.scala: -------------------------------------------------------------------------------- 1 | package app.flux.react.app.document 2 | 3 | import app.models.document.Task 4 | import hydro.flux.react.uielements.Bootstrap.Variant 5 | import hydro.flux.react.uielements.BootstrapTags 6 | import hydro.flux.react.uielements.Bootstrap 7 | import hydro.flux.react.uielements.BootstrapTags.bootstrapClassSuffixOptions 8 | import japgolly.scalajs.react.vdom.VdomElement 9 | import japgolly.scalajs.react.vdom.html_<^.^ 10 | 11 | import scala.collection.immutable.Seq 12 | import scala.math.abs 13 | 14 | private[document] object TaskEditorUtils { 15 | 16 | case class TaskInSeq( 17 | task: Task, 18 | index: Int, 19 | maybeAmountCollapsed: Option[Int], 20 | isRoot: Boolean, 21 | isLeaf: Boolean, 22 | ) 23 | 24 | def applyCollapsedProperty(tasks: Seq[Task]): Stream[TaskInSeq] = { 25 | def getAmountCollapsed(tasksWithIndex: Stream[(Task, Int)], collapsedIndentation: Int): Int = { 26 | tasksWithIndex.takeWhile(_._1.indentation > collapsedIndentation).size 27 | } 28 | 29 | def inner(tasksWithIndex: Stream[(Task, Int)]): Stream[TaskInSeq] = tasksWithIndex match { 30 | case (task, index) #:: rest => 31 | val maybeAmountCollapsed = 32 | if (task.collapsed) Some(getAmountCollapsed(rest, task.indentation)) else None 33 | 34 | val isRoot = task.indentation == 0 35 | val isLeaf = getOption(tasks, index = index + 1) match { 36 | case Some(nextTask) if nextTask.indentation > task.indentation => false 37 | case _ => true 38 | } 39 | 40 | val nextTaskInSeq = TaskInSeq( 41 | task = task, 42 | index = index, 43 | maybeAmountCollapsed = maybeAmountCollapsed, 44 | isRoot = isRoot, 45 | isLeaf = isLeaf, 46 | ) 47 | nextTaskInSeq #:: inner(rest.drop(maybeAmountCollapsed getOrElse 0)) 48 | case Stream.Empty => Stream.Empty 49 | } 50 | 51 | inner(tasks.toStream.zipWithIndex) 52 | } 53 | 54 | def toStableBootstrapTagVariant(tag: String): Bootstrap.Variant = { 55 | if (tag.startsWith("#")) Bootstrap.Variant.default 56 | else BootstrapTags.toStableVariant(tag) 57 | } 58 | 59 | def maybeHideTagName(tag: String): String = { 60 | if (tag.startsWith("#")) "#" 61 | else tag 62 | } 63 | 64 | private def getOption[T](seq: Seq[T], index: Int): Option[T] = 65 | if (seq.indices.contains(index)) Some(seq(index)) else None 66 | } 67 | -------------------------------------------------------------------------------- /app/js/shared/src/main/scala/app/flux/router/AppPages.scala: -------------------------------------------------------------------------------- 1 | package app.flux.router 2 | 3 | import hydro.common.I18n 4 | import app.models.document.DocumentEntity 5 | import hydro.flux.router.Page 6 | import hydro.flux.router.Page.PageBase 7 | import hydro.models.access.EntityAccess 8 | 9 | import scala.async.Async.async 10 | import scala.async.Async.await 11 | import scala.scalajs.concurrent.JSExecutionContext.Implicits.queue 12 | 13 | object AppPages { 14 | 15 | // **************** Document views **************** // 16 | case object DocumentAdministration 17 | extends PageBase("app.document-administration", iconClass = "fa fa-pencil fa-fw") 18 | case class TaskList(documentId: Long) extends Page { 19 | override def title(implicit i18n: I18n, entityAccess: EntityAccess) = async { 20 | await(entityAccess.newQuery[DocumentEntity]().findById(documentId)).name 21 | } 22 | override def iconClass = "icon-list" 23 | } 24 | case class MobileTaskList(documentId: Long) extends Page { 25 | override def title(implicit i18n: I18n, entityAccess: EntityAccess) = async { 26 | await(entityAccess.newQuery[DocumentEntity]().findById(documentId)).name 27 | } 28 | override def iconClass = "fa fa-mobile" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /app/js/shared/src/main/scala/app/flux/router/Module.scala: -------------------------------------------------------------------------------- 1 | package app.flux.router 2 | 3 | import hydro.common.I18n 4 | import app.flux.stores.document.AllDocumentsStore 5 | import hydro.flux.action.Dispatcher 6 | import hydro.flux.router.Page 7 | import hydro.models.access.EntityAccess 8 | import japgolly.scalajs.react.extra.router._ 9 | 10 | final class Module(implicit 11 | reactAppModule: app.flux.react.app.Module, 12 | dispatcher: Dispatcher, 13 | i18n: I18n, 14 | allDocumentsStore: AllDocumentsStore, 15 | entityAccess: EntityAccess, 16 | ) { 17 | 18 | implicit lazy val router: Router[Page] = (new RouterFactory).createRouter() 19 | } 20 | -------------------------------------------------------------------------------- /app/js/shared/src/main/scala/app/flux/stores/Module.scala: -------------------------------------------------------------------------------- 1 | package app.flux.stores 2 | 3 | import app.api.ScalaJsApi.GetInitialDataResponse 4 | import app.api.ScalaJsApiClient 5 | import hydro.common.I18n 6 | import app.flux.stores.document.AllDocumentsStore 7 | import app.flux.stores.document.DocumentSelectionStore 8 | import app.flux.stores.document.DocumentStoreFactory 9 | import app.models.user.User 10 | import hydro.common.time.Clock 11 | import hydro.flux.action.Dispatcher 12 | import hydro.flux.stores.ApplicationIsOnlineStore 13 | import hydro.flux.stores.LocalDatabaseHasBeenLoadedStore 14 | import hydro.flux.stores.PageLoadingStateStore 15 | import hydro.flux.stores.UserStore 16 | import hydro.flux.stores.DatabaseExplorerStoreFactory 17 | import hydro.models.access.HydroPushSocketClientFactory 18 | import hydro.models.access.JsEntityAccess 19 | 20 | final class Module(implicit 21 | i18n: I18n, 22 | user: User, 23 | entityAccess: JsEntityAccess, 24 | dispatcher: Dispatcher, 25 | clock: Clock, 26 | scalaJsApiClient: ScalaJsApiClient, 27 | hydroPushSocketClientFactory: HydroPushSocketClientFactory, 28 | getInitialDataResponse: GetInitialDataResponse, 29 | ) { 30 | 31 | implicit val userStore = new UserStore 32 | implicit val databaseExplorerStoreFactory = new DatabaseExplorerStoreFactory 33 | implicit val allDocumentsStore = new AllDocumentsStore 34 | implicit val documentStoreFactory = new DocumentStoreFactory 35 | implicit val documentSelectionStore = new DocumentSelectionStore 36 | implicit val globalMessagesStore = new GlobalMessagesStore 37 | implicit val pageLoadingStateStore = new PageLoadingStateStore 38 | implicit val pendingModificationsStore = new PendingModificationsStore 39 | implicit val applicationIsOnlineStore = new ApplicationIsOnlineStore 40 | implicit val localDatabaseHasBeenLoadedStore = new LocalDatabaseHasBeenLoadedStore 41 | } 42 | -------------------------------------------------------------------------------- /app/js/shared/src/main/scala/app/flux/stores/document/DocumentSelectionStore.scala: -------------------------------------------------------------------------------- 1 | package app.flux.stores.document 2 | 3 | import hydro.common.Annotations.visibleForTesting 4 | import app.models.document.Document 5 | import app.models.document.Document.IndexedCursor 6 | import app.models.document.Document.IndexedSelection 7 | import org.scalajs.dom 8 | 9 | import scala.util.matching.Regex 10 | 11 | final class DocumentSelectionStore { 12 | 13 | def setSelection(documentId: Long, indexedSelection: IndexedSelection): Unit = { 14 | dom.window.localStorage.setItem(localStorageKey(documentId), serialize(indexedSelection)) 15 | } 16 | 17 | def getSelection(documentId: Long): IndexedSelection = { 18 | dom.window.localStorage.getItem(localStorageKey(documentId)) match { 19 | case null => IndexedSelection.nullInstance 20 | case item => deserialize(item) 21 | } 22 | } 23 | 24 | @visibleForTesting private[document] def serialize(selection: IndexedSelection): String = { 25 | val IndexedSelection(start, end) = selection 26 | s"${start.seqIndex},${start.offsetInTask};${end.seqIndex},${end.offsetInTask}" 27 | } 28 | private val deserializeRegex: Regex = raw"(\d+),(\d+);(\d+),(\d+)".r 29 | @visibleForTesting private[document] def deserialize(string: String): IndexedSelection = string match { 30 | case deserializeRegex(startIndex, startOffset, endIndex, endOffset) => 31 | IndexedSelection( 32 | IndexedCursor(startIndex.toInt, startOffset.toInt), 33 | IndexedCursor(endIndex.toInt, endOffset.toInt), 34 | ) 35 | case _ => IndexedSelection.nullInstance 36 | } 37 | 38 | private def localStorageKey(documentId: Long): String = { 39 | s"doc-sel-${documentId}" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /app/js/shared/src/main/scala/app/models/access/Module.scala: -------------------------------------------------------------------------------- 1 | package app.models.access 2 | 3 | import app.api.ScalaJsApi.GetInitialDataResponse 4 | import app.api.ScalaJsApiClient 5 | import app.models.document.DocumentEntity 6 | import app.models.document.DocumentPermissionAndPlacement 7 | import app.models.document.TaskEntity 8 | import app.models.modification.EntityTypes 9 | import app.models.user.User 10 | import hydro.common.time.Clock 11 | import hydro.models.access.EntitySyncLogic 12 | import hydro.models.access.HydroPushSocketClientFactory 13 | import hydro.models.access.HybridRemoteDatabaseProxy 14 | import hydro.models.access.JsEntityAccess 15 | import hydro.models.access.JsEntityAccessImpl 16 | import hydro.models.access.LocalDatabaseImpl 17 | import hydro.models.access.LocalDatabaseImpl.SecondaryIndexFunction 18 | 19 | import scala.collection.immutable.Seq 20 | 21 | final class Module(implicit 22 | user: User, 23 | clock: Clock, 24 | scalaJsApiClient: ScalaJsApiClient, 25 | getInitialDataResponse: GetInitialDataResponse, 26 | ) { 27 | 28 | implicit private val secondaryIndexFunction = Module.secondaryIndexFunction 29 | implicit private val entitySyncLogic = new EntitySyncLogic.FullySynced(EntityTypes.all) 30 | 31 | implicit val hydroPushSocketClientFactory: HydroPushSocketClientFactory = 32 | new HydroPushSocketClientFactory() 33 | 34 | implicit val entityAccess: JsEntityAccess = { 35 | val webWorkerModule = new hydro.models.access.webworker.Module() 36 | implicit val localDatabaseWebWorkerApiStub = webWorkerModule.localDatabaseWebWorkerApiStub 37 | val localDatabaseFuture = LocalDatabaseImpl.create(separateDbPerCollection = false) 38 | implicit val remoteDatabaseProxy = HybridRemoteDatabaseProxy.create(localDatabaseFuture) 39 | val entityAccess = new JsEntityAccessImpl() 40 | 41 | entityAccess.startCheckingForModifiedEntityUpdates() 42 | 43 | entityAccess 44 | } 45 | } 46 | object Module { 47 | val secondaryIndexFunction: SecondaryIndexFunction = SecondaryIndexFunction({ 48 | case User.Type => Seq() 49 | case DocumentEntity.Type => Seq() 50 | case DocumentPermissionAndPlacement.Type => Seq() 51 | case TaskEntity.Type => Seq(ModelFields.TaskEntity.documentId) 52 | }) 53 | } 54 | -------------------------------------------------------------------------------- /app/js/shared/src/main/scala/app/models/document/TaskIdAndIndex.scala: -------------------------------------------------------------------------------- 1 | package app.models.document 2 | 3 | import app.models.document.Document.IndexedCursor 4 | 5 | case class TaskIdAndIndex(taskId: Long, taskIndex: Int) { 6 | def inDocument(document: Document): TaskIdAndIndex = { 7 | if (document.tasksOption(taskIndex).exists(_.id == taskId)) { 8 | // This instance is consistent with the given document 9 | this 10 | } else { 11 | document.maybeIndexOf(taskId) match { 12 | // The taskIndex was updated, but the taskId is still present --> follow the existing taskId 13 | case Some(newTaskIndex) => TaskIdAndIndex(taskId = taskId, taskIndex = newTaskIndex) 14 | case None => 15 | document.tasksOption(taskIndex) match { 16 | case Some(newTask) => 17 | // The task no longer exists so fall back to the taskIndex 18 | TaskIdAndIndex(taskId = newTask.id, taskIndex = taskIndex) 19 | case None => 20 | TaskIdAndIndex(taskId = taskId, taskIndex = 0) 21 | } 22 | } 23 | } 24 | } 25 | 26 | } 27 | object TaskIdAndIndex { 28 | val nullInstance: TaskIdAndIndex = TaskIdAndIndex(taskId = -1, taskIndex = 0) 29 | 30 | def fromIndexedCursor(indexedCursor: IndexedCursor)(implicit document: Document): TaskIdAndIndex = { 31 | fromTaskIndex(indexedCursor.seqIndex) 32 | } 33 | def fromTaskIndex(seqIndex: Int)(implicit document: Document): TaskIdAndIndex = { 34 | TaskIdAndIndex( 35 | taskId = document.tasks(seqIndex).id, 36 | taskIndex = seqIndex, 37 | ) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /app/js/shared/src/main/scala/hydro/common/BrowserUtils.scala: -------------------------------------------------------------------------------- 1 | package hydro.common 2 | 3 | import hydro.common.JsLoggingUtils.logExceptions 4 | import org.scalajs.dom 5 | 6 | import scala.scalajs.js 7 | 8 | object BrowserUtils { 9 | 10 | lazy val isMobileOrTablet: Boolean = logExceptions { 11 | val navigator = dom.window.navigator 12 | val userAgent = maybeAsString(navigator.userAgent) 13 | val vendor = maybeAsString(navigator.asInstanceOf[js.Dynamic].vendor) 14 | val opera = maybeAsString(dom.window.asInstanceOf[js.Dynamic].opera) 15 | 16 | val stringToTest = userAgent orElse vendor orElse opera getOrElse "" 17 | 18 | stringContainsAnyOf( 19 | haystack = stringToTest, 20 | needles = Seq("android", "blackberry", "iphone", "ipad", "ipod", "opera mini", "iemobile", "wpdesktop"), 21 | ) 22 | } 23 | 24 | lazy val isFirefox: Boolean = logExceptions { 25 | val navigator = dom.window.navigator 26 | val userAgent = maybeAsString(navigator.userAgent) 27 | val vendor = maybeAsString(navigator.asInstanceOf[js.Dynamic].vendor) 28 | 29 | val stringToTest = userAgent orElse vendor getOrElse "" 30 | 31 | stringToTest contains "Firefox" 32 | } 33 | 34 | lazy val isMacOsX: Boolean = logExceptions { 35 | val navigator = dom.window.navigator 36 | navigator.platform.toLowerCase.startsWith("mac") 37 | } 38 | 39 | private def stringContainsAnyOf(haystack: String, needles: Seq[String]): Boolean = { 40 | needles.map(_.toLowerCase).exists(haystack.toLowerCase.contains) 41 | } 42 | 43 | private def maybeAsString(value: js.Any): Option[String] = { 44 | if (js.isUndefined(value)) { 45 | None 46 | } else { 47 | Some(value.asInstanceOf[String]) 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /app/js/shared/src/main/scala/hydro/common/JsI18n.scala: -------------------------------------------------------------------------------- 1 | package hydro.common 2 | 3 | /** 4 | * @param i18nMessages Maps key to the message with placeholders. 5 | */ 6 | private[common] final class JsI18n(i18nMessages: Map[String, String]) extends I18n { 7 | 8 | // ****************** Implementation of I18n trait ****************** // 9 | override def apply(key: String, args: Any*): String = { 10 | val messageWithPlaceholders = i18nMessages.get(key).getOrElse(key) 11 | messageFormat(messageWithPlaceholders, args: _*) 12 | } 13 | 14 | // ****************** Helper methods ****************** // 15 | /** 16 | * Formats given message to include arguments at the placeholders. 17 | * 18 | * @param messageWithPlaceholders e.g. "Debt of {0} to {1}". 19 | * @param args Arguments to insert into message. 20 | */ 21 | private def messageFormat(messageWithPlaceholders: String, args: Any*): String = { 22 | var result = messageWithPlaceholders 23 | for ((arg, i) <- args.zipWithIndex) { 24 | result = result.replaceAllLiterally(s"{$i}", arg.toString) 25 | } 26 | result 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /app/js/shared/src/main/scala/hydro/common/JsLoggingUtils.scala: -------------------------------------------------------------------------------- 1 | package hydro.common 2 | 3 | import japgolly.scalajs.react.CallbackTo 4 | import org.scalajs.dom.console 5 | 6 | import scala.concurrent.ExecutionContext 7 | import scala.concurrent.Future 8 | 9 | object JsLoggingUtils { 10 | 11 | def logExceptions[T](codeBlock: => T): T = { 12 | try { 13 | codeBlock 14 | } catch { 15 | case t: Throwable => 16 | console.log(s" Caught exception: $t") 17 | t.printStackTrace() 18 | throw t 19 | } 20 | } 21 | def logFailure[T](future: => Future[T])(implicit executor: ExecutionContext): Future[T] = { 22 | val theFuture = logExceptions(future) 23 | theFuture.failed.foreach { t => 24 | console.log(s" Caught exception: $t") 25 | t.printStackTrace() 26 | } 27 | theFuture 28 | } 29 | 30 | def LogExceptionsCallback[T](codeBlock: => T): CallbackTo[T] = { 31 | CallbackTo(logExceptions(codeBlock)) 32 | } 33 | 34 | def LogExceptionsFuture[T](codeBlock: => T)(implicit ec: ExecutionContext): Future[T] = { 35 | Future(logExceptions(codeBlock)) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /app/js/shared/src/main/scala/hydro/common/Module.scala: -------------------------------------------------------------------------------- 1 | package hydro.common 2 | 3 | import app.api.ScalaJsApi.GetInitialDataResponse 4 | 5 | final class Module(implicit getInitialDataResponse: GetInitialDataResponse) { 6 | 7 | implicit lazy val i18n: I18n = new JsI18n(getInitialDataResponse.i18nMessages) 8 | } 9 | -------------------------------------------------------------------------------- /app/js/shared/src/main/scala/hydro/common/Unique.scala: -------------------------------------------------------------------------------- 1 | package hydro.common 2 | 3 | /** 4 | * Wrapper class for any value. Different instances of `Unique` are never equal to each other. 5 | */ 6 | final class Unique[V] private (value: V) { 7 | def get: V = value 8 | } 9 | 10 | object Unique { 11 | def apply[V](value: V): Unique[V] = new Unique(value) 12 | } 13 | -------------------------------------------------------------------------------- /app/js/shared/src/main/scala/hydro/common/time/JsClock.scala: -------------------------------------------------------------------------------- 1 | package hydro.common.time 2 | 3 | import java.time.Instant 4 | import java.time.LocalDate 5 | import java.time.LocalTime 6 | 7 | final class JsClock extends Clock { 8 | 9 | override def now = { 10 | val date = LocalDate.now() 11 | val time = LocalTime.now() 12 | LocalDateTime.of(date, time) 13 | } 14 | 15 | override def nowInstant = Instant.now() 16 | } 17 | -------------------------------------------------------------------------------- /app/js/shared/src/main/scala/hydro/common/time/Module.scala: -------------------------------------------------------------------------------- 1 | package hydro.common.time 2 | 3 | final class Module { 4 | 5 | implicit lazy val clock: Clock = new JsClock 6 | } 7 | -------------------------------------------------------------------------------- /app/js/shared/src/main/scala/hydro/common/websocket/SerialWebsocketClientParallelizer.scala: -------------------------------------------------------------------------------- 1 | package hydro.common.websocket 2 | 3 | import java.nio.ByteBuffer 4 | 5 | import scala.collection.immutable.Seq 6 | import scala.concurrent.Future 7 | 8 | final class SerialWebsocketClientParallelizer(websocketPath: String, numWebsockets: Int) { 9 | 10 | private val websocketClients: Seq[SerialWebsocketClient] = 11 | (0 until numWebsockets).map(_ => new SerialWebsocketClient(websocketPath = websocketPath)) 12 | 13 | def sendAndReceive(request: ByteBuffer): Future[ByteBuffer] = { 14 | websocketClients.minBy(_.backlogSize).sendAndReceive(request) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /app/js/shared/src/main/scala/hydro/flux/action/Action.scala: -------------------------------------------------------------------------------- 1 | package hydro.flux.action 2 | 3 | trait Action 4 | -------------------------------------------------------------------------------- /app/js/shared/src/main/scala/hydro/flux/action/Module.scala: -------------------------------------------------------------------------------- 1 | package hydro.flux.action 2 | 3 | final class Module { 4 | 5 | implicit val dispatcher: Dispatcher = new Dispatcher.Impl 6 | } 7 | -------------------------------------------------------------------------------- /app/js/shared/src/main/scala/hydro/flux/action/StandardActions.scala: -------------------------------------------------------------------------------- 1 | package hydro.flux.action 2 | 3 | import app.api.ScalaJsApi.UserPrototype 4 | 5 | object StandardActions { 6 | 7 | // **************** General actions **************** // 8 | case class SetPageLoadingState(isLoading: Boolean) extends Action 9 | 10 | /** Special action that gets sent to the dispatcher's callbacks after they processed the contained action. */ 11 | case class Done private[action] (action: Action) extends Action 12 | 13 | /** Special action that gets sent to the dispatcher's callbacks after processing an action failed. */ 14 | case class Failed private[action] (action: Action) extends Action 15 | 16 | // **************** User-related actions **************** // 17 | case class UpsertUser(userPrototype: UserPrototype) extends Action 18 | } 19 | -------------------------------------------------------------------------------- /app/js/shared/src/main/scala/hydro/flux/react/ReactVdomUtils.scala: -------------------------------------------------------------------------------- 1 | package hydro.flux.react 2 | 3 | import japgolly.scalajs.react.vdom.TagMod 4 | import japgolly.scalajs.react.vdom.VdomArray 5 | import japgolly.scalajs.react.vdom.VdomNode 6 | import japgolly.scalajs.react.vdom.html_<^._ 7 | 8 | import scala.collection.immutable.Seq 9 | 10 | object ReactVdomUtils { 11 | object ^^ { 12 | def classes(cls: String*): TagMod = classes(cls.toVector) 13 | def classes(cls: Iterable[String]): TagMod = 14 | ^.classSetM(cls.toVector.filter(_.nonEmpty).map(c => (c, true)).toMap) 15 | 16 | def ifThen(cond: Boolean)(thenElement: => TagMod): TagMod = { 17 | if (cond) { 18 | thenElement 19 | } else { 20 | TagMod.empty 21 | } 22 | } 23 | def ifDefined[T](option: Option[T])(thenElement: T => TagMod): TagMod = { 24 | ifThen(option.isDefined)(thenElement(option.get)) 25 | } 26 | } 27 | 28 | object << { 29 | def ifThen(cond: Boolean)(thenElement: => VdomNode): VdomNode = { 30 | if (cond) { 31 | thenElement 32 | } else { 33 | VdomArray.empty() 34 | } 35 | } 36 | 37 | def ifDefined[T](option: Option[T])(thenElement: T => VdomNode): VdomNode = { 38 | ifThen(option.isDefined)(thenElement(option.get)) 39 | } 40 | 41 | def joinWithSpaces[A](elems: TraversableOnce[A])(implicit 42 | f: A => VdomNode, 43 | stringF: String => VdomNode, 44 | ): VdomArray = { 45 | VdomArray.empty() ++= elems.flatMap(a => Seq(f(a), stringF(" "))) 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /app/js/shared/src/main/scala/hydro/flux/react/uielements/ApplicationDisconnectedIcon.scala: -------------------------------------------------------------------------------- 1 | package hydro.flux.react.uielements 2 | 3 | import hydro.common.I18n 4 | import hydro.common.JsLoggingUtils.logExceptions 5 | import hydro.flux.react.HydroReactComponent 6 | import hydro.flux.stores.ApplicationIsOnlineStore 7 | import japgolly.scalajs.react._ 8 | import japgolly.scalajs.react.vdom.html_<^._ 9 | 10 | final class ApplicationDisconnectedIcon(implicit 11 | applicationIsOnlineStore: ApplicationIsOnlineStore, 12 | i18n: I18n, 13 | ) extends HydroReactComponent { 14 | 15 | // **************** API ****************// 16 | def apply(): VdomElement = { 17 | component((): Unit) 18 | } 19 | 20 | // **************** Implementation of HydroReactComponent methods ****************// 21 | override protected val config = ComponentConfig(backendConstructor = new Backend(_), initialState = State()) 22 | .withStateStoresDependency( 23 | applicationIsOnlineStore, 24 | _.copy(isDisconnected = !applicationIsOnlineStore.state.isOnline), 25 | ) 26 | 27 | // **************** Implementation of HydroReactComponent types ****************// 28 | protected type Props = Unit 29 | protected case class State(isDisconnected: Boolean = false) 30 | 31 | protected class Backend($ : BackendScope[Props, State]) extends BackendBase($) { 32 | 33 | override def render(props: Props, state: State): VdomElement = logExceptions { 34 | state.isDisconnected match { 35 | case true => 36 | Bootstrap.NavbarBrand()( 37 | Bootstrap.FontAwesomeIcon("chain-broken")(^.title := i18n("app.offline")) 38 | ) 39 | case false => 40 | <.span() 41 | } 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /app/js/shared/src/main/scala/hydro/flux/react/uielements/BootstrapTags.scala: -------------------------------------------------------------------------------- 1 | package hydro.flux.react.uielements 2 | 3 | import hydro.flux.react.uielements.Bootstrap.Variant 4 | 5 | import scala.collection.immutable.Seq 6 | import scala.math.abs 7 | 8 | object BootstrapTags { 9 | private val bootstrapClassSuffixOptions: Seq[Variant] = 10 | Seq(Variant.primary, Variant.success, Variant.info, Variant.warning, Variant.danger) 11 | 12 | def toStableVariant(tag: String): Variant = { 13 | val index = abs(tag.hashCode) % bootstrapClassSuffixOptions.size 14 | bootstrapClassSuffixOptions(index) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /app/js/shared/src/main/scala/hydro/flux/react/uielements/GlobalMessages.scala: -------------------------------------------------------------------------------- 1 | package hydro.flux.react.uielements 2 | 3 | import app.flux.stores.GlobalMessagesStore 4 | import app.flux.stores.GlobalMessagesStore.Message 5 | import hydro.common.JsLoggingUtils.logExceptions 6 | import hydro.flux.react.HydroReactComponent 7 | import hydro.flux.react.uielements.Bootstrap.Variant 8 | import japgolly.scalajs.react._ 9 | import japgolly.scalajs.react.vdom.html_<^._ 10 | 11 | import scala.scalajs.js 12 | 13 | final class GlobalMessages(implicit globalMessagesStore: GlobalMessagesStore) extends HydroReactComponent { 14 | 15 | // **************** API ****************// 16 | def apply(): VdomElement = { 17 | component((): Unit) 18 | } 19 | 20 | // **************** Implementation of HydroReactComponent methods ****************// 21 | override protected val config = ComponentConfig(backendConstructor = new Backend(_), initialState = State()) 22 | .withStateStoresDependency(globalMessagesStore, _.copy(maybeMessage = globalMessagesStore.state)) 23 | 24 | // **************** Implementation of HydroReactComponent types ****************// 25 | protected type Props = Unit 26 | protected case class State(maybeMessage: Option[GlobalMessagesStore.Message] = None) 27 | 28 | protected class Backend($ : BackendScope[Props, State]) extends BackendBase($) { 29 | 30 | override def render(props: Props, state: State): VdomElement = logExceptions { 31 | state.maybeMessage match { 32 | case None => <.span() 33 | case Some(message) => 34 | Bootstrap.Alert(variant = Variant.info)( 35 | ^.className := "global-messages", 36 | <.span( 37 | Bootstrap.Icon(iconClassName(message.messageType))( 38 | ^.style := js.Dictionary("marginRight" -> "11px") 39 | ), 40 | " ", 41 | ), 42 | message.string, 43 | ) 44 | } 45 | } 46 | 47 | private def iconClassName(messageType: Message.Type): String = messageType match { 48 | case Message.Type.Working => "fa fa-circle-o-notch fa-spin" 49 | case Message.Type.Success => "fa fa-check" 50 | case Message.Type.Failure => "fa fa-warning" 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /app/js/shared/src/main/scala/hydro/flux/react/uielements/HalfPanel.scala: -------------------------------------------------------------------------------- 1 | package hydro.flux.react.uielements 2 | 3 | import hydro.flux.react.ReactVdomUtils.<< 4 | import hydro.flux.react.ReactVdomUtils.^^ 5 | import hydro.flux.react.uielements.Bootstrap.Size 6 | import hydro.flux.react.uielements.Bootstrap.Variant 7 | import japgolly.scalajs.react._ 8 | import japgolly.scalajs.react.vdom.html_<^._ 9 | 10 | import scala.collection.immutable.Seq 11 | 12 | object HalfPanel { 13 | private val component = ScalaComponent 14 | .builder[Props](getClass.getSimpleName) 15 | .renderPC((_, props, children) => 16 | Bootstrap.Col(lg = 6)( 17 | ^^.classes(props.panelClasses), 18 | Bootstrap.Panel()( 19 | Bootstrap.PanelHeading( 20 | props.title, 21 | <<.ifThen(props.closeButtonCallback.isDefined) { 22 | <.div( 23 | ^.className := "pull-right", 24 | Bootstrap.Button(variant = Variant.default, size = Size.xs)( 25 | ^.onClick --> props.closeButtonCallback.get, 26 | Bootstrap.FontAwesomeIcon("times", fixedWidth = true), 27 | ), 28 | ) 29 | }, 30 | ), 31 | Bootstrap.PanelBody(children), 32 | ), 33 | ) 34 | ) 35 | .build 36 | 37 | // **************** API ****************// 38 | def apply( 39 | title: VdomElement, 40 | panelClasses: Seq[String] = Seq(), 41 | closeButtonCallback: Option[Callback] = None, 42 | )(children: VdomNode*): VdomElement = { 43 | component(Props(title, panelClasses, closeButtonCallback))(children: _*) 44 | } 45 | 46 | // **************** Private inner types ****************// 47 | private case class Props( 48 | title: VdomElement, 49 | panelClasses: Seq[String], 50 | closeButtonCallback: Option[Callback], 51 | ) 52 | } 53 | -------------------------------------------------------------------------------- /app/js/shared/src/main/scala/hydro/flux/react/uielements/LocalDatabaseHasBeenLoadedIcon.scala: -------------------------------------------------------------------------------- 1 | package hydro.flux.react.uielements 2 | 3 | import hydro.common.I18n 4 | import hydro.common.JsLoggingUtils.logExceptions 5 | import hydro.flux.react.HydroReactComponent 6 | import hydro.flux.stores.LocalDatabaseHasBeenLoadedStore 7 | import japgolly.scalajs.react._ 8 | import japgolly.scalajs.react.vdom.html_<^._ 9 | 10 | final class LocalDatabaseHasBeenLoadedIcon(implicit 11 | localDatabaseHasBeenLoadedStore: LocalDatabaseHasBeenLoadedStore, 12 | i18n: I18n, 13 | ) extends HydroReactComponent { 14 | 15 | // **************** API ****************// 16 | def apply(): VdomElement = { 17 | component((): Unit) 18 | } 19 | 20 | // **************** Implementation of HydroReactComponent methods ****************// 21 | override protected val config = ComponentConfig(backendConstructor = new Backend(_), initialState = State()) 22 | .withStateStoresDependency( 23 | localDatabaseHasBeenLoadedStore, 24 | _.copy(hasBeenLoaded = localDatabaseHasBeenLoadedStore.state.hasBeenLoaded), 25 | ) 26 | 27 | // **************** Implementation of HydroReactComponent types ****************// 28 | protected type Props = Unit 29 | protected case class State(hasBeenLoaded: Boolean = false) 30 | 31 | protected class Backend($ : BackendScope[Props, State]) extends BackendBase($) { 32 | 33 | override def render(props: Props, state: State): VdomElement = logExceptions { 34 | state.hasBeenLoaded match { 35 | case true => 36 | Bootstrap.NavbarBrand()( 37 | Bootstrap.FontAwesomeIcon("database")(^.title := i18n("app.local-database-has-been-loaded")) 38 | ) 39 | case false => 40 | <.span() 41 | } 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /app/js/shared/src/main/scala/hydro/flux/react/uielements/Module.scala: -------------------------------------------------------------------------------- 1 | package hydro.flux.react.uielements 2 | 3 | import app.flux.stores._ 4 | import app.models.user.User 5 | import hydro.common.I18n 6 | import hydro.common.time.Clock 7 | import hydro.flux.action.Dispatcher 8 | import hydro.flux.stores.ApplicationIsOnlineStore 9 | import hydro.flux.stores.LocalDatabaseHasBeenLoadedStore 10 | import hydro.flux.stores.PageLoadingStateStore 11 | import hydro.flux.stores.UserStore 12 | import hydro.flux.stores.DatabaseExplorerStoreFactory 13 | import hydro.models.access.JsEntityAccess 14 | 15 | final class Module(implicit 16 | i18n: I18n, 17 | user: User, 18 | entityAccess: JsEntityAccess, 19 | globalMessagesStore: GlobalMessagesStore, 20 | pageLoadingStateStore: PageLoadingStateStore, 21 | pendingModificationsStore: PendingModificationsStore, 22 | applicationIsOnlineStore: ApplicationIsOnlineStore, 23 | localDatabaseHasBeenLoadedStore: LocalDatabaseHasBeenLoadedStore, 24 | userStore: UserStore, 25 | databaseExplorerStoreFactory: DatabaseExplorerStoreFactory, 26 | dispatcher: Dispatcher, 27 | clock: Clock, 28 | ) { 29 | 30 | implicit lazy val pageHeader = new PageHeader 31 | implicit lazy val globalMessages: GlobalMessages = new GlobalMessages 32 | implicit lazy val pageLoadingSpinner: PageLoadingSpinner = new PageLoadingSpinner 33 | implicit lazy val applicationDisconnectedIcon: ApplicationDisconnectedIcon = new ApplicationDisconnectedIcon 34 | implicit lazy val localDatabaseHasBeenLoadedIcon: LocalDatabaseHasBeenLoadedIcon = 35 | new LocalDatabaseHasBeenLoadedIcon 36 | implicit lazy val pendingModificationsCounter: PendingModificationsCounter = new PendingModificationsCounter 37 | implicit lazy val sbadminMenu: SbadminMenu = new SbadminMenu() 38 | implicit lazy val sbadminLayout: SbadminLayout = new SbadminLayout() 39 | } 40 | -------------------------------------------------------------------------------- /app/js/shared/src/main/scala/hydro/flux/react/uielements/PageHeader.scala: -------------------------------------------------------------------------------- 1 | package hydro.flux.react.uielements 2 | 3 | import hydro.common.I18n 4 | import hydro.flux.router.Page 5 | import hydro.models.access.EntityAccess 6 | import japgolly.scalajs.react._ 7 | import japgolly.scalajs.react.vdom.html_<^._ 8 | 9 | final class PageHeader(implicit i18n: I18n, entityAccess: EntityAccess) { 10 | 11 | private val component = ScalaComponent 12 | .builder[Props](getClass.getSimpleName) 13 | .renderPC { (_, props, children) => 14 | <.h1( 15 | ^.className := "page-header", 16 | <.i(^.className := props.iconClass), 17 | " ", 18 | props.title, 19 | " ", 20 | children, 21 | ) 22 | } 23 | .build 24 | private val waitForFuture = new WaitForFuture[String] 25 | 26 | // **************** API ****************// 27 | def apply(page: Page, title: String = null): VdomElement = { 28 | withExtension(page, title)() 29 | } 30 | 31 | def withExtension(page: Page, title: String = null)(children: VdomNode*): VdomElement = { 32 | def newComponent(title: String): VdomElement = 33 | component(Props(title = title, iconClass = page.iconClass))(children: _*) 34 | if (title != null) { 35 | newComponent(title = title) 36 | } else { 37 | waitForFuture(futureInput = page.title, waitingElement = newComponent(title = "")) { titleFromPage => 38 | newComponent(title = titleFromPage) 39 | } 40 | } 41 | } 42 | 43 | // **************** Private inner types ****************// 44 | private case class Props(title: String, iconClass: String) 45 | } 46 | -------------------------------------------------------------------------------- /app/js/shared/src/main/scala/hydro/flux/react/uielements/PageLoadingSpinner.scala: -------------------------------------------------------------------------------- 1 | package hydro.flux.react.uielements 2 | 3 | import hydro.common.JsLoggingUtils.logExceptions 4 | import hydro.flux.react.HydroReactComponent 5 | import hydro.flux.stores.PageLoadingStateStore 6 | import japgolly.scalajs.react._ 7 | import japgolly.scalajs.react.vdom.html_<^._ 8 | 9 | final class PageLoadingSpinner(implicit pageLoadingStateStore: PageLoadingStateStore) 10 | extends HydroReactComponent { 11 | 12 | // **************** API ****************// 13 | def apply(): VdomElement = { 14 | component((): Unit) 15 | } 16 | 17 | // **************** Implementation of HydroReactComponent methods ****************// 18 | override protected val config = ComponentConfig(backendConstructor = new Backend(_), initialState = State()) 19 | .withStateStoresDependency( 20 | pageLoadingStateStore, 21 | _.copy(isLoading = pageLoadingStateStore.state.isLoading), 22 | ) 23 | 24 | // **************** Implementation of HydroReactComponent types ****************// 25 | protected type Props = Unit 26 | protected case class State(isLoading: Boolean = false) 27 | 28 | protected class Backend($ : BackendScope[Props, State]) extends BackendBase($) { 29 | 30 | override def render(props: Props, state: State): VdomElement = logExceptions { 31 | state.isLoading match { 32 | case true => 33 | Bootstrap.NavbarBrand()( 34 | Bootstrap.FontAwesomeIcon("circle-o-notch", "spin") 35 | ) 36 | case false => 37 | <.span() 38 | } 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /app/js/shared/src/main/scala/hydro/flux/react/uielements/Panel.scala: -------------------------------------------------------------------------------- 1 | package hydro.flux.react.uielements 2 | 3 | import hydro.flux.react.ReactVdomUtils.^^ 4 | import japgolly.scalajs.react.vdom.html_<^._ 5 | 6 | import scala.collection.immutable.Seq 7 | 8 | object Panel { 9 | def apply( 10 | title: String, 11 | panelClasses: Seq[String] = Seq(), 12 | key: String = null, 13 | lg: Int = 12, 14 | )( 15 | children: VdomNode* 16 | ): VdomElement = { 17 | Bootstrap.Row( 18 | ^^.classes(panelClasses), 19 | ^^.ifThen(key != null) { ^.key := key }, 20 | Bootstrap.Col(lg = lg)( 21 | Bootstrap.Panel()( 22 | Bootstrap.PanelHeading(title), 23 | Bootstrap.PanelBody(children: _*), 24 | ) 25 | ), 26 | ) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /app/js/shared/src/main/scala/hydro/flux/react/uielements/PendingModificationsCounter.scala: -------------------------------------------------------------------------------- 1 | package hydro.flux.react.uielements 2 | 3 | import app.flux.stores.PendingModificationsStore 4 | import hydro.common.JsLoggingUtils.logExceptions 5 | import hydro.flux.react.HydroReactComponent 6 | import japgolly.scalajs.react._ 7 | import japgolly.scalajs.react.vdom.html_<^._ 8 | 9 | final class PendingModificationsCounter(implicit pendingModificationsStore: PendingModificationsStore) 10 | extends HydroReactComponent { 11 | 12 | // **************** API ****************// 13 | def apply(): VdomElement = { 14 | component((): Unit) 15 | } 16 | 17 | // **************** Implementation of HydroReactComponent methods ****************// 18 | override protected val config = ComponentConfig(backendConstructor = new Backend(_), initialState = State()) 19 | .withStateStoresDependency( 20 | pendingModificationsStore, 21 | _.copy(numberOfModifications = pendingModificationsStore.state.numberOfModifications), 22 | ) 23 | 24 | // **************** Implementation of HydroReactComponent types ****************// 25 | protected type Props = Unit 26 | protected case class State(numberOfModifications: Int = 0) 27 | 28 | protected class Backend($ : BackendScope[Props, State]) extends BackendBase($) { 29 | 30 | override def render(props: Props, state: State): VdomElement = logExceptions { 31 | state.numberOfModifications match { 32 | case 0 => 33 | <.span() 34 | case numberOfModifications => 35 | Bootstrap.NavbarBrand()( 36 | ^.className := "pending-modifications", 37 | Bootstrap.Glyphicon("hourglass"), 38 | " ", 39 | numberOfModifications, 40 | ) 41 | } 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /app/js/shared/src/main/scala/hydro/flux/react/uielements/WaitForFuture.scala: -------------------------------------------------------------------------------- 1 | package hydro.flux.react.uielements 2 | 3 | import hydro.common.I18n 4 | import hydro.common.JsLoggingUtils.LogExceptionsCallback 5 | import japgolly.scalajs.react._ 6 | import japgolly.scalajs.react.vdom.html_<^._ 7 | 8 | import scala.concurrent.Future 9 | import scala.scalajs.concurrent.JSExecutionContext.Implicits.queue 10 | import scala.scalajs.js 11 | import scala.util.Failure 12 | import scala.util.Success 13 | 14 | final class WaitForFuture[V] { 15 | private val component = ScalaComponent 16 | .builder[Props](getClass.getSimpleName) 17 | .initialState(State(input = None)) 18 | .renderPS((_, props, state) => 19 | state.input match { 20 | case Some(input) => props.inputToElement(input) 21 | case None => props.waitingElement 22 | } 23 | ) 24 | .componentWillMount($ => 25 | LogExceptionsCallback { 26 | $.props.futureInput.map(input => $.modState(_.copy(input = Some(input))).runNow()) 27 | } 28 | ) 29 | .build 30 | 31 | // **************** API ****************// 32 | def apply(futureInput: Future[V], waitingElement: VdomElement = null)( 33 | inputToElement: V => VdomElement 34 | )(implicit i18n: I18n): VdomElement = { 35 | futureInput.value match { 36 | case Some(Success(value)) => inputToElement(value) 37 | case Some(Failure(_)) => waitingElement 38 | case None => 39 | component.apply( 40 | Props( 41 | futureInput = futureInput, 42 | inputToElement = inputToElement, 43 | waitingElement = Option(waitingElement) getOrElse defaultWaitingElement, 44 | ) 45 | ) 46 | } 47 | } 48 | 49 | private def defaultWaitingElement(implicit i18n: I18n): VdomElement = 50 | <.div(^.style := js.Dictionary("padding" -> "200px 0 500px 60px"), s"${i18n("app.loading")}...") 51 | 52 | // **************** Private inner types ****************// 53 | private case class Props( 54 | futureInput: Future[V], 55 | inputToElement: V => VdomElement, 56 | waitingElement: VdomElement, 57 | ) 58 | private case class State(input: Option[V]) 59 | } 60 | -------------------------------------------------------------------------------- /app/js/shared/src/main/scala/hydro/flux/react/uielements/dbexplorer/DatabaseExplorer.scala: -------------------------------------------------------------------------------- 1 | package hydro.flux.react.uielements.dbexplorer 2 | 3 | import app.models.modification.EntityTypes 4 | import hydro.models.access.JsEntityAccess 5 | import hydro.common.I18n 6 | import hydro.flux.react.uielements.Bootstrap 7 | import hydro.flux.react.uielements.PageHeader 8 | import hydro.flux.react.HydroReactComponent 9 | import hydro.flux.router.RouterContext 10 | import japgolly.scalajs.react._ 11 | import japgolly.scalajs.react.vdom.html_<^._ 12 | 13 | final class DatabaseExplorer(implicit 14 | i18n: I18n, 15 | pageHeader: PageHeader, 16 | databaseTableView: DatabaseTableView, 17 | ) extends HydroReactComponent.Stateless { 18 | 19 | // **************** API ****************// 20 | def apply(router: RouterContext): VdomElement = { 21 | component(Props(router)) 22 | } 23 | 24 | // **************** Implementation of HydroReactComponent methods ****************// 25 | override protected val statelessConfig = StatelessComponentConfig(backendConstructor = new Backend(_)) 26 | 27 | // **************** Implementation of HydroReactComponent types ****************// 28 | protected case class Props(router: RouterContext) 29 | 30 | protected final class Backend(val $ : BackendScope[Props, State]) extends BackendBase($) { 31 | override def render(props: Props, state: Unit): VdomElement = { 32 | implicit val router = props.router 33 | 34 | <.span( 35 | pageHeader(router.currentPage), 36 | ( 37 | for (entityType <- EntityTypes.all) yield { 38 | Bootstrap.Row( 39 | ^.key := entityType.toString, 40 | databaseTableView(entityType), 41 | ) 42 | } 43 | ).toVdomArray, 44 | ) 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /app/js/shared/src/main/scala/hydro/flux/react/uielements/dbexplorer/Module.scala: -------------------------------------------------------------------------------- 1 | package hydro.flux.react.uielements.dbexplorer 2 | 3 | import hydro.common.I18n 4 | import hydro.flux.react.uielements.PageHeader 5 | import hydro.flux.stores.DatabaseExplorerStoreFactory 6 | import hydro.models.access.JsEntityAccess 7 | 8 | final class Module(implicit 9 | i18n: I18n, 10 | pageHeader: PageHeader, 11 | jsEntityAccess: JsEntityAccess, 12 | databaseExplorerStoreFactory: DatabaseExplorerStoreFactory, 13 | ) { 14 | 15 | private implicit lazy val databaseTableView: DatabaseTableView = new DatabaseTableView 16 | 17 | lazy val databaseExplorer: DatabaseExplorer = new DatabaseExplorer 18 | } 19 | -------------------------------------------------------------------------------- /app/js/shared/src/main/scala/hydro/flux/react/uielements/input/InputBase.scala: -------------------------------------------------------------------------------- 1 | package hydro.flux.react.uielements.input 2 | 3 | import japgolly.scalajs.react._ 4 | import org.scalajs.dom.console 5 | 6 | /** 7 | * Contains base traits to be used by input components that describe a single value. 8 | * 9 | * Aggregate input values are not supported. 10 | */ 11 | object InputBase { 12 | 13 | /** A reference to an input component. */ 14 | trait Reference[Value] { 15 | def apply(): Proxy[Value] 16 | } 17 | 18 | /** Proxy that allows client code of an input component to interact with its value. */ 19 | trait Proxy[Value] { 20 | 21 | /** Returns the current value or None if this field is invalidly formatted. */ 22 | def value: Option[Value] 23 | 24 | /** Returns the current value or the default if this field is invalidly formatted. */ 25 | def valueOrDefault: Value 26 | 27 | /** 28 | * Sets the value of the input component and returns the value after this change. 29 | * 30 | * The return value may be different from the input if the input is invalid for this 31 | * field. 32 | */ 33 | def setValue(value: Value): Value 34 | 35 | final def valueIsValid: Boolean = value.isDefined 36 | 37 | /** Focuses the input field. */ 38 | def focus(): Unit = { 39 | console.log("focus() not implemented") 40 | throw new UnsupportedOperationException() 41 | } 42 | 43 | def registerListener(listener: Listener[Value]): Unit 44 | def deregisterListener(listener: Listener[Value]): Unit 45 | } 46 | 47 | object Proxy { 48 | def nullObject[Value](): Proxy[Value] = new NullObject 49 | 50 | private final class NullObject[Value]() extends Proxy[Value] { 51 | override def value = None 52 | override def valueOrDefault = null.asInstanceOf[Value] 53 | override def setValue(value: Value) = value 54 | override def registerListener(listener: Listener[Value]) = {} 55 | override def deregisterListener(listener: Listener[Value]) = {} 56 | } 57 | } 58 | 59 | trait Listener[-Value] { 60 | 61 | /** Gets called every time this field gets updated. This includes updates that are not done by the user. */ 62 | def onChange(newValue: Value, directUserChange: Boolean): Callback 63 | } 64 | 65 | object Listener { 66 | def nullInstance[Value] = new Listener[Value] { 67 | override def onChange(newValue: Value, directUserChange: Boolean) = Callback.empty 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /app/js/shared/src/main/scala/hydro/flux/react/uielements/input/InputValidator.scala: -------------------------------------------------------------------------------- 1 | package hydro.flux.react.uielements.input 2 | 3 | trait InputValidator[Value] { 4 | def isValid(value: Value): Boolean 5 | } 6 | 7 | object InputValidator { 8 | def alwaysValid[Value]: InputValidator[Value] = _ => true 9 | } 10 | -------------------------------------------------------------------------------- /app/js/shared/src/main/scala/hydro/flux/react/uielements/usermanagement/Module.scala: -------------------------------------------------------------------------------- 1 | package hydro.flux.react.uielements.usermanagement 2 | 3 | import hydro.common.I18n 4 | import app.models.user.User 5 | import hydro.common.time.Clock 6 | import hydro.flux.action.Dispatcher 7 | import hydro.flux.react.uielements.PageHeader 8 | import hydro.flux.stores.UserStore 9 | 10 | final class Module(implicit 11 | i18n: I18n, 12 | user: User, 13 | dispatcher: Dispatcher, 14 | clock: Clock, 15 | userStore: UserStore, 16 | pageHeader: PageHeader, 17 | ) { 18 | 19 | private implicit lazy val updatePasswordForm = new UpdatePasswordForm 20 | private implicit lazy val addUserForm = new AddUserForm 21 | private implicit lazy val allUsersList = new AllUsersList 22 | 23 | lazy val userProfile: UserProfile = new UserProfile 24 | lazy val userAdministration: UserAdministration = new UserAdministration 25 | } 26 | -------------------------------------------------------------------------------- /app/js/shared/src/main/scala/hydro/flux/react/uielements/usermanagement/UserAdministration.scala: -------------------------------------------------------------------------------- 1 | package hydro.flux.react.uielements.usermanagement 2 | 3 | import hydro.common.I18n 4 | import hydro.flux.react.uielements.PageHeader 5 | import hydro.flux.router.RouterContext 6 | import japgolly.scalajs.react._ 7 | import japgolly.scalajs.react.vdom.html_<^._ 8 | import hydro.flux.react.uielements.Bootstrap.Variant 9 | import hydro.flux.react.uielements.Bootstrap.Size 10 | import hydro.flux.react.uielements.Bootstrap 11 | 12 | final class UserAdministration(implicit 13 | i18n: I18n, 14 | pageHeader: PageHeader, 15 | allUsersList: AllUsersList, 16 | addUserForm: AddUserForm, 17 | ) { 18 | 19 | private val component = ScalaComponent 20 | .builder[Props](getClass.getSimpleName) 21 | .renderP(($, props) => { 22 | implicit val router = props.router 23 | <.span( 24 | pageHeader(router.currentPage), 25 | Bootstrap.Row(allUsersList()), 26 | Bootstrap.Row(addUserForm()), 27 | ) 28 | }) 29 | .build 30 | 31 | // **************** API ****************// 32 | def apply(router: RouterContext): VdomElement = { 33 | component(Props(router)) 34 | } 35 | 36 | // **************** Private inner types ****************// 37 | private case class Props(router: RouterContext) 38 | } 39 | -------------------------------------------------------------------------------- /app/js/shared/src/main/scala/hydro/flux/react/uielements/usermanagement/UserProfile.scala: -------------------------------------------------------------------------------- 1 | package hydro.flux.react.uielements.usermanagement 2 | 3 | import hydro.common.I18n 4 | import hydro.flux.react.uielements.Bootstrap 5 | import hydro.flux.react.uielements.PageHeader 6 | import hydro.flux.router.RouterContext 7 | import japgolly.scalajs.react._ 8 | import japgolly.scalajs.react.vdom.html_<^._ 9 | 10 | final class UserProfile(implicit i18n: I18n, updatePasswordForm: UpdatePasswordForm, pageHeader: PageHeader) { 11 | 12 | private val component = ScalaComponent 13 | .builder[Props](getClass.getSimpleName) 14 | .renderP(($, props) => { 15 | implicit val router = props.router 16 | <.span( 17 | pageHeader(router.currentPage), 18 | Bootstrap.Row(updatePasswordForm()), 19 | ) 20 | }) 21 | .build 22 | 23 | // **************** API ****************// 24 | def apply(router: RouterContext): VdomElement = { 25 | component(Props(router)) 26 | } 27 | 28 | // **************** Private inner types ****************// 29 | private case class Props(router: RouterContext) 30 | } 31 | -------------------------------------------------------------------------------- /app/js/shared/src/main/scala/hydro/flux/router/Page.scala: -------------------------------------------------------------------------------- 1 | package hydro.flux.router 2 | 3 | import hydro.common.I18n 4 | import hydro.models.access.EntityAccess 5 | 6 | import scala.concurrent.Future 7 | 8 | trait Page { 9 | def title(implicit i18n: I18n, entityAccess: EntityAccess): Future[String] 10 | def iconClass: String 11 | } 12 | object Page { 13 | abstract class PageBase(titleKey: String, override val iconClass: String) extends Page { 14 | override def title(implicit i18n: I18n, entityAccess: EntityAccess) = Future.successful(titleSync) 15 | def titleSync(implicit i18n: I18n) = i18n(titleKey) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /app/js/shared/src/main/scala/hydro/flux/router/StandardPages.scala: -------------------------------------------------------------------------------- 1 | package hydro.flux.router 2 | 3 | import hydro.common.I18n 4 | import hydro.flux.router.Page.PageBase 5 | import hydro.models.access.EntityAccess 6 | 7 | import scala.concurrent.Future 8 | import scala.scalajs.js 9 | 10 | object StandardPages { 11 | case object Root extends Page { 12 | override def title(implicit i18n: I18n, entityAccess: EntityAccess) = Future.successful("Root") 13 | override def iconClass = "" 14 | } 15 | 16 | // **************** User management views **************** // 17 | case object UserProfile extends PageBase("app.user-profile", iconClass = "fa fa-user fa-fw") 18 | case object UserAdministration extends PageBase("app.user-administration", iconClass = "fa fa-cogs fa-fw") 19 | 20 | // **************** Database explorer views **************** // 21 | case object DatabaseExplorer extends PageBase("app.database-explorer", iconClass = "fa fa-database") 22 | 23 | // **************** Menu bar search **************** // 24 | case class Search private (encodedQuery: String) extends Page { 25 | def query: String = { 26 | val decoded = js.URIUtils.decodeURIComponent(js.URIUtils.decodeURI(encodedQuery)) 27 | decoded 28 | .replace('+', ' ') // Hack: The Chrome 'search engine' feature translates space into plus 29 | .replace(Search.escapedPlus, "+") // Unescape plus in case it was entered in a search 30 | } 31 | 32 | override def title(implicit i18n: I18n, entityAccess: EntityAccess) = 33 | Future.successful(i18n("app.search-results-for", query)) 34 | override def iconClass = "icon-list" 35 | } 36 | object Search { 37 | 38 | private val escapedPlus = "_PLUS_" 39 | 40 | def fromInput(query: String): Search = { 41 | val manuallyEscapedQuery = query.replace("+", escapedPlus) 42 | new Search(js.URIUtils.encodeURIComponent(manuallyEscapedQuery)) 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /app/js/shared/src/main/scala/hydro/flux/stores/ApplicationIsOnlineStore.scala: -------------------------------------------------------------------------------- 1 | package hydro.flux.stores 2 | 3 | import hydro.common.Listenable 4 | import hydro.flux.stores.ApplicationIsOnlineStore.State 5 | import hydro.models.access.HydroPushSocketClientFactory 6 | 7 | final class ApplicationIsOnlineStore(implicit hydroPushSocketClientFactory: HydroPushSocketClientFactory) 8 | extends StateStore[State] { 9 | 10 | hydroPushSocketClientFactory.pushClientsAreOnline.registerListener(PushClientsAreOnlineListener) 11 | 12 | private var _state: State = State(isOnline = hydroPushSocketClientFactory.pushClientsAreOnline.get) 13 | 14 | // **************** Public API ****************// 15 | override def state: State = _state 16 | 17 | // **************** Private inner types ****************// 18 | object PushClientsAreOnlineListener extends Listenable.Listener[Boolean] { 19 | override def onChange(isOnline: Boolean): Unit = { 20 | _state = State(isOnline) 21 | invokeStateUpdateListeners() 22 | } 23 | } 24 | } 25 | 26 | object ApplicationIsOnlineStore { 27 | case class State(isOnline: Boolean) 28 | } 29 | -------------------------------------------------------------------------------- /app/js/shared/src/main/scala/hydro/flux/stores/CombiningStateStore.scala: -------------------------------------------------------------------------------- 1 | package hydro.flux.stores 2 | 3 | import scala.collection.immutable.Seq 4 | 5 | abstract class CombiningStateStore[InputStateA, InputStateB, OutputState]( 6 | storeA: StateStore[InputStateA], 7 | storeB: StateStore[InputStateB], 8 | ) extends StateStore[OutputState] { 9 | onStateUpdateListenersChange() 10 | 11 | protected def combineStoreStates(storeAState: InputStateA, storeBState: InputStateB): OutputState 12 | 13 | override final def state: OutputState = { 14 | combineStoreStates(storeA.state, storeB.state) 15 | } 16 | 17 | override final protected def onStateUpdateListenersChange(): Unit = { 18 | for (inputStore <- Seq(storeA, storeB)) { 19 | if (this.stateUpdateListeners.isEmpty) { 20 | inputStore.deregister(InputStoreListener) 21 | } else { 22 | if (!(inputStore.stateUpdateListeners contains InputStoreListener)) { 23 | inputStore.register(InputStoreListener) 24 | } 25 | } 26 | } 27 | } 28 | 29 | private object InputStoreListener extends StateStore.Listener { 30 | override def onStateUpdate(): Unit = { 31 | CombiningStateStore.this.invokeStateUpdateListeners() 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /app/js/shared/src/main/scala/hydro/flux/stores/CombiningStateStore3.scala: -------------------------------------------------------------------------------- 1 | package hydro.flux.stores 2 | 3 | import scala.collection.immutable.Seq 4 | 5 | abstract class CombiningStateStore3[InputStateA, InputStateB, InputStateC, OutputState]( 6 | storeA: StateStore[InputStateA], 7 | storeB: StateStore[InputStateB], 8 | storeC: StateStore[InputStateC], 9 | ) extends StateStore[OutputState] { 10 | onStateUpdateListenersChange() 11 | 12 | protected def combineStoreStates( 13 | storeAState: InputStateA, 14 | storeBState: InputStateB, 15 | storeCState: InputStateC, 16 | ): OutputState 17 | 18 | override final def state: OutputState = { 19 | combineStoreStates(storeA.state, storeB.state, storeC.state) 20 | } 21 | 22 | override final protected def onStateUpdateListenersChange(): Unit = { 23 | for (inputStore <- Seq(storeA, storeB, storeC)) { 24 | if (this.stateUpdateListeners.isEmpty) { 25 | inputStore.deregister(InputStoreListener) 26 | } else { 27 | if (!(inputStore.stateUpdateListeners contains InputStoreListener)) { 28 | inputStore.register(InputStoreListener) 29 | } 30 | } 31 | } 32 | } 33 | 34 | private object InputStoreListener extends StateStore.Listener { 35 | override def onStateUpdate(): Unit = { 36 | CombiningStateStore3.this.invokeStateUpdateListeners() 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /app/js/shared/src/main/scala/hydro/flux/stores/DatabaseExplorerStoreFactory.scala: -------------------------------------------------------------------------------- 1 | package hydro.flux.stores 2 | 3 | import hydro.flux.stores.DatabaseExplorerStoreFactory.State 4 | import hydro.models.access.JsEntityAccess 5 | import hydro.models.modification.EntityModification 6 | import hydro.models.modification.EntityType 7 | import hydro.models.Entity 8 | 9 | import scala.async.Async.async 10 | import scala.async.Async.await 11 | import scala.collection.immutable.Seq 12 | import scala.concurrent.Future 13 | import scala.scalajs.concurrent.JSExecutionContext.Implicits.queue 14 | 15 | final class DatabaseExplorerStoreFactory(implicit entityAccess: JsEntityAccess) extends StoreFactory { 16 | 17 | // **************** Public API **************** // 18 | def get(entityType: EntityType.any): Store = getCachedOrCreate(entityType) 19 | 20 | // **************** Implementation of base class methods and types **************** // 21 | override protected def createNew(input: Input): Store = new Store(input) 22 | 23 | /* override */ 24 | protected type Input = EntityType.any 25 | 26 | /* override */ 27 | final class Store(entityType: EntityType.any) extends AsyncEntityDerivedStateStore[State] { 28 | // **************** Implementation of base class methods **************** // 29 | override protected def calculateState(): Future[State] = async { 30 | val allEntities = await(entityAccess.newQuery()(entityType).data()) 31 | State(allEntities = allEntities) 32 | } 33 | 34 | override protected def modificationImpactsState( 35 | entityModification: EntityModification, 36 | state: State, 37 | ): Boolean = { 38 | entityModification.entityType == entityType 39 | } 40 | } 41 | } 42 | 43 | object DatabaseExplorerStoreFactory { 44 | case class State(allEntities: Seq[Entity]) 45 | } 46 | -------------------------------------------------------------------------------- /app/js/shared/src/main/scala/hydro/flux/stores/FixedStateStore.scala: -------------------------------------------------------------------------------- 1 | package hydro.flux.stores 2 | 3 | import scala.collection.immutable.Seq 4 | 5 | case class FixedStateStore[State](fixedState: State) extends StateStore[State] { 6 | override final def state: State = fixedState 7 | } 8 | -------------------------------------------------------------------------------- /app/js/shared/src/main/scala/hydro/flux/stores/LocalDatabaseHasBeenLoadedStore.scala: -------------------------------------------------------------------------------- 1 | package hydro.flux.stores 2 | 3 | import hydro.common.Listenable 4 | import hydro.flux.stores.LocalDatabaseHasBeenLoadedStore.State 5 | import hydro.models.access.JsEntityAccess 6 | 7 | final class LocalDatabaseHasBeenLoadedStore(implicit jsEntityAccess: JsEntityAccess) 8 | extends StateStore[State] { 9 | 10 | jsEntityAccess.localDatabaseHasBeenLoaded.registerListener(HasBeenLoadedListener) 11 | 12 | private var _state: State = State(hasBeenLoaded = jsEntityAccess.localDatabaseHasBeenLoaded.get) 13 | 14 | // **************** Public API ****************// 15 | override def state: State = _state 16 | 17 | // **************** Private inner types ****************// 18 | object HasBeenLoadedListener extends Listenable.Listener[Boolean] { 19 | override def onChange(hasBeenLoaded: Boolean): Unit = { 20 | _state = State(hasBeenLoaded = hasBeenLoaded) 21 | invokeStateUpdateListeners() 22 | } 23 | } 24 | } 25 | 26 | object LocalDatabaseHasBeenLoadedStore { 27 | case class State(hasBeenLoaded: Boolean) 28 | } 29 | -------------------------------------------------------------------------------- /app/js/shared/src/main/scala/hydro/flux/stores/PageLoadingStateStore.scala: -------------------------------------------------------------------------------- 1 | package hydro.flux.stores 2 | 3 | import hydro.flux.action.Action 4 | import hydro.flux.action.Dispatcher 5 | import hydro.flux.action.StandardActions 6 | import hydro.flux.stores.PageLoadingStateStore.State 7 | 8 | final class PageLoadingStateStore(implicit dispatcher: Dispatcher) extends StateStore[State] { 9 | dispatcher.registerPartialSync(dispatcherListener) 10 | 11 | private var _state: State = State(isLoading = false) 12 | 13 | // **************** Public API ****************// 14 | override def state: State = _state 15 | 16 | // **************** Private dispatcher methods ****************// 17 | private def dispatcherListener: PartialFunction[Action, Unit] = { 18 | case StandardActions.SetPageLoadingState(isLoading) => 19 | setState(State(isLoading = isLoading)) 20 | } 21 | 22 | // **************** Private state helper methods ****************// 23 | private def setState(state: State): Unit = { 24 | val originalState = _state 25 | _state = state 26 | if (_state != originalState) { 27 | invokeStateUpdateListeners() 28 | } 29 | } 30 | } 31 | 32 | object PageLoadingStateStore { 33 | case class State(isLoading: Boolean) 34 | } 35 | -------------------------------------------------------------------------------- /app/js/shared/src/main/scala/hydro/flux/stores/StateStore.scala: -------------------------------------------------------------------------------- 1 | package hydro.flux.stores 2 | 3 | import scala.collection.mutable 4 | 5 | /** 6 | * Abstract base class for any store that exposes a single listenable state. 7 | * 8 | * @tparam State An immutable type that contains all state maintained by this store 9 | */ 10 | abstract class StateStore[State] { 11 | 12 | private val _stateUpdateListeners: mutable.Set[StateStore.Listener] = mutable.LinkedHashSet() 13 | private var isCallingListeners: Boolean = false 14 | 15 | // **************** Public API: To override ****************// 16 | def state: State 17 | 18 | // **************** Public API: Final ****************// 19 | final def register(listener: StateStore.Listener): Unit = { 20 | checkNotCallingListeners() 21 | 22 | _stateUpdateListeners.add(listener) 23 | onStateUpdateListenersChange() 24 | } 25 | 26 | final def deregister(listener: StateStore.Listener): Unit = { 27 | checkNotCallingListeners() 28 | 29 | _stateUpdateListeners.remove(listener) 30 | onStateUpdateListenersChange() 31 | } 32 | 33 | // **************** Protected methods to override ****************// 34 | protected def onStateUpdateListenersChange(): Unit = {} 35 | 36 | // **************** Protected helper methods ****************// 37 | protected final def invokeStateUpdateListeners(): Unit = { 38 | checkNotCallingListeners() 39 | isCallingListeners = true 40 | _stateUpdateListeners.foreach(_.onStateUpdate()) 41 | isCallingListeners = false 42 | } 43 | 44 | final def stateUpdateListeners: scala.collection.Set[StateStore.Listener] = _stateUpdateListeners 45 | 46 | protected final def checkNotCallingListeners(): Unit = { 47 | require(!isCallingListeners, "checkNotCallingListeners(): But isCallingListeners is true") 48 | } 49 | } 50 | 51 | object StateStore { 52 | 53 | def alwaysReturning[State](fixedState: State): StateStore[State] = new StateStore[State] { 54 | override def state: State = fixedState 55 | } 56 | 57 | trait Listener { 58 | def onStateUpdate(): Unit 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /app/js/shared/src/main/scala/hydro/flux/stores/StoreFactory.scala: -------------------------------------------------------------------------------- 1 | package hydro.flux.stores 2 | 3 | import scala.collection.mutable 4 | 5 | abstract class StoreFactory { 6 | 7 | private val cache: mutable.Map[Input, Store] = mutable.Map() 8 | 9 | // **************** Abstract methods/types ****************// 10 | /** 11 | * The (immutable) input type that together with injected dependencies is enough to 12 | * calculate the latest value of `State`. Example: Int. 13 | */ 14 | protected type Input 15 | 16 | /** The type of store that gets created by this factory. */ 17 | protected type Store 18 | 19 | protected def createNew(input: Input): Store 20 | 21 | // **************** Protected API ****************// 22 | protected final def getCachedOrCreate(input: Input): Store = { 23 | if (cache contains input) { 24 | cache(input) 25 | } else { 26 | val created = createNew(input) 27 | cache.put(input, created) 28 | created 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /app/js/shared/src/main/scala/hydro/flux/stores/UserStore.scala: -------------------------------------------------------------------------------- 1 | package hydro.flux.stores 2 | 3 | import app.api.ScalaJsApiClient 4 | import hydro.models.modification.EntityModification 5 | import app.models.user.User 6 | import hydro.flux.action.Dispatcher 7 | import hydro.flux.action.StandardActions.UpsertUser 8 | import hydro.flux.stores.UserStore.State 9 | import hydro.models.access.JsEntityAccess 10 | 11 | import scala.async.Async.async 12 | import scala.async.Async.await 13 | import scala.collection.immutable.Seq 14 | import scala.concurrent.Future 15 | import scala.scalajs.concurrent.JSExecutionContext.Implicits.queue 16 | 17 | final class UserStore(implicit 18 | dispatcher: Dispatcher, 19 | scalaJsApiClient: ScalaJsApiClient, 20 | entityAccess: JsEntityAccess, 21 | ) extends AsyncEntityDerivedStateStore[State] { 22 | 23 | dispatcher.registerPartialAsync { case UpsertUser(userPrototype) => 24 | scalaJsApiClient.upsertUser(userPrototype) 25 | } 26 | 27 | override protected def calculateState(): Future[State] = async { 28 | val allUsers = await(entityAccess.newQuery[User]().data()) 29 | State(allUsers = allUsers) 30 | } 31 | 32 | override protected def modificationImpactsState( 33 | entityModification: EntityModification, 34 | state: State, 35 | ): Boolean = 36 | entityModification.entityType == User.Type 37 | } 38 | 39 | object UserStore { 40 | case class State(allUsers: Seq[User]) 41 | } 42 | -------------------------------------------------------------------------------- /app/js/shared/src/main/scala/hydro/jsfacades/Bootbox.scala: -------------------------------------------------------------------------------- 1 | package hydro.jsfacades 2 | 3 | import scala.concurrent.Future 4 | import scala.concurrent.Promise 5 | import scala.scalajs.js 6 | import scala.scalajs.js.annotation.JSGlobal 7 | import org.scalajs.dom 8 | import org.scalajs.dom.raw.HTMLInputElement 9 | 10 | /** Facade on top of bootbox.js */ 11 | object Bootbox { 12 | 13 | def alert(str: String): Unit = { 14 | RawJsBootbox.alert(str) 15 | } 16 | 17 | def prompt(title: String, value: String, animate: Boolean, selectValue: Boolean): Future[Option[String]] = { 18 | val resultPromise: Promise[Option[String]] = Promise() 19 | val callback: js.Function1[String, Unit] = response => { 20 | resultPromise.success(Option(response)) 21 | } 22 | 23 | RawJsBootbox.prompt( 24 | js.Dynamic.literal( 25 | title = title, 26 | value = value, 27 | callback = callback, 28 | animate = animate, 29 | ) 30 | ) 31 | 32 | if (selectValue && value.nonEmpty) { 33 | val promptInput = 34 | dom.document.getElementsByClassName("bootbox-input-text").apply(0).asInstanceOf[HTMLInputElement] 35 | promptInput.setSelectionRange(0, promptInput.value.length) 36 | } 37 | 38 | resultPromise.future 39 | } 40 | 41 | // Using global instead of import because bootbox seems to rely on JQuery and bootstrap.js being 42 | // present in the global scope 43 | @JSGlobal("bootbox") 44 | @js.native 45 | object RawJsBootbox extends js.Object { 46 | def alert(str: String): Unit = js.native 47 | def prompt(parameters: js.Object): Unit = js.native 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /app/js/shared/src/main/scala/hydro/jsfacades/ClipboardPolyfill.scala: -------------------------------------------------------------------------------- 1 | package hydro.jsfacades 2 | 3 | import scala.scalajs.js 4 | import scala.scalajs.js.annotation.JSImport 5 | 6 | @JSImport("clipboard-polyfill", JSImport.Namespace) 7 | @js.native 8 | object ClipboardPolyfill extends js.Object { 9 | def write(dataTransfer: DT): Unit = js.native 10 | def writeText(text: String): Unit = js.native 11 | 12 | @js.native 13 | class DT extends js.Object { 14 | def setData(tpe: String, data: String): Unit = js.native 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /app/js/shared/src/main/scala/hydro/jsfacades/Mousetrap.scala: -------------------------------------------------------------------------------- 1 | package hydro.jsfacades 2 | 3 | import org.scalajs.dom.raw.KeyboardEvent 4 | 5 | import scala.scalajs.js 6 | import scala.scalajs.js.annotation.JSImport 7 | 8 | object Mousetrap { 9 | 10 | def bind(key: String, callback: js.Function1[KeyboardEvent, Unit]): Unit = 11 | RegularMousetrap.bind(key, callback) 12 | def bindGlobal(key: String, callback: js.Function1[KeyboardEvent, Unit]): Unit = 13 | GlobalMousetrap.bindGlobal(key, callback) 14 | 15 | @JSImport("mousetrap", JSImport.Namespace) 16 | @js.native 17 | object RegularMousetrap extends js.Object { 18 | def bind(key: String, callback: js.Function1[KeyboardEvent, Unit]): Unit = js.native 19 | } 20 | 21 | @JSImport("global-mousetrap", JSImport.Namespace) 22 | @js.native 23 | object GlobalMousetrap extends js.Object { 24 | def bindGlobal(key: String, callback: js.Function1[KeyboardEvent, Unit]): Unit = js.native 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /app/js/shared/src/main/scala/hydro/jsfacades/escapeHtml.scala: -------------------------------------------------------------------------------- 1 | package hydro.jsfacades 2 | 3 | import scala.scalajs.js 4 | import scala.scalajs.js.annotation.JSImport 5 | 6 | @JSImport("escape-html", JSImport.Namespace) 7 | @js.native 8 | object escapeHtml extends js.Object { 9 | def apply(text: String): String = js.native 10 | } 11 | -------------------------------------------------------------------------------- /app/js/shared/src/main/scala/hydro/models/access/JsEntityAccess.scala: -------------------------------------------------------------------------------- 1 | package hydro.models.access 2 | 3 | import hydro.common.Listenable 4 | import hydro.models.modification.EntityModification 5 | import hydro.models.modification.EntityType 6 | import hydro.models.Entity 7 | 8 | import scala.collection.immutable.Seq 9 | import scala.concurrent.Future 10 | 11 | trait JsEntityAccess extends EntityAccess { 12 | 13 | // **************** Getters ****************// 14 | override def newQuery[E <: Entity: EntityType](): DbResultSet.Async[E] 15 | 16 | /** 17 | * Returns the modifications that are incorporated into the data backing `newQuery()` ,but are not yet persisted 18 | * remotely. 19 | */ 20 | def pendingModifications: PendingModifications 21 | 22 | def localDatabaseHasBeenLoaded: Listenable[Boolean] 23 | 24 | // **************** Setters ****************// 25 | /** 26 | * Note: All read actions that are started after this call is started are postponed until the data backing 27 | * `newQuery()` has been updated. 28 | */ 29 | def persistModifications(modifications: Seq[EntityModification]): Future[Unit] 30 | final def persistModifications(modifications: EntityModification*): Future[Unit] = 31 | persistModifications(modifications.toVector) 32 | 33 | def clearLocalDatabase(): Future[Unit] 34 | 35 | // **************** Other ****************// 36 | def registerListener(listener: JsEntityAccess.Listener): Unit 37 | def deregisterListener(listener: JsEntityAccess.Listener): Unit 38 | } 39 | 40 | object JsEntityAccess { 41 | 42 | trait Listener { 43 | 44 | /** 45 | * Called when a modification is persisted so that: 46 | * - Future calls to `newQuery()` will contain the given modifications 47 | * OR 48 | * - Future calls to `pendingModifications()` will have or no longer have the given modifications 49 | */ 50 | def modificationsAddedOrPendingStateChanged(modifications: Seq[EntityModification]): Unit 51 | 52 | /** Called when `pendingModifications.persistedLocally` becomes true. */ 53 | def pendingModificationsPersistedLocally(): Unit = {} 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /app/js/shared/src/main/scala/hydro/models/access/PendingModifications.scala: -------------------------------------------------------------------------------- 1 | package hydro.models.access 2 | 3 | import hydro.common.GuavaReplacement.ImmutableSetMultimap 4 | import hydro.models.modification.EntityModification 5 | import hydro.models.modification.EntityType 6 | import hydro.models.Entity 7 | 8 | import scala.collection.immutable.Seq 9 | 10 | case class PendingModifications(modifications: Seq[EntityModification], persistedLocally: Boolean) { 11 | private lazy val addModificationIds: ImmutableSetMultimap[EntityType.any, Long] = { 12 | val builder = ImmutableSetMultimap.builder[EntityType.any, Long]() 13 | modifications collect { case modification: EntityModification.Add[_] => 14 | builder.put(modification.entityType, modification.entityId) 15 | } 16 | builder.build() 17 | } 18 | 19 | def additionIsPending[E <: Entity: EntityType](entity: E): Boolean = { 20 | addModificationIds.get(implicitly[EntityType[E]]) contains entity.id 21 | } 22 | 23 | def ++(otherModifications: Iterable[EntityModification]): PendingModifications = 24 | copy(modifications = modifications ++ minus(otherModifications, modifications)) 25 | 26 | def --(otherModifications: Iterable[EntityModification]): PendingModifications = 27 | copy(modifications = minus(modifications, otherModifications)) 28 | 29 | private def minus[E](a: Iterable[E], b: Iterable[E]): Seq[E] = { 30 | val bSet = b.toSet 31 | a.filter(!bSet.contains(_)).toVector 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /app/js/shared/src/main/scala/hydro/models/access/PendingModificationsListener.scala: -------------------------------------------------------------------------------- 1 | package hydro.models.access 2 | 3 | import hydro.models.modification.EntityModification 4 | 5 | trait PendingModificationsListener { 6 | def onPendingModificationAddedByOtherInstance(modification: EntityModification): Unit 7 | def onPendingModificationRemovedByOtherInstance(modificationPseudoUniqueIdentifier: Long): Unit 8 | } 9 | -------------------------------------------------------------------------------- /app/js/shared/src/main/scala/hydro/models/access/RemoteDatabaseProxy.scala: -------------------------------------------------------------------------------- 1 | package hydro.models.access 2 | 3 | import hydro.models.modification.EntityModification 4 | import hydro.models.modification.EntityType 5 | import hydro.models.Entity 6 | 7 | import scala.collection.immutable.Seq 8 | import scala.concurrent.Future 9 | 10 | /** Proxy for the server-side database. */ 11 | trait RemoteDatabaseProxy { 12 | def queryExecutor[E <: Entity: EntityType](): DbQueryExecutor.Async[E] 13 | 14 | def pendingModifications(): Future[Seq[EntityModification]] 15 | 16 | def persistEntityModifications(modifications: Seq[EntityModification]): PersistEntityModificationsResponse 17 | 18 | /** 19 | * Start listening for entity modifications. 20 | * 21 | * Upon receiving any modifications, the given listener should be invoked. 22 | */ 23 | def startCheckingForModifiedEntityUpdates( 24 | maybeNewEntityModificationsListener: Seq[EntityModification] => Future[Unit] 25 | ): Unit 26 | 27 | def clearLocalDatabase(): Future[Unit] 28 | 29 | /** 30 | * If there is a local database, this future completes when it's finished loading. Otherwise, this future never 31 | * completes. 32 | */ 33 | def localDatabaseReadyFuture: Future[Unit] 34 | 35 | def registerPendingModificationsListener(listener: PendingModificationsListener): Unit 36 | 37 | case class PersistEntityModificationsResponse( 38 | queryReflectsModificationsFuture: Future[Unit], 39 | completelyDoneFuture: Future[Unit], 40 | ) 41 | } 42 | -------------------------------------------------------------------------------- /app/js/shared/src/main/scala/hydro/models/access/SingletonKey.scala: -------------------------------------------------------------------------------- 1 | package hydro.models.access 2 | 3 | import java.time.Instant 4 | 5 | import app.scala2js.AppConverters 6 | import hydro.common.ScalaUtils 7 | import hydro.common.Annotations.visibleForTesting 8 | import hydro.models.modification.EntityModification 9 | import hydro.models.modification.EntityType 10 | import hydro.models.UpdatableEntity 11 | import hydro.scala2js.Scala2Js 12 | import hydro.scala2js.Scala2Js.Converter 13 | import hydro.scala2js.StandardConverters._ 14 | 15 | import scala.scalajs.js 16 | 17 | @visibleForTesting 18 | sealed trait SingletonKey[V] { 19 | implicit def valueConverter: Scala2Js.Converter[V] 20 | 21 | def name: String = ScalaUtils.objectName(this) 22 | override def toString = name 23 | } 24 | 25 | @visibleForTesting 26 | object SingletonKey { 27 | abstract class StringSingletonKey extends SingletonKey[String] { 28 | override val valueConverter = implicitly[Scala2Js.Converter[String]] 29 | } 30 | 31 | object NextUpdateTokenKey extends StringSingletonKey 32 | object VersionKey extends StringSingletonKey 33 | 34 | object DbStatusKey extends SingletonKey[DbStatus] { 35 | override val valueConverter: Converter[DbStatus] = DbStatusConverter 36 | 37 | private object DbStatusConverter extends Converter[DbStatus] { 38 | private val populatingNumber: Int = 1 39 | private val readyNumber: Int = 2 40 | 41 | override def toJs(value: DbStatus) = { 42 | val result = js.Array[js.Any]() 43 | 44 | value match { 45 | case DbStatus.Populating(startTime) => 46 | result.push(populatingNumber) 47 | result.push(Scala2Js.toJs(startTime)) 48 | case DbStatus.Ready => 49 | result.push(readyNumber) 50 | } 51 | 52 | result 53 | } 54 | 55 | override def toScala(value: js.Any) = { 56 | val array = value.asInstanceOf[js.Array[js.Any]] 57 | val typeNumber = Scala2Js.toScala[Int](array(0)) 58 | 59 | array.toVector match { 60 | case Vector(_, startTime) if typeNumber == populatingNumber => 61 | DbStatus.Populating(Scala2Js.toScala[Instant](startTime)) 62 | case Vector(_) if typeNumber == readyNumber => 63 | DbStatus.Ready 64 | } 65 | } 66 | } 67 | } 68 | 69 | sealed trait DbStatus 70 | object DbStatus { 71 | case class Populating(startTime: Instant) extends DbStatus 72 | object Ready extends DbStatus 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /app/js/shared/src/main/scala/hydro/models/access/webworker/Module.scala: -------------------------------------------------------------------------------- 1 | package hydro.models.access.webworker 2 | 3 | final class Module() { 4 | 5 | val localDatabaseWebWorkerApiStub: LocalDatabaseWebWorkerApi.ForClient = new LocalDatabaseWebWorkerApiStub() 6 | } 7 | -------------------------------------------------------------------------------- /app/js/shared/src/main/scala/hydro/models/access/worker/JsWorkerClientFacade.scala: -------------------------------------------------------------------------------- 1 | package hydro.models.access.worker 2 | 3 | import hydro.common.ScalaUtils.ifThenOption 4 | import hydro.models.access.worker.JsWorkerClientFacade.JsWorkerClient 5 | import hydro.models.access.worker.impl.DedicatedWorkerFacadeImpl 6 | import hydro.models.access.worker.impl.SharedWorkerFacadeImpl 7 | 8 | import scala.scalajs.js 9 | 10 | trait JsWorkerClientFacade { 11 | def setUpClient(scriptUrl: String, onMessage: js.Any => Unit): JsWorkerClient 12 | } 13 | object JsWorkerClientFacade { 14 | 15 | def getSharedIfSupported(): Option[JsWorkerClientFacade] = { 16 | ifThenOption(!js.isUndefined(js.Dynamic.global.SharedWorker)) { 17 | SharedWorkerFacadeImpl 18 | } 19 | } 20 | def getDedicated(): JsWorkerClientFacade = DedicatedWorkerFacadeImpl 21 | 22 | trait JsWorkerClient { 23 | def postMessage(message: js.Any): Unit 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /app/js/shared/src/main/scala/hydro/models/access/worker/JsWorkerServerFacade.scala: -------------------------------------------------------------------------------- 1 | package hydro.models.access.worker 2 | 3 | import hydro.models.access.worker.JsWorkerServerFacade.WorkerScriptLogic 4 | import hydro.models.access.worker.impl.DedicatedWorkerFacadeImpl 5 | import hydro.models.access.worker.impl.SharedWorkerFacadeImpl 6 | import org.scalajs.dom.experimental.sharedworkers.SharedWorkerGlobalScope 7 | import org.scalajs.dom.webworkers.DedicatedWorkerGlobalScope 8 | 9 | import scala.concurrent.Future 10 | import scala.scalajs.js 11 | 12 | trait JsWorkerServerFacade { 13 | def setUpFromWorkerScript(workerScriptLogic: WorkerScriptLogic): Unit 14 | } 15 | object JsWorkerServerFacade { 16 | 17 | def getFromGlobalScope(): JsWorkerServerFacade = { 18 | if (!js.isUndefined(js.Dynamic.global.onconnect)) { 19 | SharedWorkerFacadeImpl 20 | } else if (!js.isUndefined(js.Dynamic.global.onmessage)) { 21 | DedicatedWorkerFacadeImpl 22 | } else { 23 | throw new AssertionError("This global scope supports none of the implemented workers") 24 | } 25 | } 26 | 27 | trait WorkerScriptLogic { 28 | def onMessage(data: js.Any): Future[OnMessageResponse] 29 | 30 | } 31 | 32 | case class OnMessageResponse(response: js.Any, responseToBroadcastToOtherPorts: js.Any = js.undefined) 33 | } 34 | -------------------------------------------------------------------------------- /app/js/shared/src/main/scala/hydro/models/access/worker/impl/DedicatedWorkerFacadeImpl.scala: -------------------------------------------------------------------------------- 1 | package hydro.models.access.worker.impl 2 | 3 | import hydro.models.access.worker.JsWorkerClientFacade 4 | import hydro.models.access.worker.JsWorkerClientFacade.JsWorkerClient 5 | import hydro.models.access.worker.JsWorkerServerFacade 6 | import hydro.models.access.worker.JsWorkerServerFacade.WorkerScriptLogic 7 | import org.scalajs.dom 8 | import org.scalajs.dom.raw.DedicatedWorkerGlobalScope 9 | import org.scalajs.dom.webworkers.Worker 10 | 11 | import scala.scalajs.concurrent.JSExecutionContext.Implicits.queue 12 | import scala.scalajs.js 13 | 14 | private[worker] object DedicatedWorkerFacadeImpl extends JsWorkerClientFacade with JsWorkerServerFacade { 15 | 16 | override def setUpClient(scriptUrl: String, onMessage: js.Any => Unit): JsWorkerClient = { 17 | println(" Setting up DedicatedWorker client...") 18 | 19 | val worker = new Worker(scriptUrl) 20 | worker.onmessage = (event: dom.MessageEvent) => { 21 | onMessage(event.data.asInstanceOf[js.Any]) 22 | } 23 | 24 | println(" Setting up DedicatedWorker client: Done") 25 | 26 | new JsWorkerClient { 27 | override def postMessage(message: js.Any): Unit = worker.postMessage(message) 28 | } 29 | } 30 | 31 | override def setUpFromWorkerScript(workerScriptLogic: WorkerScriptLogic): Unit = { 32 | println(" Setting up DedicatedWorker server...") 33 | 34 | def onMessage(msg: dom.MessageEvent) = { 35 | workerScriptLogic.onMessage(msg.data.asInstanceOf[js.Any]) foreach { response => 36 | DedicatedWorkerGlobalScope.self.postMessage(response.response) 37 | } 38 | } 39 | 40 | DedicatedWorkerGlobalScope.self.addEventListener("message", onMessage _) 41 | 42 | println(" Setting up DedicatedWorker server: Done") 43 | } 44 | 45 | } 46 | -------------------------------------------------------------------------------- /app/js/shared/src/main/scala/hydro/scala2js/Scala2Js.scala: -------------------------------------------------------------------------------- 1 | package hydro.scala2js 2 | 3 | import hydro.models.access.ModelField 4 | 5 | import scala.scalajs.js 6 | import scala.scalajs.js.JSConverters._ 7 | 8 | object Scala2Js { 9 | 10 | trait Converter[T] { 11 | def toJs(value: T): js.Any 12 | def toScala(value: js.Any): T 13 | } 14 | 15 | trait MapConverter[T] extends Converter[T] { 16 | override def toJs(value: T): js.Dictionary[js.Any] 17 | final override def toScala(value: js.Any): T = toScala(value.asInstanceOf[js.Dictionary[js.Any]]) 18 | def toScala(value: js.Dictionary[js.Any]): T 19 | } 20 | 21 | def toJs[T: Converter](value: T): js.Any = { 22 | implicitly[Converter[T]].toJs(value) 23 | } 24 | 25 | def toJs[T: Converter](values: Iterable[T]): js.Array[js.Any] = { 26 | values.map(implicitly[Converter[T]].toJs).toJSArray 27 | } 28 | 29 | def toJsMap[T: MapConverter](value: T): js.Dictionary[js.Any] = { 30 | implicitly[MapConverter[T]].toJs(value) 31 | } 32 | 33 | def toScala[T: Converter](value: js.Any): T = { 34 | implicitly[Converter[T]].toScala(value) 35 | } 36 | 37 | def toJs[V, E](value: V, field: ModelField[V, E]): js.Any = 38 | toJs(value)(StandardConverters.fromModelField(field)) 39 | } 40 | -------------------------------------------------------------------------------- /app/js/webpack.dev.js: -------------------------------------------------------------------------------- 1 | const scalajsConfig = require('./scalajs.webpack.config'); 2 | 3 | module.exports = Object.assign( 4 | {}, 5 | scalajsConfig, 6 | { 7 | node: { 8 | fs: "empty", 9 | }, 10 | } 11 | ); 12 | -------------------------------------------------------------------------------- /app/js/webpack.prod.js: -------------------------------------------------------------------------------- 1 | const UglifyJsPlugin = require('uglifyjs-webpack-plugin'); 2 | const webpack = require("webpack"); 3 | const scalajsConfig = require('./scalajs.webpack.config'); 4 | 5 | module.exports = Object.assign( 6 | {}, 7 | scalajsConfig, 8 | { 9 | node: { 10 | fs: "empty", 11 | }, 12 | plugins: [ 13 | new UglifyJsPlugin(), 14 | new webpack.DefinePlugin({ 15 | "process.env": { 16 | "NODE_ENV": '"production"' 17 | } 18 | }), 19 | ], 20 | } 21 | ); 22 | -------------------------------------------------------------------------------- /app/js/webworker/src/main/scala/Main.scala: -------------------------------------------------------------------------------- 1 | import hydro.models.access.webworker.LocalDatabaseWebWorkerScript 2 | 3 | object Main { 4 | def main(args: Array[String]): Unit = { 5 | LocalDatabaseWebWorkerScript.run() 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /app/jvm/src/main/assets/images/document_shortcut_96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nymanjens/piga/dea554dafafb07ca3692d81a41241b857dc22274/app/jvm/src/main/assets/images/document_shortcut_96x96.png -------------------------------------------------------------------------------- /app/jvm/src/main/assets/images/favicon192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nymanjens/piga/dea554dafafb07ca3692d81a41241b857dc22274/app/jvm/src/main/assets/images/favicon192x192.png -------------------------------------------------------------------------------- /app/jvm/src/main/assets/images/favicon48x48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nymanjens/piga/dea554dafafb07ca3692d81a41241b857dc22274/app/jvm/src/main/assets/images/favicon48x48.png -------------------------------------------------------------------------------- /app/jvm/src/main/assets/images/favicon512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nymanjens/piga/dea554dafafb07ca3692d81a41241b857dc22274/app/jvm/src/main/assets/images/favicon512x512.png -------------------------------------------------------------------------------- /app/jvm/src/main/assets/lib/fontello/LICENSE.txt: -------------------------------------------------------------------------------- 1 | Font license info 2 | 3 | 4 | ## Fontelico 5 | 6 | Copyright (C) 2012 by Fontello project 7 | 8 | Author: Crowdsourced, for Fontello project 9 | License: SIL (http://scripts.sil.org/OFL) 10 | Homepage: http://fontello.com 11 | 12 | 13 | ## Font Awesome 14 | 15 | Copyright (C) 2016 by Dave Gandy 16 | 17 | Author: Dave Gandy 18 | License: SIL () 19 | Homepage: http://fortawesome.github.com/Font-Awesome/ 20 | 21 | 22 | ## Maki 23 | 24 | Copyright (C) Mapbox, LCC 25 | 26 | Author: Mapbox 27 | License: BSD (https://github.com/mapbox/maki/blob/gh-pages/LICENSE.txt) 28 | Homepage: http://mapbox.com/maki/ 29 | 30 | 31 | ## Modern Pictograms 32 | 33 | Copyright (c) 2012 by John Caserta. All rights reserved. 34 | 35 | Author: John Caserta 36 | License: SIL (http://scripts.sil.org/OFL) 37 | Homepage: http://thedesignoffice.org/project/modern-pictograms/ 38 | 39 | 40 | -------------------------------------------------------------------------------- /app/jvm/src/main/assets/lib/fontello/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "", 3 | "css_prefix_text": "icon-", 4 | "css_use_suffix": false, 5 | "hinting": true, 6 | "units_per_em": 1000, 7 | "ascent": 850, 8 | "glyphs": [ 9 | { 10 | "uid": "186dec7a13156bbe2550790c158fb85d", 11 | "css": "crown", 12 | "code": 59392, 13 | "src": "fontelico" 14 | }, 15 | { 16 | "uid": "48b87105bd38c20315f1b705b8c7b38c", 17 | "css": "list", 18 | "code": 59395, 19 | "src": "fontawesome" 20 | }, 21 | { 22 | "uid": "f279f25007794fa6837ff78fe94b216e", 23 | "css": "money", 24 | "code": 59396, 25 | "src": "fontawesome" 26 | }, 27 | { 28 | "uid": "8fb55fd696d9a0f58f3b27c1d8633750", 29 | "css": "table", 30 | "code": 59397, 31 | "src": "fontawesome" 32 | }, 33 | { 34 | "uid": "30b79160618d99ce798e4bd11cafe3fe", 35 | "css": "food", 36 | "code": 59400, 37 | "src": "fontawesome" 38 | }, 39 | { 40 | "uid": "ff1710a605b3fc98346903db89034558", 41 | "css": "balance-scale", 42 | "code": 62030, 43 | "src": "fontawesome" 44 | }, 45 | { 46 | "uid": "5408be43f7c42bccee419c6be53fdef5", 47 | "css": "template", 48 | "code": 61686, 49 | "src": "fontawesome" 50 | }, 51 | { 52 | "uid": "1b5a5d7b7e3c71437f5a26befdd045ed", 53 | "css": "new-empty", 54 | "code": 59393, 55 | "src": "fontawesome" 56 | }, 57 | { 58 | "uid": "fc455b9530e0b37facc289f42a120fdd", 59 | "css": "building", 60 | "code": 59409, 61 | "src": "maki" 62 | }, 63 | { 64 | "uid": "8a4eb4f98d8e0f3686a741c952b03c08", 65 | "css": "tennis", 66 | "code": 59394, 67 | "src": "maki" 68 | }, 69 | { 70 | "uid": "212ab54aac31030950fec3e2448ebee9", 71 | "css": "saving", 72 | "code": 59398, 73 | "src": "modernpics" 74 | } 75 | ] 76 | } -------------------------------------------------------------------------------- /app/jvm/src/main/assets/lib/fontello/css/animation.css: -------------------------------------------------------------------------------- 1 | /* 2 | Animation example, for spinners 3 | */ 4 | .animate-spin { 5 | -moz-animation: spin 2s infinite linear; 6 | -o-animation: spin 2s infinite linear; 7 | -webkit-animation: spin 2s infinite linear; 8 | animation: spin 2s infinite linear; 9 | display: inline-block; 10 | } 11 | @-moz-keyframes spin { 12 | 0% { 13 | -moz-transform: rotate(0deg); 14 | -o-transform: rotate(0deg); 15 | -webkit-transform: rotate(0deg); 16 | transform: rotate(0deg); 17 | } 18 | 19 | 100% { 20 | -moz-transform: rotate(359deg); 21 | -o-transform: rotate(359deg); 22 | -webkit-transform: rotate(359deg); 23 | transform: rotate(359deg); 24 | } 25 | } 26 | @-webkit-keyframes spin { 27 | 0% { 28 | -moz-transform: rotate(0deg); 29 | -o-transform: rotate(0deg); 30 | -webkit-transform: rotate(0deg); 31 | transform: rotate(0deg); 32 | } 33 | 34 | 100% { 35 | -moz-transform: rotate(359deg); 36 | -o-transform: rotate(359deg); 37 | -webkit-transform: rotate(359deg); 38 | transform: rotate(359deg); 39 | } 40 | } 41 | @-o-keyframes spin { 42 | 0% { 43 | -moz-transform: rotate(0deg); 44 | -o-transform: rotate(0deg); 45 | -webkit-transform: rotate(0deg); 46 | transform: rotate(0deg); 47 | } 48 | 49 | 100% { 50 | -moz-transform: rotate(359deg); 51 | -o-transform: rotate(359deg); 52 | -webkit-transform: rotate(359deg); 53 | transform: rotate(359deg); 54 | } 55 | } 56 | @-ms-keyframes spin { 57 | 0% { 58 | -moz-transform: rotate(0deg); 59 | -o-transform: rotate(0deg); 60 | -webkit-transform: rotate(0deg); 61 | transform: rotate(0deg); 62 | } 63 | 64 | 100% { 65 | -moz-transform: rotate(359deg); 66 | -o-transform: rotate(359deg); 67 | -webkit-transform: rotate(359deg); 68 | transform: rotate(359deg); 69 | } 70 | } 71 | @keyframes spin { 72 | 0% { 73 | -moz-transform: rotate(0deg); 74 | -o-transform: rotate(0deg); 75 | -webkit-transform: rotate(0deg); 76 | transform: rotate(0deg); 77 | } 78 | 79 | 100% { 80 | -moz-transform: rotate(359deg); 81 | -o-transform: rotate(359deg); 82 | -webkit-transform: rotate(359deg); 83 | transform: rotate(359deg); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /app/jvm/src/main/assets/lib/fontello/css/fontello-codes.css: -------------------------------------------------------------------------------- 1 | 2 | .icon-crown:before { content: '\e800'; } /* '' */ 3 | .icon-new-empty:before { content: '\e801'; } /* '' */ 4 | .icon-tennis:before { content: '\e802'; } /* '' */ 5 | .icon-list:before { content: '\e803'; } /* '' */ 6 | .icon-money:before { content: '\e804'; } /* '' */ 7 | .icon-table:before { content: '\e805'; } /* '' */ 8 | .icon-saving:before { content: '\e806'; } /* '' */ 9 | .icon-food:before { content: '\e808'; } /* '' */ 10 | .icon-building:before { content: '\e811'; } /* '' */ 11 | .icon-template:before { content: '\f0f6'; } /* '' */ 12 | .icon-balance-scale:before { content: '\f24e'; } /* '' */ -------------------------------------------------------------------------------- /app/jvm/src/main/assets/lib/fontello/css/fontello-ie7-codes.css: -------------------------------------------------------------------------------- 1 | 2 | .icon-crown { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); } 3 | .icon-new-empty { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); } 4 | .icon-tennis { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); } 5 | .icon-list { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); } 6 | .icon-money { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); } 7 | .icon-table { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); } 8 | .icon-saving { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); } 9 | .icon-food { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); } 10 | .icon-building { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); } 11 | .icon-template { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); } 12 | .icon-balance-scale { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); } -------------------------------------------------------------------------------- /app/jvm/src/main/assets/lib/fontello/css/fontello-ie7.css: -------------------------------------------------------------------------------- 1 | [class^="icon-"], [class*=" icon-"] { 2 | font-family: 'fontello'; 3 | font-style: normal; 4 | font-weight: normal; 5 | 6 | /* fix buttons height */ 7 | line-height: 1em; 8 | 9 | /* you can be more comfortable with increased icons size */ 10 | /* font-size: 120%; */ 11 | } 12 | 13 | .icon-crown { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); } 14 | .icon-new-empty { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); } 15 | .icon-tennis { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); } 16 | .icon-list { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); } 17 | .icon-money { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); } 18 | .icon-table { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); } 19 | .icon-saving { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); } 20 | .icon-food { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); } 21 | .icon-building { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); } 22 | .icon-template { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); } 23 | .icon-balance-scale { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); } -------------------------------------------------------------------------------- /app/jvm/src/main/assets/lib/fontello/font/fontello.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nymanjens/piga/dea554dafafb07ca3692d81a41241b857dc22274/app/jvm/src/main/assets/lib/fontello/font/fontello.eot -------------------------------------------------------------------------------- /app/jvm/src/main/assets/lib/fontello/font/fontello.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nymanjens/piga/dea554dafafb07ca3692d81a41241b857dc22274/app/jvm/src/main/assets/lib/fontello/font/fontello.ttf -------------------------------------------------------------------------------- /app/jvm/src/main/assets/lib/fontello/font/fontello.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nymanjens/piga/dea554dafafb07ca3692d81a41241b857dc22274/app/jvm/src/main/assets/lib/fontello/font/fontello.woff -------------------------------------------------------------------------------- /app/jvm/src/main/assets/lib/fontello/font/fontello.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nymanjens/piga/dea554dafafb07ca3692d81a41241b857dc22274/app/jvm/src/main/assets/lib/fontello/font/fontello.woff2 -------------------------------------------------------------------------------- /app/jvm/src/main/scala/app/Module.scala: -------------------------------------------------------------------------------- 1 | import app.api.ApiModule 2 | import app.common.CommonModule 3 | import app.controllers.ControllersModule 4 | import app.models.ModelsModule 5 | import com.google.inject.AbstractModule 6 | import app.tools.ApplicationStartHook 7 | 8 | final class Module extends AbstractModule { 9 | override def configure() = { 10 | bind(classOf[ApplicationStartHook]).asEagerSingleton 11 | 12 | install(new CommonModule) 13 | install(new ControllersModule) 14 | install(new ModelsModule) 15 | install(new ApiModule) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /app/jvm/src/main/scala/app/api/ApiModule.scala: -------------------------------------------------------------------------------- 1 | package app.api 2 | 3 | import com.google.inject.AbstractModule 4 | import hydro.api.EntityPermissions 5 | 6 | final class ApiModule extends AbstractModule { 7 | override def configure() = { 8 | bind(classOf[ScalaJsApiServerFactory]) 9 | bind(classOf[EntityPermissions]).to(classOf[AppEntityPermissions]) 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /app/jvm/src/main/scala/app/common/CommonModule.scala: -------------------------------------------------------------------------------- 1 | package app.common 2 | 3 | import java.time.ZoneId 4 | 5 | import com.google.inject._ 6 | import hydro.common.time.Clock 7 | import hydro.common.time.JvmClock 8 | import hydro.common.I18n 9 | import hydro.common.PlayI18n 10 | 11 | final class CommonModule extends AbstractModule { 12 | 13 | override def configure() = { 14 | bindSingleton(classOf[PlayI18n], classOf[PlayI18n.Impl]) 15 | bind(classOf[I18n]).to(classOf[PlayI18n]) 16 | } 17 | 18 | @Provides 19 | def provideClock(): Clock = 20 | // TODO: Make this configurable 21 | new JvmClock(ZoneId.of("Europe/Paris")) 22 | 23 | private def bindSingleton[T](interface: Class[T], implementation: Class[_ <: T]): Unit = { 24 | bind(interface).to(implementation) 25 | bind(implementation).asEagerSingleton 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /app/jvm/src/main/scala/app/controllers/ControllersModule.scala: -------------------------------------------------------------------------------- 1 | package app.controllers 2 | 3 | import hydro.controllers.InternalApi.ScalaJsApiCaller 4 | import hydro.controllers.JavascriptFiles.appAssets 5 | import hydro.controllers.JavascriptFiles.Asset 6 | import hydro.controllers.JavascriptFiles.UnversionedAsset 7 | import hydro.controllers.JavascriptFiles.VersionedAsset 8 | import app.controllers.helpers.ScalaJsApiCallerImpl 9 | import com.google.inject.AbstractModule 10 | import com.google.inject.Provides 11 | 12 | import scala.collection.immutable.Seq 13 | 14 | final class ControllersModule extends AbstractModule { 15 | 16 | override def configure() = { 17 | bind(classOf[ScalaJsApiCaller]).to(classOf[ScalaJsApiCallerImpl]) 18 | } 19 | 20 | @Provides @appAssets def provideAppAssets: Seq[Asset] = Seq( 21 | VersionedAsset("bootstrap/dist/css/bootstrap.min.css"), 22 | VersionedAsset("metismenu/dist/metisMenu.min.css"), 23 | VersionedAsset("font-awesome/css/font-awesome.min.css"), 24 | UnversionedAsset("font-awesome/fonts/fontawesome-webfont.woff2?v=4.6.3"), 25 | UnversionedAsset("font-awesome/fonts/fontawesome-webfont.woff?v=4.6.3 0"), 26 | UnversionedAsset("font-awesome/fonts/fontawesome-webfont.ttf?v=4.6.3"), 27 | VersionedAsset("lib/fontello/css/fontello.css"), 28 | UnversionedAsset("lib/fontello/font/fontello.woff2?49985636"), 29 | VersionedAsset("startbootstrap-sb-admin-2/dist/css/sb-admin-2.css"), 30 | VersionedAsset("stylesheets/main.min.css"), 31 | VersionedAsset("jquery/dist/jquery.min.js"), 32 | VersionedAsset("bootstrap/dist/js/bootstrap.min.js"), 33 | VersionedAsset("metismenu/dist/metisMenu.min.js"), 34 | VersionedAsset("bootbox/dist/bootbox.all.min.js"), 35 | VersionedAsset("startbootstrap-sb-admin-2/dist/js/sb-admin-2.js"), 36 | UnversionedAsset("bootstrap/dist/fonts/glyphicons-halflings-regular.woff2"), 37 | ) 38 | } 39 | -------------------------------------------------------------------------------- /app/jvm/src/main/scala/app/controllers/helpers/ScalaJsApiCallerImpl.scala: -------------------------------------------------------------------------------- 1 | package app.controllers.helpers 2 | 3 | import java.nio.ByteBuffer 4 | 5 | import app.api.ScalaJsApi.UserPrototype 6 | import app.api.ScalaJsApiServerFactory 7 | import hydro.controllers.InternalApi.ScalaJsApiCaller 8 | import app.models.user.User 9 | import boopickle.Default._ 10 | import app.api.Picklers._ 11 | import app.models.document.DocumentEntity 12 | import com.google.inject.Inject 13 | import hydro.api.PicklableDbQuery 14 | import hydro.models.Entity 15 | import hydro.models.modification.EntityModification 16 | import hydro.models.modification.EntityType 17 | 18 | import scala.collection.immutable.Seq 19 | 20 | final class ScalaJsApiCallerImpl @Inject() (implicit scalaJsApiServerFactory: ScalaJsApiServerFactory) 21 | extends ScalaJsApiCaller { 22 | 23 | override def apply(path: String, argsMap: Map[String, ByteBuffer])(implicit user: User): ByteBuffer = { 24 | val scalaJsApiServer = scalaJsApiServerFactory.create() 25 | 26 | path match { 27 | case "getInitialData" => 28 | Pickle.intoBytes(scalaJsApiServer.getInitialData()) 29 | case "getAllEntities" => 30 | val types = Unpickle[Seq[EntityType.any]].fromBytes(argsMap("types")) 31 | Pickle.intoBytes(scalaJsApiServer.getAllEntities(types)) 32 | case "persistEntityModifications" => 33 | val modifications = Unpickle[Seq[EntityModification]].fromBytes(argsMap("modifications")) 34 | val waitUntilQueryReflectsModifications = 35 | Unpickle[Boolean].fromBytes(argsMap("waitUntilQueryReflectsModifications")) 36 | Pickle.intoBytes( 37 | scalaJsApiServer.persistEntityModifications(modifications, waitUntilQueryReflectsModifications) 38 | ) 39 | case "executeDataQuery" => 40 | val dbQuery = Unpickle[PicklableDbQuery].fromBytes(argsMap("dbQuery")) 41 | Pickle.intoBytes[Seq[Entity]](scalaJsApiServer.executeDataQuery(dbQuery)) 42 | case "executeCountQuery" => 43 | val dbQuery = Unpickle[PicklableDbQuery].fromBytes(argsMap("dbQuery")) 44 | Pickle.intoBytes(scalaJsApiServer.executeCountQuery(dbQuery)) 45 | case "upsertUser" => 46 | val userPrototype = Unpickle[UserPrototype].fromBytes(argsMap("userPrototype")) 47 | Pickle.intoBytes(scalaJsApiServer.upsertUser(userPrototype)) 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /app/jvm/src/main/scala/app/models/ModelsModule.scala: -------------------------------------------------------------------------------- 1 | package app.models 2 | 3 | import app.models.access.JvmEntityAccess 4 | import com.google.inject.AbstractModule 5 | import hydro.models.access.EntityAccess 6 | 7 | final class ModelsModule extends AbstractModule { 8 | override def configure() = { 9 | bindSingleton(classOf[EntityAccess], classOf[JvmEntityAccess]) 10 | } 11 | 12 | private def bindSingleton[T](interface: Class[T], implementation: Class[_ <: T]): Unit = { 13 | bind(interface).to(implementation) 14 | bind(implementation).asEagerSingleton 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /app/jvm/src/main/scala/app/models/access/JvmEntityAccess.scala: -------------------------------------------------------------------------------- 1 | package app.models.access 2 | 3 | import app.models.document.DocumentEntity 4 | import app.models.document.DocumentPermissionAndPlacement 5 | import app.models.document.TaskEntity 6 | import app.models.slick.SlickEntityTableDefs 7 | import app.models.user.User 8 | import com.google.inject._ 9 | import hydro.common.time.Clock 10 | import hydro.models.access.JvmEntityAccessBase 11 | import hydro.models.modification.EntityType 12 | import hydro.models.slick.SlickEntityTableDef 13 | 14 | final class JvmEntityAccess @Inject() (implicit clock: Clock) extends JvmEntityAccessBase { 15 | 16 | protected def getEntityTableDef(entityType: EntityType.any): SlickEntityTableDef[entityType.get] = { 17 | val tableDef = entityType match { 18 | case User.Type => SlickEntityTableDefs.UserDef 19 | case DocumentEntity.Type => SlickEntityTableDefs.DocumentEntityDef 20 | case DocumentPermissionAndPlacement.Type => SlickEntityTableDefs.DocumentPermissionAndPlacementDef 21 | case TaskEntity.Type => SlickEntityTableDefs.TaskEntityDef 22 | } 23 | tableDef.asInstanceOf[SlickEntityTableDef[entityType.get]] 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /app/jvm/src/main/scala/app/models/user/Users.scala: -------------------------------------------------------------------------------- 1 | package app.models.user 2 | 3 | import app.models.access.JvmEntityAccess 4 | import app.models.access.ModelFields 5 | import hydro.models.modification.EntityModification 6 | import com.google.common.base.Charsets 7 | import com.google.common.hash.Hashing 8 | import hydro.common.time.Clock 9 | import hydro.models.access.DbQueryImplicits._ 10 | 11 | object Users { 12 | 13 | def createUser(loginName: String, password: String, name: String, isAdmin: Boolean = false): User = 14 | User( 15 | loginName = loginName, 16 | passwordHash = hash(password), 17 | name = name, 18 | isAdmin = isAdmin, 19 | ) 20 | 21 | def copyUserWithPassword(user: User, password: String): User = { 22 | user.copy(passwordHash = hash(password)) 23 | } 24 | 25 | def getOrCreateRobotUser()(implicit entityAccess: JvmEntityAccess, clock: Clock): User = { 26 | val loginName = "robot" 27 | def hash(s: String) = Hashing.sha512().hashString(s, Charsets.UTF_8).toString 28 | 29 | entityAccess.newQuerySync[User]().findOne(ModelFields.User.loginName === loginName) match { 30 | case Some(user) => user 31 | case None => 32 | val userAddition = EntityModification.createAddWithRandomId( 33 | createUser( 34 | loginName = loginName, 35 | password = hash(clock.now.toString), 36 | name = "Robot", 37 | ) 38 | ) 39 | val userWithId = userAddition.entity 40 | entityAccess.persistEntityModifications(userAddition)(user = userWithId) 41 | userWithId 42 | } 43 | } 44 | 45 | def authenticate(loginName: String, password: String)(implicit entityAccess: JvmEntityAccess): Boolean = { 46 | entityAccess.newQuerySync[User]().findOne(ModelFields.User.loginName === loginName) match { 47 | case Some(user) if user.passwordHash == hash(password) => true 48 | case _ => false 49 | } 50 | } 51 | 52 | private def hash(password: String) = Hashing.sha512().hashString(password, Charsets.UTF_8).toString 53 | } 54 | -------------------------------------------------------------------------------- /app/jvm/src/main/scala/hydro/api/EntityPermissions.scala: -------------------------------------------------------------------------------- 1 | package hydro.api 2 | 3 | import app.models.user.User 4 | import hydro.models.modification.EntityModification 5 | import hydro.models.Entity 6 | 7 | /** 8 | * Permissions for arbitrary Entity read/write operations. 9 | * 10 | * Note that this explicitly excludes server-implemented modifications, which can have custom permissions. 11 | */ 12 | trait EntityPermissions { 13 | 14 | /** Throws an exception if this modifications is not allowed as write operation. */ 15 | def checkAllowedForWrite(modification: EntityModification)(implicit user: User): Unit 16 | 17 | /** 18 | * Returns true if the given entity can be read by the given user. 19 | * 20 | * Warning: EntityPermissions doesn't resend EntityModifications if permissions change. It is the responsibility 21 | * of the updater to foresee a mechanism to invalidate the local database(s). 22 | */ 23 | def isAllowedToRead(entity: Entity)(implicit user: User): Boolean 24 | 25 | final def isAllowedToStream(entityModification: EntityModification)(implicit user: User): Boolean = { 26 | entityModification match { 27 | case EntityModification.Add(entity) => isAllowedToRead(entity) 28 | case EntityModification.Update(entity) => isAllowedToRead(entity) 29 | case EntityModification.Remove(_) => true 30 | } 31 | } 32 | } 33 | object EntityPermissions { 34 | 35 | object DefaultImpl extends EntityPermissions { 36 | 37 | override def checkAllowedForWrite(modification: EntityModification)(implicit user: User): Unit = { 38 | require(modification.entityType != User.Type, "Please modify users by calling upsertUser() instead") 39 | } 40 | 41 | override def isAllowedToRead(entity: Entity)(implicit user: User): Boolean = true 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /app/jvm/src/main/scala/hydro/common/PlayI18n.scala: -------------------------------------------------------------------------------- 1 | package hydro.common 2 | 3 | import com.google.inject.Inject 4 | import hydro.common.GuavaReplacement.Iterables.getOnlyElement 5 | import play.api.i18n.Lang 6 | import play.api.i18n.Langs 7 | import play.api.i18n.MessagesApi 8 | 9 | trait PlayI18n extends I18n { 10 | 11 | /** Returns a map that maps key to the message with placeholders. */ 12 | def allI18nMessages: Map[String, String] 13 | } 14 | 15 | object PlayI18n { 16 | final class Impl @Inject() (implicit val messagesApi: MessagesApi, langs: Langs) extends PlayI18n { 17 | 18 | private val defaultLang: Lang = { 19 | require(langs.availables.size == 1, "Only a single language is supported at a time.") 20 | getOnlyElement(langs.availables) 21 | } 22 | 23 | // ****************** Implementation of PlayI18n trait ****************** // 24 | override def apply(key: String, args: Any*): String = { 25 | messagesApi(key, args: _*)(defaultLang) 26 | } 27 | 28 | override val allI18nMessages: Map[String, String] = { 29 | // defaultLang is extended by "default" in case it didn't overwrite a message key. 30 | messagesApi.messages("default") ++ messagesApi.messages(defaultLang.code) 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /app/jvm/src/main/scala/hydro/common/ResourceFiles.scala: -------------------------------------------------------------------------------- 1 | package hydro.common 2 | 3 | import java.io.FileNotFoundException 4 | import java.nio.file.Path 5 | import java.nio.file.Paths 6 | 7 | object ResourceFiles { 8 | 9 | def read(path: String): String = { 10 | Option(getClass.getResourceAsStream(path)) 11 | .map(scala.io.Source.fromInputStream) 12 | .map(_.mkString) 13 | .getOrElse(throw new FileNotFoundException(path)) 14 | } 15 | 16 | def read(path: Path): String = read(path.toString) 17 | 18 | def readLines(path: String): List[String] = { 19 | Option(getClass.getResourceAsStream(path)) 20 | .map(scala.io.Source.fromInputStream) 21 | .map(_.getLines.toList) 22 | .getOrElse(throw new FileNotFoundException(path)) 23 | } 24 | 25 | def readLines(path: Path): List[String] = readLines(path.toString) 26 | 27 | def exists(path: String): Boolean = { 28 | getClass.getResourceAsStream(path) != null 29 | } 30 | 31 | def exists(path: Path): Boolean = exists(path.toString) 32 | 33 | def canonicalizePath(pathString: String): String = { 34 | val pathStringWithHomeResolved = pathString.replaceFirst("^~", System.getProperty("user.home")) 35 | Paths.get(pathStringWithHomeResolved).toAbsolutePath.normalize.toString 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /app/jvm/src/main/scala/hydro/common/UpdateTokens.scala: -------------------------------------------------------------------------------- 1 | package hydro.common 2 | 3 | import java.time.Instant 4 | 5 | import app.api.ScalaJsApi.UpdateToken 6 | import hydro.common.GuavaReplacement.Splitter 7 | 8 | import scala.collection.immutable.Seq 9 | 10 | object UpdateTokens { 11 | 12 | def toUpdateToken(instant: Instant): UpdateToken = { 13 | s"${instant.getEpochSecond}:${instant.getNano}" 14 | } 15 | 16 | def toInstant(updateToken: UpdateToken): Instant = { 17 | val Seq(epochSecond, nano) = Splitter.on(':').split(updateToken) 18 | Instant.ofEpochSecond(epochSecond.toLong).plusNanos(nano.toLong) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /app/jvm/src/main/scala/hydro/common/publisher/TriggerablePublisher.scala: -------------------------------------------------------------------------------- 1 | package hydro.common.publisher 2 | 3 | import org.reactivestreams.Publisher 4 | import org.reactivestreams.Subscriber 5 | import org.reactivestreams.Subscription 6 | 7 | import scala.collection.JavaConverters._ 8 | 9 | final class TriggerablePublisher[T] extends Publisher[T] { 10 | private val subscribers: java.util.List[Subscriber[_ >: T]] = 11 | new java.util.concurrent.CopyOnWriteArrayList() 12 | 13 | override def subscribe(subscriber: Subscriber[_ >: T]): Unit = { 14 | subscribers.add(subscriber) 15 | subscriber.onSubscribe(new Subscription { 16 | override def request(n: Long): Unit = {} 17 | override def cancel(): Unit = { 18 | subscribers.remove(subscriber) 19 | } 20 | }) 21 | } 22 | 23 | def trigger(value: T): Unit = { 24 | for (s <- subscribers.asScala) { 25 | s.onNext(value) 26 | } 27 | } 28 | 29 | def complete(): Unit = { 30 | for (s <- subscribers.asScala) { 31 | s.onComplete() 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /app/jvm/src/main/scala/hydro/common/time/JvmClock.scala: -------------------------------------------------------------------------------- 1 | package hydro.common.time 2 | 3 | import java.time.Instant 4 | import java.time.LocalDate 5 | import java.time.LocalTime 6 | import java.time.ZoneId 7 | 8 | final class JvmClock(zone: ZoneId) extends Clock { 9 | 10 | private val initialInstant: Instant = Instant.now 11 | private val initialNanos: Long = System.nanoTime 12 | 13 | override def now = { 14 | val date = LocalDate.now(zone) 15 | val time = LocalTime.now(zone) 16 | LocalDateTime.of(date, time) 17 | } 18 | 19 | override def nowInstant = initialInstant plusNanos (System.nanoTime - initialNanos) 20 | } 21 | -------------------------------------------------------------------------------- /app/jvm/src/main/scala/hydro/models/modification/EntityModificationEntity.scala: -------------------------------------------------------------------------------- 1 | package hydro.models.modification 2 | 3 | import java.time.Instant 4 | 5 | import app.models.access.JvmEntityAccess 6 | import app.models.user.User 7 | import hydro.models.Entity 8 | 9 | /** 10 | * Symbolises a modification to an entity. 11 | * 12 | * EntityModificationEntity entities are immutable and are assumed to be relatively short-lived, especially after 13 | * code updates to related models. 14 | */ 15 | case class EntityModificationEntity( 16 | userId: Long, 17 | modification: EntityModification, 18 | instant: Instant, 19 | override val idOption: Option[Long] = None, 20 | ) extends Entity { 21 | require(userId > 0) 22 | for (idVal <- idOption) require(idVal > 0) 23 | 24 | override def withId(id: Long) = copy(idOption = Some(id)) 25 | 26 | def user(implicit entityAccess: JvmEntityAccess): User = entityAccess.newQuerySync[User]().findById(userId) 27 | } 28 | 29 | object EntityModificationEntity { 30 | def tupled = (this.apply _).tupled 31 | } 32 | -------------------------------------------------------------------------------- /app/jvm/src/main/scala/hydro/models/slick/SlickEntityManager.scala: -------------------------------------------------------------------------------- 1 | package hydro.models.slick 2 | 3 | import hydro.models.slick.SlickUtils.dbApi._ 4 | import hydro.models.slick.SlickUtils.dbRun 5 | import hydro.models.Entity 6 | 7 | import scala.collection.immutable.Seq 8 | 9 | final class SlickEntityManager[E <: Entity] private (implicit val tableDef: SlickEntityTableDef[E]) { 10 | 11 | // ********** Management methods ********** // 12 | def createTable(): Unit = { 13 | //Logger.info( 14 | // s"Creating table `${tableDef.tableName}`:\n " + 15 | // newQuery.schema.createStatements.mkString("\n")) 16 | dbRun(newQuery.schema.create) 17 | } 18 | 19 | // ********** Mutators ********** // 20 | def addNew(entityWithId: E): Unit = { 21 | require(entityWithId.idOption.isDefined, s"This entity has no id ($entityWithId)") 22 | mustAffectOneSingleRow { 23 | dbRun(newQuery.forceInsert(entityWithId)) 24 | } 25 | } 26 | 27 | def updateIfExists(entityWithId: E): Unit = { 28 | require(entityWithId.idOption.isDefined, s"This entity has no id ($entityWithId)") 29 | dbRun(newQuery.filter(_.id === entityWithId.id).update(entityWithId)) 30 | } 31 | 32 | def removeIfExists(entityId: Long): Unit = { 33 | dbRun(newQuery.filter(_.id === entityId).delete) 34 | } 35 | 36 | // ********** Getters ********** // 37 | def fetchAll(): Seq[E] = dbRun(newQuery).toVector 38 | 39 | def newQuery: TableQuery[tableDef.Table] = new TableQuery(tableDef.table) 40 | 41 | private def mustAffectOneSingleRow(query: => Int): Unit = { 42 | val affectedRows = query 43 | require(affectedRows == 1, s"Query affected $affectedRows rows") 44 | } 45 | } 46 | object SlickEntityManager { 47 | def forType[E <: Entity: SlickEntityTableDef]: SlickEntityManager[E] = new SlickEntityManager[E] 48 | } 49 | -------------------------------------------------------------------------------- /app/jvm/src/main/scala/hydro/models/slick/SlickEntityTableDef.scala: -------------------------------------------------------------------------------- 1 | package hydro.models.slick 2 | 3 | import hydro.models.slick.SlickUtils.dbApi.{Table => SlickTable} 4 | import hydro.models.slick.SlickUtils.dbApi.{Tag => SlickTag} 5 | import hydro.models.slick.SlickUtils.dbApi._ 6 | import hydro.models.Entity 7 | 8 | trait SlickEntityTableDef[E <: Entity] { 9 | type Table <: SlickEntityTableDef.EntityTable[E] 10 | def tableName: String 11 | def table(tag: SlickTag): Table 12 | } 13 | 14 | object SlickEntityTableDef { 15 | 16 | /** Table extension to be used with an Entity model. */ 17 | // Based on active-slick (https://github.com/strongtyped/active-slick) 18 | abstract class EntityTable[E <: Entity]( 19 | tag: SlickTag, 20 | tableName: String, 21 | schemaName: Option[String] = None, 22 | )(implicit val colType: BaseColumnType[Long]) 23 | extends SlickTable[E](tag, schemaName, tableName) { 24 | 25 | def id = column[Long]("id", O.PrimaryKey, O.AutoInc) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /app/jvm/src/main/scala/hydro/models/slick/StandardSlickEntityTableDefs.scala: -------------------------------------------------------------------------------- 1 | package hydro.models.slick 2 | 3 | import java.time.Instant 4 | 5 | import app.api.Picklers._ 6 | import hydro.models.modification.EntityModification 7 | import hydro.models.modification.EntityModificationEntity 8 | import hydro.models.slick.SlickEntityTableDef.EntityTable 9 | import hydro.models.slick.SlickUtils.dbApi._ 10 | import hydro.models.slick.SlickUtils.dbApi.{Tag => SlickTag} 11 | import hydro.models.slick.SlickUtils.instantToSqlTimestampMapper 12 | 13 | object StandardSlickEntityTableDefs { 14 | 15 | implicit object EntityModificationEntityDef extends SlickEntityTableDef[EntityModificationEntity] { 16 | 17 | override val tableName: String = "ENTITY_MODIFICATION_ENTITY" 18 | override def table(tag: SlickTag): Table = new Table(tag) 19 | 20 | /* override */ 21 | final class Table(tag: SlickTag) extends EntityTable[EntityModificationEntity](tag, tableName) { 22 | def userId = column[Long]("userId") 23 | def entityId = column[Long]("entityId") 24 | def change = column[EntityModification]("modification") 25 | def instant = column[Instant]("date")(instantToSqlTimestampMapper) 26 | // The instant field can't hold the nano precision of the `instant` field above. It thus 27 | // has to be persisted separately. 28 | def instantNanos = column[Long]("instantNanos") 29 | 30 | override def * = { 31 | def tupled( 32 | tuple: (Long, Long, EntityModification, Instant, Long, Option[Long]) 33 | ): EntityModificationEntity = 34 | tuple match { 35 | case (userId, entityId, modification, instant, instantNanos, idOption) => 36 | EntityModificationEntity( 37 | userId = userId, 38 | modification = modification, 39 | instant = Instant.ofEpochSecond(instant.getEpochSecond, instantNanos), 40 | idOption = idOption, 41 | ) 42 | } 43 | def unapply( 44 | e: EntityModificationEntity 45 | ): Option[(Long, Long, EntityModification, Instant, Long, Option[Long])] = 46 | Some((e.userId, e.modification.entityId, e.modification, e.instant, e.instant.getNano, e.idOption)) 47 | 48 | (userId, entityId, change, instant, instantNanos, id.?) <> (tupled _, unapply _) 49 | } 50 | } 51 | 52 | implicit val entityModificationToBytesMapper: ColumnType[EntityModification] = 53 | SlickUtils.bytesMapperFromPickler 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /app/jvm/src/main/twirl/views/Base.scala.html: -------------------------------------------------------------------------------- 1 | @( 2 | title: String, 3 | extrascript: Html = Html("") 4 | )( 5 | content: Html 6 | )( 7 | implicit env: play.api.Environment 8 | ) 9 | 10 | 11 | 12 | 13 | 14 | @title 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | @* *@ 23 | 24 | 25 | @* ************ CSS ************ *@ 26 | @* Bootstrap Core CSS *@ 27 | 28 | 29 | @* Metis menu (used by Start Bootstrap) *@ 30 | 31 | 32 | @* Custom Fonts *@ 33 | 34 | 35 | 36 | @* startbootstrap-sb-admin-2 CSS *@ 37 | 38 | 39 | @* Custom CSS *@ 40 | 41 | 42 | 43 | 44 | 45 | @content 46 | 47 | @* ************ JAVASCRIPT ************ *@ 48 | 49 | 50 | 51 | 52 | 53 | @* startbootstrap-sb-admin-2 JavaScript *@ 54 | 55 | 56 | @extrascript 57 | 58 | 59 | -------------------------------------------------------------------------------- /app/jvm/src/main/twirl/views/doneAndClose.scala.html: -------------------------------------------------------------------------------- 1 | @() 2 | 3 | 4 | 5 | 6 | 7 | Done 8 | 11 | 12 | 13 | Done 14 | 15 | 16 | -------------------------------------------------------------------------------- /app/jvm/src/main/twirl/views/login.scala.html: -------------------------------------------------------------------------------- 1 | @( 2 | loginForm: Form[(String,String)], 3 | returnTo: String 4 | )( 5 | implicit request: Request[AnyContent], messages: Messages, flash: Flash, env: play.api.Environment 6 | ) 7 | 8 | @import helper._ 9 | 10 | @Base(Messages("app.login")) { 11 |
12 |
13 |
14 | 44 |
45 |
46 |
47 | } 48 | -------------------------------------------------------------------------------- /app/jvm/src/main/twirl/views/reactApp.scala.html: -------------------------------------------------------------------------------- 1 | @import helper._ 2 | 3 | @( 4 | )( 5 | implicit env: play.api.Environment 6 | ) 7 | 8 | @extrascript = { 9 | @* The compiled scala.js code *@ 10 | @utils.ImportScalaJsProject( 11 | projectName = "client", 12 | assets = path => routes.Assets.versioned(path).toString, 13 | resourceExists = name => getClass.getResource(s"/public/$name") != null) 14 | } 15 | 16 | @Base("Task Keeper", extrascript = extrascript) { 17 |
18 |
19 |
Loading....
20 |
21 |
22 | } 23 | -------------------------------------------------------------------------------- /app/jvm/src/main/twirl/views/utils/ImportScalaJsProject.scala.html: -------------------------------------------------------------------------------- 1 | @import scalajs.html.jsScript 2 | 3 | @(projectName: String, 4 | assets: String => String, 5 | resourceExists: String => Boolean, 6 | htmlAttributes: Html = Html("") 7 | ) 8 | 9 | @defining(s"${projectName.toLowerCase}") { name => 10 | @Seq(s"$name-opt-library.js", s"$name-fastopt-library.js").find(resourceExists).map(name => jsScript(assets(name), htmlAttributes)) 11 | } 12 | 16 | @defining(s"${projectName.toLowerCase}") { name => 17 | @Seq(s"$name-opt.js", s"$name-fastopt.js").find(resourceExists).map(name => jsScript(assets(name), htmlAttributes)) 18 | } 19 | -------------------------------------------------------------------------------- /app/jvm/src/test/resources/test-application.conf: -------------------------------------------------------------------------------- 1 | # Secret key 2 | # ~~~~~ 3 | play.http.secret.key = "testsecret" 4 | 5 | # The application languages 6 | # ~~~~~ 7 | play.i18n.langs = ["en"] 8 | akka.http.parsing.max-uri-length = 16k 9 | 10 | # Database configuration 11 | # ~~~~~ 12 | # In memory database: 13 | db.default.driver = org.h2.Driver 14 | db.default.url = "jdbc:h2:mem:test1" 15 | db.default.connectionPool = disabled 16 | db.default.keepAliveConnection = true 17 | db.default.logStatements = true 18 | db.default.slick.profile = "slick.jdbc.H2Profile$" 19 | 20 | # Application-specific configuration 21 | # ~~~~~ 22 | app.development.dropAndCreateNewDb = true 23 | -------------------------------------------------------------------------------- /app/jvm/src/test/scala/app/api/PicklersTest.scala: -------------------------------------------------------------------------------- 1 | package app.api 2 | 3 | import app.api.Picklers._ 4 | import app.api.ScalaJsApi._ 5 | import app.common.testing.TestObjects._ 6 | import app.common.testing._ 7 | import app.models.document.TaskEntity 8 | import hydro.common.testing._ 9 | import hydro.models.modification.EntityModification 10 | import hydro.models.modification.EntityType 11 | import app.models.user.User 12 | import boopickle.Default._ 13 | import boopickle.Pickler 14 | import org.junit.runner._ 15 | import org.specs2.runner._ 16 | 17 | import scala.collection.immutable.Seq 18 | 19 | @RunWith(classOf[JUnitRunner]) 20 | class PicklersTest extends HookedSpecification { 21 | 22 | "EntityModification" in { 23 | testPickleAndUnpickle[EntityModification](EntityModification.Add(testTaskEntity)) 24 | testPickleAndUnpickle[EntityModification](EntityModification.Add(testDocumentEntity)) 25 | testPickleAndUnpickle[EntityModification](EntityModification.Add(testDocumentPermissionAndPlacement)) 26 | } 27 | 28 | "GetInitialDataResponse" in { 29 | testPickleAndUnpickle[GetInitialDataResponse](testGetInitialDataResponse) 30 | } 31 | 32 | "TaskEntity" in { 33 | testPickleAndUnpickle[TaskEntity](testTaskEntity) 34 | } 35 | 36 | private def testPickleAndUnpickle[T: Pickler](value: T) = { 37 | val bytes = Pickle.intoBytes[T](value) 38 | val unpickled = Unpickle[T].fromBytes(bytes) 39 | unpickled mustEqual value 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /app/jvm/src/test/scala/app/common/CaseFormatsTest.scala: -------------------------------------------------------------------------------- 1 | package app.common 2 | 3 | import com.google.common.truth.Truth.assertThat 4 | import com.google.testing.junit.testparameterinjector.TestParameter 5 | import com.google.testing.junit.testparameterinjector.TestParameterInjector 6 | import com.google.testing.junit.testparameterinjector.TestParameters 7 | import org.junit.runner.RunWith 8 | import org.junit.Test 9 | 10 | import scala.collection.JavaConverters._ 11 | 12 | @RunWith(classOf[TestParameterInjector]) 13 | class CaseFormatsTest { 14 | 15 | @Test 16 | @TestParameters( 17 | Array( 18 | // snake_case 19 | "{input: ab_c, expected: [ab, c]}", 20 | // CamelCase 21 | "{input: abC, expected: [ab, c]}", 22 | "{input: AbCdE, expected: [ab, cd, e]}", 23 | "{input: AbCdEF, expected: [ab, cd, e, f]}", 24 | // dash-case 25 | "{input: ab-C, expected: [ab, c]}", 26 | // Other 27 | "{input: THE angryBird, expected: [the, angry, bird]}", 28 | "{input: 'a;b,c:-=+', expected: [a, b, c]}", 29 | "{input: a1, expected: [a1]}", 30 | ) 31 | ) 32 | def tokenize_success(input: String, expected: java.util.List[String]) = { 33 | assertThat(CaseFormats.tokenize(input).asJava) containsExactlyElementsIn expected 34 | } 35 | 36 | @Test 37 | @TestParameters( 38 | Array( 39 | "{input: [a], expected: A}", 40 | "{input: [ab], expected: Ab}", 41 | "{input: [ab, c], expected: AbC}", 42 | "{input: [ab, c, d], expected: AbCD}", 43 | ) 44 | ) 45 | def toUpperCamelCase_success(input: java.util.List[String], expected: String) = { 46 | assertThat(CaseFormats.toUpperCamelCase(input.asScala)) isEqualTo expected 47 | } 48 | 49 | @Test 50 | @TestParameters( 51 | Array( 52 | "{input: [a], expected: a}", 53 | "{input: [ab], expected: ab}", 54 | "{input: [ab, c], expected: ab_c}", 55 | "{input: [ab, c, d], expected: ab_c_d}", 56 | ) 57 | ) 58 | def toSnakeCase_success(input: java.util.List[String], expected: String) = { 59 | assertThat(CaseFormats.toSnakeCase(input.asScala)) isEqualTo expected 60 | } 61 | 62 | @Test 63 | @TestParameters( 64 | Array( 65 | "{input: [a], expected: a}", 66 | "{input: [ab], expected: ab}", 67 | "{input: [ab, c], expected: ab-c}", 68 | "{input: [ab, c, d], expected: ab-c-d}", 69 | ) 70 | ) 71 | def toDashCase_success(input: java.util.List[String], expected: String) = { 72 | assertThat(CaseFormats.toDashCase(input.asScala)) isEqualTo expected 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /app/jvm/src/test/scala/app/common/testing/TestModule.scala: -------------------------------------------------------------------------------- 1 | package app.common.testing 2 | 3 | import app.api.ApiModule 4 | import app.api.ScalaJsApiServerFactory 5 | import app.models.ModelsModule 6 | import app.models.access.JvmEntityAccess 7 | import com.google.inject._ 8 | import hydro.api.EntityPermissions 9 | import hydro.common.testing.FakeClock 10 | import hydro.common.time._ 11 | import hydro.common.PlayI18n 12 | import hydro.common.testing.FakePlayI18n 13 | import hydro.common.I18n 14 | import hydro.models.access.EntityAccess 15 | import hydro.models.access.JvmEntityAccessBase 16 | 17 | final class TestModule extends AbstractModule { 18 | 19 | override def configure() = { 20 | install(new ModelsModule) 21 | bind(classOf[ScalaJsApiServerFactory]) 22 | bind(classOf[EntityPermissions]).toInstance(EntityPermissions.DefaultImpl) 23 | bindSingleton(classOf[Clock], classOf[FakeClock]) 24 | bindSingleton(classOf[PlayI18n], classOf[FakePlayI18n]) 25 | bind(classOf[I18n]).to(classOf[PlayI18n]) 26 | 27 | bind(classOf[EntityAccess]).to(classOf[JvmEntityAccess]) 28 | bind(classOf[JvmEntityAccessBase]).to(classOf[JvmEntityAccess]) 29 | } 30 | 31 | @Provides() 32 | private[testing] def playConfiguration(): play.api.Configuration = { 33 | play.api.Configuration.from( 34 | Map( 35 | ) 36 | ) 37 | } 38 | 39 | private def bindSingleton[T](interface: Class[T], implementation: Class[_ <: T]): Unit = { 40 | bind(interface).to(implementation) 41 | bind(implementation).asEagerSingleton() 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /app/jvm/src/test/scala/app/common/testing/TestUtils.scala: -------------------------------------------------------------------------------- 1 | package app.common.testing 2 | 3 | import java.time.Instant 4 | import java.time.ZoneId 5 | 6 | import app.models.access.JvmEntityAccess 7 | import hydro.models.modification.EntityModification 8 | import hydro.models.modification.EntityType 9 | import app.models.user.User 10 | import hydro.common.time.LocalDateTime 11 | import hydro.models.Entity 12 | 13 | object TestUtils { 14 | 15 | def persist[E <: Entity: EntityType](entity: E)(implicit entityAccess: JvmEntityAccess): E = { 16 | implicit val user = User( 17 | idOption = Some(9213982174887321L), 18 | loginName = "robot", 19 | passwordHash = "Some hash", 20 | name = "Robot", 21 | isAdmin = false, 22 | ) 23 | val addition = 24 | if (entity.idOption.isDefined) EntityModification.Add(entity) 25 | else EntityModification.createAddWithRandomId(entity) 26 | entityAccess.persistEntityModifications(addition) 27 | addition.entity 28 | } 29 | 30 | def localDateTimeOfEpochSecond(milli: Long): LocalDateTime = { 31 | val instant = Instant.ofEpochSecond(milli).atZone(ZoneId.of("Europe/Paris")) 32 | LocalDateTime.of( 33 | instant.toLocalDate, 34 | instant.toLocalTime, 35 | ) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /app/jvm/src/test/scala/app/models/user/UsersTest.scala: -------------------------------------------------------------------------------- 1 | package app.models.user 2 | 3 | import app.common.testing._ 4 | import hydro.common.testing._ 5 | import app.models.access.JvmEntityAccess 6 | import com.google.inject._ 7 | import org.junit.runner._ 8 | import org.specs2.runner._ 9 | import play.api.test._ 10 | 11 | @RunWith(classOf[JUnitRunner]) 12 | class UsersTest extends HookedSpecification { 13 | 14 | @Inject implicit private val entityAccess: JvmEntityAccess = null 15 | @Inject implicit private val clock: FakeClock = null 16 | 17 | override def before() = { 18 | Guice.createInjector(new TestModule).injectMembers(this) 19 | } 20 | 21 | "createUser()" in new WithApplication { 22 | val user = Users.createUser("alice", password = "j", name = "Alice") 23 | 24 | user.loginName mustEqual "alice" 25 | user.name mustEqual "Alice" 26 | } 27 | 28 | "getOrCreateRobotUser()" in new WithApplication { 29 | val robotUser = Users.getOrCreateRobotUser() 30 | val secondRobotUser = Users.getOrCreateRobotUser() 31 | 32 | robotUser.loginName mustEqual "robot" 33 | secondRobotUser mustEqual robotUser 34 | entityAccess.newQuerySync[User]().data() mustEqual Seq(robotUser) 35 | } 36 | 37 | "authenticate()" in new WithApplication { 38 | TestUtils.persist(Users.createUser(loginName = "alice", password = "j", name = "Alice")) 39 | 40 | Users.authenticate(loginName = "alice", password = "j") mustEqual true 41 | Users.authenticate(loginName = "wrong_username", password = "j") mustEqual false 42 | Users.authenticate(loginName = "alice", password = "wrong password") mustEqual false 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /app/jvm/src/test/scala/hydro/api/PicklableDbQueryTest.scala: -------------------------------------------------------------------------------- 1 | package hydro.api 2 | 3 | import app.common.testing._ 4 | import hydro.common.testing._ 5 | import app.models.access.ModelFields 6 | import app.models.user.User 7 | import hydro.models.access.DbQuery 8 | import hydro.models.access.DbQueryImplicits._ 9 | import org.junit.runner._ 10 | import org.specs2.runner._ 11 | 12 | import scala.collection.immutable.Seq 13 | 14 | @RunWith(classOf[JUnitRunner]) 15 | class PicklableDbQueryTest extends HookedSpecification { 16 | 17 | "regular -> picklable -> regular" in { 18 | def testFromRegularToRegular(query: DbQuery[_]) = { 19 | PicklableDbQuery.fromRegular(query).toRegular mustEqual query 20 | } 21 | 22 | "null object" in { 23 | testFromRegularToRegular( 24 | DbQuery[User](filter = DbQuery.Filter.NullFilter(), sorting = None, limit = None) 25 | ) 26 | } 27 | "limit" in { 28 | testFromRegularToRegular( 29 | DbQuery[User]( 30 | filter = DbQuery.Filter.NullFilter(), 31 | sorting = None, 32 | limit = Some(192), 33 | ) 34 | ) 35 | } 36 | "filters" in { 37 | val filters: Seq[DbQuery.Filter[User]] = Seq( 38 | (ModelFields.User.loginName === "a") && (ModelFields.User.loginName !== "b"), 39 | ModelFields.User.name containsIgnoreCase "abc", 40 | ) 41 | for (filter <- filters) yield { 42 | testFromRegularToRegular(DbQuery[User](filter = filter, sorting = None, limit = Some(192))) 43 | } 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /app/jvm/src/test/scala/hydro/common/CollectionUtilsTest.scala: -------------------------------------------------------------------------------- 1 | package hydro.common 2 | 3 | import hydro.common.testing._ 4 | import org.junit.runner._ 5 | import org.specs2.matcher.MatchResult 6 | import org.specs2.runner._ 7 | 8 | import scala.collection.immutable.Seq 9 | 10 | @RunWith(classOf[JUnitRunner]) 11 | class CollectionUtilsTest extends HookedSpecification { 12 | 13 | "getMostCommonString" in { 14 | CollectionUtils.getMostCommonString(Seq("abc")) mustEqual "abc" 15 | CollectionUtils.getMostCommonString(Seq("abc", "ABC", "abc", "def")) mustEqual "abc" 16 | CollectionUtils.getMostCommonString(Seq("abc", "ABC", "ABC", "def")) mustEqual "ABC" 17 | CollectionUtils.getMostCommonString(Seq("abc", "abc", "ABC", "ABC", "def", "def", "def")) mustEqual "def" 18 | } 19 | 20 | "getMostCommonStringIgnoringCase" in { 21 | CollectionUtils.getMostCommonStringIgnoringCase(Seq("abc")) mustEqual "abc" 22 | CollectionUtils.getMostCommonStringIgnoringCase(Seq("abc", "ABC", "abc", "def")) mustEqual "abc" 23 | CollectionUtils.getMostCommonStringIgnoringCase(Seq("abc", "ABC", "ABC", "def")) mustEqual "ABC" 24 | CollectionUtils.getMostCommonStringIgnoringCase( 25 | Seq("abc", "abc", "ABC", "ABC", "ABC", "def", "def", "def", "def") 26 | ) mustEqual "ABC" 27 | } 28 | 29 | "toBiMapWithStableIntKeys" in { 30 | def doTest[V](stableNameMapper: V => String, a: V, b: V, c: V): MatchResult[_] = { 31 | val abcMap = CollectionUtils.toBiMapWithStableIntKeys(stableNameMapper, Seq(a, b, c)) 32 | 33 | abcMap.keySet mustEqual Set(a, b, c) 34 | abcMap.inverse().keySet must haveSize(3) 35 | 36 | CollectionUtils.toBiMapWithStableIntKeys(stableNameMapper, Seq(c, a, b)) mustEqual abcMap 37 | CollectionUtils.toBiMapWithStableIntKeys(stableNameMapper, Seq(a)).get(a) mustEqual abcMap.get(a) 38 | CollectionUtils.toBiMapWithStableIntKeys(stableNameMapper, Seq(a, a, c)) must throwAn[Exception] 39 | } 40 | 41 | object A 42 | object B 43 | object C 44 | 45 | "with strings" in doTest[String](s => s, "a", "b", "C") 46 | "with objects" in doTest[Any](_.getClass.getSimpleName, A, B, C) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /app/jvm/src/test/scala/hydro/common/ScalaUtilsTest.scala: -------------------------------------------------------------------------------- 1 | package hydro.common 2 | 3 | import org.specs2.mutable._ 4 | 5 | class ScalaUtilsTest extends Specification { 6 | 7 | "objectName" in { 8 | ScalaUtils.objectName(TestObject) mustEqual "TestObject" 9 | } 10 | 11 | object TestObject 12 | } 13 | -------------------------------------------------------------------------------- /app/jvm/src/test/scala/hydro/common/TagsTest.scala: -------------------------------------------------------------------------------- 1 | package hydro.common 2 | 3 | import org.specs2.mutable._ 4 | 5 | class TagsTest extends Specification { 6 | 7 | "isValidTagName" in { 8 | Tags.isValidTag("") mustEqual false 9 | Tags.isValidTag("'") mustEqual false 10 | Tags.isValidTag("single-illegal-char-at-end?") mustEqual false 11 | Tags.isValidTag("]single-illegal-char-at-start") mustEqual false 12 | 13 | Tags.isValidTag("a") mustEqual true 14 | Tags.isValidTag("normal-string") mustEqual true 15 | Tags.isValidTag("aC29_()_-_@_!") mustEqual true 16 | Tags.isValidTag("aC29_()_-_@_!_&_$_+_=_._<>_;_:") mustEqual true 17 | } 18 | 19 | "parseTagsString" in { 20 | Tags.parseTagsString("a,b,c") mustEqual Seq("a", "b", "c") 21 | Tags.parseTagsString(",,b,c") mustEqual Seq("b", "c") 22 | Tags.parseTagsString("") mustEqual Seq() 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /app/jvm/src/test/scala/hydro/common/UpdateTokensTest.scala: -------------------------------------------------------------------------------- 1 | package hydro.common 2 | 3 | import java.time.Instant 4 | 5 | import hydro.common.UpdateTokens.toInstant 6 | import hydro.common.UpdateTokens.toUpdateToken 7 | import app.common.testing._ 8 | import hydro.common.testing._ 9 | import org.junit.runner._ 10 | import org.specs2.runner._ 11 | 12 | @RunWith(classOf[JUnitRunner]) 13 | class UpdateTokensTest extends HookedSpecification { 14 | 15 | private val time = Instant.now() 16 | 17 | "toLocalDateTime(toUpdateToken())" in { 18 | toInstant(toUpdateToken(time)) mustEqual time 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /app/jvm/src/test/scala/hydro/common/testing/FakePlayI18n.scala: -------------------------------------------------------------------------------- 1 | package hydro.common.testing 2 | 3 | import hydro.common.PlayI18n 4 | 5 | import scala.collection.mutable 6 | 7 | final class FakePlayI18n extends PlayI18n { 8 | 9 | private val mappings: mutable.Map[String, String] = mutable.Map() 10 | 11 | override def apply(key: String, args: Any*): String = 12 | mappings.getOrElse(key, key) 13 | 14 | override def allI18nMessages = mappings.toMap 15 | 16 | def setMappings(keyToValues: (String, String)*): Unit = { 17 | for ((key, value) <- keyToValues) { 18 | mappings.put(key, value) 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /app/jvm/src/test/scala/hydro/common/testing/HookedSpecification.scala: -------------------------------------------------------------------------------- 1 | package hydro.common.testing 2 | 3 | import org.specs2.mutable.Specification 4 | import org.specs2.specification.core.Fragments 5 | 6 | trait HookedSpecification extends Specification { 7 | 8 | override final def map(fragments: => Fragments): Fragments = { 9 | val fragmentsWithBeforeAndAfter = fragments flatMap (fragment => { 10 | val extendedFragments: Fragments = step(before) ^ fragment ^ step(after) 11 | extendedFragments.contents 12 | }) 13 | step(beforeAll) ^ fragmentsWithBeforeAndAfter ^ step(afterAll) 14 | } 15 | 16 | protected def before() = {} 17 | protected def after() = {} 18 | protected def beforeAll() = {} 19 | protected def afterAll() = {} 20 | } 21 | -------------------------------------------------------------------------------- /app/jvm/src/test/scala/hydro/common/time/JvmClockTest.scala: -------------------------------------------------------------------------------- 1 | package hydro.common.time 2 | 3 | import java.time.Duration 4 | import java.time.Instant 5 | import java.time.ZoneId 6 | 7 | import hydro.common.time.JavaTimeImplicits._ 8 | import org.specs2.matcher.MatchResult 9 | import org.specs2.mutable._ 10 | 11 | class JvmClockTest extends Specification { 12 | 13 | val jvmClock = new JvmClock(ZoneId.of("Europe/Paris")) 14 | 15 | "nowInstant" in { 16 | assertEqualWithDelta(jvmClock.nowInstant, Instant.now, Duration.ofMillis(10)) 17 | 18 | Thread.sleep(1000) 19 | 20 | assertEqualWithDelta(jvmClock.nowInstant, Instant.now, Duration.ofMillis(10)) 21 | } 22 | 23 | def assertEqualWithDelta(a: Instant, b: Instant, delta: Duration): MatchResult[Instant] = { 24 | a must beGreaterThan(b - delta) 25 | a must beLessThan(b + delta) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /app/jvm/src/test/scala/hydro/common/time/LocalDateTimeTest.scala: -------------------------------------------------------------------------------- 1 | package hydro.common.time 2 | 3 | import java.time.Duration 4 | import java.time.Month._ 5 | 6 | import app.common.testing.TestUtils._ 7 | import org.specs2.mutable._ 8 | 9 | class LocalDateTimeTest extends Specification { 10 | 11 | "plus #1" in { 12 | val date = localDateTimeOfEpochSecond(1030507) 13 | val duration = Duration.ofSeconds(204060) 14 | (date plus duration) mustEqual localDateTimeOfEpochSecond(1234567) 15 | } 16 | 17 | "plus #2" in { 18 | val date = LocalDateTime.of(2012, MAY, 12, 12, 30) 19 | val duration = Duration.ofDays(2) plus Duration.ofHours(12) 20 | (date plus duration) mustEqual LocalDateTime.of(2012, MAY, 15, 0, 30) 21 | } 22 | 23 | "minus #1" in { 24 | val date = localDateTimeOfEpochSecond(1030507) 25 | val duration = Duration.ofSeconds(204060) 26 | (date minus duration) mustEqual localDateTimeOfEpochSecond(826447) 27 | } 28 | 29 | "minus #2" in { 30 | val date = LocalDateTime.of(2012, MAY, 12, 10, 30) 31 | val duration = Duration.ofDays(2) plus Duration.ofHours(12) 32 | (date minus duration) mustEqual LocalDateTime.of(2012, MAY, 9, 22, 30) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /app/jvm/src/test/scala/hydro/common/time/TimeUtilsTest.scala: -------------------------------------------------------------------------------- 1 | package hydro.common.time 2 | 3 | import java.time.LocalDate 4 | import java.time.Month._ 5 | 6 | import org.specs2.mutable._ 7 | 8 | class TimeUtilsTest extends Specification { 9 | 10 | "requireStartOfMonth" in { 11 | TimeUtils.requireStartOfMonth(LocalDate.of(1991, APRIL, 1)) 12 | TimeUtils.requireStartOfMonth(LocalDate.of(1991, APRIL, 3)) must throwA[IllegalArgumentException] 13 | } 14 | 15 | "allMonths" in { 16 | TimeUtils.allMonths mustEqual Seq( 17 | JANUARY, 18 | FEBRUARY, 19 | MARCH, 20 | APRIL, 21 | MAY, 22 | JUNE, 23 | JULY, 24 | AUGUST, 25 | SEPTEMBER, 26 | OCTOBER, 27 | NOVEMBER, 28 | DECEMBER, 29 | ) 30 | } 31 | 32 | "parseDateString" in { 33 | TimeUtils.parseDateString("1992-07-22") mustEqual LocalDateTimes.createDateTime(1992, JULY, 22) 34 | TimeUtils.parseDateString("2001-7-3") mustEqual LocalDateTimes.createDateTime(2001, JULY, 3) 35 | TimeUtils.parseDateString("1992-07-33") must throwA[IllegalArgumentException] 36 | TimeUtils.parseDateString("1992-0722") must throwA[IllegalArgumentException] 37 | TimeUtils.parseDateString("1992-07-22-") must throwA[IllegalArgumentException] 38 | TimeUtils.parseDateString("1992-07-dd") must throwA[IllegalArgumentException] 39 | TimeUtils.parseDateString("19920722") must throwA[IllegalArgumentException] 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /app/shared/src/main/scala/app/AppVersion.scala: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | /** Version string that gets incremented on every deploy. */ 4 | object AppVersion { 5 | val versionString: String = "3.83" 6 | } 7 | -------------------------------------------------------------------------------- /app/shared/src/main/scala/app/api/Picklers.scala: -------------------------------------------------------------------------------- 1 | package app.api 2 | 3 | import app.models.document.DocumentEntity 4 | import app.models.document.TaskEntity 5 | import app.models.document.DocumentPermissionAndPlacement 6 | import app.models.user.User 7 | import boopickle.Default._ 8 | import hydro.api.StandardPicklers 9 | import hydro.models.Entity 10 | import hydro.models.UpdatableEntity.LastUpdateTime 11 | 12 | object Picklers extends StandardPicklers { 13 | 14 | // Pickler that does the same as an autogenerated User pickler, except that it redacts the user's password 15 | implicit object UserPickler extends Pickler[User] { 16 | override def pickle(user: User)(implicit state: PickleState): Unit = logExceptions { 17 | state.pickle(user.loginName) 18 | // Password redacted 19 | state.pickle(user.name) 20 | state.pickle(user.isAdmin) 21 | state.pickle(user.idOption) 22 | state.pickle(user.lastUpdateTime) 23 | } 24 | override def unpickle(implicit state: UnpickleState): User = logExceptions { 25 | User( 26 | loginName = state.unpickle[String], 27 | passwordHash = "", 28 | name = state.unpickle[String], 29 | isAdmin = state.unpickle[Boolean], 30 | idOption = state.unpickle[Option[Long]], 31 | lastUpdateTime = state.unpickle[LastUpdateTime], 32 | ) 33 | } 34 | } 35 | 36 | override implicit val entityPickler: Pickler[Entity] = compositePickler[Entity] 37 | .addConcreteType[User] 38 | .addConcreteType[DocumentEntity] 39 | .addConcreteType[TaskEntity] 40 | .addConcreteType[DocumentPermissionAndPlacement] 41 | } 42 | -------------------------------------------------------------------------------- /app/shared/src/main/scala/app/common/CaseFormats.scala: -------------------------------------------------------------------------------- 1 | package app.common 2 | 3 | import hydro.common.GuavaReplacement.Splitter 4 | 5 | object CaseFormats { 6 | 7 | def tokenize(s: String): Seq[String] = { 8 | // Convert to ASCII alphanumeric characters + underscores 9 | val normalized = { 10 | s.replaceAll("[^a-zA-Z0-9]+", "_") 11 | } 12 | 13 | var result = Seq(normalized) 14 | 15 | // Separate in words by separator 16 | result = result.flatMap(r => Splitter.on('_').omitEmptyStrings().split(r)) 17 | 18 | // Separate camelCase words 19 | result = result.flatMap(parseCamelCase) 20 | 21 | // Convert all strings to lowercase 22 | result = result.map(_.toLowerCase) 23 | 24 | result 25 | } 26 | 27 | def toUpperCamelCase(tokens: Seq[String]): String = { 28 | tokens.map(s => s.head.toUpper + s.tail).mkString 29 | } 30 | 31 | def toSnakeCase(tokens: Seq[String]): String = { 32 | tokens.mkString("_") 33 | } 34 | 35 | def toDashCase(tokens: Seq[String]): String = { 36 | tokens.mkString("-") 37 | } 38 | 39 | private def parseCamelCase(s: String): Seq[String] = { 40 | // If there is no case difference, return the input 41 | if (s == s.toLowerCase) Seq(s) 42 | else if (s == s.toUpperCase) Seq(s) 43 | 44 | // There is a case difference --> parse into words 45 | else { 46 | def inner(lastWord: String, remainder: List[Char]): List[String] = { 47 | remainder match { 48 | case Nil => lastWord :: Nil 49 | case head :: tail if head.isUpper => lastWord :: inner(lastWord = head.toString, remainder = tail) 50 | case head :: tail => inner(lastWord = lastWord + head, remainder = tail) 51 | } 52 | } 53 | 54 | inner(lastWord = "", remainder = s.toList).filter(_.nonEmpty) 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /app/shared/src/main/scala/app/common/document/UserDocument.scala: -------------------------------------------------------------------------------- 1 | package app.common.document 2 | 3 | import app.models.access.ModelFields 4 | import app.models.document.DocumentEntity 5 | import app.models.document.DocumentPermissionAndPlacement 6 | import app.models.user.User 7 | import hydro.common.OrderToken 8 | import hydro.models.access.DbQueryImplicits._ 9 | import hydro.models.access.EntityAccess 10 | 11 | import scala.async.Async.async 12 | import scala.async.Async.await 13 | import scala.collection.immutable.Seq 14 | import scala.concurrent.ExecutionContext.Implicits.global 15 | import scala.concurrent.Future 16 | 17 | // Note: The user is assumed to be the implicit user 18 | case class UserDocument( 19 | documentId: Long, 20 | name: String, 21 | orderToken: OrderToken, 22 | ) 23 | 24 | object UserDocument { 25 | 26 | def fetchAllForUser()(implicit user: User, entityAccess: EntityAccess): Future[Seq[UserDocument]] = async { 27 | val permissionAndPlacements = await( 28 | entityAccess 29 | .newQuery[DocumentPermissionAndPlacement]() 30 | .filter(ModelFields.DocumentPermissionAndPlacement.userId === user.id) 31 | .data() 32 | ) 33 | 34 | val documentEntities = 35 | await( 36 | entityAccess 37 | .newQuery[DocumentEntity]() 38 | .filter(ModelFields.DocumentEntity.id isAnyOf (permissionAndPlacements.map(_.documentId))) 39 | .data() 40 | ) 41 | val documentEntityMap = uniqueIndex(documentEntities)(_.id) 42 | 43 | val unsortedResult = 44 | for (permissionAndPlacement <- permissionAndPlacements) yield { 45 | val documentEntity = documentEntityMap(permissionAndPlacement.documentId) 46 | UserDocument( 47 | documentId = documentEntity.id, 48 | name = documentEntity.name, 49 | orderToken = permissionAndPlacement.orderToken, 50 | ) 51 | } 52 | 53 | unsortedResult.sortBy(_.orderToken) 54 | } 55 | 56 | private def uniqueIndex[K, V](iterable: Iterable[V])(keyMapper: V => K): Map[K, V] = { 57 | iterable.map(v => (keyMapper(v) -> v)).toMap 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /app/shared/src/main/scala/app/models/document/DocumentEntity.scala: -------------------------------------------------------------------------------- 1 | package app.models.document 2 | 3 | import hydro.common.OrderToken 4 | import hydro.models.modification.EntityType 5 | import hydro.models.Entity 6 | import hydro.models.UpdatableEntity 7 | import hydro.models.UpdatableEntity.LastUpdateTime 8 | 9 | case class DocumentEntity( 10 | name: String, 11 | override val idOption: Option[Long] = None, 12 | override val lastUpdateTime: LastUpdateTime = LastUpdateTime.neverUpdated, 13 | ) extends UpdatableEntity { 14 | 15 | override def withId(id: Long) = copy(idOption = Some(id)) 16 | override def withLastUpdateTime(time: LastUpdateTime): Entity = copy(lastUpdateTime = time) 17 | } 18 | object DocumentEntity { 19 | implicit val Type: EntityType[DocumentEntity] = EntityType() 20 | 21 | def tupled = (this.apply _).tupled 22 | } 23 | -------------------------------------------------------------------------------- /app/shared/src/main/scala/app/models/document/DocumentPermissionAndPlacement.scala: -------------------------------------------------------------------------------- 1 | package app.models.document 2 | 3 | import hydro.common.OrderToken 4 | import hydro.models.Entity 5 | import hydro.models.UpdatableEntity 6 | import hydro.models.UpdatableEntity.LastUpdateTime 7 | import hydro.models.modification.EntityType 8 | 9 | /** 10 | * Combines a user and a document. The existence of this combination implies a permission. The extra data 11 | * indicates the placement of the document for that user. 12 | */ 13 | case class DocumentPermissionAndPlacement( 14 | documentId: Long, 15 | userId: Long, 16 | orderToken: OrderToken, 17 | override val idOption: Option[Long] = None, 18 | override val lastUpdateTime: LastUpdateTime = LastUpdateTime.neverUpdated, 19 | ) extends UpdatableEntity { 20 | 21 | override def withId(id: Long) = copy(idOption = Some(id)) 22 | override def withLastUpdateTime(time: LastUpdateTime): Entity = copy(lastUpdateTime = time) 23 | } 24 | object DocumentPermissionAndPlacement { 25 | implicit val Type: EntityType[DocumentPermissionAndPlacement] = EntityType() 26 | 27 | def tupled = (this.apply _).tupled 28 | } 29 | -------------------------------------------------------------------------------- /app/shared/src/main/scala/app/models/document/TaskEntity.scala: -------------------------------------------------------------------------------- 1 | package app.models.document 2 | 3 | import hydro.common.OrderToken 4 | import hydro.models.modification.EntityType 5 | import hydro.common.time.LocalDateTime 6 | import hydro.models.Entity 7 | import hydro.models.UpdatableEntity 8 | import hydro.models.UpdatableEntity.LastUpdateTime 9 | 10 | import java.time.Instant 11 | import scala.collection.immutable.Seq 12 | 13 | case class TaskEntity( 14 | documentId: Long, 15 | contentHtml: String, 16 | orderToken: OrderToken, 17 | indentation: Int, 18 | collapsed: Boolean, 19 | checked: Boolean, 20 | delayedUntil: Option[LocalDateTime], 21 | tags: Seq[String], 22 | lastContentModifierUserId: Long, 23 | override val idOption: Option[Long] = None, 24 | override val lastUpdateTime: LastUpdateTime = LastUpdateTime.neverUpdated, 25 | ) extends UpdatableEntity 26 | with Ordered[TaskEntity] { 27 | 28 | override def withId(id: Long) = copy(idOption = Some(id)) 29 | override def withLastUpdateTime(time: LastUpdateTime): Entity = copy(lastUpdateTime = time) 30 | 31 | // **************** Ordered methods **************** // 32 | override def compare(that: TaskEntity): Int = { 33 | val result = this.orderToken compare that.orderToken 34 | if (result == 0) 35 | this.lastUpdateTime 36 | .mostRecentInstant() 37 | .getOrElse(Instant.EPOCH) compareTo that.lastUpdateTime.mostRecentInstant().getOrElse(Instant.EPOCH) 38 | else result 39 | } 40 | } 41 | object TaskEntity { 42 | implicit val Type: EntityType[TaskEntity] = EntityType() 43 | 44 | def tupled = (this.apply _).tupled 45 | } 46 | -------------------------------------------------------------------------------- /app/shared/src/main/scala/app/models/modification/EntityTypes.scala: -------------------------------------------------------------------------------- 1 | package app.models.modification 2 | 3 | import app.models.document.DocumentEntity 4 | import app.models.document.DocumentPermissionAndPlacement 5 | import app.models.document.TaskEntity 6 | import app.models.user.User 7 | import hydro.models.modification.EntityType 8 | 9 | import scala.collection.immutable.Seq 10 | 11 | object EntityTypes { 12 | 13 | lazy val all: Seq[EntityType.any] = Seq( 14 | User.Type, 15 | DocumentEntity.Type, 16 | DocumentPermissionAndPlacement.Type, 17 | TaskEntity.Type, 18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /app/shared/src/main/scala/app/models/user/User.scala: -------------------------------------------------------------------------------- 1 | package app.models.user 2 | 3 | import hydro.models.modification.EntityType 4 | import hydro.models.Entity 5 | import hydro.models.UpdatableEntity 6 | import hydro.models.UpdatableEntity.LastUpdateTime 7 | 8 | case class User( 9 | loginName: String, 10 | passwordHash: String, 11 | name: String, 12 | isAdmin: Boolean, 13 | override val idOption: Option[Long] = None, 14 | override val lastUpdateTime: LastUpdateTime = LastUpdateTime.neverUpdated, 15 | ) extends UpdatableEntity { 16 | 17 | override def withId(id: Long) = copy(idOption = Some(id)) 18 | override def withLastUpdateTime(time: LastUpdateTime): Entity = copy(lastUpdateTime = time) 19 | } 20 | 21 | object User { 22 | implicit val Type: EntityType[User] = EntityType() 23 | 24 | def tupled = (this.apply _).tupled 25 | } 26 | -------------------------------------------------------------------------------- /app/shared/src/main/scala/hydro/api/PicklableModelField.scala: -------------------------------------------------------------------------------- 1 | package hydro.api 2 | 3 | import app.models.access.ModelFields 4 | import hydro.models.access.ModelField 5 | 6 | /** Fork of ModelField that is picklable. */ 7 | case class PicklableModelField(fieldNumber: Int) { 8 | def toRegular: ModelField.any = ModelFields.fromNumber(fieldNumber) 9 | } 10 | 11 | object PicklableModelField { 12 | def fromRegular(regular: ModelField.any): PicklableModelField = 13 | PicklableModelField(ModelFields.toNumber(regular)) 14 | } 15 | -------------------------------------------------------------------------------- /app/shared/src/main/scala/hydro/api/ScalaJsApiRequest.scala: -------------------------------------------------------------------------------- 1 | package hydro.api 2 | 3 | import java.nio.ByteBuffer 4 | 5 | /** 6 | * Picklable combination of method name (= path) and method arguments, representing a single call 7 | * to a ScalaJS API method. 8 | */ 9 | case class ScalaJsApiRequest(path: String, args: Map[String, ByteBuffer]) 10 | -------------------------------------------------------------------------------- /app/shared/src/main/scala/hydro/common/Annotations.scala: -------------------------------------------------------------------------------- 1 | package hydro.common 2 | 3 | import scala.annotation.StaticAnnotation 4 | 5 | object Annotations { 6 | 7 | /** Scala version of com.google.common.annotations.VisibleForTesting. */ 8 | class visibleForTesting extends StaticAnnotation 9 | 10 | /** Scala version of javax.annotations.Nullable. */ 11 | class nullable extends StaticAnnotation 12 | 13 | /** Scala version of GuardedBy. */ 14 | class guardedBy(objectName: String) extends StaticAnnotation 15 | } 16 | -------------------------------------------------------------------------------- /app/shared/src/main/scala/hydro/common/CollectionUtils.scala: -------------------------------------------------------------------------------- 1 | package hydro.common 2 | 3 | import hydro.common.GuavaReplacement.ImmutableBiMap 4 | 5 | import scala.collection.immutable.ListMap 6 | import scala.collection.immutable.Seq 7 | 8 | object CollectionUtils { 9 | 10 | /** Converts list of pairs to ListMap. * */ 11 | def toListMap[A, B](entries: Iterable[(A, B)]): ListMap[A, B] = ListMap(entries.toSeq: _*) 12 | 13 | def asMap[K, V](keys: Iterable[K], valueFunc: K => V): Map[K, V] = keys.map(k => k -> valueFunc(k)).toMap 14 | 15 | def getMostCommonStringIgnoringCase(strings: Iterable[String]): String = { 16 | require(strings.nonEmpty) 17 | val mostCommonLowerCaseString = getMostCommonString(strings.map(_.toLowerCase)) 18 | getMostCommonString(strings.filter(_.toLowerCase == mostCommonLowerCaseString)) 19 | } 20 | 21 | def getMostCommonString(strings: Iterable[String]): String = { 22 | strings.groupBy(identity).mapValues(_.size).toSeq.minBy(-_._2)._1 23 | } 24 | 25 | def ifThenSeq[V](condition: Boolean, value: V): Seq[V] = if (condition) Seq(value) else Seq() 26 | 27 | def maybeGet[E](seq: Seq[E], index: Int): Option[E] = { 28 | if (seq.indices contains index) { 29 | Some(seq(index)) 30 | } else { 31 | None 32 | } 33 | } 34 | 35 | /** 36 | * Converts the given values to a bimap that associates an integer with each value. 37 | * 38 | * These associated integers remain stable as long as the `stableNameMapper` returns the same value, 39 | * even if the order of values is changed, or values are added/removed. 40 | */ 41 | def toBiMapWithStableIntKeys[V]( 42 | stableNameMapper: V => String, 43 | values: Iterable[V], 44 | ): ImmutableBiMap[V, Int] = { 45 | val valuesSeq = values.toVector 46 | val names = valuesSeq.map(stableNameMapper) 47 | val hashCodes = names.map(_.hashCode) 48 | 49 | require(names.distinct.size == names.size, s"There are names that are not unique: $names") 50 | require( 51 | hashCodes.distinct.size == hashCodes.size, 52 | s"There are hash codes that are not unique: $hashCodes. " + 53 | "This is bad luck and can be solved by adding a salt (missing feature at the moment).", 54 | ) 55 | 56 | val resultBuilder = ImmutableBiMap.builder[V, Int]() 57 | for ((value, hash) <- valuesSeq zip hashCodes) { 58 | resultBuilder.put(value, hash) 59 | } 60 | resultBuilder.build() 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /app/shared/src/main/scala/hydro/common/GlobalStopwatch.scala: -------------------------------------------------------------------------------- 1 | package hydro.common 2 | 3 | import java.time.Instant 4 | 5 | import hydro.common.time.JavaTimeImplicits._ 6 | 7 | object GlobalStopwatch { 8 | 9 | private var startTime: Instant = Instant.now() 10 | private var lastLogTime: Instant = Instant.now() 11 | 12 | def startAndLog(stepName: => String): Unit = { 13 | println(s" {GlobalStopwatch} Starting timer ($stepName)") 14 | startTime = Instant.now() 15 | lastLogTime = Instant.now() 16 | } 17 | 18 | def logTimeSinceStart(stepName: => String): Unit = { 19 | val now = Instant.now() 20 | val lastDiff = now - lastLogTime 21 | val startDiff = now - startTime 22 | println( 23 | s" {GlobalStopwatch} Elapsed: Since last time: ${lastDiff.toMillis}ms, Since start: ${startDiff.toMillis}ms ($stepName) " 24 | ) 25 | 26 | lastLogTime = Instant.now() 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /app/shared/src/main/scala/hydro/common/I18n.scala: -------------------------------------------------------------------------------- 1 | package hydro.common 2 | 3 | trait I18n { 4 | 5 | def apply(key: String, args: Any*): String 6 | } 7 | -------------------------------------------------------------------------------- /app/shared/src/main/scala/hydro/common/LoggingUtils.scala: -------------------------------------------------------------------------------- 1 | package hydro.common 2 | 3 | object LoggingUtils { 4 | 5 | def logExceptions[T](codeBlock: => T): T = { 6 | try { 7 | codeBlock 8 | } catch { 9 | case t: Throwable => 10 | println(s" Caught exception: $t") 11 | t.printStackTrace() 12 | throw t 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /app/shared/src/main/scala/hydro/common/Require.scala: -------------------------------------------------------------------------------- 1 | package hydro.common 2 | 3 | object Require { 4 | 5 | def requireNonNull(objects: Any*): Unit = { 6 | for ((obj, index) <- objects.zipWithIndex) { 7 | require(obj != null, s"Reference at index $index is null") 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /app/shared/src/main/scala/hydro/common/ScalaUtils.scala: -------------------------------------------------------------------------------- 1 | package hydro.common 2 | 3 | import scala.concurrent._ 4 | 5 | object ScalaUtils { 6 | 7 | /** Returns the name of the object as defined by "object X {}" */ 8 | def objectName(obj: AnyRef): String = { 9 | obj.getClass.getSimpleName.replace("$", "") 10 | } 11 | 12 | def callbackSettingFuturePair(): (() => Unit, Future[Unit]) = { 13 | val promise = Promise[Unit]() 14 | val callback: () => Unit = () => promise.success() 15 | (callback, promise.future) 16 | } 17 | 18 | def toPromise[T](future: Future[T]): Promise[T] = Promise[T]().completeWith(future) 19 | 20 | def ifThenOption[T](condition: Boolean)(value: => T): Option[T] = { 21 | if (condition) { 22 | Some(value) 23 | } else { 24 | None 25 | } 26 | } 27 | 28 | def stripRequiredPrefix(s: String, prefix: String): String = { 29 | require(s.startsWith(prefix), s"string doesn't start with prefix: prefix = $prefix, string = $s") 30 | s.stripPrefix(prefix) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /app/shared/src/main/scala/hydro/common/SerializingTaskQueue.scala: -------------------------------------------------------------------------------- 1 | package hydro.common 2 | 3 | import java.util.concurrent.atomic.AtomicBoolean 4 | import java.util.concurrent.atomic.AtomicLong 5 | 6 | import hydro.common 7 | import hydro.common.Annotations.guardedBy 8 | 9 | import scala.concurrent.ExecutionContext.Implicits.global 10 | import scala.concurrent.Future 11 | 12 | trait SerializingTaskQueue { 13 | def schedule[T](task: => Future[T]): Future[T] 14 | } 15 | object SerializingTaskQueue { 16 | def create(): SerializingTaskQueue = new WithInfiniteQueue() 17 | def withAtMostSingleQueuedTask(): SerializingTaskQueue = new WithAtMostSingleQueuedTask() 18 | 19 | private class WithInfiniteQueue extends SerializingTaskQueue { 20 | @guardedBy("this") 21 | private var mostRecentlyAddedTaskFuture: Future[_] = Future.successful(null) 22 | 23 | override def schedule[T](task: => Future[T]): Future[T] = { 24 | this.synchronized { 25 | val result: Future[T] = mostRecentlyAddedTaskFuture.flatMap(_ => task) 26 | mostRecentlyAddedTaskFuture = result.recover { case throwable: Throwable => 27 | println(s" Caught exception in SerializingTaskQueue: $throwable") 28 | throwable.printStackTrace() 29 | null 30 | } 31 | result 32 | } 33 | } 34 | } 35 | 36 | private class WithAtMostSingleQueuedTask extends SerializingTaskQueue { 37 | private val delegate: SerializingTaskQueue = new WithInfiniteQueue() 38 | @guardedBy("this") 39 | private var queueContainsTask: Boolean = false 40 | 41 | override def schedule[T](task: => Future[T]): Future[T] = { 42 | this.synchronized { 43 | if (queueContainsTask) { 44 | Future.failed(new RuntimeException("Task was not scheduled")) 45 | } else { 46 | queueContainsTask = true 47 | delegate.schedule { 48 | this.synchronized { 49 | queueContainsTask = false 50 | } 51 | task 52 | } 53 | } 54 | } 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /app/shared/src/main/scala/hydro/common/Tags.scala: -------------------------------------------------------------------------------- 1 | package hydro.common 2 | 3 | import hydro.common.GuavaReplacement.Splitter 4 | 5 | import scala.collection.immutable.Seq 6 | import scala.math.abs 7 | import scala.util.matching.Regex 8 | 9 | object Tags { 10 | private val validTagRegex: Regex = """[a-zA-Z0-9-_@$&()+=!.<>#;: ]+""".r 11 | 12 | def isValidTag(tag: String): Boolean = tag match { 13 | case validTagRegex() => true 14 | case _ => false 15 | } 16 | 17 | /** Parse a comma-separated list of tags that are assumed to be validated already. */ 18 | def parseTagsString(tagsString: String): Seq[String] = { 19 | Splitter.on(',').omitEmptyStrings().trimResults().split(tagsString) 20 | } 21 | 22 | def serializeToString(tags: Iterable[String]): String = tags.mkString(",") 23 | } 24 | -------------------------------------------------------------------------------- /app/shared/src/main/scala/hydro/common/testing/FakeClock.scala: -------------------------------------------------------------------------------- 1 | package hydro.common.testing 2 | 3 | import java.time.Instant 4 | import java.time.Month.JANUARY 5 | 6 | import hydro.common.time.Clock 7 | import hydro.common.time.LocalDateTime 8 | 9 | final class FakeClock extends Clock { 10 | 11 | @volatile private var currentLocalDateTime: LocalDateTime = FakeClock.defaultLocalDateTime 12 | @volatile private var currentInstant: Instant = FakeClock.defaultInstant 13 | 14 | override def now = currentLocalDateTime 15 | override def nowInstant = currentInstant 16 | 17 | def setNow(localDateTime: LocalDateTime): Unit = { 18 | currentLocalDateTime = localDateTime 19 | } 20 | 21 | def setNowInstant(instant: Instant): Unit = { 22 | currentInstant = instant 23 | } 24 | } 25 | 26 | object FakeClock { 27 | val defaultLocalDateTime: LocalDateTime = LocalDateTime.of(2000, JANUARY, 1, 0, 0) 28 | val defaultInstant: Instant = Instant.ofEpochMilli(9812093809912L) 29 | } 30 | -------------------------------------------------------------------------------- /app/shared/src/main/scala/hydro/common/testing/FakeI18n.scala: -------------------------------------------------------------------------------- 1 | package hydro.common.testing 2 | 3 | import hydro.common.I18n 4 | 5 | import scala.collection.mutable 6 | 7 | final class FakeI18n extends I18n { 8 | 9 | private val mappings: mutable.Map[String, String] = mutable.Map() 10 | 11 | override def apply(key: String, args: Any*): String = 12 | mappings.getOrElse(key, key) 13 | 14 | def setMappings(keyToValues: (String, String)*): Unit = { 15 | for ((key, value) <- keyToValues) { 16 | mappings.put(key, value) 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /app/shared/src/main/scala/hydro/common/time/Clock.scala: -------------------------------------------------------------------------------- 1 | package hydro.common.time 2 | 3 | import java.time.Instant 4 | 5 | trait Clock { 6 | 7 | def now: LocalDateTime 8 | def nowInstant: Instant 9 | } 10 | -------------------------------------------------------------------------------- /app/shared/src/main/scala/hydro/common/time/JavaTimeImplicits.scala: -------------------------------------------------------------------------------- 1 | package hydro.common.time 2 | 3 | import java.time.Duration 4 | import java.time.Instant 5 | import java.time.LocalDate 6 | 7 | object JavaTimeImplicits { 8 | 9 | abstract class BaseWrapper[T: Ordering](thisComparable: T)(implicit ordering: Ordering[T]) { 10 | def <=(other: T): Boolean = ordering.compare(thisComparable, other) <= 0 11 | def <(other: T): Boolean = ordering.compare(thisComparable, other) < 0 12 | def >=(other: T): Boolean = ordering.compare(thisComparable, other) >= 0 13 | def >(other: T): Boolean = ordering.compare(thisComparable, other) > 0 14 | } 15 | 16 | implicit object InstantOrdering extends Ordering[Instant] { 17 | override def compare(x: Instant, y: Instant): Int = x compareTo y 18 | } 19 | implicit class InstantWrapper(thisInstant: Instant) extends BaseWrapper[Instant](thisInstant) { 20 | def -(duration: Duration): Instant = thisInstant minus duration 21 | def +(duration: Duration): Instant = thisInstant plus duration 22 | def -(instant: Instant): Duration = Duration.between(instant, thisInstant) 23 | } 24 | 25 | implicit object LocalDateTimeOrdering extends Ordering[LocalDateTime] { 26 | override def compare(x: LocalDateTime, y: LocalDateTime): Int = x compareTo y 27 | } 28 | implicit class LocalDateTimeWrapper(thisDate: LocalDateTime) extends BaseWrapper[LocalDateTime](thisDate) { 29 | def -(other: LocalDateTime): Duration = { 30 | // Heuristic because scala.js doesn't support Duration.between(LocalDate, LocalDate) 31 | val localDateDayDiff = thisDate.toLocalDate.toEpochDay - other.toLocalDate.toEpochDay 32 | val localTimeNanoDiff = thisDate.toLocalTime.toNanoOfDay - other.toLocalTime.toNanoOfDay 33 | Duration.ofDays(localDateDayDiff) plus Duration.ofNanos(localTimeNanoDiff) 34 | } 35 | } 36 | 37 | implicit object LocalDateOrdering extends Ordering[LocalDate] { 38 | override def compare(x: LocalDate, y: LocalDate): Int = x compareTo y 39 | } 40 | implicit class LocalDateWrapper(thisDate: LocalDate) extends BaseWrapper[LocalDate](thisDate) 41 | 42 | implicit object DurationOrdering extends Ordering[Duration] { 43 | override def compare(x: Duration, y: Duration): Int = x compareTo y 44 | } 45 | implicit class DurationWrapper(thisDuration: Duration) extends BaseWrapper[Duration](thisDuration) 46 | } 47 | -------------------------------------------------------------------------------- /app/shared/src/main/scala/hydro/common/time/LocalDateTimes.scala: -------------------------------------------------------------------------------- 1 | package hydro.common.time 2 | 3 | import java.time.LocalTime 4 | import java.time.Month 5 | 6 | /** 7 | * Extension of `LocalDateTime`, which should keep the same API as `java.time.LocalDateTime`. 8 | */ 9 | object LocalDateTimes { 10 | 11 | def ofJavaLocalDateTime(javaDateTime: java.time.LocalDateTime): LocalDateTime = { 12 | LocalDateTime.of(javaDateTime.toLocalDate, javaDateTime.toLocalTime) 13 | } 14 | 15 | def createDateTime(year: Int, month: Month, dayOfMonth: Int): LocalDateTime = { 16 | LocalDateTime.of(year, month, dayOfMonth, 0, 0) 17 | } 18 | 19 | def toStartOfDay(localDateTime: LocalDateTime): LocalDateTime = { 20 | LocalDateTime.of(localDateTime.toLocalDate, LocalTime.MIN) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /app/shared/src/main/scala/hydro/common/time/TimeUtils.scala: -------------------------------------------------------------------------------- 1 | package hydro.common.time 2 | 3 | import java.time.DateTimeException 4 | import java.time.LocalDate 5 | import java.time.LocalTime 6 | import java.time.Month 7 | 8 | import scala.collection.immutable.Seq 9 | 10 | object TimeUtils { 11 | 12 | def requireStartOfMonth(date: LocalDate): Unit = { 13 | require(date.getDayOfMonth == 1, s"Date $date should be at the first day of the month.") 14 | } 15 | 16 | def allMonths: Seq[Month] = Month.values().toList 17 | 18 | /** 19 | * Parses the incoming date string to a LocalDateTime. 20 | * 21 | * @param dateString in the form of yyyy-mm-dd, e.g. "2016-03-13". Leading zeros may be omitted. 22 | * @throws IllegalArgumentException if the given string could not be parsed 23 | */ 24 | def parseDateString(dateString: String): LocalDateTime = { 25 | require(!dateString.startsWith("-"), dateString) 26 | require(!dateString.endsWith("-"), dateString) 27 | val parts = dateString.split("-").toList 28 | require(dateString.split("-").size == 3, parts) 29 | 30 | val yyyy :: mm :: dd :: Nil = parts 31 | try { 32 | LocalDateTime.of( 33 | LocalDate.of(yyyy.toInt, mm.toInt, dd.toInt), 34 | LocalTime.MIN, 35 | ) 36 | } catch { 37 | case e: NumberFormatException => throw new IllegalArgumentException(e) 38 | case e: DateTimeException => throw new IllegalArgumentException(e) 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /app/shared/src/main/scala/hydro/models/Entity.scala: -------------------------------------------------------------------------------- 1 | package hydro.models 2 | 3 | // Based on active-slick (https://github.com/strongtyped/active-slick) 4 | 5 | /** Base trait to define a model having an ID (i.e.: Entity). */ 6 | trait Entity { 7 | 8 | /** Returns the Entity ID */ 9 | final def id: Long = idOption.getOrElse(throw new IllegalStateException(s"This entity has no ID: $this")) 10 | 11 | /** 12 | * The Entity ID wrapped in an Option. 13 | * Expected to be None when Entity not yet persisted, otherwise Some[Id]. 14 | */ 15 | def idOption: Option[Long] 16 | 17 | /** Returns a copy of this Entity with an ID. */ 18 | def withId(id: Long): Entity 19 | } 20 | 21 | object Entity { 22 | def asEntity(entity: Entity): Entity = entity 23 | 24 | def withId[E <: Entity](id: Long, entity: E): E = entity.withId(id).asInstanceOf[E] 25 | } 26 | -------------------------------------------------------------------------------- /app/shared/src/main/scala/hydro/models/access/DbQueryExecutor.scala: -------------------------------------------------------------------------------- 1 | package hydro.models.access 2 | 3 | import hydro.models.modification.EntityType 4 | import hydro.models.Entity 5 | 6 | import scala.collection.immutable.Seq 7 | import scala.concurrent.Future 8 | 9 | /** Traits for classes that can take a `DbQuery` and return their result. */ 10 | object DbQueryExecutor { 11 | 12 | trait Sync[E <: Entity] { 13 | def data(dbQuery: DbQuery[E]): Seq[E] 14 | def count(dbQuery: DbQuery[E]): Int 15 | 16 | def asAsync: Async[E] = { 17 | val delegate = this 18 | new Async[E] { 19 | override def data(dbQuery: DbQuery[E]) = Future.successful(delegate.data(dbQuery)) 20 | override def count(dbQuery: DbQuery[E]) = Future.successful(delegate.count(dbQuery)) 21 | } 22 | } 23 | } 24 | trait Async[E <: Entity] { 25 | def data(dbQuery: DbQuery[E]): Future[Seq[E]] 26 | def count(dbQuery: DbQuery[E]): Future[Int] 27 | } 28 | 29 | def fromEntities[E <: Entity: EntityType](entities: Iterable[E]): Sync[E] = new Sync[E] { 30 | override def data(dbQuery: DbQuery[E]) = stream(dbQuery).toVector 31 | override def count(dbQuery: DbQuery[E]) = stream(dbQuery).size 32 | 33 | private def stream(dbQuery: DbQuery[E]): Stream[E] = { 34 | var stream = entities.toStream.filter(dbQuery.filter.apply) 35 | for (sorting <- dbQuery.sorting) { 36 | stream = stream.sorted(sorting.toOrdering) 37 | } 38 | for (limit <- dbQuery.limit) { 39 | stream = stream.take(limit) 40 | } 41 | stream 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /app/shared/src/main/scala/hydro/models/access/DbQueryImplicits.scala: -------------------------------------------------------------------------------- 1 | package hydro.models.access 2 | 3 | import hydro.models.access.DbQuery.Filter 4 | import hydro.models.access.DbQuery.PicklableOrdering 5 | 6 | import scala.collection.immutable.Seq 7 | 8 | object DbQueryImplicits { 9 | implicit class KeyWrapper[V, E](field: ModelField[V, E]) { 10 | def ===(value: V): Filter[E] = Filter.Equal(field, value) 11 | def !==(value: V): Filter[E] = Filter.NotEqual(field, value) 12 | def isAnyOf(values: Seq[V]): Filter[E] = Filter.AnyOf(field, values) 13 | def isNoneOf(values: Seq[V]): Filter[E] = Filter.NoneOf(field, values) 14 | } 15 | implicit class OrderedKeyWrapper[V: PicklableOrdering, E](field: ModelField[V, E]) { 16 | def <(value: V): Filter[E] = Filter.LessThan(field, value) 17 | def >(value: V): Filter[E] = Filter.GreaterThan(field, value) 18 | def <=(value: V): Filter[E] = Filter.LessOrEqualThan(field, value) 19 | def >=(value: V): Filter[E] = Filter.GreaterOrEqualThan(field, value) 20 | } 21 | 22 | implicit class StringKeyWrapper[E](field: ModelField[String, E]) { 23 | def containsIgnoreCase(substring: String): Filter[E] = Filter.ContainsIgnoreCase(field, substring) 24 | def doesntContainIgnoreCase(substring: String): Filter[E] = 25 | Filter.DoesntContainIgnoreCase(field, substring) 26 | } 27 | 28 | implicit class SeqKeyWrapper[E](field: ModelField[Seq[String], E]) { 29 | def contains(value: String): Filter[E] = Filter.SeqContains(field, value) 30 | def doesntContain(value: String): Filter[E] = Filter.SeqDoesntContain(field, value) 31 | } 32 | 33 | implicit class FilterWrapper[E](thisFilter: Filter[E]) { 34 | def ||(otherFilter: Filter[E]): Filter[E] = Filter.Or(Seq(thisFilter, otherFilter)) 35 | def &&(otherFilter: Filter[E]): Filter[E] = Filter.And(Seq(thisFilter, otherFilter)) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /app/shared/src/main/scala/hydro/models/access/EntityAccess.scala: -------------------------------------------------------------------------------- 1 | package hydro.models.access 2 | 3 | import hydro.models.modification.EntityType 4 | import hydro.models.Entity 5 | 6 | /** Central point of access to the storage layer. */ 7 | trait EntityAccess { 8 | 9 | // **************** Getters ****************// 10 | def newQuery[E <: Entity: EntityType](): DbResultSet.Async[E] 11 | } 12 | -------------------------------------------------------------------------------- /app/shared/src/main/scala/hydro/models/modification/EntityType.scala: -------------------------------------------------------------------------------- 1 | package hydro.models.modification 2 | 3 | import hydro.models.Entity 4 | 5 | import scala.reflect.ClassTag 6 | 7 | /** Enumeration of all entity types that are transfered between server and client. */ 8 | final class EntityType[E <: Entity](val entityClass: Class[E]) { 9 | type get = E 10 | 11 | def checkRightType(entity: Entity): get = { 12 | require( 13 | entity.getClass == entityClass, 14 | s"Got entity of type ${entity.getClass}, but this entityType requires $entityClass", 15 | ) 16 | entity.asInstanceOf[E] 17 | } 18 | 19 | lazy val name: String = entityClass.getSimpleName + "Type" 20 | override def toString = name 21 | } 22 | object EntityType { 23 | type any = EntityType[_ <: Entity] 24 | 25 | def apply[E <: Entity]()(implicit classTag: ClassTag[E]): EntityType[E] = 26 | new EntityType[E](classTag.runtimeClass.asInstanceOf[Class[E]]) 27 | } 28 | -------------------------------------------------------------------------------- /conf/application.conf: -------------------------------------------------------------------------------- 1 | # Secret key 2 | # ~~~~~ 3 | play.http.secret.key = "changeme" 4 | 5 | # The application languages 6 | # ~~~~~ 7 | play.i18n.langs = ["en"] 8 | akka.http.parsing.max-uri-length = 16k 9 | 10 | # Database configuration 11 | # ~~~~~ 12 | # In memory database: 13 | #db.default.slick.profile = "slick.jdbc.H2Profile$" 14 | #db.default.driver = org.h2.Driver 15 | #db.default.url = "jdbc:h2:mem:test1" 16 | #db.default.connectionPool = disabled 17 | #db.default.keepAliveConnection = true 18 | #db.default.logStatements = true 19 | 20 | # MySQL database: 21 | db.default.driver=com.mysql.jdbc.Driver 22 | db.default.url="jdbc:mysql://localhost/piga?user=root&password=pw" 23 | db.default.slick.profile = "slick.jdbc.MySQLProfile$" 24 | 25 | # Application-specific configuration 26 | # ~~~~~ 27 | # Settings used during development only 28 | # app.development.dropAndCreateNewDb = true 29 | # app.development.loadDummyUsers = true 30 | # app.development.loadDummyData = true 31 | -------------------------------------------------------------------------------- /docker-compose-for-tests.yaml: -------------------------------------------------------------------------------- 1 | # To run all tests and repeat on each source change: 2 | # docker-compose --file=docker-compose-for-tests.yaml up 3 | 4 | version: "3.7" 5 | 6 | services: 7 | app: 8 | build: 9 | context: . 10 | dockerfile: Dockerfile.test 11 | tty: true 12 | volumes: 13 | # Run the test on the current directory 14 | - .:/src 15 | # Exclude all target folders from the previous volume binding by explicitly binding them to an 16 | # empty volume 17 | - /src/app/shared/.jvm/target/ 18 | - /src/app/shared/.js/target/ 19 | - /src/app/js/shared/target/ 20 | - /src/app/js/manualtests/target/ 21 | - /src/app/js/webworker/target/ 22 | - /src/app/js/client/target/ 23 | - /src/app/jvm/target/ 24 | - /src/project/target/ 25 | - /src/project/project/target/ 26 | command: 'sbt -mem 2048 "~testQuick"' 27 | 28 | # Fixes Docker network issue: https://stackoverflow.com/a/62333327 29 | networks: {default: {ipam: {config: [{subnet: 172.17.0.0/16}]}}} 30 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.3" 2 | 3 | services: 4 | db: 5 | image: mariadb:10.3 6 | volumes: ["db_data:/var/lib/mysql"] 7 | restart: always 8 | environment: 9 | MYSQL_ROOT_PASSWORD: piga 10 | MYSQL_DATABASE: piga 11 | 12 | web: 13 | depends_on: [db] 14 | command: bin/server 15 | image: nymanjens/piga:latest 16 | ports: ["9000:9000"] 17 | restart: always 18 | environment: 19 | APPLICATION_SECRET: ${APPLICATION_SECRET} 20 | DATABASE_URL: jdbc:mysql://db:3306/piga?user=root&password=piga 21 | 22 | volumes: 23 | db_data: {} 24 | -------------------------------------------------------------------------------- /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=1.9.7 2 | -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | // // repository for Typesafe plugins 2 | resolvers += "Typesafe Releases" at "https://repo.typesafe.com/typesafe/releases/" 3 | 4 | // Tell the compiler to ignore version mismatches in scala-xml 5 | ThisBuild / libraryDependencySchemes += "org.scala-lang.modules" %% "scala-xml" % VersionScheme.Always 6 | 7 | // The Play plugin 8 | addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.8.20") // Must be the same as BuildSettings.versions.play 9 | // addSbtPlugin("com.vmunier" % "sbt-play-scalajs" % "0.3.1") 10 | 11 | // scala.js plugins 12 | addSbtPlugin("org.scala-js" % "sbt-scalajs" % "0.6.22") 13 | 14 | // Web plugins 15 | addSbtPlugin("com.typesafe.sbt" % "sbt-less" % "1.1.2") 16 | addSbtPlugin("com.typesafe.sbt" % "sbt-rjs" % "1.0.10") 17 | addSbtPlugin("com.typesafe.sbt" % "sbt-digest" % "1.1.4") 18 | addSbtPlugin("com.typesafe.sbt" % "sbt-mocha" % "1.1.2") 19 | addSbtPlugin("ch.epfl.scala" % "sbt-web-scalajs-bundler" % "0.13.0") 20 | 21 | // Other 22 | addSbtPlugin("com.typesafe.sbt" % "sbt-gzip" % "1.0.2") 23 | addSbtPlugin("com.typesafe.sbt" % "sbt-native-packager" % "1.7.6") 24 | // addSbtPlugin("org.scalastyle" %% "scalastyle-sbt-plugin" % "1.0.0") 25 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nymanjens/piga/dea554dafafb07ca3692d81a41241b857dc22274/screenshot.png -------------------------------------------------------------------------------- /unix-service.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # To install piga as a service: 3 | # - move this file to /etc/init.d/piga 4 | # - update SERVICE_ROOT 5 | # - run: 6 | # sudo chmod +x /etc/init.d/piga 7 | # sudo update-rc.d piga defaults # start at startup 8 | 9 | ### BEGIN INIT INFO 10 | # Provides: piga 11 | # Required-Start: $local_fs $remote_fs $network $syslog $named 12 | # Required-Stop: $local_fs $remote_fs $network $syslog $named 13 | # Default-Start: 2 3 4 5 14 | # Default-Stop: 0 1 6 15 | # Short-Description: Starts piga 16 | # Description: Starts piga using start-stop-daemon 17 | ### END INIT INFO 18 | 19 | USER=root 20 | HOME=/root 21 | export USER HOME 22 | 23 | ### settings ## 24 | SERVICE_ROOT=/path/to/piga 25 | RUNNING_PID=$SERVICE_ROOT/RUNNING_PID 26 | 27 | ### helper functions ### 28 | kill_running() { 29 | if [ -f "$RUNNING_PID" ]; then 30 | pid=`cat "$RUNNING_PID"` 31 | echo "Killing process $pid found in $RUNNING_PID" 32 | kill -15 "$pid" 33 | 34 | echo "Waiting for $RUNNING_PID to disappear..." 35 | for i in `seq 1 40`; do 36 | if [ -f "$RUNNING_PID" ]; then 37 | if ps -p $pid > /dev/null ; then 38 | sleep 0.5 39 | else 40 | echo "Process stopped without removing $RUNNING_PID" && echo 41 | echo "Removing $RUNNING_PID..." 42 | rm -f "$RUNNING_PID" 43 | echo "Done" && echo 44 | break 45 | fi 46 | else 47 | echo "Done" && echo 48 | break 49 | fi 50 | done 51 | 52 | if [ -f "$RUNNING_PID" ]; then 53 | echo "Timed out" && echo 54 | fi 55 | fi 56 | } 57 | 58 | ### service implementation ### 59 | case "$1" in 60 | start|restart) 61 | kill_running 62 | 63 | echo "Starting piga..." && echo 64 | 65 | su pi -c "cd $SERVICE_ROOT && bin/server -Dhttp.port=8782" > /tmp/piga-logs 2>&1 & 66 | 67 | echo "Waiting for $RUNNING_PID to appear" 68 | for i in `seq 1 20`; do 69 | if [ ! -f "$RUNNING_PID" ]; then 70 | sleep 0.5 71 | else 72 | break 73 | fi 74 | done 75 | 76 | if [ -f "$RUNNING_PID" ]; then 77 | pid=`cat "$RUNNING_PID"` 78 | echo "Done. Process $pid was started." && echo 79 | else 80 | echo "Timed out" && echo 81 | fi 82 | ;; 83 | 84 | stop) 85 | echo "Stopping piga..." && echo 86 | kill_running 87 | ;; 88 | 89 | *) 90 | echo "Usage: service piga {start|stop|restart}" 91 | exit 1 92 | ;; 93 | esac 94 | 95 | exit 0 96 | --------------------------------------------------------------------------------