diff --git a/.carve/ignore b/.carve/ignore new file mode 100644 index 0000000000..51464bd51e --- /dev/null +++ b/.carve/ignore @@ -0,0 +1,42 @@ +athens.db/help-url +athens.db/ego-url +athens.views.right-sidebar/sidebar-section-heading-style +athens.core/init +athens.main.core/main +athens.patterns/date +athens.util/common-ancestor +athens.events/pages +athens.self-hosted.presence.utils/MEMBERS +athens.common-events.schema/datascript-indent-multi +athens.common-events.schema/datascript-unindent-multi +athens.common-events.schema/datascript-drop-child +athens.common-events.schema/datascript-drop-multi-child +athens.common-events.schema/datascript-drop-link-child +athens.common-events.schema/datascript-drop-diff-parent +athens.common-events.schema/datascript-drop-multi-diff-source-same-parents +athens.common-events.schema/datascript-drop-multi-diff-source-diff-parents +athens.common-events.schema/datascript-drop-link-diff-parent +athens.common-events.schema/datascript-drop-same +athens.common-events.schema/datascript-drop-multi-same-source +athens.common-events.schema/datascript-drop-multi-same-all +athens.common-events.schema/datascript-link-same +athens.common-events.schema/datascript-selected-delete +athens.common-events.schema/datascript-block-open +athens.self-hosted.presence.views/toolbar-presence-el +athens.self-hosted.client/retract-args +athens.events/select-down +athens.common-events.stress-test/transact-without-linkmaker +athens.common-events.graph.atomic/make-block-open-op +athens.common-events.graph.atomic/make-block-remove-op +athens.common-events.graph.atomic/make-block-move-op +athens.common-events.graph.atomic/make-page-rename-op +athens.common-events.graph.atomic/make-page-merge-op +athens.common-events.graph.atomic/make-page-remove-op +athens.common-events.graph.atomic/make-shortcut-new-op +athens.common-events.graph.atomic/make-shortcut-remove-op +athens.common-events.graph.atomic/make-shortcut-move-op +athens.common-events.graph.schema/valid-atomic-op? +athens.common-events.graph.schema/explain-atomic-op +athens.style/unzoom +athens.self-hosted.fluree.test-helpers/query +athens.views.jetsam/jetsam-component diff --git a/.clj-kondo/config.edn b/.clj-kondo/config.edn new file mode 100644 index 0000000000..a3efe0905c --- /dev/null +++ b/.clj-kondo/config.edn @@ -0,0 +1,11 @@ +{:linters {:unresolved-namespace {:exclude [clojure.string]} + :unresolved-symbol {:exclude [random-uuid + goog.DEBUG + (com.rpl.specter/recursive-path)]} + :unused-referred-var {:exclude {clojure.test [is deftest testing]}} + :unsorted-required-namespaces {:level :warning}} + :lint-as {day8.re-frame.tracing/fn-traced clojure.core/fn + day8.re-frame.tracing/defn-traced clojure.core/defn + reagent.core/with-let clojure.core/let + instaparse.core/defparser clojure.core/def + athens.common.sentry/defntrace clojure.core/defn}} diff --git a/.clj-kondo/rewrite-clj/rewrite-clj/config.edn b/.clj-kondo/rewrite-clj/rewrite-clj/config.edn new file mode 100644 index 0000000000..19ecae96a0 --- /dev/null +++ b/.clj-kondo/rewrite-clj/rewrite-clj/config.edn @@ -0,0 +1,5 @@ +{:lint-as + {rewrite-clj.zip/subedit-> clojure.core/-> + rewrite-clj.zip/subedit->> clojure.core/->> + rewrite-clj.zip/edit-> clojure.core/-> + rewrite-clj.zip/edit->> clojure.core/->>}} diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000..cc51300ba3 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +/Dockerfile +/.dockerignore +/.gitignore +/.shadow-cljs/ +/node_modules/ +/.git/ +/resources/public/js/compiled diff --git a/.gitbook.yaml b/.gitbook.yaml new file mode 100644 index 0000000000..e5a0fddea9 --- /dev/null +++ b/.gitbook.yaml @@ -0,0 +1 @@ +root: ./doc/ diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000000..cfb72f702c --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,12 @@ +# These are supported funding model platforms + +github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] +patreon: # Replace with a single Patreon username +open_collective: athens +ko_fi: # Replace with a single Ko-fi username +tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel +community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry +liberapay: # Replace with a single Liberapay username +issuehunt: # Replace with a single IssueHunt username +otechie: # Replace with a single Otechie username +custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000000..fcf8fe10ab --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,14 @@ +--- +name: Bug Report +about: Report 🐞 Bugs +title: '' +labels: 'type: 🐞 bug' +assignees: '' + +--- + +**Problem** + +**Screenshots/Demo** + +**Athens Version** diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000000..e7634b940f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,10 @@ +--- +name: Feature Request +about: Suggest a feature for this project +title: '' +labels: 'type: feature request' +assignees: '' + +--- + +Please search our GitHub and Discord to see if your feature has already been requested! diff --git a/.github/ISSUE_TEMPLATE/question.md b/.github/ISSUE_TEMPLATE/question.md new file mode 100644 index 0000000000..1e97ee4f44 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/question.md @@ -0,0 +1,12 @@ +--- +name: Question +about: Do you need help or information? +title: '' +labels: 'type: ❓ question' +assignees: '' + +--- + +**Please ask questions on our [Discord](https://discord.gg/HNmxvpm)** + +You will get a response far faster in our Discord, plus the Athenians are awesome :) diff --git a/.github/custom-actions/clojure-env/action.yml b/.github/custom-actions/clojure-env/action.yml new file mode 100644 index 0000000000..606b0d19a9 --- /dev/null +++ b/.github/custom-actions/clojure-env/action.yml @@ -0,0 +1,39 @@ +name: 'Clojure Env' +description: 'Setup a clojure environment' +runs: + using: "composite" + steps: + - name: Restore maven + uses: actions/cache@v2 + id: restore-maven + with: + path: ~/.m2/repository + key: ${{ runner.os }}-maven-${{ hashFiles('deps.edn') }} + restore-keys: | + ${{ runner.os }}-maven- + + - name: Restore gitlibs + uses: actions/cache@v2 + id: restore-gitlibs + with: + path: ~/.gitlibs + key: ${{ runner.os }}-gitlibs-${{ hashFiles('deps.edn') }} + restore-keys: | + ${{ runner.os }}-gitlibs- + + - name: Prepare java + uses: actions/setup-java@v2 + with: + java-version: '17' + distribution: 'temurin' + + - uses: DeLaGuardo/setup-clojure@3.5 + with: + cli: 1.10.3.986 + + - name: Fetch dependencies + # Clojure on windows needs to be run on powershell. + # Powershell is also present on other platforms so we can use it for all. + shell: pwsh + if: steps.restore-maven.outputs.cache-hit != 'true' + run: clojure -P diff --git a/.github/custom-actions/node-env/action.yml b/.github/custom-actions/node-env/action.yml new file mode 100644 index 0000000000..80ae48af7e --- /dev/null +++ b/.github/custom-actions/node-env/action.yml @@ -0,0 +1,22 @@ +name: 'Node Env' +description: 'Setup a node environment' +runs: + using: "composite" + steps: + - name: Get yarn cache directory path + id: yarn-cache-dir-path + run: echo "::set-output name=dir::$(yarn cache dir)" + shell: bash + + - name: Restore yarn cache + uses: actions/cache@v2 + id: restore-yarn + with: + path: ${{ steps.yarn-cache-dir-path.outputs.dir }} + key: ${{ runner.os }}-v1-yarn-${{ hashFiles('yarn.lock') }} + restore-keys: | + ${{ runner.os }}-v1-yarn- + + - name: Fetch yarn dependencies + run: yarn install --frozen-lockfile --network-timeout 10000000 + shell: bash diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c32d38cccc..90cacd5ba9 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,45 +1,300 @@ name: build -on: [push - , pull_request - ] +on: + push: + paths-ignore: + - '*.md' + - 'docs/**' + pull_request: + paths-ignore: + - '*.md' + - 'docs/**' + +env: + # Github container registry https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry + REGISTRY: ghcr.io jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + + # We don't use yarn lint here in order to have faster CI. + # Keep version and script up to date! + - uses: DeLaGuardo/setup-clj-kondo@master + with: + version: '2022.03.09' + + - name: Lint + run: clj-kondo --lint src + + + style: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: ./.github/custom-actions/clojure-env + + - name: Style + run: yarn style + + + carve: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: ./.github/custom-actions/clojure-env + + - name: Carve unused vars + run: yarn carve + + + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: ./.github/custom-actions/clojure-env + - uses: ./.github/custom-actions/node-env + + - name: Run JVM tests + run: yarn server:test + + - name: Run Karma tests + run: yarn client:test + + # TODO: these tests cause the test runner to never exist, so they + # can't be ran on CI. Please run them manually for now. + # See https://github.com/fluree/db/issues/163. + # - name: Start Fluree process + # run: yarn server:fluree + + # - name: Run Fluree tests + # run: yarn server:test:fluree + + + # e2e: + # runs-on: ubuntu-latest + # steps: + # - uses: actions/checkout@v2 + # - uses: ./.github/custom-actions/clojure-env + # - uses: ./.github/custom-actions/node-env + + # # Caching the build is generally a terrible idea, but e2e is really slow + # # and shadow-cljs is usually pretty good at cache invalidation. + # # Still, if you think this cache is breaking builds, just bump the version number. + # - name: Restore shadow-cljs build cache + # uses: actions/cache@v2 + # id: restore-shadow-cljs-build-cache + # with: + # path: ./.shadow-cljs + # key: ${{ runner.os }}-v2-shadow-cljs-build-cache-${{ hashFiles('yarn.lock') }} + # restore-keys: | + # ${{ runner.os }}-v2-shadow-cljs-build-cache + + # - name: Compile JS assets for dev + # run: yarn client:dev-build + + # - name: Run client e2e tests over dev build + # run: yarn client:e2e + + + build-app: + needs: [test, lint, style, carve] + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: ./.github/custom-actions/clojure-env + - uses: ./.github/custom-actions/node-env + + - name: Compile JS Assets for production + run: yarn prod --config-merge "{:closure-defines {athens.core/SENTRY_DSN \"${SENTRY_DSN}\" athens.util/COMMIT_URL \"${COMMIT_URL}\"}}" + env: + SENTRY_DSN: ${{ secrets.sentry_dsn }} + COMMIT_URL: "https://github.com/${{github.repository}}/commit/${{github.sha}}" + + # - name: Run client e2e tests over the prod build + # run: yarn client:e2e + + - name: Upload built app for release-web, release-electron + uses: actions/upload-artifact@v2 + with: + name: app + path: resources + + + release-web: + needs: [build-app] + # Only deploy on v2.* tag pushes to the default branch (main). + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v2.') + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: ./.github/custom-actions/node-env + + - name: Download built app + uses: actions/download-artifact@v2 + with: + name: app + path: resources + + - name: Copy built app to a vercel prod deploy folder + run: mkdir -p vercel-release/vercel-static/athens && cp -R resources/public/. vercel-release/vercel-static/athens/ + + - uses: amondnet/vercel-action@v20 + with: + vercel-token: ${{ secrets.VERCEL_TOKEN }} + vercel-org-id: ${{ secrets.VERCEL_ORG_ID}} + vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID}} + scope: athens-research + vercel-args: './vercel-release/' + # Poor mans ternary operator https://github.com/actions/runner/issues/409#issuecomment-727565588 + alias-domains: ${{ env.PRERELEASE == 'true' && env.PRERELEASE_DOMAIN || env.RELEASE_DOMAIN }} + env: + PRERELEASE: ${{ contains(github.ref, '-alpha.') || contains(github.ref, '-beta.') || contains(github.ref, '-rc.')}} + PRERELEASE_DOMAIN: beta.athensresearch.org + RELEASE_DOMAIN: web.athensresearch.org + + + release-electron: + needs: [build-app] + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') + runs-on: ${{ matrix.os }} + env: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + + strategy: + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + + steps: + - uses: actions/checkout@v2 + - uses: ./.github/custom-actions/node-env + + - name: Prepare for app notarization (macOS) + if: startsWith(matrix.os, 'macos') + # Import Apple API key for app notarization on macOS + run: | + mkdir -p ~/private_keys/ + echo '${{ secrets.api_key }}' > ~/private_keys/AuthKey_${{ secrets.api_key_id }}.p8 + + - name: Download built app + uses: actions/download-artifact@v2 + with: + name: app + path: resources + + - name: Build and Publish Electron App + uses: samuelmeuli/action-electron-builder@v1 + with: + + # Don't run `yarn build`, which otherwise happens by default + skip_build: true + + # GitHub token, automatically provided to the action + # (No need to define this secret in the repo settings) + github_token: ${{ secrets.github_token }} + + # macOS code signing certificate + mac_certs: ${{ secrets.mac_certs }} + mac_certs_password: ${{ secrets.mac_certs_password }} + + # If the commit is tagged with a version (e.g. "v1.0.0"), + # release the app after building + release: ${{ startsWith(github.ref, 'refs/tags/v') }} + + env: + # macOS notarization API key + API_KEY_ID: ${{ secrets.api_key_id }} + API_KEY_ISSUER_ID: ${{ secrets.api_key_issuer_id }} + + + release-server: + runs-on: ubuntu-latest + needs: [test, lint, style, carve, build-app] + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') + + steps: + - uses: actions/checkout@v2 + - uses: ./.github/custom-actions/clojure-env + + - name: Download built app + uses: actions/download-artifact@v2 + with: + name: app + path: resources + + - name: Compile server code + run: yarn server:compile + + - name: Build server executable uberjar + run: yarn server:uberjar + + # we need QEMU for multi-arch build + - name: Set up QEMU + uses: docker/setup-qemu-action@v1 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v1 + + - name: Login to Github Container registry + uses: docker/login-action@v1 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract server metadata (tags, labels) for athens docker image + id: athens-meta + uses: docker/metadata-action@v3 + with: + images: ${{ env.REGISTRY }}/${{ github.repository }} + + - name: Extract server metadata (tags, labels) for nginx docker image + id: nginx-meta + uses: docker/metadata-action@v3 + with: + images: ${{ env.REGISTRY }}/${{ github.repository_owner }}/nginx + + - name: Build and push athens + uses: docker/build-push-action@v2 + with: + # Use the current folder as context instead of the branch. + # Needed to use artifacts like the server jar. + context: . + platforms: linux/amd64,linux/arm64 + file: athens.dockerfile + push: true + tags: ${{ steps.athens-meta.outputs.tags }} + labels: ${{ steps.athens-meta.outputs.labels }} + # Use GitHub actions cache. + # https://github.com/docker/build-push-action/blob/master/docs/advanced/cache.md#github-cache + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Build and push nginx + uses: docker/build-push-action@v2 + with: + context: . + platforms: linux/amd64,linux/arm64 + file: nginx.dockerfile + push: true + tags: ${{ steps.nginx-meta.outputs.tags }} + labels: ${{ steps.nginx-meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + + - name: Replace version in docker-compose + run: sed -i.bk 's/:latest/:${{ steps.athens-meta.outputs.version }}/' docker-compose.yml - # scratch: - # runs-on: ubuntu-18.04 - # steps: - # - name: Git checkout - # uses: actions/checkout@v1 - # with: - # fetch-depth: 1 - # submodules: 'true' - # - # - name: Scratch - # run: | - # echo "Scratch" - test: - # ubuntu 18.04 comes with lein + java8 installed - runs-on: ubuntu-18.04 - steps: - - name: Git checkout - uses: actions/checkout@v1 - with: - fetch-depth: 1 - submodules: 'true' - - - name: Cache deps - uses: actions/cache@v1 - id: cache-deps - with: - path: ~/.m2/repository - key: ${{ runner.os }}-maven-${{ hashFiles('project.clj') }} - restore-keys: | - ${{ runner.os }}-maven- - - name: Fetch deps - if: steps.cache-deps.outputs.cache-hit != 'true' - run: | - lein deps - - name: Run tests - run: | - script/test/jvm + - name: Publish Docker compose + uses: ncipollo/release-action@v1 + with: + artifacts: "docker-compose.yml" + token: ${{ secrets.GITHUB_TOKEN }} + allowUpdates: true + prerelease: true + draft: true diff --git a/.gitignore b/.gitignore index 194c42ba70..d45730b869 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,10 @@ /out/ /resources/public/js/ +/resources/*.js /target/ /*-init.clj /*.log +repl-client-debug # Leiningen /.lein-* @@ -13,4 +15,47 @@ # shadow-cljs cache, port files /.shadow-cljs/ + +# Intelli-j files .idea/ +athens.iml + +# local caches +.clj-kondo/.cache +.lsp/ +.cache/ + +# cache directory for dependencies installed with deps.edn +.cpcache + +# Mac OS files +.DS_Store + +# Emacs files +.#* +.calva + +# electron build +dist + +# design system output +/src/gen + +# docker compose templating backup +docker-compose.yml.bk + +# dev mode server data +athens-data/* +!athens-data/README.md + +# java AOT classes +classes/* +!classes/README.md + +# vercel static site deployment +vercel-static/* +!vercel-static/index.html +!vercel-state/athens/.gitignore +.vercel +vercel-release/* +!vercel-release/package.json diff --git a/.ignore b/.ignore new file mode 100644 index 0000000000..7aa6f8cacb --- /dev/null +++ b/.ignore @@ -0,0 +1 @@ +*.datoms diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000000..b6a7d89c68 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +16 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000000..1fc6ec44a9 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,3401 @@ +# Changelog + +All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. + +## [2.1.0-beta.5](https://github.com/athensresearch/athens/compare/v2.1.0-beta.4...v2.1.0-beta.5) (2022-10-10) + + +### Features + +* add top-level css variable for app height ([dd3791c](https://github.com/athensresearch/athens/commit/dd3791cd0dfd1052e3d1a9edbaf87a2ff1f5ec68)) +* clearer menu in dark mode ([9315c7f](https://github.com/athensresearch/athens/commit/9315c7f9633f6dcc536aac55ff820c47a5edc735)) +* highlight current page name in sidebar ([ce44032](https://github.com/athensresearch/athens/commit/ce44032e71bd4dd7435b5a1d7f6ce19058803b2b)) +* more prominent notifications ([94cc942](https://github.com/athensresearch/athens/commit/94cc942cbbc86fab690a3e7c58f0c91e5370ecdd)) +* notification content parsed and rendered ([37abf6c](https://github.com/athensresearch/athens/commit/37abf6c2fdb1356c553c69a8e2c08f1d1fdd7e8b)) +* notifications grouped by page ([7867c78](https://github.com/athensresearch/athens/commit/7867c78a0b09e43f2b899dacae8cead440f912e2)) +* rendered notification object title ([57b7ac5](https://github.com/athensresearch/athens/commit/57b7ac54b9bb85c2d30ba3e1064a5083dcf17642)) + + +### Bug Fixes + +* daily notes dont break ([05582cb](https://github.com/athensresearch/athens/commit/05582cb4a55cea6bb6ba74e5089160ab4b0e650f)) +* don't crash on bad position ([086d034](https://github.com/athensresearch/athens/commit/086d034ee53b73678d25aaaff960cc02938b26da)) +* don't error out when re-frame-10x is missing ([b28117d](https://github.com/athensresearch/athens/commit/b28117da4c8d68bca9e9f32e9224bfe9655354e6)) +* ignore properties when testing whether page is empty ([b222614](https://github.com/athensresearch/athens/commit/b222614fa7c3a00b3dfed5fe79cde53fe2d8bd1b)) +* items on page shouldn't overlap ([0fb46e7](https://github.com/athensresearch/athens/commit/0fb46e7ce0ce7c86d4319f30a12372bf263ee99b)) +* left sidebar respects tasks ff ([bfce6af](https://github.com/athensresearch/athens/commit/bfce6afa282ebd0bbebeff80bfd7834aa2fff57d)) +* main items in left sidebar aligned properly ([ac1ac3b](https://github.com/athensresearch/athens/commit/ac1ac3bec1cce2ccfb565c742113ea15aa38bf61)) +* notification contents should appear again ([419c140](https://github.com/athensresearch/athens/commit/419c14009fa87085a43446a65437a29a1b3e0069)) +* page header aligns correctly ([2b96c5b](https://github.com/athensresearch/athens/commit/2b96c5b5e2fe95474d0cdc7e26fa3fca80b26805)) +* Show comments on zoomed in view ([c428319](https://github.com/athensresearch/athens/commit/c42831974e1bdb411c92e46f11f8db6696d537f6)) + + +### Refactors + +* add menu to notification items ([d0bdc7a](https://github.com/athensresearch/athens/commit/d0bdc7adeda581f5e24ae4806fff50d7b969b81b)) +* cleaner simpler notification style ([1c08a1d](https://github.com/athensresearch/athens/commit/1c08a1d762cd47451f49674c1c123a75af90c3b8)) +* improve page sizing implementation ([0da1e16](https://github.com/athensresearch/athens/commit/0da1e16d61e6b4019b46dd4fcf879e5e16b78ad7)) +* new page components ([22ac1e4](https://github.com/athensresearch/athens/commit/22ac1e4840e6fdacdf947af98376985d5229a32a)) +* remove unnecessary line ([7d44280](https://github.com/athensresearch/athens/commit/7d442800ea3641a2e970503d76dea61536157300)) +* use app height instead of just vh units ([67c46ac](https://github.com/athensresearch/athens/commit/67c46ac2bdbddb1e067af2c7e57baae2a5a6482f)) +* way simpler notifications renderer ([132f270](https://github.com/athensresearch/athens/commit/132f270b98ebd95c8e3fdaf5379b152bbccc2934)) + + +* lint ([b9f1f65](https://github.com/athensresearch/athens/commit/b9f1f65ad94562f9fc1d8b08c9051951acb4107a)) +* lint ([0bc66d1](https://github.com/athensresearch/athens/commit/0bc66d1ca0fbbf85a5736eaf907c475769dcf390)) +* lint ([be7bdff](https://github.com/athensresearch/athens/commit/be7bdff081ae057e7e02a1c6e5e9db619ece7d86)) +* lint ([f891163](https://github.com/athensresearch/athens/commit/f8911639e5cff99ab005dbf174bbc85858b801fb)) +* lint ([03baf5a](https://github.com/athensresearch/athens/commit/03baf5af221ba7dd651d1b619657a926bfa38209)) +* lint ([a0cd186](https://github.com/athensresearch/athens/commit/a0cd1865195537951aa204a5757f0b9595526e37)) +* remove commented code ([df45c41](https://github.com/athensresearch/athens/commit/df45c4131e77b6bf33dd32f12ae38e4da98e9b35)) +* remove storybook ([4c718b2](https://github.com/athensresearch/athens/commit/4c718b2aa578d7ba620e7135b9fd35a1fefdede3)) +* remove unused files ([287af51](https://github.com/athensresearch/athens/commit/287af51357f01d1fcd0e90573b90ff646eab429d)) +* show hints for null fn calls ([dac2bbe](https://github.com/athensresearch/athens/commit/dac2bbe3ee4b8087a438eae8abc6cb933c777872)) + +## [2.1.0-beta.4](https://github.com/athensresearch/athens/compare/v2.1.0-beta.3...v2.1.0-beta.4) (2022-09-27) + + +### Features + +* add auth to api ([e470bb3](https://github.com/athensresearch/athens/commit/e470bb38abdd59371b100c5851ad2de6d27e5d1f)) +* add basic content negotiation to api ([8914d37](https://github.com/athensresearch/athens/commit/8914d37c9aeb3b943686045d9ed6f79b1ab4289b)) +* add card layerstyle ([c9d6afd](https://github.com/athensresearch/athens/commit/c9d6afdcc628b6f5818f39007a223ec2a4cd07e6)) +* add dummy block editor to card edit popover ([5ccb104](https://github.com/athensresearch/athens/commit/5ccb104867aff81abdc95850e67ffba814309fb0)) +* add empty component, show in tasks ([161c67c](https://github.com/athensresearch/athens/commit/161c67c0975b0171b63db5e230be9021a2bb6b6d)) +* add empty for sidebar shortcuts ([c373da4](https://github.com/athensresearch/athens/commit/c373da429d1e84a64198cf23e9f22f70e616a677)) +* add empty to notifications popover ([ee284cb](https://github.com/athensresearch/athens/commit/ee284cbd526d1044f51e43f165f785cf0b148175)) +* add rect tabs style, make default ([b11ff22](https://github.com/athensresearch/athens/commit/b11ff221ac2a8f7591231f1211995c6f2d0b028e)) +* add selectors to path api ([ea09793](https://github.com/athensresearch/athens/commit/ea0979362219dd656c7b87a6be6a8f95bfa0ccd6)) +* Added some style magic from shanberg to task refs ([363b5ca](https://github.com/athensresearch/athens/commit/363b5ca13529f8e1a4150c3e4a3ae34d2dd2416b)) +* better handling of multiple menu sources ([11f6e1f](https://github.com/athensresearch/athens/commit/11f6e1f7a2aacab6f7835a59ea390187985954a5)) +* block in new interactions for tasks in sidebar ([62ee269](https://github.com/athensresearch/athens/commit/62ee269007b3e2d5fc1dbaa52666667d0d24397d)) +* break up import events into ~900kb chunks ([fdd6ab3](https://github.com/athensresearch/athens/commit/fdd6ab31c370ae30a53439b66e107e8471a631d4)) +* can convert block to task ([ac6aaa1](https://github.com/athensresearch/athens/commit/ac6aaa12d333eb892727b13c7ba67924aced97bc)) +* content spacing improvements ([c21c112](https://github.com/athensresearch/athens/commit/c21c112feeaf8455332d82b714437892e73e45ff)) +* context menu supports mutliple targtes ([a2b2123](https://github.com/athensresearch/athens/commit/a2b2123a1b3826bf30b36e4d9983f968f9a16aea)) +* context menus can combine ([0e6f3a8](https://github.com/athensresearch/athens/commit/0e6f3a85cd91dab89a74353883dc70bad8d6254b)) +* contextmenu offerors are keyed ([df4cf85](https://github.com/athensresearch/athens/commit/df4cf8544da9f049116aa9b0f7b0a42f20ea2be0)) +* Convert block to task & save task title. ([649ae96](https://github.com/athensresearch/athens/commit/649ae96a4b704cd323466a93dfe895e7a8105e79)) +* first pass on queries for tasks ([#2235](https://github.com/athensresearch/athens/issues/2235)) ([30fdffc](https://github.com/athensresearch/athens/commit/30fdffce25dc4a5645eda51eb1483bb8b0e6f1c7)) +* gate api on feature flag ([dc6c3b8](https://github.com/athensresearch/athens/commit/dc6c3b8001473bed4d08150b9576b023e41474ea)) +* import from roam using internal representation ([a755b75](https://github.com/athensresearch/athens/commit/a755b75952e64f6b2bf0b4578a974af86ef06d63)) +* improve card over column style ([21a1257](https://github.com/athensresearch/athens/commit/21a12574ba36d03898f52cef0456fdfdd2732b28)) +* improve tasks and tasks in refs ([1dc191d](https://github.com/athensresearch/athens/commit/1dc191dc9c8f95ed731a4ccfce35e7b70d963879)) +* improved ui around blocks ([d0ef9b3](https://github.com/athensresearch/athens/commit/d0ef9b3f2f6b092af52b4eb2b3d2ee1cda4f227f)) +* improving task layout ([74a3045](https://github.com/athensresearch/athens/commit/74a3045cc10e78b99d84335c61d2422dd604818c)) +* initial menu works ([c9069bf](https://github.com/athensresearch/athens/commit/c9069bfda59ed2b668ba2ca7bebd67a432e1406e)) +* kanban components forward ref ([7b65f4d](https://github.com/athensresearch/athens/commit/7b65f4d8a4bd761f33e1eb5fd0d6afdf7a650d11)) +* more data shown in task popover ([aeebe0c](https://github.com/athensresearch/athens/commit/aeebe0c9918ef14382169c482361b43f834ca779)) +* more data shown in task popover ([54550ee](https://github.com/athensresearch/athens/commit/54550ee2b4caea22b1a46e8603bfcdecc3b95cbc)) +* more work on comments around tasks ([a1a2876](https://github.com/athensresearch/athens/commit/a1a287647c786f77056a4207f409a9740c1038a9)) +* more work on comments around tasks ([c4c166c](https://github.com/athensresearch/athens/commit/c4c166c6dd89a0f7ca7c44837df7acb9cb2120ed)) +* much added sidebar task stuff ([9485ad6](https://github.com/athensresearch/athens/commit/9485ad678eb410a5e46ff3d3ff1f160e35f70ab7)) +* Navigate tasks with keyboard part 1 ([e71dbe8](https://github.com/athensresearch/athens/commit/e71dbe8281bf96d12298f95171147973d890a6c5)) +* new icons ([02e91d0](https://github.com/athensresearch/athens/commit/02e91d043c3c87a70f97a1de1aad65c719de6d0a)) +* nice sidebar goodies ([6108f0d](https://github.com/athensresearch/athens/commit/6108f0d81d2a853ddbe9105da4894fe476fe440b)) +* objects can claim exclusive menus ([b02b1d2](https://github.com/athensresearch/athens/commit/b02b1d2a0953bf18e3ca353363c06c7944233349)) +* Parsing to text wraps block-refs in `(())` ([db9e868](https://github.com/athensresearch/athens/commit/db9e868f8a86f941e42fe9f2633869dbccf03dd0)) +* polish taskbox component ([47b57cc](https://github.com/athensresearch/athens/commit/47b57cc0f572f3b513bfc1e5e5d6ce6662f49e6a)) +* popover-style task editing ui ([7f71d67](https://github.com/athensresearch/athens/commit/7f71d6770de1dc44202dc6c2ff574333f61b7174)) +* reactive task status ([f0d7a80](https://github.com/athensresearch/athens/commit/f0d7a80205b34b1d895848d025a7a9daabbf748d)) +* readable table query ([#31](https://github.com/athensresearch/athens/issues/31)) ([aaef7d4](https://github.com/athensresearch/athens/commit/aaef7d46da363797605464345ae14e495079055f)) +* restyle things shown in sidebar ([99b58e8](https://github.com/athensresearch/athens/commit/99b58e86c5937856b5bb035dd6a0dfe1cd03b83b)) +* show fake traffic lights on macos when not focused ([9237ec4](https://github.com/athensresearch/athens/commit/9237ec45083d7ddaf8aab5ec916ccd84f0c123bb)) +* showing current block title in toolbar ([d94095d](https://github.com/athensresearch/athens/commit/d94095d7d776f1d3db5a1a542f0835d8f326d2ad)) +* simple GET /block/uid POST /add API ([5a25533](https://github.com/athensresearch/athens/commit/5a255339458cc7911e27db78a5a5a10843f812ae)) +* stable api for context menu ([3df5878](https://github.com/athensresearch/athens/commit/3df5878d0ea8d6e51c3f57b930fe9dbccb81e9ec)) +* starting out ([9e3f35a](https://github.com/athensresearch/athens/commit/9e3f35a225f6a6393c48dfbb3ee5bac784d7e1d4)) +* Task indent & unindent. ([c53bfda](https://github.com/athensresearch/athens/commit/c53bfda05f880fff616e848251ed574a39d763c7)) +* Task ref to navigate just like block refs do. ([3280202](https://github.com/athensresearch/athens/commit/3280202af2e8813138d1d79aad9898f16b12466f)) +* Task Ref using `:task/title` ([b0ef860](https://github.com/athensresearch/athens/commit/b0ef860b5101cd20c02826bd56eb7d33545495ca)) +* tasks completable from sidebar ([ca33f7d](https://github.com/athensresearch/athens/commit/ca33f7d13d00749f30a9717b53140939dbc48a3e)) +* Tasks keyboard navigation up ([160c4be](https://github.com/athensresearch/athens/commit/160c4bed7c828d6d50dd3af90ae34ce4e1c7079d)) +* Tasks title editor [Enter] support ([d0ca422](https://github.com/athensresearch/athens/commit/d0ca422b05b5a45217a82ae08d18946787e76fc2)) +* theme toggle in sidebar ([85a105b](https://github.com/athensresearch/athens/commit/85a105bb5058d0956f9c999f795f2588cffe8bfe)) +* update task box style ([88c8f01](https://github.com/athensresearch/athens/commit/88c8f0144a875f45f3bf59b4968c06fc45ed4d33)) + + +### Bug Fixes + +* [Enter] and [Backspace] fixed ([b0d2f22](https://github.com/athensresearch/athens/commit/b0d2f222a3400c7f3494412088968ca9823ec13a)) +* `unindent` not loosing focus. ([8fa9b7c](https://github.com/athensresearch/athens/commit/8fa9b7c77724be334bbf3755502b7026e80e724d)) +* add grid space for reactions on comments ([2c6879d](https://github.com/athensresearch/athens/commit/2c6879de5ef8be3676e542bcd5d7639779eee744)) +* add key to interface ([ecce381](https://github.com/athensresearch/athens/commit/ecce3812398ede16393fd5d2e8db4e803aaf0ab9)) +* add padding to end of list ([cbca488](https://github.com/athensresearch/athens/commit/cbca488d29475941038193f1e36464683954a20c)) +* add some space to bottom of sidebar items ([7454f47](https://github.com/athensresearch/athens/commit/7454f4793e6da5db1e903ac3264671946cd71949)) +* also allow lists in internal-representation->atomic-ops ([f860f9f](https://github.com/athensresearch/athens/commit/f860f9fcdba4fa6a9e4db8e4919a078f3e2df456)) +* Backward compatible focusing during unindent ([af05226](https://github.com/athensresearch/athens/commit/af052261573049751f9a0a8d4c6fe102cff6606f)) +* better alignment for buttons in left sidebar ([0552c9a](https://github.com/athensresearch/athens/commit/0552c9a3ee4b31b5d7519296c82f55d43bd20f6b)) +* better alignment for task display settings control ([05b519a](https://github.com/athensresearch/athens/commit/05b519a39fb9110d93a21102f8d66ffb9a239cf4)) +* better style for input in task form ([5e8f68a](https://github.com/athensresearch/athens/commit/5e8f68a5304163849799f1a337034c78d85504de)) +* block anchors draggable again ([f037cb6](https://github.com/athensresearch/athens/commit/f037cb66977b78ed4152c65f2dc1780174498114)) +* block background should fit block size ([535e4bc](https://github.com/athensresearch/athens/commit/535e4bc01e8c5cc76795549d94ae762bc17025d9)) +* block buttons and line height match up ([677f42b](https://github.com/athensresearch/athens/commit/677f42bd8c7a8cfd8b37e6ea620f8ea3cc9ad491)) +* block container gets correct ref ([408bcf3](https://github.com/athensresearch/athens/commit/408bcf357a68a1955c52073a7fa6d4fa81fc142f)) +* block errors render properly ([a3642c5](https://github.com/athensresearch/athens/commit/a3642c57952c99a8cd7f2e4a7b9a7bb5cc2074ca)) +* block menu appears on rightclick of anchor too ([bd3149c](https://github.com/athensresearch/athens/commit/bd3149c857db58448a71a25a8290cf18d9920ea5)) +* block selection background fits block better ([8c00610](https://github.com/athensresearch/athens/commit/8c00610d70f5075f5a8190eadde7326b4da8ec1f)) +* bookmark icon has right border size ([fc510eb](https://github.com/athensresearch/athens/commit/fc510eb2fc56bd2b3dbb344a1ac335941a3749ab)) +* can copy multiple refs ([c8fa1e6](https://github.com/athensresearch/athens/commit/c8fa1e6b4a1031cddcdc36b43f0c7e1f26c80138)) +* can drag blocks again ([67d4480](https://github.com/athensresearch/athens/commit/67d44805b8b77baa939b04c1b5dd8884f4bcf4aa)) +* can scroll kanban cols again ([5968e22](https://github.com/athensresearch/athens/commit/5968e22a182f9a17619fd0d23058f84209918c67)) +* can show menu items in comment ([e430cae](https://github.com/athensresearch/athens/commit/e430cae3023708f752facc2ea181278a3ea277c3)) +* clearer indicator for task details form ([5160e0c](https://github.com/athensresearch/athens/commit/5160e0cf47d990cb0266b306319e90b9bcd8474d)) +* clearer text in db dialog ([40d2b5d](https://github.com/athensresearch/athens/commit/40d2b5d29d9ed09eebaa881636168a0d3a7d09ef)) +* code cleanup ([8d55f68](https://github.com/athensresearch/athens/commit/8d55f686dc80db7a91cbb0491f4554dadd4e8d00)) +* comments boxes don't stretch to far ([bb383c7](https://github.com/athensresearch/athens/commit/bb383c7102d4b4333e5d20c0510b0a25a916d64b)) +* couple menu issues ([b90b6d9](https://github.com/athensresearch/athens/commit/b90b6d9fb6e9602cecc17f4b4655d578fea5733d)) +* daily notes pages shouldn't get squished ([b7b8b16](https://github.com/athensresearch/athens/commit/b7b8b16ae5c7bc0fa81a4275b1eb54f8c987c3eb)) +* db-dump handling even if `:block/key` is `nil` ([e25e989](https://github.com/athensresearch/athens/commit/e25e9891eb630afd58869643087e1465fce6000a)) +* disable user button when user not on a page ([445f9d2](https://github.com/athensresearch/athens/commit/445f9d224eacf5034c0e3be8de4540cad10600d9)) +* docker-compose.yml only need 1 restart in each services ([256845b](https://github.com/athensresearch/athens/commit/256845bfce4e50343ede1390b218b2a087234224)) +* don't animate taskbox state on initial render ([40415be](https://github.com/athensresearch/athens/commit/40415be24d35811676ce1e1751ea3384bcd5e883)) +* double block new is a block move instead ([6f4e56e](https://github.com/athensresearch/athens/commit/6f4e56e057461e589f14c3711b2f2698e152f8d1)) +* double click anchor to navigate works ([15712c6](https://github.com/athensresearch/athens/commit/15712c69a68e056aafefbbb2042a298bd14ad787)) +* edit on embed should edit transcluding block ([0b6919d](https://github.com/athensresearch/athens/commit/0b6919df1175cccbe16cc71693df7d883a3663a9)) +* eliminate console warnings from unused props ([a8a7485](https://github.com/athensresearch/athens/commit/a8a7485c7adcff70fc238bd1cc4dfcd03351cf9f)) +* eliminate console warnings from unused props ([31408c1](https://github.com/athensresearch/athens/commit/31408c1b5f07b2c85387e4c77153b5824fbdcc70)) +* eliminate misc console errors ([94fc666](https://github.com/athensresearch/athens/commit/94fc66647e5644aed3dc786f6ad7087c69ffb971)) +* embeds should show children ([448b134](https://github.com/athensresearch/athens/commit/448b1348870152bfc34adc99cd93b78da345e4e9)) +* emoji shouldn't break out of bounds ([392bafa](https://github.com/athensresearch/athens/commit/392bafa2b79a4b209dd2d6915473dde3cf44578d)) +* enter handler for block page, show title for tasks. ([0bf5192](https://github.com/athensresearch/athens/commit/0bf5192bffd7ea419a6d8791a379f8bda01fff3c)) +* expand button disabled when no tasks present ([ec7c4b1](https://github.com/athensresearch/athens/commit/ec7c4b118bcc0b949db963c8f2a4be69a649e261)) +* ff should be {} if missing ([8a75180](https://github.com/athensresearch/athens/commit/8a75180d29c64d3b047ad689d9abb59feed50e5a)) +* Handle tasks without creation time ([b4a1180](https://github.com/athensresearch/athens/commit/b4a118066cc978cd1f24f8821df57e0f08481f8d)) +* hide cancelled tasks from sidebar ([30068e6](https://github.com/athensresearch/athens/commit/30068e6bcacad281c109c56751109b32b050449e)) +* icons in menu buttons should be the right size ([478818e](https://github.com/athensresearch/athens/commit/478818e2191834186dc7bef78aadd58459370b75)) +* icons shouldn't have underlying grid ([fb7f453](https://github.com/athensresearch/athens/commit/fb7f4530ed60e6df7f52eb3429655f7c7e5db6d8)) +* icons were wrong ([37ffc74](https://github.com/athensresearch/athens/commit/37ffc74775e381aa5d86bf83b9c5cd3d8f64f6da)) +* increase server max memory ([e727393](https://github.com/athensresearch/athens/commit/e72739370e4be201f87092b9ba53ce63318d8e01)) +* Indent keeps focus ([3d54ce2](https://github.com/athensresearch/athens/commit/3d54ce2b2053c2bfe0803821e60a6a0689bb4285)) +* internal-representation->atomic-ops should throw on invalid repr ([06f483e](https://github.com/athensresearch/athens/commit/06f483e2259e99d858018ad1d3caeb629ba850db)) +* janky scrolling when dragging card on kanban ([ac0c8b8](https://github.com/athensresearch/athens/commit/ac0c8b8773b5f9f65cd20065a5d5cf3238876caa)) +* long titles in sidebar items shouldn't overflow ([f6544e5](https://github.com/athensresearch/athens/commit/f6544e59ba968d41aab1ed49879931990a6f65f5)) +* make icons happy again ([5ed9f30](https://github.com/athensresearch/athens/commit/5ed9f305e94eba247ce867bcfa0214b799f19f3e)) +* menu should close when opening block, menu should support block selectiosn ([f5b94c2](https://github.com/athensresearch/athens/commit/f5b94c234038f8c44a8ff61b1da8f3aa1f77d955)) +* minor code style ([3a13c71](https://github.com/athensresearch/athens/commit/3a13c71b3f13d7fb59c21f3a2f89097fa6b779cd)) +* minor improvement ([169b241](https://github.com/athensresearch/athens/commit/169b2419e05c96fe1dbc45f30f51734556de2190)) +* misc prop passing errors ([900d412](https://github.com/athensresearch/athens/commit/900d412ab9d50d00d763e6350f24051134292d4b)) +* option menus have same line height as other menus ([808ccc8](https://github.com/athensresearch/athens/commit/808ccc8d8ea0204ca80c0136400caa96ba412b24)) +* prevent error when using block type menus ([9a88a38](https://github.com/athensresearch/athens/commit/9a88a38d39ea89596553f9ab5493b9ae9de2292c)) +* proper icon import ([1fe0f90](https://github.com/athensresearch/athens/commit/1fe0f90aedcb3b82c7819d40aea782bd7bb9153c)) +* reffed tasks wrap text ([93e53ed](https://github.com/athensresearch/athens/commit/93e53edfa1e056016197479d9ff179a0daa89e7d)) +* remove extra contextmenucontxt declaration ([25f79d5](https://github.com/athensresearch/athens/commit/25f79d555567cb2815f4e0bd87fb5fded4f33ea0)) +* remove extra space after task ([95d1dba](https://github.com/athensresearch/athens/commit/95d1dbab85274dfd9ac5254969a971f0b5b3b8ba)) +* restore focus behavior prior to chakra update ([cc54c4c](https://github.com/athensresearch/athens/commit/cc54c4c2afb53951548c0ae6eadc6f23776b2af5)) +* restore focus outlines when desired ([bbe9625](https://github.com/athensresearch/athens/commit/bbe962509e036141eb2045e74672bd5ceca7b2db)) +* right sidebar drag handle doesn't scroll away ([e2b2749](https://github.com/athensresearch/athens/commit/e2b27496234b2e774dbd08bc98ade50d5849d4fa)) +* right sidebar drag handle shouldn't get disconnected ([9e6e381](https://github.com/athensresearch/athens/commit/9e6e381567f9579344b3f3e59407b48c6844b41d)) +* right sidebar els align to top ([6e37056](https://github.com/athensresearch/athens/commit/6e370568cf398ec1865a383fbd1997c59626e6f6)) +* right sidebar items have proper bg ([d301322](https://github.com/athensresearch/athens/commit/d3013227a9c613a6ca4ab23fa1254c222721b669)) +* right sidebar items shouldn't be squished ([df1bb46](https://github.com/athensresearch/athens/commit/df1bb46f2b8cac524ea45b950ab1ebd05fdc1704)) +* right sidebar should trigger toolbar when scrolled ([c93b16c](https://github.com/athensresearch/athens/commit/c93b16c8040b2ec0bd538a68112c7128afc531b0)) +* Right Sidebar title block-ref support ([3b6f554](https://github.com/athensresearch/athens/commit/3b6f554c1b7cbc094716fb3074ae7716eaebbab8)) +* sidebar bottom section sticky ([ad22047](https://github.com/athensresearch/athens/commit/ad220474eab78b8b5900b43b867a41ab7840256b)) +* smaller sidebar footer icons ([eb0c877](https://github.com/athensresearch/athens/commit/eb0c877f8f4865054f554f3b5901a517360a21e0)) +* task children when clicked don't show up in zommed-in view ([9a3dc5f](https://github.com/athensresearch/athens/commit/9a3dc5f01ee6a230a58ed67b683b39b219de83fe)) +* task menu gets correct default status ([83085d1](https://github.com/athensresearch/athens/commit/83085d1f231993c10a6a5278458a06b48e604881)) +* Tasks Embed used wrong lookup vector. ([111e13d](https://github.com/athensresearch/athens/commit/111e13df8739be2a827b17cdc93dee1a96b1fb4f)) +* title doesn't appear unless main content scrolled down ([d27eaac](https://github.com/athensresearch/athens/commit/d27eaac0cef13d91865ee1d2f635d0e1951316e1)) +* toggle appears only on interaction ([3df5bad](https://github.com/athensresearch/athens/commit/3df5bad30278b16b1890d3d43bd23d07f0dc546d)) +* transcluded tasks dont break ([a7c6750](https://github.com/athensresearch/athens/commit/a7c6750ff4a55d26bb6d4e21cd5643d54a364b03)) +* turn properties/update-in to graph/update-in ([8e74817](https://github.com/athensresearch/athens/commit/8e74817dfce8fcff7dccafc40bd1ef9b73d882f9)) +* vercel builds failing ([d64daa8](https://github.com/athensresearch/athens/commit/d64daa80b830bddb542ba91bf07de0d1f3c6a07b)) + + +### Work in Progress + +* basic API ([e9487eb](https://github.com/athensresearch/athens/commit/e9487eb51a6f440b7f4c31f81681605820f8feb6)) + + +### Performance + +* defer updating sidebar width in graph ([bc3155a](https://github.com/athensresearch/athens/commit/bc3155a7fd0177ac30994be172fa3202a20f1de2)) +* Do not load `re-frame-10x` by default ([42287bb](https://github.com/athensresearch/athens/commit/42287bb73d7f5cddb612fe666a1168d35c12a06b)) +* Don't spam `::inline-search.events/close!` ([a086a06](https://github.com/athensresearch/athens/commit/a086a06e9c7db9c6021d6d48fe798766c2aa6fd6)) + + +* add scripts for debugging prod builds ([4de40c6](https://github.com/athensresearch/athens/commit/4de40c692f4d97257f3c95643010ac25d407d06e)) +* carve ([7466804](https://github.com/athensresearch/athens/commit/746680420055158c7b57eaff42beac714d5f46ee)) +* clean up testing code ([ea4aa06](https://github.com/athensresearch/athens/commit/ea4aa0674f5004385dcdfe2d1bd91f818387320e)) +* comment unused notification fns ([afd5002](https://github.com/athensresearch/athens/commit/afd50025fd2a890b5a7a01dad29a30066fb38cb5)) +* disable e2e on ci ([ae85676](https://github.com/athensresearch/athens/commit/ae85676e806997e6a0e9730c7d8cf9794c9664bb)) +* don't auto load re-frame-10x on both builds ([e02abe2](https://github.com/athensresearch/athens/commit/e02abe294648548216c025d4ee73fd2ad084ff51)) +* fix ([f95240e](https://github.com/athensresearch/athens/commit/f95240e825e1baea58aab58d8f6fc3772979a02f)) +* fix ([f3e4369](https://github.com/athensresearch/athens/commit/f3e4369936c832c1ab2f745df8a2dd5d498f2584)) +* fix lint ([b1a13a7](https://github.com/athensresearch/athens/commit/b1a13a7724f7e36b1e8e0e4b2db41fa2e3b509ce)) +* fix lint ([3933dca](https://github.com/athensresearch/athens/commit/3933dca7b3f59347f07e80d6e825961260157c4d)) +* lint ([e7cc0a1](https://github.com/athensresearch/athens/commit/e7cc0a1b912d349a605de36026e461e7a2764212)) +* lint ([4fe5c43](https://github.com/athensresearch/athens/commit/4fe5c43b9c9b7969ac46f16393da6583d3288ba3)) +* lint ([5b0ed47](https://github.com/athensresearch/athens/commit/5b0ed47862b511a6a8ba452ddfbe80b796449558)) +* lint ([b852c97](https://github.com/athensresearch/athens/commit/b852c9714b946cd15adea1609cbd5b71ad9930d4)) +* lint ([c674c6d](https://github.com/athensresearch/athens/commit/c674c6d5d4353c71bbb603b17b16d4ef47a4790f)) +* lint ([3149dc2](https://github.com/athensresearch/athens/commit/3149dc23cda18fcfd61ef158d41f49ed2edf847a)) +* lint ([addb56e](https://github.com/athensresearch/athens/commit/addb56eca231a7d5b3dd0be1f2dff50803aa28f5)) +* lint ([ffe8bf8](https://github.com/athensresearch/athens/commit/ffe8bf8139563d84b74298edb6b15c32e4ce75f4)) +* lint ([f71304f](https://github.com/athensresearch/athens/commit/f71304feadf2d1b5d1027a6f35abc4e57dfe528a)) +* lint ([92de934](https://github.com/athensresearch/athens/commit/92de934a0275ce3e67ed3c0ee76036ecc53772f2)) +* lint ([04d0c94](https://github.com/athensresearch/athens/commit/04d0c949587cd72b96857cb27178bb42799bee6e)) +* lint ([2aedc69](https://github.com/athensresearch/athens/commit/2aedc69286e6bb6d6e7469b80eea65a4e5bf5756)) +* lint ([aedead3](https://github.com/athensresearch/athens/commit/aedead30988d139822117b722bf46062136ee457)) +* lint ([9c07042](https://github.com/athensresearch/athens/commit/9c07042f4835adfe12413092ff41a0064335411f)) +* lint & carve ([84b7797](https://github.com/athensresearch/athens/commit/84b779799f235c29864fd84ec06a7a36d83fd07b)) +* remove commented code ([4a0f28e](https://github.com/athensresearch/athens/commit/4a0f28e06e54e07fc180885f8cbc6a5c95086599)) +* remove console log ([9d39632](https://github.com/athensresearch/athens/commit/9d3963252908522fe9c829c407c904a4b04f83eb)) +* remove console log ([4a95e04](https://github.com/athensresearch/athens/commit/4a95e0402b638cecabcb2dfcf550e3293725f4a3)) +* remove debug logs ([27d1b75](https://github.com/athensresearch/athens/commit/27d1b75db1f5e7878b9295a010ed71c88bbc1605)) +* remove debug style ([b700e3a](https://github.com/athensresearch/athens/commit/b700e3a3c1144eb7807919be8a3bb228c76e4999)) +* remove shared state in open undo test ([840700f](https://github.com/athensresearch/athens/commit/840700f0fba561d9bcff809b1791c5723dc5fbc1)) +* remove storybook ([b1af957](https://github.com/athensresearch/athens/commit/b1af957c97af1bee9f9a7ddf85f9ba29020b7ec8)) +* remove unused comments ([786b096](https://github.com/athensresearch/athens/commit/786b096841a6d6d84f9c50750f61143fc0ec206e)) +* retire old menu ([a6368e1](https://github.com/athensresearch/athens/commit/a6368e137a3f626dce45a5a400472ae5dc6039f5)) +* saving progress ([33c1e55](https://github.com/athensresearch/athens/commit/33c1e55b11ea45ea117de41635ed55a8ab4fce9a)) +* show hints for null fn calls ([16324de](https://github.com/athensresearch/athens/commit/16324de1a018725decaa8d5947651c582cb3f3bf)) +* style fix ([dc0727b](https://github.com/athensresearch/athens/commit/dc0727b1f8ba7b6ad3c5d409b85a8e8782d1cb3a)) +* style, link & carve fixes ([a73b9bd](https://github.com/athensresearch/athens/commit/a73b9bd53d89e8ffc5adaa1599479a2bfa1aaac3)) +* style, lint & carve happy ([d0a1c51](https://github.com/athensresearch/athens/commit/d0a1c51367bfb5a55f71e9d71eae2dfacdb7e47b)) +* transpile class fields out of node_modules ([8f597b6](https://github.com/athensresearch/athens/commit/8f597b6c61b33d027623928467a5c21169fca75a)) +* update chakra ([2c700ba](https://github.com/athensresearch/athens/commit/2c700baa1b11ce7c15c01beb661515cec9c6dfc7)) +* use react 18 ([a6e5edb](https://github.com/athensresearch/athens/commit/a6e5edba24685654a55d7dc8ca971c27584283a6)) + + +### Enhancements + +* checkmark update status ([acfc2ca](https://github.com/athensresearch/athens/commit/acfc2ca7f7ca82cd68b6bf1a8ca5228f220ac0ac)) +* improved task edit ui ([d9bca25](https://github.com/athensresearch/athens/commit/d9bca25fc767987a78253e8e3f6533886f561ddd)) +* nicer style for unrenderable blocks in table ([f78a1d6](https://github.com/athensresearch/athens/commit/f78a1d6c785d33e749412978d2b32ee5755c8820)) +* polish block rows, refactor grid size ([21c00e5](https://github.com/athensresearch/athens/commit/21c00e5e29f4ff5540d6c88b10e410e90d2d2dfe)) +* table can scroll when needed ([2e5e533](https://github.com/athensresearch/athens/commit/2e5e533b2e18634d2023f0a64eea32ada5684297)) +* transition block background ([5b815b3](https://github.com/athensresearch/athens/commit/5b815b3e2d35fd6a166f1892254b65141f29ffe4)) + + +### Refactors + +* :properties/update-in supports first/last children and is named :graph/update-in ([84e9eac](https://github.com/athensresearch/athens/commit/84e9eac03ae7db306413f2044d9797ffbb33e8e3)) +* anchor uses new menu fn ([68fa5fa](https://github.com/athensresearch/athens/commit/68fa5fae66ca4f1fe42cc9e20722a67b64491b93)) +* api based on path read/write ([cc246e2](https://github.com/athensresearch/athens/commit/cc246e25304546eeb238269dc515634824d3c898)) +* block and anchor use same menu, plus support for debug menu content ([d2a10f2](https://github.com/athensresearch/athens/commit/d2a10f2e1865f4dade35d82fc5ca1ce6e6f53fb3)) +* block container uses new contextmenu ([2958339](https://github.com/athensresearch/athens/commit/29583398c1ebaeef9c2e856298d64b412052d64c)) +* block menu comes from block core ([f583022](https://github.com/athensresearch/athens/commit/f5830225b514ffae82528d534e03046fbc8bb313)) +* block mouse interaction stuff moved out of container el ([dbc779c](https://github.com/athensresearch/athens/commit/dbc779cccf4c72e9413699d61ca3e3eeb6f2775c)) +* clean up anchor and toggle ([96cc151](https://github.com/athensresearch/athens/commit/96cc151ed840f442d566a9e3c50b381bf6d7a520)) +* cleaner block container impl ([ef9a01b](https://github.com/athensresearch/athens/commit/ef9a01b1a27307948006202e9337497431c8130a)) +* cleaner context menu prop api ([312673c](https://github.com/athensresearch/athens/commit/312673c07ebcd36ead797c5dc9fe181634065a07)) +* cleanpup ([86b2215](https://github.com/athensresearch/athens/commit/86b2215a2e5adbbb0f81f9b5c2c382d6704a2ba6)) +* clearer name for menu fns ([cf07022](https://github.com/athensresearch/athens/commit/cf0702279d4ab2d5c986c065f2e4c56152a9ee47)) +* comments use new context menu ([a132e2f](https://github.com/athensresearch/athens/commit/a132e2f70a88a0a6db652d2d579fc42c69d4d061)) +* handlers not events ([5d338be](https://github.com/athensresearch/athens/commit/5d338be6ecc4623e12c3c73bad6ce84fa4ca8a4b)) +* Introduced `tasks.view` ns ([7db9a02](https://github.com/athensresearch/athens/commit/7db9a029dc64128827831a16bcafc54a477f8883)) +* misc cleanup ([cb362a4](https://github.com/athensresearch/athens/commit/cb362a4279d9630101c282ef426e6d93cd611e1a)) +* move context menu into its own space ([7264152](https://github.com/athensresearch/athens/commit/7264152221ab51093b0faff36152fb73fff7967c)) +* move roam import into own ns ([6015481](https://github.com/athensresearch/athens/commit/60154813847f59644114b431e1f39c809b77e449)) +* nicer comments, anchors have menu ([d4b4906](https://github.com/athensresearch/athens/commit/d4b49067bb94ed088a0557bd3e93e3e462ff279f)) +* put presence and ref count in page gutter ([4fd96d1](https://github.com/athensresearch/athens/commit/4fd96d1b1e912fab9e1c30360f6fe2cc2b56c539)) +* remove errorboundary from content, leave it up to container ([51266f4](https://github.com/athensresearch/athens/commit/51266f41a8d288ca8b7c027afcb90418ac093a77)) +* remove extra dom elements ([fb356b2](https://github.com/athensresearch/athens/commit/fb356b20c85a998506fdb76fbcf8db53f30b3fba)) +* remove extra wrappers ([55b7272](https://github.com/athensresearch/athens/commit/55b72720e1a20b31137b5c4155262f659493985e)) +* remove redundant position op ([5c19765](https://github.com/athensresearch/athens/commit/5c19765f5ce632f6755827d8b7b1472ee14bb408)) +* remove unused code ([b9bbef6](https://github.com/athensresearch/athens/commit/b9bbef6fb8577b9cf303d7f835f747530b0b1df5)) +* rename 'database' to 'workspace' on front end ([ad12b6b](https://github.com/athensresearch/athens/commit/ad12b6bfa9921a931584945166636648ec0e09cd)) +* rename inline task title fn ([8a8c76f](https://github.com/athensresearch/athens/commit/8a8c76fb01fb8088b4e7fb675b971e71e0f40495)) +* shortcuts list uses same widget component ([d8ae781](https://github.com/athensresearch/athens/commit/d8ae7811e8421cd36e20e56a3432c2bcb343919f)) +* tighter task outline view ([006c3b0](https://github.com/athensresearch/athens/commit/006c3b0bc654228461c12798a5f4a589e5f9b29e)) +* use atomic events for dnd-image ([3a976b0](https://github.com/athensresearch/athens/commit/3a976b0f0e34e31a31344272dcbed0c052975eff)) +* use existing binding ([0d5ab5c](https://github.com/athensresearch/athens/commit/0d5ab5c87e3dbd109a1eddabd20f99eae7b16204)) +* use new empty component for sidebar ([d168416](https://github.com/athensresearch/athens/commit/d168416313280b21d782302135bc299a84b18d72)) +* use new empty component for sidebar ([e94c2d0](https://github.com/athensresearch/athens/commit/e94c2d079c3d581331296d7dcb8611b0eb99bebe)) +* use same component for task block and ref ([f31fce5](https://github.com/athensresearch/athens/commit/f31fce527fd11ff5355db75cc54e69743b4e059a)) +* use same strategy for both toolbar showers ([e203b13](https://github.com/athensresearch/athens/commit/e203b1321118513d308f576afd7d2917bdcc4439)) + +## [2.1.0-beta.3](https://github.com/athensresearch/athens/compare/v2.1.0-beta.2...v2.1.0-beta.3) (2022-08-22) + + +### Features + +* add notifications popover ([0f086a7](https://github.com/athensresearch/athens/commit/0f086a78f1a09faf5a36ba216d5ddc3150e5ecf6)) +* add overline to distinguish daily notes ([2314be0](https://github.com/athensresearch/athens/commit/2314be02d4df94d280ed69b5897108ec44e6cb74)) +* Athens Task Assignee support. ([0894819](https://github.com/athensresearch/athens/commit/0894819d4113a220a7104eddd5ffe9e4287747bf)) +* Athens Task Priority. ([2ba1973](https://github.com/athensresearch/athens/commit/2ba197393505adfcd70cd360c9c15b9a36f1b742)) +* basic implementation of updated layout ([bf6f8d4](https://github.com/athensresearch/athens/commit/bf6f8d4ca9d658f23f5a66403200e5732e5eeba6)) +* basic updated reaction ([bf81c09](https://github.com/athensresearch/athens/commit/bf81c09d832b7409b5dd9cc28d1878e5960d87db)) +* better shorcuts states ([e9fbd99](https://github.com/athensresearch/athens/commit/e9fbd9901cd65978f29edce89e952712d0d042d0)) +* better shorcuts states ([0ac50aa](https://github.com/athensresearch/athens/commit/0ac50aaf201464696beb3f49ed5fa5a82a4c78f6)) +* bootstrapping `:task/status` page ([62c3a99](https://github.com/athensresearch/athens/commit/62c3a99fd295ea6504f533c070b83419f8d227e0)) +* comments have context menu ([c33dcd4](https://github.com/athensresearch/athens/commit/c33dcd472a3986f846fef88e968f537a16a871ad)) +* comments toast reuses same toast ([1934bda](https://github.com/athensresearch/athens/commit/1934bdad93bc46ee171b2a1b3c14abade651dc6d)) +* different reactiosn ([53b4dc2](https://github.com/athensresearch/athens/commit/53b4dc2e15fa785da6e9c13bf3adc5d063a48ad0)) +* edit Task status ([80639d4](https://github.com/athensresearch/athens/commit/80639d40a286538543386d403cc6ec3d92b3f6c1)) +* editing title via editor ([cfd91a0](https://github.com/athensresearch/athens/commit/cfd91a009b7a01b37d5123e93c089bb2f92aa463)) +* hide properties auto-complete and property blocks if feature ([7399334](https://github.com/athensresearch/athens/commit/7399334ee392ec8ec9891b0014d6206c013d288a)) +* in progress blocking in notifications ([9bcf87b](https://github.com/athensresearch/athens/commit/9bcf87b7f1001a202c44a0c91cef37c3fa4d2a75)) +* in progress blocking in notifications ([12e4933](https://github.com/athensresearch/athens/commit/12e49331bc31e4cdc510ffaf4756a895d8947e1f)) +* inbox page ([e3b25fb](https://github.com/athensresearch/athens/commit/e3b25fb00f01f01151bc80d8ab1b6849e9772827)) +* popover apears, added message body ([4eb68ef](https://github.com/athensresearch/athens/commit/4eb68ef5f5c720eb141854df38d8a91081bfb9ef)) +* reactive renderer use, and gated on feature flags ([0b06711](https://github.com/athensresearch/athens/commit/0b06711915dd9f4cc3cb2f121260d1874af6e517)) +* reactive title ([7808239](https://github.com/athensresearch/athens/commit/780823979bbf35a7491c9aa7d8f51b2f67b46908)) +* reactive-get-entity-type ([81cc3e9](https://github.com/athensresearch/athens/commit/81cc3e981edecfdb49af682225d29f5db5b324d4)) +* real block type discovery ([e228ae4](https://github.com/athensresearch/athens/commit/e228ae4d415c693dd126cb6b17ef16b1ae946ea0)) +* saving progress ([d62d374](https://github.com/athensresearch/athens/commit/d62d374ac86840f8c76998dac5020b186c891bca)) +* support creating pages in :properties/update-in ([290e48c](https://github.com/athensresearch/athens/commit/290e48cc2ede74373d6b78e9cf4eb31ff1dd0bcb)) +* Support editing of Task Title when there is no title block prop. ([5b80653](https://github.com/athensresearch/athens/commit/5b80653ed9411c5e5ad773815cee13d5b80fc381)) +* Support presence on Task Title editor. ([2b9b580](https://github.com/athensresearch/athens/commit/2b9b58038aa901ebe6b64bac60e0de95fd568f3b)) +* Task statuses configured on a graph. ([a294605](https://github.com/athensresearch/athens/commit/a294605e6b78dca80b56a71157f6ca2153fb5fa6)) +* try info text for notification badge ([2bcfa72](https://github.com/athensresearch/athens/commit/2bcfa7277a1a99aa2598a048d31f24b39ba250e7)) + + +### Bug Fixes + +* -rule to Rule in tsx ([fe5ae24](https://github.com/athensresearch/athens/commit/fe5ae24208b099b2b4ec6ed0e19de108098ad83b)) +* add missing icon ([facdae4](https://github.com/athensresearch/athens/commit/facdae488f80c175195611a905a0896eb070c0cf)) +* all reaction items are buttons ([8c13b4b](https://github.com/athensresearch/athens/commit/8c13b4b7292b15ac526d4a7b975f2fd87ba1ade6)) +* allow scrolling shortcuts ([9bcb6d8](https://github.com/athensresearch/athens/commit/9bcb6d80247f364f4b2c7369dddd6d5d96d77e58)) +* always show open in main for daily ([f2c7553](https://github.com/athensresearch/athens/commit/f2c7553b33b60f7bed639cf73ecd3ff3cd4e7dcd)) +* autocomplete menu works again ([848dd16](https://github.com/athensresearch/athens/commit/848dd16be316500ecfb50f3dddd611c182618e41)) +* autocomplete menus work again ([153bc64](https://github.com/athensresearch/athens/commit/153bc64fc59dcb811d30a058da6d3597fb9da340)) +* autocomplete should be autoComplete ([50ce9ed](https://github.com/athensresearch/athens/commit/50ce9eda135f0720a82deddff8808d1d3adaa88f)) +* better daily notes fn ([79a07ec](https://github.com/athensresearch/athens/commit/79a07ecca2ff068b91019346613b33b896908cdd)) +* better formatting ([2283663](https://github.com/athensresearch/athens/commit/22836639eaa66a402691a7cc40885bc7f0defb69)) +* better icon sizing in comments ([585b9f1](https://github.com/athensresearch/athens/commit/585b9f1f91c8ce28ac5f863655b099993c3fc8c0)) +* better layout, scrolling ([831c5fd](https://github.com/athensresearch/athens/commit/831c5fd18b60ab01ec9bd5fcec48eebbddb16f72)) +* block toggle shouldn't be draggable ([21385e5](https://github.com/athensresearch/athens/commit/21385e5afa902b8e96b1aec621acf8f97fc1b66f)) +* block-el should prioritize the reactive props ([172029a](https://github.com/athensresearch/athens/commit/172029ac3fc0cf13e95777bf8afecf05a4395f1e)) +* brighter notification badge ([0bea69d](https://github.com/athensresearch/athens/commit/0bea69dc63568381435a4b427ab2602629275b59)) +* Bring mouse selection back. ([b8ead5c](https://github.com/athensresearch/athens/commit/b8ead5c839c5dbbb84abf09fb824704f1ab08a73)) +* can click other people's reactions ([03532ec](https://github.com/athensresearch/athens/commit/03532ec34e0035dc12286bcace89f9e78f03f3f9)) +* can resize right sidebar ([fe30ee6](https://github.com/athensresearch/athens/commit/fe30ee61c3c9abecf5bfc75e79cf920ea475be3b)) +* cannot move bullet under own children ([7301f96](https://github.com/athensresearch/athens/commit/7301f96784c2e91a4182a88ad899db05b30e648c)) +* clean up page buttons ([07d8da4](https://github.com/athensresearch/athens/commit/07d8da40d371dff7d126b32b95c39820bc1ad7cd)) +* correct icon attr casing ([092c6af](https://github.com/athensresearch/athens/commit/092c6af1748e855dc83c5bbc14b20b5aa72ce38f)) +* correct prop name for sidebar width ([68e9b9f](https://github.com/athensresearch/athens/commit/68e9b9f77ab68121bbb255e99d5f98b1fce73068)) +* correct transition for toolbar underlay ([01f7093](https://github.com/athensresearch/athens/commit/01f70939491529057955953ff03356f933e62b86)) +* correct transition for toolbar underlay ([801455c](https://github.com/athensresearch/athens/commit/801455c4ade527953f716f4ca366812930c1d4d8)) +* dom events ([abe6997](https://github.com/athensresearch/athens/commit/abe6997a115b7c19062b24acda357963aa6a7afe)) +* don't allow removing in-mem db ([b441eab](https://github.com/athensresearch/athens/commit/b441eab19f55ccd43a1be60fd22faab0fcc91a18)) +* don't clip daily notes ([a524d2d](https://github.com/athensresearch/athens/commit/a524d2d68aa143d5b78a8abacf1e86d02f3a8dfd)) +* don't mount autocomplete els too often ([6b62ab4](https://github.com/athensresearch/athens/commit/6b62ab485e033e06ba92711af4dd4658c17a48d0)) +* drag and drop properties ([4cb96cf](https://github.com/athensresearch/athens/commit/4cb96cf7a41a8b3e60aae49cc868c1f0ed17a707)) +* drop after/before prop moves block to first ([3d2b1ba](https://github.com/athensresearch/athens/commit/3d2b1ba6e45f3fbf5ee9908cd11f2c2012823028)) +* Enter behavior in tasks ([51322d7](https://github.com/athensresearch/athens/commit/51322d75d930f38c391bc4f0bf3d292eaa09ff97)) +* enter on prop should split block ([5210a4d](https://github.com/athensresearch/athens/commit/5210a4d2c57b8afb78010c66e0a385d1b8094732)) +* faster daily notes ([00c5ac2](https://github.com/athensresearch/athens/commit/00c5ac29419bdd37be520990672123a4de8cd432)) +* gate emoji picker element on reactions feature flag ([3a31fb8](https://github.com/athensresearch/athens/commit/3a31fb8f78e70e1b4f824d6da0afa6b68e0c4169)) +* hide debug cursor ([fed68c2](https://github.com/athensresearch/athens/commit/fed68c28ae2f9108042a9fb1cc0c27b255f97220)) +* if multiple blocks selected, selection doesn't clear on context menuclick ([773dac7](https://github.com/athensresearch/athens/commit/773dac7ac5ef1bded55e8b6da5d54db0ba9dca33)) +* import textarea pos correctly ([3a8171a](https://github.com/athensresearch/athens/commit/3a8171a5f7c98ed6a87fbcf9027cae4395d98343)) +* include icon ([ac2eaef](https://github.com/athensresearch/athens/commit/ac2eaef8ff03aba9c5d94f91a090ccb25c6f3b36)) +* include rest props ([4740646](https://github.com/athensresearch/athens/commit/4740646730084013586c2bb6b71664aa462510c3)) +* layout fixes ([6daf077](https://github.com/athensresearch/athens/commit/6daf07744bd64d03bf32c71e8963508b1d2f94c0)) +* minor fixes ([e2de0ef](https://github.com/athensresearch/athens/commit/e2de0ef76e378f80c937fa03ac03016e7023e302)) +* misc issues ([5a75a0e](https://github.com/athensresearch/athens/commit/5a75a0ede0c638484cba6234a95d9112f746bcf5)) +* misc issues ([0201521](https://github.com/athensresearch/athens/commit/02015214088f203d6c91c62b05d53fc47c883be2)) +* misc toolbar cleanup ([54310a4](https://github.com/athensresearch/athens/commit/54310a4aa121bd5d9731b27ccc22a8c2674018c2)) +* misc ui fixes ([c016278](https://github.com/athensresearch/athens/commit/c01627819cc3a85ed4fdf4d2c7adf0193b8e1e67)) +* more predictable daily scrolling behavior ([d379df8](https://github.com/athensresearch/athens/commit/d379df8b4b7d36e05da63e4979648f91ee7dd770)) +* no error from tooltip in menu ([ea38f5b](https://github.com/athensresearch/athens/commit/ea38f5b1cc02069ca6690d26d994f3c618fd04d6)) +* no focus fighting on db modal ([55b6481](https://github.com/athensresearch/athens/commit/55b64819f146e583ed71d37ec72b3589813e8c11)) +* notification for block author and don't double-add user when they comment on their own block ([b3f3c93](https://github.com/athensresearch/athens/commit/b3f3c93732d8c9c7b8ba2207c61f90e23171ce67)) +* onArchive, stopPropagation ([ebdd539](https://github.com/athensresearch/athens/commit/ebdd5393031b383b28488279df9cbc7f842a941a)) +* pages should stretch-sorry ([2b0fb35](https://github.com/athensresearch/athens/commit/2b0fb35ff704216079275fe5945b747e3ab89199)) +* pass string to block/move ([d46b175](https://github.com/athensresearch/athens/commit/d46b175b35c4aca5d733b344f51866fe370870e1)) +* props can also be dragged by name ([b098bcc](https://github.com/athensresearch/athens/commit/b098bcccccf19193a732634a613800c3d548d00d)) +* remove broken icon ([70b3829](https://github.com/athensresearch/athens/commit/70b3829ef1218531eff3fda01f3370ceef4e115b)) +* remove broken icon ([4e2d250](https://github.com/athensresearch/athens/commit/4e2d250f256d527fabe7c75bbff05c29e9f1a301)) +* remove console log ([c2e701c](https://github.com/athensresearch/athens/commit/c2e701c2c355021a642108f6b05de01b035a37be)) +* remove console log ([95eaa13](https://github.com/athensresearch/athens/commit/95eaa13444bbd481a2a729db9fe980033ee6d9ab)) +* remove separation between context menu and click location ([5487840](https://github.com/athensresearch/athens/commit/54878402eea7c924c6d5a99acacf0d9984f92c32)) +* resuable toast should be reusable ([b4bd9a6](https://github.com/athensresearch/athens/commit/b4bd9a68d8465baebdc517bb803952d4f23a830e)) +* revert unintended changes ([bb8e5c0](https://github.com/athensresearch/athens/commit/bb8e5c043d2e398425644cefdc02f678f177b5a4)) +* right-sidebar graph and page views ([15a226b](https://github.com/athensresearch/athens/commit/15a226b141e74618e4f17ec8c812615d8431c7d5)) +* shortcut names don't overflow ([1d44670](https://github.com/athensresearch/athens/commit/1d44670afaeeb6f70fcea58cf0fa7087c218d792)) +* simplify reaction style ([93da313](https://github.com/athensresearch/athens/commit/93da313316e62d6a5f4cdcc76ac1202a3f3a49f0)) +* solve theme issue with buttons ([07fa188](https://github.com/athensresearch/athens/commit/07fa1888157a3f341e202ab010f31b392dc66889)) +* solve theme issue with buttons ([66fd75a](https://github.com/athensresearch/athens/commit/66fd75a861009c884e26928ad623e6955ddcdf33)) +* solve theme issue with buttons ([cbd8534](https://github.com/athensresearch/athens/commit/cbd85343fcbf62cce343823e138df9ad470d231b)) +* solve theme issue with buttons ([9daac29](https://github.com/athensresearch/athens/commit/9daac29b2339b1398eda4edd1a031e08f5a18a98)) +* working daily notes ([cd79554](https://github.com/athensresearch/athens/commit/cd795543e2186dec520398ead40586ff0708ba85)) +* working main nav ([21ddeed](https://github.com/athensresearch/athens/commit/21ddeedc61361563aadde52aa28ede092fd9e57b)) + + +### Performance + +* blocks out of view don't do as much ([9809ee8](https://github.com/athensresearch/athens/commit/9809ee8f82068a6706820dd6d1feb5893fe1b0ae)) + + +* **deps-dev:** bump karma from 6.3.3 to 6.3.16 ([400bb79](https://github.com/athensresearch/athens/commit/400bb794ee3cdcd612f62edc6470835e89b3a828)) +* **deps:** bump ejs from 3.1.6 to 3.1.7 ([c926516](https://github.com/athensresearch/athens/commit/c92651632bde6a702b777e5d0e66265daaad5bb1)) +* **deps:** bump follow-redirects from 1.14.7 to 1.14.8 ([433319d](https://github.com/athensresearch/athens/commit/433319dadf8e36fb3c8279d60f0ba449f50262b3)) +* **deps:** bump jpeg-js from 0.4.3 to 0.4.4 ([283a583](https://github.com/athensresearch/athens/commit/283a58319a1d5c26b990e1e6f48636c813970c1b)) +* **deps:** bump minimist from 1.2.5 to 1.2.6 ([56b7dd4](https://github.com/athensresearch/athens/commit/56b7dd46011cc41fc4ac9e94d63bd0c031ca0b0e)) +* **deps:** bump plist from 3.0.2 to 3.0.5 ([4059c16](https://github.com/athensresearch/athens/commit/4059c1617e0d7963e0f8c2df64cbd741634f08a8)) +* **deps:** bump terser from 4.8.0 to 4.8.1 ([dbcc1dd](https://github.com/athensresearch/athens/commit/dbcc1dd252043537be8446cc1109bed3302391e9)) +* fix ([5da1512](https://github.com/athensresearch/athens/commit/5da1512e654774039f4eca678fadd5252b81e2e8)) +* fix lint ([bce03e6](https://github.com/athensresearch/athens/commit/bce03e656f2b0eb013ca6ad014719c3d62806658)) +* fix lint ([76a0a23](https://github.com/athensresearch/athens/commit/76a0a23eddfd4ee576b43e689fbfdbdd5c55c91d)) +* fix lint ([2deb9f0](https://github.com/athensresearch/athens/commit/2deb9f0213fd5c20c28fe1a34b1f16992e8addbb)) +* fix linting ([2866a45](https://github.com/athensresearch/athens/commit/2866a4598e626904a6e7bdde1432d1121734e071)) +* fix linting ([79aa03c](https://github.com/athensresearch/athens/commit/79aa03cb53bc78a187187cd97b1e9141ac39c02c)) +* lint ([029ecb9](https://github.com/athensresearch/athens/commit/029ecb92396b1e03e774cf6af54f71313244cba3)) +* lint ([f635cea](https://github.com/athensresearch/athens/commit/f635cea302cd1db5420eab708041eafeb9346384)) +* lint ([592a857](https://github.com/athensresearch/athens/commit/592a8572f3049e743dfe543b64e5d7b60c6b3ebc)) +* lint ([226ddc4](https://github.com/athensresearch/athens/commit/226ddc4b100b086e4c9934062c23c68b4fc311ec)) +* lint ([ee9d35b](https://github.com/athensresearch/athens/commit/ee9d35be596bebc05345ee10570ca75429e68398)) +* lint ([e17578b](https://github.com/athensresearch/athens/commit/e17578bb3594f71a819967f2a1a3b4054fab0bb9)) +* linting ([3f49135](https://github.com/athensresearch/athens/commit/3f4913500453136bcc20fb2f2e618bceb95c051f)) +* re-enable e2e job ([c80371c](https://github.com/athensresearch/athens/commit/c80371ce221a5f4745868ff996630023aa219b04)) +* remove unused ([ddc5673](https://github.com/athensresearch/athens/commit/ddc567317c032f60be6d1e50d1d9649e7268f969)) +* remove unused ([3c2d10b](https://github.com/athensresearch/athens/commit/3c2d10b6368bcd9ec886453209c043bafa9d6c31)) +* saving progress ([3f4593d](https://github.com/athensresearch/athens/commit/3f4593d3aa4380f1f25f60518bac6d0b0400a045)) +* saving progress ([64760c4](https://github.com/athensresearch/athens/commit/64760c4b7f17cafc7b0821c50f35c709b2bb6098)) +* saving progress ([9707463](https://github.com/athensresearch/athens/commit/9707463745a904ef9e783fcdda0707d66f8a4442)) +* saving progress ([33fcf55](https://github.com/athensresearch/athens/commit/33fcf551207a64efbe6269d6640376ee2254c00d)) +* type interface for rightsidebarresizecontrol ([c56cb94](https://github.com/athensresearch/athens/commit/c56cb94c718c18ad1d67739beb978aa6f26aaaad)) +* update e2e ([b24c205](https://github.com/athensresearch/athens/commit/b24c205f794524d91e6ee93f214cc557a7d0b2aa)) + + +### Enhancements + +* quality of life fixes for headings in blocks and block refs ([ae06dad](https://github.com/athensresearch/athens/commit/ae06dad0a4e962a410ee20e175e90ebc4d05189c)) +* **right-sidebar:** make width % based; persist width to graph ([eb6d1b4](https://github.com/athensresearch/athens/commit/eb6d1b43516f1fc3c7cdaccc94184fc17c3b1717)) +* use settings as a modal, not as a page ([64b29f2](https://github.com/athensresearch/athens/commit/64b29f29906136bc0fc1f453a924bf2e8172e6f6)) + + +### Refactors + +* `block/content` -> `block/editor` ([8409d47](https://github.com/athensresearch/athens/commit/8409d47c036a22844c073b5786e4db0ee75f0813)) +* `BlockTypeProtocol` taking care only of `content` ([2cd2934](https://github.com/athensresearch/athens/commit/2cd293451b4e282c5b75ce7f6afd04ebac202259)) +* auto-add missing :block/uid to internal representation ([71640c9](https://github.com/athensresearch/athens/commit/71640c9a4758912a157c3abe84799e1843e1f9f5)) +* Batched property value updates. ([25e1aa5](https://github.com/athensresearch/athens/commit/25e1aa578c5267a3f681cf1ed21360556bde8683)) +* Better `select` implementation for `:task/status`. ([2195c6f](https://github.com/athensresearch/athens/commit/2195c6f666f114c9a7eb23c5e87fb4844aefc37a)) +* current-user is You when not remote ([fefed0e](https://github.com/athensresearch/athens/commit/fefed0ed125d68a0e9b0dc3f370572b3c184fa6a)) +* dndkit for sidebar shortcuts ([#2251](https://github.com/athensresearch/athens/issues/2251)) ([8d8871f](https://github.com/athensresearch/athens/commit/8d8871f5b3df107e6497125fe6c577d846525c35)) +* Field titles and optional require. ([42547be](https://github.com/athensresearch/athens/commit/42547be1b3af03b625d7f3b90829061cd8665c85)) +* init task status via :properties/update-in ([2b1db21](https://github.com/athensresearch/athens/commit/2b1db21774d76e53d5faf738cbac5c2638a65f34)) +* look up task uids directly in props ([a10aa11](https://github.com/athensresearch/athens/commit/a10aa11190912798578798708e06d9cf0335b055)) +* minor prop cleanup ([81af9a3](https://github.com/athensresearch/athens/commit/81af9a328407d877c52e73efe146e254bf6f7a75)) +* more cleanup ([fe8eba4](https://github.com/athensresearch/athens/commit/fe8eba43213551a4c43d503423de5b803bb0d14f)) +* move new fn to utils ([d3d5dd2](https://github.com/athensresearch/athens/commit/d3d5dd21cbdc7f9465927dd732b4ee19eacae788)) +* moved `linked-ref` & `inline-refs` to `block/core` ([db4ed5e](https://github.com/athensresearch/athens/commit/db4ed5e0964634a84446585c29c6b116e23e24fe)) +* only list-no sort filter group ([14c2d03](https://github.com/athensresearch/athens/commit/14c2d03ac9d10e884fa63d19b7b305f4e38879d9)) +* optimize search-in-block-content for comments ([147a97b](https://github.com/athensresearch/athens/commit/147a97bb4e6e549cc53237b9915413c47b8d7a76)) +* Really use `athens.types` ns ([1f3a046](https://github.com/athensresearch/athens/commit/1f3a046693406cb4220cca5d22093fbbc897393f)) +* remove debug code ([e249b6e](https://github.com/athensresearch/athens/commit/e249b6e573c063319a55493763728c16d3472436)) +* remove debug message ([baa0377](https://github.com/athensresearch/athens/commit/baa037733e59f257b016cb0cfed7f2730bacda04)) +* remove debug messages ([480f491](https://github.com/athensresearch/athens/commit/480f4911846d75a136629eb29b32e42ced9837cd)) +* remove unused history listener ([276b86b](https://github.com/athensresearch/athens/commit/276b86b233a47f5f511032097adb36c98f3532b8)) +* review items ([e621b1f](https://github.com/athensresearch/athens/commit/e621b1f11c2ed2f80cd0cb0e348d07ea7aa02fd6)) +* specify highlight color differently to fix inconsistancy ([d18f2d1](https://github.com/athensresearch/athens/commit/d18f2d11767e5ce4e0b17726dcab86a7a0b2c104)) +* unify css transitions for smoother theme switch ([ff96b34](https://github.com/athensresearch/athens/commit/ff96b34decd9ba322fe5c777ff369f2aa5204349)) +* use :entity/type instead of :block/type ([73994c0](https://github.com/athensresearch/athens/commit/73994c08d0c6dc8c0592260086d9b757c4b291c4)) +* use :properties/update-in in tasks ([b67f96f](https://github.com/athensresearch/athens/commit/b67f96f3361f5aa31a85ef687fa0a4e16ea3ce47)) +* use reframe not context ([70ad733](https://github.com/athensresearch/athens/commit/70ad73338c1f6bba335aac9cfe3612dbed0d0c9e)) +* use reframe not context ([fde0ae8](https://github.com/athensresearch/athens/commit/fde0ae857922ee0f9e0df17dc6bc2913799928e2)) +* use standard icons for block element anchors ([0c3e1fe](https://github.com/athensresearch/athens/commit/0c3e1fe3be525791eb27c6067fdf68702046ba7b)) + +## [2.1.0-beta.2](https://github.com/athensresearch/athens/compare/v2.1.0-beta.1...v2.1.0-beta.2) (2022-07-15) + + +### Features + +* :time/edits supports multiple edits ([e79c003](https://github.com/athensresearch/athens/commit/e79c00385e6a78e859572b2435471725b4b55d1e)) +* `block-ref` rendering via protocol ([203f0b6](https://github.com/athensresearch/athens/commit/203f0b67be4a9658d2ff9381e0a595b51a0da656)) +* add :properties/update-in event ([45cfaf4](https://github.com/athensresearch/athens/commit/45cfaf4fd307ba179f8888d6e42a18f622a135bd)) +* add order/get ([b66bb78](https://github.com/athensresearch/athens/commit/b66bb78bc934ed0c87ec3290d1a2896805608005)) +* allow creation of new prop on lookup ([8d38bd5](https://github.com/athensresearch/athens/commit/8d38bd57a5126f79cd0203dc8b1f2821927e82a3)) +* backspace at the start of a property will move it to first child ([8871058](https://github.com/athensresearch/athens/commit/88710583ad623f2613ef42d1aaeb4a96d5dc5351)) +* Block embeds ported to `BlockTypeProtocol` ([5972d67](https://github.com/athensresearch/athens/commit/5972d6761367bddd7f7c212cc1478b286dc20f91)) +* block in reactions components ([83fa29d](https://github.com/athensresearch/athens/commit/83fa29db9af3a32f8f3f19f25c50ce00e3d1d25e)) +* block properties ([8393bdb](https://github.com/athensresearch/athens/commit/8393bdbed0cd11f6401baf7f69eb40eceab8553f)) +* Communicate supported transclusions and breadcrumbs ([77d8723](https://github.com/athensresearch/athens/commit/77d87232d8b82168f2e0c9f831c0cb91fe31ad47)) +* create properties via :: ([0ae7683](https://github.com/athensresearch/athens/commit/0ae7683e0c3068822ab6d95d36ffceeec8ccc372)) +* handle enter on props ([3d1fd99](https://github.com/athensresearch/athens/commit/3d1fd99d6a7ac0682e8f95831835cfd2f36210de)) +* handle up/down for properties ([eec6161](https://github.com/athensresearch/athens/commit/eec616112eee7edf59274f96cf382c75b5c53355)) +* hide comment functionality behind feature flag ([39dc2be](https://github.com/athensresearch/athens/commit/39dc2be76ab3c1642adb6ee851368d00c2de3980)) +* hide reactions and cover photo behind feature flags ([d441816](https://github.com/athensresearch/athens/commit/d4418165228172f132a324bb3570dd1b2a4a4224)) +* migrate datascript dbs ([b119f33](https://github.com/athensresearch/athens/commit/b119f33415c633828ee01cf19da81ba83733af79)) +* migrate datascript v2 times to v3 times ([5441a04](https://github.com/athensresearch/athens/commit/5441a04fbf7a8997dc3b3673fde324e3a78e9abf)) +* pages and blocks get full properties reactively ([0a810ff](https://github.com/athensresearch/athens/commit/0a810ff04620a0c7d581e5a04450e2fa024acb43)) +* pages have rough header image UX ([e24818e](https://github.com/athensresearch/athens/commit/e24818e9374f2777290cb370afc9b3c6caa554c5)) +* Placeholder for reusable editor. ([0431e98](https://github.com/athensresearch/athens/commit/0431e987d00dcc903fb13109282fc6bea81daa2c)) +* render properties in outliner ([2192272](https://github.com/athensresearch/athens/commit/21922724f87ee694481b0b9e43881a89c8f13ee8)) +* rework anchor functionality ([62c8144](https://github.com/athensresearch/athens/commit/62c8144172a16fd56c623efb252c7a2533ce7b39)) +* saving props. ([2eaf93d](https://github.com/athensresearch/athens/commit/2eaf93d2895ba4e3ca986c315d2129768707cba5)) +* show all blocks edited on a daily note ([2d772d1](https://github.com/athensresearch/athens/commit/2d772d177918a389ed25e8ac662022c47a8cd538)) +* show linked props on page ([c0517e2](https://github.com/athensresearch/athens/commit/c0517e2aa3c9ad2fa9040e12c9d0ba081c05c007)) +* show page name for users on other pages, indicate self user ([c211cae](https://github.com/athensresearch/athens/commit/c211caeed39109291bab78fd90711f320b062ed0)) +* show properties in breadcrumbs ([18d2842](https://github.com/athensresearch/athens/commit/18d284266c708069da236b61582317b43737c63f)) +* support event creation and log time in protocol ([d997730](https://github.com/athensresearch/athens/commit/d9977302faaae6ad86e5c73d042877bb8b86dcf2)) +* support feature flags ([3e64560](https://github.com/athensresearch/athens/commit/3e645601236986c8485e2044e3d028eea0408776)) +* support indent/unindent in properties ([5afc80d](https://github.com/athensresearch/athens/commit/5afc80ddb000703c7981d2554d22d6246ad0effd)) +* support presence-id on events ([3ba9d18](https://github.com/athensresearch/athens/commit/3ba9d18e5d00e82c2e2c43bd735a5c5fad5d9d96)) +* time control under feature flag ([36c9538](https://github.com/athensresearch/athens/commit/36c9538727a90dac9d501ffe9b92df619cf8b1c8)) +* update datascript schema with first-class time ([c7566a8](https://github.com/athensresearch/athens/commit/c7566a89361eda618f0f49acd8487c95c44532b5)) +* use first-class time in atomic resolvers ([770c68c](https://github.com/athensresearch/athens/commit/770c68c10f8cbbc3029e32851527ca63c0d52d18)) +* working reactions from block menu ([b418f9d](https://github.com/athensresearch/athens/commit/b418f9d7479d7fc59bf8d7b12fc21c306902bbd0)) + + +### Bug Fixes + +* :after/:before prop is not a valid location ([364129d](https://github.com/athensresearch/athens/commit/364129d1d9d2ac6159da42a5783c56765855cdfc)) +* add-property-map should recur on children too ([57650a3](https://github.com/athensresearch/athens/commit/57650a395673c474bad493e2563c89cf51d5a50c)) +* block children are reactive, again ([8c2124c](https://github.com/athensresearch/athens/commit/8c2124c16de355298a2a09cfadf459635117973a)) +* block move should save before moving ([d5b6fdc](https://github.com/athensresearch/athens/commit/d5b6fdc9a13c073847d8def2d59ff340e6704af7)) +* children toggle should show/hide props ([05190e3](https://github.com/athensresearch/athens/commit/05190e325f7b2f90017e8d4b5734ff7e53642407)) +* clean up reaction props when removing one ([d083dbd](https://github.com/athensresearch/athens/commit/d083dbd4410b22f0a47a50245d635aab6dc025e3)) +* code style ([c70a68a](https://github.com/athensresearch/athens/commit/c70a68a38a3e0f3091b437f89a0866714da94979)) +* correct reagent args ([ea04181](https://github.com/athensresearch/athens/commit/ea0418100c85a7f4e70b24fa0e5d5c53bba8dfd9)) +* create new props that start with colon ([e74babd](https://github.com/athensresearch/athens/commit/e74babd11dea9e9124d70ddfccd7477df287ac80)) +* don't create empty prop ([c1e653b](https://github.com/athensresearch/athens/commit/c1e653b823ec65a313ccf8bc167cccb77cd44a0e)) +* don't make up time if there's no create time ([3f2155d](https://github.com/athensresearch/athens/commit/3f2155d03e71efa3a33645126ee84d0e5af60797)) +* don't show cover image when disabled ([6b9afa2](https://github.com/athensresearch/athens/commit/6b9afa2c341c9b2e02d4593a419179d83616b364)) +* don't show placeholder block when there's props ([72850aa](https://github.com/athensresearch/athens/commit/72850aab8b5aa2291f39742fcf8ed2851c571289)) +* don't try to create prop if there's an exact match ([075b6ca](https://github.com/athensresearch/athens/commit/075b6ca8bf193ddf876730d3142edff36b21ce86)) +* emoji picker popover el needs key ([9b967d5](https://github.com/athensresearch/athens/commit/9b967d5223d4a47ef8afeedb14e7690e48a9273f)) +* ignore IDB errors from emoji-picker-element ([255ef6c](https://github.com/athensresearch/athens/commit/255ef6c19caf2fc01c4b88d824a24bbab7011557)) +* make keyboard navigation optional ([94da28f](https://github.com/athensresearch/athens/commit/94da28fd1eafb7cca607c31a08c722f90d8cb5a7)) +* new-uids-map should work with props ([61c0f80](https://github.com/athensresearch/athens/commit/61c0f8002e5b1ceb6dd13786dfe5a7167e9084e2)) +* page delete should delete blocks like block delete ([50c0a07](https://github.com/athensresearch/athens/commit/50c0a07a05ee88b9716b464a74db39e27fd103a1)) +* pprint event and explanation on failed resolve ([909d1d8](https://github.com/athensresearch/athens/commit/909d1d87e1b1fc636c0da8484811841390782f6f)) +* property-position should support page-id ([accdc99](https://github.com/athensresearch/athens/commit/accdc99ed5881cbe6390b4f497f1c5f6ccb76354)) +* remove comments debug spam ([c159a3f](https://github.com/athensresearch/athens/commit/c159a3f8e54f05861cc2b19e6420b26dc20ee4ec)) +* remove unused binding ([bc0e57e](https://github.com/athensresearch/athens/commit/bc0e57ea0999e0e3b4c8e22dff6204a9bb2b0b53)) +* remove unused fn ([e139200](https://github.com/athensresearch/athens/commit/e13920046ed748e7522589c0b97f4938dfc97a9f)) +* remove unused require ([8879b43](https://github.com/athensresearch/athens/commit/8879b43dcd6e0f701970929322d4950308635e11)) +* removing a child should remove the order number ([c500a66](https://github.com/athensresearch/athens/commit/c500a66803e214548b4e6fdfce687686a58abe9b)) +* restore cover photo and reactions feature flags ([8b08162](https://github.com/athensresearch/athens/commit/8b08162754f6af0d13ef5b3f3d6cc61c6a23c767)) +* some react errors that keep showing up ([dff269c](https://github.com/athensresearch/athens/commit/dff269c8a82e0a357bab1583d3d64b73b3d86144)) +* support new time format in all pages, etc ([9c8b626](https://github.com/athensresearch/athens/commit/9c8b62667fc550aac8ad28e50e070d1288b1bf1a)) +* test and fix get-block-property-document ([3cd0ef5](https://github.com/athensresearch/athens/commit/3cd0ef56c39aebae68df78b07da7a158ff8e5df0)) +* use children? binding on toggle ([13c8057](https://github.com/athensresearch/athens/commit/13c8057283a07dc17eedf2b50a4c3cc1b55b1f3a)) +* use db/get-block and get-parent should use common-db helpers ([05a29ed](https://github.com/athensresearch/athens/commit/05a29edf078b7324a92adec16dd5661b742c18f6)) +* use time and author from block creation ([84fe37a](https://github.com/athensresearch/athens/commit/84fe37a85cae853bf2f2862b47754d9449b7d5e7)) +* wrap time in event to allow for author lookup ([ddca8ef](https://github.com/athensresearch/athens/commit/ddca8efa3547b096ee2a1ed1f89e09a1f08d75a3)) + + +### Refactors + +* `:caret-position` out of block local `state` ([9708f65](https://github.com/athensresearch/athens/commit/9708f654cb7c62b19a9a7c9c2d7f42b087dc03b4)) +* `:inline-ref/states` replaced with re-frame ([8524c61](https://github.com/athensresearch/athens/commit/8524c61879f5d06a45c2d8558480e9e7d7abc22e)) +* `:inline-refs/open` replaced with re-frame ([89c2d31](https://github.com/athensresearch/athens/commit/89c2d31477c5ff3eeac54803e3e6b67dc6f7157f)) +* `:linked-ref/open` replaced with re-frame ([907786d](https://github.com/athensresearch/athens/commit/907786d75cfff7348c3002f3b5c486aee3696d14)) +* `:search/index` migrated to re-frame ([2a4a863](https://github.com/athensresearch/athens/commit/2a4a863184912d5a2e5a97b901031b18dd939632)) +* `:search/query` replaced with re-frame and unused state removed. ([23befb7](https://github.com/athensresearch/athens/commit/23befb70db793d7fb070356db68d71e84f522d59)) +* `:search/results` moved to re-frame ([5bc2a84](https://github.com/athensresearch/athens/commit/5bc2a846c798a09f7555ed4b060f502017ea9c45)) +* `:search/type` replaced with `re-frame` model ([c6a2e18](https://github.com/athensresearch/athens/commit/c6a2e184e46bf2077cca45cd1a302b66d8bff17b)) +* add position helpers in new ns ([a587aee](https://github.com/athensresearch/athens/commit/a587aee5ebb320c83e6f5a8359104b0c9e78d5aa)) +* add sub for feature flags ([a7b7c6d](https://github.com/athensresearch/athens/commit/a7b7c6d23fc3b70d4a5170d3e576c35932f35372)) +* block local `state` cleanup start ([50cdb1f](https://github.com/athensresearch/athens/commit/50cdb1fb5465edfd301e1e0d3f909c4433a956c8)) +* cleanup `inline-refs` & `linked-refs` ([4595e8a](https://github.com/athensresearch/athens/commit/4595e8a65d32e4960d276c0faa1c9134ff9c57e4)) +* cleanup debugs code ([0c8d213](https://github.com/athensresearch/athens/commit/0c8d213ee2113ed6ca989ad8c91e3b76ce041517)) +* dragging support out of local block state. ([9d75481](https://github.com/athensresearch/athens/commit/9d754817591b71f798982c77239038561ce59381)) +* keyboard interactions in editor w/o block on a graph. ([ce6d38c](https://github.com/athensresearch/athens/commit/ce6d38c26e451e81499f62fcab4c5818fa1953e7)) +* localized `last-event` state ([7aa0a58](https://github.com/athensresearch/athens/commit/7aa0a58c2c4ed52e82219289e02514411ead2b68)) +* log level and binding name ([ec7e24a](https://github.com/athensresearch/athens/commit/ec7e24ac3c510639186b715de747521ad88b50c7)) +* make `:block/uid` fn arg, don't need `state` for it. ([d8b6daf](https://github.com/athensresearch/athens/commit/d8b6dafd695c27cf6c027553f45adb2484f211ca)) +* make migration runner generic and cljc ([829b078](https://github.com/athensresearch/athens/commit/829b0781b1acc3075f0c13a1c328348271eb9dde)) +* move descendants fns to common-db ([b608fb2](https://github.com/athensresearch/athens/commit/b608fb275edbe622d5b2b563d2f28263461c4579)) +* move event-tx resolution into own fn ([7b242b7](https://github.com/athensresearch/athens/commit/7b242b7e32ab5d732c54d482f776929e8c56e82b)) +* move property sorting into own fn ([6a9248f](https://github.com/athensresearch/athens/commit/6a9248f1eb1d3a357df98bc2d17273e0da14b9c3)) +* move reactions up to default renderer ([c22e087](https://github.com/athensresearch/athens/commit/c22e0873f86c88753705975dbe56f10c8fe1a008)) +* paste verbatim by default as configuration option to editor. ([b505129](https://github.com/athensresearch/athens/commit/b5051295f3fd3cdf7a8ded06db21a6d6be70d3a3)) +* remove changes to unrelated files ([5fe4063](https://github.com/athensresearch/athens/commit/5fe4063ebc7ee8db2142dd416fadb93d2c012b93)) +* remove duplicated prev-block-uid code ([10f96b1](https://github.com/athensresearch/athens/commit/10f96b1f45e6edf94501c21b6cd7bfbb4d8a2ac9)) +* remove unused rules ([2482da4](https://github.com/athensresearch/athens/commit/2482da4c993e09cfa38ba7612d9548e959edfbce)) +* removed `:last-keydown` from block local `state`. ([ec0e19a](https://github.com/athensresearch/athens/commit/ec0e19ae3da453ddaa730821204eee4f31870dc0)) +* Removed `:show-editable-dom` from block `state` map. ([c8ea851](https://github.com/athensresearch/athens/commit/c8ea8512b664f317fe48eba047e4ffa32d1caed9)) +* Removed `:string/local` & `:string/idle-fn` from `state`. ([3866e49](https://github.com/athensresearch/athens/commit/3866e49352db605ecace2f626a7792451f79e97e)) +* removed `:string/previous` from block `state` map. ([b9235b1](https://github.com/athensresearch/athens/commit/b9235b139b9db0fc35e109db52bd82e212a65581)) +* removed `embed-id` from `BlockTypeProtocol` ([5fa4a35](https://github.com/athensresearch/athens/commit/5fa4a35744e9db1d621d220aaadd78ab05ac430c)) +* removed unused code ([4729b91](https://github.com/athensresearch/athens/commit/4729b91846473e6163172fdd59863410703e1d5f)) +* resolve-transact! passes event time to resolver ([eed73bf](https://github.com/athensresearch/athens/commit/eed73bfa96c4586fdf193fcd3071e476ed9dc81e)) +* take one of editor component ([a8c0944](https://github.com/athensresearch/athens/commit/a8c0944361e317e895a385e4e1ea33c78447b63d)) +* we have editor with chrome and editor with search & slash. ([8e9ff19](https://github.com/athensresearch/athens/commit/8e9ff197beb66e8783b0a758f02f4986c61e341a)) + + +* add deep remove prop test ([80d555b](https://github.com/athensresearch/athens/commit/80d555be6385f5f55cded20d9baea58a27eeb0c0)) +* bust nodes cache ([aeef8ca](https://github.com/athensresearch/athens/commit/aeef8cafe306f250c74a4d3b929bb30e853b5c9e)) +* disable e2e on CI ([afc9bff](https://github.com/athensresearch/athens/commit/afc9bff70c85dd8df68d08de3ac8db61b36f3b1d)) +* file cleanup ([d7ab5a5](https://github.com/athensresearch/athens/commit/d7ab5a59e1fbb4301d5145f1d464126975a803d6)) +* fix ([9f44475](https://github.com/athensresearch/athens/commit/9f44475aa7da140997c3da6cdf1932e24edad031)) +* fix e2e test, removed modifications done only for testing ([cf9df07](https://github.com/athensresearch/athens/commit/cf9df07533b1f2fc7f291add5931ac5a101dc26d)) +* fix lint errors ([4b1cd2b](https://github.com/athensresearch/athens/commit/4b1cd2b5c1476139bf8d0cfd16a2e9b988d4bc11)) +* get-block-property-document now returns create and edit data ([9f5dbf5](https://github.com/athensresearch/athens/commit/9f5dbf5e2d2120c6173e494f0652ca86bddbb5ce)) +* ignore redef errors ([7c2bce9](https://github.com/athensresearch/athens/commit/7c2bce9d60d6accc723298d5bfde4d9a41c1774d)) +* prerelease comparison should be to a string ([f8a6bf0](https://github.com/athensresearch/athens/commit/f8a6bf06ed12dc4f9b0f5d158669b1c4c9aa68c6)) +* remove unused types ([c5586a7](https://github.com/athensresearch/athens/commit/c5586a7126a53ef9253a876f69cd270b688e17de)) +* run style:fix and carve:interactive ([1dedb60](https://github.com/athensresearch/athens/commit/1dedb60556ff0e51f5312aed73775a3edccb9cab)) +* saving progress ([fd02a1d](https://github.com/athensresearch/athens/commit/fd02a1d5e4897393d1266a61ae355e1ef5a4265b)) +* style:fix ([fcef334](https://github.com/athensresearch/athens/commit/fcef3341154f81dfd287b87c34221ff825ed4ce4)) +* update shadow-cljs and cljs ([00bf824](https://github.com/athensresearch/athens/commit/00bf824465225dc0e1f688f6cdeee56bfe182ce2)) +* update shadow-cljs and highlight.js ([5c66731](https://github.com/athensresearch/athens/commit/5c6673109d8f69562579ba4a08cc8677ce204c21)) +* update to clojurescript 1.11.51 ([5b4073f](https://github.com/athensresearch/athens/commit/5b4073f869d75bd4417eca10cd3547b1b367adbd)) +* use java 17 ([df3be8c](https://github.com/athensresearch/athens/commit/df3be8c66d85d32e7bfb0a00af0f68a2b1ac87de)) + +## [2.1.0-beta.1](https://github.com/athensresearch/athens/compare/v2.0.0-beta.37...v2.1.0-beta.1) (2022-06-16) + + +### Features + +* add context menu hook ([5c0b549](https://github.com/athensresearch/athens/commit/5c0b549e6ec49d7f6548f70c55a0b13b374d9a85)) +* anchor uses new context menu hook ([8c5423e](https://github.com/athensresearch/athens/commit/8c5423effb31d3708ce0bd1173c23001257f4300)) +* context menu also works on block container ([7c16441](https://github.com/athensresearch/athens/commit/7c164417b6e579936784c085bea8e58302fd0c4c)) + + +### Bug Fixes + +* defaultIsOpen state for linked refs ([59fbb60](https://github.com/athensresearch/athens/commit/59fbb6088471eda9f5915cd2729fdfd5c4c2fc03)) + + +### Enhancements + +* if a block open in right sidebar is not in main view, ([0ef8836](https://github.com/athensresearch/athens/commit/0ef8836f57e052b9e1be1c7f2292911e0b0b4290)) + + +### Refactors + +* **block:** use new contextmenu for blocks ([66a9d33](https://github.com/athensresearch/athens/commit/66a9d339b1701853885e403dde6edd0e536f46ca)) + + +### Documentation + +* add versioning ADR ([1c75e5f](https://github.com/athensresearch/athens/commit/1c75e5f53d266597baf409311092567a0710be2a)) + + +* bump to 2.1.0 range ([0b081e8](https://github.com/athensresearch/athens/commit/0b081e89e65c033c0332a2c9a82bb1016494876f)) +* deploy prerelease to beta domain ([f348a99](https://github.com/athensresearch/athens/commit/f348a99498411a09c70148a37885af74159fbbde)) +* docstrings ([ffca46a](https://github.com/athensresearch/athens/commit/ffca46aa42e9ec38a1f7df53b0ff2bc946a132ac)) +* docstrings ([15176d9](https://github.com/athensresearch/athens/commit/15176d93909e0c477873609b5bcf67beff8bdc66)) +* don't build macos in parallel ([6d987e4](https://github.com/athensresearch/athens/commit/6d987e4b7977da3fe81a867b67b55cb0dbc72dab)) +* fix ([492c362](https://github.com/athensresearch/athens/commit/492c362e00e226ea5ea027e5fbb96524c4e7beb3)) +* re-enable auto updates for electron ([ee29f33](https://github.com/athensresearch/athens/commit/ee29f3325f09801acd9b9cae3fb4af3cea6d5d4c)) + +## [2.0.0-beta.37](https://github.com/athensresearch/athens/compare/v2.0.0-beta.36...v2.0.0-beta.37) (2022-05-27) + + +### Features + +* remove safari unsupported warning ([e39d530](https://github.com/athensresearch/athens/commit/e39d530f561172591af4c02d80d29a123a12c6fc)) + + +### Bug Fixes + +* athena should highlight results ([ea5abe5](https://github.com/athensresearch/athens/commit/ea5abe5d6bdeb77b5849645f3eb32962305e9a9a)) + + +### Refactors + +* don't use lookbehind in replace-roam-date ([3f4066a](https://github.com/athensresearch/athens/commit/3f4066a0029e995b7c7a8c293b5a8b3f2be17755)) +* move regex fns into athens.patterns cljc ([ded5f2a](https://github.com/athensresearch/athens/commit/ded5f2aafe0a46ae3aaec05e6ad044b1a6d0a9d9)) +* refactor highlight to not use lookbehind ([c4d386c](https://github.com/athensresearch/athens/commit/c4d386cff7d11f741b64cab2da28f48ad95fb3c4)) +* refactor unlinked to not use lookbehind ([870e39e](https://github.com/athensresearch/athens/commit/870e39ef8bed67bfe22c1a76da5cc58d59c8c6de)) +* remove lookbehind from instaparse ([c8bfa5b](https://github.com/athensresearch/athens/commit/c8bfa5bc47303a545b8a58ba3ce8004fe976eb33)) +* remove unused backtick token ([d3e1877](https://github.com/athensresearch/athens/commit/d3e18775d594ccff3ea965a33be0af5742d47eea)) +* use same file for clj and cljs parser tests ([2575222](https://github.com/athensresearch/athens/commit/2575222d2e178885d3221ad0083380037439826a)) +* use word boundary instead of positive lookbehind in parser ([6a4f3cd](https://github.com/athensresearch/athens/commit/6a4f3cd971b0bc1f36834ef6e64a16aa6fc5aad2)) + + +* add boundary tests ([4e66a13](https://github.com/athensresearch/athens/commit/4e66a1306df013595a3d3d8f32462f45fb9f38fb)) +* add hashtag test for unlinked ([6405bdc](https://github.com/athensresearch/athens/commit/6405bdc582ac355e7f63561cc2f5c2d24ca89709)) +* add tests for athens.patterns ([bab8d71](https://github.com/athensresearch/athens/commit/bab8d71ad62abaa9b78c77027b7098355862b653)) +* disable failing lookbehind tests ([6a406a0](https://github.com/athensresearch/athens/commit/6a406a0b8e629735417ebcaf01e0886944eeb0ff)) +* more tests for roam-date ([7ea9d3f](https://github.com/athensresearch/athens/commit/7ea9d3f9c107274aee12fa13d41f8a454056575f)) +* remove unused vars ([e14e53d](https://github.com/athensresearch/athens/commit/e14e53da0afc7ea0a1f76e740a6e32006cfa8495)) +* update backslash escapes test for cljs output ([be1e5df](https://github.com/athensresearch/athens/commit/be1e5dfa13ad8677076e74367e522e105df4dae5)) + +## [2.0.0-beta.36](https://github.com/athensresearch/athens/compare/v2.0.0-beta.35...v2.0.0-beta.36) (2022-05-24) + + +### Features + +* serve web client from athens server ([775c981](https://github.com/athensresearch/athens/commit/775c98102bf4797d8111015b54a7885e51eee44a)) + + +* release web to vercel ([e52f6e2](https://github.com/athensresearch/athens/commit/e52f6e258919e0eba066192520746174e2d77c88)) +* release-server now requires build-app ([1b3ef1d](https://github.com/athensresearch/athens/commit/1b3ef1d1b3236705237bd5cca7e560144694d57b)) + +## [2.0.0-beta.35](https://github.com/athensresearch/athens/compare/v2.0.0-beta.34...v2.0.0-beta.35) (2022-05-18) + + +### Features + +* permalink includes graph name and password ([5424b05](https://github.com/athensresearch/athens/commit/5424b0558b73aea7a0d67d8448e7844e914ffa48)) + + +### Bug Fixes + +* don't control dialog inputs ([7c59bbd](https://github.com/athensresearch/athens/commit/7c59bbdfed4c28910b449a787e758590ae4326fe)) +* don't show plaintext password on permalink ([6351d1d](https://github.com/athensresearch/athens/commit/6351d1d53c7ff10b94eabf5e7665fd6280ae14bb)) +* show own user on presence if there are no other users ([de5aead](https://github.com/athensresearch/athens/commit/de5aeada02f0f11eb178914d5b3d5d29ea772b85)) + +## [2.0.0-beta.34](https://github.com/athensresearch/athens/compare/v2.0.0-beta.33...v2.0.0-beta.34) (2022-05-04) + + +### Features + +* add navigation section to help ([590a275](https://github.com/athensresearch/athens/commit/590a275c50d3acca47e03b0d6f8a9bd6e22d49e8)) +* mod+alt+o zooms out of current block ([e446f0c](https://github.com/athensresearch/athens/commit/e446f0c1517476ce350cb2c10418b8226341d55f)) +* Permalink creates db if needed, and works on electron ([#2175](https://github.com/athensresearch/athens/issues/2175)) ([13b5241](https://github.com/athensresearch/athens/commit/13b5241d10082dc68fb79c28d10194477f853cd2)) +* pressing up/down with no focus takes you to last/first block ([c6f7806](https://github.com/athensresearch/athens/commit/c6f7806e6f3847ab45cd740a5f42178ebb8ec1fc)) + + +### Bug Fixes + +* :daily-notes/items should always be a vector ([d06a359](https://github.com/athensresearch/athens/commit/d06a359f841dac83e0e1355aa12f4dea99a3f26b)) +* `:page/rename` & `:page/merge` w/o regex injection. ([4a817bb](https://github.com/athensresearch/athens/commit/4a817bb01b13dc44b8433206b6f7593056ff3fff)) +* also handle naked hashtag in nested page renames ([206d34c](https://github.com/athensresearch/athens/commit/206d34c9ccbc861684b0cc0e8b2682fd505c1757)) +* disable "open file..." dialog on cmd/ctrl+o ([18a818a](https://github.com/athensresearch/athens/commit/18a818a083c9f18cdc2aa57b6a062cb573591155)) +* fold shortcut should use shortcut key on mac ([79f7ad7](https://github.com/athensresearch/athens/commit/79f7ad701bfbd77b1a61efb3063a3c6ad53ea828)) +* page linked refs start closed when 10+ ([ce77fa9](https://github.com/athensresearch/athens/commit/ce77fa96cc7e48d8c444fa002a50c6293599b072)) +* prev-block-uid should not try to go to pages ([1ede064](https://github.com/athensresearch/athens/commit/1ede0641b30e6f24425348302390ccacf7f56d85)) +* prevent browser defaults that focus URL ([c94b162](https://github.com/athensresearch/athens/commit/c94b16229a76c1f36866aa18cc1fd9c59e7334ee)) +* restore the alert/js event ([f34b69c](https://github.com/athensresearch/athens/commit/f34b69cd9edc8bde2158650e209956cb4286a296)) +* unfold block is mod+down ([e42c5c7](https://github.com/athensresearch/athens/commit/e42c5c7a83977314e75be0b73569de7d2f58e7f8)) +* up on first window child should not lose block focus ([805065e](https://github.com/athensresearch/athens/commit/805065e301701a0485e85b496da9ec03ffc51044)) + + +### Refactors + +* also not used anymore ([c2c9ae7](https://github.com/athensresearch/athens/commit/c2c9ae76d9e65f8e67212e45b8f68526a4560c67)) +* remove devtool ([b24bad6](https://github.com/athensresearch/athens/commit/b24bad69ca92596a58bec4bb479c740c7943d932)) +* removed now dead `patterns/linked` ([0dfe2d0](https://github.com/athensresearch/athens/commit/0dfe2d0dbf3b193de89123b0df37239bf24e8820)) + +## [2.0.0-beta.33](https://github.com/athensresearch/athens/compare/v2.0.0-beta.32...v2.0.0-beta.33) (2022-04-27) + + +### Bug Fixes + +* row title is addressed by full name ([70ac275](https://github.com/athensresearch/athens/commit/70ac2758d44829bec57a4f4be977f7c5568bb806)) + +## [2.0.0-beta.32](https://github.com/athensresearch/athens/compare/v2.0.0-beta.31...v2.0.0-beta.32) (2022-04-27) + + +### Features + +* initial virtualizing ([b0e4380](https://github.com/athensresearch/athens/commit/b0e4380fe2660d534153f9738fa73199b9ea1ce7)) +* sortable styled table ([0141331](https://github.com/athensresearch/athens/commit/0141331e577fefc04b905259b4ba5f189cb2f50a)) + + +### Bug Fixes + +* can merge pages again ([e52234c](https://github.com/athensresearch/athens/commit/e52234cabea30e6d99ee49c9a56d5650f7ada8a9)) +* constrain all-pages width ([b397133](https://github.com/athensresearch/athens/commit/b39713384453b89cce75a44df39822a3f6d79f96)) +* copy on embed blocks ([3a891fe](https://github.com/athensresearch/athens/commit/3a891fe09d0b3c641e66f710fb9d5e73996e345b)) +* disambiguate edit and create time ([5c06600](https://github.com/athensresearch/athens/commit/5c06600a1268478cab14a572e02d1c21c0cbec70)) +* properly format and display dates in table ([8467018](https://github.com/athensresearch/athens/commit/8467018185267c2d2f325d1d7101aa5dc07c0104)) +* table works for many and few pages ([7e577fb](https://github.com/athensresearch/athens/commit/7e577fb6968c81be6cd357955a4023d0c3a47c88)) +* working confirmation dialog for page merge ([8e67abe](https://github.com/athensresearch/athens/commit/8e67abebacf2f13678a5ef476024252fefb50003)) + + +* cleanup ([dcfd5aa](https://github.com/athensresearch/athens/commit/dcfd5aab2cd2032cf6ff8b7f5a26fcd971b5f4a7)) +* fix ([48df468](https://github.com/athensresearch/athens/commit/48df46827972236e8f0f98a30e51fe68835be9d8)) + + +### Refactors + +* add new colorscheme for subtle and highlight buttons ([4e3a191](https://github.com/athensresearch/athens/commit/4e3a191d11ba8b3f956b3f78d41b28f138524c62)) +* cleaner style application in all-pages table ([91e3b2d](https://github.com/athensresearch/athens/commit/91e3b2df8915b764ae77ade612f6369a637bdbcb)) + + +### Documentation + +* update readme ([9f545ef](https://github.com/athensresearch/athens/commit/9f545ef45685a08688a2ad1c3c150b2ae6a7b55f)) + +## [2.0.0-beta.31](https://github.com/athensresearch/athens/compare/v2.0.0-beta.30...v2.0.0-beta.31) (2022-04-20) + + +### Features + +* don't autoblock non-chrome browsers ([81324e0](https://github.com/athensresearch/athens/commit/81324e050c372119711464c44be9d3212fe05a2b)) +* Navigate back when user deletes current page ([bbdafc4](https://github.com/athensresearch/athens/commit/bbdafc44e7a454fcc3d7d69bf3ea5bbd41713ea8)) + + +### Bug Fixes + +* can click toolbar buttons ([93accb3](https://github.com/athensresearch/athens/commit/93accb38ee4611eb991b1bda94bf88230b2b513e)) +* consider unknown OS to be linux ([449653f](https://github.com/athensresearch/athens/commit/449653fb9b97c5d43e1c94094bfd987a7fe8511f)) +* safari user agent is lower case ([b0bff69](https://github.com/athensresearch/athens/commit/b0bff693bd5765a965523bd3c38d3ad9572a0d11)) +* show unsupported message for safari only ([ee33753](https://github.com/athensresearch/athens/commit/ee33753446ecef06a00dfd347b330b27f822c8e9)), closes [/github.com/athensresearch/athens/pull/2096#issuecomment-1083101498](https://github.com/athensresearch//github.com/athensresearch/athens/pull/2096/issues/issuecomment-1083101498) +* still support chrome ([7cf620a](https://github.com/athensresearch/athens/commit/7cf620a8ea0d25e2900aae90a6625c872ae5d4c4)) + + +* update shadow-cljs, cljs, tick ([0dfcd36](https://github.com/athensresearch/athens/commit/0dfcd36bf43910dfb846bf8e8d2a9b6315d1801a)) + +## [2.0.0-beta.30](https://github.com/athensresearch/athens/compare/v2.0.0-beta.29...v2.0.0-beta.30) (2022-04-19) + + +### Features + +* add new edit icons ([bb0f40d](https://github.com/athensresearch/athens/commit/bb0f40de9db77a167284859317f402476fe88bca)) +* add working 404 page ([21012be](https://github.com/athensresearch/athens/commit/21012be5e0b6b21b5a750a22d7d17fee30ccfcea)) +* can open in sidebar from inline ref breadcrumb ([fd4f56c](https://github.com/athensresearch/athens/commit/fd4f56cf12bc6e2674f18690cdf2ac1b13f1542f)) +* JVM Crash Reporting ([226c793](https://github.com/athensresearch/athens/commit/226c79374a8fe6a0081f6d487b6566b049eb964d)) +* new page header controls ([01724ee](https://github.com/athensresearch/athens/commit/01724ee1be050da21fe9e67db0f5e50e53b81768)) +* separate button for open in sidebar ([c35c78c](https://github.com/athensresearch/athens/commit/c35c78c5ea6655202c50e822fae289adb813a29d)) +* separate button for open in sidebar ([bf694f1](https://github.com/athensresearch/athens/commit/bf694f1f9564ee4869503de6120bb5b14a02054e)) +* show "open in main view" for daily notes ([2f99633](https://github.com/athensresearch/athens/commit/2f9963350f6c3bbb28fdcca7495466940107dc78)) +* use new edit icon ([51fcd03](https://github.com/athensresearch/athens/commit/51fcd03fe0c1ff09467af54f120e9e08d380318d)) + + +### Bug Fixes + +* 404 shows properly on page-by-title ([8a35ace](https://github.com/athensresearch/athens/commit/8a35ace7528b5b155d75d6f0de4be0dd980a388e)) +* add person icon ([35aa390](https://github.com/athensresearch/athens/commit/35aa39036485fe7889fc247716493eb20f4b2564)) +* all pages table wraps and stretches ([7463254](https://github.com/athensresearch/athens/commit/7463254fb272ace20e5429881540a765ddd4679d)) +* also identify page by uid ([f8d1148](https://github.com/athensresearch/athens/commit/f8d11488757e6503f05a98b15e288f950161a7e4)) +* arrow up and down from blocks works ([ba8d3cb](https://github.com/athensresearch/athens/commit/ba8d3cb63fa5fdf1a32225f261285ffef3def0e0)) +* block embed controls properly placed ([2f6bd9e](https://github.com/athensresearch/athens/commit/2f6bd9e74824d6312df96d89ddf426d38740c93d)) +* block toggle and anchor properly sized ([c15679e](https://github.com/athensresearch/athens/commit/c15679ef2f28259e3eec36cfbc501592d4c36b72)) +* breadcrumbs allowed to be big again, and checkboxes not broken ([780876d](https://github.com/athensresearch/athens/commit/780876df33e2efb36be1516ce411cfbc3141ee9e)) +* breadcrumbs should wrap ([0266059](https://github.com/athensresearch/athens/commit/02660594dd03f791a1e789904c716e0bdb51a686)) +* can click sidebar to scroll ([2574d34](https://github.com/athensresearch/athens/commit/2574d34a8a497b8496f2a337d85858cd5ac62678)) +* can copy multiple block refs ([e151792](https://github.com/athensresearch/athens/commit/e15179292f33a252d982d10a5cbfc093f6ce0b6a)) +* can drag-select in sidebar ([0c5a4a4](https://github.com/athensresearch/athens/commit/0c5a4a4c171dcbb11fb55f0f789e0fd85946b4ad)) +* can scroll to items in sidebar again ([5284874](https://github.com/athensresearch/athens/commit/528487497663689b2f892a06be6a761b3f11b184)) +* centered daily notes ([653ccc8](https://github.com/athensresearch/athens/commit/653ccc86b70751fabf3408c87d7f22cec5b19d8f)) +* consistent page widths ([a82f418](https://github.com/athensresearch/athens/commit/a82f418fbbc9a2f64173716f6e11886f865332c8)) +* correct chevron direction in references ([b9b92d6](https://github.com/athensresearch/athens/commit/b9b92d617005627d527069ec780ad2a2576e1754)) +* correct transition on apptoolbar ([9fe3982](https://github.com/athensresearch/athens/commit/9fe39825a45c335c9a81a153fea7bebf08d28267)) +* css prop should go in sx ([6426355](https://github.com/athensresearch/athens/commit/6426355a5d745b15940cd057d05d8cbbd37dbce9)) +* don't add new pages to daily pages ([67745a4](https://github.com/athensresearch/athens/commit/67745a43705c33768e40b6829e3c9a244820722c)) +* don't import athens.utils in electron ([c8578ce](https://github.com/athensresearch/athens/commit/c8578ce577ed205240ed39824266620e60a45992)) +* embeds only as broken as on release ([ae5dbae](https://github.com/athensresearch/athens/commit/ae5dbae1758d39ae348f7bb1a8df04818b5d6e26)) +* flip page open buttons for daily pages ([74cadcc](https://github.com/athensresearch/athens/commit/74cadccc0016bebd915c335166e954d7b5942944)) +* force consistent typography on block text and textarea ([e432025](https://github.com/athensresearch/athens/commit/e432025b17b9442aee415043f44557cdac09f2c2)) +* inline menu closes on click outside ([870250b](https://github.com/athensresearch/athens/commit/870250b858f1f718cf63ca2e3a8c59d781879d23)) +* linked refs in block work ([e6707fb](https://github.com/athensresearch/athens/commit/e6707fbb2e75b7c59784066e05ef3ba854af4616)) +* mark/highlight colors are global ([81750cd](https://github.com/athensresearch/athens/commit/81750cd6bd08b94537a0b383a6a8f8df33071738)) +* misc minor layout issues ([9bdf174](https://github.com/athensresearch/athens/commit/9bdf1749cdd6c4535341dc90ee9eddd4ecea5079)) +* more types of links included in block interaction passthrough ([84522d9](https://github.com/athensresearch/athens/commit/84522d9e12e55da941e71b76d79bb7e6ef323b4e)) +* nested links clickable again ([270c05a](https://github.com/athensresearch/athens/commit/270c05a3c3d741bed35f4fe655ebcbde94285f96)) +* no nil uids on daily pages ([7cecc4f](https://github.com/athensresearch/athens/commit/7cecc4febf146190bf4cc1d7409e17361992b347)) +* node page button shouldn't squish ([eb02b0a](https://github.com/athensresearch/athens/commit/eb02b0a024677e598644d2553f969a4d97e4ca77)) +* open page in sidebar shouldn't open graph ([6afcc68](https://github.com/athensresearch/athens/commit/6afcc682f051ed8a7e1d0a26ddd4ede11ff0febb)) +* pass more tests ([49bb181](https://github.com/athensresearch/athens/commit/49bb1813421cea53131863bf09e089d8bd3a46e6)) +* passing more tests ([5bdede1](https://github.com/athensresearch/athens/commit/5bdede134f376eab564d3d3acac248b032699b71)) +* perf and key error on daily notes ([c31cbfc](https://github.com/athensresearch/athens/commit/c31cbfc75193e1149943853e64991db98652b3c5)) +* proper icon size in table header ([1bb3350](https://github.com/athensresearch/athens/commit/1bb3350bd5d6f110e71353236952ceecbccbc903)) +* proper indentation for blocks in embeds ([8bfff9d](https://github.com/athensresearch/athens/commit/8bfff9df339d7bc1a9adb502862c76fbad25a721)) +* proper toast message on permalink action ([a68de83](https://github.com/athensresearch/athens/commit/a68de8397c8b42f2f2999ce255ca86046ea0d93f)) +* reduce excess spacing around daily notes ([0e02491](https://github.com/athensresearch/athens/commit/0e02491ff14b51b354304aed96e66ade339a31aa)) +* remove :first-child warning ([fedbec0](https://github.com/athensresearch/athens/commit/fedbec025eee7972d2b1e78eb5213fddd3d96f59)) +* remove content area until editing ([c3ec4a9](https://github.com/athensresearch/athens/commit/c3ec4a94db04da35c3cdd1cd01f546ec2c63f215)) +* remove some println ([04dd227](https://github.com/athensresearch/athens/commit/04dd227d2e37d2e11b97623f669f5f46b997094e)) +* sidebar items state in db ([39f8365](https://github.com/athensresearch/athens/commit/39f8365339f4329323d5543b5415ee44aec961a0)) +* solve another cause of collapsed checkboxes ([4502d52](https://github.com/athensresearch/athens/commit/4502d52dabda8becf74b65a2903bbe99d6b30509)) +* solve cause of extra title wrapping ([0ce0063](https://github.com/athensresearch/athens/commit/0ce00632f81ccb7493e17d83a70e2c3f2cf3e1af)) +* solve some cases of improper buttons in page header ([b2e350b](https://github.com/athensresearch/athens/commit/b2e350bdf912e51753a288b4eed5bf32068e2012)) +* throttle dispatches on-scroll ([f560575](https://github.com/athensresearch/athens/commit/f560575bcbbd8c7d646c09abb31968d748bcfd8f)) +* use a debounce instead of throttle for scroll ([e16f5c4](https://github.com/athensresearch/athens/commit/e16f5c465aca6b029b238e94f0a27f4489a15531)) +* use correct property for menu title spcing ([c716d37](https://github.com/athensresearch/athens/commit/c716d37fa5d93f0d7874dec98f82c51760844fd1)) +* use correct property for menu title spcing ([0ece2ec](https://github.com/athensresearch/athens/commit/0ece2ec816cb3663412bb80ccb4f629b1db8952a)) +* use theme on loading screen ([f57a3c4](https://github.com/athensresearch/athens/commit/f57a3c4a10ede0b4f32860f9ac9ec8fc670c0c56)) + + +### Performance + +* early returns for menus in blocks ([38bda25](https://github.com/athensresearch/athens/commit/38bda25ab291c8298b59a42217bd7125daaee0fd)) +* only include autocompletes on active block ([ee29db5](https://github.com/athensresearch/athens/commit/ee29db51fab5d20d024fb7cb751accf05524f882)) +* remove unused css ([506ff8c](https://github.com/athensresearch/athens/commit/506ff8c611d37d410042cf91b6f6bccc1da72899)) +* use system fonts for speed ([e4c26eb](https://github.com/athensresearch/athens/commit/e4c26eb5b8795c9df3bd287ac35abcddd72e91f6)) + + +### Refactors + +* blocks use new inline refs component ([5c238ab](https://github.com/athensresearch/athens/commit/5c238ab6da968b8ad5c9ab02456306879f63b42c)) +* chakra window icons ([8ab8636](https://github.com/athensresearch/athens/commit/8ab86367b581890bfc816cb7c00a3c4b73de5eb9)) +* dedicated space for presence on blocks ([3348dc5](https://github.com/athensresearch/athens/commit/3348dc5e8bd95cf212f7e2d28ea672f52be732d1)) +* functioning block menu ([a57e7d5](https://github.com/athensresearch/athens/commit/a57e7d5d133148b1f46285502751eb094118ea73)) +* make sure every menu and popover is lazy ([b281dda](https://github.com/athensresearch/athens/commit/b281dda46598ab03744372cdcd710bed7b647847)) +* midflight reducing getcaretpos ([6a28dcc](https://github.com/athensresearch/athens/commit/6a28dccd86d0ab746d17efb5c5f9defa37310438)) +* minor cleanup ([4f42fe4](https://github.com/athensresearch/athens/commit/4f42fe48a440fcca58c0f14d8c75032c14118bbb)) +* move block components out of redundant comps folder ([f049f60](https://github.com/athensresearch/athens/commit/f049f607b30d1be14e3d8ea86af9d435c0ab078d)) +* move reference components to new file ([79502a7](https://github.com/athensresearch/athens/commit/79502a72394b5c5b45f0134ed82336b3510c3c2f)) +* new file for sidebar components ([c1686b1](https://github.com/athensresearch/athens/commit/c1686b16c0b4acb6740c155acb54fe65ee751752)) +* no mui in graph ([2fd482b](https://github.com/athensresearch/athens/commit/2fd482b7a071a92f1881e348c18d0410f798dd74)) +* only one title component ([e37150e](https://github.com/athensresearch/athens/commit/e37150e391b0a8640dd0654b451fab5d6f25c8ad)) +* replace and remove material-ui ([95dc4bb](https://github.com/athensresearch/athens/commit/95dc4bb75a0d66ace8f9983d9ae099149fca8e43)) +* replace material icons on node page ([cc1bfd5](https://github.com/athensresearch/athens/commit/cc1bfd578417d01f7d2c353ff172c03f157066b4)) +* retire stylefy and garden ([2184c10](https://github.com/athensresearch/athens/commit/2184c10cdafe26350a9d89bf5f1f5edf7a61b223)) +* rework node page titles ([82e1e97](https://github.com/athensresearch/athens/commit/82e1e971a3b1918f1ef21fb1b5a4e150fa8ccef1)) +* reworking linked ref styles ([84b0ff0](https://github.com/athensresearch/athens/commit/84b0ff0b6f1d09d5c98626d5edcaa8fcb5fa3211)) +* undo class changes to textarea ([1e7c2b4](https://github.com/athensresearch/athens/commit/1e7c2b446edf217cb5716cac883e9ccd7f0d33f8)) +* use correct block container styles ([1cd833f](https://github.com/athensresearch/athens/commit/1cd833f98597e915d441391aa78cd7aefa938f3b)) +* use correct block container styles ([20e1cbe](https://github.com/athensresearch/athens/commit/20e1cbe127374516f8e53e94372adb652254882c)) + + +* cleanup ([9fedb20](https://github.com/athensresearch/athens/commit/9fedb20391b2b2864864ad3ae14b1bd8ee7dfffb)) +* fix lint issues ([059d421](https://github.com/athensresearch/athens/commit/059d421ae7677df6d21e68044a6e94d71d836562)) +* fix lint issues ([e880ef5](https://github.com/athensresearch/athens/commit/e880ef5b7fbe710cc0f9369292f0055d8edb4b2e)) +* happy little comment around fragile styles ([4f4cb42](https://github.com/athensresearch/athens/commit/4f4cb421b0825566d324da70278f5ab2dae3a095)) +* lint fixes ([259622a](https://github.com/athensresearch/athens/commit/259622af6ad83042b0c0b1e65373bc4fd7597d19)) +* lint fixes ([43b2b0b](https://github.com/athensresearch/athens/commit/43b2b0b34ad4f6591b01902d6ae92f343fd9e56d)) +* lint fixes ([0be358d](https://github.com/athensresearch/athens/commit/0be358d38c15b448d507bdb7390a6b2936080641)) +* lint fixes ([fef4597](https://github.com/athensresearch/athens/commit/fef459763f468cdf341007e736de0ca6c44343c5)) +* linting ([7bc4e09](https://github.com/athensresearch/athens/commit/7bc4e099b6a1574fe7d00081274edd9c0e8aa200)) +* linting ([c941068](https://github.com/athensresearch/athens/commit/c9410683d96292f1579ee69ea805a0051568e0f2)) +* linting fixes ([6dc3771](https://github.com/athensresearch/athens/commit/6dc37710a98c4b6b2f000965799044a2fcfb38a6)) +* minor cleanup ([b5d4197](https://github.com/athensresearch/athens/commit/b5d41970fd8561aec73dea4991a13d18a7cd96a2)) +* remove console logs ([41f107d](https://github.com/athensresearch/athens/commit/41f107dee03cce831ab442372b36f6f634d6fcce)) +* remove outdated comment ([e4fca23](https://github.com/athensresearch/athens/commit/e4fca23e48145df8de185f0342c471ba672d5131)) +* remove unused bindin ([f93e275](https://github.com/athensresearch/athens/commit/f93e275c547b7362970138ba44f98af260f33a6b)) +* update e2e page selectors ([6dc8d07](https://github.com/athensresearch/athens/commit/6dc8d07a4f1459d58eba7ad868f1c5de6364113d)) +* update e2e to new copy ref text ([558af98](https://github.com/athensresearch/athens/commit/558af98fa0f3c34ec952cc11e40739636797fea5)) + +## [2.0.0-beta.29](https://github.com/athensresearch/athens/compare/v2.0.0-beta.28...v2.0.0-beta.29) (2022-04-13) + + +### Refactors + +* don't use where-triple filter if there's no since-order ([3c96236](https://github.com/athensresearch/athens/commit/3c9623611889f90e3533596edce31af20deabaa0)) + +## [2.0.0-test](https://github.com/athensresearch/athens/compare/v2.0.0-beta.27...v2.0.0-test) (2022-04-08) + + +### Bug Fixes + +* use scalable ordering for events ([cce15ef](https://github.com/athensresearch/athens/commit/cce15ef4bb797126a18c12d911149e3e59f82785)) + + +* fix ([39cedd6](https://github.com/athensresearch/athens/commit/39cedd6a5447702912da2bbbb90fd6883d41059a)) +* update clojure ([a86ecfe](https://github.com/athensresearch/athens/commit/a86ecfeaf9f24bfcefefd758a9ecdb0a1034a8ae)) + +## [2.0.0-beta.28](https://github.com/athensresearch/athens/compare/v2.0.0-beta.27...v2.0.0-beta.28) (2022-04-12) + + +### Features + +* add a slash command to insert own name link ([ad53214](https://github.com/athensresearch/athens/commit/ad532140ef62e25c8cf3a4eff6da93c8060cef0b)) +* Page link creation reporting also from page titles. ([bc3a614](https://github.com/athensresearch/athens/commit/bc3a6142d3f4a7976555139dafd6e1546d4fba4e)) +* support copying permalink ([7abf100](https://github.com/athensresearch/athens/commit/7abf100b35b37ffc22819848b06bdd509993fba2)) +* support loading a url on first boot on web client ([f94cfab](https://github.com/athensresearch/athens/commit/f94cfab0524217a932c9e467717b6b533849e39e)) + + +### Bug Fixes + +* /me shouldn't add a space at the end ([4ab8adb](https://github.com/athensresearch/athens/commit/4ab8adb388e62e2cda5365c2a7a68053091b87d6)) +* don't remove block if there's nothing to paste ([e2fd834](https://github.com/athensresearch/athens/commit/e2fd834794780854a76dbbd2d3530d893b387a63)) +* don't show permalink button on electron ([52a969f](https://github.com/athensresearch/athens/commit/52a969ff9006b90aff17569705a4408ac205806c)) +* ensure router starts after boot ([cfaff36](https://github.com/athensresearch/athens/commit/cfaff3668bde92c10165744a3111218b814c9302)) +* focus on first block after paste ([6691e0a](https://github.com/athensresearch/athens/commit/6691e0ab2dd35ff088910bfefcc289570565ca79)) +* only navigate at the end of the boot sequence ([4de8cf2](https://github.com/athensresearch/athens/commit/4de8cf25d4f447294793097b6098f87f7ce99b6c)) +* seq is the right fn to check if not empty ([faf493f](https://github.com/athensresearch/athens/commit/faf493fd8ddf9cb5640a6010aab999e6c9e2c3a6)) + + +### Refactors + +* use contains-op? to filter op list ([b180ecf](https://github.com/athensresearch/athens/commit/b180ecf61fb390406600c2dd1acb0308f3b8d2e1)) + +## [2.0.0-beta.27](https://github.com/athensresearch/athens/compare/v2.0.0-beta.25...v2.0.0-beta.27) (2022-04-07) + + +### Features + +* Don't report PII ([d230732](https://github.com/athensresearch/athens/commit/d2307328c35c8c0770dfb7ed89ea379926406978)) +* migrate events without a uid ([98ab9df](https://github.com/athensresearch/athens/commit/98ab9dfe16556b293120a53f064c2365d85e46b8)) +* migrate to efficient event log filtering ([420a128](https://github.com/athensresearch/athens/commit/420a1286627e22a9d978f936001a7c6e10443566)) +* support event log migrations ([47fcabe](https://github.com/athensresearch/athens/commit/47fcabe2371ec21e641058ea88e5e6b04ba23f41)) + + +### Bug Fixes + +* allow remote dbs in web athens ([094192e](https://github.com/athensresearch/athens/commit/094192e5d23a821f0f04005e707310c082016f4e)) +* bugs alex found ([677fb6e](https://github.com/athensresearch/athens/commit/677fb6ea085b4ed1d254d2e41b765b68a559c75e)) +* current version is 0 if none is present ([bb8ef68](https://github.com/athensresearch/athens/commit/bb8ef68b886efe07f32ccfe5ce60de20c5c934c3)) +* db-dump also ignored from limit on received ([a91df3c](https://github.com/athensresearch/athens/commit/a91df3cd60c05d132b008cf3fff21c1eca0dc483)) +* event-log/events now received id as kw arg ([25543ff](https://github.com/athensresearch/athens/commit/25543ff4a23df89b86e13ae83cd70adb98c72c1a)) +* exclude db dump from size limit ([7b28172](https://github.com/athensresearch/athens/commit/7b28172aaf764ea98b214ffe910eaafb211d8bfe)) +* Feature block link correct counting ([#2111](https://github.com/athensresearch/athens/issues/2111)) ([f1807a2](https://github.com/athensresearch/athens/commit/f1807a2b35879b281c22e46b52dd8ba86996b7fc)) +* fix args on migrate call site ([58ae736](https://github.com/athensresearch/athens/commit/58ae736581c59805305cc8f611af55f59c7d18e0)) +* fix wording on some limit messages ([7f55150](https://github.com/athensresearch/athens/commit/7f55150b1ec395740bb859250fd0c2799ad7113e)) +* get-current-version should return 0 on err ([a94fc64](https://github.com/athensresearch/athens/commit/a94fc648ce5d70d3eec78ba3f36e5b3a84dcb4d7)) +* minor migration logging ([d716ae7](https://github.com/athensresearch/athens/commit/d716ae7d49581d9b766780091422c79c9ab1ff5e)) +* query page size for migrations should be 100 ([7dadf54](https://github.com/athensresearch/athens/commit/7dadf546142095474e107884d573672d748040c5)) +* remove leftover print ([84624c3](https://github.com/athensresearch/athens/commit/84624c3cf1b217706f381448fde3076cb12b49bb)) +* set a 1MB event limit ([81198f4](https://github.com/athensresearch/athens/commit/81198f408119df7fa288fe0a1d042ba8bf8e0be1)) +* show remote db page in db modal when not electron ([477ecd1](https://github.com/athensresearch/athens/commit/477ecd1b28377127415d6d0fe6449d8f3557945d)) +* transit usage was very bork in clj ([92210e2](https://github.com/athensresearch/athens/commit/92210e2d260700828bc849e1324fe98c7fe930c9)) +* use long instead of bigint for event log ([20ee4b4](https://github.com/athensresearch/athens/commit/20ee4b482e260425db8ffd491c6a73bd7c8e2a6a)) + + +### Refactors + +* add athens.self-hosted.fluree.utils ([19bf9d3](https://github.com/athensresearch/athens/commit/19bf9d338a41794eb588c12f9c6fca7467e58d20)) +* move migrator into own ns ([0f97365](https://github.com/athensresearch/athens/commit/0f97365e97f422669f61c2b176646309f97541fd)) + + +* ignore unused test helper ([146f459](https://github.com/athensresearch/athens/commit/146f459bbd8b5b3100c6dd88a7d07a49afb477b1)) +* run fluree tests manually for now ([bb033c8](https://github.com/athensresearch/athens/commit/bb033c8ae1c4585ab03d36e21e530f70cc5c8172)) +* update to clojure 11 ([8ac293e](https://github.com/athensresearch/athens/commit/8ac293ea4511e54c9400081f4704d2d688638833)) + +## [2.0.0-beta.26](https://github.com/athensresearch/athens/compare/v2.0.0-beta.25...v2.0.0-beta.26) (2022-04-06) + + +### Features + +* migrate events without a uid ([98ab9df](https://github.com/athensresearch/athens/commit/98ab9dfe16556b293120a53f064c2365d85e46b8)) +* migrate to efficient event log filtering ([420a128](https://github.com/athensresearch/athens/commit/420a1286627e22a9d978f936001a7c6e10443566)) +* support event log migrations ([47fcabe](https://github.com/athensresearch/athens/commit/47fcabe2371ec21e641058ea88e5e6b04ba23f41)) + + +### Bug Fixes + +* allow remote dbs in web athens ([094192e](https://github.com/athensresearch/athens/commit/094192e5d23a821f0f04005e707310c082016f4e)) +* bugs alex found ([677fb6e](https://github.com/athensresearch/athens/commit/677fb6ea085b4ed1d254d2e41b765b68a559c75e)) +* current version is 0 if none is present ([bb8ef68](https://github.com/athensresearch/athens/commit/bb8ef68b886efe07f32ccfe5ce60de20c5c934c3)) +* db-dump also ignored from limit on received ([a91df3c](https://github.com/athensresearch/athens/commit/a91df3cd60c05d132b008cf3fff21c1eca0dc483)) +* event-log/events now received id as kw arg ([25543ff](https://github.com/athensresearch/athens/commit/25543ff4a23df89b86e13ae83cd70adb98c72c1a)) +* exclude db dump from size limit ([7b28172](https://github.com/athensresearch/athens/commit/7b28172aaf764ea98b214ffe910eaafb211d8bfe)) +* Feature block link correct counting ([#2111](https://github.com/athensresearch/athens/issues/2111)) ([f1807a2](https://github.com/athensresearch/athens/commit/f1807a2b35879b281c22e46b52dd8ba86996b7fc)) +* fix args on migrate call site ([58ae736](https://github.com/athensresearch/athens/commit/58ae736581c59805305cc8f611af55f59c7d18e0)) +* fix wording on some limit messages ([7f55150](https://github.com/athensresearch/athens/commit/7f55150b1ec395740bb859250fd0c2799ad7113e)) +* get-current-version should return 0 on err ([a94fc64](https://github.com/athensresearch/athens/commit/a94fc648ce5d70d3eec78ba3f36e5b3a84dcb4d7)) +* minor migration logging ([d716ae7](https://github.com/athensresearch/athens/commit/d716ae7d49581d9b766780091422c79c9ab1ff5e)) +* query page size for migrations should be 100 ([7dadf54](https://github.com/athensresearch/athens/commit/7dadf546142095474e107884d573672d748040c5)) +* remove leftover print ([84624c3](https://github.com/athensresearch/athens/commit/84624c3cf1b217706f381448fde3076cb12b49bb)) +* set a 1MB event limit ([81198f4](https://github.com/athensresearch/athens/commit/81198f408119df7fa288fe0a1d042ba8bf8e0be1)) +* show remote db page in db modal when not electron ([477ecd1](https://github.com/athensresearch/athens/commit/477ecd1b28377127415d6d0fe6449d8f3557945d)) +* transit usage was very bork in clj ([92210e2](https://github.com/athensresearch/athens/commit/92210e2d260700828bc849e1324fe98c7fe930c9)) +* use long instead of bigint for event log ([20ee4b4](https://github.com/athensresearch/athens/commit/20ee4b482e260425db8ffd491c6a73bd7c8e2a6a)) + + +### Refactors + +* add athens.self-hosted.fluree.utils ([19bf9d3](https://github.com/athensresearch/athens/commit/19bf9d338a41794eb588c12f9c6fca7467e58d20)) +* move migrator into own ns ([0f97365](https://github.com/athensresearch/athens/commit/0f97365e97f422669f61c2b176646309f97541fd)) + + +* ignore unused test helper ([146f459](https://github.com/athensresearch/athens/commit/146f459bbd8b5b3100c6dd88a7d07a49afb477b1)) +* run fluree tests manually for now ([bb033c8](https://github.com/athensresearch/athens/commit/bb033c8ae1c4585ab03d36e21e530f70cc5c8172)) +* update to clojure 11 ([8ac293e](https://github.com/athensresearch/athens/commit/8ac293ea4511e54c9400081f4704d2d688638833)) + +## [2.0.0-beta.25](https://github.com/athensresearch/athens/compare/v2.0.0-beta.24...v2.0.0-beta.25) (2022-04-04) + + +### Features + +* Block/Page Creation Monitoring ([6c8a6fb](https://github.com/athensresearch/athens/commit/6c8a6fb84d755323c0e0d1cbf61a249001e63aab)) +* Block/Page Creation Tracking ([32fa5b5](https://github.com/athensresearch/athens/commit/32fa5b564ab053e526695e4a24f359b30cbd61e9)) +* Block/Page Creation Tracking ([bfa8611](https://github.com/athensresearch/athens/commit/bfa86115851384272619b5589819a61560d26572)) +* Feature Usage Monitoring no autocapture no more ([#2107](https://github.com/athensresearch/athens/issues/2107)) ([6beeae3](https://github.com/athensresearch/athens/commit/6beeae31be83fa06f82b00492291ad34dca4d5ad)) +* improved table ([d18c249](https://github.com/athensresearch/athens/commit/d18c2498339753222db4403aba6a9ff585c8205f)) +* improved windwo dragging ([bd50ec2](https://github.com/athensresearch/athens/commit/bd50ec229260b4a33a0343cdbf4811aa87df9354)) +* Link Creation Reporting ([722046f](https://github.com/athensresearch/athens/commit/722046f580e92d0751e478fc88e1456281111d93)) +* Page Create Tracking ([54fe0d3](https://github.com/athensresearch/athens/commit/54fe0d391d3a60175975a80172678f70ad222727)) +* Page Creation Reporting ([0866af6](https://github.com/athensresearch/athens/commit/0866af62c00b1b026db5f7a6b8083e9c1da38385)) +* Page Creation Reporting ([540b31b](https://github.com/athensresearch/athens/commit/540b31bf375c13bb618382885361d476aa383df2)) +* Page Creation Reporting ([b5aec96](https://github.com/athensresearch/athens/commit/b5aec96c4b4ac40c7f5040d5ceaec601d9b24fbc)) +* Page Creation Reporting ([7abb1c1](https://github.com/athensresearch/athens/commit/7abb1c1e26cbc4f864908f9ed0882de02f3b14fa)) +* Page Creation Reporting ([2591234](https://github.com/athensresearch/athens/commit/2591234fb08a02c82e855c1aacada03dbadf9017)) +* Page Creation Reporting ([004494a](https://github.com/athensresearch/athens/commit/004494a19470453760e2d22f6b59a38a74ccb7a2)) +* Page/Block Creation Reporting ([0aa51a3](https://github.com/athensresearch/athens/commit/0aa51a3163e2a1676d2800bd880eb1c6ca413488)) +* progress ([aa7730a](https://github.com/athensresearch/athens/commit/aa7730a8bff8824be7d0058af67852b53b1cb105)) +* progress ([0ff1313](https://github.com/athensresearch/athens/commit/0ff131302e6ebbdc4a1ffa7715b057c760a88a24)) +* responsive secondary toolbar menu ([8482045](https://github.com/athensresearch/athens/commit/8482045a2e3e615d27735cd2ef808122be0e7594)) +* structural-diff of Atomic Graph Ops ([a18efcd](https://github.com/athensresearch/athens/commit/a18efcd50a6df9f0d9fac5fd632439b127f80b80)) +* toast now reacts to theme color mode ([b3feb98](https://github.com/athensresearch/athens/commit/b3feb98f63313ad36d1ceb22a5024e4338c503a9)) + + +### Bug Fixes + +* add missing change ([a9a9e5a](https://github.com/athensresearch/athens/commit/a9a9e5a1f49c0321e6520ce604440a15c0ac3479)) +* add santized block uid to anhor ([d81213a](https://github.com/athensresearch/athens/commit/d81213a9cbb5872df6ac7bbe6ec249e182e8ed9c)) +* better borders within athena ([63a4faf](https://github.com/athensresearch/athens/commit/63a4faf622f993ecb8d86bb103960425e5525fa2)) +* brighter athena in light mode ([311a9f0](https://github.com/athensresearch/athens/commit/311a9f0855ea49fa6c6a4b9103abda186166a2fd)) +* can close sidebar items again ([b0ed26c](https://github.com/athensresearch/athens/commit/b0ed26cd7b7cd0b1c3c7d5ca02e63c86992b65b3)) +* can edit node page titles again ([903f040](https://github.com/athensresearch/athens/commit/903f040e50f5efe4fb807cb65bf749f0c22c1361)) +* can edit title on block page ([06b2fe7](https://github.com/athensresearch/athens/commit/06b2fe7fc3b923b6927e183acb6a84984df26aff)) +* don't break the theme ([4557c6e](https://github.com/athensresearch/athens/commit/4557c6ecf1299318a0780223348d14575db21372)) +* don't downlevel generators in libraries ([f46decc](https://github.com/athensresearch/athens/commit/f46decc0ed72191308bd70e1059b341b9a553204)) +* fix cursor insertion on first interaction ([68f5507](https://github.com/athensresearch/athens/commit/68f55075a78ffbf017e7e1ef0960f2ff523484b4)) +* left sidebar should be reactive ([48e0b2b](https://github.com/athensresearch/athens/commit/48e0b2ba011d1a0bc7ee5426bbf26b695416412e)) +* misc block interaction issues ([82da8bc](https://github.com/athensresearch/athens/commit/82da8bc3b92b70e7c9027e02bb3ef03ddf375e5c)) +* misc fixes ([d0fc6db](https://github.com/athensresearch/athens/commit/d0fc6dba1365dd352be7d572f408ede68ec3e080)) +* more elements interactable within blocks ([7e85d23](https://github.com/athensresearch/athens/commit/7e85d23deefd611f0ebf248db8519373c10fb595)) +* more tests passing ([f881023](https://github.com/athensresearch/athens/commit/f881023deb7dba3a1c5495eec4a0510b83f5d3a0)) +* no isEditing error ([c7d8501](https://github.com/athensresearch/athens/commit/c7d85018e7d2698d25110a7ca488fe2ceaf33bd9)) +* partially working inline search menu ([92bc797](https://github.com/athensresearch/athens/commit/92bc797777dc85d18546f5d6729439a90bee782f)) +* remove unused binding ([5a8e297](https://github.com/athensresearch/athens/commit/5a8e29772d1c565c859a54a8eba06b3eaafa3a10)) +* right sidebar items open by default ([5fcaf9a](https://github.com/athensresearch/athens/commit/5fcaf9af241e0cffdf059db08a7370367640194a)) +* sidebar items open by default ([5fbde80](https://github.com/athensresearch/athens/commit/5fbde802a9219ad8c7ba0d78336bd1e501cbaa98)) +* solve minor issues with toolbar and all-pages ([876de3d](https://github.com/athensresearch/athens/commit/876de3d344317599d88f746e6a6cafae524fa574)) +* some athena tests passing ([0421032](https://github.com/athensresearch/athens/commit/04210326adc43d83f6b04cc5832c489df9d89cb6)) +* style ([010f9ce](https://github.com/athensresearch/athens/commit/010f9ceb6d135034c970b4d283c4e4aea30157cd)) +* style ([f1084cc](https://github.com/athensresearch/athens/commit/f1084cc32cbfe718f76749d1b4e067bde66e7682)) +* titlebar border transitions nicely ([d38f459](https://github.com/athensresearch/athens/commit/d38f45953aec958e7194ad8d6a26346be4d57f89)) +* use correct border color in help sections ([3cf971d](https://github.com/athensresearch/athens/commit/3cf971d0114b0c37f43e9b619c6fd982fff0a43f)) +* use correct case for Icons.tsx ([593ebb9](https://github.com/athensresearch/athens/commit/593ebb9fdd7b82324dd9d29311705f9c48499301)) +* use SSR friendly selector to remove console error ([9378f72](https://github.com/athensresearch/athens/commit/9378f727781c5bf892091b505266c993a6a351a3)) +* used wrong source ([1bcff3c](https://github.com/athensresearch/athens/commit/1bcff3c8526d0a6065eab250d4def232d9bcdbb2)) +* working inline search menu ([8233f99](https://github.com/athensresearch/athens/commit/8233f99252c6739bc340d7f4e9a3d9e607fd2218)) + + +### Refactors + +* `:block/save` cleanup ([67d7251](https://github.com/athensresearch/athens/commit/67d725170798aed477516436bb102be33a485232)) +* block in new components for autocomplete searc ([ce6aeef](https://github.com/athensresearch/athens/commit/ce6aeef0f01abcf730185782da72df2805b73df1)) +* cleaner use of error boundary ([060b366](https://github.com/athensresearch/athens/commit/060b366c8f09b93643446ada80a9cf8dffbc2e86)) +* completed slash menu ([9ad61f7](https://github.com/athensresearch/athens/commit/9ad61f768570c57eb6d63d0113fee8ff02f54e8f)) +* remove styled-components ([486e44a](https://github.com/athensresearch/athens/commit/486e44ac6ebe33768317f54baaf830ba475669be)) +* replace most uses of stylefy ([241e052](https://github.com/athensresearch/athens/commit/241e0526eab1cd474505e6318b60d4792092701a)) +* retire most storybook components ([f0f3a52](https://github.com/athensresearch/athens/commit/f0f3a52459237079a03c059a7c855f2d85fb6b2a)) +* structure parser generating text representation ([b18d119](https://github.com/athensresearch/athens/commit/b18d119c86948bef9a44aaf551a178bbb8d60671)) +* use better slash menu ([569e62e](https://github.com/athensresearch/athens/commit/569e62e666349e20b7cc6bd2e0d63587c1b8bda8)) +* use chakra for block content element ([1e9a440](https://github.com/athensresearch/athens/commit/1e9a440d4fcc039ecce088915d9cb9c28e745ed4)) + + +* another test passing ([cb636c2](https://github.com/athensresearch/athens/commit/cb636c2ccdc1fbdab445941523c730efe94bd4ba)) +* clean up toggle ([7367508](https://github.com/athensresearch/athens/commit/736750874b52df53cec95117e8025003791a1633)) +* cleanup ([fd5aeb5](https://github.com/athensresearch/athens/commit/fd5aeb545b13d1a9423d9322b24b84502f7c35d4)) +* cleanup ([c600a51](https://github.com/athensresearch/athens/commit/c600a511fc4243b9e41fcf1efc0fb0db5ec8cb41)) +* cleanup lint ([99b7fc8](https://github.com/athensresearch/athens/commit/99b7fc877376c9f996f43995a60612ca7554b492)) +* cleanup minor redundancies ([9205337](https://github.com/athensresearch/athens/commit/9205337402428229cd975d517c2d82ae1f0a41f6)) +* cleanup unused ([73f0b6c](https://github.com/athensresearch/athens/commit/73f0b6c44ef01429e9aa6d29ff32182e5b40214d)) +* fix ([91a27f8](https://github.com/athensresearch/athens/commit/91a27f8d49c2fab44bd63282b7d3598d11c80d5f)) +* formatting cleanup ([80cdcc1](https://github.com/athensresearch/athens/commit/80cdcc11b30617d05ce8b39e0c056fb316720e58)) +* lint ([42e299d](https://github.com/athensresearch/athens/commit/42e299d0534c3a41525557c891b5dc39be872f14)) +* lint ([ebbdf5d](https://github.com/athensresearch/athens/commit/ebbdf5dfb80655db5c8ef0a55711945754bd2ed5)) +* lint ([d7780cb](https://github.com/athensresearch/athens/commit/d7780cb583c7a91ecf0a403ee30da54ae7e25470)) +* lint ([a2cbce8](https://github.com/athensresearch/athens/commit/a2cbce8d702d0dbaf22869b1f771307ba0aa6e7a)) +* linting fixes ([c3f42a8](https://github.com/athensresearch/athens/commit/c3f42a80d2806defc0f054234c494aebf557845f)) +* more cleanup ([c5de678](https://github.com/athensresearch/athens/commit/c5de6787eca2fc74e0765d4f62d29ab2e5defc60)) +* more tests passing ([d445d5b](https://github.com/athensresearch/athens/commit/d445d5bfb00a6ee4906365937719f583542efbfd)) +* remove unused ([da7e86c](https://github.com/athensresearch/athens/commit/da7e86c813a913de8988c749b108f9c500f99adf)) +* remove unused lint rules ([033b73b](https://github.com/athensresearch/athens/commit/033b73b34d32eec68fe98743bf68c8e17c2a147c)) +* style cleanup ([008011b](https://github.com/athensresearch/athens/commit/008011b9c0f62e1cf66852b912ca0dd3861c152a)) +* unbreak storybook ([b36184e](https://github.com/athensresearch/athens/commit/b36184e93db11e097002df1449b1e0798773a043)) +* update e2e page title selectors ([b59e61d](https://github.com/athensresearch/athens/commit/b59e61def708b11192d5691628de31e72ed6e639)) +* use same babel preset on main as on app ([8012176](https://github.com/athensresearch/athens/commit/8012176e2989d4f49e0e57fb656e9dbb701c3b56)) + +## [2.0.0-beta.24](https://github.com/athensresearch/athens/compare/v2.0.0-beta.23...v2.0.0-beta.24) (2022-03-17) + +## [2.0.0-beta.23](https://github.com/athensresearch/athens/compare/v2.0.0-beta.22...v2.0.0-beta.23) (2022-03-15) + + +### Bug Fixes + +* docker path is /srv/ not /src/ ([#2083](https://github.com/athensresearch/athens/issues/2083)) ([bfb3fde](https://github.com/athensresearch/athens/commit/bfb3fdef2a55539c10ae2508853ab2077ca8e9a7)) + +## [2.0.0-beta.22](https://github.com/athensresearch/athens/compare/v2.0.0-beta.21...v2.0.0-beta.22) (2022-03-15) + + +### Bug Fixes + +* default config should use docker values ([73e5224](https://github.com/athensresearch/athens/commit/73e5224d4a88291b4e670af3dcc81e6f6b3dfea6)) + +## [2.0.0-beta.21](https://github.com/athensresearch/athens/compare/v2.0.0-beta.20...v2.0.0-beta.21) (2022-03-15) + + +### Features + +* add :datascript :persist-base-path to server config ([712759d](https://github.com/athensresearch/athens/commit/712759dabe37e4f5a6a61ebc1733e594021c65db)) +* add athens.self-hosted.web.persistence ns ([5b7c664](https://github.com/athensresearch/athens/commit/5b7c6643d45aaa3fab2c0727c282efed65ed58f6)) +* allow cli load to resume from last event ([e82a71b](https://github.com/athensresearch/athens/commit/e82a71b6d6d2f98e92caf04c3cdbcf993b7b25db)) +* block-ref's breadcrumb in tooltip ([109610e](https://github.com/athensresearch/athens/commit/109610e048d574e7734156ef1ea3cc733df08daa)) +* incremental snapshotting on load ([3a69a27](https://github.com/athensresearch/athens/commit/3a69a27dcdac623cf971fdf69ba3b2a85d07d132)) +* persist server datascript db ([7d2fd6b](https://github.com/athensresearch/athens/commit/7d2fd6b4ddbb8984b0176d26cab98043a83f09ac)) + + +### Bug Fixes + +* cljs throws js stuff ([2c99f48](https://github.com/athensresearch/athens/commit/2c99f4852f3a60184b709278cb022deec9040936)) +* event-id should be a uuid ([858642c](https://github.com/athensresearch/athens/commit/858642c7812d4e2cf5daa26f65cff5d0ff2341a3)) +* throw when ref-block does not have parent ([5bbafa9](https://github.com/athensresearch/athens/commit/5bbafa924649ed21b9c692b8864a6bfdf76d1b72)) +* time logging should be in double ([e04e553](https://github.com/athensresearch/athens/commit/e04e5533990f3d47417c73885ffae4bc10860e0b)) +* workaround slow fluree filter ([4be370d](https://github.com/athensresearch/athens/commit/4be370d52932e1570a67c927fb6abcd9a37a38b5)) + + +### Refactors + +* make most persistence ns fns private ([4e420ea](https://github.com/athensresearch/athens/commit/4e420ea7eeb37fa8ad8a311a98a6055ff3bd1283)) +* move stuff around after thinking more ([b518f8d](https://github.com/athensresearch/athens/commit/b518f8df909cf46d7f8b87490811efd03099acb9)) +* Remove unused namespace reference ([34c7596](https://github.com/athensresearch/athens/commit/34c7596f570eca2b7955bf4f66ac99a9e329a03c)) +* use /datascript/persist instead of just /persist/ ([f62f829](https://github.com/athensresearch/athens/commit/f62f829f2cf9347446571ab3c9594443237d2ed3)) + + +* refactor and test position->uid+parent ([48786cd](https://github.com/athensresearch/athens/commit/48786cd69df368796f88caa070428de1f8074181)) + +## [2.0.0-beta.20](https://github.com/athensresearch/athens/compare/v2.0.0-beta.19...v2.0.0-beta.20) (2022-03-11) + + +### Bug Fixes + +* HOC perf mon to always show forwarded comp. ([719eb7b](https://github.com/athensresearch/athens/commit/719eb7b50ccb83798dad71ba6f53a4f5924726d8)) + + +* cljstyle fix ([e86e0d7](https://github.com/athensresearch/athens/commit/e86e0d71af2c0cb89fa0900773c05178bb4a3eb1)) + +## [2.0.0-beta.19](https://github.com/athensresearch/athens/compare/v2.0.0-beta.18...v2.0.0-beta.19) (2022-03-10) + + +### Features + +* Feature monitoring: right-sidebar usage ([f92d19b](https://github.com/athensresearch/athens/commit/f92d19bb0388d1f9dbc6e61cbd9fa479531afb13)) + + +### Bug Fixes + +* Allow to start with empty line. ([1095dc5](https://github.com/athensresearch/athens/commit/1095dc5bc9df23b23b507d098d8d5ea3fbfe530c)) + + +* testing new behavior ([22361aa](https://github.com/athensresearch/athens/commit/22361aa33c57cd0bd36a4a307ceb690109924582)) + +## [2.0.0-beta.18](https://github.com/athensresearch/athens/compare/v2.0.0-beta.17...v2.0.0-beta.18) (2022-03-03) + + +### Features + +* directly show children on inline refs ([00b39e4](https://github.com/athensresearch/athens/commit/00b39e4dfd2960dedb4ddc9f45e218538e519021)) +* don't warn on exit while editing ([d6911fc](https://github.com/athensresearch/athens/commit/d6911fc9807ab5049dcaeb0451fa0cde00b47eaf)) + + +### Bug Fixes + +* also unlink `:node/title` contents when unlinking ([2e1e45b](https://github.com/athensresearch/athens/commit/2e1e45ba2280f2ceade98928b88b2484f982802e)) +* Case of nested page links ([491e2e8](https://github.com/athensresearch/athens/commit/491e2e8b678aff7d7dece7d351498b3e9f567159)) +* show more than 1000 links in all page listing ([e538bdb](https://github.com/athensresearch/athens/commit/e538bdb440e865f7f8c5e5a2986e6efa759e2df1)) + + +### Refactors + +* dead code is dead code. ([4866c57](https://github.com/athensresearch/athens/commit/4866c57556dc5c3cebe3b6eb56b226fd15473353)) +* Improved readability, better naming. ([3b4788b](https://github.com/athensresearch/athens/commit/3b4788b7be3d6340e11f00e375e0b85f3150f732)) + +## [2.0.0-beta.17](https://github.com/athensresearch/athens/compare/v2.0.0-beta.16...v2.0.0-beta.17) (2022-02-28) + + +### Bug Fixes + +* handle loading states on reconnect in a sane way ([f6eee39](https://github.com/athensresearch/athens/commit/f6eee3901645b91ed77727119091bdef06830cfd)) +* query needs db value, not conn ([e8b6d0e](https://github.com/athensresearch/athens/commit/e8b6d0e7e2c247e2735bee4ce2abba27db0dbed9)) +* revert visibility change to fn ([4b81e11](https://github.com/athensresearch/athens/commit/4b81e118a4e6dbb8da316a0ba4c764d2187f5507)) + + +* add more block context menu tests ([acc58e8](https://github.com/athensresearch/athens/commit/acc58e86cca33d3cf809b98914554031a3603ff9)) +* fix click out expectations ([0d48659](https://github.com/athensresearch/athens/commit/0d48659e9e5088907ddecd494c531fc26a6f6e52)) + +## [2.0.0-beta.16](https://github.com/athensresearch/athens/compare/v2.0.0-beta.15...v2.0.0-beta.16) (2022-02-23) + + +### Features + +* add deftraced sentry macro ([1d567b7](https://github.com/athensresearch/athens/commit/1d567b7ad76b3fa2d4804825e90948461d127d16)) +* HOC for perf monitoring ([be66ca8](https://github.com/athensresearch/athens/commit/be66ca8c9d71368a409d45c18c2ffd10c42585f6)) +* HOC not nesting incorrectly. ([3114cc1](https://github.com/athensresearch/athens/commit/3114cc1ca10a6d7001cda66df8050787c5f7e9bb)) +* macro for sentry wrapping ([ad60923](https://github.com/athensresearch/athens/commit/ad6092359d14a649f8af186b962533d92aab2ca2)) +* perf monitoring of `:boot/desktop` ([381e914](https://github.com/athensresearch/athens/commit/381e9149e28bddab1d0be0ae447eda57abfac557)) +* rendering performance monitoring. ([64a497d](https://github.com/athensresearch/athens/commit/64a497d75becfbcaf866a30161acc5cc66183317)) +* router perf monitoring ([b90f9c4](https://github.com/athensresearch/athens/commit/b90f9c4448d3c0ed1578bf5b301be7a6715dac9b)) +* Sentry perf monitoring edition async-flow ([cd0d0d9](https://github.com/athensresearch/athens/commit/cd0d0d959ff7a029fe4fc3f53d4d49942534779a)) +* use the same undo for local and remote ([0dadf6d](https://github.com/athensresearch/athens/commit/0dadf6d1d4016e2b85b90c5ae92663a3fed2cbc1)) + + +### Bug Fixes + +* add missing type hint ([ead4788](https://github.com/athensresearch/athens/commit/ead478859b9ebaa22add4924cd57c335f84781cd)) +* allow detect chromium via user agent ([f1e51cf](https://github.com/athensresearch/athens/commit/f1e51cf6857f971a95ccf1d59f10afaee8651a8f)) +* also cover cmd+q and activate states in mac ([2a520a7](https://github.com/athensresearch/athens/commit/2a520a7995ce1d10cf83f35203353225c93b9d33)) +* also disable block-uid-nil-eater for clj ([fbdfcee](https://github.com/athensresearch/athens/commit/fbdfcee027e731b0b4b059253b6b83e77e37ad00)) +* also navigate on delete if uid matches ([47f11c4](https://github.com/athensresearch/athens/commit/47f11c4c5224279d7685a82669c7c43282c6492e)) +* always use athens undo/redo ([8ed0d0a](https://github.com/athensresearch/athens/commit/8ed0d0aa16f2bf787fbc36929e68c21c2759dd53)) +* athena navigation test should wait for boot ([3bd0504](https://github.com/athensresearch/athens/commit/3bd0504876396f4a52f38bb5530b39b41de9f078)) +* athena tests should cleanup page ([399a08b](https://github.com/athensresearch/athens/commit/399a08bfe232c17aa581901b45641fc309a117e7)) +* block ref render should be reactive ([d9dc5da](https://github.com/athensresearch/athens/commit/d9dc5dafd6d1805f94976688acc114c58a99d04e)) +* boot sequence and db-dump async flows ([7f2429e](https://github.com/athensresearch/athens/commit/7f2429e42f337317a895deb359371f3adb7f6fcb)) +* check if tx is running, don't assume ([d0ba723](https://github.com/athensresearch/athens/commit/d0ba723f2944a82a6211adb4027908beb552993e)) +* clicking inline ref count should show them ([1a6297d](https://github.com/athensresearch/athens/commit/1a6297de027641d72d7e6a42eb30f578763409ad)) +* daily notes should update reactively ([2f4d8a9](https://github.com/athensresearch/athens/commit/2f4d8a927dbb0ae3e34800dbb5aee3bd01eac429)) +* defntrace should stringify name ([ca04841](https://github.com/athensresearch/athens/commit/ca04841200d3014e7069c4dbb614ac801f006926)) +* defntrace should work in clj ([d339806](https://github.com/athensresearch/athens/commit/d339806b10e2a56bd61c75a213580c48e583ae4b)) +* don't `div` just fragment ([e1039c3](https://github.com/athensresearch/athens/commit/e1039c345228c19586c5a1fc7c196fc4922aa331)) +* don't health check datoms db-dump ([ce79565](https://github.com/athensresearch/athens/commit/ce795658802435eddf1f5dd8381cdb445b9464f3)) +* don't health-check empty dbs ([89668e9](https://github.com/athensresearch/athens/commit/89668e9208728c0791bb17cfd4636e60e4237ad8)) +* don't remove used helper fn ([841572b](https://github.com/athensresearch/athens/commit/841572b68c434fdc803f16abca2c7f10a4fddb35)) +* don't report all console debugs to Sentry. ([4b0bc13](https://github.com/athensresearch/athens/commit/4b0bc13e74026a69f33af8b231b30feb019e933c)) +* get-node-document still needs to return children strs ([d19c776](https://github.com/athensresearch/athens/commit/d19c776e0041439998eb5d3ce18ab564353fcf2f)) +* guard pulls in common-db ([45683a2](https://github.com/athensresearch/athens/commit/45683a274e8add1d1494bbb9a753df8bcb032805)) +* initialize reactive watchers on startup ([#2037](https://github.com/athensresearch/athens/issues/2037)) ([bd0441c](https://github.com/athensresearch/athens/commit/bd0441cc519b5c3980589c013784911a7041a7c8)) +* make copy block tree great (again?) ([fa2d3e6](https://github.com/athensresearch/athens/commit/fa2d3e65020c73d8e4f1dd815170763cf23de8f1)) +* match posh pull behaviour for refactored fns ([65d3c00](https://github.com/athensresearch/athens/commit/65d3c001763623e2ef73a786dfb8400931652158)) +* must check if page was removed before removing it ([48d26b8](https://github.com/athensresearch/athens/commit/48d26b80fa0db2ac76acf1cb0dfff19a1ba30fca)) +* navigate to page from athena when result is for a block ([b6ce6cf](https://github.com/athensresearch/athens/commit/b6ce6cfa3bcced42b0f69944c304c569e227a0e3)) +* no more warnings for no reason. ([239bab7](https://github.com/athensresearch/athens/commit/239bab777b0d2615846a78d5eee8e0ec41e99b04)) +* not pushing sentry span here anymore ([df60f26](https://github.com/athensresearch/athens/commit/df60f269bbb5a9ecbb8ceaf00a3e2095005386be)) +* remove timeout from saveLastBlockAndEnter ([7568345](https://github.com/athensresearch/athens/commit/75683456924a4298a72e62cac58d62389c90d9f6)) +* review items ([5a7b6b3](https://github.com/athensresearch/athens/commit/5a7b6b381550fc1138cdcb84c4dc4ce698d9990a)) +* set loading while reconnecting ([1771d21](https://github.com/athensresearch/athens/commit/1771d21d4507b1ec238e0d79f9719af62f6b3c05)) +* textarea should not be rendered when not editing ([a4f11e2](https://github.com/athensresearch/athens/commit/a4f11e2fa8a20ab202d4f8f9dbbecc2c44f970e5)) +* this interceptor was promoting span to auto-tx ([1951e68](https://github.com/athensresearch/athens/commit/1951e6895de100c77234827f6c3b42a1cd7792fb)) +* update existing cached dbs to have http-url ([074dc7e](https://github.com/athensresearch/athens/commit/074dc7ec2673d17d8ae266f784bd1bc034529bd3)) +* update test should focus on a single update ([87ec730](https://github.com/athensresearch/athens/commit/87ec7304dea086caf473711f007b2540b1b902b2)) +* use async-flow for local loading db too ([9577575](https://github.com/athensresearch/athens/commit/9577575f00379f47ebb573f1c27484b9063f9acf)) + + +### Work in Progress + +* Interceptors ([bedb901](https://github.com/athensresearch/athens/commit/bedb9015855d198889f44565ca2a703af3298190)) +* start wrapper around Sentry perf monitoring ([65daccb](https://github.com/athensresearch/athens/commit/65daccb2b1a50f7db5ea462d747e9ed8425e6d58)) + + +### Refactors + +* add a few e2e utils ([75d3f3d](https://github.com/athensresearch/athens/commit/75d3f3dff628e2d2592cab2a94dfda383a53a7da)) +* add inputInAthena helper ([c3a831a](https://github.com/athensresearch/athens/commit/c3a831a4fe5c8ecd8cc0aa17464cff12b3d04dc9)) +* blocks pull their own data ([f64539e](https://github.com/athensresearch/athens/commit/f64539ecef940f3ce1ed0cfb4a6b5630a6ecbd15)) +* disable posh during reset-conn ([8215194](https://github.com/athensresearch/athens/commit/821519445a052dc934f8a7af1c71cc2ab1006740)) +* ensure title is visible after page creation ([0da7251](https://github.com/athensresearch/athens/commit/0da7251fb6bd327fb77050253a924a91ab888e6c)) +* factor out async flow and sentry tx ([f65f8e6](https://github.com/athensresearch/athens/commit/f65f8e6085a9a96e082d69120d904d2b00cd2b8a)) +* finish async flow just through resolve-transact-forward ([949665d](https://github.com/athensresearch/athens/commit/949665d3c47fada3ce48e32967a1858e79f829a7)) +* fold :remote/forward-event into :resolve-transact-forward ([df62d52](https://github.com/athensresearch/athens/commit/df62d52ddcd15a3cf0a5309f5fb4075a2c309fd8)) +* get-block is not used reactively ([dd24335](https://github.com/athensresearch/athens/commit/dd24335d94219fe2bbd376a62e9951719a02686c)) +* isolate and optimise get-block-document ([1d0112d](https://github.com/athensresearch/athens/commit/1d0112df5a50c7c6090303fd737d7a5df49159b3)) +* isolate and optimize get-linked-references ([a14861c](https://github.com/athensresearch/athens/commit/a14861cf05cecc60680820e2eb1148bcad9d63a8)) +* isolate and optimize get-node-document ([2d4dbf0](https://github.com/athensresearch/athens/commit/2d4dbf0af831590c882a0423c45fff9e4ac6687e)) +* isolate and optimize get-parents-recursively ([099c574](https://github.com/athensresearch/athens/commit/099c574e2626b9b7969d4d73e143c1fb22326b4d)) +* move block page linked refs into own comp ([30d6c5d](https://github.com/athensresearch/athens/commit/30d6c5d9f7020313f5c6ceffd3ddca768e1d7d0c)) +* move block page parents to own comp ([ef76bb3](https://github.com/athensresearch/athens/commit/ef76bb3ee7a3d1e94ca31a83254ccf6c052dd35d)) +* prefer editing/is-editing over editing/uid ([ae37edd](https://github.com/athensresearch/athens/commit/ae37edd84ec3a04ce9b3807986fb4a6ea6025235)) +* remove a few leftover uses of posh ([2119b13](https://github.com/athensresearch/athens/commit/2119b133c894f13c789eeb2f9625a7886cb9e853)) +* remove block-uid-nil-eater from txs ([59c2395](https://github.com/athensresearch/athens/commit/59c23958730b4dcf51cb34171eb9b37adf39a327)) +* remove old single player undo ([7f51c40](https://github.com/athensresearch/athens/commit/7f51c404a8952aa00339adc317451aa1894e8d19)) +* rename deftrace to defntraced ([5a08a2b](https://github.com/athensresearch/athens/commit/5a08a2b3e51bbff990efa0505393812c4c7a9d47)) +* resolve https on db creation ([f8f9f22](https://github.com/athensresearch/athens/commit/f8f9f220561cd99eaa7ba199dbbcd249c02ef1fa)) +* resolve https on db creation ([5538e48](https://github.com/athensresearch/athens/commit/5538e4870a428674bded40d83dab3c8e1c10124c)) +* tighten posh use ([1917b7e](https://github.com/athensresearch/athens/commit/1917b7e7d0563785c05d587560a5ec4c40e77c7c)) +* use new Sentry tracking macro ([fae6cc8](https://github.com/athensresearch/athens/commit/fae6cc8cbfa53649ad131a979d31101fe7a947e1)) +* wrap body in deftraced ([27ed22d](https://github.com/athensresearch/athens/commit/27ed22daa1188b704b725778f24fac2c45185eb7)) + + +* add a base electron test script ([0a3f4ba](https://github.com/athensresearch/athens/commit/0a3f4ba6ecc212725f0fa12ff5265502c96de77b)) +* add copy refs test ([74faa9a](https://github.com/athensresearch/athens/commit/74faa9a4186557b063dac378339dcf58266be10d)) +* add undo e2e ([ac66d00](https://github.com/athensresearch/athens/commit/ac66d00f59eca76e6409b0abd8fd3df217599569)) +* cache the build for e2e ([72cfe9c](https://github.com/athensresearch/athens/commit/72cfe9c4986a9a38f9f8cc3631eecd5d41e64c78)) +* cleanup log/info ([94e93d2](https://github.com/athensresearch/athens/commit/94e93d2f80ffacaa38d3f56aa913c243a91754e1)) +* fail e2e faster ([1f10d69](https://github.com/athensresearch/athens/commit/1f10d6979fceba8583aee9d15e413c11aa618d26)) +* fix ([f34d414](https://github.com/athensresearch/athens/commit/f34d414ac883c5d27ee04a566d1a0da562b4dbdc)) +* fix lint error ([9c788f8](https://github.com/athensresearch/athens/commit/9c788f81f669caf2ae6ce4420d2e96db2a74d3ea)) +* fixes ([ddcb4ea](https://github.com/athensresearch/athens/commit/ddcb4ea5c83152794a8b7673462e07f66606aaad)) +* remove unused fn ([04ff24c](https://github.com/athensresearch/athens/commit/04ff24c96fbea30b345cb1f45235bab5f549ede9)) +* remove unused ns ([73b6005](https://github.com/athensresearch/athens/commit/73b6005f7897adcdf31cf612c60c45f0f3b0dcd4)) +* remove unused require ([8238259](https://github.com/athensresearch/athens/commit/823825922c5cadf9d31e9d05d3f77eee186418c9)) +* removed unused fn ([9f4517b](https://github.com/athensresearch/athens/commit/9f4517bf915e725588bad227ad8539fa58bb98d5)) +* rename Sentry's TX and span so we know where it's coming from. ([dcb3522](https://github.com/athensresearch/athens/commit/dcb352277903df809484ee523330ec8610f5c66e)) +* shorthands and environment names ([9dc0667](https://github.com/athensresearch/athens/commit/9dc0667353230c51fdcaebd9017e0cb7c5f31fbf)) +* use default reporters for e2e ([63c43ea](https://github.com/athensresearch/athens/commit/63c43eacd057c389b803fac6498f43727004ab4b)) +* use multiple workers on web e2e ([43a128d](https://github.com/athensresearch/athens/commit/43a128d25c6f137d8ace0c73d6c57bea13aa6a25)) +* use primarily web build for e2e ([73821c0](https://github.com/athensresearch/athens/commit/73821c0ee943ad7a9bdf2cebea616353682bf4f7)) + +## [2.0.0-beta.15](https://github.com/athensresearch/athens/compare/v2.0.0-beta.14...v2.0.0-beta.15) (2022-02-02) + + +### Bug Fixes + +* a few bad electron specific invocations ([08bee39](https://github.com/athensresearch/athens/commit/08bee39822f09e2bc39731c8000b94632890ef14)) +* allow any domain to health check ([7f9e653](https://github.com/athensresearch/athens/commit/7f9e653c4e2aae01ffe2c06bfd4311dd4ce7a0c7)) +* clicking on a breadcrumb should it as the new parent view ([1bb6c0b](https://github.com/athensresearch/athens/commit/1bb6c0b31d7ca4b27fa78537a1e7ecba28640015)) +* open/close inline refs should reset breadcrumbs state ([5042d1e](https://github.com/athensresearch/athens/commit/5042d1e0c95781512c668dcb9c8590114efc3657)) +* reader features should be under compiler-options ([6186245](https://github.com/athensresearch/athens/commit/618624526d0f253d3d873e6d9c59b556eef7de39)) +* remove stray print ([c6c458a](https://github.com/athensresearch/athens/commit/c6c458a43849ed40c0ca023348ec61d0a827ed0d)) + + +### Refactors + +* centralize electron requires, provide good errors ([4c540b4](https://github.com/athensresearch/athens/commit/4c540b4a76f52612a4f83c4f8b78b57f7e7d39fe)) +* unify web and desktop boot ([75e66d8](https://github.com/athensresearch/athens/commit/75e66d83b47972d2227127b109814ceb04fc2c40)) +* use reader conditionals for electron code ([dabbf59](https://github.com/athensresearch/athens/commit/dabbf59fdd153e0cec8554298139fc949b577c7a)) + + +* add note about local docker builds on M1 ([fef4f65](https://github.com/athensresearch/athens/commit/fef4f6530d394808c68d33f6f19312c6168c9687)) +* deploy v2 browser app on release ([d2e71be](https://github.com/athensresearch/athens/commit/d2e71be5db0587d5b06a52b55e4e4e5da366082c)) +* deploy web client to vercel ([d954288](https://github.com/athensresearch/athens/commit/d954288f8575d110b4e920e7f4155286f2f1f274)) +* fix ([176af6f](https://github.com/athensresearch/athens/commit/176af6f49a0cc3af9317f04661751d6bba5e005e)) +* fix release-web tag check ([c6e7919](https://github.com/athensresearch/athens/commit/c6e7919717e5338fde8a24e3da41272bf14041fe)) +* fix vercel cp ([9f7cf43](https://github.com/athensresearch/athens/commit/9f7cf43d9c6e1d7859614d7fa44f4e179ca82214)) +* remove unused file ([5902e9a](https://github.com/athensresearch/athens/commit/5902e9a444e7d419ba4ef11a4bdf7d21339cee51)) + +## [2.0.0-beta.14](https://github.com/athensresearch/athens/compare/v2.0.0-beta.13...v2.0.0-beta.14) (2022-01-31) + + +### Features + +* show references inline ([#2006](https://github.com/athensresearch/athens/issues/2006)) ([bf568d8](https://github.com/athensresearch/athens/commit/bf568d8188607a43639a7019b48ccf274a4be818)) + + +### Bug Fixes + +* include :block/{new,save,remove} in the reorder exception ([ffac7f7](https://github.com/athensresearch/athens/commit/ffac7f7388da837e1e6261cee6f67ed3e41931b2)) +* redo-middle-move-composite should be comparing both pages ([9984e63](https://github.com/athensresearch/athens/commit/9984e6323288ffdf66a86fb9d10b5f699cc1f4bf)) + + +* add undo move/remove failure scenarios ([7551a7d](https://github.com/athensresearch/athens/commit/7551a7d383f15223c530732c54075c141cad9c1e)) + + +### Refactors + +* binding should reflect operation ([ab36105](https://github.com/athensresearch/athens/commit/ab36105bf9844ed23aee835ed3b78cde9164a23e)) + +## [2.0.0-beta.13](https://github.com/athensresearch/athens/compare/v2.0.0-beta.12...v2.0.0-beta.13) (2022-01-24) + + +### Bug Fixes + +* **indent:** if sibling block is closed, open ([a43a71c](https://github.com/athensresearch/athens/commit/a43a71cb50b1f106ad588254e34c03db9579d8d4)) +* pressing up and the previous block has a closed child ([d2b795f](https://github.com/athensresearch/athens/commit/d2b795ff65b886847bf02ff62462aee0481e5ad0)) +* small paste bugs ([#1991](https://github.com/athensresearch/athens/issues/1991)) ([18d76e4](https://github.com/athensresearch/athens/commit/18d76e4f11426e596e4d8c0473cf4a1965068d3d)) + + +### Enhancements + +* **help-popup:** Use more general term for search shortcut ([312b1ce](https://github.com/athensresearch/athens/commit/312b1cea1dff1d90a311a12fb25be51b9281e302)) + + +* **deps:** bump engine.io from 4.1.1 to 4.1.2 ([e4cdb87](https://github.com/athensresearch/athens/commit/e4cdb876dc441bd712721ae2117bbc99a827234c)) +* **deps:** bump follow-redirects from 1.14.1 to 1.14.7 ([d11e39c](https://github.com/athensresearch/athens/commit/d11e39ca8a27139560812b1f35af056c5f0e34e1)) +* **deps:** bump log4js from 6.3.0 to 6.4.1 ([a4153bb](https://github.com/athensresearch/athens/commit/a4153bb89815e32484718c797e283f69f01bbb69)) +* **deps:** bump node-fetch from 2.6.1 to 2.6.7 ([f257800](https://github.com/athensresearch/athens/commit/f2578005779b2f2262c6e718b102c0004944d23c)) +* **deps:** bump trim-off-newlines from 1.0.1 to 1.0.3 ([fa7f247](https://github.com/athensresearch/athens/commit/fa7f247a1320677dd215fe2ee57c27f61e305437)) +* remove marked dependency ([#1997](https://github.com/athensresearch/athens/issues/1997)) ([4558bc2](https://github.com/athensresearch/athens/commit/4558bc20039ce1b00e86bff974d82e4af6686efe)) + +## [2.0.0-beta.12](https://github.com/athensresearch/athens/compare/v2.0.0-beta.11...v2.0.0-beta.12) (2022-01-20) + + +### Features + +* add :page/merge undo ([dce25c5](https://github.com/athensresearch/athens/commit/dce25c5bd24a20be1a34479ac91926d4d1dc1dd6)) +* add order DSL ([5dd40dc](https://github.com/athensresearch/athens/commit/5dd40dc96353c37673bbcefd5dfc617a8287c030)) +* build-paste-op returns a flat list of atomic ops ([15f54dc](https://github.com/athensresearch/athens/commit/15f54dccf64d5646b407aa78cb0de55f037bbce4)) +* build-undo-event with support for composite ([90390d8](https://github.com/athensresearch/athens/commit/90390d886ded4fb6f3911a16f8a19685ebe07aed)) +* group block save at the end of paste-op ([dc92885](https://github.com/athensresearch/athens/commit/dc92885c96088d1cfc6ddb29c2d5404d30010f85)) +* redo testing of `:block/new` and `:block/remove` ([006d5dc](https://github.com/athensresearch/athens/commit/006d5dc32e2d39e3f00d5d1d4ad85bb8c12616ec)) +* restore shortcut on undo page/remove and page/merge ([7471f90](https://github.com/athensresearch/athens/commit/7471f905569a4489cb48f4064c659e6b1a3a8bc1)) +* support :block/remove in undo ([d105f6d](https://github.com/athensresearch/athens/commit/d105f6d3171bcb63f9db535fd3f4f0faa024f276)) +* support undo/redo for lan party ([a0682d6](https://github.com/athensresearch/athens/commit/a0682d6dcfa328a3762772baa736f9c87a1f481f)) +* undo `:block/new` operation with initial tests. ([dabd498](https://github.com/athensresearch/athens/commit/dabd4982663ad9317b4cad9877cdd4bb0170cf02)) +* Undo `:page/rename` ([a4f63bd](https://github.com/athensresearch/athens/commit/a4f63bdda96d661a07c62d3d7bfb03614e804ffb)) +* undo composite ([2bb5ff3](https://github.com/athensresearch/athens/commit/2bb5ff3090c5e047c6763e437e6cabd499e024db)) +* undo composite improvements ([f381702](https://github.com/athensresearch/athens/commit/f381702a0182adf52b214ea4226662becee62eed)) +* undo for block/open ([a4385d2](https://github.com/athensresearch/athens/commit/a4385d2db1f065764cd01d8b641a8df2ad35e398)) +* undo resolver for :block/save ([e3c643e](https://github.com/athensresearch/athens/commit/e3c643e248f92eb05e8a8cc8cbf4fd82f0689941)) + + +### Bug Fixes + +* don't return repr with uid for title or open true ([99ac570](https://github.com/athensresearch/athens/commit/99ac570f92a63c3f20d006dded4ff70cef36b3a8)) +* more places that need to account for :block/open? default value ([9f3234f](https://github.com/athensresearch/athens/commit/9f3234fde49f34446a6a77a1477e85f44a16c43e)) +* resolver for composite ops was not matching vector return format ([38d8429](https://github.com/athensresearch/athens/commit/38d842959e6f3eb0bd65f402c6f50fbf5b043d2f)) +* support undoing contiguous moves ([acaef19](https://github.com/athensresearch/athens/commit/acaef19128a899a79223aa4645f81602555d0628)) +* undo-op should receive current and op dbs ([33d0b78](https://github.com/athensresearch/athens/commit/33d0b78bed10d7b6d46500cc5228a6c987f402c1)) +* use atomic op in undo test ([0572702](https://github.com/athensresearch/athens/commit/0572702cca07792cb00cb431bb18725c4f293b43)) + + +### Work in Progress + +* refactor functions; stuck on why indices aren't updating ([4b601cf](https://github.com/athensresearch/athens/commit/4b601cf8704f5b96a2789081d5f80a03e9b15caa)) + + +### Documentation + +* add docstrings to athens.common-events.resolver.order ([60bc879](https://github.com/athensresearch/athens/commit/60bc879ccb2e196b418f62c7640ce92db929d897)) +* add note about impl source ([0367287](https://github.com/athensresearch/athens/commit/036728747fb79c4bca013480556d0cbbc3416e13)) + + +### Refactors + +* add common undo ops to fixture ns ([d7632de](https://github.com/athensresearch/athens/commit/d7632de0bb3600c23a958bf91c622c126ff88d28)) +* add get-position helper ([f2c4aaf](https://github.com/athensresearch/athens/commit/f2c4aafc5e0ae9f89ce4d54b2de81ab992181d91)) +* cleanup for review ([238a1b3](https://github.com/athensresearch/athens/commit/238a1b3bf50697ac694a96f3b2e5f87bea9199d6)) +* don't warn on compat-position ([f3ff90b](https://github.com/athensresearch/athens/commit/f3ff90b62386a81c03321af6e5108dc8e46391fb)) +* review items from jeff ([1351d58](https://github.com/athensresearch/athens/commit/1351d589b4209fdd840bf906c79264a72a456211)) +* undo resolver returns vector of ops ([07af7cb](https://github.com/athensresearch/athens/commit/07af7cb66c341249e6efed20c500c5d1d9aaef34)) +* use backrefs instead of _refs ([2a71bd6](https://github.com/athensresearch/athens/commit/2a71bd6007a475a353fc04e8fa57e3bba1357e28)) +* use kw as fn ([e14d6d6](https://github.com/athensresearch/athens/commit/e14d6d663ba4a347b5ca910a07f344f1fbce6516)) +* use order in block/move ([b00399e](https://github.com/athensresearch/athens/commit/b00399e20878194313ceaf56f9c1b8f1ed578416)) +* use order in block/new ([be4af63](https://github.com/athensresearch/athens/commit/be4af63e15ae7ed618c363b5943269abfd918716)) +* use order in block/remove ([72aeded](https://github.com/athensresearch/athens/commit/72aeded89f5a8542c73b3ea8229d014c0b696cee)) +* use order in page/merge ([3993184](https://github.com/athensresearch/athens/commit/39931841d376efdb75f4ed5a2ae49a056c0c369b)) +* use order in shortcut/* ([8c49cc7](https://github.com/athensresearch/athens/commit/8c49cc7d772779301b55cf04a75d6dfde4081ecb)) + + +* add compat-position tests ([79333a8](https://github.com/athensresearch/athens/commit/79333a8206c2b36d3f4b1a57d3121e0b93abfee9)) +* add tests for get-internal-representation ([6496ab5](https://github.com/athensresearch/athens/commit/6496ab57130017368ee68ea831e435b3d18ffe1c)) +* carve ([eef7ce3](https://github.com/athensresearch/athens/commit/eef7ce3240095fb02799d7b7405f3dbc9b6f7018)) +* comment not needed anymore ([8f3ff9f](https://github.com/athensresearch/athens/commit/8f3ff9f77e26fe25494af349fe669a8c45ddec1d)) +* Commented out redo tests until `:block/remove` is reversable ([562fb38](https://github.com/athensresearch/athens/commit/562fb380d6c5399fa9945a69a8e38d4c57b60ba4)) +* enable composite-of-composites-undo test ([f14f1bb](https://github.com/athensresearch/athens/commit/f14f1bb8735548cced2e16ca73ad1af19a1f58c1)) +* more test cases. ([335e10e](https://github.com/athensresearch/athens/commit/335e10ef875a1d7606c3502bbe98391a49f3de92)) +* style happy now ([c86cdb7](https://github.com/athensresearch/athens/commit/c86cdb7719bf14b1d5d97b7529ee0335e08d8122)) + +## [2.0.0-beta.11](https://github.com/athensresearch/athens/compare/v2.0.0-beta.10...v2.0.0-beta.11) (2022-01-19) + + +### Bug Fixes + +* offsetTop error from navigating to dailynotes ([debbdca](https://github.com/athensresearch/athens/commit/debbdcae078c1aec418c3c42188ef43d209bf7eb)) + + +### Enhancements + +* **db-picker:** allow users to remove dbs from list ([7ad8c27](https://github.com/athensresearch/athens/commit/7ad8c27090943382768e14987b1e7fab466a2d63)) +* **presence:** move inline avatars to the right ([9fa2d23](https://github.com/athensresearch/athens/commit/9fa2d230b88eadd16e0c8e3b247f0ebde547592d)) + +## [2.0.0-beta.10](https://github.com/athensresearch/athens/compare/v2.0.0-beta.9...v2.0.0-beta.10) (2022-01-13) + + +### Enhancements + +* stop using restore-navigation until more reliable ([81f0d93](https://github.com/athensresearch/athens/commit/81f0d93eb931e307bfc5a98c2329f08ac83d633d)) + +## [2.0.0-beta.9](https://github.com/athensresearch/athens/compare/v2.0.0-beta.8...v2.0.0-beta.9) (2022-01-10) + + +### Bug Fixes + +* `Meta+k` didn't work on Linux ([8dca24c](https://github.com/athensresearch/athens/commit/8dca24ce113eb748dab7feae5ce85b61535a4e9b)) +* delete and merge not preserving children ([ed0ab98](https://github.com/athensresearch/athens/commit/ed0ab9853574ed29e70281e109d0f4734eb3d830)) + + +* e2e utils and a cleaner tests for "delete doesn't merge" ([123ed7f](https://github.com/athensresearch/athens/commit/123ed7f79a4d4d456e7a1629aea00e6a876ec5fe)) +* rename test from template to meaningful name ([47bf1e7](https://github.com/athensresearch/athens/commit/47bf1e75d34c6546f0ea27d9577223456fec9bd4)) +* supposingly failing e2e test. ([c7c323e](https://github.com/athensresearch/athens/commit/c7c323eff39e2fdb08dd07b68d3dea2fc20f2119)) + + +### Documentation + +* add ADR for undo/redo ([1bc46ee](https://github.com/athensresearch/athens/commit/1bc46eeec70e5489325522f6ecf0d3a513da0017)) + +## [2.0.0-beta.8](https://github.com/athensresearch/athens/compare/v2.0.0-beta.7...v2.0.0-beta.8) (2022-01-05) + + +### Enhancements + +* add help manual for anchor text links ([1b1eb85](https://github.com/athensresearch/athens/commit/1b1eb85bb086d19a98f1ceb18906cba929e82c0b)) + + +* don't save db to disk on e2e ([6a8a515](https://github.com/athensresearch/athens/commit/6a8a515e6d4bc8ddd07de84b10103b374c197387)) +* update nvmrc to use 16 (active LTS) ([ea93367](https://github.com/athensresearch/athens/commit/ea93367bbeb02f7b7ccda5a4a8b9027342313113)) + +## [2.0.0-beta.7](https://github.com/athensresearch/athens/compare/v2.0.0-beta.6...v2.0.0-beta.7) (2022-01-05) + + +### Features + +* add new e2e test script ([741df9b](https://github.com/athensresearch/athens/commit/741df9b89005e310da1194ad52c462465680639b)) + + +### Bug Fixes + +* don't flicker old state on save ([14bf8f7](https://github.com/athensresearch/athens/commit/14bf8f7d77e5e887ffb9188624af23e6dcb035fe)) +* idling for 2s on a block with changes will save it ([21c6a8b](https://github.com/athensresearch/athens/commit/21c6a8b7a337738e76cb8629e24efe4c4ef0ca07)) + + +### Refactors + +* rename basic spec file ([a86c4b5](https://github.com/athensresearch/athens/commit/a86c4b56c6955555949911dd28fd9e716a6207fc)) + +## [2.0.0-beta.6](https://github.com/athensresearch/athens/compare/v2.0.0-beta.5...v2.0.0-beta.6) (2022-01-03) + + +### Features + +* render page link and block ref aliases. ([4dca7db](https://github.com/athensresearch/athens/commit/4dca7db460a698d374320d5f5e18a1cc35ff0ed5)) +* resolve-transact! can also transact without middleware ([963a9bd](https://github.com/athensresearch/athens/commit/963a9bd8d0b915e5fc10d234be49debe1ebf9202)) +* resolve-transact! returns the transacted datoms ([0f7fcd1](https://github.com/athensresearch/athens/commit/0f7fcd18fcf429389d49c003b8fe226f40160caf)) +* titled page links & block refs. ([b01158c](https://github.com/athensresearch/athens/commit/b01158c0d90c14a056654d28f4b7491431a139f3)) +* use undo instead of reset for rollback ([686f82c](https://github.com/athensresearch/athens/commit/686f82cad09a6409879f94143f405f0e4deb4b58)) + + +### Bug Fixes + +* another uuid conversion ([44e7afc](https://github.com/athensresearch/athens/commit/44e7afcc74c429a8ade0cb1c8130fcdcb42eada6)) +* apply-new-server-event to remote/update-optimistic-state everywhere ([9827d81](https://github.com/athensresearch/athens/commit/9827d81f5163e5b3b4213093556d6f91b06d2048)) +* atomically update optimistic state ([f56b7de](https://github.com/athensresearch/athens/commit/f56b7de26a2ff496e392cbacbc498a677494dec2)) +* dedicated fn to convert uuid to str ([0ad22b2](https://github.com/athensresearch/athens/commit/0ad22b2dae5f45d3ce98ff7cdc9973400f112dd3)) +* don't save tx-data info for forwarded events ([f47f577](https://github.com/athensresearch/athens/commit/f47f57719d203ed392c8e2e086a59e3fa9896c83)) +* optimistic operations need to be atomic ([e4194ee](https://github.com/athensresearch/athens/commit/e4194ee4686f382a16512d57cac1964a7384b076)) +* pin fluree ledger to 1.0.0-beta17 ([cfdfe01](https://github.com/athensresearch/athens/commit/cfdfe01652f67ad175ce42a109310a5284a96d3e)) +* reverse the buttons on the closing warning ([87bb442](https://github.com/athensresearch/athens/commit/87bb442342a379ff7a93766330a09f1311dcd4fc)) +* rollback should transact the rollback-tx, not the original again ([5ac431e](https://github.com/athensresearch/athens/commit/5ac431e8c47d3184eea8edb9c6ce5cb26aebeb85)) +* rollback warning was not comparing the right things ([839c7fe](https://github.com/athensresearch/athens/commit/839c7fe9be0a8eb28d0d47c6e44740f74f05587e)) +* show mismatched ids in rollback warning ([40334f5](https://github.com/athensresearch/athens/commit/40334f504e97a3ef06b4b9bc7346168203c728bd)) +* youtube embeds. ([a224795](https://github.com/athensresearch/athens/commit/a224795ae4544f2bbdc77b1fecd4a6b57fef24fd)) + + +### Work in Progress + +* debug on ci ([22b07e7](https://github.com/athensresearch/athens/commit/22b07e7b9e274244bdb29d973c2f39cf4fff65b0)) + + +* add client e2e tests ([9c1397f](https://github.com/athensresearch/athens/commit/9c1397f7caa3ffb7008ddcc8a8c27fd165e02cb0)) +* less logging ([a3249ca](https://github.com/athensresearch/athens/commit/a3249ca812126f0397ece8cf24f5fdb861d37953)) +* update datascript ([4742a57](https://github.com/athensresearch/athens/commit/4742a57d4bfcaaf800c7180f3f35873daa54b04f)) +* workaround playwright electron close on CI ([eed40ee](https://github.com/athensresearch/athens/commit/eed40ee014c3ad86d358215ca7d213a44152c636)) + + +### Refactors + +* add debug logs to optimistic rollback ([1e9f685](https://github.com/athensresearch/athens/commit/1e9f6859bfa3fb551a0793d209d5a9ae7e6539d1)) +* add log line before blocking fluree call ([878be13](https://github.com/athensresearch/athens/commit/878be13c8cfcbbf4161efd65cf833d4339e384d6)) +* add note about renaming event-sync ([db0bf63](https://github.com/athensresearch/athens/commit/db0bf63ac4c619b51a0b5f3f64696112ecca87f7)) + +## [2.0.0-beta.5](https://github.com/athensresearch/athens/compare/v2.0.0-beta.4...v2.0.0-beta.5) (2021-12-16) + + +### Features + +* add log recovery to athens cli ([705b735](https://github.com/athensresearch/athens/commit/705b73592545cf2aa1e307f480b7ae631d42d938)) +* add recovery fns to athens.self-hosted.event-log ([34bc91e](https://github.com/athensresearch/athens/commit/34bc91e29d8dfccc81c06c976ec6c7f9a3150826)) +* deploy notebooks to vercel ([45c7f59](https://github.com/athensresearch/athens/commit/45c7f59a0b3ae62230ef06f59b0540013a59a52a)) +* use backoff during load process ([fb9f968](https://github.com/athensresearch/athens/commit/fb9f968a5084701ec776c2182ba3ad8767a76fbe)) + + +### Bug Fixes + +* cli:compile build script had an extra single quote ([d1b2af1](https://github.com/athensresearch/athens/commit/d1b2af111b77d5a0ccef84072e4a980dea5a8206)) +* cli:uberjar needs to take in the compiled classes ([3bca14e](https://github.com/athensresearch/athens/commit/3bca14e29a3182dcf56e5c2b5e13cad3224a8920)) +* current-route for pages now uses title ([2b8dd64](https://github.com/athensresearch/athens/commit/2b8dd64fae2c17f3401a057d2ac3f7d3a4dc89ac)) +* deleting the page on the right sidebar ([446851a](https://github.com/athensresearch/athens/commit/446851ab3d7f3dec7cbe9dcbde52fe203cf11c18)), closes [#1350](https://github.com/athensresearch/athens/issues/1350) +* don't track side effects from mapped fns ([0c34e4d](https://github.com/athensresearch/athens/commit/0c34e4d5c3f5b09d8ed3521f87041112f3f2338d)) +* Fix the issue with merging blocks using the delete key ([ffadff4](https://github.com/athensresearch/athens/commit/ffadff408f36a5cb4ff4da73fea877ab6ec68422)) +* re-order conditions and add comments ([a6a29c3](https://github.com/athensresearch/athens/commit/a6a29c309b53d5009e537a10265b3c7ef9aea3d5)) +* structure parser not allowing empty page links ([9c998bb](https://github.com/athensresearch/athens/commit/9c998bbb07b58f0fe67977cdf15d9f048af994cf)) +* support updating on block remove and merge. ([5677ac6](https://github.com/athensresearch/athens/commit/5677ac6fe269971083cf56f22ada1e7589e9c2fd)) +* take into account local edit state when merge-delete. ([edb8fb0](https://github.com/athensresearch/athens/commit/edb8fb0b2e6aea5df7de7366925199478c6cf297)) +* variable naming ([8917815](https://github.com/athensresearch/athens/commit/8917815bf08c07afba37771cc1bc7c9978e956d4)) + + +### Refactors + +* move rename and test lazy-cat-while ([4d67342](https://github.com/athensresearch/athens/commit/4d67342e076c7e30b322ca927721742c70a6ec4d)) + + +* add script to clean fluree data ([7ad0374](https://github.com/athensresearch/athens/commit/7ad0374c5376eafbf76ac10fdf4d347d5f800a86)) +* **deps-dev:** bump electron from 12.0.9 to 12.1.0 ([aabbedc](https://github.com/athensresearch/athens/commit/aabbedc3d683be0e0daa5e88f7195077049c152d)) +* **deps:** bump nth-check from 2.0.0 to 2.0.1 ([3951102](https://github.com/athensresearch/athens/commit/39511023a7c1a82c8655f2dc0c6820aef6463c6d)) +* **deps:** bump tmpl from 1.0.4 to 1.0.5 ([e400529](https://github.com/athensresearch/athens/commit/e400529fe2ed2cd302195d963d702713d395bfdb)) +* don't install :npm-deps from clojure deps. ([2262f9f](https://github.com/athensresearch/athens/commit/2262f9f9856cbfd82a340ac70007df0ad6fe8feb)), closes [/github.com/thheller/shadow-cljs/issues/800#issuecomment-725716087](https://github.com/athensresearch//github.com/thheller/shadow-cljs/issues/800/issues/issuecomment-725716087) +* install dependencies and build on vercel ([#1901](https://github.com/athensresearch/athens/issues/1901)) ([b69e21b](https://github.com/athensresearch/athens/commit/b69e21b094e4bda06fe6a6b8329a038dd654c68c)) +* remove unused ns ([7db4c5e](https://github.com/athensresearch/athens/commit/7db4c5e88570a590c64614a5e67eb3db018295d1)) +* rename fluree clean to wipe ([37f58aa](https://github.com/athensresearch/athens/commit/37f58aa96ee63ff02e33301fcc7c50bad0c0f8a3)) +* update fluree/db deps ([19fcbca](https://github.com/athensresearch/athens/commit/19fcbcae7115acc19f008cd17b1ae4fac2ae9732)) +* use checkout@v2 ([1b8f6bb](https://github.com/athensresearch/athens/commit/1b8f6bb9cbfbdf5ba3b851ea72c57d593b90c43a)) +* use latest electro v12.* ([90c3455](https://github.com/athensresearch/athens/commit/90c3455db29b30fc86274ba21ecfcde7393dc612)) + +## [2.0.0-beta.4](https://github.com/athensresearch/athens/compare/v2.0.0-beta.3...v2.0.0-beta.4) (2021-12-09) + + +### Features + +* templates ([07733fc](https://github.com/athensresearch/athens/commit/07733fc42d9011633b064f4f538628cd266d0bff)) + + +### Bug Fixes + +* don't add classes to default paths ([39cb81d](https://github.com/athensresearch/athens/commit/39cb81d17ead2745ede257afcc6c6bb41b66af68)) +* handle big blocks ([599cf6a](https://github.com/athensresearch/athens/commit/599cf6a72c205287d161a4ee726f82856f8a1f71)) +* make clerk start ([d8cfd8c](https://github.com/athensresearch/athens/commit/d8cfd8cc6fb9d7743db9ac365d09797574f6c011)) +* make it save and exit. ([9c840b4](https://github.com/athensresearch/athens/commit/9c840b476d39d5eb54f04dcf6b6b850250d185b8)) + + +### Documentation + +* add rollback optimizations ADR proposal ([bbe75cb](https://github.com/athensresearch/athens/commit/bbe75cb680ab6711e44444dcf5478b1af791e785)) + + +### Work in Progress + +* Structure parser ([e1cac48](https://github.com/athensresearch/athens/commit/e1cac48438f5934b0d06c067609fac6f2aebf99c)) +* structure parser with typed-refs (just for `embed`) ([3525637](https://github.com/athensresearch/athens/commit/3525637e7d46eb5ab547135c11513cdeafd1d8f5)) + + +* ignore the notebooks build file, but watch others there ([b0197ed](https://github.com/athensresearch/athens/commit/b0197ed82d10381f86c319dc5a79490ad1c2526d)) +* style happy ([27490e4](https://github.com/athensresearch/athens/commit/27490e4edf225a3c5dcba0e539a2f6a5b5185c48)) +* support clerk notebooks ([7253a49](https://github.com/athensresearch/athens/commit/7253a4925ec9f0619fe6971ab5532aedd78220ae)) +* use uberdeps to create parametrized executable uberjars ([e4bcce6](https://github.com/athensresearch/athens/commit/e4bcce653c707462ad3ec347c376e059aa9160d1)) + + +### Refactors + +* move macro to utils. ([3f91ffb](https://github.com/athensresearch/athens/commit/3f91ffb42dd058390a925915d526cc6a6913af91)) + +## [2.0.0-beta.3](https://github.com/athensresearch/athens/compare/v2.0.0-beta.2...v2.0.0-beta.3) (2021-11-29) + + +### Bug Fixes + +* remove nils from inline presence too ([bdb8937](https://github.com/athensresearch/athens/commit/bdb8937dbff54273fdc957aa1f4fb681ca331db0)) + +## [2.0.0-beta.2](https://github.com/athensresearch/athens/compare/v2.0.0-beta.1...v2.0.0-beta.2) (2021-11-26) + + +### Bug Fixes + +* use same semantics for event replay as processing. ([c301a38](https://github.com/athensresearch/athens/commit/c301a388a527e01f4d1c986a61cbeefb4eb5213b)) + + +### Refactors + +* rename duplicate ADR 10 to 16 ([feff0c3](https://github.com/athensresearch/athens/commit/feff0c3be7a6f69861edf06c2a4d16bf4baf6d60)) + + +### Documentation + +* expand and rename event log adr ([23797ab](https://github.com/athensresearch/athens/commit/23797ab04611aed0bee2b56e1729b94c1f2c7414)) + +## [2.0.0-beta.1](https://github.com/athensresearch/athens/compare/v2.0.0-beta.1...v2.0.0-beta.1) (2021-11-25) + + +### Bug Fixes + +* run health check on client as well ([9161df0](https://github.com/athensresearch/athens/commit/9161df0814b5373452d285addfea881cc72f79cd)) + + +### Documentation + +* updates for 2.0.0-beta launch ([#1868](https://github.com/athensresearch/athens/issues/1868)) ([87b1e82](https://github.com/athensresearch/athens/commit/87b1e82df539ccf1c756481b67b36f273d5ca243)) + +## [1.0.0-alpha.rtc.46](https://github.com/athensresearch/athens/compare/v1.0.0-alpha.rtc.45...v1.0.0-alpha.rtc.46) (2021-11-21) + + +### Bug Fixes + +* this docker images was to skinny ([a209a28](https://github.com/athensresearch/athens/commit/a209a28c3e0a091a70e1ac18f7cb0c39b0fbc13e)) + +## [1.0.0-alpha.rtc.45](https://github.com/athensresearch/athens/compare/v1.0.0-alpha.rtc.44...v1.0.0-alpha.rtc.45) (2021-11-21) + + +* added comment on why we need QEMU. ([a742df4](https://github.com/athensresearch/athens/commit/a742df4c691a04d04f8987b16745c55db495f6bb)) +* linux-arm64 target supported and smaller images ([ccb51a7](https://github.com/athensresearch/athens/commit/ccb51a7b6e1a99665e741d8869fb415c05b55c57)) + +## [1.0.0-alpha.rtc.44](https://github.com/athensresearch/athens/compare/v1.0.0-alpha.rtc.43...v1.0.0-alpha.rtc.44) (2021-11-19) + + +### Features + +* handle errors and reconnect on add-event! ([e582c70](https://github.com/athensresearch/athens/commit/e582c70688e5c3fa01df9d445fb979c9d9214d3a)) + + +### Bug Fixes + +* don't print whole remote-db config ([25a5cd2](https://github.com/athensresearch/athens/commit/25a5cd294526b0bbb4f9274cf414091861aff4ec)) +* save event to log inside the same lock as db transaction ([746a2ae](https://github.com/athensresearch/athens/commit/746a2ae945014cee6050d7b82aebfb58f3e1d60d)) +* timeout on health check ([c1bc1f4](https://github.com/athensresearch/athens/commit/c1bc1f4ce06a555340d03f358f0f4ec2b9bebc60)) +* use new fluree config in defrecord ([9dc5796](https://github.com/athensresearch/athens/commit/9dc57961d63628bba4a764acfbc8d07552eebf37)) + + +* add fluree dev script ([f370c44](https://github.com/athensresearch/athens/commit/f370c44b436a5257ce301d822b816c44ddd3d023)) + +## [1.0.0-alpha.rtc.43](https://github.com/athensresearch/athens/compare/v1.0.0-alpha.rtc.42...v1.0.0-alpha.rtc.43) (2021-11-18) + +## [1.0.0-alpha.rtc.42](https://github.com/athensresearch/athens/compare/v1.0.0-alpha.rtc.41...v1.0.0-alpha.rtc.42) (2021-11-18) + + +### Bug Fixes + +* re-enable copy paste ([5936131](https://github.com/athensresearch/athens/commit/5936131bd40531cbeeab37cd899bf50722b20495)) + +## [1.0.0-alpha.rtc.41](https://github.com/athensresearch/athens/compare/v1.0.0-alpha.rtc.40...v1.0.0-alpha.rtc.41) (2021-11-18) + + +### Performance + +* don't reset conn if they are the same ([01b9b03](https://github.com/athensresearch/athens/commit/01b9b03e65695acfb28c3056111dc3911b2b073b)) + +## [1.0.0](https://github.com/athensresearch/athens/compare/v1.0.0-alpha.rtc.38...v1.0.0) (2021-11-12) + + +* disallow auto-update to pre-release versions ([af82684](https://github.com/athensresearch/athens/commit/af826845364be2d8cf05618cc06e6fdd86f0e634)) +* remove in-app update setting ([3ed9ee1](https://github.com/athensresearch/athens/commit/3ed9ee1a940209a40d5cc865a6809e8c721c98f0)), closes [/github.com/athensresearch/athens/pull/1803#discussion_r745655213](https://github.com/athensresearch//github.com/athensresearch/athens/pull/1803/issues/discussion_r745655213) + +## [1.0.0-alpha.rtc.40](https://github.com/athensresearch/athens/compare/v1.0.0-alpha.rtc.39...v1.0.0-alpha.rtc.40) (2021-11-18) + + +### Features + +* add icon example to button sotry ([85f3e3d](https://github.com/athensresearch/athens/commit/85f3e3d9e6457a780c715170ce71004ddaf0045d)) +* fill out welcome components ([62919c8](https://github.com/athensresearch/athens/commit/62919c8a79653b3a7dcd7fe819fe121830ab6121)) + + +### Bug Fixes + +* :block/remove should change titles too ([71e6751](https://github.com/athensresearch/athens/commit/71e6751fd842aa923b1af45f1e3dd7e012671dc2)) +* better logging. ([5fedd23](https://github.com/athensresearch/athens/commit/5fedd2363b6bcd61b837e8c2500b0c92be0a77b3)) +* **dbicon:** proper margins for db icon in toolbar ([5b46e28](https://github.com/athensresearch/athens/commit/5b46e28f8658309dc3cc4ba4e297c2ed6fdb53e1)) +* welcome buttons properly styled ([75f1f90](https://github.com/athensresearch/athens/commit/75f1f90ea2ef44b4281e680a1a1f61cd31dfd256)) +* **welcome:** consistent language for database ([aac0798](https://github.com/athensresearch/athens/commit/aac0798e4adf21396dc9796a0d5ed215706e75af)) + + +### Refactors + +* **button:** reimplement button content spacing ([6f73fdc](https://github.com/athensresearch/athens/commit/6f73fdc2403bd3a5bd823bfddc4d3833dee842e6)) +* remove semantic events support from server ([7e2ad7d](https://github.com/athensresearch/athens/commit/7e2ad7de07517e61b185cb0689e6b3bdb567cafb)) + + +### Work in Progress + +* cleaner logging ([759270a](https://github.com/athensresearch/athens/commit/759270a6537736f63c5c6ac42e088006415d3a39)) +* logging cleanup round 1. ([e2cbdea](https://github.com/athensresearch/athens/commit/e2cbdeab023a4025524e5ae43a2320e3643d2f0a)) +* things and stuff removed ([7c282e8](https://github.com/athensresearch/athens/commit/7c282e8f2c90f76d644a1e6d31ac57a5b72a9bb4)) + + +* add source maps and pseudo names to electron build ([9bc5c26](https://github.com/athensresearch/athens/commit/9bc5c266950d3ebf989c5155250b0e22b5bb81fd)) +* log size of memory-log when replaying. ([ac63e87](https://github.com/athensresearch/athens/commit/ac63e87cff0ef860a34b72b1cd6daef29c94d619)) +* style happier ([ff728aa](https://github.com/athensresearch/athens/commit/ff728aafa5c681d9d29936c67c46f6ac853be594)) +* yarn clean should remove all .js and .js.map, and be crossplatform ([067f64c](https://github.com/athensresearch/athens/commit/067f64c056af4742c58aa008b8dd8cd07410dcbb)) + +## [1.0.0-alpha.rtc.39](https://github.com/athensresearch/athens/compare/v1.0.0-alpha.rtc.38...v1.0.0-alpha.rtc.39) (2021-11-16) + + +### Features + +* **help-popup:** Add help popup with content. ([178eb09](https://github.com/athensresearch/athens/commit/178eb0954f15ad6a66ab7fa4c293d75017b70cad)) +* **help-popup:** Add tooltip to help icon ([c3e1d0b](https://github.com/athensresearch/athens/commit/c3e1d0b5885466c77550e3739d8ce8f5b2f32bd4)) +* **help-popup:** Comment out help links until we have ([fcc6c4c](https://github.com/athensresearch/athens/commit/fcc6c4cf416ffb491a5800c0a9edae9c1e0d609d)) +* **help-popup:** Fix a few shortcuts ([c96ace5](https://github.com/athensresearch/athens/commit/c96ace58d50f46fa5eb0386d12c86c7edea0f3a9)) +* **help-popup:** Fix border mismatches ([17b1f45](https://github.com/athensresearch/athens/commit/17b1f45cffcfa777b7b1d7b1e2880b9d7cbafe26)) +* **help-popup:** Fix closing of modal using escape. ([1e6c8f8](https://github.com/athensresearch/athens/commit/1e6c8f8e41dcabffe1a08fab5830e813c44ec605)) +* **help-popup:** Fix copy and remove a shortcut that ([c706c89](https://github.com/athensresearch/athens/commit/c706c89fc4c9ba5761fb9ec44085f4a0a696d4dd)) +* **help-popup:** Fix issue with scroll-bar and move ([5b27673](https://github.com/athensresearch/athens/commit/5b27673f207d2458cce81c7e57aa4299cb39a9af)) +* **help-popup:** Fix scroll behaviour ([3f77421](https://github.com/athensresearch/athens/commit/3f774210affadf7b01a2ec8fb672708f1b4122bf)) +* **help-popup:** Fix spacing ([6924941](https://github.com/athensresearch/athens/commit/6924941a5a41a90564b7c69f0226f6913325ddf4)) +* **help-popup:** Remove "code block" from Help. ([55042ae](https://github.com/athensresearch/athens/commit/55042ae560a143a3bf7049cc03601644158619d0)) +* notify user of page being removed from beneath. ([bbb0bd9](https://github.com/athensresearch/athens/commit/bbb0bd98f12fc301afe064ec497b15d2d3c9fea6)) +* use athens protocol for initial events in local ([1d3ba34](https://github.com/athensresearch/athens/commit/1d3ba34029444b0fe5c5f9f2db4d74d5f9d55f0c)) + + +### Bug Fixes + +* all the warnings and errors in help POPUP ([be59fe2](https://github.com/athensresearch/athens/commit/be59fe26984e1573949805c5094649b71076af21)) +* better current page title detection ([2c10096](https://github.com/athensresearch/athens/commit/2c1009612ccf3f9f28a5d492502d55a6b6f41f78)) +* don't delete dbs from disk in db-picker ([0ce8eee](https://github.com/athensresearch/athens/commit/0ce8eee4c104b62745ee885a8cff282ad92d120a)) +* editing/target should delegate to editing/uid ([179bc64](https://github.com/athensresearch/athens/commit/179bc64a9b38ae6264c1beca83fa03a3eb9a2a60)) +* embeds show presence as well as the original block ([679c1e1](https://github.com/athensresearch/athens/commit/679c1e1cf13abdb0cfd44e386d571e6ebbfe4ee9)) +* enable `shortcut/move` atomic op on server. ([33ab6b7](https://github.com/athensresearch/athens/commit/33ab6b7940a566d728fd9cf7cd4e6d73549846bf)) +* go to default db after connection failure ([d503b28](https://github.com/athensresearch/athens/commit/d503b287f43daee6cc83040a7f40cc6676ee5a3f)) +* group-title is now nested in a vector ([76d2b05](https://github.com/athensresearch/athens/commit/76d2b054b842d74eddc39c3f005e4d60af986b0a)) +* health-check server before connecting to websocket ([8bd701c](https://github.com/athensresearch/athens/commit/8bd701cf999f220953623e67975af513432bf880)) +* locally determine old string for do nothing check ([e996564](https://github.com/athensresearch/athens/commit/e996564a3f70b450420737e4a6957c06f2d5a163)) +* remove block/order from IR ([ae50ec5](https://github.com/athensresearch/athens/commit/ae50ec5321387a49fce1f765efad59ecefec91dc)) +* we this argument `page/title` not `name` anymore ([fabe834](https://github.com/athensresearch/athens/commit/fabe8349e3d57ebf7d608806d832a24e3464300e)) + + +### Work in Progress + +* help-popup ([ec3c304](https://github.com/athensresearch/athens/commit/ec3c30425225d8558d531cecedbb6d251ea1dbb3)) + + +### Documentation + +* add digitalocean instruction and better permissions instructions ([8f1a793](https://github.com/athensresearch/athens/commit/8f1a793e954c49c0cae2cb341b28ce2cc6e28f25)) +* Fix links to Athens Research Blog ([#1666](https://github.com/athensresearch/athens/issues/1666)) ([f7fccce](https://github.com/athensresearch/athens/commit/f7fccce389eab664a3a815d1627b976ad2e5a7d0)) + + +* auto update all builds except on v2 ([fa7d7c9](https://github.com/athensresearch/athens/commit/fa7d7c97d60442f0631ace1bae196f8c15f92949)) +* style, lint, carve ([d686d5e](https://github.com/athensresearch/athens/commit/d686d5ead9e38f49eafe51bd2104dd27b326503e)) + + +### Refactors + +* reduce log noise on non-verbose settings ([9ddf93e](https://github.com/athensresearch/athens/commit/9ddf93e84812adf7f62238adb4557a78c2b03220)) +* review athens protocol ([dd64548](https://github.com/athensresearch/athens/commit/dd6454891976dbfa7d7fba6dfdba68ab9629322f)) +* review internal representation names ([909ad57](https://github.com/athensresearch/athens/commit/909ad57364872c892b59498a05f2b06838a7e310)) +* style happy ([e8135ed](https://github.com/athensresearch/athens/commit/e8135ed111dc457a9b4712dae770d819b1fcb310)) +* style happy. ([b0cf925](https://github.com/athensresearch/athens/commit/b0cf925037abfe272504b43120cef3c60623e319)) +* use button tooltip instead ([a2c36b7](https://github.com/athensresearch/athens/commit/a2c36b776a3fbd11fcee19623a3d9f8902defcd0)) + +## [1.0.0-alpha.rtc.38](https://github.com/athensresearch/athens/compare/v1.0.0-alpha.rtc.37...v1.0.0-alpha.rtc.38) (2021-11-11) + +## [1.0.0-alpha.rtc.37](https://github.com/athensresearch/athens/compare/v1.0.0-alpha.rtc.36...v1.0.0-alpha.rtc.37) (2021-11-11) + +## [1.0.0-alpha.rtc.36](https://github.com/athensresearch/athens/compare/v1.0.0-alpha.rtc.35...v1.0.0-alpha.rtc.36) (2021-11-11) + + +### Bug Fixes + +* shortcut op is new, not add ([13ccb04](https://github.com/athensresearch/athens/commit/13ccb042b47609d007b4c73df0d26c575cc67e47)) + +## [1.0.0-alpha.rtc.35](https://github.com/athensresearch/athens/compare/v1.0.0-alpha.rtc.34...v1.0.0-alpha.rtc.35) (2021-11-11) + + +### Bug Fixes + +* re-enable `:page/merge` ([a5a3b76](https://github.com/athensresearch/athens/commit/a5a3b76fd9d8d18ce63710ea712f03eb89a25dc9)) + +## [1.0.0-alpha.rtc.34](https://github.com/athensresearch/athens/compare/v1.0.0-alpha.rtc.33...v1.0.0-alpha.rtc.34) (2021-11-11) + + +### Features + +* `:page/delete` is atomic. ([1b00805](https://github.com/athensresearch/athens/commit/1b00805022eb5e4ad71c5ea6af07762fec7af460)) +* `:page/merge` is atomic, so atomic. ([8f09340](https://github.com/athensresearch/athens/commit/8f09340214633f48f8e090991e65f6324219e157)) +* add schema for shortcut atomic operations ([95d8d40](https://github.com/athensresearch/athens/commit/95d8d40f3e04601694b912a719ea82fee7e565d1)) +* atomic `:page/rename` ([1a41e07](https://github.com/athensresearch/athens/commit/1a41e072819c1d8a83d45da5cc0692a6431a8366)) + + +### Bug Fixes + +* `:block/indent` positioning ([13d7f20](https://github.com/athensresearch/athens/commit/13d7f2087caa20beb9efb18edbb9eaa5a5fa4def)) +* `:page/merge` throwing exceptions in `orderkeeper` no more. ([bf82419](https://github.com/athensresearch/athens/commit/bf82419731e3fecfed90b07e9e0a3b5e722b96f5)) +* enable atomic `:page/rename` on protocol. ([1cb5a0f](https://github.com/athensresearch/athens/commit/1cb5a0f6bd2c4de43ba9f1818e47c7bca3b6e6df)) +* make `:page/remove` idempotent. ([e67806b](https://github.com/athensresearch/athens/commit/e67806b7da51450375912ee21304a7e21a504d34)) +* these tests should not expect exceptions anymore. ([a123490](https://github.com/athensresearch/athens/commit/a123490d1ca78cb78643edea5399add467b8a5ee)) + + +### Refactors + +* use name instead of title to identify pages in protocol ([9bc0e95](https://github.com/athensresearch/athens/commit/9bc0e95a425fc027fa31bb59640ff5babdb8f3ed)) +* use startsWith boolean output as string ([c7cb73e](https://github.com/athensresearch/athens/commit/c7cb73eafe9704e659a9fdebe2dea0c26b0b7dc4)) + + +* gh pages, auto-updates only for v1.* ([f07ea50](https://github.com/athensresearch/athens/commit/f07ea503f89febb57180d6964adb77ff56b13d7b)) +* remove in-app update setting ([bdd15a8](https://github.com/athensresearch/athens/commit/bdd15a85e5eebf50f2221ab8c28ba0d747179d70)), closes [/github.com/athensresearch/athens/pull/1803#discussion_r745655213](https://github.com/athensresearch//github.com/athensresearch/athens/pull/1803/issues/discussion_r745655213) +* use if condition on composite action ([4d708e5](https://github.com/athensresearch/athens/commit/4d708e53f05d72cb01efd29ac9d5a2d9496913aa)) + +## [1.0.0-alpha.rtc.33](https://github.com/athensresearch/athens/compare/v1.0.0-alpha.rtc.32...v1.0.0-alpha.rtc.33) (2021-11-08) + + +### Bug Fixes + +* page navigation by title including nested cases. ([7e37201](https://github.com/athensresearch/athens/commit/7e3720102b1aa016faf73fd48d08a2343415fa60)) +* use right bindings in :block/save event ([e301070](https://github.com/athensresearch/athens/commit/e301070ba0e956cf7a0e8659b7839eb75c82d1e4)) + + +### Refactors + +* remove unused require. ([76a9db6](https://github.com/athensresearch/athens/commit/76a9db686885bffdeaf5deefd94a9b7ce496b54f)) + +## [1.0.0-alpha.rtc.32](https://github.com/athensresearch/athens/compare/v1.0.0-alpha.rtc.31...v1.0.0-alpha.rtc.32) (2021-11-08) + + +### Bug Fixes + +* cleaner `page/page-by-title` ([a446a58](https://github.com/athensresearch/athens/commit/a446a5873c69efc7b29b9d204e43497340c050c0)) +* don't dispatch nil, reframe does not accept it. ([72a8272](https://github.com/athensresearch/athens/commit/72a8272d2faa0dbe3f6334b943e3168b8fa63479)) +* don't infinite loop during `:block/remove` ([e9b5180](https://github.com/athensresearch/athens/commit/e9b518098fcfff146fa0b31d3e34af3447168db0)) +* fix wrong refactor ([5dfa574](https://github.com/athensresearch/athens/commit/5dfa5744de9d543d89a836b74b2f25f5be489d81)) +* make nested page links work again. ([6e1f0e1](https://github.com/athensresearch/athens/commit/6e1f0e182e56d77a0252e003b2ac57a6717e9fc8)) +* style/carve happy ([aa54c78](https://github.com/athensresearch/athens/commit/aa54c78534dd42bc591b1694de5cebc83c9df0b0)) +* update own presence optimistically ([94ab5fb](https://github.com/athensresearch/athens/commit/94ab5fb4f1d8670a80b522555e017161fbfe73db)) +* use compat-position for child and bump up ([308356c](https://github.com/athensresearch/athens/commit/308356c327728bc017e069881235387ad6460635)) + + +### Enhancements + +* more informant 404 page. ([880133d](https://github.com/athensresearch/athens/commit/880133dfb97d8b256f324227455bfe7263516b2e)) +* navigate to pages by page title, not uid. ([66e3bf2](https://github.com/athensresearch/athens/commit/66e3bf2ba89013ec70805c5330575e0845710573)) + + +* bump fluree ([d749d88](https://github.com/athensresearch/athens/commit/d749d88a427981a1c1792a61bd7671f1ef374966)) +* separate arm64 ci build ([46da69c](https://github.com/athensresearch/athens/commit/46da69cab4255ba90a54089c5e3a420065c2ad2a)) +* this would infinite loop, but isn't anymore. ([dc36450](https://github.com/athensresearch/athens/commit/dc36450832451019fe253c9451d477f5eea5671b)) + + +### Refactors + +* remove last-tx ([cd56dde](https://github.com/athensresearch/athens/commit/cd56dde8643e4c92b8535ee367c43db84d745db4)) +* remove old-string from :block/save event ([9cea456](https://github.com/athensresearch/athens/commit/9cea456de456589aa36bb4df85954804678aedde)) +* remove response-accepted schema ([e9267c9](https://github.com/athensresearch/athens/commit/e9267c9a9ba11aa6f3660e49075751bc04afcc05)) + +## [1.0.0-alpha.rtc.31](https://github.com/athensresearch/athens/compare/v1.0.0-alpha.rtc.30...v1.0.0-alpha.rtc.31) (2021-11-04) + + +### Features + +* log resolve-transact! total time ([bda3449](https://github.com/athensresearch/athens/commit/bda34494c1b9fa9a4dff99866b7fdfbe6207bbb6)) +* remove page-uid, absolute block order use in protocol ([682c24d](https://github.com/athensresearch/athens/commit/682c24d717f19ebbf0b5a7a3c75b82f38af63aa7)) + + +### Bug Fixes + +* `:block/move` allowed to go places. ([bf2a17e](https://github.com/athensresearch/athens/commit/bf2a17efdaf2a4e85898a4b531273af4c443b2ea)) +* block internal state `drag-target` compatible with block move relative positioning. ([e8832e9](https://github.com/athensresearch/athens/commit/e8832e9ac42b3d8f246fadbdf9c29d58d54ebd58)) +* clear session list on reconnect ([114cdbc](https://github.com/athensresearch/athens/commit/114cdbcb941002b8d2c785d8867ef01528884003)) +* improve compat-position warning ([a529de4](https://github.com/athensresearch/athens/commit/a529de4ab2efd5544369565f45a3e762ccb48220)) +* improve logging for paste ([e337145](https://github.com/athensresearch/athens/commit/e33714548391060b67ce9b03b44fac89412fe83c)) +* last block on an empty page should not be 1 ([2322689](https://github.com/athensresearch/athens/commit/2322689995c06a94a078a5b27cfdaf93de6bde68)) +* position ref-uid is string ([6f3a898](https://github.com/athensresearch/athens/commit/6f3a898a60da16dc28e57ca277eaa424f2e03508)) +* style ([6f71e7a](https://github.com/athensresearch/athens/commit/6f71e7a0ffee0abf5f09f78a650d5a1ca0879446)) +* style fix. ([08dbf26](https://github.com/athensresearch/athens/commit/08dbf26d95475ca24ad298d2a8c3f8eaa486251a)) + + +### Refactors + +* faster page lookups ([74d7ecc](https://github.com/athensresearch/athens/commit/74d7ecc6745dfe546bb13c5b1e3dbf7b0ec6ca58)) + + +* all ([9159f48](https://github.com/athensresearch/athens/commit/9159f489e11f95a60226093eeed38c3b41e93cf4)) + +## [1.0.0-alpha.rtc.30](https://github.com/athensresearch/athens/compare/v1.0.0-alpha.rtc.29...v1.0.0-alpha.rtc.30) (2021-11-02) + + +### Bug Fixes + +* :block/move to new parent. ([c9da42b](https://github.com/athensresearch/athens/commit/c9da42bf3a049aa8b98b9122002813caeb2108a0)) +* take same-page/up into account when computing new-block-order ([ccac6dd](https://github.com/athensresearch/athens/commit/ccac6dd4918bda9b811d93d8e87e632238973874)) + + +### Work in Progress + +* 1st drop event moved to `:block/move` ([a0de566](https://github.com/athensresearch/athens/commit/a0de566607cf380b5f8c7564705e33ec85644556)) +* cleanup of drop blocks ([ed8dc57](https://github.com/athensresearch/athens/commit/ed8dc57c2f882b53d5696432b15e2d44033b43e4)) +* cleanup, x-mas came early this year. ([3366d4b](https://github.com/athensresearch/athens/commit/3366d4bd2b75356a38852c4c9e98ebd159bc5e0e)) +* drop diff parent using `:block/move` ([a07ef16](https://github.com/athensresearch/athens/commit/a07ef168ee706e67b472b7439ec36ffe2a2e76b1)) +* drop same parent using `:block/move` ([3ce1174](https://github.com/athensresearch/athens/commit/3ce1174397e76bc76703f5c62fc11295f2daf4ba)) +* drop semantic events cleanup. ([1a22f1b](https://github.com/athensresearch/athens/commit/1a22f1b2dd2fb8ed53ce18d51734f827cecc8292)) +* drop-multi different source parents cases using `:block/move`. ([de0c9a8](https://github.com/athensresearch/athens/commit/de0c9a833714cac0b68c384ef4100c408be31adf)) +* drop-multi/same-all using block/move-chain ([bf310bc](https://github.com/athensresearch/athens/commit/bf310bc5260f64d7e758fee57dff22520834b988)) +* failing tests. ([7472553](https://github.com/athensresearch/athens/commit/7472553867dc6a955cba882d65f960683bfe79df)) +* introduced `:block/link` re-frame event to drop links to blocks. ([b600f4c](https://github.com/athensresearch/athens/commit/b600f4c12bbfa395a47137eb5298435d09387b47)) +* last drop-multi migrated to `:block/move` ([4407534](https://github.com/athensresearch/athens/commit/4407534b5bfb955b6c4d4394c4acd1aadb4154d4)) +* moving blocks like a boss, well not really just yet. ([b9a05ed](https://github.com/athensresearch/athens/commit/b9a05edc5f261988c8862a87f72afe5c9b196b7c)) +* removed dead coda around drop events. ([60fcdbf](https://github.com/athensresearch/athens/commit/60fcdbf5f13291f700e1693856894e507bb7a0a5)) +* simple drop multi using chain of `:block/move` ([7226bd5](https://github.com/athensresearch/athens/commit/7226bd52f40d6c1d53204bf5c9a3be28ccd25c4e)) + +## [1.0.0-alpha.rtc.29](https://github.com/athensresearch/athens/compare/v1.0.0-alpha.rtc.28...v1.0.0-alpha.rtc.29) (2021-10-28) + + +### Bug Fixes + +* bring local ledger up to date during ensure-ledger! ([a15ecea](https://github.com/athensresearch/athens/commit/a15ecea8e207d7b835d848de739870b97c798cde)), closes [/github.com/fluree/db/issues/126#issuecomment-953903963](https://github.com/athensresearch//github.com/fluree/db/issues/126/issues/issuecomment-953903963) +* don't include a link to Welcome in mini-datoms ([00ba659](https://github.com/athensresearch/athens/commit/00ba6594f49c2f800ec0242268b9b6c2fd91fdc3)) +* load theme earlier in the boot sequence ([5297d0c](https://github.com/athensresearch/athens/commit/5297d0cec193db3e5beadaecda2a8b92f25fb9ff)) +* use initial datoms, but without any page links ([e1e23d6](https://github.com/athensresearch/athens/commit/e1e23d6e3675489125c0d243e81efb85ae5c0a0e)) +* workaround for fluree tx limit ([45b6877](https://github.com/athensresearch/athens/commit/45b6877554eff88a01db9334f6036f8b90e0989e)) +* workaround query delay ([0ae6ca8](https://github.com/athensresearch/athens/commit/0ae6ca83fc9e56a0966c74463859582fa0ea20d0)) + + +* don't build dmg zip ([92ca6b4](https://github.com/athensresearch/athens/commit/92ca6b41e604d5c038c247430f7270b69fb2c8ba)) + +## [1.0.0-alpha.rtc.28](https://github.com/athensresearch/athens/compare/v1.0.0-alpha.rtc.27...v1.0.0-alpha.rtc.28) (2021-10-27) + + +### Features + +* store user color between sessions ([994acf0](https://github.com/athensresearch/athens/commit/994acf0bb096e6feeb3930e4c52b8bdf4cfa4f48)) + + +### Bug Fixes + +* break shape-parent-query loop if there's no parent ([bc2d170](https://github.com/athensresearch/athens/commit/bc2d170f8d06e0050ca04e299bdb3c2407f808d8)) +* fix inline presence, go to user page ([37a5507](https://github.com/athensresearch/athens/commit/37a550789b17a5c2d20b52a05fb541074e0447cc)) +* throw on recreating page with different uid, mismatched daily page title/uid ([7cce9eb](https://github.com/athensresearch/athens/commit/7cce9eb7f952ae647b0757686ac57d7c00f8812b)) +* use daily note uid for daily note title on page create ([e26b917](https://github.com/athensresearch/athens/commit/e26b91745c87b1ffa2ee67bad17f20c88b4ceb00)) +* use daily notes uid for new pages on :block/save ([5fce285](https://github.com/athensresearch/athens/commit/5fce285eb05716fa2dbb60fb415edb222691d469)) +* use resolve-transact! in all locations, mark :transact event for removal ([d11125b](https://github.com/athensresearch/athens/commit/d11125baf527e6770a112c9d939194dccc0cc307)) + + +### Refactors + +* move date utils into cljc ns ([615ae86](https://github.com/athensresearch/athens/commit/615ae86e2e4c1948f2ef5ab60dc91a516bdaa72d)) +* use date-to-day to simplify logic ([6853a5c](https://github.com/athensresearch/athens/commit/6853a5c68e28718e176690ee497810063ee4a33e)) + + +### Work in Progress + +* cleaned requires. ([c171845](https://github.com/athensresearch/athens/commit/c171845f523388f1edf1db9edf88d5ed5f489848)) +* fixing wrong resolution and fallout ([c7c156b](https://github.com/athensresearch/athens/commit/c7c156bf0bfc35cdbf41f98cc1933e63e08c9962)) +* Marked locations to make new atomic transactions be possible. ([d2e4580](https://github.com/athensresearch/athens/commit/d2e4580034b9ddebf894d6a3edf99c2c0627792b)) +* Moving to 1 transaction per each atomic graph op. ([51225fe](https://github.com/athensresearch/athens/commit/51225fe9dae733f5ac443ed215e0eefb78309753)) + + +* add disabled test for missing block new ref ([e750d53](https://github.com/athensresearch/athens/commit/e750d53a51535317a5b6646b31de64e79ef38a95)) +* add server:wipe script ([f87d458](https://github.com/athensresearch/athens/commit/f87d4581b4263f58f3759f60fe63d93052b6bc40)) +* enable `:block/new` test that checks for existence of rel block. ([fe7b8a7](https://github.com/athensresearch/athens/commit/fe7b8a744bd992210f1b88c3c8874e1914863858)) +* pin ua-parser-js to an uncompromised version ([2d18345](https://github.com/athensresearch/athens/commit/2d1834542a9ad6608d5612036d01ec92e8d2283b)) +* style happy. ([a3c2163](https://github.com/athensresearch/athens/commit/a3c2163487373b2806de663d28244944db3a35e6)) +* update v1-to-v2 test ([ee45e99](https://github.com/athensresearch/athens/commit/ee45e99d9d5c16c44dc8ae3c662088136f656846)) + +## [1.0.0-alpha.rtc.27](https://github.com/athensresearch/athens/compare/v1.0.0-alpha.rtc.26...v1.0.0-alpha.rtc.27) (2021-10-21) + + +### Bug Fixes + +* don't fire db events on navigated while still loading ([a8122b6](https://github.com/athensresearch/athens/commit/a8122b62f0d5d9fe86d738957ddb79641cad7593)) +* let boot control loading status ([2957bca](https://github.com/athensresearch/athens/commit/2957bcae4b3a484e243655b47fd472c74292b144)) +* remove selected db on connection failure ([ef025e4](https://github.com/athensresearch/athens/commit/ef025e4eb343c5b6b5f340122e5ca3c1cdf9f47f)) +* use single exit point on reset-conn for async-flow ([b04a9b7](https://github.com/athensresearch/athens/commit/b04a9b75094aab7e2882795ca85796b284c3d22f)) + + +### Refactors + +* remove unused get-db events ([3f6fcc3](https://github.com/athensresearch/athens/commit/3f6fcc32bf49e39a22d6712befd180b1e1e36373)) + + +* style, lint, carve ([a669ffc](https://github.com/athensresearch/athens/commit/a669ffcbd5e9e53da5aeb2a1a3e87069f0bb86a9)) + +## [1.0.0-alpha.rtc.26](https://github.com/athensresearch/athens/compare/v1.0.0-alpha.rtc.25...v1.0.0-alpha.rtc.26) (2021-10-20) + + +### Documentation + +* ADR for Atomic Graph Operations and Transacting. ([23022fb](https://github.com/athensresearch/athens/commit/23022fbe05f81e676f4e4edfc91046c1fb92e14e)) + + +* update fluree ledger to 1.0 ([1db7a24](https://github.com/athensresearch/athens/commit/1db7a241a520bfd757b965060c19e0f19b0e4e2d)) + +## [1.0.0-alpha.rtc.25](https://github.com/athensresearch/athens/compare/v1.0.0-alpha.rtc.24...v1.0.0-alpha.rtc.25) (2021-10-20) + + +### Features + +* `:block/remove` delete also subtree ([9a82163](https://github.com/athensresearch/athens/commit/9a821631bb0003cb67b4316db74bd40252714b87)) +* `:block/remove` taking care of block refs too. ([b343572](https://github.com/athensresearch/athens/commit/b343572766df6284293096202129609a8ad364a9)) +* allow leaving even with unsynced changes ([31f107d](https://github.com/athensresearch/athens/commit/31f107d7a3771244ee994d9178e71c88c1eb024a)) +* basic delete works on atomics ([3fb4036](https://github.com/athensresearch/athens/commit/3fb4036ef21c8b8129e29e9441b0c322cc9ed5e1)) +* pass page title on to avatar/presence details ([240a9e7](https://github.com/athensresearch/athens/commit/240a9e7b2dccf89329201250aa06f62efdd47433)) + + +### Bug Fixes + +* disconnect rtc client when deleting db ([c9a6621](https://github.com/athensresearch/athens/commit/c9a6621f626fc0e6c0504a87f8759d6c499b6f56)) +* don't move back cursor when there's no expansion ([98cfb24](https://github.com/athensresearch/athens/commit/98cfb24cd1bb2e95932b249282714748e2488b45)) +* map user to person in inline presence ([f7fb663](https://github.com/athensresearch/athens/commit/f7fb66306c6c11cdaca90d864115c8ec57eda4ea)) +* print uuid as string ([f38121a](https://github.com/athensresearch/athens/commit/f38121a6b68309f98cbea3927b824f7f660f0e22)) +* rollback-tx-snapshot atomically ([bcb334b](https://github.com/athensresearch/athens/commit/bcb334bb3c4773d79b0840936a945b9227eb7d43)) +* use db-picker instead of client connection to determine if db is remote ([29e8d5b](https://github.com/athensresearch/athens/commit/29e8d5b1e5f22e4686351050987bd85b7e00effa)) +* use right key for block/uid on initial presence ([c140340](https://github.com/athensresearch/athens/commit/c140340ee85acf43036b486c6869ad95fb0b7a14)) +* user editing log should be debug level ([1c0b775](https://github.com/athensresearch/athens/commit/1c0b7750b34878a960effdd041be0de82e0fe040)) + + +* commit deps added by fluree to shadow-cljs builds ([d567fca](https://github.com/athensresearch/athens/commit/d567fcad3fbdd21f6bcb48e339cba99cd6ce317d)) +* fix ([fe9df20](https://github.com/athensresearch/athens/commit/fe9df20871a645109e3cb2e81cbec97bed297eb2)) +* re-enable cljs tests, except the one that needs full web build fix ([79da717](https://github.com/athensresearch/athens/commit/79da7177560250faca8aa165ae3a03493a9362ef)) +* remove unused ns ([cef5bc7](https://github.com/athensresearch/athens/commit/cef5bc78e4bfdf370b575eac4ef9d44a84f30fe4)) +* run more tests in cljs ([6a0b035](https://github.com/athensresearch/athens/commit/6a0b035cb911cfcf1890b1eca61564b9f09d5d25)) +* style happy ([ea39d0e](https://github.com/athensresearch/athens/commit/ea39d0e211a6234645c743702ad75f86a627a687)) +* style job should run style, not lint ([bdeae79](https://github.com/athensresearch/athens/commit/bdeae79f52e929669ff9ce3d4de72f20f316925e)) +* these tests need to pass ([d0981c9](https://github.com/athensresearch/athens/commit/d0981c97950deac1f4e38415da98163bc938091e)) +* use atomic `:block/remove` to remove blocks. ([9425330](https://github.com/athensresearch/athens/commit/9425330c366029b44cbeee9ff535ce34be7b72fa)) +* user docker compose server settings as default ([5b9e3da](https://github.com/athensresearch/athens/commit/5b9e3da9ae34460081e5d36a92a1b8f4f10a5adf)) + +## [1.0.0-alpha.rtc.24](https://github.com/athensresearch/athens/compare/v1.0.0-alpha.rtc.23...v1.0.0-alpha.rtc.24) (2021-10-14) + + +### Bug Fixes + +* fetch all events on startup ([42e21d6](https://github.com/athensresearch/athens/commit/42e21d6344ec0f7394499766f314720e18ba6956)) + +## [1.0.0-alpha.rtc.23](https://github.com/athensresearch/athens/compare/v1.0.0-alpha.rtc.22...v1.0.0-alpha.rtc.23) (2021-10-13) + + +* more generous docker health checks ([eb1eba3](https://github.com/athensresearch/athens/commit/eb1eba30640d444b588b5161ebc10c50c740d3a3)) + +## [1.0.0-alpha.rtc.22](https://github.com/athensresearch/athens/compare/v1.0.0-alpha.rtc.21...v1.0.0-alpha.rtc.22) (2021-10-13) + + +### Bug Fixes + +* use updated schema ns ([e27860d](https://github.com/athensresearch/athens/commit/e27860dd1f3596b01ff8e7d9e67eff19081f6095)) + + +* add health checks to docker compose, fix fluree server address ([7fa21b2](https://github.com/athensresearch/athens/commit/7fa21b20ea24e6cb16b6879bb1b5e1982d881049)) +* fixit ([79e9aa1](https://github.com/athensresearch/athens/commit/79e9aa1cbafafa6ae1514fd9d3a57e595acd141e)) + +## [1.0.0-alpha.rtc.21](https://github.com/athensresearch/athens/compare/v1.0.0-alpha.rtc.20...v1.0.0-alpha.rtc.21) (2021-10-13) + + +* increase max memory for athens server ([44e9672](https://github.com/athensresearch/athens/commit/44e9672004561fa5c10fb493b453ba644ad23ba3)) + +## [1.0.0-alpha.rtc.20](https://github.com/athensresearch/athens/compare/v1.0.0-alpha.rtc.19...v1.0.0-alpha.rtc.20) (2021-10-13) + + +### Bug Fixes + +* include default.config.edn in uberjar, only use config.edn in dev ([8a5ef89](https://github.com/athensresearch/athens/commit/8a5ef89208144a3620949874b1e68ed29fbabf05)) +* remove cljstyle workaround ([fce927e](https://github.com/athensresearch/athens/commit/fce927e99d2591c497352a83351c427510ecbdce)) + +## [1.0.0-alpha.rtc.19](https://github.com/athensresearch/athens/compare/v1.0.0-alpha.rtc.18...v1.0.0-alpha.rtc.19) (2021-10-13) + + +### Features + +* use fluree as an event-log with a datascript db ([e558ba0](https://github.com/athensresearch/athens/commit/e558ba044acab4134be16e31fa9d25d412e496f0)) + + +### Bug Fixes + +* datascript comp transacts using web transact fn ([d1e00f3](https://github.com/athensresearch/athens/commit/d1e00f3bccefb65e2969284c4b1c0f2550dfd455)) + + +* don't use prefix for docker services ([6de4ff8](https://github.com/athensresearch/athens/commit/6de4ff87016ed18eaad856bf111c955188196c5c)) +* include test in default source paths ([8cf2012](https://github.com/athensresearch/athens/commit/8cf2012f2510f3263839441bfe2ea3cd71ca83a2)) +* use immutable tag for fluree docker ref ([6013ae0](https://github.com/athensresearch/athens/commit/6013ae0e972a490f4f75b06420ebaa753f0889ea)) + +## [1.0.0-alpha.rtc.18](https://github.com/athensresearch/athens/compare/v1.0.0-alpha.rtc.17...v1.0.0-alpha.rtc.18) (2021-10-12) + + +### Features + +* `:page/new` can be composite op. ([d5635a7](https://github.com/athensresearch/athens/commit/d5635a7b1195b414b803420c9bd4b1eece22da35)) +* reject optimistically transacted events ([fc7b4be](https://github.com/athensresearch/athens/commit/fc7b4be7356891592dfc4f500c916529e67eb823)) + + +### Bug Fixes + +* :git/sha issue and ordered was released. ([e6237d8](https://github.com/athensresearch/athens/commit/e6237d8b56daf13365624d901f5f38eabec66fc8)) +* cljs log converts args to js ([7d2b29f](https://github.com/athensresearch/athens/commit/7d2b29fef5490e87c456c2c764a6e70e5705bff8)) +* update carve to fix report bug ([fb36b0c](https://github.com/athensresearch/athens/commit/fb36b0cb2c9c951d1396b0d2b1f71f31aea98f28)) + + +### Refactors + +* remove unused old event tracking subs ([118e61d](https://github.com/athensresearch/athens/commit/118e61d94a9eb2c135e9ab7e6d8e2119849f61d0)) +* yarn server runs server, yarn server:uberjar builds uberjar ([1a44b48](https://github.com/athensresearch/athens/commit/1a44b4836bf67cb2e28f4bfb8727520bec7a9423)) + + +* concurrency compatible `:block/new-v2` op with tests. ([caabff8](https://github.com/athensresearch/athens/commit/caabff840c2c0b43cac26e979424f300240352f9)) +* move from lein to clj+deps.edn ([af01e2a](https://github.com/athensresearch/athens/commit/af01e2a0d0921ac740d19b7583ff904ded2f7bc3)) + +## [1.0.0-alpha.rtc.17](https://github.com/athensresearch/athens/compare/v1.0.0-alpha.rtc.16...v1.0.0-alpha.rtc.17) (2021-10-07) + + +* cache built app ([42032f0](https://github.com/athensresearch/athens/commit/42032f0ae214729cfb276e80bab992312e459e62)) + +## [1.0.0-alpha.rtc.16](https://github.com/athensresearch/athens/compare/v1.0.0-alpha.rtc.15...v1.0.0-alpha.rtc.16) (2021-10-07) + + +### Features + +* add basic eventsync impl ([8e986d8](https://github.com/athensresearch/athens/commit/8e986d8d80d00b0dbf65888aaf9091f59d6e5915)) +* add description of EventSync ([46ffb69](https://github.com/athensresearch/athens/commit/46ffb6902a33f586c74c00bdcc52fee2707e779a)) +* block-uid nil eater part 2. ([219eea0](https://github.com/athensresearch/athens/commit/219eea0377ceae6e3b0375f4c8be3183681abd7b)) +* DB Consistency check/fix on startup and some logging. ([54de653](https://github.com/athensresearch/athens/commit/54de6537c5131c682d4fc6387d0d91f203b7b320)) +* first event-sync implementation ([8ab0f48](https://github.com/athensresearch/athens/commit/8ab0f483c21bf1302fa01db26ce457fb3b5ee178)) +* **notification:** block in notification component ([cc14bb9](https://github.com/athensresearch/athens/commit/cc14bb90987b081905d9c3c105bb0a3607abaa54)) +* **notifications:** add notifications component ([f22a957](https://github.com/athensresearch/athens/commit/f22a95701493c619ccaacbd59eff8fc2139be518)) +* process operations optimistically in RTC ([f1adb2b](https://github.com/athensresearch/athens/commit/f1adb2b6058b16a6f2602be4f7e3a2f7089cd1a0)) +* show db sync state as events left to sync ([a546e20](https://github.com/athensresearch/athens/commit/a546e20f2120d05b39e83d327bce868dd4cb341e)) +* **toast:** basic toast fn in place ([6bf841a](https://github.com/athensresearch/athens/commit/6bf841aee5a91e1e93884c3923e893c67daa26e2)) +* Uniform logging for CLJ & CLJS. ([497e237](https://github.com/athensresearch/athens/commit/497e237c74d2e4b8f04efc99717fc8e4f40a86ee)) +* use electron-window-state to persist window size and position ([30f3522](https://github.com/athensresearch/athens/commit/30f3522b5d49085cfea1ce096e266dabf7ae310c)) + + +### Bug Fixes + +* add missing folder to repo ([d57ffb9](https://github.com/athensresearch/athens/commit/d57ffb9173b63b8afcbdd6fc2755cc3c15b878d9)) +* **button:** properly unrequire props ([f5e7fca](https://github.com/athensresearch/athens/commit/f5e7fcad13d445fe1eb5d5fb1b5a0f4b0a93b2ce)) +* check selected-db, not client state for write-db ([ac95df8](https://github.com/athensresearch/athens/commit/ac95df83cdcde6583d80823bd1e990e1b42ddaab)) +* **db:** use correct default zoom level ([6896d5a](https://github.com/athensresearch/athens/commit/6896d5a4bf3081b5ea9aac2a0aac4d67df95f40c)) +* fix keyword typo ([09af693](https://github.com/athensresearch/athens/commit/09af6931fc6e5f0f13d10fc9549dce57f1eb2336)) +* log but don't error out on stale strings ([c9be401](https://github.com/athensresearch/athens/commit/c9be4013716c005996dcfdd73afc9cfcb3acbf98)) +* log correct `event-id`. ([8f5240c](https://github.com/athensresearch/athens/commit/8f5240c8b3a0d58a7a2be7b157b3976a0cdb6d7f)) +* match block uid correctly ([032199e](https://github.com/athensresearch/athens/commit/032199e9aac7b4aecfcf764610646ea48ea008cf)) +* **presence:** presence menu appears over toolbar ([d290f5c](https://github.com/athensresearch/athens/commit/d290f5c344a1bbae5545241b9b2d58dc61a62934)) +* remove unused ns require ([2a65a4f](https://github.com/athensresearch/athens/commit/2a65a4fe6070320feb39b77ebe58f629c1ab2cd8)) +* removed `:remote/db-id` as we ain't using it no mo. ([736c968](https://github.com/athensresearch/athens/commit/736c96805244317bc31810ea87cfbcebcc863a82)) +* removed unused test ([b40d29b](https://github.com/athensresearch/athens/commit/b40d29bf73695eeed7a851b91e2daa02e31ecfc0)) +* small fixes from the demo meeting ([7fa4ece](https://github.com/athensresearch/athens/commit/7fa4ece1c1e85ba4e4d420ebca199eeaca5c7bc6)) +* sync on empty memory log from post-op db ([f6e73c5](https://github.com/athensresearch/athens/commit/f6e73c50b624128db31bde0f53845d6aafc80879)) +* this is not how one transacts. ([d6564a4](https://github.com/athensresearch/athens/commit/d6564a41c6abe282e90065d59f44a556f58b5c8b)) +* **toolbar:** unzoom toolbar ([7eef057](https://github.com/athensresearch/athens/commit/7eef05744c560dd8d08100e530e3d1516c72efa3)) +* transact needs vector. ([f177902](https://github.com/athensresearch/athens/commit/f177902513faf7bd90fffb157fa2922e14fb642d)) +* typo ([16db7d5](https://github.com/athensresearch/athens/commit/16db7d5136fad89a1ef32a536512d86846234d60)) +* use right key for page lookup ([911d78e](https://github.com/athensresearch/athens/commit/911d78e8421b30d7b1e258d7a3ccfb9f6a0dbf4e)) +* we actually don't need `:db/id` on `:block/children` ([f1cf26d](https://github.com/athensresearch/athens/commit/f1cf26d60c08e32c9649372e0c89b333659706a7)) + + +* `dev/datahike-conn` useful also outside `dev` ns. ([b26173b](https://github.com/athensresearch/athens/commit/b26173bef6e62b1a887c94f667acb22fa255e7e2)) +* `remote.cljs` also uses `common.log`. ([6760532](https://github.com/athensresearch/athens/commit/6760532794df357b0336c37339d9bd37467eea74)) +* actual memory requirements. ([bdd6ad8](https://github.com/athensresearch/athens/commit/bdd6ad8449f2eb6d896c220f3337cdf7770bce9b)) +* add tests for event-sync ([868bdf2](https://github.com/athensresearch/athens/commit/868bdf23cf7c77e2f231d860e037c643c6bb67e0)) +* all the checks. ([573edb0](https://github.com/athensresearch/athens/commit/573edb0f2cb87805706c96917329e43cc8adb7e7)) +* by default use same path for DB as dockerized config. ([4ef2119](https://github.com/athensresearch/athens/commit/4ef21196da078e90ac5e86f10bd22a941267559f)) +* commented out broken tests. ([c1f08df](https://github.com/athensresearch/athens/commit/c1f08dfef1ed7f1c2b98cc7c25e0093713b00f67)) +* fixit ([e6828a2](https://github.com/athensresearch/athens/commit/e6828a2df06f864822bc508778e5c7efde89d4bc)) +* fixit ([c225a48](https://github.com/athensresearch/athens/commit/c225a48803f428c9e6b05e5570e197507457a929)) +* logging cleanup. ([bff79fc](https://github.com/athensresearch/athens/commit/bff79fcb0145a62702fd38386552ab1df497be94)) +* pprint failed transactions. ([0c3343f](https://github.com/athensresearch/athens/commit/0c3343fffe3f4ed391a5fe69a067253e935a4cf9)) +* remove unneeded commas ([aaa007c](https://github.com/athensresearch/athens/commit/aaa007cfb9fdb8b1e91f21411c042bb0e4b0f97c)) +* talk `:block/uid` to me. ([25587ae](https://github.com/athensresearch/athens/commit/25587aeb972bcbd4c09ed1ccdf1411d26a4fa6da)) +* use deps.edn with lein ([df9528f](https://github.com/athensresearch/athens/commit/df9528f14e19a396a8237c25712967e790536312)) +* use sha for org.flatland/ordered ([27cebb0](https://github.com/athensresearch/athens/commit/27cebb00eb0a4e9e3c6b00928b359d1d2c424da0)) + + +### Refactors + +* follow map-first seq-last clojure convention ([0d1b8a2](https://github.com/athensresearch/athens/commit/0d1b8a24f69dd0eb6f697ce0fe44e3fb0bdae0a6)) +* **notification:** simplify buttons ([d07a20a](https://github.com/athensresearch/athens/commit/d07a20a53785d0dfadf1c5ba52611a3e29b76369)) +* **notification:** simplify buttons ([25ccd84](https://github.com/athensresearch/athens/commit/25ccd843774fe2909b2bde3f0784b91bb8df5e0b)) +* **presence:** move layout containment to html ([d532cd9](https://github.com/athensresearch/athens/commit/d532cd98cec7081fc56f7a99d593f8060e6aa2b1)) +* use log/debug in optimistic events ([8856cc2](https://github.com/athensresearch/athens/commit/8856cc27475e9571bd670f0cc6f43c78cbe54a69)) + +## [1.0.0-beta.98](https://github.com/athensresearch/athens/compare/v1.0.0-alpha.rtc.8...v1.0.0-beta.98) (2021-09-27) + +## [1.0.0-beta.97](https://github.com/athensresearch/athens/compare/v1.0.0-alpha.rtc.6...v1.0.0-beta.97) (2021-09-21) + + +### Bug Fixes + +* Correct JS constructor name. ([bfbc940](https://github.com/athensresearch/athens/commit/bfbc94020a16793be7cd6f65ae78a08ab2f2abe3)) +* Just errors and crashes sent to Sentry. ([7c48179](https://github.com/athensresearch/athens/commit/7c48179ec67d59104f3823a7f1d4f1df36845c78)) +* Sort linked references by date descending. ([95dbd60](https://github.com/athensresearch/athens/commit/95dbd60dac93ce524c5fe64ee6b2336ef8ce2940)) + +## [1.0.0-beta.96](https://github.com/athensresearch/athens/compare/v1.0.0-alpha.rtc.2...v1.0.0-beta.96) (2021-09-02) + + +### Bug Fixes + +* Selection behaviour fixed (ported to main). ([2cc7877](https://github.com/athensresearch/athens/commit/2cc787759ada642f6faccc7c8ccace75b99348ff)) + +## [1.0.0-beta.93](https://github.com/athensresearch/athens/compare/v1.0.0-beta.92...v1.0.0-beta.93) (2021-08-04) + +## [1.0.0-alpha.rtc.15](https://github.com/athensresearch/athens/compare/v1.0.0-alpha.rtc.14...v1.0.0-alpha.rtc.15) (2021-09-30) + + +### Features + +* **blocks:** use ts block anchor ([a9733f2](https://github.com/athensresearch/athens/commit/a9733f21aac700b6f64a3d3215bdc3de76d4dd66)) +* **button:** buttons have styled focus ring ([6f07ad3](https://github.com/athensresearch/athens/commit/6f07ad3905a53783e13d5ad27df76b66ad39d69d)) + + +### Bug Fixes + +* **anchor:** can drag blocks again ([19106a9](https://github.com/athensresearch/athens/commit/19106a94bcb5d2b36582390ef13d7b3fa5804718)) +* don't send editing nil events ([49259cf](https://github.com/athensresearch/athens/commit/49259cf6206a43d0f50c7442b5c5867c1c731b63)) +* presence for home page should show all users on daily notes ([811eba2](https://github.com/athensresearch/athens/commit/811eba207cf1c0f9770201e1e89196b402e5cd2a)) +* **toolbar:** rendering database menu ([b54c470](https://github.com/athensresearch/athens/commit/b54c47078ea384c54e854b7329daefb2d92391b8)) + + +### Refactors + +* **blocks:** use ts toggle and bullet ([82b617e](https://github.com/athensresearch/athens/commit/82b617ed49734963f119b43f4218dd444dd0455b)) +* **block:** use ts debug details ([ca0eb2a](https://github.com/athensresearch/athens/commit/ca0eb2a8bb535ad4f6daaeaf930caafd15fd8fd0)) +* **block:** use ts debug details ([531f096](https://github.com/athensresearch/athens/commit/531f0962ab587930c6a45be757e3848f0ad7d668)) +* **toolbar:** use ts toolbar ([b81f765](https://github.com/athensresearch/athens/commit/b81f765869619f607c1f012e9d69861056e84b46)) + + +* add newline ([0d7475b](https://github.com/athensresearch/athens/commit/0d7475b01b66f4f36ac17ce30b26692042856139)) +* ignore temp unused fn ([abd84e4](https://github.com/athensresearch/athens/commit/abd84e48e71dce3810249cdfa3552f5a6fbecd7a)) +* lint ([349415c](https://github.com/athensresearch/athens/commit/349415c98c236c7fa4eda4f2f28fb00235e796b1)) +* lint ([a0048e2](https://github.com/athensresearch/athens/commit/a0048e2bad3e5cce82ad2bffa450a21404534416)) +* minor fixes and cleanup ([d58e960](https://github.com/athensresearch/athens/commit/d58e9606b73add52ddd0f89c3c5cd5e0ab0f4191)) +* minor fixes and cleanup ([c089e72](https://github.com/athensresearch/athens/commit/c089e7222140b040af8abd11c3cb76f5b53334c3)) +* remove unused import ([f5ee071](https://github.com/athensresearch/athens/commit/f5ee07125899dcf0b3ded2a95205c262553c5ea5)) + +## [1.0.0-alpha.rtc.14](https://github.com/athensresearch/athens/compare/v1.0.0-alpha.rtc.13...v1.0.0-alpha.rtc.14) (2021-09-30) + + +* increase yarn timeout ([1878c50](https://github.com/athensresearch/athens/commit/1878c50a05aa17e785dd768ef61033423dd7316f)) + +## [1.0.0-alpha.rtc.13](https://github.com/athensresearch/athens/compare/v1.0.0-alpha.rtc.12...v1.0.0-alpha.rtc.13) (2021-09-30) + + +### Bug Fixes + +* **presence:** clean up misc styling issues ([80121a3](https://github.com/athensresearch/athens/commit/80121a3a567a71c7cb2dfd15c267e87681a1426d)) + + +* add missing iconoir-dep ([918ab53](https://github.com/athensresearch/athens/commit/918ab53893847bf96e1b972974d9906850c77ab3)) + +## [1.0.0-alpha.rtc.12](https://github.com/athensresearch/athens/compare/v1.0.0-alpha.rtc.11...v1.0.0-alpha.rtc.12) (2021-09-30) + + +### Bug Fixes + +* always have username and color for people ([a97d003](https://github.com/athensresearch/athens/commit/a97d0035402061f10e3a7a90d86370844be016b8)) +* don't release docker-compose that attempts to build images ([1a313e2](https://github.com/athensresearch/athens/commit/1a313e219f1da8f8c3f544b33aa9c7e92269b6e4)) + + +* always store athens data by the compose file ([420a7c7](https://github.com/athensresearch/athens/commit/420a7c79024d40140b667c344d17620efbe04dc8)) +* don't build server jar on release-electron ([d3daf2a](https://github.com/athensresearch/athens/commit/d3daf2a8cd53be0c53bcf21ad20dba7010ac42d9)) +* use data env var for data, logs path ([13a0165](https://github.com/athensresearch/athens/commit/13a016519144295963be2dc41f317e923a3b8b38)) + +## [1.0.0-alpha.rtc.11](https://github.com/athensresearch/athens/compare/v1.0.0-alpha.rtc.10...v1.0.0-alpha.rtc.11) (2021-09-28) + + +### Bug Fixes + +* always provide a color for current user ([f49c24d](https://github.com/athensresearch/athens/commit/f49c24d6e77ab6269459389fda3c21a164d2fb61)) +* less logging on backend and more testing. ([84a3de5](https://github.com/athensresearch/athens/commit/84a3de59959c29b1114dca6eabdad2a5e4ec8942)) + + +### Refactors + +* **spinner:** use ts spinner ([80b8a87](https://github.com/athensresearch/athens/commit/80b8a872de6501358c151e18a92314c4f76cfc5d)) +* **toggle:** use ts toggle ([794b004](https://github.com/athensresearch/athens/commit/794b004e8c8f8d37a0a9d5a216bcb7037cd8c119)) + + +* cljstyle happy ([efbe155](https://github.com/athensresearch/athens/commit/efbe1559ff074353666eb4114fb765e50d7dc701)) +* don't limit docker release to main ([086b9a4](https://github.com/athensresearch/athens/commit/086b9a491c1f3de48aa73045da33c54ed326d8ba)) +* factor out env setup ([1c2b009](https://github.com/athensresearch/athens/commit/1c2b0097a4db2b494fd46735fb091794dffd9e8c)) +* fix style issues ([d763bde](https://github.com/athensresearch/athens/commit/d763bdeff10676359ad0f44a683a29e7e44c65c0)) +* fix style issues ([ead68b3](https://github.com/athensresearch/athens/commit/ead68b370e2ab134c1149ad4e9a17fafa5bbccc6)) +* fix style issues ([60e2ccd](https://github.com/athensresearch/athens/commit/60e2ccdae45ec09552a4615a076b411ce7471f78)) +* release athens,nginx docker image and docker compose ([2ed4f71](https://github.com/athensresearch/athens/commit/2ed4f71fe391b2dd7d62bac61a9e12f3db90ac5f)) +* rename release jobs ([8c13d2a](https://github.com/athensresearch/athens/commit/8c13d2ab097741ba0069074110b2e2c87092f76e)) +* reuse checkout via anchor ([040836f](https://github.com/athensresearch/athens/commit/040836f230a86e0e7de791f4d0c4613c1ed1cf11)) +* use ubuntu-latest throughout ([4c070f7](https://github.com/athensresearch/athens/commit/4c070f7ffe4669470d982e0191f6a4ce77d55f7c)) + +## [1.0.0-alpha.rtc.10](https://github.com/athensresearch/athens/compare/v1.0.0-alpha.rtc.9...v1.0.0-alpha.rtc.10) (2021-09-27) + + +### Features + +* Orderkeeper to keep all your `:block/order` ordered. ([7ac1844](https://github.com/athensresearch/athens/commit/7ac18448b02a600f7dd1ada16277c58209d0cfca)) + +* Don't log TXs and typo. ([915a818](https://github.com/athensresearch/athens/commit/915a818dd70f0fbf52a4212501af36313348320a)) + + +* carve happy. ([0dba4d4](https://github.com/athensresearch/athens/commit/0dba4d4f12923ee308af2d4a6e90b710cc5bf314)) + +## [1.0.0-alpha.rtc.9](https://github.com/athensresearch/athens/compare/v1.0.0-alpha.rtc.8...v1.0.0-alpha.rtc.9) (2021-09-27) + + +### Features + +* **dialog:** add new dialog component ([15ced38](https://github.com/athensresearch/athens/commit/15ced387e9047568793464131c2c61c9925b108c)) + + + +### Bug Fixes + +* always focus first child, even on daily notes ([c7b1a90](https://github.com/athensresearch/athens/commit/c7b1a90ad5028b592c2f8c091249c2235b6bc639)), closes [#1669](https://github.com/athensresearch/athens/issues/1669) +* **avatar:** stack accepts style props ([969eb22](https://github.com/athensresearch/athens/commit/969eb22a5880b5fbca3aab114f1b65914764972c)) +* pass username, not channel, to goodbye-handler ([a70489c](https://github.com/athensresearch/athens/commit/a70489c91cae41a2d3c11f909d99afa0cde4f47d)) +* **presence:** use consistent avatar spacing ([0ad1b16](https://github.com/athensresearch/athens/commit/0ad1b16d80bb86a2e8ccd59f4c8dbc569ba964cc)) +* remove unused component ([2a9c489](https://github.com/athensresearch/athens/commit/2a9c48945e8b2b6eabd9b3d506712f8e55e7681d)) + + +### Refactors + +* **dialog:** replace page merge alert with dialog ([5fd1951](https://github.com/athensresearch/athens/commit/5fd1951bedd83f48316d91eb24ec74c478ef7b3d)) + + +* remove old alert component ([ac9cc4f](https://github.com/athensresearch/athens/commit/ac9cc4f2bb26d69f205820a520b104799875c597)) + + +## [1.0.0](https://github.com/athensresearch/athens/compare/v1.0.0-beta.98...v1.0.0) (2021-11-12) + +## [1.0.0-beta.98](https://github.com/athensresearch/athens/compare/v1.0.0-beta.97...v1.0.0-beta.98) (2021-09-27) + +## [1.0.0-beta.97](https://github.com/athensresearch/athens/compare/v1.0.0-beta.96...v1.0.0-beta.97) (2021-09-21) + + +### Bug Fixes + +* Correct JS constructor name. ([bfbc940](https://github.com/athensresearch/athens/commit/bfbc94020a16793be7cd6f65ae78a08ab2f2abe3)) +* Just errors and crashes sent to Sentry. ([7c48179](https://github.com/athensresearch/athens/commit/7c48179ec67d59104f3823a7f1d4f1df36845c78)) +* Sort linked references by date descending. ([95dbd60](https://github.com/athensresearch/athens/commit/95dbd60dac93ce524c5fe64ee6b2336ef8ce2940)) + +## [1.0.0-beta.96](https://github.com/athensresearch/athens/compare/v1.0.0-beta.94...v1.0.0-beta.96) (2021-09-02) + + +## [1.0.0-alpha.rtc.8](https://github.com/athensresearch/athens/compare/v1.0.0-alpha.rtc.7...v1.0.0-alpha.rtc.8) (2021-09-23) + + +### Features + +* Password protection ([c4027eb](https://github.com/athensresearch/athens/commit/c4027eb9c93c19b75a177b7706a60b1a8205592a)) +* send full presence state on connect ([710cefb](https://github.com/athensresearch/athens/commit/710cefb244c4df6b476bf0e9410dc0ea73df6854)) +* set user presence on first child block when navigating ([8001ba5](https://github.com/athensresearch/athens/commit/8001ba50082238c2e25b08858d33863f3de3dc61)) + + +### Bug Fixes + +* all pages is slow when a page has 100+ blocks ([e2e3204](https://github.com/athensresearch/athens/commit/e2e3204fc2d5746cd55420ae5bb3bc91ce186663)) +* **db menu:** less broken style for db picker menu ([e82825a](https://github.com/athensresearch/athens/commit/e82825a9ccc262f35dd364b9fb07f9ee0567968c)) +* review items ([f820cef](https://github.com/athensresearch/athens/commit/f820cef2cbeda1a6f6b500bcdcc6c0389602039e)) +* selection issues. ([297df4a](https://github.com/athensresearch/athens/commit/297df4a7d4f66ce2c984748bacc4552ca5ecd7a1)) + +## [1.0.0-alpha.rtc.7](https://github.com/athensresearch/athens/compare/v1.0.0-alpha.rtc.6...v1.0.0-alpha.rtc.7) (2021-09-21) + + +### Bug Fixes + +* Catchup with wrong RTC releases. ([9e48f65](https://github.com/athensresearch/athens/commit/9e48f657a7531aeb72ae43936753ac17070a90e2)) +* set body classes ([4a7d3ac](https://github.com/athensresearch/athens/commit/4a7d3aca71be253e0af2d62a84fa6a68128ae6f4)), closes [#1654](https://github.com/athensresearch/athens/issues/1654) + +## [1.0.0-alpha.rtc.4](https://github.com/athensresearch/athens/compare/v1.0.0-alpha.rtc.3...v1.0.0-alpha.rtc.4) (2021-09-16) + + +### Features + +* add type for connection and host address ([a305db5](https://github.com/athensresearch/athens/commit/a305db52c019d54b6771eae219943b1f1c4c68a2)) +* **apptoolbar:** improve apptoolbar state and presence ([242ee95](https://github.com/athensresearch/athens/commit/242ee959cc9123f6b9f821fa61c330e73dc0e50d)) +* build proper tsx button component ([107d3dd](https://github.com/athensresearch/athens/commit/107d3dd7557fd48781170d68b8d1c14fa281b4a6)) +* **button:** add button variants ([2063569](https://github.com/athensresearch/athens/commit/20635691d483fba55ca5b19d1f12e98e55cacfd2)) +* **db icon:** now support emoji and custom colors ([7755cef](https://github.com/athensresearch/athens/commit/7755cefb07ae97963ff3ada8dbdb38ead14fffce)) +* **dialog:** dialog should pass through modal props ([fa7f8ec](https://github.com/athensresearch/athens/commit/fa7f8ecfc9338d046ac3f2b4b53a4593fb696aab)) +* integrate page level presence ([26d3259](https://github.com/athensresearch/athens/commit/26d3259ca4c420c0757954518af426503fd39d2b)) +* move presencedetails with settings from concept to base ([ce8f59e](https://github.com/athensresearch/athens/commit/ce8f59ece3450ede7d6bd0b151dcdd0054616bef)) +* **rtc:** show connection status in presence toolbar area ([ee6399d](https://github.com/athensresearch/athens/commit/ee6399dd2cb5d545ca30f4ddb0b9c2b6d58d5fdb)) +* **storybook:** add accessibility and color theme addons for devx ([05466eb](https://github.com/athensresearch/athens/commit/05466ebc60e05a6f1b7fe0df8d39ae57804ca384)) +* **storybook:** add app overview stories ([e3c1ca4](https://github.com/athensresearch/athens/commit/e3c1ca435f5dd1ff650949631e432e347ef5e581)) +* **storybook:** add avatar component ([bf6f341](https://github.com/athensresearch/athens/commit/bf6f34118f95f606e08d06eb3369aef28df5838e)) +* **storybook:** add avatar stack component ([d6fcf22](https://github.com/athensresearch/athens/commit/d6fcf22e376c815e8178761c1b37fa4950c55bcf)) +* **storybook:** add avatars to block story ([1ea93bd](https://github.com/athensresearch/athens/commit/1ea93bd87c0ddc4958f3d527a4f9b1b896e3c2e2)) +* **storybook:** add avatars to block story ([b870251](https://github.com/athensresearch/athens/commit/b8702516eae857bf94f9eb1ffb05bfa7be3c611d)) +* **storybook:** add basic breadcrumb component ([0f832a1](https://github.com/athensresearch/athens/commit/0f832a17a0a74d99ded5c4b57024995c2884c1c5)) +* **storybook:** add basic dialog component ([1a84673](https://github.com/athensresearch/athens/commit/1a846738ddf10d7412cd7caeef68319d8b69cae3)) +* **storybook:** add button hover style ([6b9dd15](https://github.com/athensresearch/athens/commit/6b9dd152a82d602d243bc4f474164acdefa980ca)) +* **storybook:** add checkbox component ([c4ba5d8](https://github.com/athensresearch/athens/commit/c4ba5d8ed45112836939bac6e1a528329f2d5855)) +* **storybook:** add db menu ([98f99b5](https://github.com/athensresearch/athens/commit/98f99b54a5f7402762d4bef447376aed7a5825bb)) +* **storybook:** add embed component ([42431fd](https://github.com/athensresearch/athens/commit/42431fd80b635b92b0da230e23bdb164a284a8cf)) +* **storybook:** add left sidebar story ([76566e8](https://github.com/athensresearch/athens/commit/76566e8a1b59248feec84984f1f4c2c4d5cd5855)) +* **storybook:** add meter component ([4037363](https://github.com/athensresearch/athens/commit/40373639b40f3768d3467475b7f174edf721d27b)) +* **storybook:** add presence item to toolbar ([adfafdb](https://github.com/athensresearch/athens/commit/adfafdb5a7d7f74fb2b83a9ce12c31b8f769fa0c)) +* **storybook:** add presence story to page ([d1d63e9](https://github.com/athensresearch/athens/commit/d1d63e92b130b8e308d33072019ca4a6ab5d0879)) +* **storybook:** add preview concept ([0984ac7](https://github.com/athensresearch/athens/commit/0984ac77340938e51ee98f3faf2450ae1727a5ce)) +* **storybook:** add profile settings dialog ([7c87389](https://github.com/athensresearch/athens/commit/7c87389ee5039795d89ae965eaea57475b6a9e64)) +* **storybook:** add profile settings dialog ([3412763](https://github.com/athensresearch/athens/commit/3412763626cbdd453be0fa1adc07bff7bbc8513f)) +* **storybook:** add references to pages ([54bc086](https://github.com/athensresearch/athens/commit/54bc086ca66351a6c3e0f53f641fced4343621eb)) +* **storybook:** add right sidebar component ([510a365](https://github.com/athensresearch/athens/commit/510a365513545be0b38328513ec4d73277a0eeb0)) +* **storybook:** add separate browser and app stories ([6426912](https://github.com/athensresearch/athens/commit/64269126a0adf94f80e4383ee5e24b226bd48bac)) +* **storybook:** add simple badge component ([fb32567](https://github.com/athensresearch/athens/commit/fb32567cc1db2db1bbb779332481f228226bc9d8)) +* **storybook:** added commandbar component ([3513669](https://github.com/athensresearch/athens/commit/3513669b68ed0e414dd7afbba74d30cb93de7695)) +* **storybook:** added left sidebar story ([ae6caf8](https://github.com/athensresearch/athens/commit/ae6caf8bfe1150ff3af5e4ff03210374b63536e7)) +* **storybook:** allow buttons to have unset shape and style ([8b1e439](https://github.com/athensresearch/athens/commit/8b1e439594da76238065525e08a60c4a48900159)) +* **storybook:** allow buttons to have unset shape and style ([6837b42](https://github.com/athensresearch/athens/commit/6837b42e29b8eca32556fc06e4dee7af6533274f)) +* **storybook:** begin to implement blocks in typescript ([eb51890](https://github.com/athensresearch/athens/commit/eb518904da1de4378fc5490a5ffc46ae5113e920)) +* **storybook:** block in bidilink component and story ([5adecf8](https://github.com/athensresearch/athens/commit/5adecf8a67582d6f79c288166bba51dd2e6a6300)) +* **storybook:** block in early text input component ([1a4147e](https://github.com/athensresearch/athens/commit/1a4147ea240c23f281e50aba84e791f05699ba7f)) +* **storybook:** block in email settings ([6f9ca11](https://github.com/athensresearch/athens/commit/6f9ca11b9c2115df56ea81cfa27eb4bb64f9f744)) +* **storybook:** block in new dailynotes concept ([2c6bc26](https://github.com/athensresearch/athens/commit/2c6bc265e1e196a32bbf8a0a12df2e11675a24b1)) +* **storybook:** block in new dailynotes concept ([d4f09a7](https://github.com/athensresearch/athens/commit/d4f09a78650f2a8a293933c79591227d6c0229c4)) +* **storybook:** block in settings page ([894308e](https://github.com/athensresearch/athens/commit/894308e7be188d44bd790b9b0503dbe386f4cf7f)) +* **storybook:** block in style guide page ([78ca286](https://github.com/athensresearch/athens/commit/78ca28618aa36b0eebd01033785056b009e851ae)) +* **storybook:** blocking in daily notes ([ea4fc47](https://github.com/athensresearch/athens/commit/ea4fc473024ddddd9bd205d3304dceeed5246812)) +* **storybook:** blocks don't become editable if not editable ([b2ac479](https://github.com/athensresearch/athens/commit/b2ac479aeffe84dfbaf30a4b4e84de7183bc96ec)) +* **storybook:** clean up blocks and export subcomponents ([e778605](https://github.com/athensresearch/athens/commit/e778605c9971189929050efb2b4c5d6a9ff63bde)) +* **storybook:** compenents and stories for page content ([f887854](https://github.com/athensresearch/athens/commit/f887854abce960574f4de58ad06c1acff127874d)) +* **storybook:** create css vars in js instead of clj ([dca0688](https://github.com/athensresearch/athens/commit/dca06885cc21c086010eacd45c9d3fb8bb492310)) +* **storybook:** electron-like wrapper for desktop testing in storybook ([7ada2fd](https://github.com/athensresearch/athens/commit/7ada2fd7435f8380a5027ee481723b4d1e6cf5c9)) +* **storybook:** more working block aspects ([3984224](https://github.com/athensresearch/athens/commit/3984224539ca7ce3b42768b7f63cdebcfad3f36a)) +* **storybook:** previews can render limited block content ([fad186d](https://github.com/athensresearch/athens/commit/fad186dfd99e498c4f96254cd34b20608fe59f18)) +* **storybook:** themed story context ([5c90fe2](https://github.com/athensresearch/athens/commit/5c90fe25ee8ce68ab0c96830082dc30177ea2c7f)) +* **storybook:** update storybook build config ([0fd36fe](https://github.com/athensresearch/athens/commit/0fd36fe838c85b30bd4eb1c5a6d2abcd3558984f)) +* **storybook:** use badges to indicate component status ([5e49e03](https://github.com/athensresearch/athens/commit/5e49e03dccc95aa745b7cb7a4eba3cc095aa4f2a)) +* **storybook:** use context provider for presence ([77f6643](https://github.com/athensresearch/athens/commit/77f6643a9737dfb6475c9c7e2f9acd88647e2184)) +* **storybook:** use presence context for page ([a477a84](https://github.com/athensresearch/athens/commit/a477a846be4bc3d4be902dbc1515cae9f70f337c)) +* **style:** add serif font var ([184d56f](https://github.com/athensresearch/athens/commit/184d56f97296852772c3af4c32da422ed90ec8fc)) +* **welcome, icons:** dbs support emoji and color ([207097c](https://github.com/athensresearch/athens/commit/207097c1cadc60721c8c36ff7e1dcb7d7f84b304)) +* **welcome:** block in welcome component ([6da39a9](https://github.com/athensresearch/athens/commit/6da39a9d6f8381b3b423fd57a093c3d25d36477b)) +* **welcome:** block in welcome component ([3f3bb38](https://github.com/athensresearch/athens/commit/3f3bb38ec42e7be0be9eeacbe8c1e526e03ee5d9)) + + +### Bug Fixes + +* add typeroots back ([9cc2457](https://github.com/athensresearch/athens/commit/9cc2457b9750254a2c9b3b0eb95e832be251c679)) +* **breadcrumbs:** use proper attribute for svg stroke properties ([17ae5e6](https://github.com/athensresearch/athens/commit/17ae5e6d63a0d598f05ef1539263b9b6e814cd5d)) +* broadcast username update ([28111a2](https://github.com/athensresearch/athens/commit/28111a2b4531e7e303c7ebde1f017e2235a5840b)) +* comment unused prop ([5ae417d](https://github.com/athensresearch/athens/commit/5ae417d1dd4d097852b8f9797e68830182c0156e)) +* concurrent resolution should not happen. ([698913e](https://github.com/athensresearch/athens/commit/698913ec7dda9330eb891392e323600c5480d9af)) +* don't error out when changing name ([65ea223](https://github.com/athensresearch/athens/commit/65ea223db60f7330a3cfc0eab60e91e79c250785)) +* don't exclude root stories ([10dc517](https://github.com/athensresearch/athens/commit/10dc5171e0ef96d2f5bada932e87ac1825e0d797)) +* Don't send tx data with db-dump. ([8610251](https://github.com/athensresearch/athens/commit/8610251c5ce8fb5a7a1fb7e5d1078d9053422c93)) +* don't show babel warning durings storybook build ([cdb30ab](https://github.com/athensresearch/athens/commit/cdb30ab92a059fad3cf1e1fe3506387565e15311)) +* further babel warning fixes ([7086789](https://github.com/athensresearch/athens/commit/7086789893e6c5b606299026c2053c9bb6edc562)) +* **overlay:** don't specify overlay flex direction ([f9c751c](https://github.com/athensresearch/athens/commit/f9c751cec391a6479df47c2235762c321ed3c9b0)) +* remove duplicate require ([f0ec1df](https://github.com/athensresearch/athens/commit/f0ec1df950ea862736641543e40023f6a62ad562)) +* remove duplicated button file ([ee20473](https://github.com/athensresearch/athens/commit/ee20473c4e7b2eb188db6e6b4103513943ea7266)) +* silence postcss deprecation warning ([1900e10](https://github.com/athensresearch/athens/commit/1900e1017ef34728b17ddff8b6e15e5e14a1a79a)), closes [/github.com/storybookjs/storybook/issues/14440#issuecomment-814326123](https://github.com/athensresearch//github.com/storybookjs/storybook/issues/14440/issues/issuecomment-814326123) +* src paths for comps should match src/js depth ([fcb39ec](https://github.com/athensresearch/athens/commit/fcb39ec700cd90997335e487752a2940567fdf0f)) +* **storybook:** add missing config ([c522c5d](https://github.com/athensresearch/athens/commit/c522c5d6ce3f0e1c9bedafd82e0054c887478e54)) +* **storybook:** add missing mock data ([52fe409](https://github.com/athensresearch/athens/commit/52fe409687b815637e37fae5385c84a46f86531c)) +* **storybook:** add missing rename changes ([1351e0c](https://github.com/athensresearch/athens/commit/1351e0c885df9724d5cb5e6ca9b11c0404103846)) +* **storybook:** add missing shadows to block tools ([f5d42e9](https://github.com/athensresearch/athens/commit/f5d42e9ccf95b1bbd9fb550caa3d792dbd01688e)) +* **storybook:** add missing spacing in cmd bar button ([0e1e832](https://github.com/athensresearch/athens/commit/0e1e832cd73aa40c76d8e3518dbcf2395240ba00)) +* **storybook:** apply base styles to body ([7ed2d4a](https://github.com/athensresearch/athens/commit/7ed2d4a888dd7773a6db893d9c84c8a7c764679e)) +* **storybook:** attempt fix for uneditable blocks ([3a8a6af](https://github.com/athensresearch/athens/commit/3a8a6affc312a0c4dcf695706f1f5ea12e5c4acf)) +* **storybook:** avatars appear correctly ([f491c6d](https://github.com/athensresearch/athens/commit/f491c6d2e8122072202e5ff084819a50262ab3c6)) +* **storybook:** better example linked block text ([02524fc](https://github.com/athensresearch/athens/commit/02524fca29bcabd06c6902b873bd968b59da908e)) +* **storybook:** better story background ([896c1ee](https://github.com/athensresearch/athens/commit/896c1ee86e7324d78cc000feabccdccf2893ac92)) +* **storybook:** blocks open by default ([26d4f93](https://github.com/athensresearch/athens/commit/26d4f93b1d1145a381b6ad96c8b1991e58517265)) +* **storybook:** buttons passthrough classname ([3737c33](https://github.com/athensresearch/athens/commit/3737c335cd0ce08608a36ae4491c11e38edab141)) +* **storybook:** clean up button story ([5b29b1a](https://github.com/athensresearch/athens/commit/5b29b1a8b75c74eb218ab715f4c42b1220d6eab4)) +* **storybook:** commandbar heading appears above text ([96cf6d6](https://github.com/athensresearch/athens/commit/96cf6d68fe376635f79a00600d4a35783841d013)) +* **storybook:** correct minor issues with browser and standalone stories ([d8e27fc](https://github.com/athensresearch/athens/commit/d8e27fcfd2ccf2d730ef780f0a3b5f143af07ebd)) +* **storybook:** don't use broken story wrapper ([ae62c45](https://github.com/athensresearch/athens/commit/ae62c45095645f94dacd9e3bcdf23e1f3aab9dd6)) +* **storybook:** finally solve theme context issue ([36a7cca](https://github.com/athensresearch/athens/commit/36a7cca0258f29a694e5aab6166dc0cf0c0feccb)) +* **storybook:** fix a bunch of button bugs ([4c5eb62](https://github.com/athensresearch/athens/commit/4c5eb62e9293c358db9661eedd848dfcd0df23ce)) +* **storybook:** fix a bunch of button bugs ([94d09a9](https://github.com/athensresearch/athens/commit/94d09a903e9a51a691ec73b515d30d0a6e59bd5b)) +* **storybook:** fix avatar positioning on blocks ([b9ac583](https://github.com/athensresearch/athens/commit/b9ac5835c775eb8541b3d991aa86d8d018df0612)) +* **storybook:** fix block recurse limiter ([a4ffe47](https://github.com/athensresearch/athens/commit/a4ffe4726b92dea2ad1f03db87d291c6ece28f59)) +* **storybook:** generated blocks toggle properly ([7af9c8c](https://github.com/athensresearch/athens/commit/7af9c8cb2e036cb18ba627715e4c4c23dec77a44)) +* **storybook:** get proper mock data for toolbar ([9784052](https://github.com/athensresearch/athens/commit/9784052a6ec16b1d0a00f64b3378974a6b57dee1)) +* **storybook:** get proper mock data for toolbar ([ce4fb9f](https://github.com/athensresearch/athens/commit/ce4fb9ff3b4153e5bcbd15a527b1b2f5dd0d196c)) +* **storybook:** hide command bar on click behind ([d56c28b](https://github.com/athensresearch/athens/commit/d56c28bbaf451916be3f0d3468d9fa268d4830d9)) +* **storybook:** include missing shadow style ([9109914](https://github.com/athensresearch/athens/commit/91099146f877448ecd1f7c6ecab22845a3f707f9)) +* **storybook:** make os stories work again ([617bf5c](https://github.com/athensresearch/athens/commit/617bf5ceaa759fb9a0d684bbac78b2114168f688)) +* **storybook:** make window buttons visible in linux ([b4d4c20](https://github.com/athensresearch/athens/commit/b4d4c20d9c5c9b15a085d28e4606b277b997a916)) +* **storybook:** minor cleanup ([f8f6f26](https://github.com/athensresearch/athens/commit/f8f6f26d2215506093dc9c7eecb1460d85958adb)) +* **storybook:** minor fixes to style generation ([55dc649](https://github.com/athensresearch/athens/commit/55dc6494aa13510c4a6f451332b0a0c6c6c58636)) +* **storybook:** minor layout fix ([4c97d4b](https://github.com/athensresearch/athens/commit/4c97d4b3d13da55f3a1f871f6998e885097f5617)) +* **storybook:** minor presence cleanup ([5d3a8b2](https://github.com/athensresearch/athens/commit/5d3a8b28c4dc9c934c47ade11ceffedb54deaf1b)) +* **storybook:** page menu toggle should be round ([30a24ed](https://github.com/athensresearch/athens/commit/30a24ed4c66124e505e446066c5564af74c7a5d6)) +* **storybook:** permute opacities into style map ([9df8ce7](https://github.com/athensresearch/athens/commit/9df8ce7c5c6cece028910a8f5597d285178f257a)) +* **storybook:** presence works on standalone story ([a1ed4a9](https://github.com/athensresearch/athens/commit/a1ed4a9723165054c892935a529d3fb990fdb7bd)) +* **storybook:** properly align left sidebar footer bits ([6fa1784](https://github.com/athensresearch/athens/commit/6fa1784b801d79706669aff1a2e7119aa2f7d649)) +* **storybook:** properly capture focus on presence overlay ([059dfc7](https://github.com/athensresearch/athens/commit/059dfc755451d412043322d9606d5b6b9368fa7a)) +* **storybook:** properly place page content ([8f1161d](https://github.com/athensresearch/athens/commit/8f1161daea3b6c6cb0dd58d00988ae295ed558d8)) +* **storybook:** relink failing page story ([54d3612](https://github.com/athensresearch/athens/commit/54d3612a43a9e05daddfc451975460184ad88d45)) +* **storybook:** remove autocomplete and suggest from command bar ([226d449](https://github.com/athensresearch/athens/commit/226d4495763b42c003688b56a635ebdfdf5ab01f)) +* **storybook:** remove duplicate avatar element from block ([695e151](https://github.com/athensresearch/athens/commit/695e151ef9a677b808b364ffc2bbaefbd9c2adea)) +* **storybook:** selected block backgrounds no longer stack ([afb3b40](https://github.com/athensresearch/athens/commit/afb3b40eea001748d897c4a663fa659db3f0898c)) +* **storybook:** solve issue with plain button ([aca8ba4](https://github.com/athensresearch/athens/commit/aca8ba457de668a9820750a04aab989053214376)) +* **storybook:** use propber background for stories ([d66861d](https://github.com/athensresearch/athens/commit/d66861d8a1f163cad086c5afb7c2b79ae6a204e0)) +* **storybook:** use updated link color in dark mode too ([cf6e468](https://github.com/athensresearch/athens/commit/cf6e46844b8b0361b4f885153611b83d25b35ce2)) +* **storybook:** use user color for block side border ([c1c7163](https://github.com/athensresearch/athens/commit/c1c7163bef86e439323fff779b604079abcc0907)) +* **storybook:** various issues with app layout and page spacing ([a9eeb36](https://github.com/athensresearch/athens/commit/a9eeb36b429159f564cde627a358909f2400dc7e)) +* **style:** properly support nested theme colors ([c38fc23](https://github.com/athensresearch/athens/commit/c38fc234e40fed5314066c3409b536ded8c69a7c)) + + +### Refactors + +* **blocks:** use hooks for added block features ([3470e93](https://github.com/athensresearch/athens/commit/3470e93e7b6287b31c79f988a2c8d5563cb875d7)) +* **dbicon:** move icon into components root ([8aee7c7](https://github.com/athensresearch/athens/commit/8aee7c7e975912ff0ca43b936d79f9240d8de7cc)) +* **docs:** use decorators for whole-app story wrappers ([f23718f](https://github.com/athensresearch/athens/commit/f23718fa8a4a5a0cda1f6a5da1bc0ed4255806d1)) +* move concept components to concept stories header ([f1827c4](https://github.com/athensresearch/athens/commit/f1827c46e5decf8654a83f87597ba52acd4f3305)) +* **page, blocks:** clearer component relations ([92afde2](https://github.com/athensresearch/athens/commit/92afde244172476b279a494a3632018a6601dc5d)) +* **storybook:** add mock data and app state ([2977b19](https://github.com/athensresearch/athens/commit/2977b194adbf3501419f0777c3c827c0fe930bbd)) +* **storybook:** another round of button fixes ([26b36c6](https://github.com/athensresearch/athens/commit/26b36c65be166196e5b7cfa0e56631869c783f7e)) +* **storybook:** better fn for block rendering ([1c4d243](https://github.com/athensresearch/athens/commit/1c4d2432f8af5e063ed107725ecd52e3bdd41701)) +* **storybook:** better mock data for database menu ([ffa4677](https://github.com/athensresearch/athens/commit/ffa46770b010d9cb32f3c62929dcebe00dd2ef2c)) +* **storybook:** better story organization ([5d5bf56](https://github.com/athensresearch/athens/commit/5d5bf56248f5023c3a1c377322c204347eb19e9d)) +* **storybook:** blocks render from structured json ([6bcb7d4](https://github.com/athensresearch/athens/commit/6bcb7d489a593d1088519b68af5a35ad9b59ce47)) +* **storybook:** button now pure styled-component for easier overrides ([1c7cd4d](https://github.com/athensresearch/athens/commit/1c7cd4d442decd655ed7f6047dcce74843686d34)) +* **storybook:** button now pure styled-component for easier overrides ([c12c6d5](https://github.com/athensresearch/athens/commit/c12c6d50637cda7a0a1c857cf400fb3595a93d47)) +* **storybook:** improve block tree recursing ([f80f8c6](https://github.com/athensresearch/athens/commit/f80f8c60f4d9cfe232983dd25194bd9e9fa77c37)) +* **storybook:** lift db and presence components ([22caefd](https://github.com/athensresearch/athens/commit/22caefda8115dac70c0d3cf0d55f10d5d23d12f0)) +* **storybook:** make recurseblocks function more available ([18d4e9d](https://github.com/athensresearch/athens/commit/18d4e9de6b54c086fdf209a68e91180fc387f41a)) +* **storybook:** move block data to mockdata file ([1f03eda](https://github.com/athensresearch/athens/commit/1f03edae32a1f5df1c260e912bebde12c6a57c79)) +* **storybook:** move maincontent into app components ([ffe254e](https://github.com/athensresearch/athens/commit/ffe254e3f74c4cd5fec046a4e7fbd5254a622bac)) +* **storybook:** move most components under concept dir ([5424b00](https://github.com/athensresearch/athens/commit/5424b00e7d0e14f48822979c5cb9b7c4140f430e)) +* **storybook:** move storybook state to app hook ([3f2ecd7](https://github.com/athensresearch/athens/commit/3f2ecd7b8628550a5d3f0879b38ab6f106e7e128)) +* **storybook:** rebuild avatars ([0fa1e29](https://github.com/athensresearch/athens/commit/0fa1e2945ee88ed3db3bfbf7f56ff1383e32a595)) +* **storybook:** refactor block stories ([a0a35e5](https://github.com/athensresearch/athens/commit/a0a35e58248ea5f277d84e8ea11470aec96a4e07)) +* **storybook:** reimplement page menu with proper focus and portal ([83beef8](https://github.com/athensresearch/athens/commit/83beef8dbdd886205535efbf4a19903fcbc439d3)) +* **storybook:** reorganize link story component ([ecda273](https://github.com/athensresearch/athens/commit/ecda2730b4775e78d3796a3d96e2282b126faa64)) +* **storybook:** use hooks for block fns ([8f60c9e](https://github.com/athensresearch/athens/commit/8f60c9e1486c1f2691a103e76bf69411f72249b2)) +* **storybook:** use simpler more consistent desktop story wrapper ([0c356e5](https://github.com/athensresearch/athens/commit/0c356e5dafbe35dcccb9c6e3170ffd74dbb14580)) +* use new button component ([c9e2ff5](https://github.com/athensresearch/athens/commit/c9e2ff5e2f65241b109db5acf27831102e4b3fb5)) +* use root relative imports for components ([454c130](https://github.com/athensresearch/athens/commit/454c1302405261b136c8db7cf356a2869e3a487c)) + + +* add operating system type ([3d377de](https://github.com/athensresearch/athens/commit/3d377de2665d1630dfa46a6358a4e7cfa52fbaef)) +* fix import semantics for components ([65cd0e4](https://github.com/athensresearch/athens/commit/65cd0e4b66c27d16a9969a1e2460496eec8e63bd)) +* fix misc type issues ([fecd599](https://github.com/athensresearch/athens/commit/fecd599ead0cfbf9362d1f4aba751bce73dc1d83)) +* fix spacing ([da4b75a](https://github.com/athensresearch/athens/commit/da4b75a273e45eb83bf2ad1f15458063af6b9beb)) +* formatting updates ([a2b006d](https://github.com/athensresearch/athens/commit/a2b006d5d4dbe1927df42dcd58e633c6d52b404e)) +* merge from upstream changes ([1bcd729](https://github.com/athensresearch/athens/commit/1bcd729a9a973e444badde05219022af4220c807)) +* output babel compiled files in dist/js ([ddff507](https://github.com/athensresearch/athens/commit/ddff507a1b7ed7eaa2268c2fd79798cc53e35dbb)) +* **storybook:** clean up block mock data ([3062065](https://github.com/athensresearch/athens/commit/3062065761258b4dec54b31c46c657ae3b5c397f)) +* **storybook:** clean up misc warnings ([5d5240d](https://github.com/athensresearch/athens/commit/5d5240d561d063c4099d328c8191eb1da18ffca6)) +* **storybook:** clean up page story ([ad26746](https://github.com/athensresearch/athens/commit/ad26746d08f3349687bd69d501038ca9a39396b4)) +* **storybook:** clean up preview component code ([935ce58](https://github.com/athensresearch/athens/commit/935ce58d3202c5775e3f6809494b78b69135d156)) +* **storybook:** mark components as in-use ([5eb4038](https://github.com/athensresearch/athens/commit/5eb403855e97efb013257be9459d3bdc94715482)) +* **storybook:** minor block cleanup ([0072c34](https://github.com/athensresearch/athens/commit/0072c3485019aae9bbacd512ff3f0e978c6ba270)) +* **storybook:** minor block mockdata cleanup ([5b82a19](https://github.com/athensresearch/athens/commit/5b82a19b9006a57971a3640028d8756c21f97546)) +* **storybook:** minor cleanup of app stories ([fa761c8](https://github.com/athensresearch/athens/commit/fa761c846995e27644f47ad25f7db9412436c887)) +* **storybook:** minor utility cleanup ([af89497](https://github.com/athensresearch/athens/commit/af894974e25e1fa5cda9637e37e241ce6cf6d075)) +* **storybook:** misc minor story fixes ([5255e98](https://github.com/athensresearch/athens/commit/5255e982eaed6cf7d00231208fab1053d92923ed)) +* **storybook:** raise tsconfig root level ([6dc58f3](https://github.com/athensresearch/athens/commit/6dc58f3648492303c52d4645f54eb4c6397edfdd)) +* **storybook:** remove manual checkbox style ([4890ce7](https://github.com/athensresearch/athens/commit/4890ce7c9e7c82c9f013b8ad0c306315b178cd12)) +* **storybook:** remove unused files ([c9102e8](https://github.com/athensresearch/athens/commit/c9102e8c027ac3327f143341016b6a82b8e7059f)) +* **storybook:** remove unused import ([8caedb0](https://github.com/athensresearch/athens/commit/8caedb043809859d495e25bb6377a43e1481069f)) +* **storybook:** resolve misc errors and issues ([4fb74d1](https://github.com/athensresearch/athens/commit/4fb74d1cc1f070f84e7d4924e11df3200b2abf52)) +* **storybook:** upgrade storybook ([5480037](https://github.com/athensresearch/athens/commit/54800378e1e727dda35b14ce469fd8ef99d06391)) +* support @ as root alias in js components ([403e86a](https://github.com/athensresearch/athens/commit/403e86a1f9cb780cfe3ef9c1f71d3ca61d5fe2d1)) +* support tsx components and storybook ([59c6629](https://github.com/athensresearch/athens/commit/59c662915e5efebc2df9293a01d5f675dcedacf8)) + +## [1.0.0-alpha.rtc.3](https://github.com/athensresearch/athens/compare/v1.0.0-alpha.rtc.2...v1.0.0-alpha.rtc.3) (2021-09-09) + + +### Features + +* notify client that lan-party is over 😢 ([864582a](https://github.com/athensresearch/athens/commit/864582a639ca06dc1c2a47eb7a6f9076b094af86)) +* replace block refs with content ([80f2be0](https://github.com/athensresearch/athens/commit/80f2be03443eb6213c5fe8e4660698a2ce1fc053)) +* replace block refs with text on ref delete ([8529546](https://github.com/athensresearch/athens/commit/8529546b012b2f63b00dde02b950e18fd4245334)) +* unlink multiple block refs ([57130cd](https://github.com/athensresearch/athens/commit/57130cd1c5b5bc63689cc48dfffef96c17232552)) +* use one event handler for conn-status ([6afd0fc](https://github.com/athensresearch/athens/commit/6afd0fcdb25a0d3af42d302448d9fca2f3824a71)) + + +### Bug Fixes + +* `docker-compose` setup timing-out. ([50bbc0d](https://github.com/athensresearch/athens/commit/50bbc0daf6a7da24f689f05bf6f6e9799d2f5046)) +* block refs replace failing in single uid ([02d913d](https://github.com/athensresearch/athens/commit/02d913dfb34c434d69626df58a78996f5bf9d5df)) +* bullet too big ([ffd026d](https://github.com/athensresearch/athens/commit/ffd026de803366bdeee49a3534486e5d912a3b1a)) +* bullet too small ([49439d5](https://github.com/athensresearch/athens/commit/49439d52b01060999e5fe56623a37547ff3b7414)) +* detect when client is not able to connect to remote. ([1bddf89](https://github.com/athensresearch/athens/commit/1bddf8973aa4aaf2a02738bd64cac88c679784f2)) +* error when deleting a block with block ref ([f6cba22](https://github.com/athensresearch/athens/commit/f6cba22ced3f585a09d90101306f805b56042bc7)) +* import react-dom instead of using missing global ([70027b9](https://github.com/athensresearch/athens/commit/70027b95188eb0e2c5a5a7ccfdbc20337641e00c)), closes [/github.com/athensresearch/athens/pull/1564#discussion_r704963708](https://github.com/athensresearch//github.com/athensresearch/athens/pull/1564/issues/discussion_r704963708) +* Make backend logs a bit more readable. ([2d79531](https://github.com/athensresearch/athens/commit/2d79531685520afab6848b1c5fdcc6b8f96b62db)) +* Remote client freezes when navigating down. ([ab910a1](https://github.com/athensresearch/athens/commit/ab910a1c014f6b271a3742c89774f635c0c31bf2)) +* schema of selected-delete ([d135dbd](https://github.com/athensresearch/athens/commit/d135dbdac068f0ec21a92beeafca25de5803dcb0)) +* use vector to convert title ([084b168](https://github.com/athensresearch/athens/commit/084b168940a78cb9a7ec5094822fd2b75a44456f)) + + +* add basic stress test ([29ae7d8](https://github.com/athensresearch/athens/commit/29ae7d87ee4837b86900dfe50fb84da69c6d5e95)) +* add stress test ([63ed490](https://github.com/athensresearch/athens/commit/63ed4909fcd4b2f5db4efe39a79796f0bf506721)) +* Add Vercel previews ([#1643](https://github.com/athensresearch/athens/issues/1643)) ([c860ab4](https://github.com/athensresearch/athens/commit/c860ab465fca94eefe1aa5044c54a58b04bc4c03)) +* build components once before starting client watch ([9fa0690](https://github.com/athensresearch/athens/commit/9fa0690a817fd784ecdf628c6209f4cdf7d2b787)) +* fix spacing ([dd329df](https://github.com/athensresearch/athens/commit/dd329df9cb61613a06d905d174e6619980df904b)) +* linked refs text replacement ([35200af](https://github.com/athensresearch/athens/commit/35200af3e76e8d05ec90705946a84c4eb93eb0c3)) +* support tsx components and storybook ([#1564](https://github.com/athensresearch/athens/issues/1564)) ([925f7a4](https://github.com/athensresearch/athens/commit/925f7a4c089d8d64a1082da2a452d5b7dc008ba1)) + +## [1.0.0-alpha.rtc.2](https://github.com/athensresearch/athens/compare/v1.0.0-alpha.rtc.1...v1.0.0-alpha.rtc.2) (2021-08-26) + + +### Bug Fixes + +* A bit more logging while we're debugging. ([a8903b5](https://github.com/athensresearch/athens/commit/a8903b5c2503134fdf6bd80b8e3f6aea12b1baa1)) +* Block doesn't render when clicking outside ([12c039a](https://github.com/athensresearch/athens/commit/12c039a9a5623d322ef52f9c7a65d134bc794b29)), closes [#1491](https://github.com/athensresearch/athens/issues/1491) +* **bullets:** make bullets same size across zoom levels ([4b5d877](https://github.com/athensresearch/athens/commit/4b5d8770e26153c598e9f706530c0ec0c635be25)) +* Can't delete multiple blocks at once ([c8825e6](https://github.com/athensresearch/athens/commit/c8825e6442311c95509d5bce66681cb257c72f68)), closes [#1516](https://github.com/athensresearch/athens/issues/1516) +* Daily pages creation from Daily Pages. ([287fa74](https://github.com/athensresearch/athens/commit/287fa74cd5420c04815de7004746f2e652df1411)) +* Log error when handler doesn't return handle status event. ([f668448](https://github.com/athensresearch/athens/commit/f6684487c9d123971030cac5ca26f78606c4b74e)) +* make bullet look closer to previous collapsed bullets ([ee892fc](https://github.com/athensresearch/athens/commit/ee892fcc5f2b14f61dc5d7a8027da09838fd0232)) +* nav-daily-notes and daily-note/reset ([765d6fc](https://github.com/athensresearch/athens/commit/765d6fc1ed56feae1cdc5077c2b2410ae7fd71c1)) +* Presence broadcast new username when new player introduces. ([6e46576](https://github.com/athensresearch/athens/commit/6e4657657ec5e80993366e85de5e5aecc34ad66e)) +* Presence confirm `:presence/editing` & plain keywords for args. ([65ffae2](https://github.com/athensresearch/athens/commit/65ffae25e6523150295a3ef03b2904ac6382ed51)) +* problems in the daily page scroll ([8bf3e6b](https://github.com/athensresearch/athens/commit/8bf3e6b60d1497d9c8fb0b6034c968b9fd1cbc48)) +* remove unused storage icon ([dcc5e16](https://github.com/athensresearch/athens/commit/dcc5e16fca44ce2478f0652a40e659e0b8842e09)) +* Selection behaviour fixed. [#1571](https://github.com/athensresearch/athens/issues/1571) ([a7fd284](https://github.com/athensresearch/athens/commit/a7fd284e019c5d7381a1f98ee57163c989f4c1ee)) +* Selection clear was resetting `re-frame` db. ([377e417](https://github.com/athensresearch/athens/commit/377e417a778d7a5c0673dc904b6d243ea69e9560)) +* some bullets are not round ([56d608f](https://github.com/athensresearch/athens/commit/56d608f2a74f9dab2798c601dc4495335a930e2e)) +* Some bullets are not round ([f583bc5](https://github.com/athensresearch/athens/commit/f583bc565c1784b02d2460315ab841f07f08a0f8)) +* Some bullets are not round ([1370fec](https://github.com/athensresearch/athens/commit/1370fec2996f841c2ef15c1cc321e5c428c5e0fe)), closes [#1495](https://github.com/athensresearch/athens/issues/1495) +* update daily page after creation ([e9b140e](https://github.com/athensresearch/athens/commit/e9b140eb3b7b16d2ebea0581f93e0cff2e62564f)) + + +* alignment ([384cad6](https://github.com/athensresearch/athens/commit/384cad62fa53c205878156fd83ed92a996f689a9)) +* comment and deploy gh pages to main ([cdac939](https://github.com/athensresearch/athens/commit/cdac9396078f65fb7ed8fe3568659510218aec7d)) +* fix ([87c016c](https://github.com/athensresearch/athens/commit/87c016c526119d8fb9bcfb976ef91e5f393b5132)) +* only auto-update releases from the main branch ([d98719d](https://github.com/athensresearch/athens/commit/d98719dffab2b5c4e128aeffa2dae62caba11e86)) +* only deploy to gh-pages on master ([a29bb6f](https://github.com/athensresearch/athens/commit/a29bb6f776c3be7dd0ddd8c13b47e886ba0a95bd)) + +## [1.0.0-alpha.rtc.1](https://github.com/athensresearch/athens/compare/v1.0.0-alpha.rtc.0...v1.0.0-alpha.rtc.1) (2021-08-17) + + +### Bug Fixes + +* synced hopefully all the events ([bdf3bf4](https://github.com/athensresearch/athens/commit/bdf3bf4e256c775b32ac8a58d48a54e6b84f2e60)), closes [#1506](https://github.com/athensresearch/athens/issues/1506) + + +### Refactors + +* simplify vector concat -> into ([444bfb9](https://github.com/athensresearch/athens/commit/444bfb9d406d131db6b7e528527077f1c548bb95)), closes [#1498](https://github.com/athensresearch/athens/issues/1498) + + +* only release jar on ubuntu build ([03b869c](https://github.com/athensresearch/athens/commit/03b869cc44cbef6e32cd33c7eea879426c1f906a)) + + +## [1.0.0-beta.94](https://github.com/athensresearch/athens/compare/v1.0.0-beta.93...v1.0.0-beta.94) (2021-08-04) + +## [1.0.0-beta.93](https://github.com/athensresearch/athens/compare/v1.0.0-beta.92...v1.0.0-beta.93) (2021-08-04) + +## [1.0.0-beta.92](https://github.com/athensresearch/athens/compare/v1.0.0-beta.91...v1.0.0-beta.92) (2021-08-02) + +## [1.0.0-beta.91](https://github.com/athensresearch/athens/compare/v1.0.0-beta.90...v1.0.0-beta.91) (2021-08-02) + +## [1.0.0-beta.90](https://github.com/athensresearch/athens/compare/v1.0.0-beta.89...v1.0.0-beta.90) (2021-08-02) + + +### Bug Fixes + +* "open all-pages view" keybinding on Welcome ([#1327](https://github.com/athensresearch/athens/issues/1327)) ([f411c53](https://github.com/athensresearch/athens/commit/f411c53b699e96ca71c52631e652f6dff4fbcf73)) +* Selection fixes. ([#1421](https://github.com/athensresearch/athens/issues/1421)) ([c835c79](https://github.com/athensresearch/athens/commit/c835c793bffa79d98d25007b2b525ce514f9f51f)), closes [#1279](https://github.com/athensresearch/athens/issues/1279) + + +### Enhancements + +* **toolbar:** use native operating system toolbar ([#1120](https://github.com/athensresearch/athens/issues/1120)) ([e2c953b](https://github.com/athensresearch/athens/commit/e2c953b5c2bc9257939784499b0755c81b89c89b)) + +## [1.0.0-beta.89](https://github.com/athensresearch/athens/compare/v1.0.0-beta.88...v1.0.0-beta.89) (2021-06-09) + + +### Bug Fixes + +* when editing long block-page title, title overlaps with child block ([#1291](https://github.com/athensresearch/athens/issues/1291)) ([ed9568b](https://github.com/athensresearch/athens/commit/ed9568bbe326b0e4afcb99c8415f1337425e59c5)) +* **#1107:** drop support for *** for thematic-break ([#1203](https://github.com/athensresearch/athens/issues/1203)) ([b1d978e](https://github.com/athensresearch/athens/commit/b1d978e4d2893f0fc5f8612ef4737794c68c73fe)), closes [#1107](https://github.com/athensresearch/athens/issues/1107) +* **#1277:** compare string without dangerous regex injection for slash cmd ([#1287](https://github.com/athensresearch/athens/issues/1287)) ([c1c4c2a](https://github.com/athensresearch/athens/commit/c1c4c2a11cded25017cbba4a9ca15f660cef032f)), closes [#1277](https://github.com/athensresearch/athens/issues/1277) [#1277](https://github.com/athensresearch/athens/issues/1277) +* **click:** Clicking inside a block with hyperlink works on first attempt ([#1236](https://github.com/athensresearch/athens/issues/1236)) ([c619ecf](https://github.com/athensresearch/athens/commit/c619ecfe477cc8b503d504e32daf7e4e5f317f1d)) + +## [1.0.0-beta.88](https://github.com/athensresearch/athens/compare/v1.0.0-beta.87...v1.0.0-beta.88) (2021-06-06) + + +### Bug Fixes + +* **#1289:** remove autocomplete on backspace of [[ or (( ([eacd2a3](https://github.com/athensresearch/athens/commit/eacd2a322bad0c2901d35f3d1d01e78d5040ca61)), closes [#1289](https://github.com/athensresearch/athens/issues/1289) [#1289](https://github.com/athensresearch/athens/issues/1289) + +## [1.0.0-beta.87](https://github.com/athensresearch/athens/compare/v1.0.0-beta.86...v1.0.0-beta.87) (2021-06-06) + + +### Features + +* **athena:** Shift+Click opens in right sidebar, not just Shift+Enter ([#1272](https://github.com/athensresearch/athens/issues/1272)) ([13274dc](https://github.com/athensresearch/athens/commit/13274dc521b7c0e967f75a1b1dd657eab0dcf16c)) + + +### Bug Fixes + +* **blocks:** child drop-area-indicator shouldn't squish or be misplaced ([#1264](https://github.com/athensresearch/athens/issues/1264)) ([b04c5a2](https://github.com/athensresearch/athens/commit/b04c5a2eb55438988d697fc1dbf4f6e258966507)) + + +### Enhancements + +* **settings:** clarify language around autosave backups ([#1285](https://github.com/athensresearch/athens/issues/1285)) ([de86794](https://github.com/athensresearch/athens/commit/de867941dbd512a56536cd38f4b22b208941f323)) + + +### Documentation + +* Create a Contributor Covenant Code of Conduct ([#1210](https://github.com/athensresearch/athens/issues/1210)) ([f5d6a27](https://github.com/athensresearch/athens/commit/f5d6a27eaf692be222e9a264b845854e3654ec54)) + +## [1.0.0-beta.86](https://github.com/athensresearch/athens/compare/v1.0.0-beta.85...v1.0.0-beta.86) (2021-06-02) + + +### Bug Fixes + +* **selection:** Selection "freeze" ([#1273](https://github.com/athensresearch/athens/issues/1273)) ([b66bfa8](https://github.com/athensresearch/athens/commit/b66bfa84859088940a1d6280f61ced79196162be)) + + +### Performance + +* Only run the listener in dev mode ([#1215](https://github.com/athensresearch/athens/issues/1215)) ([b276963](https://github.com/athensresearch/athens/commit/b276963b6096887f68005073bd574619d1623953)) + + +### Documentation + +* update one liner in package.json and project.clj ([#1258](https://github.com/athensresearch/athens/issues/1258)) ([1b78701](https://github.com/athensresearch/athens/commit/1b7870161c0ded543ca073cae67af2ec4f6427fd)) + +## [1.0.0-beta.85](https://github.com/athensresearch/athens/compare/v1.0.0-beta.84...v1.0.0-beta.85) (2021-05-28) + + +* upgrade yarn deps alongside lein deps, fix demo ([#1246](https://github.com/athensresearch/athens/issues/1246)) ([c1b6195](https://github.com/athensresearch/athens/commit/c1b619519f1e46bd4f5e9dcae6bf86dac9382b77)) + +## [1.0.0-beta.84](https://github.com/athensresearch/athens/compare/v1.0.0-beta.83...v1.0.0-beta.84) (2021-05-28) + + +* downgrade re-frame-10x so web version works, comment out deps-check ([#1244](https://github.com/athensresearch/athens/issues/1244)) ([5fd5763](https://github.com/athensresearch/athens/commit/5fd57630c2bc76efba7c73d6b75cb33524935c52)) + +## [1.0.0-beta.83](https://github.com/athensresearch/athens/compare/v1.0.0-beta.82...v1.0.0-beta.83) (2021-05-28) + + +### Bug Fixes + +* **auto-complete:** pressing [[,(( and enter works better. close [#1214](https://github.com/athensresearch/athens/issues/1214), [#1220](https://github.com/athensresearch/athens/issues/1220) ([#1219](https://github.com/athensresearch/athens/issues/1219)) ([856b282](https://github.com/athensresearch/athens/commit/856b282f00a292b206afa5164a8901a15a3a9203)), closes [#1204](https://github.com/athensresearch/athens/issues/1204) + + +* update deps and cljstyle fix ([#1224](https://github.com/athensresearch/athens/issues/1224)) ([181ad52](https://github.com/athensresearch/athens/commit/181ad52286d982586a9c1f5016d37553915b6b05)) + + +### Enhancements + +* **ui:** Stop content shift when scrollbars appear/disappear ([#1212](https://github.com/athensresearch/athens/issues/1212)) ([9988417](https://github.com/athensresearch/athens/commit/9988417ca3d1298031bfee933366a9969a548910)) + + +### Documentation + +* Fix grammar in README ([#1235](https://github.com/athensresearch/athens/issues/1235)) ([e55f3f8](https://github.com/athensresearch/athens/commit/e55f3f82e328ee6b1ca758ac3bd5491f717680c5)) + + +### Performance + +* **blocks:** reduce blocks DOM weight ([#1217](https://github.com/athensresearch/athens/issues/1217)) ([7b922a5](https://github.com/athensresearch/athens/commit/7b922a523991fd5f59f69422a34feb47a9a6c758)) +* **right-sidebar:** fix memory+time leak with proper GC of sorted-map [#1239](https://github.com/athensresearch/athens/issues/1239) ([#1242](https://github.com/athensresearch/athens/issues/1242)) ([32d66f5](https://github.com/athensresearch/athens/commit/32d66f5e514531a080454dc337cf7535381865fb)) + +## [1.0.0-beta.82](https://github.com/athensresearch/athens/compare/v1.0.0-beta.81...v1.0.0-beta.82) (2021-05-20) + + +### Bug Fixes + +* **undo:** undo after pair character input ([#1194](https://github.com/athensresearch/athens/issues/1194)) ([b635da0](https://github.com/athensresearch/athens/commit/b635da00d98c296a533510ef4e47fb021800c75b)), closes [#559](https://github.com/athensresearch/athens/issues/559) +* **unlinked-refs:** update unlinked refs when page changes ([#1195](https://github.com/athensresearch/athens/issues/1195)) ([5d7b0fe](https://github.com/athensresearch/athens/commit/5d7b0febca5fb9030378857a32fda08f7d876982)) + + +### Performance + +* **search:** faster search for (()), [[]] and ctrl-k ([#1191](https://github.com/athensresearch/athens/issues/1191)) ([5cfcb2a](https://github.com/athensresearch/athens/commit/5cfcb2a60bc0a5079690fcfb8674767d1a458720)), closes [#756](https://github.com/athensresearch/athens/issues/756) [#756](https://github.com/athensresearch/athens/issues/756) + +## [1.0.0-beta.81](https://github.com/athensresearch/athens/compare/v1.0.0-beta.80...v1.0.0-beta.81) (2021-05-19) + + +### Features + +* **app-toolbar:** updated filesystem/sync icons ([#1146](https://github.com/athensresearch/athens/issues/1146)) ([e2ba5d7](https://github.com/athensresearch/athens/commit/e2ba5d7f69d8c628df7a86edfd153fb23643a12b)) +* **datalog-console:** respond to datalog-console messages in browser ([#1193](https://github.com/athensresearch/athens/issues/1193)) ([3ffb781](https://github.com/athensresearch/athens/commit/3ffb781fbfc44a37156a9290cd49a1e2ff631beb)) +* **electron:** set min width and height for electron window ([#1173](https://github.com/athensresearch/athens/issues/1173)) ([f41c028](https://github.com/athensresearch/athens/commit/f41c0289357a06c3d6a5ad538eb0c731457606d5)) +* **keybindings:** keybindings for Graph, All Pages, and Settings ([#1192](https://github.com/athensresearch/athens/issues/1192)) ([47efb81](https://github.com/athensresearch/athens/commit/47efb818a02a3e74e6e994337b0e1eb30c83199f)) + + +### Bug Fixes + +* **contentarea:** hide multiline text in contentarea ([#1189](https://github.com/athensresearch/athens/issues/1189)) ([dbaa1e5](https://github.com/athensresearch/athens/commit/dbaa1e5138640e2d88a702078e9e1bff408102a7)) +* **keybindings:** place caret correctly after ctrl-i italics ([#1176](https://github.com/athensresearch/athens/issues/1176)) ([a11ea7b](https://github.com/athensresearch/athens/commit/a11ea7b8fc3aff2afccf970658adace05f5c7a13)) +* **parser:** remove support for underscores so URLs can use ([dd5affb](https://github.com/athensresearch/athens/commit/dd5affb08f1c32efe1917704608bc7380c0df2ae)) +* **roam-import:** fix roam-date regex to match ordinal numbers in roam dates more ([#1171](https://github.com/athensresearch/athens/issues/1171)) ([ebd9aac](https://github.com/athensresearch/athens/commit/ebd9aac89e73f2fb7c97bb79903945410c6fe925)), closes [#1135](https://github.com/athensresearch/athens/issues/1135) + + +* **parser:** add regression test for fixed issue [#1057](https://github.com/athensresearch/athens/issues/1057) ([#1175](https://github.com/athensresearch/athens/issues/1175)) ([e74c0c6](https://github.com/athensresearch/athens/commit/e74c0c6cfe4e4288b7a34ff2588d6d4ae434ddb6)) + + +### Enhancements + +* **all-pages:** add arrow UI and re-frame constructs ([#1152](https://github.com/athensresearch/athens/issues/1152)) ([d59198f](https://github.com/athensresearch/athens/commit/d59198ff7f99d2fb80a35e43231b3dfec560955e)) + +## [1.0.0-beta.80](https://github.com/athensresearch/athens/compare/v1.0.0-beta.79...v1.0.0-beta.80) (2021-05-13) + + +### Bug Fixes + +* **keybindings:** redo sometimes does undo ([#1151](https://github.com/athensresearch/athens/issues/1151)) ([975afc0](https://github.com/athensresearch/athens/commit/975afc04df3422c8a519ef879a522eb0095a7862)) + + +* add cljstyle alias to lein ([#1132](https://github.com/athensresearch/athens/issues/1132)) ([a64025a](https://github.com/athensresearch/athens/commit/a64025a3abd9b1ceaa6b1ebd246f06f9f9a79038)) + +## [1.0.0-beta.79](https://github.com/athensresearch/athens/compare/v1.0.0-beta.78...v1.0.0-beta.79) (2021-05-10) + + +### Bug Fixes + +* Removal of a daily journal page creates a 404 ([#1094](https://github.com/athensresearch/athens/issues/1094)) ([640420f](https://github.com/athensresearch/athens/commit/640420f8fa81ccf88b0a3dc73b55f89949e91a87)) +* **block-embed:** when block-embed is deleted, render uid instead of "invalid" ([#1093](https://github.com/athensresearch/athens/issues/1093)) ([23f1f93](https://github.com/athensresearch/athens/commit/23f1f93453bc090ec6ac4fe1e5562f1183f4365d)) + + +### Enhancements + +* **all-pages:** allow sort by titles / links / times ([#1105](https://github.com/athensresearch/athens/issues/1105)) ([2e6c548](https://github.com/athensresearch/athens/commit/2e6c54865e1a7b78419c639627aebf4cad621695)) +* **daily-notes:** Shorter debounce time for loading of daily pages ([#1136](https://github.com/athensresearch/athens/issues/1136)) ([9dca967](https://github.com/athensresearch/athens/commit/9dca967ce1564f9c6c09a46f8d3324b6c9c81585)) +* **left-sidebar:** clicking on the logo opens to issue creation rather than main repo [#1130](https://github.com/athensresearch/athens/issues/1130) ([82dd853](https://github.com/athensresearch/athens/commit/82dd853d1f9d253a315f8bc7aadcd9e625300367)) +* **linked-refs:** sort references by newest first ([#1124](https://github.com/athensresearch/athens/issues/1124)) ([19fb97a](https://github.com/athensresearch/athens/commit/19fb97add8e744e24e7d7df3aec2238556cf22da)), closes [#728](https://github.com/athensresearch/athens/issues/728) + +## [1.0.0-beta.78](https://github.com/athensresearch/athens/compare/v1.0.0-beta.77...v1.0.0-beta.78) (2021-04-29) + + +### Features + +* **blocks:** improved visibility of hover/focus of block bullets ([#1046](https://github.com/athensresearch/athens/issues/1046)) ([84f971a](https://github.com/athensresearch/athens/commit/84f971a20a4a1d7ef510916a490b94ae5c83d572)) +* **style:** improved highlight color consistency ([#1049](https://github.com/athensresearch/athens/issues/1049)) ([4546825](https://github.com/athensresearch/athens/commit/454682547046109d0cea3512d17d4fb3095397ec)) + + +### Bug Fixes + +* **breadcrumbs:** limit breadcrumb size ([#1047](https://github.com/athensresearch/athens/issues/1047)) ([a4686cc](https://github.com/athensresearch/athens/commit/a4686cc2d7978318e63c5ca5ab7618707a7e6048)) +* **filesystem:** show hidden database merge button ([#1079](https://github.com/athensresearch/athens/issues/1079)) ([432efb9](https://github.com/athensresearch/athens/commit/432efb9e0e24035ce703a11043065913800581f8)) +* use index.transit file if already exists ([#1044](https://github.com/athensresearch/athens/issues/1044)) ([865093f](https://github.com/athensresearch/athens/commit/865093f450dc70be3619f8c062dfbb398b815516)) + + +### Refactors + +* **node-page:** replace nodepage dropdown with popover ([#1045](https://github.com/athensresearch/athens/issues/1045)) ([2104370](https://github.com/athensresearch/athens/commit/2104370122e7d9036b6da613a2a850dd451c87ea)) + +## [1.0.0-beta.77](https://github.com/athensresearch/athens/compare/v1.0.0-beta.76...v1.0.0-beta.77) (2021-04-27) + + +### Bug Fixes + +* **demo:** solve cause of toolbar button wrapping on demo page ([#1037](https://github.com/athensresearch/athens/issues/1037)) ([d0230f6](https://github.com/athensresearch/athens/commit/d0230f66b4277cc4046ed4c0eb2ac3c2d1002a3d)) +* allow spaces in image urls ([#1034](https://github.com/athensresearch/athens/issues/1034)) ([aa07a57](https://github.com/athensresearch/athens/commit/aa07a57e8e1b4e2616bd66d8aa3daef3ccb053a8)) +* ensure choose file input is visible in merge from roam dialog ([#1032](https://github.com/athensresearch/athens/issues/1032)) ([a69e470](https://github.com/athensresearch/athens/commit/a69e470f566ab37a0c6c0fe55c55c6760adfe0af)) + + +### Documentation + +* improve README ([#1018](https://github.com/athensresearch/athens/issues/1018)) ([68d7bf4](https://github.com/athensresearch/athens/commit/68d7bf44ec7541ea7e1db758b7c74ad03a954605)) +* update blog links and faces ([5949786](https://github.com/athensresearch/athens/commit/5949786cd912568088f2dbd9813169edbf70c355)) + + +### Refactors + +* decompose blocks into its own namespace ([#1033](https://github.com/athensresearch/athens/issues/1033)) ([6023c44](https://github.com/athensresearch/athens/commit/6023c4420a263ebde16742d3d1de1d78b4df473a)) +* group pages into new namespace ([#1005](https://github.com/athensresearch/athens/issues/1005)) ([e2046bf](https://github.com/athensresearch/athens/commit/e2046bf7d27a7cf41a3d30e499e9647a6b63520c)) + + +### Enhancements + +* **all-pages:** hide empty block bullets in all pages view ([#1040](https://github.com/athensresearch/athens/issues/1040)) ([6a308c5](https://github.com/athensresearch/athens/commit/6a308c5ab024502f1955a9965bc6ba15c5d77578)) + +## [1.0.0-beta.76](https://github.com/athensresearch/athens/compare/v1.0.0-beta.75...v1.0.0-beta.76) (2021-04-23) + + +### Bug Fixes + +* allow editing of join remote fields ([#1019](https://github.com/athensresearch/athens/issues/1019)) ([3bf2707](https://github.com/athensresearch/athens/commit/3bf27075ca074ccfe7f768ea5d043268524679d7)) + +## [1.0.0-beta.75](https://github.com/athensresearch/athens/compare/v1.0.0-beta.74...v1.0.0-beta.75) (2021-04-22) + + +### Bug Fixes + +* bugs introduced by new parser ([#1017](https://github.com/athensresearch/athens/issues/1017)) ([a2c7cb6](https://github.com/athensresearch/athens/commit/a2c7cb6b914b62562da0c811fdae246afa4c1cc2)) + +## [1.0.0-beta.74](https://github.com/athensresearch/athens/compare/v1.0.0-beta.73...v1.0.0-beta.74) (2021-04-22) + + +### Bug Fixes + +* allow for multiple block-refs in a block. ([#1012](https://github.com/athensresearch/athens/issues/1012)) ([f26b719](https://github.com/athensresearch/athens/commit/f26b7195ed6c7975908b6f6640921dcf68274e23)) + +## [1.0.0-beta.73](https://github.com/athensresearch/athens/compare/v1.0.0-beta.72...v1.0.0-beta.73) (2021-04-21) + + +* **yarn.lock:** electron 11->12 needs new yarn.lock ([#1009](https://github.com/athensresearch/athens/issues/1009)) ([7239e51](https://github.com/athensresearch/athens/commit/7239e5176139a1c7d6672009325838cc97645376)) + +## [1.0.0-beta.72](https://github.com/athensresearch/athens/compare/v1.0.0-beta.70...v1.0.0-beta.72) (2021-04-21) + + +### Bug Fixes + +* Shift+click title of reference won't open it in the right sidebar ([#995](https://github.com/athensresearch/athens/issues/995)) ([31e7bd0](https://github.com/athensresearch/athens/commit/31e7bd0a35074aa729d6e68033d665b1ab281497)) + + +### Refactors + +* **electron:** update from 11 to 12 ([#982](https://github.com/athensresearch/athens/issues/982)) ([d9d8cbf](https://github.com/athensresearch/athens/commit/d9d8cbf44cc47f15d2d8e19962425fdf165f302a)) + + +### Enhancements + +* **electron:** make bg default to dark ([#1003](https://github.com/athensresearch/athens/issues/1003)) ([b7f1c6e](https://github.com/athensresearch/athens/commit/b7f1c6e003d5309413b4908a37ce8b01de25a66f)) +* toolbar, sidebar, and scrolling polish ([#998](https://github.com/athensresearch/athens/issues/998)) ([4cf0c99](https://github.com/athensresearch/athens/commit/4cf0c990f009ae334e96803887e9153cd2e07e38)) + +## [1.0.0-beta.71](https://github.com/athensresearch/athens/compare/v1.0.0-beta.70...v1.0.0-beta.71) (2021-04-21) + + +### Features + +* **app-toolbar:** basic tooltips ([#994](https://github.com/athensresearch/athens/issues/994)) ([2996bad](https://github.com/athensresearch/athens/commit/2996bada15ba8fb1b3eab5ecbd1a25bc2aecc298)) + + +### Bug Fixes + +* `deref` error in web environment. ([#999](https://github.com/athensresearch/athens/issues/999)) ([fa186cd](https://github.com/athensresearch/athens/commit/fa186cdb5c6d7f8650ca41ab1e3dc7451905d3a2)) + + +### Refactors + +* **parser:** multi-stage parser ([#957](https://github.com/athensresearch/athens/issues/957)) ([9a30ce1](https://github.com/athensresearch/athens/commit/9a30ce139c88b2f412d2f2f8f0c9b597c73994c7)) + +![](https://media.discordapp.net/attachments/800579670629679165/834182913792671744/115297103-a681b880-a110-11eb-8bc1-5219441d4bbd.png?width=1440&height=610) + +## [1.0.0-beta.70](https://github.com/athensresearch/athens/compare/v1.0.0-beta.69...v1.0.0-beta.70) (2021-04-20) + + +### Bug Fixes + +* Delete a block and "undo" cannot bring it back ([#991](https://github.com/athensresearch/athens/issues/991)) ([a7958fc](https://github.com/athensresearch/athens/commit/a7958fcfbfcb326cdaa16e6a0391dbd1a0a2dc65)) +* **right-sidebar:** scroll to top if open another page on right ([#992](https://github.com/athensresearch/athens/issues/992)) ([4fff704](https://github.com/athensresearch/athens/commit/4fff7048c9a295f69be0debaa8a657f12961b573)) + + +### Enhancements + +* **db-picker:** Polish db dialog ([#993](https://github.com/athensresearch/athens/issues/993)) ([a104161](https://github.com/athensresearch/athens/commit/a10416180597bada4752229421427069637bb604)) + +## [1.0.0-beta.69](https://github.com/athensresearch/athens/compare/v1.0.0-beta.68...v1.0.0-beta.69) (2021-04-18) + + +### Features + +* self-hosted real-time collab and presence ([#930](https://github.com/athensresearch/athens/issues/930)) ([8393c47](https://github.com/athensresearch/athens/commit/8393c4764d7866cf1fb8b2651c9bac8263077e7c)) + + +### Bug Fixes + +* expand the breadcrumb when [[link]], don't navigate ([#976](https://github.com/athensresearch/athens/issues/976)) ([2d3c086](https://github.com/athensresearch/athens/commit/2d3c08624340494040e268cce4ae0688fda16422)) +* non-formatted breadcrumb on page block ([#972](https://github.com/athensresearch/athens/issues/972)) ([c875b3d](https://github.com/athensresearch/athens/commit/c875b3d6b02256e949c0e105388eff79849403cc)), closes [#970](https://github.com/athensresearch/athens/issues/970) + + +### Performance + +* **athena:** debounce athena search ([#975](https://github.com/athensresearch/athens/issues/975)) ([0eef6e2](https://github.com/athensresearch/athens/commit/0eef6e234aa50c9c1d56a0f6c808006eb6093e5c)) + +## [1.0.0-beta.68](https://github.com/athensresearch/athens/compare/v1.0.0-beta.66...v1.0.0-beta.68) (2021-04-12) + + +### Bug Fixes + +* add missing toolbar background color ([#941](https://github.com/athensresearch/athens/issues/941)) ([e17c2c1](https://github.com/athensresearch/athens/commit/e17c2c10842c40383dbccb6afca0005ea8c270f7)) +* athena should stay within viewport bounds ([#943](https://github.com/athensresearch/athens/issues/943)) ([8593b58](https://github.com/athensresearch/athens/commit/8593b58dcf6b4511eb8ff1c5013d23e61a48548e)) +* block ref count is above the search dropdown ([#940](https://github.com/athensresearch/athens/issues/940)) ([d94e5fd](https://github.com/athensresearch/athens/commit/d94e5fdcbfacbfb9327faec0a996cbe971d3544e)) +* Checking a TODO item in block embed results to error ([#939](https://github.com/athensresearch/athens/issues/939)) ([dc393d4](https://github.com/athensresearch/athens/commit/dc393d45bb855e1e48c2bf34c8e0122f43dbf9ce)) +* clearer checkbox checked style ([#942](https://github.com/athensresearch/athens/issues/942)) ([eec391c](https://github.com/athensresearch/athens/commit/eec391c9f64136007f57f6edc525587fe98bffb4)) + + +### Documentation + +* remove all docs ([#919](https://github.com/athensresearch/athens/issues/919)) ([b5f2887](https://github.com/athensresearch/athens/commit/b5f2887a3434c4a63a924cb6157a393939356752)) + + +### Enhancements + +* **settings:** redo settings page ([#931](https://github.com/athensresearch/athens/issues/931)) ([c1ebd70](https://github.com/athensresearch/athens/commit/c1ebd70156fb241fea6f564a01e874b660dc7800)) + +## [1.0.0-beta.67](https://github.com/athensresearch/athens/compare/v1.0.0-beta.66...v1.0.0-beta.67) (2021-04-11) + + +### Bug Fixes + +* add missing toolbar background color ([#941](https://github.com/athensresearch/athens/issues/941)) ([e17c2c1](https://github.com/athensresearch/athens/commit/e17c2c10842c40383dbccb6afca0005ea8c270f7)) +* athena should stay within viewport bounds ([#943](https://github.com/athensresearch/athens/issues/943)) ([8593b58](https://github.com/athensresearch/athens/commit/8593b58dcf6b4511eb8ff1c5013d23e61a48548e)) +* block ref count is above the search dropdown ([#940](https://github.com/athensresearch/athens/issues/940)) ([d94e5fd](https://github.com/athensresearch/athens/commit/d94e5fdcbfacbfb9327faec0a996cbe971d3544e)) +* Checking a TODO item in block embed results to error ([#939](https://github.com/athensresearch/athens/issues/939)) ([dc393d4](https://github.com/athensresearch/athens/commit/dc393d45bb855e1e48c2bf34c8e0122f43dbf9ce)) +* fix-border-flash-on-new-block ([(#944)](https://github.com/athensresearch/athens/commit/67ab47fc161e9918d873aa370430e0e088c19b41)) + + +### Documentation + +* remove all docs ([#919](https://github.com/athensresearch/athens/issues/919)) ([b5f2887](https://github.com/athensresearch/athens/commit/b5f2887a3434c4a63a924cb6157a393939356752)) + +## [1.0.0-beta.66](https://github.com/athensresearch/athens/compare/v1.0.0-beta.65...v1.0.0-beta.66) (2021-04-07) + + +### Features + +* import from Roam ([#918](https://github.com/athensresearch/athens/issues/918)) ([1a3ee32](https://github.com/athensresearch/athens/commit/1a3ee3205a9ddfed54c3f40705d9ec2daca22e5d)) + + +### Documentation + +* describe collapse/expand shortcut in Welcome ([#913](https://github.com/athensresearch/athens/issues/913)) ([0bc7bc3](https://github.com/athensresearch/athens/commit/0bc7bc30b3ed342b98b8724f6f77d1ba2c7402ec)) + +## [1.0.0-beta.65](https://github.com/athensresearch/athens/compare/v1.0.0-beta.64...v1.0.0-beta.65) (2021-04-06) + + +### Bug Fixes + +* **10x:** ctrl/cmd-t always works to toggle 10x ([#907](https://github.com/athensresearch/athens/issues/907)) ([460fdbd](https://github.com/athensresearch/athens/commit/460fdbd017aafe04d82259e2f1edc455259a1d00)) + + +* **db:** add Karma test for node search by title ([#903](https://github.com/athensresearch/athens/issues/903)) ([2876194](https://github.com/athensresearch/athens/commit/2876194a0c68da33f8dd0eadd3a5534f2b811148)) +* **settings:** allow any user to opt-out; improve copy ([#908](https://github.com/athensresearch/athens/issues/908)) ([98a1fca](https://github.com/athensresearch/athens/commit/98a1fca13aed2bead6dd3ec9ee41d7c0078e0a53)) + +## [1.0.0-beta.64](https://github.com/athensresearch/athens/compare/v1.0.0-beta.63...v1.0.0-beta.64) (2021-04-02) + + +### Features + +* **copy+paste:** respect h1/h2/h3 markdown ([#901](https://github.com/athensresearch/athens/issues/901)) ([a2bcef5](https://github.com/athensresearch/athens/commit/a2bcef5c0862dcc88af5ff73ad4b0f044ad2c1d6)) + +## [1.0.0-beta.63](https://github.com/athensresearch/athens/compare/v1.0.0-beta.62...v1.0.0-beta.63) (2021-04-01) + + +### Features + +* **copy:** right-click to copy without formatting ([#897](https://github.com/athensresearch/athens/issues/897)) ([d994f66](https://github.com/athensresearch/athens/commit/d994f664032a0ac5e1643f1974a6504a8e2f9211)) + + +* increase network request timeout ([#899](https://github.com/athensresearch/athens/issues/899)) ([a224c23](https://github.com/athensresearch/athens/commit/a224c23791f8d5b5f03507e8fac5917a34608130)) + +## [1.0.0-beta.62](https://github.com/athensresearch/athens/compare/v1.0.0-beta.61...v1.0.0-beta.62) (2021-04-01) + + +### Features + +* **block-ref:** shift + "Drag'n'Drop" to copy block uid ([#876](https://github.com/athensresearch/athens/issues/876)) ([613b244](https://github.com/athensresearch/athens/commit/613b244a1ecc696d5fbd6e885f7e47f0c4e69ff4)) + + +### Bug Fixes + +* url to changelog ([#896](https://github.com/athensresearch/athens/issues/896)) ([241f1fd](https://github.com/athensresearch/athens/commit/241f1fdfa2d56726d3d87eb95193ffc9e9858858)) +* **athena:** make sure element exists before scroll ([#877](https://github.com/athensresearch/athens/issues/877)) ([32ae526](https://github.com/athensresearch/athens/commit/32ae52623841ebd499f4106167c24535005e5f4a)) +* catch all KaTeX errors ([#871](https://github.com/athensresearch/athens/issues/871)) ([e37a50c](https://github.com/athensresearch/athens/commit/e37a50cc436e108d9317160b835cb9a02d95844b)) +* **parser:** parse some edge case URLs ([#838](https://github.com/athensresearch/athens/issues/838)) ([1d20057](https://github.com/athensresearch/athens/commit/1d200577f61702dc31a85f582be28899beaf7da2)) + +## [1.0.0-beta.61](https://github.com/athensresearch/athens/compare/v1.0.0-beta.60...v1.0.0-beta.61) (2021-03-25) + + +### Bug Fixes + +* **parser:** parse multiple raw URLs in a single block ([#830](https://github.com/athensresearch/athens/issues/830)) ([92b766a](https://github.com/athensresearch/athens/commit/92b766a8628b95b0c136677b74d2df9fdcfaa042)) +* toolbar overlaps on scrollbar ([#818](https://github.com/athensresearch/athens/issues/818)) ([8aa6790](https://github.com/athensresearch/athens/commit/8aa6790876cd31a85f4e7e02352d028620cba932)) + + +### Documentation + +* update download links ([#804](https://github.com/athensresearch/athens/issues/804)) ([1c4aa1a](https://github.com/athensresearch/athens/commit/1c4aa1ada5e325388bc874a7aeb023760ccad64a)) + +## [1.0.0-beta.60](https://github.com/athensresearch/athens/compare/v1.0.0-beta.59...v1.0.0-beta.60) (2021-03-13) + + +### Bug Fixes + +* clicking the date doesn't open the date page ([#807](https://github.com/athensresearch/athens/issues/807)) ([9fc1a72](https://github.com/athensresearch/athens/commit/9fc1a72d7205b7bba4d4c95fb0887a8c36bf95ce)), closes [#802](https://github.com/athensresearch/athens/issues/802) +* toolbar overlaps on scrollbar ([#809](https://github.com/athensresearch/athens/issues/809)) ([2657478](https://github.com/athensresearch/athens/commit/2657478db5ef6d77b20085b6a8093c708c974033)) + + +### Documentation + +* add gitbook config ([7b2c8d7](https://github.com/athensresearch/athens/commit/7b2c8d74122243083fd837ecf344e6f5b45a6090)) +* add more contributor faces ([9b7fdad](https://github.com/athensresearch/athens/commit/9b7fdada18f84e965fecdc1b7713bf78b519dbdc)) + + +### Performance + +* **undo/redo:** undo by transaction rather than entire db ([#808](https://github.com/athensresearch/athens/issues/808)) ([167804d](https://github.com/athensresearch/athens/commit/167804d07226c2909cc4d5cb8b383afce777bdf8)) + +## [1.0.0-beta.59](https://github.com/athensresearch/athens/compare/v1.0.0-beta.58...v1.0.0-beta.59) (2021-03-11) + + +### Bug Fixes + +* **undo/redo:** textarea undo/redo; italics, highlight, underline, strikethrough [#717](https://github.com/athensresearch/athens/issues/717) ([#803](https://github.com/athensresearch/athens/issues/803)) ([2509b9a](https://github.com/athensresearch/athens/commit/2509b9a3cb63bea66f6b44cedf398d7b93dfe825)) + +## [1.0.0-beta.58](https://github.com/athensresearch/athens/compare/v1.0.0-beta.57...v1.0.0-beta.58) (2021-03-11) + + +### Bug Fixes + +* **title:** shift-click on title should open in right sidebar ([#802](https://github.com/athensresearch/athens/issues/802)) ([a51e001](https://github.com/athensresearch/athens/commit/a51e001cd4543b389460b1def17446230f51062c)), closes [#775](https://github.com/athensresearch/athens/issues/775) +* Persistent Pop-up when scrolling ([#801](https://github.com/athensresearch/athens/issues/801)) ([ce4fe61](https://github.com/athensresearch/athens/commit/ce4fe61e1e73733e22367f1160456ce0829a9e8f)) + +## [1.0.0-beta.57](https://github.com/athensresearch/athens/compare/v1.0.0-beta.56...v1.0.0-beta.57) (2021-03-11) + + +### Bug Fixes + +* **blocks:** links made clickable, z-index clean up [#772](https://github.com/athensresearch/athens/issues/772) ([#778](https://github.com/athensresearch/athens/issues/778)) ([8cabc5f](https://github.com/athensresearch/athens/commit/8cabc5fa38cdcf6624cb84b5826fbb17de628fea)) +* **icons:** fix [#792](https://github.com/athensresearch/athens/issues/792) ([#795](https://github.com/athensresearch/athens/issues/795)) ([3c7368d](https://github.com/athensresearch/athens/commit/3c7368d4e18b21b24ab5fbfda29aa638934d4525)) + + +* S3 -> GitHub release ([#799](https://github.com/athensresearch/athens/issues/799)) ([19ceb80](https://github.com/athensresearch/athens/commit/19ceb80cc713112573098cbaf10ae7dddcea5391)) + +## [1.0.0-beta.56](https://github.com/athensresearch/athens/compare/v1.0.0-beta.55...v1.0.0-beta.56) (2021-03-10) + + +### Bug Fixes + +* **electron:** issue updating Mac (Intel, not Silicon) ([#796](https://github.com/athensresearch/athens/issues/796)) ([2037a83](https://github.com/athensresearch/athens/commit/2037a83ebf60ca53a1b96be653cd55e82c36d557)), closes [#749](https://github.com/athensresearch/athens/issues/749) [#793](https://github.com/athensresearch/athens/issues/793) + + +### Documentation + +* update version number ([60390da](https://github.com/athensresearch/athens/commit/60390daea2ddba084c00ad78d65e6080c062d7ef)) + +## [1.0.0-beta.55](https://github.com/athensresearch/athens/compare/v1.0.0-beta.54...v1.0.0-beta.55) (2021-03-10) + + +### Documentation + +* add directions on how to download a specific version of Athens ([6d6e7ac](https://github.com/athensresearch/athens/commit/6d6e7ac245837fef48c5578fdf382f1fd462cad6)) + +## [1.0.0-beta.54](https://github.com/athensresearch/athens/compare/v1.0.0-beta.53...v1.0.0-beta.54) (2021-03-10) + + +### Enhancements + +* **linked-refs:** sort references by newest first ([#776](https://github.com/athensresearch/athens/issues/776)) ([954da17](https://github.com/athensresearch/athens/commit/954da17da57524fe55a4b25ea3c518a012f803c7)) + + +### Performance + +* **icons:** optimize size of material-ui/icons ([#790](https://github.com/athensresearch/athens/issues/790)) ([e60d92f](https://github.com/athensresearch/athens/commit/e60d92fb9d1235d205f868d91fbce6935617d254)) + +## [1.0.0-beta.53](https://github.com/athensresearch/athens/compare/v1.0.0-beta.52...v1.0.0-beta.53) (2021-03-09) + + +### Bug Fixes + +* **block-ref:** better reference number ([#781](https://github.com/athensresearch/athens/issues/781)) ([1c75578](https://github.com/athensresearch/athens/commit/1c75578c8496725c5f7debbad20972c60ea24c97)), closes [#742](https://github.com/athensresearch/athens/issues/742) +* **electron:** issue updating Mac App ([#777](https://github.com/athensresearch/athens/issues/777)) ([cf259a1](https://github.com/athensresearch/athens/commit/cf259a1de5e98d44f4cf3c8525b793b54a131c72)) +* **enter/tab:** do not apply changes to previous line when fast keystrokes ([#768](https://github.com/athensresearch/athens/issues/768)) ([f449fcb](https://github.com/athensresearch/athens/commit/f449fcb51821ef073b9f666677372863b4826c26)) + + +### Documentation + +* use gifs ([07dc655](https://github.com/athensresearch/athens/commit/07dc65528059d943531ab14d15e0ce5b53ed5819)) + +## [1.0.0-beta.52](https://github.com/athensresearch/athens/compare/v1.0.0-beta.51...v1.0.0-beta.52) (2021-03-09) + + +### Bug Fixes + +* **clj-kondo:** upgrade CI, with-let, specter, remove #_:clj-kondo/ignore ([#769](https://github.com/athensresearch/athens/issues/769)) ([40dabd7](https://github.com/athensresearch/athens/commit/40dabd71998514b7368a8d73959b4e6e27f5cfd1)) +* **web:** theme and graph conf issue [#773](https://github.com/athensresearch/athens/issues/773) ([#779](https://github.com/athensresearch/athens/issues/779)) ([8c42437](https://github.com/athensresearch/athens/commit/8c424372d9c5f8f15f54f3b9703b1d4196909e4c)) +* unlinked reference overflowing content ([#770](https://github.com/athensresearch/athens/issues/770)) ([defc78d](https://github.com/athensresearch/athens/commit/defc78d3ddfa7faafc585f7e9203d5cdd5c70bb2)) + + +### Documentation + +* add download links for M1 ([6d33825](https://github.com/athensresearch/athens/commit/6d33825605ce0e09d4a5b37887c318bf6be1d96a)) +* update screenshot url ([b76f45f](https://github.com/athensresearch/athens/commit/b76f45fe76fe89ba6a37764d369b23289cbaa582)) +* **README:** update screenshots, add PH badge ([#774](https://github.com/athensresearch/athens/issues/774)) ([713b4a3](https://github.com/athensresearch/athens/commit/713b4a337b5716306a525d89e353311fa8cbcb7b)) + +## [1.0.0-beta.51](https://github.com/athensresearch/athens/compare/v1.0.0-beta.50...v1.0.0-beta.51) (2021-03-07) + + +### Features + +* **graph:** local-graphs and view customization ([#767](https://github.com/athensresearch/athens/issues/767)) ([e015a04](https://github.com/athensresearch/athens/commit/e015a049f8a77f9e7f5d09638307d18fb27356ac)) +* **toolbar:** add a "Load Test DB" button for web demo ([#764](https://github.com/athensresearch/athens/issues/764)) ([685117c](https://github.com/athensresearch/athens/commit/685117c0598e9ab8f58baf7ebfa938b67a003401)) + +## [1.0.0-beta.50](https://github.com/athensresearch/athens/compare/v1.0.0-beta.49...v1.0.0-beta.50) (2021-03-05) + +### Chore + +* update electron and electron-builder ~> darwin-arm64 for M1 Mac + + +### Bug Fixes + +* **linked-refs/block-embed:** drag & drop multiple blocks ([#743](https://github.com/athensresearch/athens/issues/743)) ([6f7407d](https://github.com/athensresearch/athens/commit/6f7407d723a4d7638d65475a9ae2398326059166)) + +## [1.0.0-beta.49](https://github.com/athensresearch/athens/compare/v1.0.0-beta.48...v1.0.0-beta.49) (2021-03-04) + + +### Bug Fixes + +* **electron:** Avoid race-condition with filesystem mtime ([#734](https://github.com/athensresearch/athens/issues/734)) ([fdc5bbf](https://github.com/athensresearch/athens/commit/fdc5bbf5a6c5556fc0a6d1933c00e9b1286a97d0)) + +## [1.0.0-beta.48](https://github.com/athensresearch/athens/compare/v1.0.0-beta.47...v1.0.0-beta.48) (2021-03-04) + + +### Bug Fixes + +* **typo:** package.json hiddren->hidden ([#725](https://github.com/athensresearch/athens/issues/725)) ([148d02e](https://github.com/athensresearch/athens/commit/148d02ebad8b5ac949ca47b39f4ec2c27937b1c1)) + + +### Enhancements + +* **auth:** have another cond in case networking or other error ([#721](https://github.com/athensresearch/athens/issues/721)) ([bdae4ec](https://github.com/athensresearch/athens/commit/bdae4ec56381f3d46619b17be2dc568e2d06b5c3)) +* **graph:** clicking on node navigates to page ([#731](https://github.com/athensresearch/athens/issues/731)) ([94fbd64](https://github.com/athensresearch/athens/commit/94fbd64192b36726f68e5b366fd5c487af7d9c5e)) +* **parser:** Add LaTeX support ([#726](https://github.com/athensresearch/athens/issues/726)) ([3a3fb1f](https://github.com/athensresearch/athens/commit/3a3fb1f28cad0038e3e11a4d7c5418233cb519d2)) + +## [1.0.0-beta.47](https://github.com/athensresearch/athens/compare/v1.0.0-beta.46...v1.0.0-beta.47) (2021-03-02) + + +### Features + +* **block-embed:** block-embed and component refactor [#584](https://github.com/athensresearch/athens/issues/584) ([#719](https://github.com/athensresearch/athens/issues/719)) ([7b65099](https://github.com/athensresearch/athens/commit/7b65099a1116caad7d873791ddd294e52f068c1b)) + + +### Bug Fixes + +* **title:** Empty title and regex in block query ([#720](https://github.com/athensresearch/athens/issues/720)) ([785ee38](https://github.com/athensresearch/athens/commit/785ee3834b66315d56c865e5c10879a620f337b6)) + +## [1.0.0-beta.46](https://github.com/athensresearch/athens/compare/v1.0.0-beta.45...v1.0.0-beta.46) (2021-02-26) + + +### Performance + +* use shadow-cljs release, improve job dependencies ([#704](https://github.com/athensresearch/athens/issues/704)) ([f6793d6](https://github.com/athensresearch/athens/commit/f6793d67df79f8d77b695b9f9a9c0e75a9e537db)) + +## [1.0.0-beta.45](https://github.com/athensresearch/athens/compare/v1.0.0-beta.44...v1.0.0-beta.45) (2021-02-26) + + +### Bug Fixes + +* **electron:** make sure main-window exists before sending ([#699](https://github.com/athensresearch/athens/issues/699)) ([68eb2d8](https://github.com/athensresearch/athens/commit/68eb2d8e9364caf6392aa2edef91e19f4c12b903)) + + +### Documentation + +* added calva jack-in command for windows ([#701](https://github.com/athensresearch/athens/issues/701)) ([49dbddf](https://github.com/athensresearch/athens/commit/49dbddfe19bd0124fea12e472cd08238b7fc57b0)) + +## [1.0.0-beta.40](https://github.com/athensresearch/athens/compare/v1.0.0-beta.39...v1.0.0-beta.40) (2021-02-16) + + +### Features + +* **graph-viz:** first pass ([#645](https://github.com/athensresearch/athens/issues/645)) ([e57f0ae](https://github.com/athensresearch/athens/commit/e57f0aed323ee8064b4cd92d7f9eac8a800dbede)) + + +### Enhancements + +* **analytics:** opt-out includes Sentry, don't capture info logs, global alert ([#652](https://github.com/athensresearch/athens/issues/652)) ([4964c76](https://github.com/athensresearch/athens/commit/4964c765bb4ef3f269ec94cd09e63ab3d515cca1)) +* **analytics:** track opt-in/opt-out ([#654](https://github.com/athensresearch/athens/issues/654)) ([24cdc04](https://github.com/athensresearch/athens/commit/24cdc04f798770827102beef1703e7fb18895dc2)) + +## [1.0.0-beta.39](https://github.com/athensresearch/athens/compare/v1.0.0-beta.38...v1.0.0-beta.39) (2021-02-14) + + +### Bug Fixes + +* **keybindings:** partial solution to [#573](https://github.com/athensresearch/athens/issues/573) ([#578](https://github.com/athensresearch/athens/issues/578)) ([c33f283](https://github.com/athensresearch/athens/commit/c33f283963bc7bb02e5163884e9c97a97c7c4c0f)) +* **parser:** parse bare urls [#549](https://github.com/athensresearch/athens/issues/549) ([#636](https://github.com/athensresearch/athens/issues/636)) ([c0dfa79](https://github.com/athensresearch/athens/commit/c0dfa797085b25598c5f27ca1e53c89d4ba45215)) +* [#635](https://github.com/athensresearch/athens/issues/635), only open in right sidebar on SHIFT-click ([#637](https://github.com/athensresearch/athens/issues/637)) ([f6b88b5](https://github.com/athensresearch/athens/commit/f6b88b5e6ddcc319f7d083c938b9a9a9cff7efd8)) +* linked reference should show the formatting ([#634](https://github.com/athensresearch/athens/issues/634)) ([d5b1241](https://github.com/athensresearch/athens/commit/d5b12419b9eb43965d799ae38872653e6d4c6491)) diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 9e7fe03984..46cbea0a83 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -60,7 +60,7 @@ representative at an online or offline event. Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at -[INSERT CONTACT METHOD]. +researchathens@gmail.com. All complaints will be reviewed and investigated promptly and fairly. All community leaders are obligated to respect the privacy and security of the diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index 2c456f1f0f..0000000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,87 +0,0 @@ -# Contributing to Athens - -If you're looking for somewhere to start contributing, check out the issues tagged with "first issue" found in [issues](https://github.com/athensresearch/athens/issues). If you have a specific feature that you would like to work on (for which there is no open issue yet), please let us know. - -These are mostly guidelines, not rules. Use your best judgment, and feel free to propose changes to this document in a pull request. - -Before contributing, please first read the [Code of Conduct](https://github.com/athensresearch/athens/blob/master/CODE_OF_CONDUCT.md). - -## Communication Channels - -Most communication about Athens development happens in the #athens channel in the [Roam Slack](https://roamresearch.slack.com/join/shared_invite/enQtODg3NjIzODEwNDgwLTdhMjczMGYwN2YyNmMzMDcyZjViZDk0MTA2M2UxOGM5NTMxNDVhNDE1YWVkNTFjMGM4OTE3MTQ3MjEzNzE1MTA). - -You can also reach out to Jeff on Twitter at [@tangjeff0](https://twitter.com/tangjeff0). He posts regular updates about this project. We will make an Athens Research Twitter account later at a suitable time. - -## Development Environment - -1. Download a `java` JDK. You can download the most [current version](https://www.oracle.com/java/technologies/javase-downloads.html) or access the [JDK archives](https://jdk.java.net/archive/). -2. Download `yarn`: [yarn](https://www.npmjs.com/package/yarn). -3. Install `lein` (build automation and dependency management tool for Clojure). You can either use a [package manager](https://github.com/technomancy/leiningen/wiki/Packaging) such as Homebrew or Chocolatey, or you can download from the command line. Detailed instructions for installing from the command line on Linux, macOS, and Windows are below. - -### On Linux: - * Install curl command -> ```sudo apt-get install -y curl``` - * Download the lein script -> ```curl https://raw.githubusercontent.com/technomancy/leiningen/stable/bin/lein > lein``` - * Move the lein script to the user programs directory -> ```sudo mv lein /usr/local/bin/lein``` - * Add execute permissions to the lein script -> ```sudo chmod a+x /usr/local/bin/lein``` - * Verify your installation -> ```lein version``` - - It should take a while to run, as it will download some resources it needs the first time. See the note at the end of this section if you are having issues. - -### On macOS: - * Download the lein script -> ```curl https://raw.githubusercontent.com/technomancy/leiningen/stable/bin/lein > lein``` - * Move the lein script to the user programs directory -> ```sudo mv lein /usr/local/bin/lein``` - * Add execute permissions to the lein script -> ```sudo chmod a+x /usr/local/bin/lein``` - * Verify your installation -> ```lein version``` - - It should take a while to run, as it will download some resources it needs the first time. See the note at the end of this section if you are having issues. - -### On Windows: - * Download the lein.bat script -> ```curl -O https://raw.githubusercontent.com/technomancy/leiningen/stable/bin/lein.bat``` - * Create a bin directory for scripts -> ```md bin``` - * Move the lein.bat script to that directory -> ```move lein.bat bin``` - * Add bin to your path -> ```setx path "%path%;%USERPROFILE%\bin"``` - * Complete your installation - Close the command prompt and open a new one. Then run the following command to finish the installation. -> ```lein self-install``` - - It should take a while to run, as it will download some resources it needs the first time. See the note at the end of this section if you are having issues. - -4. Clone the repo found [here](https://github.com/athensresearch/athens). Change directory to the athens folder and run: -> ```yarn install``` - - After installing all packages and dependencies, start leiningen. -> ```lein dev``` - - Open [localhost:3000](http://localhost:3000) in your browser and you should be good to go! - -### Trouble Setting Up Your Dev Environment? -If you are having trouble getting your dev environment set up, first go through the steps found [here](https://purelyfunctional.tv/guide/how-to-install-clojure/). If you are still having trouble, please let us know in the #athens channel in the Roam Slack. - -## Clojure - -Until stated otherwise, we will be following [this style guide](https://github.com/bbatsov/clojure-style-guide). ([This version](https://guide.clojure.style) is prettier to look at.) This guide is still a work in progress; from the author: "Feel free to [open tickets or send pull requests](https://github.com/bbatsov/clojure-style-guide/issues) with improvements." Exceptions to this style guide will be collated and organized as needed. Feel free to suggest exceptions that you feel strongly about. - -We will soon likely setup [clj-kondo](https://github.com/borkdude/clj-kondo) with some config as a project linter. The clj-kondo linter follows the style guidelines in the above style guide. - -## Git Commit Messages - -Follow suggestions in https://www.conventionalcommits.org/en/v1.0.0/. - -## Issues and PRs - -Follow suggestions in https://github.com/codeforamerica/howto/blob/master/Good-GitHub-Issues.md - - diff --git a/README.md b/README.md index a8874e3e00..e6f1178479 100644 --- a/README.md +++ b/README.md @@ -1,45 +1,27 @@ -# Athens +# UPDATE: Athens is no longer being actively maintained -Open-Source [Roam](http://roamresearch.com/). For more background, please read the [Athens FAQ](https://roamresearch.com/#/app/ego/page/OaSVyM_nr) on my public Roam. +Some links are provided below for posterity. -# Contributing +## Migration -Please refer to [CONTRIBUTING.md](https://github.com/tangjeff0/athens/blob/master/CONTRIBUTING.md) and [CODE_OF_CONDUCT.md](https://github.com/tangjeff0/athens/blob/master/CODE_OF_CONDUCT.md). +Migration: If you've been using Athens, you can try to export your data using https://github.com/bshepherdson/athens-export, which converts your pages to markdown and a logseq-compatible directory. This seems to have worked for my personal graph, but it is not an officially supported project, so your mileage may vary. -## Getting Started -You will need: - -- a `java` sdk: I'm using [openjdk 11.0.2](https://jdk.java.net/archive/) -- `lein`: Clojure's main [package manager](https://leiningen.org/) (lein installs the correct version of Clojure for you) -- `yarn` or `npm`: I'm using [yarn](https://www.npmjs.com/package/yarn) - -Follow these steps: - -1. Clone the repo -2. `yarn install` -3. `lein dev` -4. Go to [localhost:3000](http://localhost:3000) - -## Built With +--- -See [Athens vs Roam Tech Stack](https://roamresearch.com/#/app/ego/page/V81KJmS5L) for more background. +Athens helps teams capture and synthesize knowledge together. Built on a graph database, Athens helps map and communicate complex knowledge in complex domains. -![img](doc/athens-vs-roam-tech-stack.png) +**[You can demo Athens in your browser (no changes are saved)](https://athensresearch.github.io/athens)** +**[You can download the free and OSS desktop app](https://github.com/athensresearch/athens/releases)** -# Roadmap / Objectives +**[Documentation (not guaranteed to be up to date)](https://athensresearch.github.io/docs/developer_guide/running)** -- to provide a self-hosted option, easily deployable on your machine -- to provide a hosted option using Datomic and their open-source license - - if hosted, maintaining best practices (such as end-to-end encryption) and complying with standards like GDPR -- to provide a React Native mobile client -- to begin development of an open protocol for bi-directional links between Roam and other open-source alternatives -# Questions +# Thank You -Send a message in the #athens channel of the [Roam Slack](https://roamresearch.slack.com/join/shared_invite/enQtODg3NjIzODEwNDgwLTdhMjczMGYwN2YyNmMzMDcyZjViZDk0MTA2M2UxOGM5NTMxNDVhNDE1YWVkNTFjMGM4OTE3MTQ3MjEzNzE1MTA) or ping me on Twitter at [@tangjeff0](https://twitter.com/tangjeff0). +Thank you to the Sponsors and Contributors who supported us along the way. Thank you. ---- +[![Sponsors](https://athensresearch.ghost.io/content/images/size/w1140/2021/04/spnosors.png)](https://opencollective.com/athens) -![Athens](doc/athens-1920.jpg) +![Contributors](https://user-images.githubusercontent.com/8952138/111184984-c1d83180-856e-11eb-9b7f-136de40d8252.png) diff --git a/athens-data/README.md b/athens-data/README.md new file mode 100644 index 0000000000..225bb759f9 --- /dev/null +++ b/athens-data/README.md @@ -0,0 +1,3 @@ +# athens-data + +This is where `yarn server` and `docker compose up` will place data files while working in dev setups. diff --git a/athens.dockerfile b/athens.dockerfile new file mode 100644 index 0000000000..3be39c80a7 --- /dev/null +++ b/athens.dockerfile @@ -0,0 +1,20 @@ +# A base, just JVM +FROM openjdk:16 + +RUN mkdir -p /srv/athens/db + +# Copy from local working directory +COPY target/athens-lan-party-standalone.jar /srv/athens/ +COPY script/docker-run-lan-party.sh /srv/athens/ + +# Set athens as the working directory +WORKDIR /srv/athens/ + +# Expose ports +EXPOSE 3010 + +# serve jar file +CMD ["/srv/athens/docker-run-lan-party.sh"] + +# Logging: By default docker uses the json-file driver to store container +# logs and can be found in : /var/lib/docker/containers/[container-id]/[container-id]-json.log:w diff --git a/babel.config.js b/babel.config.js new file mode 100644 index 0000000000..4eb2f68f0a --- /dev/null +++ b/babel.config.js @@ -0,0 +1,74 @@ +const {existsSync, lstatSync} = require("fs"); +const {resolve, dirname} = require("path"); + +function isRelativeImport(path){ + return path.startsWith("."); +} + +function isDirectory(path) { + return existsSync(path) && lstatSync(path).isDirectory(); +} + +function resolveImport (from, to) { + return resolve(dirname(from), to); +} + +function replaceDirectoryImports() { + return { + visitor: { + ImportDeclaration: (path, state) => { + const importPath = path.node.source.value; + const fileName = state.file.opts.filename; + if (isRelativeImport(importPath) && isDirectory(resolveImport(fileName, importPath))) { + path.node.source.value += "/index"; + } + } + } + } +} + + +// This config will output files to ./src/gen/components via the `yarn components` script +// See https://shadow-cljs.github.io/docs/UsersGuide.html#_javascript_dialects +module.exports = { + presets: [ + "@babel/env", + // Compile tsx files. + "@babel/preset-typescript", + // Use the react runtime import if available. + ["@babel/preset-react", {"runtime": "automatic"}] + ], + plugins: [ + // Add /index to all relative directory imports, because Shadow-CLJS does not support + // them (https://github.com/thheller/shadow-cljs/issues/841#issuecomment-777323477) + // NB: Putting these files in node_modules would have fixed the directory imports + // but broken hot reload (https://github.com/thheller/shadow-cljs/issues/764#issuecomment-663064549) + replaceDirectoryImports, + // Allow using @/ for root relative imports in the component library. + ["module-resolver", {alias: {"@": "./src/js/components"}}], + // Transform material-ui imports into deep imports for faster reload. + // material-ui is very big, and importing it all can slow down development rebuilds by a lot. + // https://material-ui.com/guides/minimizing-bundle-size/#development-environment + ["transform-imports", { + "@material-ui/core": { + transform: "@material-ui/core/esm/${member}", + preventFullImport: true + }, + "@material-ui/icons": { + transform: "@material-ui/icons/esm/${member}", + preventFullImport: true + } + }], + ["@babel/proposal-class-properties"], + ["@babel/proposal-object-rest-spread"], + // Import helpers from @babel/runtime instead of duplicating them everywhere. + "@babel/plugin-transform-runtime", + // Better debug information for styled components. + // https://styled-components.com/docs/tooling#babel-plugin + "babel-plugin-styled-components" + ], + // Do not apply this babel config to node_modules. + // Shadow-CLJS also runs babel over node_modules and we don't want this + // configuration to apply to it. + exclude: ["node_modules"] +} diff --git a/build/entitlements.mac.plist b/build/entitlements.mac.plist new file mode 100644 index 0000000000..46f43d4a07 --- /dev/null +++ b/build/entitlements.mac.plist @@ -0,0 +1,12 @@ + + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.cs.allow-dyld-environment-variables + + + diff --git a/build/icon.png b/build/icon.png new file mode 100644 index 0000000000..536d8bb090 Binary files /dev/null and b/build/icon.png differ diff --git a/classes/README.md b/classes/README.md new file mode 100644 index 0000000000..d14948f69b --- /dev/null +++ b/classes/README.md @@ -0,0 +1,4 @@ +# Classes + +This folder is where classes for [AOT compilation](https://clojure.org/guides/deps_and_cli#aot_compilation) go. +AOT compilation is necessary to create executable uberjars. diff --git a/data/athens.datoms b/data/athens.datoms index 91cda50861..eb7fb9caa6 100644 --- a/data/athens.datoms +++ b/data/athens.datoms @@ -7396,7 +7396,7 @@ "\"tangj1122@gmail.com\"", "5258", ":user/email", - "\"georgespetrequin@gmail.com\"", + "\"asd123@gmail.com\"", "4162", ":edit/time", "1587922666894", @@ -69017,4 +69017,4 @@ "4834", ":block/uid", "\"jpD2Qfd9V\"" -] \ No newline at end of file +] diff --git a/deps.edn b/deps.edn new file mode 100644 index 0000000000..0502e21570 --- /dev/null +++ b/deps.edn @@ -0,0 +1,141 @@ +{:paths ["src/clj" "src/cljs" "src/cljc" "src/js" "src/gen" "test" "resources"] + + :deps + {org.clojure/clojure #:mvn{:version "1.11.1"} + org.clojure/clojurescript #:mvn{:version "1.11.60"} + org.clojure/tools.cli #:mvn{:version "1.0.206"} + thheller/shadow-cljs #:mvn{:version "2.19.5"} + reagent/reagent #:mvn{:version "1.0.0"} + re-frame/re-frame #:mvn{:version "1.2.0"} + day8.re-frame/async-flow-fx #:mvn{:version "0.3.0"} + day8.re-frame/test #:mvn{:version "0.1.5"} + datascript/datascript #:mvn{:version "1.3.10"} + datascript-transit/datascript-transit #:mvn{:version "0.3.0"} + denistakeda/posh #:mvn{:version "0.5.8"} + cljs-http/cljs-http #:mvn{:version "0.1.46"} + metosin/reitit #:mvn{:version "0.5.13"} + metosin/komponentit #:mvn{:version "0.3.10"} + instaparse/instaparse #:mvn{:version "1.4.10"} + tick/tick #:mvn{:version "0.5.0-RC5"} + cljc.java-time/cljc.java-time #:mvn{:version "0.1.9"} + com.rpl/specter #:mvn{:version "1.1.3"} + com.taoensso/sente #:mvn{:version "1.16.2"} + binaryage/devtools #:mvn{:version "1.0.6"} + day8.re-frame/re-frame-10x #:mvn{:version "1.5.0"} + day8.re-frame/tracing #:mvn{:version "0.6.2"} + org.flatland/ordered #:mvn{:version "1.15.10"} + io.github.nextjournal/clerk #:git{:sha "d111501576537c402dc8bf65eee30e2cd90d2666"} + io.homebase/datalog-console #:mvn{:version "0.3.2"} + org.clojure/data.json #:mvn{:version "2.4.0"} + com.github.jpmonettas/flow-storm-inst #:mvn{:version "3.0.231"} + com.github.jpmonettas/flow-storm-dbg #:mvn{:version "3.0.231"} + ;; backend + ;; logging hell + org.clojure/tools.logging #:mvn{:version "1.1.0"} + ch.qos.logback/logback-classic #:mvn{:version "1.2.3"} + ;; IoC + com.stuartsierra/component #:mvn{:version "1.0.0"} + ;; configuration mgmt + yogthos/config #:mvn{:version "1.1.7"} + ;; Fluree + com.fluree/db #:mvn{:version "1.0.0-rc33"} + ;; web server + http-kit/http-kit #:mvn{:version "2.5.3"} + compojure/compojure #:mvn{:version "1.6.2"} + ring-basic-authentication/ring-basic-authentication #:mvn{:version "1.1.1"} + ring/ring-core #:mvn{:version "1.9.5"} + metosin/muuntaja #:mvn{:version "0.6.8"} + ;; data validation + metosin/malli #:mvn{:version "0.5.1"} + ;; networked repl + com.stuartsierra/component.repl #:mvn{:version "0.2.0"} + nrepl/nrepl #:mvn{:version "0.8.3"} + cider/cider-nrepl #:mvn{:version "0.27.2"}} + + :aliases + {:shadow-cljs + ;; Increase max JVM stack size. + ;; Without this, the node_modules/highlight.js/lib/languages/isbl.js file will randomly break the e2e CI job + ;; because the Google Closure Compiler can't parse it without busting the stack size. + {:jvm-opts ["-Xss4m"]} + :carve + {:extra-deps {borkdude/carve + #:git{:url "https://github.com/borkdude/carve" + :sha "7d87e7fdf471121b4f3cc4b442e6ca39503ca07e"}} + :main-opts ["-m" "carve.main"]} + + :cljstyle + {:deps {mvxcvi/cljstyle + #:git{:url "https://github.com/greglook/cljstyle.git" + :sha "14c18e5b593c39bc59f10df1b894c31a0020dc49"}} + :main-opts ["-m" "cljstyle.main"]} + + :clj-kondo + {:extra-deps {borkdude/clj-kondo + #:git{:url "https://github.com/borkdude/clj-kondo" + :sha "8937af7f4372c0d2264735ebc1439d0b61030872" + :tag "v2022.03.09"}} + :main-opts ["-m" "clj-kondo.main"]} + + :outdated + {:extra-deps {com.github.liquidz/antq + #:mvn{:version "1.1.0"}} + ;; Very noisy, due to https://github.com/liquidz/antq/issues/108 + :main-opts ["-m" "antq.core"]} + + ;; How to use flowstorm: + ;; - yarn dev + ;; - open http://localhost:3000 for the web client + ;; - yarn client:debug + ;; flowstorm UI will show up + ;; - add #trace before a defn form you want to debug + ;; it will show in the flowstorm UI under the "flows" tab + ;; press the forward and back buttons on it to trace execution values + ;; more in https://jpmonettas.github.io/flow-storm-debugger/user_guide.html#_flows_tool + ;; - add #tap before a form you want to record the value + ;; you'll see the return under the "taps" tab + ;; more in https://jpmonettas.github.io/flow-storm-debugger/user_guide.html#_taps_tool + :flowstorm + {:exec-fn flow-storm.debugger.main/start-debugger + :exec-args {:port 8777 + :repl-type :shadow + :build-id :app}} + + :uberdeps + {:replace-deps {uberdeps/uberdeps {:mvn/version "1.1.1"}} + :replace-paths [] + :main-opts ["-m" "uberdeps.uberjar"]} + + :compiled-classes + {;; AOT compilation output, to be used with uberdeps. + ;; https://github.com/tonsky/uberdeps#creating-an-executable-jar + ;; https://clojure.org/guides/deps_and_cli#aot_compilation + :extra-paths ["classes"]} + + :test + {:extra-paths ["dev/clj"] + :extra-deps {io.github.cognitect-labs/test-runner + {:git/url "https://github.com/cognitect-labs/test-runner.git" + :sha "48c3c67f98362ba1e20526db4eeb6996209c050a" + :git/tag "v0.5.0"}} + :main-opts ["-m" "cognitect.test-runner"] + :exec-fn cognitect.test-runner.api/test} + + :repl + {:extra-paths ["dev/clj"]} + + :athens + {:extra-paths ["dev/clj"] + :main-opts ["-m" "athens.self-hosted.core"]} + + :athens-cli + {:extra-paths ["dev/clj"] + :main-opts ["-m" "athens.self-hosted.save-load"]} + + :notebooks + {:extra-paths ["dev/clj" "dev/notebooks"] + :main-opts ["-m" "notebooks"]} + + :notebooks-static + {:extra-paths ["dev/clj" "dev/notebooks"] + :exec-fn notebooks/build-static-app!}}} diff --git a/dev/Brewfile b/dev/Brewfile new file mode 100644 index 0000000000..1805861969 --- /dev/null +++ b/dev/Brewfile @@ -0,0 +1,3 @@ +brew 'openjdk@11' +brew 'clojure' +brew 'yarn' diff --git a/dev/clj/config.edn b/dev/clj/config.edn new file mode 100644 index 0000000000..9d742cd157 --- /dev/null +++ b/dev/clj/config.edn @@ -0,0 +1,6 @@ +{;; :password "SuchWow" + ;; :in-memory? true + :fluree {:servers ["http://localhost:8090"]} + :datascript {:persist-base-path "./athens-data/datascript/persist/"} + :nrepl {:port 8877} + :feature-flags {:api true}} diff --git a/dev/clj/dev.clj b/dev/clj/dev.clj new file mode 100644 index 0000000000..c22e51cf94 --- /dev/null +++ b/dev/clj/dev.clj @@ -0,0 +1,30 @@ +#_{:clj-kondo/ignore [:unused-referred-var :unused-namespace]} + + +(ns dev + (:require + [athens.self-hosted.core :as app] + [com.stuartsierra.component.repl :as repl :refer [reset start stop system]] + [datascript.core :as d])) + + +(defn- local-new-system + [_] + (app/new-system)) + + +(repl/set-init local-new-system) + + +(defn datascript-conn + "Gets you Datascript connection from system." + [] + (get-in system [:datascript :conn])) + + +(comment + (d/q '[:find ?eid + :keys db/id + :where [?eid :block/order ?block-order] + (not [?eid :block/uid])] + @(datascript-conn))) diff --git a/dev/clj/logback-test.xml b/dev/clj/logback-test.xml new file mode 100644 index 0000000000..7379697e2c --- /dev/null +++ b/dev/clj/logback-test.xml @@ -0,0 +1,18 @@ + + + + + + %d{HH:mm:ss.SSS} %-5level [%thread{20}] %logger{36} - %msg%n + + + + + + + + + + + diff --git a/dev/clj/user.clj b/dev/clj/user.clj new file mode 100644 index 0000000000..85fcd30b46 --- /dev/null +++ b/dev/clj/user.clj @@ -0,0 +1,6 @@ +#_{:clj-kondo/ignore [:unused-referred-var :unused-namespace]} + + +(ns user + (:require + [com.stuartsierra.component.user-helpers :refer [dev go reset]])) diff --git a/dev/cljs/user.cljs b/dev/cljs/user.cljs index 3f4368dc3c..5ecc19c3f0 100644 --- a/dev/cljs/user.cljs +++ b/dev/cljs/user.cljs @@ -1,8 +1,13 @@ +#_{:clj-kondo/ignore [:unused-namespace + :unused-referred-var]} + + (ns cljs.user "Commonly used symbols for easy access in the ClojureScript REPL during development." (:require [cljs.repl :refer (Error->map apropos dir doc error->str ex-str ex-triage - find-doc print-doc pst source)] + find-doc print-doc pst source)] [clojure.pprint :refer (pprint)] [clojure.string :as str])) + diff --git a/dev/notebooks/intro_notebook.clj b/dev/notebooks/intro_notebook.clj new file mode 100644 index 0000000000..a6cc4506ee --- /dev/null +++ b/dev/notebooks/intro_notebook.clj @@ -0,0 +1,292 @@ +;; # Hello, Clerk 👋 +^{:nextjournal.clerk/visibility #{:hide-ns}} +(ns intro-notebook + (:require [clojure.java.io :as io] + [nextjournal.clerk :as clerk] + [nextjournal.clerk.viewer :as v] + #_[meta-csv.core :as csv]) + #_(:import (clojure.java.io file) + #_(java.net.http HttpRequest HttpClient HttpResponse$BodyHandlers) + #_(java.net URI))) + + +;; Clerk enables a *rich*, local-first notebook experience using +;; standard Clojure namespaces and Markdown files with Clojure code +;; fences. You bring your own editor and workflow, your own +;; interactive computing habits, and Clerk enhances all of that with +;; literate programming and rich visualizations. + +;; Inside `clj` files, comment blocks are interpreted as prose written +;; in an extended dialect of Markdown. Clerk supports inline TeX, so +;; we can insert the [Euler–Lagrange equation](https://en.wikipedia.org/wiki/Euler–Lagrange_equation) +;; quite easily: + +;; $${\frac{d}{d t} \frac{∂ L}{∂ \dot{q}}}-\frac{∂ L}{∂ q}=0.$$ + +;; When Clerk interprets an `md` file, the relationship between code +;; blocks and prose is reversed. Instead of the file being code by +;; default with prose in comment blocks, it will be treated as +;; Markdown by default with Clojure code in code fences. Clerk's code +;; fences have a twist, though: they evaluate their contents. + +;; There are loads of other goodies to share, most of which we'll see +;; a bit farther down the page. + +;; ## Operation + +;; You can load, evaluate, and present a file with the `clerk/show!` +;; function, but in most cases it's easier to start a file watcher +;; with something like: + +^{:nextjournal.clerk/visibility #{:hide}} +(clerk/code '(clerk/serve! {:watch-paths ["dev/notebooks"]})) + + +;; ... which will automatically reload and re-eval any `clj` or `md` +;; files that change, displaying the most recently changed one in your +;; browser. + +;; To make this performant enough to feel good, Clerk caches the +;; computations it performs while evaluating each file. Likewise, to +;; make sure it doesn't send too much data to the browser at once, +;; Clerk paginates data structures within an interactive viewer. + +;; ## Pagination + +;; As an example, the infinite sequence returned by `(range)` will be +;; loaded a little bit at a time as you click on the results. (Note +;; the little underscore under the first paren, it lets you switch +;; this sequence to a vertical rather than horizontal view). + +(range) + + +;; Opaque objects are printed as they would be in the Clojure REPL, +;; like so: +(def notebooks + (io/file "dev/notebooks/notebooks.clj")) + + +;; You can leave a form at the top-level like this to examine the +;; result of evaluating it, though you'd probably use your live +;; programming environment to do this most of the time. +(into #{} (map str) (file-seq notebooks)) + + +;; Sometimes you don't want Clerk to cache a form. You can turn off +;; caching for a form by placing a special piece of metadata before +;; it, like this: +^:nextjournal.clerk/no-cache (shuffle (range 100)) + +#_"TODO show hiding forms and results" + + +;; Another useful technique is to put an instant marking the last time +;; a form was run. This way you can update this result at any time by +;; updating the instant. +#_(let [last-run #inst "2021-12-01T16:40:56.048896Z"] ; TODO broken? + (shuffle (range 100))) + + +;; Like other objects, `UUID`s and `inst`s are rendered as they would +;; be in the REPL. +(take 10 + (repeatedly (fn [] + {:name (str + (rand-nth ["Oscar" "Karen" "Vlad" "Rebecca" "Conrad"]) " " + (rand-nth ["Miller" "Stasčnyk" "Ronin" "Meyer" "Black"])) + :role (rand-nth [:admin :operator :manager :programmer :designer]) + :id (java.util.UUID/randomUUID) + :created-at #inst "2021"}))) + + +;; Clerk also supports unicode, of course. +{:hello "👋 world" :tacos (map (comp #(map (constantly '🌮) %) range) (range 1 100))} + + +;; ## 👁 Clerk Viewer API + +;; In addition to these basic viewers for Clojure data structures, +;; Clerk comes with a set of built-in viewers for many kinds of +;; things, and a moldable viewer API that can be extended while you +;; work. + +;; ### 🧩 Built-in Viewers + +;; #### 🔢 Data Tables + +;; Clerk provides a built-in data table viewer that supports the three +;; most common tabular data shapes out of the box: a sequence of maps, +;; where each map's keys are column names; a seq of seq, which is just +;; a grid of values with an optional header; a map of seqs, in with +;; keys are column names and rows are the values for that column. + +;; NB: removed to avoid meta-csv dependency. +#_(clerk/table + (csv/read-csv "https://gist.githubusercontent.com/netj/8836201/raw/6f9306ad21398ea43cba4f7d537619d0e07d5ae3/iris.csv")) + + +;; #### 📊 Plotly + +;; Clerk also has built-in support for Plotly's low-ceremony plotting: +(clerk/plotly {:data [{:z [[1 2 3] [3 2 1]] :type "surface"}]}) + + +;; #### 📈 Vega Lite + +;; But Clerk also has Vega Lite for those who prefer that grammar. +(clerk/vl {:width 650 + :height 400 + :data {:url "https://vega.github.io/vega-datasets/data/us-10m.json" + :format {:type "topojson" :feature "counties"}} + :transform [{:lookup "id" + :from {:data {:url "https://vega.github.io/vega-datasets/data/unemployment.tsv"} + :key "id" + :fields ["rate"]}}] + :projection {:type "albersUsa"} + :mark "geoshape" + :encoding {:color {:field "rate" + :type "quantitative"}}}) + + +;; #### 📑 Markdown + +;; The same Markdown support Clerk uses for comment blocks is also +;; available programmatically: +(clerk/md (clojure.string/join "\n" (map #(str %1 ". " %2) (range 1 4) ["Lambda" "Eval" "Apply"]))) + +#_ "TODO numbered list style is missing numbers?" + + +;; #### 🤖 Code + +;; There's a code viewer uses that +;; [clojure-mode](https://nextjournal.github.io/clojure-mode/) for +;; syntax highlighting. +(clerk/code (macroexpand '(when test + expression-1 + expression-2))) + + +;; #### 🧮 TeX + +;; As we've already seen, all comment blocks can contain TeX (we use +;; [KaTeX](https://katex.org/) under the covers). In addition, you can +;; call the TeX viewer programmatically. Here, for example, are +;; Maxwell's equations in differential form: +(clerk/tex " +\\begin{alignedat}{2} + \\nabla\\cdot\\vec{E} = \\frac{\\rho}{\\varepsilon_0} & \\qquad \\text{Gauss' Law} \\\\ + \\nabla\\cdot\\vec{B} = 0 & \\qquad \\text{Gauss' Law ($\\vec{B}$ Fields)} \\\\ + \\nabla\\times\\vec{E} = -\\frac{\\partial \\vec{B}}{\\partial t} & \\qquad \\text{Faraday's Law} \\\\ + \\nabla\\times\\vec{B} = \\mu_0\\vec{J}+\\mu_0\\varepsilon_0\\frac{\\partial\\vec{E}}{\\partial t} & \\qquad \\text{Ampere's Law} +\\end{alignedat} +") + + +;; #### 🕸 Hiccup + +;; The `html` viewer interprets `hiccup` when passed a vector. (This +;; can be quite useful for building arbitrary layouts in your +;; notebooks.) +(clerk/html [:table + [:tr [:td "◤"] [:td "◥"]] + [:tr [:td "◉"] [:td "◉"]] + [:tr [:td "◣"] [:td "◢"]]]) + + +;; Alternatively you can also just pass an HTML string, perhaps +;; generated by your code: +(clerk/html "“A brilliant solution to the wrong problem can be worse than no solution at all. Solve the correct problem.”
Donald Norman") + + +;; ### 🚀 Extensibility + +;; In addition to these defaults, you can also attach a custom viewer +;; to any form. Here we make our own little viewer to greet James +;; Clerk Maxwell: +(clerk/with-viewer #(v/html [:div "Greetings to " [:strong %] "!"]) + "James Clerk Maxwell") + + +;; But we can do more interesting things, like using a predicate +;; function to match numbers and turn them into headings, or +;; converting string into paragraphs. +(clerk/with-viewers [{:pred number? + :render-fn #(v/html [(keyword (str "h" %)) (str "Heading " %)])} + {:pred string? + :render-fn #(v/html [:p %])}] + [1 "To begin at the beginning:" + 2 "It is Spring, moonless night in the small town, starless and bible-black," + 3 "the cobblestreets silent and the hunched," + 4 "courters'-and- rabbits' wood limping invisible" + 5 "down to the sloeblack, slow, black, crowblack, fishingboat-bobbing sea."]) + + +;; Or you could use black and white squares to render numbers: +^::clerk/no-cache +(clerk/with-viewers [{:pred number? + :render-fn #(v/html [:div.inline-block {:style {:width 16 :height 16} + :class (if (pos? %) "bg-black" "bg-white border-solid border-2 border-black")}])}] + (take 10 (repeatedly #(rand-int 2)))) + + +;; Or build your own colour parser and then use it to generate swatches: +(clerk/with-viewers + [{:pred #(and (string? %) + (re-matches + (re-pattern + (str "(?i)" + "(#(?:[0-9a-f]{2}){2,4}|(#[0-9a-f]{3})|" + "(rgb|hsl)a?\\((-?\\d+%?[,\\s]+){2,3}\\s*[\\d\\.]+%?\\))")) %)) + :render-fn #(v/html [:div.inline-block.rounded-sm.shadow + {:style {:width 16 + :height 16 + :border "1px solid rgba(0,0,0,.2)" + :background-color %}}])}] + ["#571845" + "rgb(144,12,62)" + "rgba(199,0,57,1.0)" + "hsl(11,100%,60%)" + "hsla(46, 97%, 48%, 1.000)"]) + + +;; Keep in mind when writing your own `:render-fn` that it will run +;; entirely in the browser, and so will not have access to your local +;; bindings on the JVM side. If you need to your viewer to pre-process +;; what it sends to the browser, you can specify a `:transform-fn` +;; that will be called before the data is sent over the wire. + +#_ "TODO example of using a :transform-fn" + + +;; #### 🏞 Customizing Data Fetching + +;; Sometimes you might want to create a custom viewer that overrides +;; Clerk's automatic paging behavior. In this example, we use a custom +;; `fetch-fn` that specifies a `content-type` to tell Clerk to serve +;; arbitrary byte arrays as PNG images. + +;; Notice that the image is conveyed out-of-band using the `url-for` +;; function to get a URL from which to fetch the blob. + +;; TODO: this seems to hang the process, removed. +#_#_ +(clerk/set-viewers! [{:pred bytes? + :fetch-fn (fn [_ bytes] {:nextjournal/content-type "image/png" + :nextjournal/value bytes}) + :render-fn (fn [blob] (v/html [:img {:src (v/url-for blob)}]))}]) + +(.. (HttpClient/newHttpClient) + (send (.build (HttpRequest/newBuilder (URI. "https://upload.wikimedia.org/wikipedia/commons/5/57/James_Clerk_Maxwell.png"))) + (HttpResponse$BodyHandlers/ofByteArray)) body) + + +#_ "TODO need to bump Clerk version for this to work?" + + +;; This is just a taste of what's possible using Clerk. Take a look in +;; the `notebooks` directory to see a collection of worked examples in +;; different domains. + +;; And don't forget to let us know how it goes! diff --git a/dev/notebooks/notebooks.clj b/dev/notebooks/notebooks.clj new file mode 100644 index 0000000000..db74cb0737 --- /dev/null +++ b/dev/notebooks/notebooks.clj @@ -0,0 +1,34 @@ +(ns notebooks + (:require + [clojure.java.io :as io] + [nextjournal.clerk :as clerk])) + + +(defn -main + [& _args] + ;; Start by showing the intro notebook. + (clerk/show! "dev/notebooks/parser_notebook.clj") + ;; The watch all files in dev/notebooks, and display the last one that changed. + ;; Opens the browser automatically. + ;; See https://github.com/nextjournal/clerk and + ;; https://github.com/nextjournal/clerk-demo for examples and docs. + (clerk/serve! {:watch-paths ["dev/notebooks"] + :show-filter-fn (fn [name] + (and (clojure.string/starts-with? name "dev/notebooks/") + ;; Ignore this file though. + (not (= name "dev/notebooks/notebooks.clj")))) + :browse? true})) + + +(defn build-static-app! + [& _args] + (clerk/build-static-app! + {:out-path "vercel-static/clerk" + :browse? false + :paths (->> "dev/notebooks" + io/file + file-seq + (filter #(.isFile %)) + (map io/as-relative-path) + (remove #{"dev/notebooks/notebooks.clj" + "dev/notebooks/.DS_Store"}))})) diff --git a/dev/notebooks/parser_notebook.clj b/dev/notebooks/parser_notebook.clj new file mode 100644 index 0000000000..d7573970b6 --- /dev/null +++ b/dev/notebooks/parser_notebook.clj @@ -0,0 +1,57 @@ +;; # Structure parsing shenanigans + +;; ## Require parser +;; `(:require [athens.parser.structure :as structure])` +^{:nextjournal.clerk/visibility #{:hide-ns}} +(ns parser-notebook + (:require + [athens.parser.structure :as structure] + [nextjournal.clerk :as clerk])) + + +;; ## Page links + +;; This a basic page link. +^{:nextjournal.clerk/visibility #{:hide}} +(clerk/code + (structure/structure-parser->ast "[[page link]]")) + + +;; Page links can also nest +(clerk/code + (structure/structure-parser->ast "[[[[notebook]] parser]]")) + + +;; ## Hashtags + +;; They are special kind of page link, and we have 2 types of hashtags + +;; ### Naked hashtags +;; That is just `#` before word +(clerk/code + (structure/structure-parser->ast "#abc")) + + +;; No spaces in between needed +(clerk/code + (structure/structure-parser->ast "#abc#123")) + + +;; ### Braced hashtags +(clerk/code + (structure/structure-parser->ast "#[[abc]]")) + + +;; We can also nest, oh my +(clerk/code + (structure/structure-parser->ast "#[[[[notebook]] parser]]")) + + +;; All sorts of wired nesting should be fine +(clerk/code + (structure/structure-parser->ast "#[[#[[notebook]] #parser]]")) + + +;; ## Block refs +(clerk/code + (structure/structure-parser->ast "((abc))")) diff --git a/doc/adr/0001-record-architecture-decisions.md b/doc/adr/0001-record-architecture-decisions.md new file mode 100644 index 0000000000..275d65a114 --- /dev/null +++ b/doc/adr/0001-record-architecture-decisions.md @@ -0,0 +1,19 @@ +# 1. Record architecture decisions + +Date: 2021-04-04 + +## Status + +Accepted + +## Context + +We need to record the architectural decisions made on this project. + +## Decision + +We will use Architecture Decision Records, as [described by Michael Nygard](http://thinkrelevance.com/blog/2011/11/15/documenting-architecture-decisions). + +## Consequences + +See Michael Nygard's article, linked above. For a lightweight ADR toolset, see Nat Pryce's [adr-tools](https://github.com/npryce/adr-tools). diff --git a/doc/adr/0002-staged-parser.md b/doc/adr/0002-staged-parser.md new file mode 100644 index 0000000000..8ca0e638db --- /dev/null +++ b/doc/adr/0002-staged-parser.md @@ -0,0 +1,105 @@ +# 2. Staged parser + +Date: 2021-04-04 + + +## Status + +Proposed + + +## Context + +Athens current parser is exploding whenever we have to changed things that are allowed. + +Examples: + +- support for "`" in fenced code blocks +- how we handle raw-url, basically every character in parsed string has to be checked if it might start raw-url. + +Current parser is monolithic, which makes it hard to maintain and add new features. + +Users are asking for more markdown support. + + +## Decision + +Let's parse things in 3 stages: +1. Block element +2. Inline elements +3. Searching for raw-urls where they are permitted. + +This means at least 2 separate parser: block and inline. + +Spec used for MD: https://github.github.com/gfm/ + +Each Phase parser is smaller, so it can run faster and is easier to maintain and test. + + +### Block elements + +List of block elements specified by MD spec: +* Leaf blocks + * Thematic breaks (like `---`) + * Headers (only AXT headings for now, that is headers starting with run of `#`) + * Indented code (contents doesn't get parsed with inline parser) + * Fenced code (contents doesn't get parsed with inline parser) + * HTML blocks (let's stay away from that if we can) + * Link reference definitions (do we want it?) + * Paragraphs + * Blank lines + * Tables (we probably want this extension) +* Container blocks + * Block quote + * List items (we probably want those, not sure if we want nested lists) + * Task list items (extension, since we have `{{[[TODO]]}}` it's not needed) + * Lists + +Only some contents of block elements should be parsed for *Inline elements*. +We communicate that with `paragraph-text` AST element, which will be processed in Phase 2. + +### Inline elements + +List of inline elements specified by MD spec: + +* Entity and numeric character references +* Code spans +* Emphasis and strong emphasis +* Strikethrough (extension) +* Links (standard `[]()` format) +* Images (standard `![]()` format) +* Autolinks (between `<>`) +* Autolinks extension (basically our current `raw-url`) +* Raw HTML (let's avoid this can of warms) +* Hard line breaks (` ` or `\` at the end of a line) +* Textual content (anything that didn't match above rules) + +To this rules we have to add our own syntax: +* Block references +* Components +* Page links +* Hashtags +* LaTeX + + +### raw-urls + +This is *Autolinks extension* from *Inline elements*. + +Having it separate will allow for performance boost. +For example we don't need to run whole parser for it. +What we can do instead is to use regex provided by RFC3986. + +And we should only process results of Stage 2 that are eligible for containing urls. + + +## Consequences + +Maintaining parser becomes easier, because we have dedicated parser to block & inline. + +Extending parser becomes easier, because these will be smaller parser to analyze. + +Parsing can be faster. +Because block level parser is not concerned with inline and raw urls. +Of course we still need to be careful and test performance impact of changes +as it is with parser, they do explode (complexity wise) if we're not careful. diff --git a/doc/adr/0003-lan-party-common-events.md b/doc/adr/0003-lan-party-common-events.md new file mode 100644 index 0000000000..0486b70f2e --- /dev/null +++ b/doc/adr/0003-lan-party-common-events.md @@ -0,0 +1,40 @@ +# 3. Lan-Party Common Events + +Date: 2021-06-28 + +## Status + +Proposed. + +Extended by [4. Lan-Party Linkmaker](0004-lan-party-linkmaker.md) + +Used by [7. Lan-Party Datascript Events](0007-lan-party-datascript-events.md) + +## Context + +With Self-Hosted (aka Lan-Party) we'd like to have the same cannonical way of executing DB updates, +both in Single-Player and Lan-Party. + +## Decision + +Every user event that results in updates to Datascript DB in Single-Player mode should be ported to Common Events. + +Porting to Common Events means: +1. Creating event + - Builder fn in `common-events` + - Schema for event in `common-events/schema` +2. Port logic to `resolver` +3. Test it. + +`resolver` should take care only of structural edits of Knowledge Graph. + +Maintaining linked nature of Knowledge Graph is addressed in [4. Lan-Party Linkmaker](0004-lan-party-linkmaker.md). + +## Consequences + +Negative consequences: +* More code to execute the same Datascript update logic. + +Positive consequences: +* With relative ease we'll be executing same updates in Single-Player and Lan-Party. +* We'll have events logic tested, not like we couldn't do it now, but still. diff --git a/doc/adr/0004-lan-party-linkmaker.md b/doc/adr/0004-lan-party-linkmaker.md new file mode 100644 index 0000000000..e0a486338b --- /dev/null +++ b/doc/adr/0004-lan-party-linkmaker.md @@ -0,0 +1,89 @@ +# 4. Lan-Party Linkmaker + +Date: 2021-06-28 + +## Status + +Proposed. + +Extends [3. Lan-Party Common Events](0003-lan-party-common-events.md) + +## Context + +While [3. Lan-Party Common Events](0003-lan-party-common-events.md) addresses structural editing of Knowledge Graph, +we also need a way to maintain linked nature of graph. + +We'd like this mechanism to be shared between Single-Player and Lan-Party modes the same way as `common-events`. + +Both blocks and pages can be referenced, and are commonly called "refs". +Page titles and block strings can contain refs, represented as a string. + +The string format for a page ref is it's title enclosed in double brackets. +The string format for a block ref is it's block uid enclosed in double parenthesis. + +Worth keeping in mind that a page title can contain other refs and thus adding a page ref can result in multiple refs. +For instance, the `Foo [[Bar]] ((baz))` page title, when referred to in a string as `[[Foo [[Bar]] ((baz))]]`, +will result in 3 entries in `:block/refs`: +- one ref to the page with title `Foo [[Bar]] ((baz))` +- one ref to the page with title `Bar` +- one ref to the block with uid `baz` + +Common events will use existing refs to effect structural changes, including the update of `:block/string` and `:node/title`. +Linkmaker has as sole responsibility to update `:block/refs` in response to `:block/string` and `:node/title` changes. +Linkmaker never updates either `:block/string` or `:block/title`. + +## Decision + +**Linkmaker** has following identified requirements: + + - *p1*: page created + - -> no ref to the page should exist due to *p2* together with *b2* + - but if it happens, it's handled the same as the *b1* corner case (ignored) + - -> the page title itself can include references, triggering *m1* + - *p2*: page deleted + - -> the event will remove string refs from strings by stripping them of the enclosing double brackets + - -> triggers *m2*, once for each string changed + - *p3*: page rename + - -> the event will replace string refs in strings + - -> triggers *m1* and *m2* + - -> new name can include refs itself, adding those to the refs of the affected strings + - *p4*: page merge + - -> the event will replace string refs in strings for the merged page + - -> functionally the same as *p3* + - *b1*: block creation + - -> it is possible to have unresolved refs to this block due to *m3* + - e.g. this case: + - user inputs `((foo))` in a block string + - block uid `foo` does not exist in db + - later block with uid `foo` is added to db + - checking this on every block creation requires either searching all strings in the db (slow), or + saving indexed information about unresolved refs on the db (extra complexity) + - this case is ignored for the sake of simplicity and how infrequent it should be, but can be revisited later + - -> otherwise functionally the same as *b2* + - *b2*: block string edit + - -> block edit event will create missing pages that are referenced + - -> triggers *m1* and *m2* + - *b3*: block delete + - -> the event will delete the block and replaces string refs on strings that ref it with the deleted block string + - -> triggers *m2* an potentially *m1* due to replacement with text + - *m1*: string for uid has new refs + - -> new refs are added as `[[:block/uid uid] :block/refs _REF_]` + - *m2*: string for uid has lost refs + - -> lost refs are removed from `[[:block/uid uid] :block/refs _REF_]` + - *m3*: string contains refs that cannot be resolved + - -> these are ignored and not added to refs + - this cover cases such as intentional usage of double parens without block uid + - this can enable instances of the *p1* corner case (page created that is already ref'd) + - *m4*: db contains broken/missing refs + - -> linkmaker recomputes all refs for the db + +In short, all cases where a string changes (either `:block/string` or `:node/title`) trigger *m1*, *m2*, *m3*, causing a ref delta to be computed. + +**Linkmaker** should preserve behavior of `walk-transact`. +We want to have it tested, so we can rely on it. + +## Consequences + +**Linkmaker** should be included before transacting. +It should work on Datascript transaction report, so we can give it structural graph edits and it should create +necessary assertions and retractions to maintained linked nature of our Knowledge Graph. diff --git a/doc/adr/0005-lan-party-remoting-protocol.md b/doc/adr/0005-lan-party-remoting-protocol.md new file mode 100644 index 0000000000..7ff72fb222 --- /dev/null +++ b/doc/adr/0005-lan-party-remoting-protocol.md @@ -0,0 +1,42 @@ +# 5. Lan-Party Remoting Protocol + +Date: 2021-06-28 + +## Status + +Proposed. + +Extended by [6. Lan-Party Presence Events](0006-lan-party-presence-events.md) + +Extended by [7. Lan-Party Datascript Events](0007-lan-party-datascript-events.md) + +## Context + +With introduction of Lan-Party we're facing inherent complexities of networking, +something that wasn't an issue for us in Single-Player mode. + +We want to have consistent, reusable, extensible communication protocol. + +## Decision + +*Remoting Protocol* needs to address following concerns: + +- Events can be initiated from any side (client or server) +- Every event expects confirmation: + - Acknowledged: Event got accepted + - Rejected: When client is stale + - Failed: When invalid/unsupported event was received +- Client should not issue new events, while awaiting confirmation of event + +## Consequences + +There is significant overhead in maintaining protocol state, +this should be abstracted away from clients as much as possible, +so when writing client code we don't need to be concerned with intricacies of underlying protocol. + +We'll also need to provide facility for `re-frame` followup events. +In Single-Player we simply could `dispatch` transaction event and followup "UI" events. + +In new Lan-Party context, followup events should be only executed when event was *Acknowledged*. + +We should check schema of events before sending and when reading, on both client & server. diff --git a/doc/adr/0006-lan-party-presence-events.md b/doc/adr/0006-lan-party-presence-events.md new file mode 100644 index 0000000000..742aefe204 --- /dev/null +++ b/doc/adr/0006-lan-party-presence-events.md @@ -0,0 +1,27 @@ +# 6. Lan-Party Presence Events + +Date: 2021-06-28 + +## Status + +Proposed + +Extends [5. Lan-Party Remoting Protocol](0005-lan-party-remoting-protocol.md) + +## Context + +In Lan-Party we'll need to communicate other Athenians presence in current Knowledge Graph. + +## Decision + +We want to utilize existing [5. Lan-Party Remoting Protocol](0005-lan-party-remoting-protocol.md). + +We'll need to communicate at least: +- Someone going *Online* & *Offline* +- Viewing page uid +- Editing block uid + +## Consequences + +Some UI behaviors should be influenced by presence state, like when someone is already editing block, nobody else should be able to edit from beneath them. + diff --git a/doc/adr/0007-lan-party-datascript-events.md b/doc/adr/0007-lan-party-datascript-events.md new file mode 100644 index 0000000000..388d37ebe3 --- /dev/null +++ b/doc/adr/0007-lan-party-datascript-events.md @@ -0,0 +1,26 @@ +# 7. Lan-Party Datascript Events + +Date: 2021-06-28 + +## Status + +Proposed. + +Extends [5. Lan-Party Remoting Protocol](0005-lan-party-remoting-protocol.md) + +Uses [3. Lan-Party Common Events](0003-lan-party-common-events.md) + +Superceded by [9. Atomic/Composite Grapth Operations](0009-atomic-composite-grapth-operations.md) + +## Context + +In Lan-Party context we'll need a way to execute [3. Lan-Party Common Events](0003-lan-party-common-events.md) on Lan-Party server. + +## Decision + +Remote events modifying Graph structure are prefixed with `:datascript/*`. +They should be integration tested (at least against Datahike, preferably also against Datascript). + +## Consequences + +We'll have canonical way of executing Graph updates and be able to execute them over the wire in Lan-Party mode. diff --git a/doc/adr/0008-local-storage.md b/doc/adr/0008-local-storage.md new file mode 100644 index 0000000000..ea5e719ace --- /dev/null +++ b/doc/adr/0008-local-storage.md @@ -0,0 +1,73 @@ +# 8. Local Storage + +Date: 2021-07-01 + +## Status + +Pending + +## Context + +Problems with local-storage state: +- We previously used a re-frame event to get local-storage values, `:local-storage/get-db-filepath` during boot. Events should not be used this way. +- We use redundant values, e.g. `db/filepath` and `db-picker/all-items`. `db/filepath` can be derived from `db-picker-all-items`. Having multiple key-values increases the amount of state replication. +- Up until this point, we used plain strings for keys. This made mapping local-storage keys to re-frame keys inconvenient, e.g. `"db/filepath"` in local-storage, `:db/filepath` in re-frame. Managing both keys manually is tedious and error-prone. +- Up until this point, we used plain strings for values. `:db-picker/all-items` is the first value we want to persist in local-storage that is a nested data structure, therefore we need to serialize and deserialize more consistently. +- We should be using the same local-storage API for getting and setting, but we have specific local-storage events and effects, e.g. `:local-storage/` +- Sometimes we get and set data to localStorage API, bypassing re-frame entirely. This happens mainly in `settings`, where we read and write to localStorage for username, email, monitoring, and backup time. + +### Examples + +* settings + * backup timer + * usage/diagnostics + * user + * OpenCollective email + * username (used by RTC) +* Recently opened databases + * Entire List + * Most recently opened (can now be derived from Entire List) +* appearance + * dark/light mode + * screen width (not merged yet) + +### Existing Solutions + +- [blulegenes](shttps://sourcegraph.com/github.com/intermine/bluegenes@dev/-/blob/src/cljs/bluegenes/effects.cljs?L15-29&subtree=true) + - Our implementation could be as simple as a pair of cofx/fx. No library actually needed. + - Doesn't let you persist or get multiple keys at once. +- https://github.com/akiroz/re-frame-storage + - Automatically persists the values you want. Don't have to create duplicate `:db` and `:fx` + - Persists multiple keys easily. + - There is a single `:persistent` key +- https://github.com/deg/re-frame-storage-fx + - Handles both local-storage and session-storage (not used yet). + - `get`s multiple keys easily. + + + +## Decision + +After first chat with Sid and Alex, the current approach is to create a single nested map for all values that need to be persisted to localStorage. + +Pros: + +- We have one data structure to work with. This means we can stick to `.edn` as much as we want to until the final serialized state (probably transit-json). One monolithic data structure makes portability easier in the future, for instance, if we stored all these settings in `settings.json` (like VS Code), `config.edn`, or in a SQL/NoSQL table. +- We can easily read in multiple values via cofx at once. The bluegenes approach only gives the coeffects one `:local-storage` key, which means multiple `get`s would overwrite this key. + +Cons: + +- It is harder to manipulate local-storage values directly from the Dev Console. Probably mainly only Athens devs would have to work around this nested, serialized data structure, but overall probably not a big deal. + +### Implementation + +- Use init-rfdb with `nil` values or empty values if a collection. Try to make sure all key value pairs are present. [Bluegenes example](https://sourcegraph.com/github.com/intermine/bluegenes@dev/-/blob/src/cljs/bluegenes/db.cljc?subtree=true) +- Read in local-storage on `boot`. Go through db logic, based on whether a db exists or not in settings or on filesystem. +- Apply additional settings, such as appearance (screen width, light/dark mode) +- Write interceptor with :after key that looks at app-db. If :athens/persist key in app-db is updated, update local-storage. See https://day8.github.io/re-frame/Interceptors/ +- Make sure user can `get` and `set` multiple values at once. + + +## Consequences + +How do we upgrade/migrate from `db/filepath` to `db-picker/all-dbs` ? diff --git a/doc/adr/0009-rtc-deployment.md b/doc/adr/0009-rtc-deployment.md new file mode 100644 index 0000000000..896f99c204 --- /dev/null +++ b/doc/adr/0009-rtc-deployment.md @@ -0,0 +1,83 @@ +# 9. RTC deployment + +Date: 2021-08-24 + + +## Status + +Proposed. + + +## Context + +Now that we have a RTC server build we need to define how it should be deployed for usage. + +The primary requirements are a method of installation, update, and guarantee of data resilience. + + +On installation: +- we should allow configuring hostname + - port should already be configurable + - might be preferrable to ship it with nginx + - don't need hostname config then +- ideally all bundled in a docker image + - whatever the services we have in that docker compose are, we always need to spit out backups/etc to a volume, and then the data is only as safe as that volume is + - even if it's not a volume, backups are an output that goes somewhere +- we can use the server for our PKM usecases as well on electron + - so whatever we come up with here for storage is applicable there as well +- there's several levels of persistence here + - what the athens client saves to the athens server + - how the athens server db commits changes + - how that db commits to it's own persistence layer + - how that persistence layer is made durable +- ideally we should be able to provide a 1-click deploy on aws/do/gcp + - the docker strategy supports this + - + + +On update: +- the server update needs to run a boot sequence where migrations can be ran +- very likely that we need a shutdown sequence + - because of alex has seen datahike corruption on filesystem twice, filipe has seen it once +- we should be able to use `component` to run both boot and shutdown seqs, via signal handling +- can we ensure the output of the server is always consistent? + - if so, even if boot or shutdown seqs are interrupted, a new runtime can be provided + - this is the holy grail of recovery + - even if the server is booted/shutdown correctly, there can still be messages in flight to other services that we can't stop + + +On data resilience: +- for simple consistency we can store resources in the same place as db + - if the db storage supports consistent backups we're good + - what about fluree if we want to use it? +- every storage mechanism has limits +- whatever limits we have on storage will affect what we allow to upload + - we have no data on what these limits should be right now + - we should add limits according to our usecases and UX of the app +- storage for identity, permissions, etc also needs to be kept +- alex has seen datahike corruption on filesystem twice, filipe has seen it once + - `No implementation of method: :-affects-key of protocol: #'hitchhiker.tree.op/IOperation found for class: incognito.base.IncognitoTaggedLiteral` + - only solution was to delete the fs data + - we need to repro to inform our decisions about data loss and server update + - we should ask the datahike folks about this +- the frequency of output of the athens setup determines how safe your data is + - e.g. snapshots of fs are infrequent + - e.g. postgres writes are frequent + - e.g. fluree writes are frequent +- we shouldn't java ports directly if we care abut data loss + - otherwise we are exposed to a lot of java related security issues + + +## Decision + +We will create create a docker compose definition for the athens server and a nginx proxy. + +The docker compose definition will contain volume mapping for the outputs of the athens deployment. + + +## Consequences + +We're defining docker compose as the input mechanism for an Athens server, and volumes as the output. +Data resilience is only as high as a user can make the volume be. + +If we discover data corruption problems stemming for disk usage of Datahike we might need to take an approach that's different than volume as output. diff --git a/doc/adr/0010-atomic-composite-grapth-operations.md b/doc/adr/0010-atomic-composite-grapth-operations.md new file mode 100644 index 0000000000..d22de8eaa6 --- /dev/null +++ b/doc/adr/0010-atomic-composite-grapth-operations.md @@ -0,0 +1,269 @@ +# 10. Atomic/Composite Grapth Operations + +Date: 2021-08-18 + +## Status + +Draft + +Supercedes [7. Lan-Party Datascript Events](0007-lan-party-datascript-events.md) + +Amended by [14. Atomic Graph Operations transacting](0014-atomic-graph-operations-transacting.md) + +## Context + +We've made an effort to support remote execution of Semantic Events. + +These events where direct port of `events.cljs` which where mostly informed by UI concerns. + +Result is that we have a lot of different events that are doing same atomic operations over and over, +but are not really reusing these Atomic Ops. + +Implementing `:block/save` that sometimes is just updating `:block/string` and other times also needs to `:page/create`. +`:paste` event is another that will be super hard to implement w/o Atomic Graph Operations. + + +## Decision + +We have two kinds of events to modify graph: +- ⚛️ Atomic Graph Ops + - Not divisible Graph Ops + - Operations like create new block, create page, save block +- ⎄ Composite Graph Ops + - Collection of events to be executed on the graph + - Like `:block/save` when new link is discovered, should produce also `:page/create` event + +Atomic events should follow same Common Events model as it's happening now. + +List of ((⚛️ Atomic Graph Ops)) +- *Page Ops* + - ((⚛️**`:page/new`**)) + - ((⚛️**`:page/rename`**)) + - ((⚛️**`:page/merge`**)) + - ((⚛️**`:page/remove`**)) +- *Block Ops* + - ((⚛️**`:block/new`**)) + - ((⚛️**`:block/save`**)) + - ((⚛️**`:block/open`**)) + - ((⚛️**`:block/move`** ⭐️)) + - ⚛️**`:block/remove`** +- *Shortcut Ops* + - (({{[[TODO]]}} ⚛️**`:shortcut/new`**)) + - (({{[[TODO]]}} ⚛️**`:shortcut/remove`**)) + - (({{[[TODO]]}} ⚛️**`:shortcut/move`**)) + + +((⎄ Composite Graph Ops)) are a way to group ((⚛️ Atomic Graph Ops)). + +We'll only need one type of Composite Graph Ops, that is Consequence Event. + +Consequence Event is 2 things: +- Trigger Event (like `:paste`) +- Consequence Events List + - Containing however many events to include into transaction resolution. + - These events can be more of Consequence Events, or ((⚛️ Atomic Graph Ops)). + + +This will allow for easier Temporality, +We'll be able to represent Main Action that moved Knowledge Graph forward. + +It will maintain information about `:paste` events that injected sub-tree with ((⚛️ Atomic Graph Ops)). + +## Consequences + +We'll have additional work of Porting Semantic Events to use Atomic Graph Ops and compositions. + +We have working `:paste` & `:block/save` events, they are broken not ported now. + +We'll have smaller amount of tests in order to provide correctness guarantees + + +## Additional Resources + +### Catalog of operations + +- Types of Graph Operations + - ⚛️ Atomic Graph Ops + - Not divisible Graph Ops + - Operations like create new block, create page, save block + - ⎄ Composite Graph Ops + - Collection of events to be executed on the graph + - Like `:block/save` when new link is discovered, should produce also `:page/create` event + +- List of Atomic Graph Operations: + - Page Ops + - ⚛️**`:page/new`** + - ((⚛️ Atomic Graph Ops)) + - *Input* + - `title` - Page title page to be created + - `page-uid` - `:block/uid` of page to be created + - `block-uid` - `:block/uid` of 1st block to be created in page to be created + - ⚛️**`:page/rename`** + - ((⚛️ Atomic Graph Ops)) + - *Input* + - `page-uid` - `:block/uid` of page to be renamed + - `new-name` - Page should have this name after operation + - `old-name` - ^^To Remove^^ This is accidental, and shouldn't be provided + - ⚛️**`:page/merge`** + - ((⚛️ Atomic Graph Ops)) + - *Input* + - `page-uid` - `:block/uid` of page to be merged into `new-page` + - `new-page` - page name of a page we'll merge contents of `page-uid` page into + - `old-name` - ^^To Remove^^ This is accidental, and shouldn't be provided + - ⚛️**`:page/remove`** + - ((⚛️ Atomic Graph Ops)) + - *Input* + - `page-uid` - `:block/uid` of the page to be deleted + - Block Ops + - ⚛️**`:block/new`** + - ((⚛️ Atomic Graph Ops)) + - *Input* + - `parent-uid` - `:block/uid` of parent block (or page) + - `block-uid` - `:block/uid` of new block to be created + - `block-order` - `:block/order` of new block to be created + - Currently it's only `int` + - We could extend it to allow `int` and 2 keywords `:first` & `:last` (to say that we want this new block to be 1st among the children of `parent-uid` or last) + - ⚛️**`:block/save`** + - ((⚛️ Atomic Graph Ops)) + - *Input* + - `block-uid` - `:block/uid` of block to be saved + - `new-string` - new value of `:block/string` to be saved + - `add-time?` - ^^To Remove^^ , we should always update `:edit/time` + - ⚛️**`:block/open`** + - ((⚛️ Atomic Graph Ops)) + - *Input* + - `block-uid` - `:block/uid` of block to be opened/closed + - `open?` - should we open or close the block + - ⎄**`:block/add-child`** + - ((⎄ Composite Graph Ops)) + - *Composition of* + - It's a special case of ((⚛️**`:block/new`**)) where block is put as 1st child + - Currently in code as `:enter/add-child` + - ⎄**`:block/open-block-add-child`** + - ((⎄ Composite Graph Ops)) + - *Input* + - `parent-uid` - `:block/uid` of parent to be open + - `block-uid` - `:block/uid` of child block to be added + - *Composition of* + - ((⚛️**`:block/new`**)) + - ((⚛️**`:block/open`**)) + - ⚛️**`:block/move`** ⭐️ + - ⭐️New Operation + - ((⚛️ Atomic Graph Ops)) + - *Input* + - `block-uid` - `:block/uid` of block to move + - `parent-uid` - `:block/uid` of new parent block + - `index` - (optional) `:block/order` new position on `:block/children` + - if not provided, position is preserved + - ⎄**`:block/split`** + - ((⎄ Composite Graph Ops)) + - *Input* + - `block-uid` - `:block/uid` of block to be split + - `value` - `:block/string` of block to be split + - `index` - split index + - `new-block-uid` - `:block/uid` of split to block + - *Composition of* + - ((⚛️**`:block/new`**)) + - ((⚛️**`:block/move`** ⭐️)) + - ((⚛️**`:block/save`**)) + - *Notes* + - In code as `:enter/split-block` + - ⎄**`:block/split-to-children`** + - ((⎄ Composite Graph Ops)) + - *Input* + - `block-uid` - `:block/uid` of block to be split + - `value` - `:block/string` of block to be split + - `index` - index of split + - `child-uid` - `:block/uid` of new block to split to that is a first child + - *Composition of* + - ((⚛️**`:block/new`**)) + - ((⚛️**`:block/save`**)) + - *Notes* + - In code as `:split-block-to-children` + - ⎄**`:block/indent`** + - ((⎄ Composite Graph Ops)) + - *Input* + - `block-uid` - `:block/uid` of block to be indented + - `text` - (optional) new `:block/string` value to be saved + - *Composition of* + - ((⚛️**`:block/save`**)) + - ((⚛️**`:block/move`** ⭐️)) + - *Notes* + - In code as `:indent` + - ⎄**`:block/indent-multi`** + - ((⎄ Composite Graph Ops)) + - *Input* + - `block-uids`: list of `:block/uid` of blocks to be indented + + - *Composition of* + - ((⎄**`:block/indent`**)) + - *Notes* + - In code as `:indent/multi` + - ⎄**`:block/unindent`** + - ((⎄ Composite Graph Ops)) + - *Input* + - `block-uid` - `:block/uid` of block to be unindented + - `text` - (optional) new `:block/string` value + - *Composition of* + - ((⚛️**`:block/save`**)) + - ((⚛️**`:block/move`** ⭐️)) + - *Notes* + - In code as `:unindent` + - ⎄**`:block/unindent-multi`** + - ((⎄ Composite Graph Ops)) + - *Input* + - `block-uids`: list of `:block/uid` of block to be unindented + - *Composition of* + - ((⎄**`:block/unindent`**)) + - *Notes* + - In code as `:unindent/multi` + - ⎄**`:block/bump-up`** + - ((⎄ Composite Graph Ops)) + - *Input* + - `block-uid` - `:block/uid` of block to be bumped up + - `new-block-uid` - `:block/uid` of new block to be created above `block-uid` block + - *Composition of* + - ((⚛️**`:block/new`**)) + - ⎄**`:paste-verbatim`** + - ((⎄ Composite Graph Ops)) + - *Input* + - `block-uid` - `:block/uid` of block to paste `text` to + - `text` - text to be added to `:block/string` + - `index` - position to add `text` at + - `value` - (^^To Remove^^)`:block/string` value + - *Composition of* + - ((⚛️**`:block/save`**)) + - {{[[TODO]]}} Drop Ops + - ⎄ **`:drop/child`** + - ((⎄ Composite Graph Ops)) + - *Input* + - `source-uid` - `:block/uid` of blocked to drop to new `new-parent-uid` + - `new-parent-uid` - `:block/uid` of new parent to drop to + - *Composition of* + - ((⚛️**`:block/move`** ⭐️)) + - ⎄**`:drop/child-multi`** + - ((⎄ Composite Graph Ops)) + - *Input* + - `source-uids` - list of `:block/uid` of blocks to be moved + - `new-parent-uid` - `:block/uid` of new parent to move to + - *Composition of* + - ((⎄ **`:drop/child`**)) + - ⎄ **`:drop/child-link`** + - ((⎄ Composite Graph Ops)) + - *Input* + - `source-uid` - `:block/uid` of block to link to + - `parent-uid` - `:block/uid` of parent block receiving link drop + - `new-block-uid` - `:block/uid` of block to create with link to `source-uid` + - *Composition of* + - ((⚛️**`:block/new`**)) + - ((⚛️**`:block/save`**)) + - ⎄ **`:drop/different-parent`** + - ((⎄ Composite Graph Ops)) + - *Input* + - `source-uid` - `:block/uid` of block to link to + - `parent-uid` - `:block/uid` of parent block receiving link drop + - + - {{[[TODO]]}} Shortcut Ops + - {{[[TODO]]}} ⚛️**`:shortcut/new`** + - {{[[TODO]]}} ⚛️**`:shortcut/remove`** + - {{[[TODO]]}} ⚛️**`:shortcut/move`** diff --git a/doc/adr/0011-components-preview.md b/doc/adr/0011-components-preview.md new file mode 100644 index 0000000000..cfa58e8009 --- /dev/null +++ b/doc/adr/0011-components-preview.md @@ -0,0 +1,49 @@ +# 10. Components Preview + +Date: 2021-09-09 + +## Status + +Approved. + +Uses [10. JS Components](0010-js-components.md). + + +## Context + +We'd like to be able to see changes in our JS Components without having to build and serve the PR that introduced those changes. + +A common way to do this in GitHub projects is to add CD automation to push PR builds to preview domains. + +We could roll our own automation to do this over the free github pages domain but it is likely that this is tricky work. + +Vercel is a popular provider of preview builds that the team already has experience with. + + +## Decision + +Use Vercel with a single member team to reduce costs. + +Use a `vercel-build` npm script to enable developers to change the build step without having access to the Vercel settings. + +Set the [Production Branch](https://vercel.com/docs/git#production-branch) to `feature/rtc-v1` while that's the main development branch, and afterwards set it to `main`. + +Set the Vercel [Build & Development Settings](https://vercel.com/docs/build-step#build-&-development-settings) to: +- Build command: `yarn vercel-build` +- Output directory: `storybook-static` +- Install command: `yarn install` + + +## Consequences + +Negative consequences: +* We have to pay for Vercel teams with at least 1 member because of the athensresearch org + * We might be able to avail of the [Vercel OSS sponsorship](https://vercel.com/support/articles/can-vercel-sponsor-my-open-source-project) +* The preview domain is deployed even if there's no changes to the JS components build + * Customizing the [Ignored Build Step](https://vercel.com/docs/platform/projects#ignored-build-step) might enable this, but seems error-prone +* Vercel will comment on every PR once, adding some spam to notifications' +* Vercel only allows for a single auto-deploy target branch that we have to change when we have a new major development branch +* Vercel does not allow in-repo file config for build or branch settings + +Positive consequences: +* We can see and show others the components build diff --git a/doc/adr/0012-presence.md b/doc/adr/0012-presence.md new file mode 100644 index 0000000000..6d96f5ec52 --- /dev/null +++ b/doc/adr/0012-presence.md @@ -0,0 +1,47 @@ +# 12. Presence + +Date: 2021-09-2 + + +## Status + +Proposed. + + +## Context + +Presence shows what block users are in. +A user can see the list of users in the DB, which of them are in the same page, which block (if visible) they are on, and navigate to where another user is. + + +## Decision + +The information we need to keep for presence is the last known location of a user. +- previous location information is not kept +- location comprises only block uid + - page is derived from block by the client + - this delegates the responsibility of access control to the client +- user comprises user id and display name + +When a user enters a page, their location is the first block. +It is possible to view a page without editing any block, but this rule allows presence to do away with any page information. + +Presence for a user is removed when the user disconnects from the server. + +Embeds are considered to be a UI view based on a ref. +If a user is on a block inside an embed, they are in that blocks location, not in the embed. +There might be other such views in the future and we shouldn't special-case embeds for now until we have a better understanding of whether we need to. + +Location for existing users is kept on the server and provided to users on connection. + + +## Consequences + +Permissions do not need to be taken into account when broadcasting presence. +Presence does not broadcast the page identifier, which is its title, and thus does not expose information that would need to be checked against permissions. + +There's no need to lookup users. +All the relevant information to display the user presence is available in the protocol. + +The rules for setting block presence can change in the future, while keeping the existing information of block location. + diff --git a/doc/adr/0013-source-of-truth.md b/doc/adr/0013-source-of-truth.md new file mode 100644 index 0000000000..b5b4cb966f --- /dev/null +++ b/doc/adr/0013-source-of-truth.md @@ -0,0 +1,85 @@ +# 13. Source of Truth + +Date: 2021-10-13 + + +## Status + +Accepted. + + +## Context + +We've moved all of our UI transaction centric events to semantic events, where we express the intent of the change rather that the database transaction representation. + +The Athens server and client both resolve data representations from these events and apply it to their local database, discarding the semantic event after resolution. +This resolution loses information, because the semantic event contains more than just the applied data, and is specific to the particular database solution used at the time. +But the concrete running local database state constitutes the usable running system. + +The core of this problem is what constitutes the source of truth for the knowledge graph. +The two candidates we have for source of truth are the database state and the the total sequence of events. + +Choosing a source of truth does not mean we will exclusively use it, and remove any usage of the other mechanism. +Instead, the choice of source of truth informs how we should reason about the system and how should the system achieve desirable properties (e.g. resilience, performance). + +Athens remains a multi-user experience where each user is able to collaborate in real time, and where data should be retained for a long time. +The decision will need to provide solutions for communication of data, both in the whole and incrementally, and for handling large amounts of data. + + +## Decision + +To better understand the shortcomings of each approach let's look at them in isolation. +Were we to use the database state as only source of truth, with no notion of incremental changes, we'd have to transmit at least partial snapshots on every change to all clients. +Conversely, without a notion of state, loading a new client would require replaying the total sequence of events. +Each approach individually is insuficient. + +The real-time collaboration imposes a hard requirement efficient state updates on clients, which in turn requires accurate incremental changes. +By accurate, we mean that applying the incremental changes upon a state should leave all clients in the same state. +Without this property clients would see increasingly different states, and client actions would not be correct. + +This property applies all the way to the initial empty state, which means that the accurate incremental changes requirement in effect means that a total sequence of events must exist and deterministically reduce to the final state. +This in turn also means that any database state can be derived from the sequence of events. +The opposite is not true, as the sequence of events cannot be derived from the resulting database state due to the loss of information described in the context above. +This leaves us with the sequence of events as the more natural source of truth. + +Instead of discarding events after resolution, we can store them permanently and achieve some of the benefits of [Event Driven Architectures](https://en.wikipedia.org/wiki/Event-driven_architecture), especially around CQRS and Event Sourcing. +Achieving full CQRS or Event Sourcing is not a goal in and of itself. + +Major benefits in our case include: + +- ease of migration, between current databases and future ones +- correctness checking, between two resolution implementations +- debugging, via replaying history of changes to runtime +- self-healing of databases, by replaying events with fixed resolutions +- decoupling of event generation and consumption, allowing more sophisticated async and offline-first usecases +- storage, for ephemeral databases + +We have decided to add an event log to the Athens server, to which we record every database-affecting operation. + +This event log will be implemented as an immutable append-only log in [Fluree](https://flur.ee/). + +Fluree was chosen because it offers a Datalog-adjacent query/transaction format, has a matching open-source licence we can use for the server (via Docker), good scalability, and a migration path to a cloud-native implementation. + +Another relevant reason is that Fluree is a technology the team has been interested in experimenting with since it feels a good fit for the long-term Athens use case, so getting some experience with it on a very limited domain is valuable. + +It is impractical to always load the full log for large enough logs. +Using state snapshots can help reduce this problem, given they function as a cache for replaying a set of logs. +State snapshots can be used on the server, client, and as an export format to interact with other tools. + + +## Consequences + +Besides enabling the scenarios described in [context](#context), there are also negative, or at least binding, consequences: + +- total time to effect changes increases, because the server needs to store the events before processing it +- the current event format becomes a frozen API that we need to support indefinitely, since all events must be replayable +- extra storage needed to store the events, which grows at a similar pace or higher than the current data storage +- the event storage format is another frozen API, since old events stored in the log need to be readable +- increased memory usage for docker deployments due to the extra log service +- limits on the event store (e.g. event size) are passed on to the system + +It is important to highlight that snapshots can and will differ according to the handling of events. +While possible in principle to completely reproduce the original handlers for a snapshot, in practice this is hard and has diminishing returns. +Prime is the case of benign bug fixes that change the output: the result was not the same, but the previous result was incorrect. +For any given long running system using snapshots, it is likely that a fresh new system that replays all events will yield a slightly different snapshot. + diff --git a/doc/adr/0014-atomic-graph-operations-transacting.md b/doc/adr/0014-atomic-graph-operations-transacting.md new file mode 100644 index 0000000000..65f81c8b03 --- /dev/null +++ b/doc/adr/0014-atomic-graph-operations-transacting.md @@ -0,0 +1,32 @@ +# 14. Atomic Graph Operations transacting + +Date: 2021-10-20 + +## Status + +Proposed. + +Amends [10. Atomic/Composite Grapth Operations](0010-atomic-composite-grapth-operations.md) + +## Context + +Some Atomic Graph Operations can't be executed within context of 1 transaction. + +Some examples: +* 2x `:block/remove` leaves a gap in `:block/order` (unless removes where at the end of children list). +* 2x `:block/new` where 2nd operation provides relative position referencing block created by 1st operation. + +This issues showed up while working on `:block/remove`, which seems to be easiest of this problem class, +because it generate gaps in `:block/order`, and we could delegate order cleanup to `order-keeper`. + +## Decision + +To facilitate multiple atomic operations execution we'll have to break up composite operations into +list of atomic ops, then resolve & transact each atomic op separately. + +## Consequences + +This approach of transacting each Atomic operation in isolation is introducing a lot of overhead. +Clearly it's not ideal, but our current data structures don't allow us for smarter approach. + +We'll have to revisit this decision when we are ready to take on CRDTs or other smarter structures. diff --git a/doc/adr/0015-adressing.md b/doc/adr/0015-adressing.md new file mode 100644 index 0000000000..76958b5cc0 --- /dev/null +++ b/doc/adr/0015-adressing.md @@ -0,0 +1,68 @@ +# 15. Addressability + +Date: 2021-11-10 + +## Status + +Accepted. + + +## Context + +We need to address both blocks and pages during protocol operations, both for individual entities and for positional relationships between entities. + +Blocks are uniquely identified by an immutable id, the block-uid. +This ID will never change once created and is idependent from the block content. + +Pages are uniquely identified by their content, usually referred to as the title. +Titles are unique but mutable. +When a title changes, all references to a title also need to change. + +Positional relationships are defined by one of the unique identifiers listed above together with a first/last/before/after relationship. + +Since pages can be referred to by knowing their title content, they are content-addressable. +This means you can address a page by knowing the page human readable title. +This allows adding blocks to a page, and referring to a page within a block, by knowing the page name. +It's not possible to do this with blocks, as you need to know the block uid to reference it. + +Although the identifier for a page is called a title, this does not completely reflect the role as an addressible identifier. +While developing the protocol operations for pages we naturally hit a tension between the concept of title and how it is a name. +Creation and deletion operations would reference the title, but rename and move operations would reference the name. + + +## Decision #1 (reverted) + +The title abstraction is suitable for a page but what really matters is that it is addressed uniquely by a known name. +This points to a higher level concept where more things can have such names, and things can have more than one name. + +For a page, the title, content, and unique addressable name happen to be the same. +If a page can be addressed by different names, then the title and content would no longer be the same as the name. +Blocks cannot be named right now but it sounds like something we could do. + +We will use the more general name instead of just title in the Athens protocol. + + +## Consequences #1 + +Protocol operations for position and page will refer to name instead of title. +Actually resolutions and code for the frontend and backend can still refer to titles. + +We can expand the use of names via new protocol operations in the future. + + +## Decision #2 + +While reviewing the Athens protocol, the issue of identity for pages came up again. + +Both page and shortcut operations needs to uniquely identify pages and cannot operate over non-page entities. +If they received the more general names, they would need to verify these correspond to pages and not to blocks. + +This reason proved sufficient to revert the previous decision. +We still would like to have a name-like abstraction for the reasons stated above, but they are a net benefit for the current set of operations. + + +## Consequences #2 + +Pages will again be addressed via title in the Athens protocol. +Names can also be used to address pages in the future via new operations or new versions of the existing operations. + diff --git a/doc/adr/0016-js-components.md b/doc/adr/0016-js-components.md new file mode 100644 index 0000000000..4136e2e8b0 --- /dev/null +++ b/doc/adr/0016-js-components.md @@ -0,0 +1,52 @@ +# 16. JS Components + +Date: 2021-08-23 + +## Status + +Proposed. + + +## Context + +Inspired by [David Nolen's ClojureScript in the Age of TypeScript](https://vouch.io/developing-mobile-digital-key-applications-with-clojurescript/) talk and post, we started thinking about how viable it would be to use JS (instead of CLJS) components. + +This change would provide design with a better and more familiar development environment for components and reduce their dependency on engineering to ship work. +The separation also has several second order benefits related to organization and communication. + +Costs for this change are centered around the degree of separation between application and components, where cljs is the primary language on the former and js on the latter, and maintaining extra tooling. + +Part of [Ongoing Hypothesis](https://docs.google.com/document/d/18ExzXHB5aezyINmIVWDBpZpXgV67kAhuAO8MvX6dbPw/edit). + + +## Decision + +Decided to use JS components with Storybook, compiled from TSX, after seeing how much Stuart was able to get done with them. + + +## Consequences + +Negative consequences: +* JS/CLJS context switching when working in the app and on components at the same time +* Higher surface area for JS interop problems +* More complex build step +* More tooling to maintain +* Harder for for CLJS engineers to develop and maintain components + + +Positive consequences: +* Better development and testing environment for components +* Clearer defined deliverable scope and context for components +* Component documentation +* Smaller and simpler application codebase +* Looser coupling between engineering and design +* Design can deliver work on separate cadence, units, and timeline, from engineering +* Easier to enforce discipline on components as pure functions +* CLJS proficiency is not necessary for design work +* Easier for contributors to contribute to components +* Reduced bus factor in design + +Deferred work items: +* The [devcards style guide](https://github.com/athensresearch/athens/blob/c697b7c62d60dd9fca0b03a32b35cc4776a90c54/src/cljs/athens/devcards/style_guide.cljs) will need to be reimplemented in Storybook +* The [devcards stylify guide conventions](https://github.com/athensresearch/athens/blob/c697b7c62d60dd9fca0b03a32b35cc4776a90c54/src/cljs/athens/devcards/styling_with_stylefy.cljs) will need to be carried over to Storybook equivalents +* The [partial work on filters](https://github.com/athensresearch/athens/blob/feature/rtc-v1/src/cljs/athens/views/filters.cljs) should be used as reference for a future filters implementation diff --git a/doc/adr/0017-athens-system-design.md b/doc/adr/0017-athens-system-design.md new file mode 100644 index 0000000000..b88b6693d0 --- /dev/null +++ b/doc/adr/0017-athens-system-design.md @@ -0,0 +1,55 @@ +# 17. Athens System Design + +Date: 2021-11-24 + +## Status + +Accepted + +## Context + +This ADR provides overview of current system design in Athens Single-Player and Lan-Party. +It serves as documentation. + +## Decision + +### Single Player + +This is Athens Client used for Personal Knowledge Management without connecting to Server. +![](0017-system-design-single-player.png) + +Everything happens in one process. + +User Interface actions are represented as Semantic Events (our re-frame events), +which in turn gets to be translated to Atomic Graph Operations. + +Atomic Graph Ops are Resolved in context of clients Datascript DB, +then transacted to the same DB. End of the story. + + +### Lan Party + +This is Athens Client used for Collaborative Knowledge Management, that is connected to Athens Lan Party server. +![](0017-system-design-lan-party.png) + +In Lan Party context, Athens Clients behaves a bit differently. +Atomic Graph Operations are applied to clients Datascript DB optimistically and dispatched to Lan Party Server over Athens Events Protocol. + +Server on receiving Event from connected client, decides 1st if event is AGO or presence. + +In case of AGO event: + +* Resolve AGO in context of temporary Datascript DB +* Transact AGO resolution to temporary Datascript DB +* Acknowledge Event to client +* Forward AGO event to all connected clients +* Store in Append only Event Store + +In case of Presence Event, server updates internal presence state, +and forwards it to all connected clients. + + +## Consequences + +Atomic Graph Operation Resolution and transacting mechanisms are shared between client and server code, +doing it this way provides us guarantees about correctness in both cases. diff --git a/doc/adr/0017-system-design-lan-party.dot b/doc/adr/0017-system-design-lan-party.dot new file mode 100644 index 0000000000..74cf74dcdd --- /dev/null +++ b/doc/adr/0017-system-design-lan-party.dot @@ -0,0 +1,79 @@ +digraph system_diagram { + label="Lan Party System Diagram"; + + subgraph cluster_client { + label="Athens Client"; + + UI; + cSE [label="Semantic Events";]; + "Optimistic Update"; + cResolution [label="AGO Resolution"]; + cTransact [label="AGO Transact"]; + cDatascript [label="Datascript DB";]; + SendIt [label="Send to Server"]; + cAck [label="Acknowledged AGO";]; + + UI -> cSE; + cSE -> "Optimistic Update" [label="Apply optimistically";]; + cSE -> SendIt [label="Send AGO over WS";]; + "Optimistic Update" -> cResolution [label="Composed of AGO";]; + cResolution -> cTransact; + cTransact -> cDatascript; + + cAck -> cDatascript [label="Updates DB, cleaning Optimistic state";]; + cDatascript -> UI [style=dotted; color=blue]; + cDatascript -> cResolution [style=dotted; color=blue]; + cDatascript -> cSE [style=dotted; color=blue]; + } + + subgraph cluster_server { + label="Athens Lan-Party Server"; + + wsReceive [label="WS Receive";]; + isAGO [shape=diamond; label="AGO?";]; + sAGO [label="Process AGO";]; + sResolution [label="AGO Resolution";]; + sTransact [label="AGO Transact";]; + sDatascript [label="Server Temporary Datascript";]; + sAck [label="Acknowledge AGO";]; + sPresence [label="Forward to all connected clients";]; + sForward [label="Forward AGO to all connected clients";]; + sEventStore [label="Event Store";]; + + wsReceive -> isAGO; + isAGO -> sAGO [label="Yes, AGO";]; + sAGO -> sEventStore [label="1";]; + sAGO -> sResolution [label="2";]; + sAGO -> sAck [label="3";]; + sAGO -> sForward [label="4";]; + sResolution -> sTransact; + sTransact -> sDatascript; + + sDatascript -> sResolution [style=dotted; color=blue]; + + isAGO -> sPresence [label="No, Presences";]; + + sEventStore -> sResolution -> sAck -> sForward [style=invis;]; + } + + SendIt -> wsReceive; + sAck -> cAck; + + subgraph cluster_legend { + + label="Legend"; + + AGO [label=; shape=plaintext] + A [shape=plaintext]; + B [shape=plaintext]; + C [shape=plaintext]; + D [shape=plaintext]; + + { rank=same AGO } + { rank=same A B } + { rank=same C D } + A->B [label="Invokes";]; + C->D [style=dotted; color=blue; label="Provides Context";]; + AGO -> A -> D [style=invis] + } +} diff --git a/doc/adr/0017-system-design-lan-party.png b/doc/adr/0017-system-design-lan-party.png new file mode 100644 index 0000000000..73a0fdd038 Binary files /dev/null and b/doc/adr/0017-system-design-lan-party.png differ diff --git a/doc/adr/0017-system-design-single-player.dot b/doc/adr/0017-system-design-single-player.dot new file mode 100644 index 0000000000..53c9a0230a --- /dev/null +++ b/doc/adr/0017-system-design-single-player.dot @@ -0,0 +1,37 @@ +digraph system_diagram { + label="Single Player System Diagram"; + + UI; + "Semantic Events"; + Resolution [label="AGO Resolution"]; + Transact [label="AGO Transact"]; + + Datascript; + + UI -> "Semantic Events"; + "Semantic Events" -> Resolution [label="Composed of AGO"]; + Resolution -> Transact; + Transact -> Datascript; + + Datascript -> UI [style=dotted; color=blue]; + Datascript -> Resolution [style=dotted; color=blue]; + Datascript -> "Semantic Events" [style=dotted; color=blue]; + + subgraph cluster_legend { + rank=sink; + label="Legend"; + + AGO [label=; shape=plaintext] + A [shape=plaintext]; + B [shape=plaintext]; + C [shape=plaintext]; + D [shape=plaintext]; + + { rank=same AGO } + { rank=same A B } + { rank=same C D } + A->B [label="Invokes";]; + C->D [style=dotted; color=blue; label="Provides Context";]; + AGO -> A -> D [style=invis] + } +} diff --git a/doc/adr/0017-system-design-single-player.png b/doc/adr/0017-system-design-single-player.png new file mode 100644 index 0000000000..0727e7a442 Binary files /dev/null and b/doc/adr/0017-system-design-single-player.png differ diff --git a/doc/adr/0018-athens-protocol-principles.md b/doc/adr/0018-athens-protocol-principles.md new file mode 100644 index 0000000000..a95e209f96 --- /dev/null +++ b/doc/adr/0018-athens-protocol-principles.md @@ -0,0 +1,63 @@ +# 18. Athens Protocol Principles + +Date: 2021-11-23 + + +## Status + +Accepted. + + +## Context + +The Athens Protocol defines the format and semantics for messages between Athens clients. +These clients can be standalone clients, clients connected to servers, or other future architectures. + +Athens as it is today does not have a public protocol and API, but this is the outline of the principles that should guide it. +The current private protocol and API adheres to these principles. + +The protocol aims to support a few key requirements: + 1. longevity: information must be usable for at least a long period of time in the future, e.g. 10+ years + 2. robustness: clients must be able to synchronize through unreliable network connections + 3. reliability: data must not be lost and remain acessible + 4. extensibility: the protocol must support extension over time + + +## Decision + +We decided to model the Athens Protocol as an append-only immutable log of deterministic operations. +Operations from multiple clients are weaved together into a [single canonical log](0013-event-log.md). +Knowledge graph state is determined by the reduction of all operations. +The current state can be represented by a canonical view. + +Operations express semantic changes to the knowledge graph. +Non-trivial operations are expressed as [composites of atomic operations](0010-atomic-composite-grapth-operations.md). +The effect of atomic operations is kept small and local to their direct relationships. + +Future versions of the protocol can enhance the vocabulary of atomic operations, or relax the constraints on existing operations. +Removal of existing operations or tightening of constrains is not supported. +Those two classes of changes are breaking changes, and would render previously valid operations invalid. + +Clients synchronize state by sending optimistic operations, and listening to the canonical stream of operations from the server. +Clients can create a new optimistic state by applying optimistic operations over the last known state. +Synchronization can happen as frequently or infrequently as desired. +Each synchronized operation has a unique id used for identification and to prevent duplication in the log. + +Standalone clients do not need to synchronize with other clients, but still express changes in the same format. +They are, essentially, a client that never synchronizes with other clients. + +Different clients can arrive at different states based on their interpretation of the operations. +This way clients can select for information that is relevant to them. +This also allows for different client capabilities and conflict resolution strategies. +For instance, a mobile client might only try to model a subtree of the graph, or even only allow insertions on specific pages. + +The [initial set of atomic operations](0010-atomic-composite-grapth-operations.md) expresses only a tree structure via page identity and block identity, content and location. +For now, content parsing for references is not part of the protocol itself, and is instead left to the clients. + + +## Consequences + +The canonical log requires clients to achieve consensus on what the order of operations is. + +The latest graph state can only be obtained by replaying the full log, or via querying for a client-compatible view of the current state. + diff --git a/doc/adr/0019-athens-ledger-save-load.md b/doc/adr/0019-athens-ledger-save-load.md new file mode 100644 index 0000000000..3a960f227f --- /dev/null +++ b/doc/adr/0019-athens-ledger-save-load.md @@ -0,0 +1,24 @@ +# 19. Athens ledger save-load + +Date: 2021-12-01 + +## Status + +Accepted. + +## Context + +In case of data-loss we want a mechanism that can be used to restore the data. + +## Decision + +1. MVP - 0 : Use cron to save the ledger on disk, use the cli to load a previous ledger. + + - Have a cli using which we can + - Save: Take the current state of ledger and save it in the specified folder. + - Load: Load events from some other ledger to a new server instance. + - Delete the current ledger + - Restart the fluree ledger + - Load the create a new ledger and load the events from the other ledger. + + - Create a cron job that will save the ledger every X amount of time. diff --git a/doc/adr/0020-rollback-optimizations.md b/doc/adr/0020-rollback-optimizations.md new file mode 100644 index 0000000000..e304e69cf0 --- /dev/null +++ b/doc/adr/0020-rollback-optimizations.md @@ -0,0 +1,50 @@ +# 20. Rollback Optimizations + +Date: 2021-12-02 + + +## Status + +MVP + + +## Context + +The Athens client optimistically resolves and applies events to its internal database state for responsiveness. + +When the client receives events that invalidate the optimistic state it must return to a valid state. +Currently, for each event that invalidates the state, we reset to the last valid state all, apply the event, and then reapply all the optimistic changes. + +This approach is tauntamount to a full database reset does not allow for incremental subscription updates. +For non-trivial databases,`posh` (datascript watcher for re-frame) will re-run all subscriptions, and thus all queries, over datascript and freeze the app for a few seconds. + + +## Approach + +We've identified two promising approaches: +- batch processing for events that cause rollbacks +- perform incremental rollbacks + +The first approach requires accumulating events before processing them, either by count or time. +A time based approach based on a debounce mechanism, such as [`goog.debounce`](https://google.github.io/closure-library/api/goog.functions.html), sounds like the most straightforward. +It is unclear how to perform an idiomatic debounce in re-frame. + +The second approach requires a way to undo existing optimistic changes instead of resetting to a previous state. +This can be achieved by storing the datoms from the transaction report for each change. +These datoms contain a boolean that indicates whether the datom as added or removed, and by flipping the boolean we obtain a valid transaction that undoes the original. +After undoing all optimistic changes, each new event is applied, followed by the optimistic changes. +We then again store the transaction report from the reapplication of optimistic changes, since these are now new reports. + + +## Insights from MVP + +We started getting frequent freezes for seconds or even minutes. +Profiling shows `posh` seemed to be causing some of the freezes. +We settled on following the second approach for the MVP and are observing the results. +If indeed the cause of freezing is due to resetting the database, this approach should completely eliminate it. + + +## Decision + + +## Consequences diff --git a/doc/adr/0021-undo-redo.md b/doc/adr/0021-undo-redo.md new file mode 100644 index 0000000000..75d453c24e --- /dev/null +++ b/doc/adr/0021-undo-redo.md @@ -0,0 +1,160 @@ +# 21. Undo/Redo + +Date: 2022-01-06 + + +## Status + +MVP implementation + + +## Context + +Athens RTC does not support undo/redo functionality. +This was deliberately cut to reduce scope since the semantics of undo/redo are non-trivial in multiplayer applications. + +In our internal usage we've seen a number of situations where not having undo has resulted in data loss. +Some of these were via deliberate deletion of content, others through accidental deletion via bugs. +In both classes of problems the data would have been recovered easily with undo. + +Time travel functionality, where past states of a graph can be seen and used to recover data, sounds like a promising approach to undo/redo that could leverage the [Athens Protocol model of time](doc/adr/0018-athens-protocol-principles.md). +While related, it does not quite fit: time travel is about visualizing past states, but undo is about reverting operations over a past state. + +To further complicate matters, the multiplayer nature of Athens RTC means that undo must take into account interleaved operations from other users. +This is significantly different than in a singleplayer where there is a single canonical "timeline" that does not change aside from the interactions of the current user. + +We can find accounts of undo implementations in modern multiplayer product blogs like [Figma's](https://www.figma.com/blog/how-figmas-multiplayer-technology-works/) and [Hex's](https://hex.tech/blog/a-pragmatic-approach-to-live-collaboration). Both +use a key-value property model as the lowest level of operation, and perform undo by reversing those operations. + +Figma's undo implementation also lists one very important guiding principle: performing an undo followed by a redo should leave the application in the same state. +This is crucial when undoing in multiplayer with multiple editors. +The important implication of this principle is that the redo operation should not simply be the original undone operation, because more might have changed meanwhile, but rather the reverse of the undo operation itself. + +Athens's model of time gives us a starting point for a model of undo: time only moves forward, and thus undoing an operation yield an operation that reverses the effects of the original one, to be applied in a point further in time. + +[Operations in Athens](doc/adr/0010-atomic-composite-grapth-operations.md) follow the principle of determinism, where the operation itself carries enough information for all participants to perform the same changes with no ambiguity over the same state. +This property enables us to derive an undo operation from a state together with any operation. + +We can reduce the participants to three types: the undo issuer, the server, and other users. +Although in theory any of these could construct the concrete undo operation, it is onerous for the server and other users to keep all possible past states that might be required for undos from anyone, for any point in time. + +The prime candidate to keep relevant states is the undo issuer themselves. +This participant can keep a list of states needed for their own set of undoable operations. +However, this operation is still optimistic in nature, and subject to conflicts. +It can be thought of as saying "this is how to undo the operation given what I know now". + +We can also transfer the burden of resolution to the server, achieving a "true" undo since the server itself is the source of truth for the RTC system. +The server would still need a way to keep or recall past states in order to resolve the undo operation. +This approach would require the client issuing the undo to somehow direct the server to undo the operation, and wait for the result, losing the optimistic nature of the change. + + +## Approach + +The primary motivation for undo/redo right now is the prevention of data loss in the moment, and keeping that goal in mind we decided to pursue the first candidate: issuer resolves undo operation. +Higher fidelity reconstruction of past application state, either for inspection or data recovery, is left to future time travel functionality. + +For each atomic operation over a db state, we can build a corresponding atomic or composite that undoes it in the context of that db state. +For composite operations, we can compute the undo atomic/composite for each atomic operation in it, maintaining order so that the operations make sense. + +Given that any arbitrary composite operation can be resolved to a composite that undoes it, we can repeat the process to arrive at a redo operation that restores the state prior to effecting the undo. +There's two subtle details to keep in mind in this description: the context for redo, and the restored state. + +In the same way that the original undo was resolved in the context of the db state prior to the operation to be undone, so must the redo be resolved in the context of the db state prior to the operation to be redone. +These two contexts are not the same, as performing an undo "moved" time forward, and operations from other users might have "moved" time forward as well. + +The state restored by redo is then not the result of applying the original operation. +It is the state before the undo operation was applied. +This is consistent with Figma's principle that undo-redo should result in the same state. + +This is the list of undo operations for each atomic: + - undo operation map + - block + - :block/new + - undo is :block/remove for uid + - :block/save + - undo is :block/save to previous str + - :block/open + - undo is :block/open with inverse bool + - :block/remove + - undo is + - paste of previous IR for block uid in previous position + - :block/save for all the ref in str that were replaced in remove + - :block/move + - undo is move to previous position + - page + - :page/new + - undo is :page/remove for title + - :page/rename + - undo is :page/rename to previous title + - :page/remove + - undo is + - is paste of previous IR for title + - :block/save for every ref stripped by remove + - :shortcut/new + shortcut/move to previous position if any + - :page/merge + - undo is + - :page/new to previous title + - :shortcut/new + shortcut/move to previous position if any + - :block/move to all moved blocks + - :block/save to previous string for all blocks affected by rename + - shortcut + - :shortcut/new + - undo is :shortcut/remove + - :shortcut/remove + - undo is + - :shortcut/new + - :shortcut/move to previous position + - :shortcut/move + - undo is shortcut/move to previous position + +Each client will hold the last X operations they performed, and the database state they were performed over. +The undo operation is computed only when undo is triggered, not ahead of time. +The saved db state should be updated each time the operation order changes and the optimistic state suffers a rollback. + +Since undo operations can trivially generate much larger operations than the original, we must pay special attention to payload limits in the client and server. +We know some of these limits right now but do not enforce them, so we need to start enforcing them for undo/redo to work reliably. +We can also warn users that some operations cannot be undone according to some heuristic (e.g. number of deleted blocks). + +Similarly to limits, undo also puts extra stress on the resolution error cases since it will trivially generate uncommon situations. +We must make sure that invariants are enforced on resolution. +Examples include: cannot rename to an existing page, cannot rename a page that does not exist, etc. + +A few of the undo operations rely on a view of the current data via IR (internal representation), which is then converted to a set of operations that create that structure. +Undo again adds extra stress to this functionality via enhanced usage. +We know it's possible to end up with unlinked refs from the order of block creation. +We can address that issue by creating all blocks before adding their content. + + +## Insights from MVP + +- what block should be focused after undo/redo? +- should block/open have undo? +- what are undoable scenenarios and how do we present them to the user? +- what limits do we want to enforce? +- how do we present scenarios that won't allow undo to the user? +- what are the invariants we need to check on op and undo op resolution? +- repeating undo/redo over an operation results in an ever increasing operation due to nested composites +- clients with partial state, derived from partial loads, cannot compute the full undo operation since it doesn't have all the data. + In that case it can compute a partial undo, and ask the server to compute the full version. + Another strategy is to have the partial load client pull all the data necessary for the operation. +- When can optimize the operations in undo by flattening the list and analysing it for redundancy (e.g. block/save followed by block/delete). + This can help save IO, and we already know that our performance due to IO is suffering. + This analysis would need to fully understand the causality chain between operations in order to not introduce bugs over valid composites. + A better place to effect this optimization is in the transaction resolver itself, as an execution planner. + This would also reduce, or even eliminate, the need for iterative resolution. +- It's not actually mandatory to update the saved db state on client optimistic rollbacks. + The difference here is a semantic one: if we update we say undos show use the most up to date data, if we don't we say they should use the original data. +- Contiguous moves are problematic to undo, because of how they implicitely have a grouping together with how they resolve back their previous position. + We currently have a "forward" bias, where we prefer the :first position if available, followed by :after. + But while undoing contiguous moves this fails, because undoing reverts the order of operations, and resolves each position relative to the previous block, which is not yet moved, and results in all blocks but the first staying in place. + There's a range of approaches to this issue: static analysis, changing the bias, encoding contiguous ranges, failing the undo. + For now we chose to restore the relative order of contiguous moves, after reversing all operations. + This is not correct for the general case, but it might be sufficient for all the cases we care about, especially since we have decided that undo is not time travel. + +## Decision + + +## Consequences + + +## Further work diff --git a/doc/adr/0022-multi-platform-client.md b/doc/adr/0022-multi-platform-client.md new file mode 100644 index 0000000000..ac6f47e061 --- /dev/null +++ b/doc/adr/0022-multi-platform-client.md @@ -0,0 +1,81 @@ +# 22. Multi-platform Client + +Date: 2021-08-20 + + +## Status + +Pending + + +## Context + +The Athens client is built for two platforms, Electron and Web, using the same compilation unit. +This is possible in CLJS due to the dynamic nature of using Electron dependencies via `js/require`, allowing compilation to finish successfully. + +It's common enough for a `js/require` to be included as a toplevel form though, leading the web app to crash on load. +This crash happens because the `js/require` variable is not defined on a web browser, thus leading to a "variable is not defined" error. + +The distinction between what's web specific and what's electron specific is not very clear in the code base, facilitating developer mistakes. +Ideally, platform specific code and abstractions would be isolated from non-platform ones. + +From a capability point of view, the web platform is mostly a subset of the electron platform. +Electron clients can do everything the web clients can do, and more. +This isn't strictly correct as the Electron client is bound to a Chromium browser, and web clients can run on other browsers and thus have different capabilities. +But we are primarily concerned with Chrome/Chromium as the main client here. + + +## Proposals + +### Platform specific capabilities + +When there's a mismatch between capabilities provided by the environment and the capabilities the application is expecting at runtime, an error is thrown. + +We can move some of the runtime errors to compile-time errors by using either Shadow-CLJS [Reader Conditionals](https://shadow-cljs.github.io/docs/UsersGuide.html#_conditional_reading) or [Custom Resolvers](https://shadow-cljs.github.io/docs/UsersGuide.html#js-resolve). +These features allow for conditionally loading of clj and js modules. +Under this approach the compilation would fail when trying to make use platform specific modules. +Failures of this type are usually easy to trace because of the detail of compiler errors. +But use of indirection, such as re-frame handler registration, can defer the error back to runtime. + +We can keep the errors at runtime by using Clojure conditional expressions, optionally with [Closure Defines](https://shadow-cljs.github.io/docs/UsersGuide.html#_conditional_reading). +The latter allows for improved optimizations of the size of compiled code. +We can also improve the quality of errors by providing our own descriptive error messages on else side of the conditional, such that usage of platform specific functionality outside that platform is signalled clearly. +Under this approach there will be no compile-time platform related error detection. + + +### Seam between app functionality and platform functionality + +It is sensible to confine platform specific functionality to namespaces (e.g. `athens.platform.electron`). +This provides guidance to both implementer and reviewer of how platform features are used in the rest of the application. + +We can further this separation by choosing only a subset of app constructs that needs to make use of platform features. +The most obvious construct right now is the Database. +Proposed initial types would be in-memory, local, self-hosted. +It's possible we may also want self-hosted graphs to store data locally, but at this moment it's not clear how and why, and we could always add more types. +Under this model specific database types define how they should be stored, and the app only uses generic interfaces to interact with them. + +We could also introduce an additional construct that defines local graph storage IO. +For Electron this would be disk, and for Web this would be one of the persistent storage layers (local storage, indexeddb, service worker cache, others). +This can get further complicated because not all storage provides the same capabilities with regards to notifications and atomicity of operations. +Under this model databases that require local storage could only be used if such storage was available. + + +### Future functionality + +The current pain point is database storage mechanisms, but it is likely we will have other functionality in the future that is only applicable to one client type. +It is hard to put forward a general enough model that would support any client specific capability, instead we should focus on keeping the door open for changing our model later on. + + +## Decision + +We decided to use Shadow-CLJS's Reader Conditionals with a special key for electron builds, together with a single namespace (`athens.electron.utils`) to contain Electron specific `js/require`. + + +### Implementation + +TBD + + +## Consequences + +TDB diff --git a/doc/adr/0023-frontend-perf-mon-events-failure.dot b/doc/adr/0023-frontend-perf-mon-events-failure.dot new file mode 100644 index 0000000000..bcc23929f9 --- /dev/null +++ b/doc/adr/0023-frontend-perf-mon-events-failure.dot @@ -0,0 +1,37 @@ +digraph re_frame_events_fail { + label = "re-frame events monitoring: failure scenario"; + + subgraph cluster_sentry { + label = "Sentry"; + sentry_tx_start [label = "Start TX"]; + sentry_tx_stop [label = "Finish TX"]; + } + + subgraph cluster_async { + label = "re-frame-async-flow" + rf_async_start [label = "re-frame-async-flow-start"]; + rf_async_update [label = "re-frame-async-flow-update"]; + rf_async_stop [label = "re-frame-async-flow-stop"]; + } + + subgraph cluster_events_fx { + label = "re-frame events and fx handler"; + + rf_event [label = "re-frame event"]; + + do_fx1 [label = "do-fx-1"]; + do_fxn [label = "do-fx-n"]; + + // ok_fx1 [label = "success-fx-1"]; + fail_fx1 [label = "fail-fx-1"]; + + ok_fxn [label = "success-fx-n"]; + } + + rf_event -> sentry_tx_start [label = "1"]; + rf_event -> rf_async_start [label = "2"]; + rf_event -> do_fx1 [label = "3"]; + do_fx1 -> fail_fx1 [label = "4"; color = red]; + fail_fx1 -> rf_async_stop [label = "5"]; + rf_async_stop -> sentry_tx_stop [label = "6"]; +} diff --git a/doc/adr/0023-frontend-perf-mon-events-failure.png b/doc/adr/0023-frontend-perf-mon-events-failure.png new file mode 100644 index 0000000000..d99e3c1e87 Binary files /dev/null and b/doc/adr/0023-frontend-perf-mon-events-failure.png differ diff --git a/doc/adr/0023-frontend-perf-mon-events-success.dot b/doc/adr/0023-frontend-perf-mon-events-success.dot new file mode 100644 index 0000000000..8dbff5eaaf --- /dev/null +++ b/doc/adr/0023-frontend-perf-mon-events-success.dot @@ -0,0 +1,40 @@ +digraph re_frame_events_success { + label = "re-frame events monitoring: success scenario"; + + subgraph cluster_sentry { + label = "Sentry"; + sentry_tx_start [label = "Start TX"]; + sentry_tx_stop [label = "Finish TX"]; + } + + subgraph cluster_async { + label = "re-frame-async-flow" + rf_async_start [label = "re-frame-async-flow-start"]; + rf_async_update [label = "re-frame-async-flow-update"]; + rf_async_stop [label = "re-frame-async-flow-stop"]; + } + + subgraph cluster_events_fx { + label = "re-frame events and fx handler"; + + rf_event [label = "re-frame event"]; + + do_fx1 [label = "do-fx-1"]; + do_fxn [label = "do-fx-n"]; + + ok_fx1 [label = "success-fx-1"]; + // fail_fx1 [label = "fail-fx-1"]; + + ok_fxn [label = "success-fx-n"]; + } + + rf_event -> sentry_tx_start [label = "1"]; + rf_event -> rf_async_start [label = "2"]; + rf_event -> do_fx1 [label = "3"]; + do_fx1 -> ok_fx1 [label = "4"; color = green]; + ok_fx1 -> rf_async_update [label = "5"]; + rf_async_update -> do_fxn [label = "6"]; + do_fxn -> ok_fxn [label = "7"]; + ok_fxn -> rf_async_stop [label = "8"]; + rf_async_stop -> sentry_tx_stop [label = "9"]; +} diff --git a/doc/adr/0023-frontend-perf-mon-events-success.png b/doc/adr/0023-frontend-perf-mon-events-success.png new file mode 100644 index 0000000000..afb586508d Binary files /dev/null and b/doc/adr/0023-frontend-perf-mon-events-success.png differ diff --git a/doc/adr/0023-frontend-performance-monitoring.md b/doc/adr/0023-frontend-performance-monitoring.md new file mode 100644 index 0000000000..7c3e721c89 --- /dev/null +++ b/doc/adr/0023-frontend-performance-monitoring.md @@ -0,0 +1,113 @@ +# 23. Frontend Performance Monitoring + +Date: 2022-03-02 + +## Status + +Proposed + +## Context + +After implementing Collaborative Knowledge Graphs, our Graphs started growing very fast, +which exposed all sorts of performance problems. + +To solve the problem in a sustainable way, we want to measure and report our performance. + +While we know that both Backend and Frontend experience performance problems, +we've decided to first tackle Frontend performance, because it's experienced by all +users, and everytime a user opens and writes to the app. + + +## Approach + +We've decided to capture performance monitoring data in [[Sentry]] for 2 reasons: +- It allows for tracking *spans* (portions) of transactions, not only transactions +- It will allow us to correlate backend performance with frontend performance + +We have 3 groups of operations that cost us time on the frontend: +- [[re-frame]] event processing, including effects +- Rendering +- [[DataScript]] updates and reads (those are usually part of previous 1) + +Description of the above follows. + +### Monitoring (([[re-frame]] event processing, including effects)) + +The way [[re-frame]] events are advised to be implemented is to return data that represents further processing, +which usually takes a fraction of total event processing time. + +Here's successful async event processing: +![](0023-frontend-perf-mon-events-success.png) + +Here's failure async event processing: +![](0023-frontend-perf-mon-events-failure.png) + + +### Monitoring ((Rendering)) + +We've tried attacking the problem of measuring Rendering performance with [[HOC]] (Higher Order Component). +This leverages [[React.js]]'s ability to use lifecycle hooks to measure when a component is setup and when it's finished rendering, +which allows us to capture the time it took to render. + + +### Monitoring (([[DataScript]] updates and reads (those are usually part of previous 1))) + +We want to monitor timing of all [[DataScript]] usage because it's our data access layer, which is usually where apps spend a lot of time. +To do so we've decided to go with a `defn` [macro](https://blog.klipse.tech/clojure/2019/03/08/spec-custom-defn.html#args-of-defn-macro). + +We did consider using a wrapper macro, which would be a simpler macro to maintain, +but it required all call sites to maintain the wrapping, which adds a lot of accidental complexity. + + +## Insights from MVP + +### Implicit transactions + +These are spans that get automatically upgraded to Sentry TXs, because there wasn't a TX already present. + +We can't implicitly capture transactions. We have to be explicit, because of Sentry's TX quota: +- We've been automatically promoting Sentry spans that don't have running Sentry TX to TX itself. +- This explodes the amount of TXs reported and goes over our quota. +- This needs to be reduced significantly, or totally. + + +### Potential followup: Profiler API + +[[React.js]] offers a Profiler API, also using a [[HOC]] approach, with the [onRender Callback](https://reactjs.org/docs/profiler.html#onrender-callback) that handles rendering information. +However, it's not clear how we could integrate it with Sentry, because Sentry measures times itself, +while the Profiler API offers summary of execution times. + +Profiler API is by default turned off in production builds. + +Here is how to enable it in production builds https://gist.github.com/bvaughn/25e6233aeb1b4f0cdb8d8366e54a3977. + + +### Managing Sentry's TXs & spans ain't easy, but it's necessary + +In Reagent everything is event-driven, pretending like it's asynchronous model, +but we run on JS, which is single-thread. + +To avoid needing to pass Sentry TXs & Spans, we need make them omnipresent. +This is why we need to manage them ourselves. + +Otherwise, we'd have to pass Sentry TX and Span as arguments to every fn call that we want to potentially monitor. + + +### Surprising insights + +Having deep perf monitoring allows us to discover performance improvements we have not been looking for, +like the fact that `block-nil-eater` middleware is constant overhead. +In hindsight this is obvious, but nobody suspected it. + + +## Decision + +We'll continue monitoring frontend performance. + +We've identified [[Frontend Performance Monitoring]] to be ((Musts | Expected needs)) as in [[Kano Framework]], +which means we can't suck at it, but it also makes little sense to be extraordinary at it. + +## Consequences + +We get to see how Athens performs for end users. +And we get to see that continuously. diff --git a/doc/adr/0024-reactive-datascript.md b/doc/adr/0024-reactive-datascript.md new file mode 100644 index 0000000000..475c75e430 --- /dev/null +++ b/doc/adr/0024-reactive-datascript.md @@ -0,0 +1,52 @@ +# 24. Reactive DataScript + +Date: 2022-03-03 + + +## Status + +Implemented + + +## Context + +Athens uses [Posh](https://github.com/denistakeda/posh) to trigger changes in Reagent components when relevant DataScript data changes. + +[Frontend Performance Monitoring](0023-frontend-performance-monitoring.md) revealed a significant amount of time was spent inside Posh post-transaction processing, which prompted us to look deeper into our usage of Posh to determine if it was possible to improve performance. + +We found that a number of issues: +1. our usage of `datascript.core/reset-conn!` to change database content was very slow when used together with Posh reactions, because it created a very large transaction report that needed to be analysed. +2. many of our queries and pulls looked for much more data than necessary, leading to extra analysis and renders +3. it was very hard to know if a given component was using a reactive pull or query, since `reagent/atom` usage anywhere in a function call stack will register a reaction onto a component + + +## Decision + +We added the `athens.reactive` namespace to function as a single access point to reactive datascript functions. +we moved all instances of data access functions that are meant to be reactive to this namespace, and added functions to inspect the watchers. + +This namespace is meant to address issue #3 by convention: you should be explicitely looking for a reactive function to get one. + +All functions gathered in this namespace were reviewed for scope, reducing it as much as possible to address issue #2. +`get-reactive-node-document` and `get-reactive-block-document` were converted into non-recursive versions to limit the scope of renders, and block components now watch only their direct children. + +Issue #1 was addressed by pausing Posh watchers while resetting the connection. +Posh itself does not contain functions to stop or pause watch, but by inspecting local state we found that watching a different datascript connection will stop watching the previous one. +This observation allowed us to pause watchers by watching an empty connection, reset connection, and then watch the original connection again. + +We also employed another mechanism to reduce the impact of watchers: when loading a different database, the loading component is as top-most as possible and there is no other component under it. +By reducing the number of components on screen we reduce the number of component that might be watching state at this time. + +Monitoring allowed us to validate that these changes did indeed improve performance, and will allow us to observe if that changes. + + +## Consequences + +Developers will have to call the `athens.reactive` namespace explicitely to create or reference functions that are reactive. + +PR reviewers will need to subject PRs that use this namespace or `posh.reagent` to extra scrutiny to prevent performance regressions. + +Developers will have to manually add and remove watchers. + +Boot and large pages are much faster. + diff --git a/doc/adr/0025-versioning.md b/doc/adr/0025-versioning.md new file mode 100644 index 0000000000..f0e957bad8 --- /dev/null +++ b/doc/adr/0025-versioning.md @@ -0,0 +1,49 @@ +# Versioning + +## Version Numbers + +Athens uses [Semver](https://semver.org/). +We use the `alpha`, `beta`, `rc` labels for pre-releases. +Large enough feature changes will increment the major number. + +We place a high premium on backwards compatibility. +You should always be able to go from one version to a higher stable version without losing data. +We will migrate your data automatically when there's a breaking change. + +Pre-releases aren't backwards compatible, but we try to not break anything between them because we use them ourselves. + +We don't support going from a higher version to a lower version without data or functionality loss though. +The best we can guarantee here is that we will try to identify this happens, and fail gracefully. + + +## Support + +Our support strategy is chosen to match our current development manpower. + +New features happen on the latest versions. + +Older versions get critical bug fixes, but no new features. +We consider a bug fix to be critical if Athens won't work at all without it. + + +## Release artifacts + +Releases are automatically created on https://github.com/athensresearch/athens/releases. +Pre-releases are tagged in front of the version name. + +Each release contains the major parts of Athens: +- desktop client +- web client +- server + +We deploy the web client automatically to the following domains: +- latest release at https://web.athensresearch.org +- latest pre-release at https://beta.athensresearch.org +- latest development at https://dev.athensresearch.org + +We deploy development web clients on GitHub PRs. +A link to the development deploy will show up automatically as a comment on the PR. + +The desktop client will auto update in the background and notify users that there's an update. +Releases will update to the next higher version number, ignoring pre-releases if you're not on a pre-release. +The web client will auto-update on refresh for its domain. diff --git a/doc/adr/0026-properties.md b/doc/adr/0026-properties.md new file mode 100644 index 0000000000..705a092330 --- /dev/null +++ b/doc/adr/0026-properties.md @@ -0,0 +1,142 @@ +# Properties + +Properties are a new type of parent-child relationship for blocks. + +Currently, blocks can have children: + +```md +- parent block + - first child + - second child +``` + +Block children behave as an ordered list of blocks. +Order numbers are not visible in the outline, and are implicit in the listing of children. + +You can think of `first child` as having order number 1, and `second child` as having order number 2. +You cannot have two children share the same order number - if one appears after the other, then the latter has a higher order number. + +Properties are similar to children in that they are blocks with a parent, but instead of an order number they possess a key (also known as a name). + +```md +- parent block + : a key - a prop + : another key - another prop + - child of a another prop + - first child + - second child +``` + +The block `a prop` is a property of `parent block` under the key `a key`. +Property keys are page titles, and a block cannot have duplicate keys. + +Property blocks are shown in the outline as prefixed with `:` followed by the key. +Properties differ from children in that they are ordered alphabetically by key name, cannot be reordered, and appear before children. + +Property blocks are otherwise the same as any other block. +You can edit and reference them, add children and properties under them, move them to other places. + +You can convert any given block into a property by typing `::` and searching or creating a new property key. +Pressing backspace at the start of a property block will remove the key, turning it into a child block. + +Clicking on the key will take you to the page for that key. +Pages that are used as properties have a `Linked properties` section at the end. + + +## Uses + +### Labelling + +You can label well-known pieces of information on your graph using properties. + +```md +[[Deep Work]] + : Author - [[Cal Newport]] + : Highlights - + - Clarity about what matters provides clarity about what does not. + - As Nietzsche said: “It is only ideas gained from walking that have any worth. + - Stopped at page 120 on 2022-05-20 + - Stopped at page 189 on 2022-05-24 +``` + +Using properties instead of children for labelling has the following advantages: +- they show up at the top +- they show up always in the same order +- you can look up all blocks with the property on the property page + +### Named relationships + +Using refs you can establish a relationship between a block and a page or a block: + +```md +- Deep Work [[Cal Newport]] +``` + +Let's call this block `Deep Work`. +You can say that `Deep Work` is associated with `Cal Newport`, but you cannot say much more than that. +You can think of this as a `['Deep Work' 'Cal Newport']` tuple. + +Using properties you can name the relationship: + +```md +- Deep Work + : Author - [[Cal Newport]] +``` + +This example matches the `['Deep Work' 'Author' 'Cal Newport']` triple. + +Named associations are a powerful and expressive way to think about data. +Triples are the atomic data of subject-predicate-object databases. + +### On-graph data storage + +Athens' data model is rich enough to store collection-based application data: +- parent-children relationships model trees +- children model vectors +- properties model maps + +This Athens document can be mapped to the following JSON data: + +```md +- + : value - a string + : vector - + - 1 + - 2 + - 3 + : map - + : key - value + : another key - another value + : nested data - + : level 1 - + : level 2 - + :level 3 - +``` +```json +{ + "value": "a string", + "vector": [ + "1", + "2", + "3" + ], + "map": { + "key": "value", + "another key": "another value" + }, + "nested data": { + "level 1": { + "level 2": { + "level 3": "" + } + } + } +} +``` + +The only primitive data type currently available is `string`, but since other data types can be serialised to string it is expressive enough. + +We plan to add support to more primitive data types like `number`, `ref`, `datetime`, `user`. + +Our own first-party features persist data on-graph data. +It has enabled us to prototype much faster. \ No newline at end of file diff --git a/doc/athens-1920.jpg b/doc/athens-1920.jpg deleted file mode 100644 index b5c256b4a3..0000000000 Binary files a/doc/athens-1920.jpg and /dev/null differ diff --git a/doc/athens-vs-roam-tech-stack.png b/doc/athens-vs-roam-tech-stack.png deleted file mode 100644 index 68c193c5d2..0000000000 Binary files a/doc/athens-vs-roam-tech-stack.png and /dev/null differ diff --git a/doc/debug-github-actions.md b/doc/debug-github-actions.md new file mode 100644 index 0000000000..f1a947dac5 --- /dev/null +++ b/doc/debug-github-actions.md @@ -0,0 +1,47 @@ +# How to debug github actions + +Sometimes things fail on github actions but not locally. +The best and fastest way to debug this is to try and setup a local environment that's similar to the github runner. + +This command will start a docker container in the background running ubuntu and with `~/work/athens` (change to yours) folder mounted on `/sandbox`: +``` +docker run -d -it -v ~/work/athens:/sandbox/ --name sandbox-container clojure:tools-deps /bin/sh +``` + +Now you can connect to it: +``` +docker attach sandbox-container +``` + +This will start `sh` inside the container. + +The `clojure:tools-deps` docker image has clojure, but doesn't have node, xfvb, or electron system deps, so we'll need to install it: +``` +apt-get update +apt-get install -y xvfb +apt-get install -y libgconf-2-4 libnss3 libatk-bridge2.0-0 libgdk-pixbuf2.0-0 libgtk-3-0 libgbm-dev libasound2 +apt-get install -y curl +curl -fsSL https://deb.nodesource.com/setup_16.x | bash - +apt-get install -y nodejs +npm install -g yarn +``` + +Change the code in `test/e2e/electron-test.ts` that says `if (process.env.CI)` to `if (true)` to force the CI only logic. + +Now you should be able to run project commands: +``` +cd /sandbox +yarn +yarn client:e2e +``` + +The `yarn client:e2e:only:verbose ` script is especially useful to see the verbose logs and figure out where e2e tests are stuck. + +Be aware that filesystem operations over docker mapped volumes are much slower, and `yarn` in particular will be very slow. + +When you're done you can remove this container: +``` +docker container rm sandbox-container +``` + + diff --git a/doc/glossary.md b/doc/glossary.md new file mode 100644 index 0000000000..f342cf59a7 --- /dev/null +++ b/doc/glossary.md @@ -0,0 +1,10 @@ +# Engineering Glossary + +* DB / Database + * rfdb - refers to re-frame's database. re-frame docs typically call re-frame db `[app-db](https://day8.github.io/re-frame/FAQs/Inspecting-app-db/)`. app-db should be used for non-persistent UI state. Examples: whether the left sidebar, right sidebar, or Athena are open or not. + * dsdb - refers to datascript's database. datascript docs typically call datascript db `[conn](https://github.com/tonsky/datascript#usage-examples)`. + * `:fs/` namespace - refers to all the operations for saving datascript to the filesystem. This is currently how local-only Athens is persisted. Athens reads and writes to filesystem via Electron, which exposes node.js libraries. + * local-storage + * `db-picker/all-dbs` - all dbs known to athens + * `db-picker/selected-db-id` - the id of the currently active db + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000000..0ba5224e4e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,67 @@ +# NB: Always use immutable tags (e.g. not latest etc) on docker refs. +# Any :latest tags on this file will be replaced with athens release version on release. + +version: "3.4" +services: + athens: + image: ghcr.io/athensresearch/athens:latest + # Uncomment the build sections if you want docker compose to build them locally. + # Note: when building locally on a M1 mac, the athens image seems to hang on connecting + # to fluree for no clear reason, but if you remove all memory flags on the + # script/docker-run-lan-party.sh script it doesn't hang anymore. Maybe an issue with + # docker on M1, unclear. + # build: + # context: . + # dockerfile: athens.dockerfile + restart: always + depends_on: + fluree: + condition: service_healthy + ports: + - 3010:3010 # Change this according to CONFIG_EDN. + volumes: + - ./athens-data/logs:/srv/athens/logs + - ./athens-data/datascript:/srv/athens/datascript + environment: + # Uses system env vars for settings if available. + # CONFIG_EDN is deep merged with the default config file. + - CONFIG_EDN=${CONFIG_EDN:-{}} + healthcheck: + test: curl -f localhost:3010/health-check + interval: 15s + timeout: 60s + retries: 10 + start_period: 15s + + + nginx: + image: ghcr.io/athensresearch/nginx:latest + # build: + # context: . + # dockerfile: nginx.dockerfile + restart: always + depends_on: + athens: + condition: service_healthy + ports: + - 80:80 + + fluree: + # Under default settings maximum recommended event size is 2mb. + # Can be increased via fdb-memory-reindex-max to up to 10mb. + image: fluree/ledger:1.0.0-beta17 + restart: always + ports: + - 8090:8090 + volumes: + - ./athens-data/fluree:/var/lib/fluree + healthcheck: + # TODO: this health check won't work after fluree/ledger:1.0.0-beta17 because + # the fluree images no longer contain curl because they use smaller docker base images. + # We need to find another way of doing it to update. Once we find it, we can also + # update our container to a smaller JDK version. + test: curl -f localhost:8090/fdb/health + interval: 15s + timeout: 30s + retries: 3 + start_period: 15s diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000000..8eaffc01a4 --- /dev/null +++ b/nginx.conf @@ -0,0 +1,25 @@ +events {} +http { + map $http_upgrade $connection_upgrade { + default upgrade; + '' close; + } + + upstream websocket { + server athens:3010; # Change the port based on config.edn + } + + server { + listen 80; + location / { + proxy_pass http://websocket; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Host $host; + proxy_connect_timeout 7d; + proxy_send_timeout 7d; + proxy_read_timeout 7d; + } + } +} diff --git a/nginx.dockerfile b/nginx.dockerfile new file mode 100644 index 0000000000..42288ac2d9 --- /dev/null +++ b/nginx.dockerfile @@ -0,0 +1,6 @@ +# nginx with a custom config +FROM nginx + +# Copy from local working directory +COPY ./nginx.conf /etc/nginx/nginx.conf + diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index 53c80475d1..0000000000 --- a/package-lock.json +++ /dev/null @@ -1,1307 +0,0 @@ -{ - "requires": true, - "lockfileVersion": 1, - "dependencies": { - "accepts": { - "version": "1.3.7", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.7.tgz", - "integrity": "sha512-Il80Qs2WjYlJIBNzNkK6KYqlVMTbZLXgHx2oT0pU/fjRHyEp+PEfEPY0R3WCwAGVOtauxh1hOxNgIf5bv7dQpA==", - "dev": true, - "requires": { - "mime-types": "~2.1.24", - "negotiator": "0.6.2" - } - }, - "after": { - "version": "0.8.2", - "resolved": "https://registry.npmjs.org/after/-/after-0.8.2.tgz", - "integrity": "sha1-/ts5T58OAqqXaOcCvaI7UF+ufh8=", - "dev": true - }, - "anymatch": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.1.tgz", - "integrity": "sha512-mM8522psRCqzV+6LhomX5wgp25YVibjh8Wj23I5RPkPppSVSjyKD2A2mBJmWGa+KN7f2D6LNh9jkBCeyLktzjg==", - "dev": true, - "requires": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - } - }, - "arraybuffer.slice": { - "version": "0.0.7", - "resolved": "https://registry.npmjs.org/arraybuffer.slice/-/arraybuffer.slice-0.0.7.tgz", - "integrity": "sha512-wGUIVQXuehL5TCqQun8OW81jGzAWycqzFF8lFp+GOM5BXLYj3bKNsYC4daB7n6XjCqxQA/qgTJ+8ANR3acjrog==", - "dev": true - }, - "async": { - "version": "2.6.3", - "resolved": "https://registry.npmjs.org/async/-/async-2.6.3.tgz", - "integrity": "sha512-zflvls11DCy+dQWzTW2dzuilv8Z5X/pjfmZOWba6TNIVDm+2UDaJmXSOXlasHKfNBs8oo3M0aT50fDEWfKZjXg==", - "dev": true, - "requires": { - "lodash": "^4.17.14" - } - }, - "async-limiter": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.1.tgz", - "integrity": "sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==", - "dev": true - }, - "backo2": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/backo2/-/backo2-1.0.2.tgz", - "integrity": "sha1-MasayLEpNjRj41s+u2n038+6eUc=", - "dev": true - }, - "balanced-match": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", - "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=", - "dev": true - }, - "base64-arraybuffer": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-0.1.5.tgz", - "integrity": "sha1-c5JncZI7Whl0etZmqlzUv5xunOg=", - "dev": true - }, - "base64id": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/base64id/-/base64id-1.0.0.tgz", - "integrity": "sha1-R2iMuZu2gE8OBtPnY7HDLlfY5rY=", - "dev": true - }, - "better-assert": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/better-assert/-/better-assert-1.0.2.tgz", - "integrity": "sha1-QIZrnhueC1W0gYlDEeaPr/rrxSI=", - "dev": true, - "requires": { - "callsite": "1.0.0" - } - }, - "binary-extensions": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.0.0.tgz", - "integrity": "sha512-Phlt0plgpIIBOGTT/ehfFnbNlfsDEiqmzE2KRXoX1bLIlir4X/MR+zSyBEkL05ffWgnRSf/DXv+WrUAVr93/ow==", - "dev": true - }, - "blob": { - "version": "0.0.5", - "resolved": "https://registry.npmjs.org/blob/-/blob-0.0.5.tgz", - "integrity": "sha512-gaqbzQPqOoamawKg0LGVd7SzLgXS+JH61oWprSLH+P+abTczqJbhTR8CmJ2u9/bUYNmHTGJx/UEmn6doAvvuig==", - "dev": true - }, - "bluebird": { - "version": "3.7.2", - "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", - "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", - "dev": true - }, - "body-parser": { - "version": "1.19.0", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.19.0.tgz", - "integrity": "sha512-dhEPs72UPbDnAQJ9ZKMNTP6ptJaionhP5cBb541nXPlW60Jepo9RV/a4fX4XWW9CuFNK22krhrj1+rgzifNCsw==", - "dev": true, - "requires": { - "bytes": "3.1.0", - "content-type": "~1.0.4", - "debug": "2.6.9", - "depd": "~1.1.2", - "http-errors": "1.7.2", - "iconv-lite": "0.4.24", - "on-finished": "~2.3.0", - "qs": "6.7.0", - "raw-body": "2.4.0", - "type-is": "~1.6.17" - } - }, - "brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "requires": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", - "dev": true, - "requires": { - "fill-range": "^7.0.1" - } - }, - "buffer-alloc": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/buffer-alloc/-/buffer-alloc-1.2.0.tgz", - "integrity": "sha512-CFsHQgjtW1UChdXgbyJGtnm+O/uLQeZdtbDo8mfUgYXCHSM1wgrVxXm6bSyrUuErEb+4sYVGCzASBRot7zyrow==", - "dev": true, - "requires": { - "buffer-alloc-unsafe": "^1.1.0", - "buffer-fill": "^1.0.0" - } - }, - "buffer-alloc-unsafe": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/buffer-alloc-unsafe/-/buffer-alloc-unsafe-1.1.0.tgz", - "integrity": "sha512-TEM2iMIEQdJ2yjPJoSIsldnleVaAk1oW3DBVUykyOLsEsFmEc9kn+SFFPz+gl54KQNxlDnAwCXosOS9Okx2xAg==", - "dev": true - }, - "buffer-fill": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/buffer-fill/-/buffer-fill-1.0.0.tgz", - "integrity": "sha1-+PeLdniYiO858gXNY39o5wISKyw=", - "dev": true - }, - "bytes": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.0.tgz", - "integrity": "sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg==", - "dev": true - }, - "callsite": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/callsite/-/callsite-1.0.0.tgz", - "integrity": "sha1-KAOY5dZkvXQDi28JBRU+borxvCA=", - "dev": true - }, - "chokidar": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.3.1.tgz", - "integrity": "sha512-4QYCEWOcK3OJrxwvyyAOxFuhpvOVCYkr33LPfFNBjAD/w3sEzWsp2BUOkI4l9bHvWioAd0rc6NlHUOEaWkTeqg==", - "dev": true, - "requires": { - "anymatch": "~3.1.1", - "braces": "~3.0.2", - "fsevents": "~2.1.2", - "glob-parent": "~5.1.0", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.3.0" - } - }, - "colors": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/colors/-/colors-1.4.0.tgz", - "integrity": "sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==", - "dev": true - }, - "component-bind": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/component-bind/-/component-bind-1.0.0.tgz", - "integrity": "sha1-AMYIq33Nk4l8AAllGx06jh5zu9E=", - "dev": true - }, - "component-emitter": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.2.1.tgz", - "integrity": "sha1-E3kY1teCg/ffemt8WmPhQOaUJeY=", - "dev": true - }, - "component-inherit": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/component-inherit/-/component-inherit-0.0.3.tgz", - "integrity": "sha1-ZF/ErfWLcrZJ1crmUTVhnbJv8UM=", - "dev": true - }, - "concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", - "dev": true - }, - "connect": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/connect/-/connect-3.7.0.tgz", - "integrity": "sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ==", - "dev": true, - "requires": { - "debug": "2.6.9", - "finalhandler": "1.1.2", - "parseurl": "~1.3.3", - "utils-merge": "1.0.1" - } - }, - "content-type": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.4.tgz", - "integrity": "sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==", - "dev": true - }, - "cookie": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.3.1.tgz", - "integrity": "sha1-5+Ch+e9DtMi6klxcWpboBtFoc7s=", - "dev": true - }, - "custom-event": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/custom-event/-/custom-event-1.0.1.tgz", - "integrity": "sha1-XQKkaFCt8bSjF5RqOSj8y1v9BCU=", - "dev": true - }, - "date-format": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/date-format/-/date-format-2.1.0.tgz", - "integrity": "sha512-bYQuGLeFxhkxNOF3rcMtiZxvCBAquGzZm6oWA1oZ0g2THUzivaRhv8uOhdr19LmoobSOLoIAxeUK2RdbM8IFTA==", - "dev": true - }, - "debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "requires": { - "ms": "2.0.0" - } - }, - "depd": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", - "integrity": "sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak=", - "dev": true - }, - "di": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/di/-/di-0.0.1.tgz", - "integrity": "sha1-gGZJMmzqp8qjMG112YXqJ0i6kTw=", - "dev": true - }, - "dom-serialize": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/dom-serialize/-/dom-serialize-2.2.1.tgz", - "integrity": "sha1-ViromZ9Evl6jB29UGdzVnrQ6yVs=", - "dev": true, - "requires": { - "custom-event": "~1.0.0", - "ent": "~2.2.0", - "extend": "^3.0.0", - "void-elements": "^2.0.0" - } - }, - "ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=", - "dev": true - }, - "encodeurl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=", - "dev": true - }, - "engine.io": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-3.2.1.tgz", - "integrity": "sha512-+VlKzHzMhaU+GsCIg4AoXF1UdDFjHHwMmMKqMJNDNLlUlejz58FCy4LBqB2YVJskHGYl06BatYWKP2TVdVXE5w==", - "dev": true, - "requires": { - "accepts": "~1.3.4", - "base64id": "1.0.0", - "cookie": "0.3.1", - "debug": "~3.1.0", - "engine.io-parser": "~2.1.0", - "ws": "~3.3.1" - }, - "dependencies": { - "debug": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", - "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", - "dev": true, - "requires": { - "ms": "2.0.0" - } - } - } - }, - "engine.io-client": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-3.2.1.tgz", - "integrity": "sha512-y5AbkytWeM4jQr7m/koQLc5AxpRKC1hEVUb/s1FUAWEJq5AzJJ4NLvzuKPuxtDi5Mq755WuDvZ6Iv2rXj4PTzw==", - "dev": true, - "requires": { - "component-emitter": "1.2.1", - "component-inherit": "0.0.3", - "debug": "~3.1.0", - "engine.io-parser": "~2.1.1", - "has-cors": "1.1.0", - "indexof": "0.0.1", - "parseqs": "0.0.5", - "parseuri": "0.0.5", - "ws": "~3.3.1", - "xmlhttprequest-ssl": "~1.5.4", - "yeast": "0.1.2" - }, - "dependencies": { - "debug": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", - "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", - "dev": true, - "requires": { - "ms": "2.0.0" - } - } - } - }, - "engine.io-parser": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-2.1.3.tgz", - "integrity": "sha512-6HXPre2O4Houl7c4g7Ic/XzPnHBvaEmN90vtRO9uLmwtRqQmTOw0QMevL1TOfL2Cpu1VzsaTmMotQgMdkzGkVA==", - "dev": true, - "requires": { - "after": "0.8.2", - "arraybuffer.slice": "~0.0.7", - "base64-arraybuffer": "0.1.5", - "blob": "0.0.5", - "has-binary2": "~1.0.2" - } - }, - "ent": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/ent/-/ent-2.2.0.tgz", - "integrity": "sha1-6WQhkyWiHQX0RGai9obtbOX13R0=", - "dev": true - }, - "escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=", - "dev": true - }, - "eventemitter3": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.0.tgz", - "integrity": "sha512-qerSRB0p+UDEssxTtm6EDKcE7W4OaoisfIMl4CngyEhjpYglocpNg6UEqCvemdGhosAsg4sO2dXJOdyBifPGCg==", - "dev": true - }, - "extend": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", - "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", - "dev": true - }, - "fill-range": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", - "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", - "dev": true, - "requires": { - "to-regex-range": "^5.0.1" - } - }, - "finalhandler": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz", - "integrity": "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==", - "dev": true, - "requires": { - "debug": "2.6.9", - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "on-finished": "~2.3.0", - "parseurl": "~1.3.3", - "statuses": "~1.5.0", - "unpipe": "~1.0.0" - } - }, - "flatted": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-2.0.2.tgz", - "integrity": "sha512-r5wGx7YeOwNWNlCA0wQ86zKyDLMQr+/RB8xy74M4hTphfmjlijTSSXGuH8rnvKZnfT9i+75zmd8jcKdMR4O6jA==", - "dev": true - }, - "follow-redirects": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.11.0.tgz", - "integrity": "sha512-KZm0V+ll8PfBrKwMzdo5D13b1bur9Iq9Zd/RMmAoQQcl2PxxFml8cxXPaaPYVbV0RjNjq1CU7zIzAOqtUPudmA==", - "dev": true, - "requires": { - "debug": "^3.0.0" - }, - "dependencies": { - "debug": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.6.tgz", - "integrity": "sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ==", - "dev": true, - "requires": { - "ms": "^2.1.1" - } - }, - "ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true - } - } - }, - "fs-extra": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", - "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", - "dev": true, - "requires": { - "graceful-fs": "^4.1.2", - "jsonfile": "^4.0.0", - "universalify": "^0.1.0" - } - }, - "fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", - "dev": true - }, - "fsevents": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.1.2.tgz", - "integrity": "sha512-R4wDiBwZ0KzpgOWetKDug1FZcYhqYnUYKtfZYt4mD5SBz76q0KR4Q9o7GIPamsVPGmW3EYPPJ0dOOjvx32ldZA==", - "dev": true, - "optional": true - }, - "glob": { - "version": "7.1.6", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", - "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", - "dev": true, - "requires": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.0.4", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - } - }, - "glob-parent": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.1.tgz", - "integrity": "sha512-FnI+VGOpnlGHWZxthPGR+QhR78fuiK0sNLkHQv+bL9fQi57lNNdquIbna/WrfROrolq8GK5Ek6BiMwqL/voRYQ==", - "dev": true, - "requires": { - "is-glob": "^4.0.1" - } - }, - "graceful-fs": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.3.tgz", - "integrity": "sha512-a30VEBm4PEdx1dRB7MFK7BejejvCvBronbLjht+sHuGYj8PHs7M/5Z+rt5lw551vZ7yfTCj4Vuyy3mSJytDWRQ==", - "dev": true - }, - "has-binary2": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has-binary2/-/has-binary2-1.0.3.tgz", - "integrity": "sha512-G1LWKhDSvhGeAQ8mPVQlqNcOB2sJdwATtZKl2pDKKHfpf/rYj24lkinxf69blJbnsvtqqNU+L3SL50vzZhXOnw==", - "dev": true, - "requires": { - "isarray": "2.0.1" - } - }, - "has-cors": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-cors/-/has-cors-1.1.0.tgz", - "integrity": "sha1-XkdHk/fqmEPRu5nCPu9J/xJv/zk=", - "dev": true - }, - "highlight.js": { - "version": "9.15.10", - "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-9.15.10.tgz", - "integrity": "sha512-RoV7OkQm0T3os3Dd2VHLNMoaoDVx77Wygln3n9l5YV172XonWG6rgQD3XnF/BuFFZw9A0TJgmMSO8FEWQgvcXw==" - }, - "http-errors": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.7.2.tgz", - "integrity": "sha512-uUQBt3H/cSIVfch6i1EuPNy/YsRSOUBXTVfZ+yR7Zjez3qjBz6i9+i4zjNaoqcoFVI4lQJ5plg63TvGfRSDCRg==", - "dev": true, - "requires": { - "depd": "~1.1.2", - "inherits": "2.0.3", - "setprototypeof": "1.1.1", - "statuses": ">= 1.5.0 < 2", - "toidentifier": "1.0.0" - } - }, - "http-proxy": { - "version": "1.18.0", - "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.0.tgz", - "integrity": "sha512-84I2iJM/n1d4Hdgc6y2+qY5mDaz2PUVjlg9znE9byl+q0uC3DeByqBGReQu5tpLK0TAqTIXScRUV+dg7+bUPpQ==", - "dev": true, - "requires": { - "eventemitter3": "^4.0.0", - "follow-redirects": "^1.0.0", - "requires-port": "^1.0.0" - } - }, - "iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "dev": true, - "requires": { - "safer-buffer": ">= 2.1.2 < 3" - } - }, - "indexof": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/indexof/-/indexof-0.0.1.tgz", - "integrity": "sha1-gtwzbSMrkGIXnQWrMpOmYFn9Q10=", - "dev": true - }, - "inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", - "dev": true, - "requires": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "inherits": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", - "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=", - "dev": true - }, - "is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dev": true, - "requires": { - "binary-extensions": "^2.0.0" - } - }, - "is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=", - "dev": true - }, - "is-glob": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.1.tgz", - "integrity": "sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg==", - "dev": true, - "requires": { - "is-extglob": "^2.1.1" - } - }, - "is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true - }, - "isarray": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.1.tgz", - "integrity": "sha1-o32U7ZzaLVmGXJ92/llu4fM4dB4=", - "dev": true - }, - "isbinaryfile": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-3.0.3.tgz", - "integrity": "sha512-8cJBL5tTd2OS0dM4jz07wQd5g0dCCqIhUxPIGtZfa5L6hWlvV5MHTITy/DBAsF+Oe2LS1X3krBUhNwaGUWpWxw==", - "dev": true, - "requires": { - "buffer-alloc": "^1.2.0" - } - }, - "isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=", - "dev": true - }, - "js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" - }, - "jsonfile": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", - "integrity": "sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss=", - "dev": true, - "requires": { - "graceful-fs": "^4.1.6" - } - }, - "karma": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/karma/-/karma-4.4.1.tgz", - "integrity": "sha512-L5SIaXEYqzrh6b1wqYC42tNsFMx2PWuxky84pK9coK09MvmL7mxii3G3bZBh/0rvD27lqDd0le9jyhzvwif73A==", - "dev": true, - "requires": { - "bluebird": "^3.3.0", - "body-parser": "^1.16.1", - "braces": "^3.0.2", - "chokidar": "^3.0.0", - "colors": "^1.1.0", - "connect": "^3.6.0", - "di": "^0.0.1", - "dom-serialize": "^2.2.0", - "flatted": "^2.0.0", - "glob": "^7.1.1", - "graceful-fs": "^4.1.2", - "http-proxy": "^1.13.0", - "isbinaryfile": "^3.0.0", - "lodash": "^4.17.14", - "log4js": "^4.0.0", - "mime": "^2.3.1", - "minimatch": "^3.0.2", - "optimist": "^0.6.1", - "qjobs": "^1.1.4", - "range-parser": "^1.2.0", - "rimraf": "^2.6.0", - "safe-buffer": "^5.0.1", - "socket.io": "2.1.1", - "source-map": "^0.6.1", - "tmp": "0.0.33", - "useragent": "2.3.0" - } - }, - "karma-chrome-launcher": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/karma-chrome-launcher/-/karma-chrome-launcher-3.1.0.tgz", - "integrity": "sha512-3dPs/n7vgz1rxxtynpzZTvb9y/GIaW8xjAwcIGttLbycqoFtI7yo1NGnQi6oFTherRE+GIhCAHZC4vEqWGhNvg==", - "dev": true, - "requires": { - "which": "^1.2.1" - } - }, - "karma-cljs-test": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/karma-cljs-test/-/karma-cljs-test-0.1.0.tgz", - "integrity": "sha1-y4YF7w4R+ab20o9Wul298m84mSM=", - "dev": true - }, - "karma-junit-reporter": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/karma-junit-reporter/-/karma-junit-reporter-2.0.1.tgz", - "integrity": "sha512-VtcGfE0JE4OE1wn0LK8xxDKaTP7slN8DO3I+4xg6gAi1IoAHAXOJ1V9G/y45Xg6sxdxPOR3THCFtDlAfBo9Afw==", - "dev": true, - "requires": { - "path-is-absolute": "^1.0.0", - "xmlbuilder": "12.0.0" - } - }, - "lodash": { - "version": "4.17.15", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz", - "integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==", - "dev": true - }, - "log4js": { - "version": "4.5.1", - "resolved": "https://registry.npmjs.org/log4js/-/log4js-4.5.1.tgz", - "integrity": "sha512-EEEgFcE9bLgaYUKuozyFfytQM2wDHtXn4tAN41pkaxpNjAykv11GVdeI4tHtmPWW4Xrgh9R/2d7XYghDVjbKKw==", - "dev": true, - "requires": { - "date-format": "^2.0.0", - "debug": "^4.1.1", - "flatted": "^2.0.0", - "rfdc": "^1.1.4", - "streamroller": "^1.0.6" - }, - "dependencies": { - "debug": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", - "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", - "dev": true, - "requires": { - "ms": "^2.1.1" - } - }, - "ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true - } - } - }, - "loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "requires": { - "js-tokens": "^3.0.0 || ^4.0.0" - } - }, - "lru-cache": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.1.5.tgz", - "integrity": "sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g==", - "dev": true, - "requires": { - "pseudomap": "^1.0.2", - "yallist": "^2.1.2" - } - }, - "media-typer": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=", - "dev": true - }, - "mime": { - "version": "2.4.4", - "resolved": "https://registry.npmjs.org/mime/-/mime-2.4.4.tgz", - "integrity": "sha512-LRxmNwziLPT828z+4YkNzloCFC2YM4wrB99k+AV5ZbEyfGNWfG8SO1FUXLmLDBSo89NrJZ4DIWeLjy1CHGhMGA==", - "dev": true - }, - "mime-db": { - "version": "1.43.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.43.0.tgz", - "integrity": "sha512-+5dsGEEovYbT8UY9yD7eE4XTc4UwJ1jBYlgaQQF38ENsKR3wj/8q8RFZrF9WIZpB2V1ArTVFUva8sAul1NzRzQ==", - "dev": true - }, - "mime-types": { - "version": "2.1.26", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.26.tgz", - "integrity": "sha512-01paPWYgLrkqAyrlDorC1uDwl2p3qZT7yl806vW7DvDoxwXi46jsjFbg+WdwotBIk6/MbEhO/dh5aZ5sNj/dWQ==", - "dev": true, - "requires": { - "mime-db": "1.43.0" - } - }, - "minimatch": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", - "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", - "dev": true, - "requires": { - "brace-expansion": "^1.1.7" - } - }, - "minimist": { - "version": "0.0.10", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.10.tgz", - "integrity": "sha1-3j+YVD2/lggr5IrRoMfNqDYwHc8=", - "dev": true - }, - "ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", - "dev": true - }, - "negotiator": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.2.tgz", - "integrity": "sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw==", - "dev": true - }, - "normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true - }, - "object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=" - }, - "object-component": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/object-component/-/object-component-0.0.3.tgz", - "integrity": "sha1-8MaapQ78lbhmwYb0AKM3acsvEpE=", - "dev": true - }, - "on-finished": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", - "integrity": "sha1-IPEzZIGwg811M3mSoWlxqi2QaUc=", - "dev": true, - "requires": { - "ee-first": "1.1.1" - } - }, - "once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", - "dev": true, - "requires": { - "wrappy": "1" - } - }, - "optimist": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/optimist/-/optimist-0.6.1.tgz", - "integrity": "sha1-2j6nRob6IaGaERwybpDrFaAZZoY=", - "dev": true, - "requires": { - "minimist": "~0.0.1", - "wordwrap": "~0.0.2" - } - }, - "os-tmpdir": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", - "integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=", - "dev": true - }, - "parseqs": { - "version": "0.0.5", - "resolved": "https://registry.npmjs.org/parseqs/-/parseqs-0.0.5.tgz", - "integrity": "sha1-1SCKNzjkZ2bikbouoXNoSSGouJ0=", - "dev": true, - "requires": { - "better-assert": "~1.0.0" - } - }, - "parseuri": { - "version": "0.0.5", - "resolved": "https://registry.npmjs.org/parseuri/-/parseuri-0.0.5.tgz", - "integrity": "sha1-gCBKUNTbt3m/3G6+J3jZDkvOMgo=", - "dev": true, - "requires": { - "better-assert": "~1.0.0" - } - }, - "parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "dev": true - }, - "path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", - "dev": true - }, - "picomatch": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.2.2.tgz", - "integrity": "sha512-q0M/9eZHzmr0AulXyPwNfZjtwZ/RBZlbN3K3CErVrk50T2ASYI7Bye0EvekFY3IP1Nt2DHu0re+V2ZHIpMkuWg==", - "dev": true - }, - "prop-types": { - "version": "15.7.2", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.7.2.tgz", - "integrity": "sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ==", - "requires": { - "loose-envify": "^1.4.0", - "object-assign": "^4.1.1", - "react-is": "^16.8.1" - } - }, - "pseudomap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz", - "integrity": "sha1-8FKijacOYYkX7wqKw0wa5aaChrM=", - "dev": true - }, - "qjobs": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/qjobs/-/qjobs-1.2.0.tgz", - "integrity": "sha512-8YOJEHtxpySA3fFDyCRxA+UUV+fA+rTWnuWvylOK/NCjhY+b4ocCtmu8TtsWb+mYeU+GCHf/S66KZF/AsteKHg==", - "dev": true - }, - "qs": { - "version": "6.7.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.7.0.tgz", - "integrity": "sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ==", - "dev": true - }, - "range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "dev": true - }, - "raw-body": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.4.0.tgz", - "integrity": "sha512-4Oz8DUIwdvoa5qMJelxipzi/iJIi40O5cGV1wNYp5hvZP8ZN0T+jiNkL0QepXs+EsQ9XJ8ipEDoiH70ySUJP3Q==", - "dev": true, - "requires": { - "bytes": "3.1.0", - "http-errors": "1.7.2", - "iconv-lite": "0.4.24", - "unpipe": "1.0.0" - } - }, - "react": { - "version": "16.9.0", - "resolved": "https://registry.npmjs.org/react/-/react-16.9.0.tgz", - "integrity": "sha512-+7LQnFBwkiw+BobzOF6N//BdoNw0ouwmSJTEm9cglOOmsg/TMiFHZLe2sEoN5M7LgJTj9oHH0gxklfnQe66S1w==", - "requires": { - "loose-envify": "^1.1.0", - "object-assign": "^4.1.1", - "prop-types": "^15.6.2" - } - }, - "react-dom": { - "version": "16.9.0", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.9.0.tgz", - "integrity": "sha512-YFT2rxO9hM70ewk9jq0y6sQk8cL02xm4+IzYBz75CQGlClQQ1Bxq0nhHF6OtSbit+AIahujJgb/CPRibFkMNJQ==", - "requires": { - "loose-envify": "^1.1.0", - "object-assign": "^4.1.1", - "prop-types": "^15.6.2", - "scheduler": "^0.15.0" - } - }, - "react-highlight.js": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/react-highlight.js/-/react-highlight.js-1.0.7.tgz", - "integrity": "sha512-OVPKnV0ZvU+V//HExwbV8M9CWy49Eo/9y9pBN2OsNWUFPN6dE4YZBLmJW/5sM2DxI5v/QQLyxOnTnSSfGCP+9Q==", - "requires": { - "highlight.js": "^9.3.0", - "prop-types": "^15.6.0" - } - }, - "react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" - }, - "readdirp": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.3.0.tgz", - "integrity": "sha512-zz0pAkSPOXXm1viEwygWIPSPkcBYjW1xU5j/JBh5t9bGCJwa6f9+BJa6VaB2g+b55yVrmXzqkyLf4xaWYM0IkQ==", - "dev": true, - "requires": { - "picomatch": "^2.0.7" - } - }, - "requires-port": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", - "integrity": "sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=", - "dev": true - }, - "rfdc": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.1.4.tgz", - "integrity": "sha512-5C9HXdzK8EAqN7JDif30jqsBzavB7wLpaubisuQIGHWf2gUXSpzy6ArX/+Da8RjFpagWsCn+pIgxTMAmKw9Zug==", - "dev": true - }, - "rimraf": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", - "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", - "dev": true, - "requires": { - "glob": "^7.1.3" - } - }, - "safe-buffer": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.0.tgz", - "integrity": "sha512-fZEwUGbVl7kouZs1jCdMLdt95hdIv0ZeHg6L7qPeciMZhZ+/gdesW4wgTARkrFWEpspjEATAzUGPG8N2jJiwbg==", - "dev": true - }, - "safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true - }, - "scheduler": { - "version": "0.15.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.15.0.tgz", - "integrity": "sha512-xAefmSfN6jqAa7Kuq7LIJY0bwAPG3xlCj0HMEBQk1lxYiDKZscY2xJ5U/61ZTrYbmNQbXa+gc7czPkVo11tnCg==", - "requires": { - "loose-envify": "^1.1.0", - "object-assign": "^4.1.1" - } - }, - "setprototypeof": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.1.tgz", - "integrity": "sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw==", - "dev": true - }, - "socket.io": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-2.1.1.tgz", - "integrity": "sha512-rORqq9c+7W0DAK3cleWNSyfv/qKXV99hV4tZe+gGLfBECw3XEhBy7x85F3wypA9688LKjtwO9pX9L33/xQI8yA==", - "dev": true, - "requires": { - "debug": "~3.1.0", - "engine.io": "~3.2.0", - "has-binary2": "~1.0.2", - "socket.io-adapter": "~1.1.0", - "socket.io-client": "2.1.1", - "socket.io-parser": "~3.2.0" - }, - "dependencies": { - "debug": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", - "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", - "dev": true, - "requires": { - "ms": "2.0.0" - } - } - } - }, - "socket.io-adapter": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-1.1.2.tgz", - "integrity": "sha512-WzZRUj1kUjrTIrUKpZLEzFZ1OLj5FwLlAFQs9kuZJzJi5DKdU7FsWc36SNmA8iDOtwBQyT8FkrriRM8vXLYz8g==", - "dev": true - }, - "socket.io-client": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-2.1.1.tgz", - "integrity": "sha512-jxnFyhAuFxYfjqIgduQlhzqTcOEQSn+OHKVfAxWaNWa7ecP7xSNk2Dx/3UEsDcY7NcFafxvNvKPmmO7HTwTxGQ==", - "dev": true, - "requires": { - "backo2": "1.0.2", - "base64-arraybuffer": "0.1.5", - "component-bind": "1.0.0", - "component-emitter": "1.2.1", - "debug": "~3.1.0", - "engine.io-client": "~3.2.0", - "has-binary2": "~1.0.2", - "has-cors": "1.1.0", - "indexof": "0.0.1", - "object-component": "0.0.3", - "parseqs": "0.0.5", - "parseuri": "0.0.5", - "socket.io-parser": "~3.2.0", - "to-array": "0.1.4" - }, - "dependencies": { - "debug": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", - "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", - "dev": true, - "requires": { - "ms": "2.0.0" - } - } - } - }, - "socket.io-parser": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-3.2.0.tgz", - "integrity": "sha512-FYiBx7rc/KORMJlgsXysflWx/RIvtqZbyGLlHZvjfmPTPeuD/I8MaW7cfFrj5tRltICJdgwflhfZ3NVVbVLFQA==", - "dev": true, - "requires": { - "component-emitter": "1.2.1", - "debug": "~3.1.0", - "isarray": "2.0.1" - }, - "dependencies": { - "debug": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", - "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", - "dev": true, - "requires": { - "ms": "2.0.0" - } - } - } - }, - "source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true - }, - "statuses": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", - "integrity": "sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow=", - "dev": true - }, - "streamroller": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/streamroller/-/streamroller-1.0.6.tgz", - "integrity": "sha512-3QC47Mhv3/aZNFpDDVO44qQb9gwB9QggMEE0sQmkTAwBVYdBRWISdsywlkfm5II1Q5y/pmrHflti/IgmIzdDBg==", - "dev": true, - "requires": { - "async": "^2.6.2", - "date-format": "^2.0.0", - "debug": "^3.2.6", - "fs-extra": "^7.0.1", - "lodash": "^4.17.14" - }, - "dependencies": { - "debug": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.6.tgz", - "integrity": "sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ==", - "dev": true, - "requires": { - "ms": "^2.1.1" - } - }, - "ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true - } - } - }, - "tmp": { - "version": "0.0.33", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", - "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", - "dev": true, - "requires": { - "os-tmpdir": "~1.0.2" - } - }, - "to-array": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/to-array/-/to-array-0.1.4.tgz", - "integrity": "sha1-F+bBH3PdTz10zaek/zI46a2b+JA=", - "dev": true - }, - "to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "requires": { - "is-number": "^7.0.0" - } - }, - "toidentifier": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.0.tgz", - "integrity": "sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==", - "dev": true - }, - "type-is": { - "version": "1.6.18", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", - "dev": true, - "requires": { - "media-typer": "0.3.0", - "mime-types": "~2.1.24" - } - }, - "ultron": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ultron/-/ultron-1.1.1.tgz", - "integrity": "sha512-UIEXBNeYmKptWH6z8ZnqTeS8fV74zG0/eRU9VGkpzz+LIJNs8W/zM/L+7ctCkRrgbNnnR0xxw4bKOr0cW0N0Og==", - "dev": true - }, - "universalify": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", - "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", - "dev": true - }, - "unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw=", - "dev": true - }, - "useragent": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/useragent/-/useragent-2.3.0.tgz", - "integrity": "sha512-4AoH4pxuSvHCjqLO04sU6U/uE65BYza8l/KKBS0b0hnUPWi+cQ2BpeTEwejCSx9SPV5/U03nniDTrWx5NrmKdw==", - "dev": true, - "requires": { - "lru-cache": "4.1.x", - "tmp": "0.0.x" - } - }, - "utils-merge": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", - "integrity": "sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=", - "dev": true - }, - "void-elements": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-2.0.1.tgz", - "integrity": "sha1-wGavtYK7HLQSjWDqkjkulNXp2+w=", - "dev": true - }, - "which": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", - "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", - "dev": true, - "requires": { - "isexe": "^2.0.0" - } - }, - "wordwrap": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.3.tgz", - "integrity": "sha1-o9XabNXAvAAI03I0u68b7WMFkQc=", - "dev": true - }, - "wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", - "dev": true - }, - "ws": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-3.3.3.tgz", - "integrity": "sha512-nnWLa/NwZSt4KQJu51MYlCcSQ5g7INpOrOMt4XV8j4dqTXdmlUmSHQ8/oLC069ckre0fRsgfvsKwbTdtKLCDkA==", - "dev": true, - "requires": { - "async-limiter": "~1.0.0", - "safe-buffer": "~5.1.0", - "ultron": "~1.1.0" - }, - "dependencies": { - "safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true - } - } - }, - "xmlbuilder": { - "version": "12.0.0", - "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-12.0.0.tgz", - "integrity": "sha512-lMo8DJ8u6JRWp0/Y4XLa/atVDr75H9litKlb2E5j3V3MesoL50EBgZDWoLT3F/LztVnG67GjPXLZpqcky/UMnQ==", - "dev": true - }, - "xmlhttprequest-ssl": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-1.5.5.tgz", - "integrity": "sha1-wodrBhaKrcQOV9l+gRkayPQ5iz4=", - "dev": true - }, - "yallist": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz", - "integrity": "sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI=", - "dev": true - }, - "yeast": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/yeast/-/yeast-0.1.2.tgz", - "integrity": "sha1-AI4G2AlDIMNy28L47XagymyKxBk=", - "dev": true - } - } -} diff --git a/package.json b/package.json index 60f5bc5afb..084833dfad 100644 --- a/package.json +++ b/package.json @@ -1,18 +1,229 @@ { + "name": "Athens", + "author": "athensresearch", + "version": "2.1.0-beta.5", + "description": "An open-source knowledege graph for research and notetaking", + "repository": { + "type": "git", + "url": "https://github.com/athensresearch/athens/" + }, + "scripts": { + "// repo scripts": "", + "update:dry": "standard-version --dry-run -p --releaseCommitMessageFormat v{{currentTag}}", + "update": "standard-version -p --releaseCommitMessageFormat v{{currentTag}}", + "clean": "rimraf resources/public/**/*.js resources/public/**/*.js.map target .shadow-cljs src/gen", + "lint": "clojure -M:clj-kondo --lint src", + "style": "clojure -M:cljstyle check", + "style:fix": "clojure -M:cljstyle fix", + "carve": "clojure -M:carve --opts '{:paths [\"src\" \"test\"] :report {:format :text}}'", + "carve:interactive": "clojure -M:carve --opts '{:paths [\"src\" \"test\"]}'", + "clj:outdated": "clojure -M:outdated", + "notebooks": "clojure -M:notebooks", + "notebooks:static": "clojure -X:notebooks-static", + "vercel:install": "./script/vercel-setup.sh && yarn && clojure -P", + "vercel:build": "yarn notebooks:static && yarn client:web:static", + "// client scripts": "", + "dev": "yarn components && concurrently \"yarn components:watch\" \"yarn client:watch\"", + "client:watch": "shadow-cljs watch main renderer app", + "client:dev-build": "yarn components && shadow-cljs compile main renderer app", + "client:test": "yarn components && shadow-cljs compile karma-test && karma start --single-run", + "client:electron": "electron .", + "client:web:server": "serve -l 3000 vercel-static/athens/", + "client:web:static": "yarn components && shadow-cljs release app && cp -R resources/public/. vercel-static/athens/", + "client:debug":"clojure -X:flowstorm", + "components": "babel ./src/js/components/ --extensions \".ts,.tsx\" --out-dir ./src/gen/components/", + "components:watch": "yarn components --watch", + "prod": "yarn components && shadow-cljs release main renderer app", + "dist": "electron-builder -p always", + "// client e2e scripts": "", + "// add --config=playwright.electron.config.ts to any e2e script to use electron instead of web build": "", + "client:e2e": "xvfb-maybe playwright test", + "client:e2e:electron": "yarn client:e2e --config=playwright.electron.config.ts", + "client:e2e:only": "yarn client:e2e --grep", + "client:e2e:only:debug": "yarn client:e2e --debug --grep", + "client:e2e:only:verbose": "DEBUG=pw:api yarn client:e2e --grep", + "client:e2e:new": "cp test/e2e/new-test.template.ts test/e2e/new-test.spec.ts && yarn client:e2e:only:debug new-test-template", + "client:e2e:server": "serve -l 3000 resources/public/", + "// server scripts": "", + "server": "clojure -M:athens", + "server:fluree": "docker-compose up --detach fluree", + "server:fluree:down": "docker-compose down", + "server:fluree:wipe": "rm -rf athens-data/fluree", + "server:compile": "clojure -M -e \"(compile 'athens.self-hosted.core)\"", + "server:uberjar": "clojure -M:uberdeps --aliases compiled-classes --main-class athens.self-hosted.core --target target/athens-lan-party-standalone.jar", + "server:test": "clojure -X:test :excludes [:fluree]", + "server:test:fluree": "clojure -X:test :includes [:fluree]", + "server:test:only": "clojure -M:test --var", + "server:repl": "clojure -A:repl", + "server:wipe": "rimraf athens-data/fluree athens-data/datascript", + "// cli scripts": "", + "cli:save": "clojure -M:athens-cli save", + "cli:load": "clojure -M:athens-cli load", + "cli:recover": "clojure -M:athens-cli recover", + "cli:compile": "clojure -M -e \"(compile 'athens.self-hosted.save-load)\"", + "cli:uberjar": "clojure -M:uberdeps --aliases compiled-classes --main-class athens.self-hosted.save-load --target target/athens-cli.jar" + }, + "main": "resources/main.js", + "build": { + "appId": "com.athensresearch.athens", + "generateUpdatesFilesForAllChannels": true, + "afterSign": "electron-builder-notarize", + "mac": { + "target": [ + { + "target": "dmg", + "arch": [ + "x64", + "arm64" + ] + }, + { + "target": "zip", + "arch": [ + "x64", + "arm64" + ] + } + ], + "hardenedRuntime": true, + "entitlements": "build/entitlements.mac.plist", + "entitlementsInherit": "build/entitlements.mac.plist" + }, + "linux": { + "target": [ + "AppImage" + ], + "category": "Office" + }, + "publish": { + "provider": "github" + } + }, + "dependencies": { + "@babel/runtime": "^7.15.4", + "@chakra-ui/react": "^1.8.6", + "@dnd-kit/core": "^6.0.5", + "@dnd-kit/sortable": "^7.0.1", + "@emotion/react": "^11", + "@emotion/styled": "^11", + "@js-joda/core": "1.12.0", + "@js-joda/locale_en-us": "3.1.1", + "@js-joda/timezone": "2.2.0", + "@sentry/integrations": "^6.17.3", + "@sentry/react": "^6.17.3", + "@sentry/tracing": "^6.17.3", + "codemirror": "^5.59.4", + "create-react-class": "^15.6.3", + "electron-log": "^4.2.4", + "electron-updater": "^4.3.4", + "electron-window-state": "^5.0.3", + "emoji-picker-element": "^1.8.2", + "framer-motion": "^6", + "highlight.js": "^11.5.1", + "katex": "^0.12.0", + "luxon": "^2.0.2", + "nedb": "^1.8.0", + "polished": "^4.1.3", + "react": "17.0.1", + "react-codemirror2": "^7.2.1", + "react-colorful": "^5.4.0", + "react-day-picker": "^7.4.10", + "react-dom": "17.0.1", + "react-error-boundary": "^3.1.4", + "react-force-graph-2d": "^1.19.0", + "react-highlight.js": "1.0.7", + "react-intersection-observer": "^8.32.1", + "react-window": "^1.8.6", + "textarea-caret": "^3.1.0", + "tslib": "^2.3.1", + "turndown": "^7.1.1" + }, "devDependencies": { + "@babel/cli": "^7.15.4", + "@babel/core": "^7.15.0", + "@babel/plugin-proposal-class-properties": "^7.14.5", + "@babel/plugin-proposal-object-rest-spread": "^7.15.6", + "@babel/plugin-proposal-private-methods": "^7.14.5", + "@babel/plugin-proposal-private-property-in-object": "^7.15.4", + "@babel/plugin-transform-runtime": "^7.15.0", + "@babel/preset-env": "^7.15.6", + "@babel/preset-react": "^7.14.5", + "@babel/preset-typescript": "^7.15.0", + "@playwright/test": "^1.17.1", + "babel-loader": "^8.2.2", + "babel-plugin-module-resolver": "^4.1.0", + "babel-plugin-styled-components": "^1.13.2", + "babel-plugin-transform-imports": "^2.0.0", + "concurrently": "^6.2.1", + "electron": "^12.2.3", + "electron-builder": "22.14", + "electron-builder-notarize": "^1.2.0", "gh-pages": "^2.2.0", - "karma": "^4.4.1", + "karma": "^6.3.16", "karma-chrome-launcher": "^3.1.0", "karma-cljs-test": "^0.1.0", "karma-junit-reporter": "^2.0.1", - "shadow-cljs": "2.8.83" + "playwright": "^1.17.1", + "rimraf": "^3.0.2", + "serve": "^13.0.2", + "shadow-cljs": "2.19.5", + "source-map-support": "^0.5.19", + "standard-version": "^9.3.1", + "typescript": "^4.3.5", + "vercel": "^24.2.3", + "xvfb-maybe": "^0.2.1" }, - "dependencies": { - "create-react-class": "^15.6.3", - "highlight.js": "9.15.10", - "marked": "^1.0.0", - "react": "16.9.0", - "react-dom": "16.9.0", - "react-highlight.js": "1.0.7" + "resolutions-comments": { + "ua-parser-js": "See https://github.com/faisalman/ua-parser-js/issues/536" + }, + "resolutions": { + "ua-parser-js": "0.7.28" + }, + "standard-version": { + "types": [ + { + "type": "doc", + "section": "Documentation" + }, + { + "type": "enhance", + "section": "Enhancements" + }, + { + "type": "feat", + "section": "Features" + }, + { + "type": "fix", + "section": "Bug Fixes" + }, + { + "type": "rfct", + "section": "Refactors" + }, + { + "type": "wip", + "section": "Work in Progress" + }, + { + "type": "perf", + "section": "Performance" + }, + { + "type": "style" + }, + { + "type": "chore" + }, + { + "type": "test" + }, + { + "type": "build" + }, + { + "type": "ci" + } + ] } } diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000000..63e87556fa --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,32 @@ +import type { PlaywrightTestConfig } from '@playwright/test'; +import * as path from 'path'; + +const outputDir = path.join(__dirname, 'test-results'); +export const baseConfig: PlaywrightTestConfig = { + outputDir, + testDir: './test/e2e', + timeout: 30000, + globalTimeout: 5400000, + forbidOnly: !!process.env.CI, + preserveOutput: process.env.CI ? 'failures-only' : 'always', + retries: process.env.CI ? 3 : 0, +}; + +const config: PlaywrightTestConfig = { + ...baseConfig, + webServer: { + command: 'yarn client:e2e:server', + // NB: This is the same port as the shadow-cljs web app server, + // so it will be reused if available. + port: 3000, + timeout: 120 * 1000, + reuseExistingServer: !process.env.CI, + }, + use: { + baseURL: 'http://localhost:3000', + browserName: 'chromium', + headless: true, + } +}; + +export default config; diff --git a/playwright.electron.config.ts b/playwright.electron.config.ts new file mode 100644 index 0000000000..e7e900c146 --- /dev/null +++ b/playwright.electron.config.ts @@ -0,0 +1,38 @@ +import type { PlaywrightTestConfig } from '@playwright/test'; +import { baseConfig } from './playwright.config'; + +// Electron setup taken from https://gist.github.com/UberMouse/facbe751c3ecb9b31e8b4f6221567b7a +// mentioned in https://github.com/microsoft/playwright/issues/8208#issuecomment-948093888. +// +// Some more useful sources on this topic: +// - replacing spectron with playwright: https://github.com/electron-userland/spectron/issues/896 +// - playwright repo electron tests: https://github.com/microsoft/playwright/tree/main/tests/electron +// - playwright electron support: https://playwright.dev/docs/api/class-electronapplication +// and https://playwright.dev/docs/api/class-electron +// - playwright+electron starter: https://github.com/spaceagetv/electron-playwright-example + +// Set env as using electron config, will be picked up by isElectron in utils. +process.env.ELECTRON_PLAYWRIGHT_CONFIG = "true"; + +const config: PlaywrightTestConfig = { + ...baseConfig, + workers: 1, + use: {}, + projects: [ { + name: 'chromium', + use: { + browserName: 'chromium', + trace: process.env.CI ? 'on-first-retry' : 'on' + }, + metadata: { + platform: process.platform, + headful: true, + browserName: 'electron', + channel: undefined, + mode: 'default', + video: false, + } + } ], +}; + +export default config; diff --git a/project.clj b/project.clj deleted file mode 100644 index e1ee8c8495..0000000000 --- a/project.clj +++ /dev/null @@ -1,66 +0,0 @@ -(defproject athens "0.1.0-SNAPSHOT" - - :description "Open-Source Roam" - - :url "https://github.com/athensresearch/athens" - - :license {:name "Eclipse Public License - v 1.0" - :url "http://www.eclipse.org/legal/epl-v10.html" - :distribution :repo - :comments "same as Clojure"} - - :dependencies [[org.clojure/clojure "1.10.1"] - [org.clojure/clojurescript "1.10.597" - :exclusions [com.google.javascript/closure-compiler-unshaded - org.clojure/google-closure-library - org.clojure/google-closure-library-third-party]] - [thheller/shadow-cljs "2.8.83"] - [reagent "0.9.1"] - [re-frame "0.11.0"] - [datascript "0.18.10"] - [re-posh "0.3.1"] - [cljs-http "0.1.46"] - [day8.re-frame/async-flow-fx "0.1.0"] - [metosin/reitit "0.4.2"] - [instaparse "1.4.10"] - [devcards "0.2.6"]] - - :plugins [[lein-shell "0.5.0"]] - - :min-lein-version "2.5.3" - - :source-paths ["src/clj" "src/cljs" "src/cljc"] - - :clean-targets ^{:protect false} ["resources/public/js/compiled" "target"] - - - :shell {:commands {"open" {:windows ["cmd" "/c" "start"] - :macosx "open" - :linux "xdg-open"}}} - - :aliases {"dev" ["with-profile" "dev" "do" - ["run" "-m" "shadow.cljs.devtools.cli" "watch" "app"]] - "devcards" ["with-profile" "dev" "do" - ["run" "-m" "shadow.cljs.devtools.cli" "watch" "devcards"]] - "prod" ["with-profile" "prod" "do" - ["run" "-m" "shadow.cljs.devtools.cli" "release" "app"]] - "build-report" ["with-profile" "prod" "do" - ["run" "-m" "shadow.cljs.devtools.cli" "run" "shadow.cljs.build-report" "app" "target/build-report.html"] - ["shell" "open" "target/build-report.html"]] - "test-jvm" ["test"] - "test-karma" ["shell" "karma" "start" "--single-run"] - "gh-pages" ["shell" "yarn" "gh-pages" "-d" "resources/public"] - "karma" ["do" - ["run" "-m" "shadow.cljs.devtools.cli" "compile" "karma-test"] - ["shell" "karma" "start" "--single-run" "--reporters" "junit,dots"]]} - - :profiles - {:dev - {:dependencies [[binaryage/devtools "1.0.0"] - [day8.re-frame/re-frame-10x "0.5.1"] - [day8.re-frame/tracing "0.5.3"]] - :source-paths ["dev"]} - :prod - {:dependencies [[day8.re-frame/tracing-stubs "0.5.3"]]}} - - :prep-tasks []) diff --git a/resources/public/cards.html b/resources/public/cards.html deleted file mode 100644 index 508c1c5aaf..0000000000 --- a/resources/public/cards.html +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - Athens Cards - - -
- - - diff --git a/resources/public/css/codemirror.css b/resources/public/css/codemirror.css new file mode 100644 index 0000000000..a64f97c777 --- /dev/null +++ b/resources/public/css/codemirror.css @@ -0,0 +1,350 @@ +/* BASICS */ + +.CodeMirror { + /* Set height, width, borders, and global font properties here */ + font-family: monospace; + height: 300px; + color: black; + direction: ltr; +} + +/* PADDING */ + +.CodeMirror-lines { + padding: 4px 0; /* Vertical padding around content */ +} +.CodeMirror pre.CodeMirror-line, +.CodeMirror pre.CodeMirror-line-like { + padding: 0 4px; /* Horizontal padding of content */ +} + +.CodeMirror-scrollbar-filler, .CodeMirror-gutter-filler { + background-color: white; /* The little square between H and V scrollbars */ +} + +/* GUTTER */ + +.CodeMirror-gutters { + border-right: 1px solid #ddd; + background-color: #f7f7f7; + white-space: nowrap; +} +.CodeMirror-linenumbers {} +.CodeMirror-linenumber { + padding: 0 3px 0 5px; + min-width: 20px; + text-align: right; + color: #999; + white-space: nowrap; +} + +.CodeMirror-guttermarker { color: black; } +.CodeMirror-guttermarker-subtle { color: #999; } + +/* CURSOR */ + +.CodeMirror-cursor { + border-left: 1px solid black; + border-right: none; + width: 0; +} +/* Shown when moving in bi-directional text */ +.CodeMirror div.CodeMirror-secondarycursor { + border-left: 1px solid silver; +} +.cm-fat-cursor .CodeMirror-cursor { + width: auto; + border: 0 !important; + background: #7e7; +} +.cm-fat-cursor div.CodeMirror-cursors { + z-index: 1; +} +.cm-fat-cursor-mark { + background-color: rgba(20, 255, 20, 0.5); + -webkit-animation: blink 1.06s steps(1) infinite; + -moz-animation: blink 1.06s steps(1) infinite; + animation: blink 1.06s steps(1) infinite; +} +.cm-animate-fat-cursor { + width: auto; + border: 0; + -webkit-animation: blink 1.06s steps(1) infinite; + -moz-animation: blink 1.06s steps(1) infinite; + animation: blink 1.06s steps(1) infinite; + background-color: #7e7; +} +@-moz-keyframes blink { + 0% {} + 50% { background-color: transparent; } + 100% {} +} +@-webkit-keyframes blink { + 0% {} + 50% { background-color: transparent; } + 100% {} +} +@keyframes blink { + 0% {} + 50% { background-color: transparent; } + 100% {} +} + +/* Can style cursor different in overwrite (non-insert) mode */ +.CodeMirror-overwrite .CodeMirror-cursor {} + +.cm-tab { display: inline-block; text-decoration: inherit; } + +.CodeMirror-rulers { + position: absolute; + left: 0; right: 0; top: -50px; bottom: 0; + overflow: hidden; +} +.CodeMirror-ruler { + border-left: 1px solid #ccc; + top: 0; bottom: 0; + position: absolute; +} + +/* DEFAULT THEME */ + +.cm-s-default .cm-header {color: blue;} +.cm-s-default .cm-quote {color: #090;} +.cm-negative {color: #d44;} +.cm-positive {color: #292;} +.cm-header, .cm-strong {font-weight: bold;} +.cm-em {font-style: italic;} +.cm-link {text-decoration: underline;} +.cm-strikethrough {text-decoration: line-through;} + +.cm-s-default .cm-keyword {color: #708;} +.cm-s-default .cm-atom {color: #219;} +.cm-s-default .cm-number {color: #164;} +.cm-s-default .cm-def {color: #00f;} +.cm-s-default .cm-variable, +.cm-s-default .cm-punctuation, +.cm-s-default .cm-property, +.cm-s-default .cm-operator {} +.cm-s-default .cm-variable-2 {color: #05a;} +.cm-s-default .cm-variable-3, .cm-s-default .cm-type {color: #085;} +.cm-s-default .cm-comment {color: #a50;} +.cm-s-default .cm-string {color: #a11;} +.cm-s-default .cm-string-2 {color: #f50;} +.cm-s-default .cm-meta {color: #555;} +.cm-s-default .cm-qualifier {color: #555;} +.cm-s-default .cm-builtin {color: #30a;} +.cm-s-default .cm-bracket {color: #997;} +.cm-s-default .cm-tag {color: #170;} +.cm-s-default .cm-attribute {color: #00c;} +.cm-s-default .cm-hr {color: #999;} +.cm-s-default .cm-link {color: #00c;} + +.cm-s-default .cm-error {color: #f00;} +.cm-invalidchar {color: #f00;} + +.CodeMirror-composing { border-bottom: 2px solid; } + +/* Default styles for common addons */ + +div.CodeMirror span.CodeMirror-matchingbracket {color: #0b0;} +div.CodeMirror span.CodeMirror-nonmatchingbracket {color: #a22;} +.CodeMirror-matchingtag { background: rgba(255, 150, 0, .3); } +.CodeMirror-activeline-background {background: #e8f2ff;} + +/* STOP */ + +/* The rest of this file contains styles related to the mechanics of + the editor. You probably shouldn't touch them. */ + +.CodeMirror { + position: relative; + overflow: hidden; + background: white; +} + +.CodeMirror-scroll { + overflow: scroll !important; /* Things will break if this is overridden */ + /* 50px is the magic margin used to hide the element's real scrollbars */ + /* See overflow: hidden in .CodeMirror */ + margin-bottom: -50px; margin-right: -50px; + padding-bottom: 50px; + height: 100%; + outline: none; /* Prevent dragging from highlighting the element */ + position: relative; +} +.CodeMirror-sizer { + position: relative; + border-right: 50px solid transparent; +} + +/* The fake, visible scrollbars. Used to force redraw during scrolling + before actual scrolling happens, thus preventing shaking and + flickering artifacts. */ +.CodeMirror-vscrollbar, .CodeMirror-hscrollbar, .CodeMirror-scrollbar-filler, .CodeMirror-gutter-filler { + position: absolute; + z-index: 6; + display: none; + outline: none; +} +.CodeMirror-vscrollbar { + right: 0; top: 0; + overflow-x: hidden; + overflow-y: scroll; +} +.CodeMirror-hscrollbar { + bottom: 0; left: 0; + overflow-y: hidden; + overflow-x: scroll; +} +.CodeMirror-scrollbar-filler { + right: 0; bottom: 0; +} +.CodeMirror-gutter-filler { + left: 0; bottom: 0; +} + +.CodeMirror-gutters { + position: absolute; left: 0; top: 0; + min-height: 100%; + z-index: 3; +} +.CodeMirror-gutter { + white-space: normal; + height: 100%; + display: inline-block; + vertical-align: top; + margin-bottom: -50px; +} +.CodeMirror-gutter-wrapper { + position: absolute; + z-index: 4; + background: none !important; + border: none !important; +} +.CodeMirror-gutter-background { + position: absolute; + top: 0; bottom: 0; + z-index: 4; +} +.CodeMirror-gutter-elt { + position: absolute; + cursor: default; + z-index: 4; +} +.CodeMirror-gutter-wrapper ::selection { background-color: transparent } +.CodeMirror-gutter-wrapper ::-moz-selection { background-color: transparent } + +.CodeMirror-lines { + cursor: text; + min-height: 1px; /* prevents collapsing before first draw */ +} +.CodeMirror pre.CodeMirror-line, +.CodeMirror pre.CodeMirror-line-like { + /* Reset some styles that the rest of the page might have set */ + -moz-border-radius: 0; -webkit-border-radius: 0; border-radius: 0; + border-width: 0; + background: transparent; + font-family: inherit; + font-size: inherit; + margin: 0; + white-space: pre; + word-wrap: normal; + line-height: inherit; + color: inherit; + z-index: 2; + position: relative; + overflow: visible; + -webkit-tap-highlight-color: transparent; + -webkit-font-variant-ligatures: contextual; + font-variant-ligatures: contextual; +} +.CodeMirror-wrap pre.CodeMirror-line, +.CodeMirror-wrap pre.CodeMirror-line-like { + word-wrap: break-word; + white-space: pre-wrap; + word-break: normal; +} + +.CodeMirror-linebackground { + position: absolute; + left: 0; right: 0; top: 0; bottom: 0; + z-index: 0; +} + +.CodeMirror-linewidget { + position: relative; + z-index: 2; + padding: 0.1px; /* Force widget margins to stay inside of the container */ +} + +.CodeMirror-widget {} + +.CodeMirror-rtl pre { direction: rtl; } + +.CodeMirror-code { + outline: none; +} + +/* Force content-box sizing for the elements where we expect it */ +.CodeMirror-scroll, +.CodeMirror-sizer, +.CodeMirror-gutter, +.CodeMirror-gutters, +.CodeMirror-linenumber { + -moz-box-sizing: content-box; + box-sizing: content-box; +} + +.CodeMirror-measure { + position: absolute; + width: 100%; + height: 0; + overflow: hidden; + visibility: hidden; +} + +.CodeMirror-cursor { + position: absolute; + pointer-events: none; +} +.CodeMirror-measure pre { position: static; } + +div.CodeMirror-cursors { + visibility: hidden; + position: relative; + z-index: 3; +} +div.CodeMirror-dragcursors { + visibility: visible; +} + +.CodeMirror-focused div.CodeMirror-cursors { + visibility: visible; +} + +.CodeMirror-selected { background: #d9d9d9; } +.CodeMirror-focused .CodeMirror-selected { background: #d7d4f0; } +.CodeMirror-crosshair { cursor: crosshair; } +.CodeMirror-line::selection, .CodeMirror-line > span::selection, .CodeMirror-line > span > span::selection { background: #d7d4f0; } +.CodeMirror-line::-moz-selection, .CodeMirror-line > span::-moz-selection, .CodeMirror-line > span > span::-moz-selection { background: #d7d4f0; } + +.cm-searching { + background-color: #ffa; + background-color: rgba(255, 255, 0, .4); +} + +/* Used to force a border model for a node */ +.cm-force-border { padding-right: .1px; } + +@media print { + /* Hide the cursor when printing */ + .CodeMirror div.CodeMirror-cursors { + visibility: hidden; + } +} + +/* See issue #2901 */ +.cm-tab-wrap-hack:after { content: ''; } + +/* Help users use markselection to safely style text background */ +span.CodeMirror-selectedtext { background: none; } diff --git a/resources/public/css/fonts/KaTeX_AMS-Regular.ttf b/resources/public/css/fonts/KaTeX_AMS-Regular.ttf new file mode 100644 index 0000000000..737cf8eb58 Binary files /dev/null and b/resources/public/css/fonts/KaTeX_AMS-Regular.ttf differ diff --git a/resources/public/css/fonts/KaTeX_AMS-Regular.woff b/resources/public/css/fonts/KaTeX_AMS-Regular.woff new file mode 100644 index 0000000000..38378bfba8 Binary files /dev/null and b/resources/public/css/fonts/KaTeX_AMS-Regular.woff differ diff --git a/resources/public/css/fonts/KaTeX_AMS-Regular.woff2 b/resources/public/css/fonts/KaTeX_AMS-Regular.woff2 new file mode 100644 index 0000000000..a4d1ba6410 Binary files /dev/null and b/resources/public/css/fonts/KaTeX_AMS-Regular.woff2 differ diff --git a/resources/public/css/fonts/KaTeX_Caligraphic-Bold.ttf b/resources/public/css/fonts/KaTeX_Caligraphic-Bold.ttf new file mode 100644 index 0000000000..04d28abd99 Binary files /dev/null and b/resources/public/css/fonts/KaTeX_Caligraphic-Bold.ttf differ diff --git a/resources/public/css/fonts/KaTeX_Caligraphic-Bold.woff b/resources/public/css/fonts/KaTeX_Caligraphic-Bold.woff new file mode 100644 index 0000000000..a01ce90606 Binary files /dev/null and b/resources/public/css/fonts/KaTeX_Caligraphic-Bold.woff differ diff --git a/resources/public/css/fonts/KaTeX_Caligraphic-Bold.woff2 b/resources/public/css/fonts/KaTeX_Caligraphic-Bold.woff2 new file mode 100644 index 0000000000..37927274af Binary files /dev/null and b/resources/public/css/fonts/KaTeX_Caligraphic-Bold.woff2 differ diff --git a/resources/public/css/fonts/KaTeX_Caligraphic-Regular.ttf b/resources/public/css/fonts/KaTeX_Caligraphic-Regular.ttf new file mode 100644 index 0000000000..b2ce555fd5 Binary files /dev/null and b/resources/public/css/fonts/KaTeX_Caligraphic-Regular.ttf differ diff --git a/resources/public/css/fonts/KaTeX_Caligraphic-Regular.woff b/resources/public/css/fonts/KaTeX_Caligraphic-Regular.woff new file mode 100644 index 0000000000..bc169b7cdd Binary files /dev/null and b/resources/public/css/fonts/KaTeX_Caligraphic-Regular.woff differ diff --git a/resources/public/css/fonts/KaTeX_Caligraphic-Regular.woff2 b/resources/public/css/fonts/KaTeX_Caligraphic-Regular.woff2 new file mode 100644 index 0000000000..f1e38bba2a Binary files /dev/null and b/resources/public/css/fonts/KaTeX_Caligraphic-Regular.woff2 differ diff --git a/resources/public/css/fonts/KaTeX_Fraktur-Bold.ttf b/resources/public/css/fonts/KaTeX_Fraktur-Bold.ttf new file mode 100644 index 0000000000..c42d169167 Binary files /dev/null and b/resources/public/css/fonts/KaTeX_Fraktur-Bold.ttf differ diff --git a/resources/public/css/fonts/KaTeX_Fraktur-Bold.woff b/resources/public/css/fonts/KaTeX_Fraktur-Bold.woff new file mode 100644 index 0000000000..f30b54b3d1 Binary files /dev/null and b/resources/public/css/fonts/KaTeX_Fraktur-Bold.woff differ diff --git a/resources/public/css/fonts/KaTeX_Fraktur-Bold.woff2 b/resources/public/css/fonts/KaTeX_Fraktur-Bold.woff2 new file mode 100644 index 0000000000..b7a83593a8 Binary files /dev/null and b/resources/public/css/fonts/KaTeX_Fraktur-Bold.woff2 differ diff --git a/resources/public/css/fonts/KaTeX_Fraktur-Regular.ttf b/resources/public/css/fonts/KaTeX_Fraktur-Regular.ttf new file mode 100644 index 0000000000..413322824e Binary files /dev/null and b/resources/public/css/fonts/KaTeX_Fraktur-Regular.ttf differ diff --git a/resources/public/css/fonts/KaTeX_Fraktur-Regular.woff b/resources/public/css/fonts/KaTeX_Fraktur-Regular.woff new file mode 100644 index 0000000000..5af51de9e5 Binary files /dev/null and b/resources/public/css/fonts/KaTeX_Fraktur-Regular.woff differ diff --git a/resources/public/css/fonts/KaTeX_Fraktur-Regular.woff2 b/resources/public/css/fonts/KaTeX_Fraktur-Regular.woff2 new file mode 100644 index 0000000000..3874f93e8d Binary files /dev/null and b/resources/public/css/fonts/KaTeX_Fraktur-Regular.woff2 differ diff --git a/resources/public/css/fonts/KaTeX_Main-Bold.ttf b/resources/public/css/fonts/KaTeX_Main-Bold.ttf new file mode 100644 index 0000000000..14390e012a Binary files /dev/null and b/resources/public/css/fonts/KaTeX_Main-Bold.ttf differ diff --git a/resources/public/css/fonts/KaTeX_Main-Bold.woff b/resources/public/css/fonts/KaTeX_Main-Bold.woff new file mode 100644 index 0000000000..33b41998e4 Binary files /dev/null and b/resources/public/css/fonts/KaTeX_Main-Bold.woff differ diff --git a/resources/public/css/fonts/KaTeX_Main-Bold.woff2 b/resources/public/css/fonts/KaTeX_Main-Bold.woff2 new file mode 100644 index 0000000000..f9b71cbe74 Binary files /dev/null and b/resources/public/css/fonts/KaTeX_Main-Bold.woff2 differ diff --git a/resources/public/css/fonts/KaTeX_Main-BoldItalic.ttf b/resources/public/css/fonts/KaTeX_Main-BoldItalic.ttf new file mode 100644 index 0000000000..ad0761f431 Binary files /dev/null and b/resources/public/css/fonts/KaTeX_Main-BoldItalic.ttf differ diff --git a/resources/public/css/fonts/KaTeX_Main-BoldItalic.woff b/resources/public/css/fonts/KaTeX_Main-BoldItalic.woff new file mode 100644 index 0000000000..115af4f072 Binary files /dev/null and b/resources/public/css/fonts/KaTeX_Main-BoldItalic.woff differ diff --git a/resources/public/css/fonts/KaTeX_Main-BoldItalic.woff2 b/resources/public/css/fonts/KaTeX_Main-BoldItalic.woff2 new file mode 100644 index 0000000000..5c500c285a Binary files /dev/null and b/resources/public/css/fonts/KaTeX_Main-BoldItalic.woff2 differ diff --git a/resources/public/css/fonts/KaTeX_Main-Italic.ttf b/resources/public/css/fonts/KaTeX_Main-Italic.ttf new file mode 100644 index 0000000000..fc8625c81c Binary files /dev/null and b/resources/public/css/fonts/KaTeX_Main-Italic.ttf differ diff --git a/resources/public/css/fonts/KaTeX_Main-Italic.woff b/resources/public/css/fonts/KaTeX_Main-Italic.woff new file mode 100644 index 0000000000..2d3087ab49 Binary files /dev/null and b/resources/public/css/fonts/KaTeX_Main-Italic.woff differ diff --git a/resources/public/css/fonts/KaTeX_Main-Italic.woff2 b/resources/public/css/fonts/KaTeX_Main-Italic.woff2 new file mode 100644 index 0000000000..08510d85a7 Binary files /dev/null and b/resources/public/css/fonts/KaTeX_Main-Italic.woff2 differ diff --git a/resources/public/css/fonts/KaTeX_Main-Regular.ttf b/resources/public/css/fonts/KaTeX_Main-Regular.ttf new file mode 100644 index 0000000000..5115a044ea Binary files /dev/null and b/resources/public/css/fonts/KaTeX_Main-Regular.ttf differ diff --git a/resources/public/css/fonts/KaTeX_Main-Regular.woff b/resources/public/css/fonts/KaTeX_Main-Regular.woff new file mode 100644 index 0000000000..42b74ab133 Binary files /dev/null and b/resources/public/css/fonts/KaTeX_Main-Regular.woff differ diff --git a/resources/public/css/fonts/KaTeX_Main-Regular.woff2 b/resources/public/css/fonts/KaTeX_Main-Regular.woff2 new file mode 100644 index 0000000000..18647fa6af Binary files /dev/null and b/resources/public/css/fonts/KaTeX_Main-Regular.woff2 differ diff --git a/resources/public/css/fonts/KaTeX_Math-BoldItalic.ttf b/resources/public/css/fonts/KaTeX_Math-BoldItalic.ttf new file mode 100644 index 0000000000..326b523bd0 Binary files /dev/null and b/resources/public/css/fonts/KaTeX_Math-BoldItalic.ttf differ diff --git a/resources/public/css/fonts/KaTeX_Math-BoldItalic.woff b/resources/public/css/fonts/KaTeX_Math-BoldItalic.woff new file mode 100644 index 0000000000..5b4041aa87 Binary files /dev/null and b/resources/public/css/fonts/KaTeX_Math-BoldItalic.woff differ diff --git a/resources/public/css/fonts/KaTeX_Math-BoldItalic.woff2 b/resources/public/css/fonts/KaTeX_Math-BoldItalic.woff2 new file mode 100644 index 0000000000..ba55276d03 Binary files /dev/null and b/resources/public/css/fonts/KaTeX_Math-BoldItalic.woff2 differ diff --git a/resources/public/css/fonts/KaTeX_Math-Italic.ttf b/resources/public/css/fonts/KaTeX_Math-Italic.ttf new file mode 100644 index 0000000000..f148fceeb0 Binary files /dev/null and b/resources/public/css/fonts/KaTeX_Math-Italic.ttf differ diff --git a/resources/public/css/fonts/KaTeX_Math-Italic.woff b/resources/public/css/fonts/KaTeX_Math-Italic.woff new file mode 100644 index 0000000000..31d0038498 Binary files /dev/null and b/resources/public/css/fonts/KaTeX_Math-Italic.woff differ diff --git a/resources/public/css/fonts/KaTeX_Math-Italic.woff2 b/resources/public/css/fonts/KaTeX_Math-Italic.woff2 new file mode 100644 index 0000000000..9871ab6b83 Binary files /dev/null and b/resources/public/css/fonts/KaTeX_Math-Italic.woff2 differ diff --git a/resources/public/css/fonts/KaTeX_SansSerif-Bold.ttf b/resources/public/css/fonts/KaTeX_SansSerif-Bold.ttf new file mode 100644 index 0000000000..dce35c8fdf Binary files /dev/null and b/resources/public/css/fonts/KaTeX_SansSerif-Bold.ttf differ diff --git a/resources/public/css/fonts/KaTeX_SansSerif-Bold.woff b/resources/public/css/fonts/KaTeX_SansSerif-Bold.woff new file mode 100644 index 0000000000..992cb3d6d0 Binary files /dev/null and b/resources/public/css/fonts/KaTeX_SansSerif-Bold.woff differ diff --git a/resources/public/css/fonts/KaTeX_SansSerif-Bold.woff2 b/resources/public/css/fonts/KaTeX_SansSerif-Bold.woff2 new file mode 100644 index 0000000000..6dd10388ad Binary files /dev/null and b/resources/public/css/fonts/KaTeX_SansSerif-Bold.woff2 differ diff --git a/resources/public/css/fonts/KaTeX_SansSerif-Italic.ttf b/resources/public/css/fonts/KaTeX_SansSerif-Italic.ttf new file mode 100644 index 0000000000..a3eb86c38d Binary files /dev/null and b/resources/public/css/fonts/KaTeX_SansSerif-Italic.ttf differ diff --git a/resources/public/css/fonts/KaTeX_SansSerif-Italic.woff b/resources/public/css/fonts/KaTeX_SansSerif-Italic.woff new file mode 100644 index 0000000000..f4fa252a2c Binary files /dev/null and b/resources/public/css/fonts/KaTeX_SansSerif-Italic.woff differ diff --git a/resources/public/css/fonts/KaTeX_SansSerif-Italic.woff2 b/resources/public/css/fonts/KaTeX_SansSerif-Italic.woff2 new file mode 100644 index 0000000000..9f2501a3aa Binary files /dev/null and b/resources/public/css/fonts/KaTeX_SansSerif-Italic.woff2 differ diff --git a/resources/public/css/fonts/KaTeX_SansSerif-Regular.ttf b/resources/public/css/fonts/KaTeX_SansSerif-Regular.ttf new file mode 100644 index 0000000000..3be73ce17f Binary files /dev/null and b/resources/public/css/fonts/KaTeX_SansSerif-Regular.ttf differ diff --git a/resources/public/css/fonts/KaTeX_SansSerif-Regular.woff b/resources/public/css/fonts/KaTeX_SansSerif-Regular.woff new file mode 100644 index 0000000000..ec283f418b Binary files /dev/null and b/resources/public/css/fonts/KaTeX_SansSerif-Regular.woff differ diff --git a/resources/public/css/fonts/KaTeX_SansSerif-Regular.woff2 b/resources/public/css/fonts/KaTeX_SansSerif-Regular.woff2 new file mode 100644 index 0000000000..e46094fba1 Binary files /dev/null and b/resources/public/css/fonts/KaTeX_SansSerif-Regular.woff2 differ diff --git a/resources/public/css/fonts/KaTeX_Script-Regular.ttf b/resources/public/css/fonts/KaTeX_Script-Regular.ttf new file mode 100644 index 0000000000..40c8a997ac Binary files /dev/null and b/resources/public/css/fonts/KaTeX_Script-Regular.ttf differ diff --git a/resources/public/css/fonts/KaTeX_Script-Regular.woff b/resources/public/css/fonts/KaTeX_Script-Regular.woff new file mode 100644 index 0000000000..4eafae7583 Binary files /dev/null and b/resources/public/css/fonts/KaTeX_Script-Regular.woff differ diff --git a/resources/public/css/fonts/KaTeX_Script-Regular.woff2 b/resources/public/css/fonts/KaTeX_Script-Regular.woff2 new file mode 100644 index 0000000000..69b1754d7e Binary files /dev/null and b/resources/public/css/fonts/KaTeX_Script-Regular.woff2 differ diff --git a/resources/public/css/fonts/KaTeX_Size1-Regular.ttf b/resources/public/css/fonts/KaTeX_Size1-Regular.ttf new file mode 100644 index 0000000000..f0aff83efb Binary files /dev/null and b/resources/public/css/fonts/KaTeX_Size1-Regular.ttf differ diff --git a/resources/public/css/fonts/KaTeX_Size1-Regular.woff b/resources/public/css/fonts/KaTeX_Size1-Regular.woff new file mode 100644 index 0000000000..0358ee4a3e Binary files /dev/null and b/resources/public/css/fonts/KaTeX_Size1-Regular.woff differ diff --git a/resources/public/css/fonts/KaTeX_Size1-Regular.woff2 b/resources/public/css/fonts/KaTeX_Size1-Regular.woff2 new file mode 100644 index 0000000000..f951ed0169 Binary files /dev/null and b/resources/public/css/fonts/KaTeX_Size1-Regular.woff2 differ diff --git a/resources/public/css/fonts/KaTeX_Size2-Regular.ttf b/resources/public/css/fonts/KaTeX_Size2-Regular.ttf new file mode 100644 index 0000000000..4f72f16795 Binary files /dev/null and b/resources/public/css/fonts/KaTeX_Size2-Regular.ttf differ diff --git a/resources/public/css/fonts/KaTeX_Size2-Regular.woff b/resources/public/css/fonts/KaTeX_Size2-Regular.woff new file mode 100644 index 0000000000..8a053d23ae Binary files /dev/null and b/resources/public/css/fonts/KaTeX_Size2-Regular.woff differ diff --git a/resources/public/css/fonts/KaTeX_Size2-Regular.woff2 b/resources/public/css/fonts/KaTeX_Size2-Regular.woff2 new file mode 100644 index 0000000000..181d9625a7 Binary files /dev/null and b/resources/public/css/fonts/KaTeX_Size2-Regular.woff2 differ diff --git a/resources/public/css/fonts/KaTeX_Size3-Regular.ttf b/resources/public/css/fonts/KaTeX_Size3-Regular.ttf new file mode 100644 index 0000000000..56d2dc6c5d Binary files /dev/null and b/resources/public/css/fonts/KaTeX_Size3-Regular.ttf differ diff --git a/resources/public/css/fonts/KaTeX_Size3-Regular.woff b/resources/public/css/fonts/KaTeX_Size3-Regular.woff new file mode 100644 index 0000000000..0ec99ad1a9 Binary files /dev/null and b/resources/public/css/fonts/KaTeX_Size3-Regular.woff differ diff --git a/resources/public/css/fonts/KaTeX_Size3-Regular.woff2 b/resources/public/css/fonts/KaTeX_Size3-Regular.woff2 new file mode 100644 index 0000000000..c2985cd380 Binary files /dev/null and b/resources/public/css/fonts/KaTeX_Size3-Regular.woff2 differ diff --git a/resources/public/css/fonts/KaTeX_Size4-Regular.ttf b/resources/public/css/fonts/KaTeX_Size4-Regular.ttf new file mode 100644 index 0000000000..baf02091aa Binary files /dev/null and b/resources/public/css/fonts/KaTeX_Size4-Regular.ttf differ diff --git a/resources/public/css/fonts/KaTeX_Size4-Regular.woff b/resources/public/css/fonts/KaTeX_Size4-Regular.woff new file mode 100644 index 0000000000..ff6731972f Binary files /dev/null and b/resources/public/css/fonts/KaTeX_Size4-Regular.woff differ diff --git a/resources/public/css/fonts/KaTeX_Size4-Regular.woff2 b/resources/public/css/fonts/KaTeX_Size4-Regular.woff2 new file mode 100644 index 0000000000..a4e810da5e Binary files /dev/null and b/resources/public/css/fonts/KaTeX_Size4-Regular.woff2 differ diff --git a/resources/public/css/fonts/KaTeX_Typewriter-Regular.ttf b/resources/public/css/fonts/KaTeX_Typewriter-Regular.ttf new file mode 100644 index 0000000000..e66c218df5 Binary files /dev/null and b/resources/public/css/fonts/KaTeX_Typewriter-Regular.ttf differ diff --git a/resources/public/css/fonts/KaTeX_Typewriter-Regular.woff b/resources/public/css/fonts/KaTeX_Typewriter-Regular.woff new file mode 100644 index 0000000000..c66d149d5e Binary files /dev/null and b/resources/public/css/fonts/KaTeX_Typewriter-Regular.woff differ diff --git a/resources/public/css/fonts/KaTeX_Typewriter-Regular.woff2 b/resources/public/css/fonts/KaTeX_Typewriter-Regular.woff2 new file mode 100644 index 0000000000..e5bf2ce1ff Binary files /dev/null and b/resources/public/css/fonts/KaTeX_Typewriter-Regular.woff2 differ diff --git a/resources/public/css/katex.min.css b/resources/public/css/katex.min.css new file mode 100644 index 0000000000..98b7c7bde8 --- /dev/null +++ b/resources/public/css/katex.min.css @@ -0,0 +1 @@ +@font-face{font-family:KaTeX_AMS;src:url(fonts/KaTeX_AMS-Regular.woff2) format("woff2"),url(fonts/KaTeX_AMS-Regular.woff) format("woff"),url(fonts/KaTeX_AMS-Regular.ttf) format("truetype");font-weight:400;font-style:normal}@font-face{font-family:KaTeX_Caligraphic;src:url(fonts/KaTeX_Caligraphic-Bold.woff2) format("woff2"),url(fonts/KaTeX_Caligraphic-Bold.woff) format("woff"),url(fonts/KaTeX_Caligraphic-Bold.ttf) format("truetype");font-weight:700;font-style:normal}@font-face{font-family:KaTeX_Caligraphic;src:url(fonts/KaTeX_Caligraphic-Regular.woff2) format("woff2"),url(fonts/KaTeX_Caligraphic-Regular.woff) format("woff"),url(fonts/KaTeX_Caligraphic-Regular.ttf) format("truetype");font-weight:400;font-style:normal}@font-face{font-family:KaTeX_Fraktur;src:url(fonts/KaTeX_Fraktur-Bold.woff2) format("woff2"),url(fonts/KaTeX_Fraktur-Bold.woff) format("woff"),url(fonts/KaTeX_Fraktur-Bold.ttf) format("truetype");font-weight:700;font-style:normal}@font-face{font-family:KaTeX_Fraktur;src:url(fonts/KaTeX_Fraktur-Regular.woff2) format("woff2"),url(fonts/KaTeX_Fraktur-Regular.woff) format("woff"),url(fonts/KaTeX_Fraktur-Regular.ttf) format("truetype");font-weight:400;font-style:normal}@font-face{font-family:KaTeX_Main;src:url(fonts/KaTeX_Main-Bold.woff2) format("woff2"),url(fonts/KaTeX_Main-Bold.woff) format("woff"),url(fonts/KaTeX_Main-Bold.ttf) format("truetype");font-weight:700;font-style:normal}@font-face{font-family:KaTeX_Main;src:url(fonts/KaTeX_Main-BoldItalic.woff2) format("woff2"),url(fonts/KaTeX_Main-BoldItalic.woff) format("woff"),url(fonts/KaTeX_Main-BoldItalic.ttf) format("truetype");font-weight:700;font-style:italic}@font-face{font-family:KaTeX_Main;src:url(fonts/KaTeX_Main-Italic.woff2) format("woff2"),url(fonts/KaTeX_Main-Italic.woff) format("woff"),url(fonts/KaTeX_Main-Italic.ttf) format("truetype");font-weight:400;font-style:italic}@font-face{font-family:KaTeX_Main;src:url(fonts/KaTeX_Main-Regular.woff2) format("woff2"),url(fonts/KaTeX_Main-Regular.woff) format("woff"),url(fonts/KaTeX_Main-Regular.ttf) format("truetype");font-weight:400;font-style:normal}@font-face{font-family:KaTeX_Math;src:url(fonts/KaTeX_Math-BoldItalic.woff2) format("woff2"),url(fonts/KaTeX_Math-BoldItalic.woff) format("woff"),url(fonts/KaTeX_Math-BoldItalic.ttf) format("truetype");font-weight:700;font-style:italic}@font-face{font-family:KaTeX_Math;src:url(fonts/KaTeX_Math-Italic.woff2) format("woff2"),url(fonts/KaTeX_Math-Italic.woff) format("woff"),url(fonts/KaTeX_Math-Italic.ttf) format("truetype");font-weight:400;font-style:italic}@font-face{font-family:"KaTeX_SansSerif";src:url(fonts/KaTeX_SansSerif-Bold.woff2) format("woff2"),url(fonts/KaTeX_SansSerif-Bold.woff) format("woff"),url(fonts/KaTeX_SansSerif-Bold.ttf) format("truetype");font-weight:700;font-style:normal}@font-face{font-family:"KaTeX_SansSerif";src:url(fonts/KaTeX_SansSerif-Italic.woff2) format("woff2"),url(fonts/KaTeX_SansSerif-Italic.woff) format("woff"),url(fonts/KaTeX_SansSerif-Italic.ttf) format("truetype");font-weight:400;font-style:italic}@font-face{font-family:"KaTeX_SansSerif";src:url(fonts/KaTeX_SansSerif-Regular.woff2) format("woff2"),url(fonts/KaTeX_SansSerif-Regular.woff) format("woff"),url(fonts/KaTeX_SansSerif-Regular.ttf) format("truetype");font-weight:400;font-style:normal}@font-face{font-family:KaTeX_Script;src:url(fonts/KaTeX_Script-Regular.woff2) format("woff2"),url(fonts/KaTeX_Script-Regular.woff) format("woff"),url(fonts/KaTeX_Script-Regular.ttf) format("truetype");font-weight:400;font-style:normal}@font-face{font-family:KaTeX_Size1;src:url(fonts/KaTeX_Size1-Regular.woff2) format("woff2"),url(fonts/KaTeX_Size1-Regular.woff) format("woff"),url(fonts/KaTeX_Size1-Regular.ttf) format("truetype");font-weight:400;font-style:normal}@font-face{font-family:KaTeX_Size2;src:url(fonts/KaTeX_Size2-Regular.woff2) format("woff2"),url(fonts/KaTeX_Size2-Regular.woff) format("woff"),url(fonts/KaTeX_Size2-Regular.ttf) format("truetype");font-weight:400;font-style:normal}@font-face{font-family:KaTeX_Size3;src:url(fonts/KaTeX_Size3-Regular.woff2) format("woff2"),url(fonts/KaTeX_Size3-Regular.woff) format("woff"),url(fonts/KaTeX_Size3-Regular.ttf) format("truetype");font-weight:400;font-style:normal}@font-face{font-family:KaTeX_Size4;src:url(fonts/KaTeX_Size4-Regular.woff2) format("woff2"),url(fonts/KaTeX_Size4-Regular.woff) format("woff"),url(fonts/KaTeX_Size4-Regular.ttf) format("truetype");font-weight:400;font-style:normal}@font-face{font-family:KaTeX_Typewriter;src:url(fonts/KaTeX_Typewriter-Regular.woff2) format("woff2"),url(fonts/KaTeX_Typewriter-Regular.woff) format("woff"),url(fonts/KaTeX_Typewriter-Regular.ttf) format("truetype");font-weight:400;font-style:normal}.katex{font:normal 1.21em KaTeX_Main,Times New Roman,serif;line-height:1.2;text-indent:0;text-rendering:auto;border-color:currentColor}.katex *{-ms-high-contrast-adjust:none!important}.katex .katex-version:after{content:"0.12.0"}.katex .katex-mathml{position:absolute;clip:rect(1px,1px,1px,1px);padding:0;border:0;height:1px;width:1px;overflow:hidden}.katex .katex-html>.newline{display:block}.katex .base{position:relative;white-space:nowrap;width:min-content}.katex .base,.katex .strut{display:inline-block}.katex .textbf{font-weight:700}.katex .textit{font-style:italic}.katex .textrm{font-family:KaTeX_Main}.katex .textsf{font-family:KaTeX_SansSerif}.katex .texttt{font-family:KaTeX_Typewriter}.katex .mathnormal{font-family:KaTeX_Math;font-style:italic}.katex .mathit{font-family:KaTeX_Main;font-style:italic}.katex .mathrm{font-style:normal}.katex .mathbf{font-family:KaTeX_Main;font-weight:700}.katex .boldsymbol{font-family:KaTeX_Math;font-weight:700;font-style:italic}.katex .amsrm,.katex .mathbb,.katex .textbb{font-family:KaTeX_AMS}.katex .mathcal{font-family:KaTeX_Caligraphic}.katex .mathfrak,.katex .textfrak{font-family:KaTeX_Fraktur}.katex .mathtt{font-family:KaTeX_Typewriter}.katex .mathscr,.katex .textscr{font-family:KaTeX_Script}.katex .mathsf,.katex .textsf{font-family:KaTeX_SansSerif}.katex .mathboldsf,.katex .textboldsf{font-family:KaTeX_SansSerif;font-weight:700}.katex .mathitsf,.katex .textitsf{font-family:KaTeX_SansSerif;font-style:italic}.katex .mainrm{font-family:KaTeX_Main;font-style:normal}.katex .vlist-t{display:inline-table;table-layout:fixed;border-collapse:collapse}.katex .vlist-r{display:table-row}.katex .vlist{display:table-cell;vertical-align:bottom;position:relative}.katex .vlist>span{display:block;height:0;position:relative}.katex .vlist>span>span{display:inline-block}.katex .vlist>span>.pstrut{overflow:hidden;width:0}.katex .vlist-t2{margin-right:-2px}.katex .vlist-s{display:table-cell;vertical-align:bottom;font-size:1px;width:2px;min-width:2px}.katex .vbox{-ms-flex-direction:column;flex-direction:column;align-items:baseline}.katex .hbox,.katex .vbox{display:-ms-inline-flexbox;display:inline-flex}.katex .hbox{-ms-flex-direction:row;flex-direction:row;width:100%}.katex .thinbox{display:inline-flex;flex-direction:row;width:0;max-width:0}.katex .msupsub{text-align:left}.katex .mfrac>span>span{text-align:center}.katex .mfrac .frac-line{display:inline-block;width:100%;border-bottom-style:solid}.katex .hdashline,.katex .hline,.katex .mfrac .frac-line,.katex .overline .overline-line,.katex .rule,.katex .underline .underline-line{min-height:1px}.katex .mspace{display:inline-block}.katex .clap,.katex .llap,.katex .rlap{width:0;position:relative}.katex .clap>.inner,.katex .llap>.inner,.katex .rlap>.inner{position:absolute}.katex .clap>.fix,.katex .llap>.fix,.katex .rlap>.fix{display:inline-block}.katex .llap>.inner{right:0}.katex .clap>.inner,.katex .rlap>.inner{left:0}.katex .clap>.inner>span{margin-left:-50%;margin-right:50%}.katex .rule{display:inline-block;border:0 solid;position:relative}.katex .hline,.katex .overline .overline-line,.katex .underline .underline-line{display:inline-block;width:100%;border-bottom-style:solid}.katex .hdashline{display:inline-block;width:100%;border-bottom-style:dashed}.katex .sqrt>.root{margin-left:.27777778em;margin-right:-.55555556em}.katex .fontsize-ensurer.reset-size1.size1,.katex .sizing.reset-size1.size1{font-size:1em}.katex .fontsize-ensurer.reset-size1.size2,.katex .sizing.reset-size1.size2{font-size:1.2em}.katex .fontsize-ensurer.reset-size1.size3,.katex .sizing.reset-size1.size3{font-size:1.4em}.katex .fontsize-ensurer.reset-size1.size4,.katex .sizing.reset-size1.size4{font-size:1.6em}.katex .fontsize-ensurer.reset-size1.size5,.katex .sizing.reset-size1.size5{font-size:1.8em}.katex .fontsize-ensurer.reset-size1.size6,.katex .sizing.reset-size1.size6{font-size:2em}.katex .fontsize-ensurer.reset-size1.size7,.katex .sizing.reset-size1.size7{font-size:2.4em}.katex .fontsize-ensurer.reset-size1.size8,.katex .sizing.reset-size1.size8{font-size:2.88em}.katex .fontsize-ensurer.reset-size1.size9,.katex .sizing.reset-size1.size9{font-size:3.456em}.katex .fontsize-ensurer.reset-size1.size10,.katex .sizing.reset-size1.size10{font-size:4.148em}.katex .fontsize-ensurer.reset-size1.size11,.katex .sizing.reset-size1.size11{font-size:4.976em}.katex .fontsize-ensurer.reset-size2.size1,.katex .sizing.reset-size2.size1{font-size:.83333333em}.katex .fontsize-ensurer.reset-size2.size2,.katex .sizing.reset-size2.size2{font-size:1em}.katex .fontsize-ensurer.reset-size2.size3,.katex .sizing.reset-size2.size3{font-size:1.16666667em}.katex .fontsize-ensurer.reset-size2.size4,.katex .sizing.reset-size2.size4{font-size:1.33333333em}.katex .fontsize-ensurer.reset-size2.size5,.katex .sizing.reset-size2.size5{font-size:1.5em}.katex .fontsize-ensurer.reset-size2.size6,.katex .sizing.reset-size2.size6{font-size:1.66666667em}.katex .fontsize-ensurer.reset-size2.size7,.katex .sizing.reset-size2.size7{font-size:2em}.katex .fontsize-ensurer.reset-size2.size8,.katex .sizing.reset-size2.size8{font-size:2.4em}.katex .fontsize-ensurer.reset-size2.size9,.katex .sizing.reset-size2.size9{font-size:2.88em}.katex .fontsize-ensurer.reset-size2.size10,.katex .sizing.reset-size2.size10{font-size:3.45666667em}.katex .fontsize-ensurer.reset-size2.size11,.katex .sizing.reset-size2.size11{font-size:4.14666667em}.katex .fontsize-ensurer.reset-size3.size1,.katex .sizing.reset-size3.size1{font-size:.71428571em}.katex .fontsize-ensurer.reset-size3.size2,.katex .sizing.reset-size3.size2{font-size:.85714286em}.katex .fontsize-ensurer.reset-size3.size3,.katex .sizing.reset-size3.size3{font-size:1em}.katex .fontsize-ensurer.reset-size3.size4,.katex .sizing.reset-size3.size4{font-size:1.14285714em}.katex .fontsize-ensurer.reset-size3.size5,.katex .sizing.reset-size3.size5{font-size:1.28571429em}.katex .fontsize-ensurer.reset-size3.size6,.katex .sizing.reset-size3.size6{font-size:1.42857143em}.katex .fontsize-ensurer.reset-size3.size7,.katex .sizing.reset-size3.size7{font-size:1.71428571em}.katex .fontsize-ensurer.reset-size3.size8,.katex .sizing.reset-size3.size8{font-size:2.05714286em}.katex .fontsize-ensurer.reset-size3.size9,.katex .sizing.reset-size3.size9{font-size:2.46857143em}.katex .fontsize-ensurer.reset-size3.size10,.katex .sizing.reset-size3.size10{font-size:2.96285714em}.katex .fontsize-ensurer.reset-size3.size11,.katex .sizing.reset-size3.size11{font-size:3.55428571em}.katex .fontsize-ensurer.reset-size4.size1,.katex .sizing.reset-size4.size1{font-size:.625em}.katex .fontsize-ensurer.reset-size4.size2,.katex .sizing.reset-size4.size2{font-size:.75em}.katex .fontsize-ensurer.reset-size4.size3,.katex .sizing.reset-size4.size3{font-size:.875em}.katex .fontsize-ensurer.reset-size4.size4,.katex .sizing.reset-size4.size4{font-size:1em}.katex .fontsize-ensurer.reset-size4.size5,.katex .sizing.reset-size4.size5{font-size:1.125em}.katex .fontsize-ensurer.reset-size4.size6,.katex .sizing.reset-size4.size6{font-size:1.25em}.katex .fontsize-ensurer.reset-size4.size7,.katex .sizing.reset-size4.size7{font-size:1.5em}.katex .fontsize-ensurer.reset-size4.size8,.katex .sizing.reset-size4.size8{font-size:1.8em}.katex .fontsize-ensurer.reset-size4.size9,.katex .sizing.reset-size4.size9{font-size:2.16em}.katex .fontsize-ensurer.reset-size4.size10,.katex .sizing.reset-size4.size10{font-size:2.5925em}.katex .fontsize-ensurer.reset-size4.size11,.katex .sizing.reset-size4.size11{font-size:3.11em}.katex .fontsize-ensurer.reset-size5.size1,.katex .sizing.reset-size5.size1{font-size:.55555556em}.katex .fontsize-ensurer.reset-size5.size2,.katex .sizing.reset-size5.size2{font-size:.66666667em}.katex .fontsize-ensurer.reset-size5.size3,.katex .sizing.reset-size5.size3{font-size:.77777778em}.katex .fontsize-ensurer.reset-size5.size4,.katex .sizing.reset-size5.size4{font-size:.88888889em}.katex .fontsize-ensurer.reset-size5.size5,.katex .sizing.reset-size5.size5{font-size:1em}.katex .fontsize-ensurer.reset-size5.size6,.katex .sizing.reset-size5.size6{font-size:1.11111111em}.katex .fontsize-ensurer.reset-size5.size7,.katex .sizing.reset-size5.size7{font-size:1.33333333em}.katex .fontsize-ensurer.reset-size5.size8,.katex .sizing.reset-size5.size8{font-size:1.6em}.katex .fontsize-ensurer.reset-size5.size9,.katex .sizing.reset-size5.size9{font-size:1.92em}.katex .fontsize-ensurer.reset-size5.size10,.katex .sizing.reset-size5.size10{font-size:2.30444444em}.katex .fontsize-ensurer.reset-size5.size11,.katex .sizing.reset-size5.size11{font-size:2.76444444em}.katex .fontsize-ensurer.reset-size6.size1,.katex .sizing.reset-size6.size1{font-size:.5em}.katex .fontsize-ensurer.reset-size6.size2,.katex .sizing.reset-size6.size2{font-size:.6em}.katex .fontsize-ensurer.reset-size6.size3,.katex .sizing.reset-size6.size3{font-size:.7em}.katex .fontsize-ensurer.reset-size6.size4,.katex .sizing.reset-size6.size4{font-size:.8em}.katex .fontsize-ensurer.reset-size6.size5,.katex .sizing.reset-size6.size5{font-size:.9em}.katex .fontsize-ensurer.reset-size6.size6,.katex .sizing.reset-size6.size6{font-size:1em}.katex .fontsize-ensurer.reset-size6.size7,.katex .sizing.reset-size6.size7{font-size:1.2em}.katex .fontsize-ensurer.reset-size6.size8,.katex .sizing.reset-size6.size8{font-size:1.44em}.katex .fontsize-ensurer.reset-size6.size9,.katex .sizing.reset-size6.size9{font-size:1.728em}.katex .fontsize-ensurer.reset-size6.size10,.katex .sizing.reset-size6.size10{font-size:2.074em}.katex .fontsize-ensurer.reset-size6.size11,.katex .sizing.reset-size6.size11{font-size:2.488em}.katex .fontsize-ensurer.reset-size7.size1,.katex .sizing.reset-size7.size1{font-size:.41666667em}.katex .fontsize-ensurer.reset-size7.size2,.katex .sizing.reset-size7.size2{font-size:.5em}.katex .fontsize-ensurer.reset-size7.size3,.katex .sizing.reset-size7.size3{font-size:.58333333em}.katex .fontsize-ensurer.reset-size7.size4,.katex .sizing.reset-size7.size4{font-size:.66666667em}.katex .fontsize-ensurer.reset-size7.size5,.katex .sizing.reset-size7.size5{font-size:.75em}.katex .fontsize-ensurer.reset-size7.size6,.katex .sizing.reset-size7.size6{font-size:.83333333em}.katex .fontsize-ensurer.reset-size7.size7,.katex .sizing.reset-size7.size7{font-size:1em}.katex .fontsize-ensurer.reset-size7.size8,.katex .sizing.reset-size7.size8{font-size:1.2em}.katex .fontsize-ensurer.reset-size7.size9,.katex .sizing.reset-size7.size9{font-size:1.44em}.katex .fontsize-ensurer.reset-size7.size10,.katex .sizing.reset-size7.size10{font-size:1.72833333em}.katex .fontsize-ensurer.reset-size7.size11,.katex .sizing.reset-size7.size11{font-size:2.07333333em}.katex .fontsize-ensurer.reset-size8.size1,.katex .sizing.reset-size8.size1{font-size:.34722222em}.katex .fontsize-ensurer.reset-size8.size2,.katex .sizing.reset-size8.size2{font-size:.41666667em}.katex .fontsize-ensurer.reset-size8.size3,.katex .sizing.reset-size8.size3{font-size:.48611111em}.katex .fontsize-ensurer.reset-size8.size4,.katex .sizing.reset-size8.size4{font-size:.55555556em}.katex .fontsize-ensurer.reset-size8.size5,.katex .sizing.reset-size8.size5{font-size:.625em}.katex .fontsize-ensurer.reset-size8.size6,.katex .sizing.reset-size8.size6{font-size:.69444444em}.katex .fontsize-ensurer.reset-size8.size7,.katex .sizing.reset-size8.size7{font-size:.83333333em}.katex .fontsize-ensurer.reset-size8.size8,.katex .sizing.reset-size8.size8{font-size:1em}.katex .fontsize-ensurer.reset-size8.size9,.katex .sizing.reset-size8.size9{font-size:1.2em}.katex .fontsize-ensurer.reset-size8.size10,.katex .sizing.reset-size8.size10{font-size:1.44027778em}.katex .fontsize-ensurer.reset-size8.size11,.katex .sizing.reset-size8.size11{font-size:1.72777778em}.katex .fontsize-ensurer.reset-size9.size1,.katex .sizing.reset-size9.size1{font-size:.28935185em}.katex .fontsize-ensurer.reset-size9.size2,.katex .sizing.reset-size9.size2{font-size:.34722222em}.katex .fontsize-ensurer.reset-size9.size3,.katex .sizing.reset-size9.size3{font-size:.40509259em}.katex .fontsize-ensurer.reset-size9.size4,.katex .sizing.reset-size9.size4{font-size:.46296296em}.katex .fontsize-ensurer.reset-size9.size5,.katex .sizing.reset-size9.size5{font-size:.52083333em}.katex .fontsize-ensurer.reset-size9.size6,.katex .sizing.reset-size9.size6{font-size:.5787037em}.katex .fontsize-ensurer.reset-size9.size7,.katex .sizing.reset-size9.size7{font-size:.69444444em}.katex .fontsize-ensurer.reset-size9.size8,.katex .sizing.reset-size9.size8{font-size:.83333333em}.katex .fontsize-ensurer.reset-size9.size9,.katex .sizing.reset-size9.size9{font-size:1em}.katex .fontsize-ensurer.reset-size9.size10,.katex .sizing.reset-size9.size10{font-size:1.20023148em}.katex .fontsize-ensurer.reset-size9.size11,.katex .sizing.reset-size9.size11{font-size:1.43981481em}.katex .fontsize-ensurer.reset-size10.size1,.katex .sizing.reset-size10.size1{font-size:.24108004em}.katex .fontsize-ensurer.reset-size10.size2,.katex .sizing.reset-size10.size2{font-size:.28929605em}.katex .fontsize-ensurer.reset-size10.size3,.katex .sizing.reset-size10.size3{font-size:.33751205em}.katex .fontsize-ensurer.reset-size10.size4,.katex .sizing.reset-size10.size4{font-size:.38572806em}.katex .fontsize-ensurer.reset-size10.size5,.katex .sizing.reset-size10.size5{font-size:.43394407em}.katex .fontsize-ensurer.reset-size10.size6,.katex .sizing.reset-size10.size6{font-size:.48216008em}.katex .fontsize-ensurer.reset-size10.size7,.katex .sizing.reset-size10.size7{font-size:.57859209em}.katex .fontsize-ensurer.reset-size10.size8,.katex .sizing.reset-size10.size8{font-size:.69431051em}.katex .fontsize-ensurer.reset-size10.size9,.katex .sizing.reset-size10.size9{font-size:.83317261em}.katex .fontsize-ensurer.reset-size10.size10,.katex .sizing.reset-size10.size10{font-size:1em}.katex .fontsize-ensurer.reset-size10.size11,.katex .sizing.reset-size10.size11{font-size:1.19961427em}.katex .fontsize-ensurer.reset-size11.size1,.katex .sizing.reset-size11.size1{font-size:.20096463em}.katex .fontsize-ensurer.reset-size11.size2,.katex .sizing.reset-size11.size2{font-size:.24115756em}.katex .fontsize-ensurer.reset-size11.size3,.katex .sizing.reset-size11.size3{font-size:.28135048em}.katex .fontsize-ensurer.reset-size11.size4,.katex .sizing.reset-size11.size4{font-size:.32154341em}.katex .fontsize-ensurer.reset-size11.size5,.katex .sizing.reset-size11.size5{font-size:.36173633em}.katex .fontsize-ensurer.reset-size11.size6,.katex .sizing.reset-size11.size6{font-size:.40192926em}.katex .fontsize-ensurer.reset-size11.size7,.katex .sizing.reset-size11.size7{font-size:.48231511em}.katex .fontsize-ensurer.reset-size11.size8,.katex .sizing.reset-size11.size8{font-size:.57877814em}.katex .fontsize-ensurer.reset-size11.size9,.katex .sizing.reset-size11.size9{font-size:.69453376em}.katex .fontsize-ensurer.reset-size11.size10,.katex .sizing.reset-size11.size10{font-size:.83360129em}.katex .fontsize-ensurer.reset-size11.size11,.katex .sizing.reset-size11.size11{font-size:1em}.katex .delimsizing.size1{font-family:KaTeX_Size1}.katex .delimsizing.size2{font-family:KaTeX_Size2}.katex .delimsizing.size3{font-family:KaTeX_Size3}.katex .delimsizing.size4{font-family:KaTeX_Size4}.katex .delimsizing.mult .delim-size1>span{font-family:KaTeX_Size1}.katex .delimsizing.mult .delim-size4>span{font-family:KaTeX_Size4}.katex .nulldelimiter{display:inline-block;width:.12em}.katex .delimcenter,.katex .op-symbol{position:relative}.katex .op-symbol.small-op{font-family:KaTeX_Size1}.katex .op-symbol.large-op{font-family:KaTeX_Size2}.katex .op-limits>.vlist-t{text-align:center}.katex .accent>.vlist-t{text-align:center}.katex .accent .accent-body{position:relative}.katex .accent .accent-body:not(.accent-full){width:0}.katex .overlay{display:block}.katex .mtable .vertical-separator{display:inline-block;min-width:1px}.katex .mtable .arraycolsep{display:inline-block}.katex .mtable .col-align-c>.vlist-t{text-align:center}.katex .mtable .col-align-l>.vlist-t{text-align:left}.katex .mtable .col-align-r>.vlist-t{text-align:right}.katex .svg-align{text-align:left}.katex svg{display:block;position:absolute;width:100%;height:inherit;fill:currentColor;stroke:currentColor;fill-rule:nonzero;fill-opacity:1;stroke-width:1;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1}.katex svg path{stroke:none}.katex img{border-style:none;min-width:0;min-height:0;max-width:none;max-height:none}.katex .stretchy{width:100%;display:block;position:relative;overflow:hidden}.katex .stretchy:after,.katex .stretchy:before{content:""}.katex .hide-tail{width:100%;position:relative;overflow:hidden}.katex .halfarrow-left{position:absolute;left:0;width:50.2%;overflow:hidden}.katex .halfarrow-right{position:absolute;right:0;width:50.2%;overflow:hidden}.katex .brace-left{position:absolute;left:0;width:25.1%;overflow:hidden}.katex .brace-center{position:absolute;left:25%;width:50%;overflow:hidden}.katex .brace-right{position:absolute;right:0;width:25.1%;overflow:hidden}.katex .x-arrow-pad{padding:0 .5em}.katex .mover,.katex .munder,.katex .x-arrow{text-align:center}.katex .boxpad{padding:0 .3em}.katex .fbox,.katex .fcolorbox{box-sizing:border-box;border:.04em solid}.katex .cancel-pad{padding:0 .2em}.katex .cancel-lap{margin-left:-.2em;margin-right:-.2em}.katex .sout{border-bottom-style:solid;border-bottom-width:.08em}.katex-display{display:block;margin:1em 0;text-align:center}.katex-display>.katex{display:block;text-align:center;white-space:nowrap}.katex-display>.katex>.katex-html{display:block;position:relative}.katex-display>.katex>.katex-html>.tag{position:absolute;right:0}.katex-display.leqno>.katex>.katex-html>.tag{left:0;right:auto}.katex-display.fleqn>.katex{text-align:left;padding-left:2em} diff --git a/resources/public/css/normalize.css b/resources/public/css/normalize.css new file mode 100644 index 0000000000..b0c1902dc6 --- /dev/null +++ b/resources/public/css/normalize.css @@ -0,0 +1,349 @@ +/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */ + +/* Document + ========================================================================== */ + +/** + * 1. Correct the line height in all browsers. + * 2. Prevent adjustments of font size after orientation changes in iOS. + */ + +html { + line-height: 1.15; /* 1 */ + -webkit-text-size-adjust: 100%; /* 2 */ +} + +/* Sections + ========================================================================== */ + +/** + * Remove the margin in all browsers. + */ + +body { + margin: 0; +} + +/** + * Render the `main` element consistently in IE. + */ + +main { + display: block; +} + +/** + * Correct the font size and margin on `h1` elements within `section` and + * `article` contexts in Chrome, Firefox, and Safari. + */ + +h1 { + font-size: 2em; + margin: 0.67em 0; +} + +/* Grouping content + ========================================================================== */ + +/** + * 1. Add the correct box sizing in Firefox. + * 2. Show the overflow in Edge and IE. + */ + +hr { + box-sizing: content-box; /* 1 */ + height: 0; /* 1 */ + overflow: visible; /* 2 */ +} + +/** + * 1. Correct the inheritance and scaling of font size in all browsers. + * 2. Correct the odd `em` font sizing in all browsers. + */ + +pre { + font-family: monospace, monospace; /* 1 */ + font-size: 1em; /* 2 */ +} + +/* Text-level semantics + ========================================================================== */ + +/** + * Remove the gray background on active links in IE 10. + */ + +a { + background-color: transparent; +} + +/** + * 1. Remove the bottom border in Chrome 57- + * 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari. + */ + +abbr[title] { + border-bottom: none; /* 1 */ + text-decoration: underline; /* 2 */ + text-decoration: underline dotted; /* 2 */ +} + +/** + * Add the correct font weight in Chrome, Edge, and Safari. + */ + +b, +strong { + font-weight: bolder; +} + +/** + * 1. Correct the inheritance and scaling of font size in all browsers. + * 2. Correct the odd `em` font sizing in all browsers. + */ + +code, +kbd, +samp { + font-family: monospace, monospace; /* 1 */ + font-size: 1em; /* 2 */ +} + +/** + * Add the correct font size in all browsers. + */ + +small { + font-size: 80%; +} + +/** + * Prevent `sub` and `sup` elements from affecting the line height in + * all browsers. + */ + +sub, +sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; +} + +sub { + bottom: -0.25em; +} + +sup { + top: -0.5em; +} + +/* Embedded content + ========================================================================== */ + +/** + * Remove the border on images inside links in IE 10. + */ + +img { + border-style: none; +} + +/* Forms + ========================================================================== */ + +/** + * 1. Change the font styles in all browsers. + * 2. Remove the margin in Firefox and Safari. + */ + +button, +input, +optgroup, +select, +textarea { + font-family: inherit; /* 1 */ + font-size: 100%; /* 1 */ + line-height: 1.15; /* 1 */ + margin: 0; /* 2 */ +} + +/** + * Show the overflow in IE. + * 1. Show the overflow in Edge. + */ + +button, +input { /* 1 */ + overflow: visible; +} + +/** + * Remove the inheritance of text transform in Edge, Firefox, and IE. + * 1. Remove the inheritance of text transform in Firefox. + */ + +button, +select { /* 1 */ + text-transform: none; +} + +/** + * Correct the inability to style clickable types in iOS and Safari. + */ + +button, +[type="button"], +[type="reset"], +[type="submit"] { + -webkit-appearance: button; +} + +/** + * Remove the inner border and padding in Firefox. + */ + +button::-moz-focus-inner, +[type="button"]::-moz-focus-inner, +[type="reset"]::-moz-focus-inner, +[type="submit"]::-moz-focus-inner { + border-style: none; + padding: 0; +} + +/** + * Restore the focus styles unset by the previous rule. + */ + +button:-moz-focusring, +[type="button"]:-moz-focusring, +[type="reset"]:-moz-focusring, +[type="submit"]:-moz-focusring { + outline: 1px dotted ButtonText; +} + +/** + * Correct the padding in Firefox. + */ + +fieldset { + padding: 0.35em 0.75em 0.625em; +} + +/** + * 1. Correct the text wrapping in Edge and IE. + * 2. Correct the color inheritance from `fieldset` elements in IE. + * 3. Remove the padding so developers are not caught out when they zero out + * `fieldset` elements in all browsers. + */ + +legend { + box-sizing: border-box; /* 1 */ + color: inherit; /* 2 */ + display: table; /* 1 */ + max-width: 100%; /* 1 */ + padding: 0; /* 3 */ + white-space: normal; /* 1 */ +} + +/** + * Add the correct vertical alignment in Chrome, Firefox, and Opera. + */ + +progress { + vertical-align: baseline; +} + +/** + * Remove the default vertical scrollbar in IE 10+. + */ + +textarea { + overflow: auto; +} + +/** + * 1. Add the correct box sizing in IE 10. + * 2. Remove the padding in IE 10. + */ + +[type="checkbox"], +[type="radio"] { + box-sizing: border-box; /* 1 */ + padding: 0; /* 2 */ +} + +/** + * Correct the cursor style of increment and decrement buttons in Chrome. + */ + +[type="number"]::-webkit-inner-spin-button, +[type="number"]::-webkit-outer-spin-button { + height: auto; +} + +/** + * 1. Correct the odd appearance in Chrome and Safari. + * 2. Correct the outline style in Safari. + */ + +[type="search"] { + -webkit-appearance: textfield; /* 1 */ + outline-offset: -2px; /* 2 */ +} + +/** + * Remove the inner padding in Chrome and Safari on macOS. + */ + +[type="search"]::-webkit-search-decoration { + -webkit-appearance: none; +} + +/** + * 1. Correct the inability to style clickable types in iOS and Safari. + * 2. Change font properties to `inherit` in Safari. + */ + +::-webkit-file-upload-button { + -webkit-appearance: button; /* 1 */ + font: inherit; /* 2 */ +} + +/* Interactive + ========================================================================== */ + +/* + * Add the correct display in Edge, IE 10+, and Firefox. + */ + +details { + display: block; +} + +/* + * Add the correct display in all browsers. + */ + +summary { + display: list-item; +} + +/* Misc + ========================================================================== */ + +/** + * Add the correct display in IE 10+. + */ + +template { + display: none; +} + +/** + * Add the correct display in IE 10. + */ + +[hidden] { + display: none; +} \ No newline at end of file diff --git a/resources/public/favicon.png b/resources/public/favicon.png new file mode 100644 index 0000000000..94946cac9c Binary files /dev/null and b/resources/public/favicon.png differ diff --git a/resources/public/favicon.svg b/resources/public/favicon.svg new file mode 100644 index 0000000000..fbdb8245a3 --- /dev/null +++ b/resources/public/favicon.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/resources/public/index.html b/resources/public/index.html index da59046a1a..d676fd4826 100644 --- a/resources/public/index.html +++ b/resources/public/index.html @@ -3,13 +3,49 @@ - athens + + + + + - -
- +
+
+ + diff --git a/script/build/athens-app b/script/build/athens-app new file mode 100755 index 0000000000..5d3f309fe9 --- /dev/null +++ b/script/build/athens-app @@ -0,0 +1,23 @@ +#!/usr/bin/env bash + +set -eo pipefail + +# Make sure all JS deps are available +yarn + +# Build app (see shadow-cljs.edn config) +yarn prod + +RELEASE_NAME=${RELEASE_NAME:-"athens-app"} + +# Clean before +rm -rf $RELEASE_NAME + +cp -R resources/public $RELEASE_NAME + +tar -zcvf $RELEASE_NAME.tar.gz $RELEASE_NAME + +# Clean after +rm -rf $RELEASE_NAME + + diff --git a/script/docker-run-lan-party.sh b/script/docker-run-lan-party.sh new file mode 100755 index 0000000000..aa20719d49 --- /dev/null +++ b/script/docker-run-lan-party.sh @@ -0,0 +1,7 @@ +#!/bin/sh + +java -Xms512m -Xmx2560m -verbose:gc -XX:-UseParallelGC \ + -XX:OnOutOfMemoryError="kill -9 %p" -XX:+HeapDumpOnOutOfMemoryError \ + -XX:HeapDumpPath=/srv/athens/logs/ \ + -XX:ErrorFile=/srv/athens/logs/java_athens_hs_err_pid%p.log \ + -jar athens-lan-party-standalone.jar diff --git a/script/save-cronjob.sh b/script/save-cronjob.sh new file mode 100644 index 0000000000..ae57b33785 --- /dev/null +++ b/script/save-cronjob.sh @@ -0,0 +1,60 @@ +#!/bin/bash + +: <<'END_COMMENT' + For cron: + + Put a shell script in one of these folders: + /etc/cron.daily, /etc/cron.hourly, /etc/cron.monthly or /etc/cron.weekly. + + If these are not enough for you, you can add more specific tasks e.g. twice a month or every 5 minutes. Go to the terminal and type: + + `crontab -e` + + This will open your personal crontab (cron configuration file). The first line in that file explains it all! In every line you can + define one command to run and its schedule, and the format is quite simple when you get the hang of it. The structure is: + `minute hour day-of-month month day-of-week command` + + For all the numbers you can use lists, e.g. 5,34,55 in the minutes field will mean run at 5 past, 34 past, and 55 past whatever hour is defined. + You can also use intervals. They are defined like this: */20. This example means every 20th, so in the minutes column it is equivalent to 0,20,40. + + So to run a command every Monday at 5:30 in the afternoon: + `30 17 * * 1 /path/to/command` + + or every 15 minutes + `*/15 * * * * /path/to/command` + + Note 1: that the day-of-week goes from 0-6 where 0 is Sunday. + Note 2: These changes are applied automatically, you don't need to restart/reload anything. +END_COMMENT + + +# Following is the script that is needed for the cron + +# This is the default location to save the ledger backups, modify it to change the default location. +# Filesystem Hierarchy https://www.pathname.com/fhs/pub/fhs-2.3.html + + +FOLDER=/var/lib/athens/backups/ + +if [ ! -d "$FOLDER" ]; then + mkdir -p "$FOLDER" +fi + +# Some of the strategies to calculate the filename +# - Timestamp +# - Monotonically increasing no. +# - No. of files in folder + 1 + +# Going with the Timestamp option +TIMESTAMP=$(date +%F-%H-%M) +FILENAME="${FOLDER}${TIMESTAMP}.edn" + +# If java not installed then install it + +if [ -z "$(which java)" ]; then + echo "Java is not installed on your operating system, please install it and try again" + exit 1 +fi + +# command to save ledger +java -jar ~/athens-cli.jar save -f "$FILENAME" diff --git a/script/test/jvm b/script/test/jvm deleted file mode 100755 index 35af74c693..0000000000 --- a/script/test/jvm +++ /dev/null @@ -1,5 +0,0 @@ -#!/usr/bin/env bash - -set -eo pipefail - -lein test-jvm diff --git a/script/vercel-setup.sh b/script/vercel-setup.sh new file mode 100755 index 0000000000..440075325e --- /dev/null +++ b/script/vercel-setup.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash + +# Fail on all sorts of errors. +# https://stackoverflow.com/a/2871034/2116927 +set -euxo pipefail + +# In Vercel -> Project Settings -> Build & Development Settings: +# Build command : yarn vercel:build +# Output directory: vercel-static +# Install command : yarn vercel:install +# +# In Vercel -> Project Settings -> Git +# Release branch: dummy-vercel-web +# Pre-release branch: dummy-vercel-beta +# These are dummy branches that we do not push builds to. +# Instead the `release-web` github actions job manually builds and deploys a prod build when needed. +# The build settings above are still used for the prod build though, and the +# vercel-release/package.json file is meant to provide noop scripts for it. + +# See https://vercel.com/docs/concepts/deployments/build-step#build-image for custom setup instructions. + +# Java 11 is already installed. +java --version + +# Clojure linux installer. +curl -O https://download.clojure.org/install/linux-install-1.10.3.1040.sh +chmod +x linux-install-1.10.3.1040.sh +./linux-install-1.10.3.1040.sh +clojure --version diff --git a/shadow-cljs.edn b/shadow-cljs.edn index b3ea6801d8..b4f7c18f87 100644 --- a/shadow-cljs.edn +++ b/shadow-cljs.edn @@ -1,23 +1,68 @@ -{:lein true - :nrepl {:port 8777} - :builds {:app {:target :browser - :output-dir "resources/public/js/compiled" - :asset-path "/js/compiled" - :modules {:app {:init-fn athens.core/init - :preloads [devtools.preload - day8.re-frame-10x.preload]}} - :dev {:compiler-options - {:closure-defines {re-frame.trace.trace-enabled? true - day8.re-frame.tracing.trace-enabled? true}}} - :devtools {:http-root "resources/public" - :http-port 3000 - :repl-init-ns athens.core}} - :devcards {:asset-path "js/devcards" - :modules {:main {:init-fn athens.devcards/main}} - :compiler-options {:devcards true} - :output-dir "resources/public/js/devcards" - :target :browser} +{:deps {:aliases [:shadow-cljs]} + ;; Don't install npm-deps declared in dependencies. + ;; If we need them, we install them manually. + ;; https://github.com/thheller/shadow-cljs/issues/800#issuecomment-725716087 + :npm-deps {:install false} + :nrepl {:port 8777} + :builds { + ;; pure browser https://athensresearch.github.io/athens + :app {:target :browser + :output-dir "resources/public/js/compiled" + :asset-path "js/compiled" + :modules {:app {:init-fn athens.core/init}} + ;; Don't try to polyfill for generators, we don't try to support older browsers + ;; and it breaks some libraries we use (ForceGraph2D) when other imports change. + ;; https://github.com/thheller/shadow-cljs/issues/854 + :js-options {:babel-preset-config {:targets {:chrome 80}}} + :compiler-options {:closure-warnings {:global-this :off} + :infer-externs :auto + :closure-defines {re-frame.trace.trace-enabled? true} + :output-feature-set :es-next + :external-config {:devtools/config {:features-to-install [:formatters :hints]}}} + :dev {:compiler-options {:closure-defines {re-frame.trace.trace-enabled? true + day8.re-frame.tracing.trace-enabled? true} + ;; Hide redef warnings, we started having around 5 that show up + ;; on every rebuild due to clojure 1.11 adding new fns. + :warnings {:redef false}}} + :release {:build-options {:ns-aliases {day8.re-frame.tracing day8.re-frame.tracing-stubs}}} + :devtools {:preloads [devtools.preload + day8.re-frame-10x.preload] + :http-root "resources/public" + :http-port 3000}} - :karma-test {:target :karma + ;; frontend for electron + :renderer {:target :browser + :output-dir "resources/public/js/compiled" + :asset-path "js/compiled" + :modules {:renderer {:init-fn athens.core/init}} + :js-options {:babel-preset-config {:targets {:chrome 80}}} + :compiler-options {:closure-warnings {:global-this :off} + :infer-externs :auto + :closure-defines {re-frame.trace.trace-enabled? true} + :output-feature-set :es-next + :external-config {:devtools/config {:features-to-install [:formatters :hints]}} + ;; see https://shadow-cljs.github.io/docs/UsersGuide.html#_conditional_reading + :reader-features #{:electron}} + :dev {:compiler-options {:closure-defines {re-frame.trace.trace-enabled? true + day8.re-frame.tracing.trace-enabled? true + ;; To enable re-frame-10x set debug to `true` + goog.DEBUG false} + :warnings {:redef false}}} + :release {:build-options {:ns-aliases {day8.re-frame.tracing day8.re-frame.tracing-stubs}} + :compiler-options {:source-map true + :pseudo-names true}} + :devtools {:preloads [devtools.preload + ;; To enable re-frame-10x uncomment below preload + #_day8.re-frame-10x.preload]}} + + ;; backend for electron (node.js) + :main {:target :node-script + :output-to "resources/main.js" + :main athens.main.core/main + :js-options {:babel-preset-config {:targets {:chrome 80}}} + :compiler-options {:output-feature-set :es-next + :reader-features #{:electron}}} + + :karma-test {:target :karma :ns-regexp "-test$" :output-to "target/karma-test.js"}}} diff --git a/src/clj/athens/self_hosted/clients.clj b/src/clj/athens/self_hosted/clients.clj new file mode 100644 index 0000000000..fc3edcb40c --- /dev/null +++ b/src/clj/athens/self_hosted/clients.clj @@ -0,0 +1,86 @@ +(ns athens.self-hosted.clients + "Client comms" + (:require + [athens.common-events :as common-events] + [athens.common-events.schema :as schema] + [athens.common.logging :as log] + [org.httpkit.server :as http])) + + +;; Internal state +;; channel -> session info +(defonce clients (atom {})) + + +;; Client management API + +(defn get-client-session + [channel] + (get @clients channel)) + + +(defn get-client-sessions + [] + (vals @clients)) + + +(defn get-client-username + [channel] + (or (:username (get-client-session channel)) + "")) + + +(defn add-client! + [channel session] + (log/debug "add-client! username:" (:username session)) + (swap! clients assoc channel session)) + + +(defn remove-client! + [channel] + (log/debug "remove-client! username:" (get-client-username channel)) + (swap! clients dissoc channel)) + + +;; Public send API +(defn send! + "Send data to a client via `channel`" + [channel data] + (let [username (get-client-username channel) + valid-event-response? (schema/valid-event-response? data) + valid-server-event? (schema/valid-server-event? data)] + (if (or valid-event-response? + valid-server-event?) + (let [type (common-events/find-event-or-atomic-op-type data) + status (:event/status data) + serialized-event (common-events/serialize data) + errors (when-not (common-events/ignore-serialized-event-validation? data) + (common-events/validate-serialized-event serialized-event))] + (if errors + (log/error "Not sending invalid event to username:" username + ", event-id:" (:event/id data) + ", type:" (common-events/find-event-or-atomic-op-type data) + ", invalid serialized event:" + "event-response take:" (str errors)) + (do + (log/debug "Sending to username:" username + ", event-id:" (:event/id data) + (if type + (str ", type: " type) + (str ", status: " status))) + (http/send! channel serialized-event)))) + ;; TODO internal failure mode, collect in reporting + (log/error "Not sending invalid event to username:" username + ", event-id:" (:event/id data) + ", type:" (common-events/find-event-or-atomic-op-type data) + ", invalid schema:" + "event-response take:" (str (schema/explain-event-response data)) + ", server-event take:" (str (schema/explain-server-event data)))))) + + +(defn broadcast! + "Broadcasts event to all connected clients" + [{:event/keys [id] :as event}] + (log/debug "Broadcasting event-id:" id "type:" (common-events/find-event-or-atomic-op-type event)) + (doseq [client (keys @clients)] + (send! client event))) diff --git a/src/clj/athens/self_hosted/components/config.clj b/src/clj/athens/self_hosted/components/config.clj new file mode 100644 index 0000000000..8b1edb9b3d --- /dev/null +++ b/src/clj/athens/self_hosted/components/config.clj @@ -0,0 +1,39 @@ +(ns athens.self-hosted.components.config + "Athens Self-Hosted Configuration management" + (:require + [athens.common.logging :as log] + [clojure.edn :as edn] + [clojure.java.io :as io] + [clojure.pprint :as pp] + [com.stuartsierra.component :as component] + [config.core :as cfg])) + + +(defrecord Configuration + [] + + component/Lifecycle + + (start + [component] + (let [default-config (-> "config.default.edn" io/resource slurp edn/read-string) + _ (when (nil? default-config) + (throw (ex-info "Cannot load default-config" {}))) + config (cfg/reload-env) + merged-config (cfg/merge-maps default-config config (:config-edn config))] + (log/info "Starting configuration component") + (log/debug "Merged configuration:" (with-out-str + (pp/pprint merged-config))) + (assoc component :config merged-config))) + + + (stop + [component] + (log/info "Stopping configuration component") + (assoc component :config nil))) + + +(defn new-config + [] + (map->Configuration {})) + diff --git a/src/clj/athens/self_hosted/components/datascript.clj b/src/clj/athens/self_hosted/components/datascript.clj new file mode 100644 index 0000000000..35b563fa9a --- /dev/null +++ b/src/clj/athens/self_hosted/components/datascript.clj @@ -0,0 +1,74 @@ +(ns athens.self-hosted.components.datascript + (:require + [athens.common-db :as common-db] + [athens.common-events :as common-events] + [athens.common-events.resolver.atomic :as atomic] + [athens.common.logging :as log] + [athens.self-hosted.event-log :as event-log] + [athens.self-hosted.web.persistence :as persistence] + [clojure.pprint :as pp] + [com.stuartsierra.component :as component]) + (:import + (clojure.lang + ExceptionInfo))) + + +(defrecord Datascript + [config fluree conn] + + component/Lifecycle + + (start + [component] + (let [in-memory? (-> config :config :in-memory?) + conn (common-db/create-conn) + persist-base-path (-> config :config :datascript :persist-base-path) + [db id] (when persist-base-path + (persistence/load persist-base-path))] + (when (and db id) + (common-db/reset-conn! conn db) + (log/info "Loaded persisted DataScript db as of event id" id)) + (log/info "Lazily replaying events into DataScript conn...") + (let [total (atom 0) + last-id (atom nil)] + ;; NB: don't hold a ref to the lazy event seq, otherwise they + ;; can't be GC'd as we go and are all kept in memory at once. + (doseq [[id data] (cond + ;; In-memory setups don't use stored events at all + in-memory? event-log/initial-events + ;; If we have the last id for persisted db, we can skip all events up to that one. + id (event-log/events fluree :since-event-id id) + ;; Otherwise just load all events. + :else (event-log/events fluree))] + (log/info "Processing" (pr-str id) "with" (common-events/find-event-or-atomic-op-type data)) + (try + (atomic/resolve-transact! conn data) + (swap! total inc) + (reset! last-id id) + (when (persistence/throttled-save! persist-base-path conn data) + (log/info "Persisted DataScript db as of event id" id)) + (catch Error err + (log/warn "Event that we've failed to process was:" + (with-out-str + (pp/pprint data))) + (log/error err "Error during processing" (pr-str id))) + (catch ExceptionInfo ex + (log/warn "Event that we've failed to process was:" + (with-out-str + (pp/pprint data))) + (log/error ex "Exception during processing" (pr-str id))))) + (log/info "✅ Replayed" @total "events.") + (common-db/health-check conn)) + (assoc component :conn conn))) + + + (stop + [component] + (log/info "Stopping Datascript") + (dissoc component :conn))) + + +(defn new-datascript + [] + (map->Datascript {})) + diff --git a/src/clj/athens/self_hosted/components/fluree.clj b/src/clj/athens/self_hosted/components/fluree.clj new file mode 100644 index 0000000000..e050c9a4ee --- /dev/null +++ b/src/clj/athens/self_hosted/components/fluree.clj @@ -0,0 +1,56 @@ +(ns athens.self-hosted.components.fluree + (:require + [athens.self-hosted.event-log :as event-log] + [clojure.tools.logging :as log] + [com.stuartsierra.component :as component] + [fluree.db.api :as fdb])) + + +(defn create-fluree-comp + [address] + ;; Wrap conn in an atom so we can reconnect without restarting component. + (let [conn-atom (atom nil) + reconnect-fn (fn [] + (when-some [conn @conn-atom] + (fdb/close conn)) + (reset! conn-atom (fdb/connect address))) + comp {:conn-atom conn-atom + :reconnect-fn reconnect-fn}] + (reconnect-fn) + comp)) + + +(defrecord Fluree + [config conn-atom reconnect-fn] + + component/Lifecycle + + (start + [component] + (let [servers (get-in config [:config :fluree :servers]) + in-memory? (-> config :config :in-memory?)] + (if in-memory? + (do + (log/warn "Athens configuration is set to use in-memory, skipping Fluree initialization.") + component) + (do + (log/info "Starting Fluree connection, servers" servers) + (let [comp (create-fluree-comp servers)] + ;; Initialize event log. + (event-log/init! comp) + (merge component comp)))))) + + + (stop + [component] + (log/info "Closing Fluree connection") + (when-some [conn @conn-atom] + (fdb/close conn)) + (dissoc component :conn-atom :reconnect-fn))) + + +(defn new-fluree + [] + (map->Fluree {})) + + diff --git a/src/clj/athens/self_hosted/components/nrepl.clj b/src/clj/athens/self_hosted/components/nrepl.clj new file mode 100644 index 0000000000..decf774b99 --- /dev/null +++ b/src/clj/athens/self_hosted/components/nrepl.clj @@ -0,0 +1,35 @@ +(ns athens.self-hosted.components.nrepl + (:require + [athens.common.logging :as log] + [com.stuartsierra.component :as component] + [nrepl.server :as nrepl])) + + +(defrecord nReplServer + [config server] + + component/Lifecycle + + (start + [component] + (if-let [nrepl-conf (get-in config [:config :nrepl])] + (let [port (:port nrepl-conf) + nrepl-handler #(do (require 'cider.nrepl) + (ns-resolve 'cider.nrepl 'cider-nrepl-handler)) + handler (nrepl-handler)] + (log/info "Starting NREPL server with config:" (pr-str nrepl-conf)) + (assoc component :server (nrepl/start-server :port port :handler handler))) + component)) + + + (stop + [component] + (when server + (log/info "Stopping NREPL server.") + (nrepl/stop-server server)) + (dissoc component :server))) + + +(defn new-nrepl-server + [] + (map->nReplServer {})) diff --git a/src/clj/athens/self_hosted/components/web.clj b/src/clj/athens/self_hosted/components/web.clj new file mode 100644 index 0000000000..50dbe66c01 --- /dev/null +++ b/src/clj/athens/self_hosted/components/web.clj @@ -0,0 +1,179 @@ +(ns athens.self-hosted.components.web + (:require + [athens.common-events :as common-events] + [athens.common-events.schema :as schema] + [athens.common.logging :as log] + [athens.self-hosted.clients :as clients] + [athens.self-hosted.web.api :as api] + [athens.self-hosted.web.datascript :as datascript] + [athens.self-hosted.web.presence :as presence] + [com.stuartsierra.component :as component] + [compojure.core :as compojure] + [org.httpkit.server :as http] + [ring.middleware.resource :as ring.resource] + [ring.util.response :as ring.response])) + + +;; WebSocket handlers + +(defn close-handler + [channel status] + (let [{:keys [username] :as session} (clients/get-client-session channel)] + (clients/remove-client! channel) + ;; Notify clients after removing the one that left. + (presence/goodbye-handler session) + (log/info "username:" (pr-str username) "!! closed connection, status:" (pr-str status)))) + + +(defn- valid-event-handler + "Processes valid event received from the client." + [datascript fluree config channel username {:event/keys [id type presence-id] :as data}] + (cond + (and (false? username) + (not= :presence/hello type)) + (do + (log/warn "Message out of order, didn't say :presence/hello.") + (common-events/build-event-rejected id + :introduce-yourself + {:protocol-error :client-not-introduced})) + + (and presence-id (not= presence-id username)) + (do + (log/warn "Message presence-id didn't match username") + (common-events/build-event-rejected id + :presence-id-mismatch + {:presence-id presence-id + :username username})) + + :else + (if-let [result (cond + (contains? presence/supported-event-types type) + (presence/presence-handler (:conn datascript) (-> config :config :password) channel data) + + (= :op/atomic type) + (datascript/atomic-op-handler datascript fluree config channel data) + + :else + (do + (log/error (pr-str username) "-> receive-handler, unsupported event:" (pr-str type)) + (common-events/build-event-rejected id + (str "Unsupported event: " type) + {:unsupported-type type})))] + (merge {:event/id id} + result) + (log/error "username:" (pr-str username) ", event-id:" (pr-str id) ", type:" (pr-str type) "No result for `valid-event-handler`")))) + + +(def ^:private forwardable-events + #{:op/atomic}) + + +(defn- make-receive-handler + [datascript fluree config] + (fn receive-handler + [channel msg] + (let [username (clients/get-client-username channel) + data (common-events/deserialize msg) + errors (common-events/validate-serialized-event msg)] + (cond + ;; TODO: we should be able to validate the serialized event without deserializing it, but + ;; we also need to build a rejected event to pass back to the client, and to do that we need + ;; the :event/id, which we only get after deserializing the event. + ;; I think this means that we should separate the :event/id from the payload itself. + errors + (do + (log/warn "username:" username "Invalid serialized event received, errors:" errors) + (clients/send! channel (common-events/build-event-rejected (:event/id data) + (str "Invalid serialized event") + errors))) + + (not (schema/valid-event? data)) + (let [explanation (schema/explain-event data)] + (log/warn "username:" username "Invalid event received, explanation:" explanation) + (clients/send! channel (common-events/build-event-rejected (:event/id data) + (str "Invalid event: " (pr-str data)) + explanation))) + + :else + (let [{:event/keys [id type]} data] + (log/info "Received valid event" "username:" username ", event-id:" id ", type:" (common-events/find-event-or-atomic-op-type data)) + (let [{:event/keys [status] + :as result} (valid-event-handler datascript fluree config channel username data)] + (log/debug "username:" username ", event-id:" id ", processed with status:" status) + ;; forward to everyone if accepted + (when (and (= :accepted status) + (contains? forwardable-events type)) + (log/debug "Forwarding accepted event, event-id:" (pr-str id)) + (clients/broadcast! data)) + ;; acknowledge + (clients/send! channel result))))))) + + +(defn- make-websocket-handler + [datascript fluree config] + (fn websocket-handler + [request] + (http/as-channel request + {:on-close close-handler + :on-receive (make-receive-handler datascript fluree config)}))) + + +(defn- make-ws-route + [datascript fluree config] + (compojure/routes + (compojure/GET "/ws" [] + (make-websocket-handler datascript fluree config)))) + + +(compojure/defroutes health-check-route + (compojure/GET "/health-check" [] {:status 200 + :headers {"Content-Type" "text/html; charset=utf-8" + "Access-Control-Allow-Origin" "*"} + :body "ok"})) + + +(compojure/defroutes web-client + (-> (compojure/GET "/" [] (ring.response/resource-response "public/index.html")) + (ring.resource/wrap-resource "public"))) + + +(defn make-handler + [datascript fluree config] + (compojure/routes web-client + health-check-route + (make-ws-route datascript fluree config) + (api/make-routes datascript fluree config))) + + +(defrecord WebServer + [config httpkit datascript fluree] + + component/Lifecycle + + (start + [component] + (if httpkit + (do + (log/warn "Server already started, it's ok. Though it means we're not managing it properly.") + component) + (let [;; select only the fields we care about, and redact the password from the config before printing it + printable-config (-> config :config (select-keys [:http :fluree :datascript :nrepl :password])) + printable-config' (update printable-config :password #(if (string? %) "" %))] + (log/info "Starting WebServer with config:" (pr-str printable-config')) + (assoc component :httpkit + (http/run-server (make-handler datascript fluree config) + (-> config :config :http)))))) + + + (stop + [component] + (log/info "Stopping WebServer") + (when httpkit + (httpkit :timeout 100) + (assoc component :httpkit nil)))) + + +(defn new-web-server + [] + (map->WebServer {})) + diff --git a/src/clj/athens/self_hosted/core.clj b/src/clj/athens/self_hosted/core.clj new file mode 100644 index 0000000000..29bb1730e3 --- /dev/null +++ b/src/clj/athens/self_hosted/core.clj @@ -0,0 +1,37 @@ +(ns athens.self-hosted.core + "Athens Self Hosted Backend entry point." + (:gen-class) + (:require + [athens.common.logging :as log] + [athens.self-hosted.components.config :as cfg] + [athens.self-hosted.components.datascript :as datascript] + [athens.self-hosted.components.fluree :as fluree] + [athens.self-hosted.components.nrepl :as nrepl] + [athens.self-hosted.components.web :as web] + [com.stuartsierra.component :as component])) + + +(defn new-system + "Creates new system map" + [] + (log/debug "Building new system map") + (component/system-map + :config (cfg/new-config) + :fluree (component/using (fluree/new-fluree) + [:config]) + :datascript (component/using (datascript/new-datascript) + [:config :fluree]) + :webserver (component/using (web/new-web-server) + [:config :datascript :fluree]) + :nrepl (component/using (nrepl/new-nrepl-server) + [:config]))) + + +(def system (new-system)) + + +(defn -main + [& _args] + (log/info "Athens Self-Hosted Starting") + (alter-var-root #'system component/start) + (log/info "Athens Self-Hosted ready to do thy bidding")) diff --git a/src/clj/athens/self_hosted/event_log.clj b/src/clj/athens/self_hosted/event_log.clj new file mode 100644 index 0000000000..431ea4144b --- /dev/null +++ b/src/clj/athens/self_hosted/event_log.clj @@ -0,0 +1,321 @@ +(ns athens.self-hosted.event-log + (:require + [athens.async :as athens.async] + [athens.athens-datoms :as datoms] + [athens.self-hosted.event-log-migrations :as event-log-migrations] + [athens.self-hosted.fluree.utils :as fu] + [clojure.core.async :as async] + [clojure.data :as data] + [clojure.data.json :as json] + [clojure.edn :as edn] + [clojure.string :as str] + [clojure.tools.logging :as log] + [fluree.db.api :as fdb]) + (:import + (java.util + UUID))) + + +(def default-ledger "events/log") + + +(defn fluree-comp->ledger + [fluree] + (-> fluree + :ledger + (or default-ledger))) + + +(def initial-events + datoms/welcome-events) + + +(defn serialize + [id data] + (assert (uuid? id)) + (let [str-id (str id) + self-tempid (str "event$self-" str-id)] + {:_id self-tempid + :event/id (str id) + :event/data (pr-str data) + ;; See athens.self-hosted.event-log-migrations/migration-3-schema + ;; for how ordering works. + :event/order self-tempid})) + + +(defn deserialize + [{id "event/id" + data "event/data"}] + [(UUID/fromString id) + (edn/read-string data)]) + + +(defn- events-page + "Returns {:next-page ... :items ...}, where items is a vector of events in + page-number for all events in db split by page-size. For use with `iteration`." + ([db since-order page-size page-number] + {:next-page (inc page-number) + :items (fu/query db + {:select {"?event" ["*"]} + :where [["?event" "event/id", "?id"] + (if since-order + ["?event" "event/order" (str "#(> ?order " since-order ")")] + ["?event" "event/order" "?order"])] + :opts {:orderBy ["ASC", "?order"] + :limit page-size + :offset (* page-size page-number)}})})) + + +(defn event-id->order + [db event-id] + (first (fu/query db {:select "?order" + :where [["?event" "event/id", (str event-id)] + ["?event" "event/order" "?order"]]}))) + + +(defn last-event-id + [fluree] + (-> fluree + :conn-atom + deref + (fdb/db (fluree-comp->ledger fluree)) + (fu/query {:selectOne {"?event" ["*"]} + :where [["?event" "event/id" "?id"] + ["?event" "event/order" "?order"]] + :opts {:orderBy ["DESC" "?order"] + :limit 1}}) + deserialize + first)) + + +(defn events + "Returns a lazy-seq of all events in conn up to now, starting at optional event-id. + Can potentially be very large, so don't hold on to the seq head while + processing, and don't use fns that realize the whole coll (e.g. count)." + [fluree & {:keys [since-event-id since-order]}] + (let [db (fdb/db (-> fluree :conn-atom deref) + (fluree-comp->ledger fluree)) + since-order' (cond + since-order since-order + since-event-id (or (event-id->order db since-event-id) + (throw (ex-info "Cannot find starting id" + {:since-event-id since-event-id}))) + :else nil) + step (partial events-page db since-order' 100)] + ;; New core fn added in Clojure 11. + ;; See https://www.juxt.pro/blog/new-clojure-iteration for usage example. + (->> (iteration step + :kf :next-page + :vf :items + :somef #(-> % :items seq) + :initk 0) + (sequence cat) + (map deserialize)))) + + +(defn double-write? + "Returns true if response is for a double-write. + Double-writes for adding events are not treated as errors, since event writes are idempotent. + Double-writes can occur on reconnect or multi-writer scenarios." + [response] + (let [{:keys [status error]} (ex-data response) + message (ex-message response)] + (and message + (= status 400) + (= error :db/invalid-tx) + ;; e.g. "Unique predicate event/id with value: uuid-3 matched an existing subject: 351843720888324." + (str/starts-with? message "Unique predicate event/id with value:") + (str/includes? message "matched an existing subject:")))) + + +(defn add-event! + "Returns the block the event guaranteed to be present in." + ([fluree id data] + (add-event! fluree id data 5000 1000)) + ([fluree id data timeout backoff] + (loop [retries-left 3] + (if (= retries-left 0) + (throw (ex-info (str "add-event! timed-out 3 times on " id) + {:id id})) + (let [conn (-> fluree :conn-atom deref) + ledger (fluree-comp->ledger fluree) + ch (fdb/transact-async conn ledger [(serialize id data)]) + {:keys [status block] + :as r} (async/ r ex-data :meta :block) + + :else + (throw (ex-info (str "add-event! failed to transact on " id) + {:id id :response r})))))))) + + +(defn init! + ([fluree] + (init! fluree initial-events)) + ([fluree seed-events] + (let [conn (-> fluree :conn-atom deref) + ledger (fluree-comp->ledger fluree)] + (log/info "Looking for event-log fluree ledger") + (when (empty? @(fdb/ledger-info conn ledger)) + (log/info "Fluree ledger for event-log not found, creating" ledger) + @(fdb/new-ledger conn ledger) + (fdb/wait-for-ledger-ready conn ledger) + (event-log-migrations/migrate! conn ledger) + (when seed-events + (let [block (atom nil)] + (log/info "Populating fresh ledger with initial events...") + (doseq [[id data] seed-events] + (reset! block (add-event! fluree id data))) + (log/info "✅ Populated fresh ledger.") + (log/info "Bringing local ledger to to date with latest transactions...") + (fu/wait-for-block conn ledger @block) + (log/info "✅ Fluree local ledger up to date."))) + (log/info "✅ Fluree ledger for event-log created.")) + (event-log-migrations/migrate! conn ledger)))) + + +#_(defn events-since + "TODO: All events since start-id." + [_db _start-id] + ;; convert start-id to subjects + ;; use same query as all-events but add more where clauses + ) + + +#_(defn subscribe + "TODO: Calls f with k, [id data], for each event starting at start-id. + k is is a subscription key that you can use with unsubscribe." + [_conn _k _start-id _f] + ;; Fluree only has fdb/listen that starts at the calling time, but we can use + ;; a notifier pattern to turn that into an arbitrary subscription since a past block. + ;; Use events-since for the initial list, store the last id+subject, call f with each of those. + ;; Make a listener fn that takes the listener data and determines if it's an event, extracts + ;; the id if so, calls events-since with it, and call f with with block there. + ;; When calling f, ignore subjects that have already been used, avoiding duplicated. + ) + + +#_(defn unsubscribe + "TODO: remove subscription with key k." + [_conn _k]) + + +;; Recovery fns + +(defn- get-asserted-txs + [asserted] + (->> asserted + (map #(get % "_tx/tx")) + (remove nil?) + (map json/read-str) + (mapcat #(get % "tx")) + (filter #(get % "event/data")))) + + +(defn- get-block-txs + [blocks] + (->> blocks + (map :asserted) + (mapcat get-asserted-txs) + (remove nil?))) + + +(defn- recover-block-events + "Returns {:stop ... :next-page ... :items ...}, where items is a seq of recovered + events in conn for block=idx+1 in conn. For use with `iteration`." + [conn idx] + (let [res @(fdb/block-query conn default-ledger {:block (inc idx) :pretty-print true}) + ex-msg (ex-message res)] + ;; If the query because the is higher than the total blocks, + ;; result will be an error map instead of seq. + (if (and ex-msg (str/starts-with? ex-msg "Start block is out of range for this ledger.")) + {:stop true} + {:next-page (inc idx) + :items (get-block-txs res)}))) + + +(defn recovered-events + "Returns a lazy-seq of all recovered events in conn up to now. + Recovered events include events from failed transactions, as well as ones that succeed. + Can potentially be very large, so don't hold on to the seq head while + processing, and don't use fns that realize the whole coll (e.g. count)." + [fluree] + (let [step (partial recover-block-events (-> fluree :conn-atom deref))] + (->> (iteration step + :kf :next-page + :vf :items + :somef #(-> % :stop not) + :initk 0) + (sequence cat) + (map deserialize)))) + + +(comment + + (def fluree-comp + (let [conn-atom (atom nil) + reconnect-fn (fn [] + (when-some [conn @conn-atom] + (fdb/close conn)) + (reset! conn-atom (fdb/connect "http://localhost:8090"))) + fluree-comp {:conn-atom conn-atom + :reconnect-fn reconnect-fn}] + fluree-comp)) + ((:reconnect-fn fluree-comp)) + + ;; Create ledger if not present. + (init! fluree-comp) + + ;; What's the first event in the ledger? + (take 1 (events fluree-comp)) + + ;; What's the first event since this event-id? + (take 1 (events fluree-comp (UUID/fromString "e6dad544-ef29-43b5-911e-9c4bfdca3fda"))) + + ;; Add a few events. + (def my-events [["uuid-1" [1 2 3]] + ["uuid-2" [4 5 6]] + ["uuid-3" [7 8 9]]]) + + (doseq [[id data] my-events] + (add-event! fluree-comp id data)) + + ;; Add the same event multiple times, or with large sizes. + (add-event! fluree-comp "uuid-4" (apply str (repeat 1000 "a"))) + + ;; Check the events again. + (events fluree-comp) + + ;; How many events do we have total? + (count (events fluree-comp)) + ;; How many events do we have if we count failed ones? + (count (recovered-events fluree-comp)) + + ;; Debug event recovery + (recover-block-events (-> fluree-comp :conn-atom deref) 3) + (take 3 (recovered-events fluree-comp)) + (take 3 (events fluree-comp)) + + ;; This should be the same (e.g. [nil nil _]) for new graphs. + (data/diff (take 3 (recovered-events fluree-comp)) + (take 3 (events fluree-comp))) + + ;; Delete ledger. + @(fdb/delete-ledger (-> fluree-comp :conn-atom deref) default-ledger)) diff --git a/src/clj/athens/self_hosted/event_log_migrations.clj b/src/clj/athens/self_hosted/event_log_migrations.clj new file mode 100644 index 0000000000..32447d14e9 --- /dev/null +++ b/src/clj/athens/self_hosted/event_log_migrations.clj @@ -0,0 +1,394 @@ +(ns athens.self-hosted.event-log-migrations + "Contains schema and data migrations to the event log." + (:require + [athens.common.logging :as log] + [athens.common.migrations :as migrations] + [athens.self-hosted.fluree.utils :as fu] + [clojure.string :as str] + [fluree.db.api :as fdb])) + + +;; Migration helpers + +(defn get-predicate-values + [conn ledger predicate] + (set (fu/query conn ledger {:select "?o" + :where [["?s" predicate "?o"]]}))) + + +(defn internal-name? + [s] + (str/starts-with? s "_")) + + +(defn collections + [conn ledger] + (->> (get-predicate-values conn ledger "_collection/name") + (remove internal-name?) + set)) + + +(defn predicates + [conn ledger] + (->> (get-predicate-values conn ledger "_predicate/name") + (remove internal-name?) + set)) + + +(defn functions + [conn ledger] + (set (get-predicate-values conn ledger "_fn/name"))) + + +;; Bootstrap migrations and version fns + +(def bootstrap-migration-1-schema + [{:_id :_collection + :_collection/name :migrations + :_collection/doc "Completed migrations for the Athens semantic event log."} + {:_id :_predicate + :_predicate/name :migrations/version + :_predicate/doc "Marker that the matching migration version was applied." + :_predicate/unique true + :_predicate/upsert true + :_predicate/type :int}]) + + +(defn bootstrap-migrate-to-1 + [ledger conn] + (when-not ((collections conn ledger) "migrations") + (fu/transact! conn ledger bootstrap-migration-1-schema))) + + +(defn bootstrap-migrations + [ledger] + [[1 (partial bootstrap-migrate-to-1 ledger)]]) + + +(defn set-version! + [ledger conn version] + (fu/transact! conn ledger [{:_id :migrations + :migrations/version version}])) + + +(defn version + [ledger conn] + (let [ret (fu/query conn ledger {:select "(max ?v)" + :where [["?s" "migrations/version" "?v"]]})] + (if (ex-message ret) + 0 + (or ret 0)))) + + +;; Migration #1 +;; First migration. Add collection and base event data. + +(def migration-1-schema + [{:_id :_collection + :_collection/name :event + :_collection/doc "Athens semantic event log."} + {:_id :_predicate + :_predicate/name :event/id + :_predicate/doc "A globally unique event id." + :_predicate/unique true + :_predicate/type :string} + {:_id :_predicate + :_predicate/name :event/data + :_predicate/doc "Event data serialized as an EDN string." + :_predicate/type :string}]) + + +(defn migrate-to-1 + [ledger conn] + (when-not (and ((collections conn ledger) "event") + (every? (predicates conn ledger) ["event/id" "event/data"])) + (fu/transact! conn ledger migration-1-schema))) + + +;; Migration #2 +;; Enforce immutability of event/id and event/data. +;; Ensure all events have id, a few early 2.0.0-beta versions were missing event ids for initial events. + +(def migration-2-fn + [{:_id :_fn$immutable + :_fn/name :immutable + :_fn/doc "Checks whether the proposed object is changing existing data." + :_fn/code "(nil? (?pO))"} + {:_id [:_predicate/name :event/id] + :_predicate/spec [:_fn$immutable]} + {:_id [:_predicate/name :event/data] + :_predicate/spec [:_fn$immutable]}]) + + +;; NB: there's no efficient way to query on a non-existing predicate in Fluree, +;; so here (and in sid+order-page) we return all elements. +;; Fluree has a filter map that can be used in `where`, but it's slow. +;; See https://discord.com/channels/896089675511508992/908441256986816533/955881109869187082 +(defn sid+id-page + "Returns {:next-page ... :items ...}, where items is a vector of [subject-id event-id] in + page-number for all events that have event/data in db, split by page-size. + Events without event/id will return nil as event-id. For use with `iteration`." + ([db page-size page-number] + (log/info "Fetching sid+id offset" (* page-size page-number)) + {:next-page (inc page-number) + :items (fu/query db {:select ["?event" "?id"] + :where [["?event" "event/data", "?data"] + {:optional [["?event" "event/id" "?id"]]}] + :opts {:limit page-size + :offset (* page-size page-number)}})})) + + +(defn add-missing-uuid! + [conn ledger sid] + (log/info "Adding uuid to sid" sid) + (fu/transact! conn ledger [{:_id sid :event/id (str (random-uuid))}])) + + +(defn sids-with-missing-uids + [conn ledger] + (let [db (fdb/db conn ledger) + step (partial sid+id-page db 100)] + (->> (iteration step + :kf :next-page + :vf :items + :somef #(-> % :items seq) + :initk 0) + (sequence cat) + ;; Remove items that have a non-nil uid in [sid uid] + (remove second) + (map first)))) + + +(defn migrate-to-2 + [ledger conn] + (when-not ((functions conn ledger) "immutable") + (fu/transact! conn ledger migration-2-fn)) + (run! (partial add-missing-uuid! conn ledger) + (sids-with-missing-uids conn ledger))) + + +;; TODO: Migration #3 +;; Adds a order number for fast partial event queries via a filter in a where-triple. +;; Existing events are updated to contain the right order number. + +(def migration-3-schema + [{:_id :_predicate + :_predicate/name :event/order + ;; Note on limits: + ;; PostgreSQL data types https://www.postgresql.org/docs/current/datatype-numeric.html + ;; Fluree data types https://developers.flur.ee/docs/overview/schema/predicates/ + ;; PostgreSQL uses `serial` and `bigserial` for auto-incrementing fields. + ;; Fluree `int` is the same max as PostgreSQL `serial` (32 bits = 4 bytes), and the same + ;; goes for `long` and `bigserial` (64 bits = 8 bytes). + ;; So `int` gives us up to 2147483647, and `long` is up to 9223372036854775807. + ;; This isn't infinite, but it's a lot, and if we hit the limit we should find another + ;; efficient way of doing the ordered log, and migrate all events there instead. + ;; I also tried Flurees `bigint` but it made queries and insertions (~0.8s at 45k events) slow. + + ;; Note on how ordering is implemented: + ;; Fluree's refs are represented in flakes as the subject ID, which seems to be a least a long + ;; that starts at 351843720888320 and goes up by 1 with each new entity. + ;; Refs work for ordering even though they are returned as `{:_id 351843720888320}`, since + ;; on the flake itself it's a number. Unclear if this is supported long term in Fluree though. + ;; We set a reference to the own entity on transaction, or migration for the ones missing it. + ;; Using refs for ordering is very efficient because no calculations need to be made on insertion, + ;; and the only limit it will hit is that of the max entity that fluree supports. + ;; I tried before using the `max-pred-val` smartfn but that proved to scale with total events, + ;; which made it unsuitable insertions even on the medium term (95k events was already taking 600ms). + + ;; TODO: the "strictly increasing" condition could be validated via specs: + ;; - collection spec to ensure order is there + ;; - predicate spec to ensure the new number is bigger than the max + ;; This validation isn't happening here, we're just transacting "correct" data. + :_predicate/doc "Strictly increasing long for event ordering." + :_predicate/unique true + :_predicate/type :ref + :_predicate/spec [[:_fn/name :immutable]]}]) + + +(defn add-order! + [conn ledger sid] + (log/info "Adding order to sid" sid) + (fu/transact! conn ledger [{:_id sid + :event/order sid}])) + + +(defn sid+order-page + "Returns {:next-page ... :items ...}, where items is a vector of [subject-id order] in + page-number for all events with an event/id in db, ordered by subject-id, split by page-size. + Before event/order was added, events were ordered by subject-id as it's a strictly increasing + bigint that acts as insertion order. + Events without event/order will return nil as order. For use with `iteration`." + ([db page-size page-number] + (log/info "Fetching sid+order offset" (* page-size page-number)) + {:next-page (inc page-number) + :items (fu/query db {:select ["?event" "?order"] + :where [["?event" "event/id", "?id"] + {:optional [["?event" "event/order" "?order"]]}] + :opts {:orderBy ["ASC", "?event"] + :limit page-size + :offset (* page-size page-number)}})})) + + +(defn sids-with-missing-order + [conn ledger] + (let [db (fdb/db conn ledger) + step (partial sid+order-page db 100)] + (->> (iteration step + :kf :next-page + :vf :items + :somef #(-> % :items seq) + :initk 0) + (sequence cat) + ;; Remove items that have a non-nil order in [sid order] + (remove second) + (map first)))) + + +(defn migrate-to-3 + [ledger conn] + (when-not ((predicates conn ledger) "event/order") + (->> (fu/transact! conn ledger migration-3-schema) + :block + ;; Force sync to ensure query recognizes the new schema predicate. + (fu/wait-for-block conn ledger))) + (run! (partial add-order! conn ledger) + (sids-with-missing-order conn ledger))) + + +(defn migrations + [ledger] + [[1 (partial migrate-to-1 ledger)] + [2 (partial migrate-to-2 ledger)] + [3 (partial migrate-to-3 ledger)]]) + + +(defn migrate! + [conn ledger & {:as opts}] + (migrations/migrate! conn + (migrations ledger) + (bootstrap-migrations ledger) + (partial version ledger) + (partial set-version! ledger) + opts)) + + +(comment + (def conn (fdb/connect "http://localhost:8090")) + (def ledger "events/log") + @(fdb/new-ledger conn ledger) + + + @(fdb/transact conn ledger migration-1-schema) + + (defn all-events [] + (-> conn + (fdb/db ledger) + (fu/query {:select ["*"] + :from "event"}))) + + ;; Migration 2 + ;; init and add some events, one without id + (migrate! conn ledger :up-to 1) + (def ids (repeatedly 4 #(str (random-uuid)))) + (fu/transact! conn ledger [{:_id :event :event/id (nth ids 0) :event/data "0"} + {:_id :event :event/id (nth ids 1) :event/data "1"} + {:_id :event :event/data "4"} + {:_id :event :event/id (nth ids 3) :event/data "3"}]) + (all-events) + + ;; I can modify id and data + (fu/transact! conn ledger [{:_id [:event/id (nth ids 3)] + :event/id (str (random-uuid)) + :event/data "10"}]) + + (all-events) + + ;; After migration the event with data 4 should have an id, and we can't change id and data + (migrate-to-2 conn ledger) + (all-events) + (fu/transact! conn ledger [{:_id [:event/id (nth ids 1)] + :event/id (str (random-uuid)) + :event/data "10"}]) + + ;; Migration 3 + ;; The events inserted above don't have an order id + (all-events) + + ;; Running the migration should add it. + (migrate-to-3 conn ledger) + (all-events) + + ;; + ) + + +(comment + (def conn (fdb/connect "http://localhost:8090")) + (def ledger "events/log") + @(fdb/new-ledger conn ledger) + + (def schema + [{:_id :_collection + :_collection/name :event} + {:_id :_predicate + :_predicate/name :event/id + :_predicate/unique true + :_predicate/type :string} + {:_id :_predicate + :_predicate/name :event/order + :_predicate/unique true + :_predicate/type :ref}]) + + @(fdb/transact conn ledger schema) + + + (defn new-event [id] + (let [str-id (str id) + self-tempid (str "event$self-" str-id)] + {:_id self-tempid + :event/id str-id + :event/order self-tempid})) + + @(fdb/transact conn ledger [(new-event 1) + (new-event 2) + (new-event 3) + (new-event 4)]) + + ;; descending order + + (-> conn + (fdb/db ledger) + (fdb/query {:select {"?event" ["*"]} + :where [["?event" "event/order", "?order"]] + :opts {:orderBy ["DESC", "?order"]}}) + deref) + ;; => [{:_id 351843720888323, "event/id" "4", "event/order" {:_id 351843720888323}} + ;; {:_id 351843720888322, "event/id" "3", "event/order" {:_id 351843720888322}} + ;; {:_id 351843720888321, "event/id" "2", "event/order" {:_id 351843720888321}} + ;; {:_id 351843720888320, "event/id" "1", "event/order" {:_id 351843720888320}}] + + ;; ascending order + + (-> conn + (fdb/db ledger) + (fdb/query {:select {"?event" ["*"]} + :where [["?event" "event/order", "?order"]] + :opts {:orderBy ["ASC", "event/order"]}}) + deref) + ;; => [{:_id 351843720888320, "event/id" "1", "event/order" {:_id 351843720888320}} + ;; {:_id 351843720888321, "event/id" "2", "event/order" {:_id 351843720888321}} + ;; {:_id 351843720888322, "event/id" "3", "event/order" {:_id 351843720888322}} + ;; {:_id 351843720888323, "event/id" "4", "event/order" {:_id 351843720888323}}] + + ;; Filtered + + (-> conn + (fdb/db ledger) + (fdb/query {:select {"?event" ["*"]} + :where [["?event" "event/order" (str "#(> ?order " 351843720888321 ")")]] + :opts {:orderBy ["ASC", "?order"]}}) + deref) + ;; => [{:_id 351843720888323, "event/id" "4", "event/order" {:_id 351843720888323}} + ;; {:_id 351843720888322, "event/id" "3", "event/order" {:_id 351843720888322}}] +) diff --git a/src/clj/athens/self_hosted/fluree/utils.clj b/src/clj/athens/self_hosted/fluree/utils.clj new file mode 100644 index 0000000000..7487287e1a --- /dev/null +++ b/src/clj/athens/self_hosted/fluree/utils.clj @@ -0,0 +1,26 @@ +(ns athens.self-hosted.fluree.utils + (:require + [clojure.core.async :as async] + [fluree.db.api :as fdb])) + + +(defn transact! + [conn ledger tx-data] + (let [ret @(fdb/transact conn ledger tx-data)] + (if (ex-message ret) + (throw ret) + ret))) + + +(defn query + ([db q-data] + @(fdb/query db q-data)) + ([conn ledger q-data] + (-> conn + (fdb/db ledger) + (query q-data)))) + + +(defn wait-for-block + [conn ledger expected-block] + (async/ comp :conn-atom deref fdb/close))) + + +(defn recover-log + [args] + (let [{:keys [fluree-address + filename]} args + comp (fluree-comp/create-fluree-comp fluree-address) + events (event-log/recovered-events comp)] + (spit filename + (pr-str (doall events))) + (-> comp :conn-atom deref fdb/close))) + + +(defn- load-events + [comp previous-events progress total] + (event-log/init! comp []) + (doseq [[id data] previous-events] + (swap! progress inc) + (log/info "Processing" id (str "#" @progress "/" total)) + (event-log/add-event! comp id data))) + + +(defn load-log + [args] + (let [{:keys [fluree-address + filename + resume]} args + comp (fluree-comp/create-fluree-comp fluree-address) + conn (-> comp + :conn-atom + deref) + previous-events (edn/read-string (slurp filename)) + total (count previous-events) + ledger-exists? (seq @(fdb/ledger-info conn event-log/default-ledger)) + progress (atom 0) + last-added-event-id (when resume + (event-log/last-event-id comp)) + previous-events-since-last-added-event + (when last-added-event-id + (drop-while (fn [[id]] (not= id last-added-event-id)) + previous-events))] + + (cond + (and ledger-exists? (not resume)) + (do + (log/info "Deleting the current ledger before loading data....") + @(fdb/delete-ledger conn + event-log/default-ledger) + (log/warn "Please restart the fluree docker.")) + + (and (not ledger-exists?) resume) + (log/warn "Cannot resume because there's no ledger") + + (and ledger-exists? resume (nil? last-added-event-id)) + (log/warn "Cannot resume because there are no events in the ledger to resume from") + + (and ledger-exists? resume last-added-event-id (empty? previous-events-since-last-added-event)) + (log/warn "Cannot resume because the last ledger event (" last-added-event-id ") is not in the backup") + + (and ledger-exists? resume last-added-event-id previous-events-since-last-added-event) + (do + (log/info "Resuming load from event" last-added-event-id) + ;; The first item is the event we already added, drop it. + (let [previous-events-after-last-added-event (rest previous-events-since-last-added-event)] + (reset! progress (- total (count previous-events-after-last-added-event))) + (load-events comp previous-events-after-last-added-event progress total))) + + :else + (do + (log/info "Recreating ledger...") + (event-log/init! comp []) + (log/info "Loading all events...") + (load-events comp previous-events progress total))))) + + +(def cli-options + ;; An option with a required argument + [["-a" "--fluree-address ADDRESS" "Fluree address" + :default "http://localhost:8090"] + ["-f" "--filename FILENAME" "Name of the file to be saved or loaded" + :default []] + ["-r" "--resume" "Attempt to resume a load attempt from the last saved event." + :default false] + ["-h" "--help"]]) + + +(defn usage + [options-summary] + (->> ["Save or load a ledger" + "" + "Usage: program-name [options] action" + "" + "Options:" + options-summary + "" + "Actions:" + " save Save the current ledger" + " load Load the passed ledger" + " recover Recover failed transactions from the current ledger" + "" + "Please refer to the manual page for more information."] + (string/join \newline))) + + +(defn error-msg + [errors] + (str "The following errors occurred while parsing your command:\n\n" + (string/join \newline errors))) + + +(defn validate-args + "Validate command line arguments. Either return a map indicating the program + should exit (with an error message, and optional ok status), or a map + indicating the action the program should take and the options provided." + [args] + (let [{:keys [options arguments errors summary]} (parse-opts args cli-options)] + (cond + ;; help => exit OK with usage summary + (:help options) {:exit-message (usage summary) :ok? true} + ;; errors => exit with description of errors + errors {:exit-message (error-msg errors)} + ;; custom validation on arguments + (and (= 1 (count arguments)) + (#{"save" "load" "recover"} + (first arguments))) {:action (first arguments) :options options} + ;; failed custom validation => exit with usage summary + :else {:exit-message (usage summary)}))) + + +(defn exit + [status msg] + (println msg) + (System/exit status)) + + +(defn -main + [& args] + (let [{:keys [action options exit-message ok?]} (validate-args args)] + (if exit-message + (exit (if ok? 0 1) exit-message) + (do + (case action + "save" (save-log options) + "load" (load-log options) + "recover" (recover-log options)) + (System/exit 0))))) diff --git a/src/clj/athens/self_hosted/web/api.clj b/src/clj/athens/self_hosted/web/api.clj new file mode 100644 index 0000000000..13efc6ac0b --- /dev/null +++ b/src/clj/athens/self_hosted/web/api.clj @@ -0,0 +1,258 @@ +(ns athens.self-hosted.web.api + (:require + [athens.common-db :as common-db] + [athens.common-events :as common-events] + [athens.common-events.bfs :as bfs] + [athens.common-events.graph.composite :as composite-ops] + [athens.common-events.graph.ops :as graph-ops] + [athens.common-events.schema :as schema] + [athens.common.utils :as utils] + [athens.dates :as dates] + [athens.self-hosted.clients :as clients] + [athens.self-hosted.web.datascript :as web.datascript] + [clojure.string :as str] + [compojure.core :as c] + [datascript.core :as d] + [muuntaja.middleware :as muuntaja.mw] + [ring.middleware.basic-authentication :as basic-auth])) + + +;; Paths + +(defn e->eid + [{:keys [:node/title :block/uid]}] + (cond + title [:node/title title] + uid [:block/uid uid])) + + +(defn page-search + [query] + (cond + (= query "@today") (-> (dates/get-day) :title) + :else (throw (ex-info "Cannot resolve title." {:page/query query})))) + + +(defn resolve-root + [_db {:keys [page/title page/query block/uid] :as x}] + (cond + title [:node/title title] + uid [:block/uid uid] + query [:node/title (page-search query)] + :else (throw (ex-info "Cannot resolve root." x)))) + + +(defn resolve-selector + [db eid {:keys [block/string block/key]}] + (let [e (d/entity db eid)] + (cond + (not e) nil + string (->> e + :block/children + (filter #(= string (:block/string %))) + first + e->eid) + key (->> e + :block/_property-of + (filter #(= key (-> % :block/key :node/title))) + first + e->eid) + :else nil))) + + +;; Different from graph.ops/build-path, paths are [root selectors...] +;; TODO: support order/prop selectors +;; Root examples: +;; {:page/title "page"} +;; {:block/uid "uid"} +;; {:page/query "@today"} +;; Selector examples: +;; {:block/string "one two"} +;; [:block/key "three four"] +(defn resolve-path + [db [root & selectors]] + (reduce (partial resolve-selector db) + (resolve-root db root) + selectors)) + + +(def eid-k-map + {:node/title :page/title + :block/uid :block/uid}) + + +(defn eid->position-id + [[k v]] + [(eid-k-map k) v]) + + +(defn create-from-selector + [db parent-eid {:keys [block/string block/key] :as selector}] + (let [uid (utils/gen-block-uid) + eid [:block/uid uid] + position (into {:relation (cond + string :last + key {:page/title key} + :else (throw (ex-info "Cannot create from selector", selector)))} + [(eid->position-id parent-eid)]) + + ops (into [(graph-ops/build-block-new-op db uid position)] + (when string + [(graph-ops/build-block-save-op db uid string)]))] + [eid ops])) + + +(defn resolve-or-create-selector + [db [eid ops] selector] + (if-some [existing-eid (resolve-selector db eid selector)] + [existing-eid ops] + (let [[created-eid create-ops] (create-from-selector db eid selector)] + [created-eid (into ops create-ops)]))) + + +(defn resolve-or-create-path + [db [root & selectors]] + (reduce (partial resolve-or-create-selector db) + [(resolve-root db root) []] + selectors)) + + +(comment + (resolve-or-create-path common-db/empty-db [{:page/query "@today"} {:block/string "one"} {:block/string "two"} {:block/string "three"}])) + + +{:event + {:event/id #uuid "1ccf42c4-516d-4985-9316-2184c933978c", :event/type :op/atomic, :event/op {:op/type :composite/consequence, :op/atomic? false, :op/trigger {:op/type :path/write}, :op/consequences [{:op/type :block/new, :op/atomic? true, :op/args {:block/uid "5f52e2d1d", :block/position {:relation :last, :node/title "September 27, 2022"}}} {:op/type :block/new, :op/atomic? true, :op/args {:block/uid "0566dd31a", :block/position {:relation :last, :block/uid "5f52e2d1d"}}} {:op/type :block/new, :op/atomic? true, :op/args {:block/uid "84a1c3c86", :block/position {:relation :last, :block/uid "0566dd31a"}}} {:op/type :block/new, :op/atomic? true, :op/args {:block/uid "43efd72f5", :block/position {:relation :last, :block/uid "84a1c3c86"}}} {:op/type :block/save, :op/atomic? true, :op/args {:block/uid "43efd72f5", :block/string "four"}}]}, :event/create-time 1664305865053, :event/presence-id "api-test"}, :explain {:event/op {:op/consequences [{:op/args {:block/position {:page/title ["missing required key" "missing required key"], :block/uid ["missing required key" "missing required key" "missing required key"], :relation ["should be either :before or :after" "invalid type" "invalid type"]}}}]}}} + + +;; Read/Write + +(defn read-path + [conn path] + (let [db @conn] + (->> path + (resolve-path db) + (common-db/get-internal-representation db)))) + + +(defn write-in-path-evt + [conn path relation data] + (when (empty? data) + (throw (ex-info "No data to write" data))) + (let [db @conn + [eid path-ops] (resolve-or-create-path db path) + default-pos (into {:relation (or relation :last)} + [(eid->position-id eid)]) + write-ops (bfs/internal-representation->atomic-ops db data default-pos)] + (->> (into path-ops write-ops) + (composite-ops/make-consequence-op {:op/type :path/write}) + common-events/build-atomic-event))) + + +(defn add-presence-id + [presence-id event] + (common-events/add-presence event presence-id)) + + +(defn process-event! + [datascript fluree config evt] + (when-not (schema/valid-event? evt) + (throw (ex-info "Invalid event" {:event evt + :explain (schema/explain-event evt)}))) + (when (->> evt + (web.datascript/exec! datascript fluree config) + :event/status + (= :accepted)) + (clients/broadcast! evt) + evt)) + + +;; Routes + +(defn ok + [x] + {:status 200 + :body x}) + + +;; for convenience with ->> +(defn ret-first + [x _] + x) + + +;; Username is always required non-empty +(defn authenticated? + [config-pw username pw] + (if (and (not (str/blank? username)) + (or (not config-pw) + (= pw config-pw))) + {:presence-id username} + false)) + + +(defn make-routes + [datascript fluree config] + (let [conn (:conn datascript) + config-pw (-> config :config :password)] + (if-not (-> config :config :feature-flags :api) + (c/routes) + (-> + (c/routes + (c/context + "/api/path" [] + + (c/POST + "/read" {{:keys [path]} :body-params} + (->> path + (read-path conn) + ok)) + + (c/POST + "/write" {{:keys [path relation data]} :body-params + {:keys [presence-id]} :basic-authentication} + (->> (write-in-path-evt conn path relation data) + (add-presence-id presence-id) + (process-event! datascript fluree config) + (ret-first path) + (read-path conn) + ok)))) + (basic-auth/wrap-basic-authentication (partial authenticated? config-pw)) + muuntaja.mw/wrap-format)))) + + +;; Examples with curl + +;; - auth +;; Uses https://en.wikipedia.org/wiki/Basic_access_authentication +;; Username is always needed even if server has no password. +;; curl -u presence-name:server-password ... +;; curl -u api-test: ... +;; Remember to use base64 encoding when not using curl https://stackoverflow.com/a/60505090/2116927 + +;; - content negotiation +;; JSON uses keywords in map keys as strings, but still keeping the namespaces (e.g. :page/title -> "page/title"). +;; Returns JSON by default, so you don't need to set the Accept header: +;; curl -H "Content-Type: application/json" ... +;; To use EDN: +;; curl -H "Content-Type: application/edn" -H "Accept: application/edn" ... + +;; - read page "page", in edn and in json +;; curl -u api-test: -H "Content-Type: application/edn" -H "Accept: application/edn" localhost:3010/api/path/read -X POST -d '{:path [{:page/title "page"}]}' +;; curl -u api-test: -H "Content-Type: application/json" localhost:3010/api/path/read -X POST -d '{"path":[{"page/title":"page"}]}' + +;; - write blocks to page +;; curl -X POST localhost:3010/api/path/write -d '{:path [{:page/title "page"}] :data [{:block/string "one" :block/children [{:block/string "two"}]}]}' +;; curl -X POST localhost:3010/api/path/write -d '{"path":[{"page/title":"page"}], "data":[{"block/string":"one", "block/children":[{"block/string":"two"}]}]}' + +;; Assume examples below have encoding and auth included like the EDN example above +;; curl -u api-test: -H "Content-Type: application/edn" -H "Accept: application/edn" ... + +;; - read todays page +;; curl localhost:3010/api/path/read -X POST -d '{:path [{:page/query "@today"}]}' +;; - read the first block with string "hello" in todays page +;; curl localhost:3010/api/path/read -X POST -d '{:path [{:page/query "@today"} {:block/string "hello"}]}' +;; - read the block with property "prop" in the first block with string "hello" in todays page +;; curl localhost:3010/api/path/read -X POST -d '{:path [{:page/query "@today"} {:block/string "hello"} {:block/key "prop"}]}' +;; - write in the today/one/two/three nested block path, creating it if needed, the child with string "four" +;; curl localhost:3010/api/path/read -X POST -d '{:path [{:page/query "@today"} {:block/string "one"} {:block/string "two"} {:block/string "three"}]}' diff --git a/src/clj/athens/self_hosted/web/datascript.clj b/src/clj/athens/self_hosted/web/datascript.clj new file mode 100644 index 0000000000..6baaf5ac59 --- /dev/null +++ b/src/clj/athens/self_hosted/web/datascript.clj @@ -0,0 +1,68 @@ +(ns athens.self-hosted.web.datascript + (:require + [athens.common-events :as common-events] + [athens.common-events.resolver.atomic :as atomic-resolver] + [athens.common.logging :as log] + [athens.self-hosted.clients :as clients] + [athens.self-hosted.event-log :as event-log] + [athens.self-hosted.web.persistence :as persistence]) + (:import + (clojure.lang + ExceptionInfo))) + + +(def supported-atomic-ops + #{:block/new + :block/save + :block/open + :block/remove + :block/move + :page/new + :page/rename + :page/merge + :page/remove + :shortcut/new + :shortcut/remove + :shortcut/move + :composite/consequence}) + + +;; We use a lock to ensure the order of event log writes and database transacts matches +;; and occurs in order. +(def single-writer-guard (Object.)) + + +(defn exec! + [{:keys [conn]} fluree config {:event/keys [id] :as event}] + (locking single-writer-guard + (try + (when-not (-> config :config :in-memory?) + (event-log/add-event! fluree id event)) + (atomic-resolver/resolve-transact! conn event) + (when-some [persist-base-path (-> config :config :datascript :persist-base-path)] + (when (persistence/throttled-save! persist-base-path conn event) + (log/info "Persisted DataScript db as of event id" id))) + (common-events/build-event-accepted id) + (catch ExceptionInfo ex + (let [err-msg (ex-message ex) + err-data (ex-data ex) + err-cause (ex-cause ex)] + (log/error ex (str "Exec event-id: " id + " FAIL: " (pr-str {:msg err-msg + :data err-data + :cause err-cause}))) + (common-events/build-event-rejected id err-msg err-data)))))) + + +(defn atomic-op-handler + [datascript fluree config channel {:event/keys [id op] :as event}] + (let [username (clients/get-client-username channel) + {:op/keys [type]} op] + (log/debug "username:" username + "event-id:" id + "-> Received Atomic Op Type:" (pr-str type)) + (if (contains? supported-atomic-ops type) + (exec! datascript fluree config event) + (common-events/build-event-rejected id + (str "Under development event: " type) + {:unsuported-type type})))) diff --git a/src/clj/athens/self_hosted/web/persistence.clj b/src/clj/athens/self_hosted/web/persistence.clj new file mode 100644 index 0000000000..17560211f7 --- /dev/null +++ b/src/clj/athens/self_hosted/web/persistence.clj @@ -0,0 +1,105 @@ +(ns athens.self-hosted.web.persistence + (:refer-clojure :exclude [load list]) + (:require + [clojure.data.json :as json] + [clojure.java.io :as io] + [clojure.string :as str] + [datascript.core :as d]) + (:import + (java.util + UUID))) + + +(def extension ".json") + + +(defn- is-persisted-file? + [path] + (and (-> path io/file .isFile) + (str/ends-with? path extension))) + + +(defn- id->path + [persist-base-path id] + (->> [id extension] + str/join + (io/file persist-base-path) + .getAbsolutePath)) + + +(defn- path->id + [path] + (if (is-persisted-file? path) + (-> path io/file .getName (str/replace extension "") UUID/fromString) + (throw (ex-info "Path is not for a persisted file" {:path path})))) + + +(defn- list + [persist-base-path] + (->> persist-base-path + io/file + .listFiles + (map #(.getAbsolutePath %)) + (filter is-persisted-file?))) + + +(defn- delete-others! + [persist-base-path id] + (->> (list persist-base-path) + (remove #{(id->path persist-base-path id)}) + (run! io/delete-file))) + + +(defn save! + [persist-base-path db id] + (let [path (id->path persist-base-path id)] + (io/make-parents path) + (->> db + d/serializable + json/write-str + (spit path)) + (delete-others! persist-base-path id) + path)) + + +(defn load + [persist-base-path] + (when-some [path (-> (list persist-base-path) first)] + [(-> path + slurp + json/read-str + d/from-serializable) + (path->id path)])) + + +(def frequency 100) +(def counter (atom 0)) + + +(defn throttled-save! + [persist-base-path conn {:event/keys [id] :as _event}] + (when (>= (swap! counter inc) frequency) + (save! persist-base-path @conn id) + (reset! counter 0) + [@conn id])) + + +(comment + ;; Matches what's on src/clj/config.default.edn in [:datascript :persist-base-path] + ;; On the docker setup there's a config override to /srv/athens/datascript/persist + (def persist-base-path "./athens-data/datascript/persist/") + + (list persist-base-path) + + (load persist-base-path) + + (id->path persist-base-path "123") + + (save! persist-base-path @(d/create-conn) "123") + (save! persist-base-path @(d/create-conn) "456") + + (delete-others! persist-base-path "456") + + (throttled-save! persist-base-path (d/create-conn) {:event/id "123"}) + ;; + ) diff --git a/src/clj/athens/self_hosted/web/presence.clj b/src/clj/athens/self_hosted/web/presence.clj new file mode 100644 index 0000000000..e6e2e00c62 --- /dev/null +++ b/src/clj/athens/self_hosted/web/presence.clj @@ -0,0 +1,76 @@ +(ns athens.self-hosted.web.presence + (:require + [athens.common-events :as common-events] + [athens.common.logging :as log] + [athens.self-hosted.clients :as clients] + [clojure.string :as str] + [datascript.core :as d])) + + +(def supported-event-types + #{:presence/hello + :presence/update}) + + +(defn- valid-password + [conn channel id {:keys [session-intro]}] + (let [username (:username session-intro) + session-id (str (random-uuid)) + session (assoc session-intro :session-id session-id)] + (log/info "New Client Intro:" session-intro) + (clients/add-client! channel session) + (clients/send! channel (common-events/build-presence-session-id-event session-id)) + (let [datoms (map ; Convert Datoms to just vectors. + (comp vec seq) + (d/datoms @conn :eavt))] + (log/debug "Sending" (count datoms) "eavt to" (pr-str username)) + (clients/send! channel + (common-events/build-db-dump-event datoms))) + (clients/send! channel + (common-events/build-presence-all-online-event (clients/get-client-sessions))) + (clients/broadcast! (common-events/build-presence-online-event session)) + + ;; confirm + (common-events/build-event-accepted id))) + + +(defn- invalid-password + [channel id {:keys [username]}] + (log/warn channel "Invalid password in hello for username:" username) + (common-events/build-event-rejected id + "You shall not pass" + {:password-error :invalid-password})) + + +(defn hello-handler + [conn server-password channel {:event/keys [id args]}] + (let [{:keys [password]} args] + (if (or (str/blank? server-password) + (= server-password password)) + (valid-password conn channel id args) + (invalid-password channel id args)))) + + +(defn update-handler + [channel {:event/keys [id args]}] + (let [{:keys [session-id]} (clients/get-client-session channel) + ;; Always build a new event with the session-id for this channel. + ;; If the client sends a incorrect/spoofed session-id, it will be ignored. + presence-update-event (common-events/build-presence-update-event session-id args)] + (swap! clients/clients update channel merge args) + (clients/broadcast! presence-update-event) + (common-events/build-event-accepted id))) + + +(defn goodbye-handler + [session] + (let [presence-offline-event (athens.common-events/build-presence-offline-event session)] + (clients/broadcast! presence-offline-event))) + + +(defn presence-handler + [conn server-password channel {:event/keys [type] :as event}] + (condp = type + :presence/hello (hello-handler conn server-password channel event) + ;; presence/goodbye is called on client close. + :presence/update (update-handler channel event))) diff --git a/src/clj/config.default.edn b/src/clj/config.default.edn new file mode 100644 index 0000000000..4b46f9c088 --- /dev/null +++ b/src/clj/config.default.edn @@ -0,0 +1,8 @@ +{:http {:port 3010} + ;; Default fluree address and datascript path on docker compose setup. + :fluree {:servers ["http://fluree:8090"]} + :datascript {:persist-base-path "/srv/athens/datascript/persist/"} + :in-memory? false + ;; :password "SuchWow" + ;; :nrepl {:port 8877} + } diff --git a/src/clj/logback.xml b/src/clj/logback.xml new file mode 100644 index 0000000000..079f9b8bef --- /dev/null +++ b/src/clj/logback.xml @@ -0,0 +1,14 @@ + + + + + + %d{HH:mm:ss.SSS} %-5level [%thread{20}] %logger{36} - %msg%n + + + + + + + diff --git a/src/cljc/athens/async.cljc b/src/cljc/athens/async.cljc new file mode 100644 index 0000000000..03984f2e44 --- /dev/null +++ b/src/cljc/athens/async.cljc @@ -0,0 +1,12 @@ +(ns athens.async + (:require + [clojure.core.async :refer [go alt! timeout]])) + + +(defn with-timeout + "Return first val from ch, or timed-out after ms." + [ch ms timed-out] + (go + (alt! + ch ([v] v) + (timeout ms) timed-out))) diff --git a/src/cljc/athens/athens_datoms.cljc b/src/cljc/athens/athens_datoms.cljc new file mode 100644 index 0000000000..49ba1d24d4 --- /dev/null +++ b/src/cljc/athens/athens_datoms.cljc @@ -0,0 +1,171 @@ +(ns athens.athens-datoms + (:require + [athens.common-db :as common-db] + [athens.common-events :as common-events] + [athens.common-events.bfs :as bfs] + [athens.common-events.graph.atomic :as atomic-graph-ops])) + + +(def welcome-page-title "Welcome") + + +(def welcome-page-internal-representation + [{:page/title welcome-page-title + :block/children [#:block{:uid "ee770c334", + :string "Welcome to Athens, Open-Source Networked Thought!",} + #:block{:uid "6aecd4172", + :string "You can open and close blocks that have children.", + :children [#:block{:uid "5f82a48ef", + :string "![](https://athens-assets-1.s3.us-east-2.amazonaws.com/welcome.gif)",}]} + #:block{:uid "7e409b1cb", + :string "**How to Use Athens**", + :open? false, + :children [#:block{:uid "289cc9981", + :string "Outliner Features", + :open? false, + :children [#:block{:uid "62b9428d5", + :string "You can click on a bullet • to zoom in on it.", + :children [#:block{:uid "70907b596", + :string "You can navigate back to a higher context by clicking on navigation breadcrumbs (when zoomed in).",}]} + #:block{:uid "c312c0f9a", + :string "Indent and unindent bullets with tab and shift-tab.",} + #:block{:uid "59ccb6c73", + :string "Drag and drop bullets to re-order blocks.",} + #:block{:uid "a8589c828", + :string "Select multiple bullets with click and drag or shift-up or shift-down.", + :children [#:block{:uid "6cf8e69b2", + :string "You can drag and drop with multiple blocks selected!",}]}]} + #:block{:uid "6b8c28b09", + :string "Markup Features", + :open? false, + :children [#:block{:uid "e434db606", + :string "To edit the raw-text of a block, simply click on it and begin typing!",} + #:block{:uid "e5dec8a28", + :string "Bold text with **double asterisks**",} + #:block{:uid "3949afab9", + :string "Mono-spaced text with `backticks`"} + #:block{:uid "a8760ca6d", + :string "Links with `[[]]`, `#`, or `#[[]]`: [[Welcome]] #Welcome #[[Welcome]]", + :open? false, + :children [#:block{:uid "239090a3c", + :string "Nothing happens if you click on these links because you're already on this page.",}]} + #:block{:uid "7f087f26e", + :string "Block references with `(())`: ((b0acdcabd))", + :open? false, + :children [#:block{:uid "b0acdcabd", + :string "I am being referenced by other blocks.",}]} + #:block{:uid "0f5b500f6", + :string "{{[[TODO]]}} `ctrl-enter` / `cmd-enter` (for mac) to cycle between TODO and DONE",} + #:block{:uid "851cfb2f3", + :string "embeds with `{{[[youtube: ]]}}` and `{{``iframe: }}`", + :open? false, + :children [#:block{:uid "d1825590b", + :string "{{[[youtube]]: https://www.youtube.com/watch?v=dQw4w9WgXcQ}}",} + #:block{:uid "56771d0e4", + :string "{{iframe: https://www.openstreetmap.org/export/embed.html?bbox=-0.004017949104309083%2C51.47612752641776%2C0.00030577182769775396%2C51.478569861898606&layer=mapnik}}",}]} + #:block{:uid "d04604730", + :string "images with `![]()` ![athens-splash](https://raw.githubusercontent.com/athensresearch/athens/master/doc/athens-puk-patrick-unsplash.jpg)",} + #:block{:uid "dd1e080f4" + :string "$$\\LaTeX$$ support" + :children [#:block{:uid "dd1e080f5" + :string "$$c = \\pm\\sqrt{a^2 + b^2}$$"} + #:block{:uid "dd1e080f6" + :string "$$E=mc^2$$"} + #:block{:uid "dd1e080f7" + :string "$$\\int_{a}^{b} x^2 \\,dx$$"} + #:block{:uid "dd1e080f8" + :string "$$\\sum_{n=1}^{\\infty} 2^{-n} = 1$$"} + #:block{:uid "dd1e080f9" + :string "$$\\prod_{i=a}^{b} f(i)$$"} + #:block{:uid "dd1e080fa" + :string "$$\\lim_{x\\to\\infty} f(x)$$"} + #:block{:uid "dd1e080fb" + :string "Highlights invalid $$\\LaTeX$$ in red, like this $$\\Latex$$"} + #:block{:uid "dd1e080fc" + :string "Also `mhchem` extension is available:" + :children [#:block{:uid "dd1e080fd" + :string "$$\\ce{Zn^2+ <=>[+ 2OH-][+ 2H+] $\\underset{\\text{amphoteres Hydroxid}}{\\ce{Zn(OH)2 v}}$ <=>[+ 2OH-][+ 2H+] $\\underset{\\text{Hydroxozikat}}{\\ce{[Zn(OH)4]^2-}}$}$$"}]}]}]} + #:block{:uid "94272f778", + :string "All Keybindings", + :open? false, + :children [#:block{:uid "e425468c5", + :string "block shortcuts (while editing a block)", + :children [#:block{:uid "0ea74b8e8", + :string "`ctrl-b` / `cmd-b` (for mac): **bold**",} + #:block{:uid "b96876779", + :string "`/`: slash commands",} + #:block{:uid "8ceea983f", + :string "`tab`: indent",} + #:block{:uid "814db8ad5", + :string "`shift-tab`: unindent",} + #:block{:uid "450028510", + :string "`shift-up` or `shift-down`: select multiple blocks",} + #:block{:uid "019774c8b", + :string "`ctrl-a` / `cmd-a` (for mac): select all blocks on page",} + #:block{:uid "1be25bf14", + :string "`ctrl-z` / `cmd-z` (for mac): undo",} + #:block{:uid "7379f541a", + :string "`ctrl-shift-z` / `cmd-shift-z` (for mac): redo",} + #:block{:uid "0c11c0416", + :string "`ctrl-up` / `cmd-up` or `ctrl-down` / `cmd-down`: collapse or expand blocks",}]} + #:block{:uid "311b96eb2", + :string "global shortcuts (can use anywhere)", + :children [#:block{:uid "55ea160af", + :string "`ctrl-\\` or `cmd-\\` (for mac): open left sidebar",} + #:block{:uid "13efc72fd", + :string "`ctrl-shift-\\` or `cmd-shift-\\` (for mac): open right sidebar",} + #:block{:uid "bb0e8a187", + :string "`ctrl-k` / `cmd-k` (for mac): open search bar",} + #:block{:uid "13dfc72bd", + :string "`alt-g`: open graph view",} + #:block{:uid "13edc72bd", + :string "`alt-d`: open daily note",} + #:block{:uid "13esc72bd", + :string "`alt-a`: open all-pages view",} + #:block{:uid "13efc72bd", + :string "`ctrl-comma` / `cmd-comma` (for mac): open settings page",}]}]} + #:block{:uid "1002528bd", + :string "Left Sidebar", + :open? false, + :children [#:block{:uid "574973f5c", + :string "Mark a page as a shortcut with the caret next to the page title.",}]} + #:block{:uid "72538ef7f", + :string "Right Sidebar", + :open? false, + :children [#:block{:uid "9d6e1fd07", + :string "Open a block or page in the right sidebar by shift clicking on the link, title, or bullet.",}]}]} + #:block{:uid "21785e1a9", + :string "**FAQ**", + :open? false, + :children [#:block{:uid "792717c36", + :string "How does Athens persist data?", + :open? false, + :children [#:block{:uid "58803d15f", + :string "Athens is persisted to your filesystem at `documents/athens` by default.",} + #:block{:uid "0f62fecbc", + :string "Database can be changed through settings button on the top right corner.",}]} + #:block{:uid "68246ce0a", + :string "How can I report bugs?", + :open? false, + :children [#:block{:uid "37dcfbf20", + :string "If your bug isn't already on our [GitHub Bug and Issue Board](https://github.com/athensresearch/athens/projects/4), post the bug to the beta testers Discord channel. Screenshots are particularly useful. Also post the version of Athens and Operating System you are on.",}]} + #:block{:uid "9576d79db", + :string "How do I update Athens?", + :open? false, + :children [#:block{:uid "199259bce", + :string "When Athens is launched, it looks for newer versions. If it finds a newer version, it downloads it and launches it the next time you open Athens.",} + #:block{:uid "bf257cc8e", + :string "You can see the version at the bottom of the left sidebar when it is opened. Click on the version to go to our [release notes on Notion](https://www.notion.so/athensresearch/Weekly-Updates-e18afa006cfd4fec9c462940ac3b84da).",}]} + #:block{:uid "2464d4538", + :string "Is there anything special about the [[Welcome]] page?", + :open? false, + :children [#:block{:uid "6275554a3", + :string "[[Welcome]] is a special page. When you restart Athens, any changes you make to this page will be overwritten, so don't write anything you need in this page!",}]}]}]}]) + + +(def welcome-events + (let [op (bfs/build-paste-op common-db/empty-db welcome-page-internal-representation) + welcome-page (common-events/build-atomic-event op) + add-sidebar (common-events/build-atomic-event (atomic-graph-ops/make-shortcut-new-op welcome-page-title))] + [[(:event/id welcome-page) welcome-page] + [(:event/id add-sidebar) add-sidebar]])) diff --git a/src/cljc/athens/blocks.cljc b/src/cljc/athens/blocks.cljc index 6c19fdf9ee..cebd749c04 100644 --- a/src/cljc/athens/blocks.cljc +++ b/src/cljc/athens/blocks.cljc @@ -1,7 +1,9 @@ (ns athens.blocks) -(defn sort-block [block] + +(defn sort-block + [block] (if-let [children (seq (:block/children block))] - (assoc block :block/children - (sort-by :block/order (map sort-block children))) - block)) + (assoc block :block/children + (sort-by :block/order (map sort-block children))) + block)) diff --git a/src/cljc/athens/common/logging.cljc b/src/cljc/athens/common/logging.cljc new file mode 100644 index 0000000000..a286ad6c41 --- /dev/null +++ b/src/cljc/athens/common/logging.cljc @@ -0,0 +1,43 @@ +(ns athens.common.logging + "Athens logging for both clj & cljs." + #?(:clj + (:require + [clojure.tools.logging :as log]))) + + +#?(:cljs + (defn apply-clj->js + [f args] + (apply f (map clj->js args)))) + + +#?(:clj (defmacro error + [& args] + `(log/error ~@args)) + :cljs (defn error + [& args] + (apply-clj->js js/console.error args))) + + +#?(:clj (defmacro warn + [& args] + `(log/warn ~@args)) + :cljs (defn warn + [& args] + (apply-clj->js js/console.warn args))) + + +#?(:clj (defmacro info + [& args] + `(log/info ~@args)) + :cljs (defn info + [& args] + (apply-clj->js js/console.log args))) + + +#?(:clj (defmacro debug + [& args] + `(log/debug ~@args)) + :cljs (defn debug + [& args] + (apply-clj->js js/console.debug args))) diff --git a/src/cljc/athens/common/migrations.cljc b/src/cljc/athens/common/migrations.cljc new file mode 100644 index 0000000000..ca7ae01d4d --- /dev/null +++ b/src/cljc/athens/common/migrations.cljc @@ -0,0 +1,39 @@ +(ns athens.common.migrations + " Migrations should be interruptible and resumable, so that crashes and mistakes + will not leave the DB in a bad state that can be recovered from. + If a migration fails, it should throw an error. + A good way to make something interruptible is to ensure its idempotent." + (:require + [athens.common.logging :as log])) + + +(defn run-migration! + [conn set-version! [migration-version migration-f]] + (log/debug "Running migration version" migration-version) + (migration-f conn) + (set-version! conn migration-version) + (log/debug "Finished migration version" migration-version) + nil) + + +(defn- migrate-bootstrap! + "Similar to migrate!, but for the migrator itself. + Doesn't keep a separate version table because that's a recursive problem, + and instead always runs all the migrations. + They should be idempotent, cheap, and don't log anything, so it's ok to always do this." + [conn bootstrap-migrations] + (run! (fn [[_ f]] (f conn)) bootstrap-migrations)) + + +(defn migrate! + "Migrate conn to latest (or up-to). + Interrupted migrations should resume gracefully next time migrate! runs. " + [conn migrations bootstrap-migrations version set-version! & {:keys [up-to] :or {up-to ##Inf}}] + (migrate-bootstrap! conn bootstrap-migrations) + (let [current-v (version conn) + v-filter (fn [[v]] (and (< current-v v) (<= v up-to))) + migrations (filter v-filter migrations)] + (when (seq migrations) + (log/debug "Running" (count migrations) "migrations") + (run! (partial run-migration! conn set-version!) migrations) + (log/debug "Ledger migrated to version" (-> migrations last first))))) diff --git a/src/cljc/athens/common/sentry.cljc b/src/cljc/athens/common/sentry.cljc new file mode 100644 index 0000000000..eb0d09dbca --- /dev/null +++ b/src/cljc/athens/common/sentry.cljc @@ -0,0 +1,105 @@ +(ns athens.common.sentry + "Macros for Sentry monitoring" + (:require + [athens.macros :as macros] + #?(:cljs [athens.utils.sentry :as sentry]))) + + +(defmacro wrap-span + [span-name body] + `(let [tx-running?# (athens.utils.sentry/tx-running?) + sentry-tx# (if tx-running?# + (athens.utils.sentry/transaction-get-current) + (athens.utils.sentry/transaction-start (str ~span-name "-wrap-span-auto-tx"))) + active-span# (athens.utils.sentry/span-active) + current-span# (when sentry-tx# + (athens.utils.sentry/span-start (or active-span# + sentry-tx#) + (str ~span-name "-wrap-span") + false)) + result# ~body] + (when current-span# + (athens.utils.sentry/span-finish current-span# false) + (when-not tx-running?# + (athens.utils.sentry/transaction-finish sentry-tx#))) + result#)) + + +(defmacro wrap-span-no-new-tx + [span-name body] + `(let [tx-running?# (athens.utils.sentry/tx-running?) + sentry-tx# (if tx-running?# + (athens.utils.sentry/transaction-get-current)) + active-span# (athens.utils.sentry/span-active) + current-span# (when sentry-tx# + (athens.utils.sentry/span-start (or active-span# + sentry-tx#) + (str ~span-name "-wrap-span") + false)) + result# ~body] + (when current-span# + (athens.utils.sentry/span-finish current-span# false) + (when-not tx-running?# + (athens.utils.sentry/transaction-finish sentry-tx#))) + result#)) + + +(defn span-start + [_span-name] + #?(:clj nil + :cljs (let [tx-running? (sentry/tx-running?) + sentry-tx (when tx-running? + (sentry/transaction-get-current)) + active-span (sentry/span-active) + current-span (when sentry-tx + (sentry/span-start (or active-span + sentry-tx) + (str _span-name "-defntrace-span") + false))] + [tx-running? sentry-tx current-span]))) + + +(defn span-finish + [[_tx-running? _sentry-tx _current-span]] + #?(:clj nil + :cljs (when _current-span + (sentry/span-finish _current-span false) + (when-not _tx-running? + (sentry/transaction-finish _sentry-tx))))) + + +(defn wrap-span-xform + [span-name body] + `((let [span-ret# (span-start ~span-name) + result# (do ~@body)] + (span-finish span-ret#) + result#))) + + +(defmacro defntrace + "Define a function that is traced by Sentry using a span-start and span-finish as a prepost block. + Accepts the same arguments as defn. You can optionally include a string as the first argument to + use that as the span name, otherwise the function name is used. + + Given the following function: + + (defn foo [x] + (bar) (baz)) + + Using defntraced instead of defn will output the following function definition: + + (defn foo [] + (let [span-ret (span-start ~span-name) + result (do (bar) (baz))] + (span-finish span-ret) + result)) + " + [& args] + (let [first-arg (first args) + provided-name (when (string? first-arg) first-arg) + args' (if provided-name (rest args) args) + xform (fn [conf name] + (->> (partial wrap-span-xform (str name)) + (partial macros/update-body-body) + (macros/update-bodies conf)))] + (cons `defn (macros/defn-args-xform xform args')))) diff --git a/src/cljc/athens/common/utils.cljc b/src/cljc/athens/common/utils.cljc new file mode 100644 index 0000000000..046ae7fddb --- /dev/null +++ b/src/cljc/athens/common/utils.cljc @@ -0,0 +1,71 @@ +(ns athens.common.utils + "Athens Common Utilities. + Shared between CLJ and CLJS." + (:require + [clojure.pprint :as pprint]) + #?(:cljs + (:require-macros + [athens.common.utils])) + #?(:clj + (:import + (java.time + LocalDateTime) + (java.util + Date)))) + + +(defn now-ts + [] + #?(:clj (.getTime (Date.)) + :cljs (.getTime (js/Date.)))) + + +(defn now-ms + [] + #?(:clj (/ (.getNano (LocalDateTime/now)) 1000000) + :cljs (js/performance.now))) + + +(defn uuid->string + "Useful for printing and to get around how cljs transit uuids are not uuids + (see https://github.com/cognitect/transit-cljs/issues/41). + It would be less characters to just type `str` instead of this fn, especially + with a namespace, but forgetting to convert uuids at all is a common error, + so having a dedicated fn helps us keep it in mind." + [uuid] + (str uuid)) + + +(defn gen-block-uid + "Generates new `:block/uid`." + [] + (subs (str (random-uuid)) 27)) + + +(defn gen-event-id + [] + (random-uuid)) + + +(defmacro log-time + [prefix expr] + `(let [start# (now-ms) + ret# ~expr] + (log/info ~prefix (double (- (now-ms) start#)) "ms") + ret#)) + + +(defmacro parses-to + [parser & tests] + `(t/are [in# out#] (= out# (do + (println in#) + (time (~parser in#)))) + ~@tests)) + + +(defn spy + "Pretty print and return x. + Useful for debugging and logging." + [x] + (pprint/pprint x) + x) diff --git a/src/cljc/athens/common_db.cljc b/src/cljc/athens/common_db.cljc new file mode 100644 index 0000000000..2f8f4d76ad --- /dev/null +++ b/src/cljc/athens/common_db.cljc @@ -0,0 +1,1226 @@ +(ns athens.common-db + "Common DB (Datalog) access layer. + So we execute same code in CLJ & CLJS." + (:refer-clojure :exclude [descendants]) + (:require + [athens.common.logging :as log] + [athens.common.migrations :as migrations] + [athens.parser :as parser] + [clojure.data :as data] + [clojure.pprint :as pp] + [clojure.set :as set] + [clojure.string :as string] + [clojure.walk :as walk] + [datascript.core :as d]) + #?(:cljs + (:require-macros + [athens.common.sentry :as sentry-m :refer [wrap-span wrap-span-no-new-tx]]))) + + +(def v1-schema + {:schema/version {} + :block/uid {:db/unique :db.unique/identity} + :node/title {:db/unique :db.unique/identity} + :attrs/lookup {:db/cardinality :db.cardinality/many} + :block/children {:db/cardinality :db.cardinality/many + :db/valueType :db.type/ref} + :block/refs {:db/cardinality :db.cardinality/many + :db/valueType :db.type/ref} + ;; TODO: do we really still use it? + :block/remote-id {:db/unique :db.unique/identity}}) + + +(def v2-schema + {;; It would be nicer to have the reverse relationship here (:block/properties as a set + ;; of children refs, instead :block/property-of as a single parent ref), but that doesn't + ;; work with the tupleAttr below. + :block/property-of {:db/cardinality :db.cardinality/one + :db/valueType :db.type/ref} + ;; key is to a property what order is to a child + :block/key {:db/cardinality :db.cardinality/one + :db/valueType :db.type/ref} + ;; tupleAttrs are used here to ensure the relationship is unique, + ;; that there are no repeat keys in a block. + ;; https://github.com/tonsky/datascript/blob/master/docs/tuples.md + ;; It could later be extended to also ensure order is unique. + :block/property-of+key {:db/tupleAttrs [:block/property-of :block/key] + :db/unique :db.unique/identity}}) + + +(def v3-schema + {;; Time is a unique number timestamp. + :time/ts {:db/unique :db.unique/identity} + ;; Presence ids are unique strings, matching the presence name. + :presence/id {:db/unique :db.unique/identity} + ;; Events have uid, and reference time, and auth. + :event/uid {:db/unique :db.unique/identity} + :event/time {:db/cardinality :db.cardinality/one + :db/valueType :db.type/ref} + :event/auth {:db/cardinality :db.cardinality/one + :db/valueType :db.type/ref} + ;; Blocks reference events for creation and edits. + :block/create {:db/cardinality :db.cardinality/one + :db/valueType :db.type/ref} + :block/edits {:db/cardinality :db.cardinality/many + :db/valueType :db.type/ref}}) + + +(defn migrate-v2-time-to-v3-time + [conn] + (->> (d/datoms @conn :eavt) + (filter (comp #{:create/time :edit/time} second)) + (mapcat (fn [[e a v]] + [[:db/retract e a] + {:db/id e + (if (= a :create/time) + :block/create + :block/edits) + {:event/time {:time/ts v}}}])) + (d/transact! conn))) + + +(def v1-bootstrap-schema + {:migrations/version {:db/unique :db.unique/identity}}) + + +(defn db-versions + [db] + (->> (d/datoms db :aevt :migrations/version) + (mapv #(nth % 2)) + (concat [0]) + set)) + + +(defn version + [conn] + (apply max (db-versions @conn))) + + +(defn set-version! + [conn version] + (d/transact! conn [{:migrations/version version}])) + + +(defn transact-schema! + [conn schema] + (let [current-schema (d/schema @conn) + merged-schema (merge current-schema schema)] + ;; NB: there is no way to update the schema of an existing conn. + ;; This returns a new conn, any watchers will have to be added again. + (reset! conn (-> (d/datoms @conn :eavt) + (d/conn-from-datoms merged-schema) + deref)))) + + +(def bootstrap-migrations + [[1 #(when (= (version %) 0) + (transact-schema! % v1-bootstrap-schema))]]) + + +(def migrations + [[1 #(transact-schema! % v1-schema)] + [2 #(transact-schema! % v2-schema)] + [3 (fn [conn] + (transact-schema! conn v3-schema) + (migrate-v2-time-to-v3-time conn))]]) + + +(defn migrate-conn! + [conn & {:as opts}] + (migrations/migrate! conn migrations bootstrap-migrations version set-version! opts) + conn) + + +(defn create-conn + [] + (migrate-conn! (d/create-conn))) + + +(defn reset-conn! + [conn db] + (if (= (version conn) (version (atom db))) + (d/reset-conn! conn db) + ;; Migrate db before resetting conn to it dbs content. + (->> (d/conn-from-db db) + migrate-conn! + deref + (d/reset-conn! conn)))) + + +(def empty-db + (d/db (create-conn))) + + +(defn e-by-av + [db a v] + (-> (d/datoms db :avet a v) + first + :e)) + + +(defn v-by-ea + [db e a] + (get (d/entity db e) a)) + + +(defn get-sidebar-elements + [db] + (->> (d/q '[:find [(pull ?e [*]) ...] + :where + [?e :page/sidebar _]] + db) + (sort-by :page/sidebar))) + + +(defn get-sidebar-count + [db] + (-> (get-sidebar-elements db) + count)) + + +(defn get-shortcut-neighbors + "Get the neighbors for a given shortcut page, as :before and :after keys. + Return nil values if there is no neighbor before or after." + [db title] + (let [sidebar-items (get-sidebar-elements db) + sidebar-titles (mapv :node/title sidebar-items) + idx (.indexOf sidebar-titles title) + neighbors {:before (get sidebar-titles (dec idx)) + :after (get sidebar-titles (inc idx))}] + neighbors)) + + +(defn flip-neighbor-position + "Flips neighbor position to undo a remove. + + --Setup-- + Page 1 <- remove shortcut + Page 2 <- :after + + --After Remove-- + Page 2 <- + + --Undo-- + Page 1 <- restore shortcut (new) + Page 2 <- :before" + [{:keys [before after] :as _neighbors}] + (cond + after {:relation :before + :page/title after} + before {:relation :after + :page/title before})) + + +(defn get-sidebar-titles + [db] + (->> (get-sidebar-elements db) + (mapv :node/title))) + + +(defn find-title-from-order + [db order] + (->> (get-sidebar-elements db) + (filter (fn [el] + (= (:page/sidebar el) + order))) + (first) + (:node/title))) + + +(defn find-source-target-title + [db source-order target-order] + (let [source-title (find-title-from-order db source-order) + target-title (find-title-from-order db target-order)] + [source-title target-title])) + + +(defn find-order-from-title + [db title] + (->> (get-sidebar-elements db) + (filter (fn [el] + (= (:node/title el) + title))) + (first) + (:page/sidebar))) + + +(defn sort-block-children + [block] + (if-let [children (seq (:block/children block))] + (assoc block :block/children + (vec (sort-by :block/order (map sort-block-children children)))) + block)) + + +(defn sort-block-properties + [properties] + (->> properties + (sort-by (comp str first)) + (map second))) + + +(defn add-property-map + [block] + (let [block' (if (:block/children block) + (update block :block/children (partial mapv add-property-map)) + block) + properties (-> block' :block/_property-of seq)] + (if properties + (assoc block' :block/properties + (->> properties + (map (fn [block] + [(-> block :block/key :node/title) (add-property-map block)])) + (into {}))) + block'))) + + +(defn get-block + "Fetches whole block based on `:db/id`." + [db eid] + (when (d/entity db eid) + (-> (d/pull db '[:db/id + :node/title + :block/uid + :block/order + :block/string + :block/open + :block/refs + :block/_refs + {:block/key [:node/title]} + {:block/_property-of [:block/uid + {:block/key [:node/title]}]} + {:block/children [:block/uid + :block/order]}] + eid) + sort-block-children + add-property-map))) + + +(defn get-page + "Fetches whole page based on `:db/id`." + [db eid] + (when (d/entity db eid) + (-> (d/pull db '[:db/id + :node/title + :block/uid + :page/sidebar + :block/refs + :block/_refs + :block/_key + {:block/_property-of [:block/uid + {:block/key [:node/title]}]} + {:block/children [:block/uid + :block/order]}] + eid) + sort-block-children + add-property-map))) + + +(defn block-exists? + [db eid] + (d/entity db eid)) + + +(defn child-of-or-property-of + [e] + (if-let [p (:block/_children e)] + (first p) + (:block/property-of e))) + + +(defn get-parent-eid + "Find parent's eids of given `eid`." + [db eid] + (-> (d/entity db eid) + child-of-or-property-of + (select-keys [:block/uid]) + seq + first)) + + +(defn get-parent + "Given `:db/id` find it's parent." + [db eid] + (get-block db (get-parent-eid db eid))) + + +(defn get-parent-eids + "Return parent parent eids for `eid`." + [db eid] + (loop [block eid + acc []] + (if-let [parent (get-parent-eid db block)] + (recur parent (conj acc parent)) + acc))) + + +(defn get-children-uids + "Fetches page or block sorted children uids based on eid lookup." + [db eid] + (when (d/entity db eid) + (->> (d/pull db '[{:block/children [:block/uid + :block/order]}] + eid) + :block/children + (sort-by :block/order) + (mapv :block/uid)))) + + +(defn get-property-uids + "Fetches page or block sorted property uids based on eid lookup." + [db eid] + (when (d/entity db eid) + (->> (d/pull db '[{:block/_property-of [:block/uid + {:block/key [:node/title]}]}] + eid) + add-property-map + :block/properties + sort-block-properties + (mapv :block/uid)))) + + +(defn sorted-prop+children-uids + [db eid] + (into (get-property-uids db eid) + (get-children-uids db eid))) + + +(defn property-key + [db eid] + (->> (d/entity db eid) + :block/key + :node/title)) + + +(def block-document-pull-vector + '[:db/id :block/uid :block/string :block/open :block/order + {:block/children ...} :block/refs :block/_refs + {:block/key [:node/title]} {:block/_property-of ...} + {:block/create [{:event/time [:time/ts]} + {:event/auth [:presence/id]}]} + {:block/edits [{:event/time [:time/ts]} + {:event/auth [:presence/id]}]}]) + + +(def node-document-pull-vector + (-> block-document-pull-vector + (conj :node/title :page/sidebar :block/_key))) + + +(defn get-block-document + "Fetches whole block and whole children" + [db id] + (->> (d/pull db block-document-pull-vector id) + sort-block-children + add-property-map)) + + +(defn get-block-property-document + "Fetches whole property document for block." + [db id] + (-> (d/pull db [:block/_property-of] id) + (update :block/_property-of #(mapv (fn [{:db/keys [id]}] (get-block-document db id)) %)) + add-property-map + :block/properties)) + + +(declare get-page-title-from-eid) + + +(defn get-entity-type + "Returns the value of eids `:entity/type` prop, if any." + [db eid] + (let [prop-entity-type (->> (d/entity db eid) + :block/_property-of + (some (fn [e] + (when (-> e :block/key :node/title (= ":entity/type")) + (:block/string e))))) + page (get-page-title-from-eid db eid)] + (cond + prop-entity-type prop-entity-type + page "page" + :else "block"))) + + +(defn get-block-uid + "Finds block `:block/uid` by eid." + [db eid] + (-> db + (d/entity eid) + :block/uid)) + + +(defn get-page-uid + "Finds page `:block/uid` by `page-title`." + [db page-title] + (-> db + (d/entity [:node/title page-title]) + :block/uid)) + + +(defn map-new-refs + "Find and replace linked ref with new linked ref, based on title change." + [linked-refs old-title new-title] + (map (fn [{:block/keys [uid string] :node/keys [title]}] + (let [[text kw] (if title + [title :node/title] + [string :block/string]) + has-spaces? (string/includes? new-title " ") + old-wrapped (str "[[" old-title "]]") + new-wrapped (str "[[" new-title "]]") + old-naked-hash (str "#" old-title) + new-naked-hash (if has-spaces? + (str "#[[" new-title "]]") + (str "#" new-title)) + new-str (-> text + (string/replace old-wrapped new-wrapped) + (string/replace old-naked-hash new-naked-hash))] + {:db/id [:block/uid uid] + kw new-str})) + linked-refs)) + + +(defn replace-linked-refs-tx + "For a given block, unlinks [[brackets]], #[[brackets]], #brackets, or ((brackets))." + [db refered-blocks] + (let [refering-blocks-ids (sequence (comp (mapcat #(:block/_refs %)) + (map :db/id) + (distinct)) + refered-blocks) + refering-blocks (d/pull-many db [:db/id + :block/string + :node/title] + refering-blocks-ids)] + (into [] + (map (fn [refering-block] + (let [updated-string-content (reduce (fn [content {:keys [block/string node/title]}] + (when content + (string/replace content + (if title + (str "[[" title "]]") + (str "((" string "))")) + (or title string)))) + (:block/string refering-block) + refered-blocks) + updated-title-content (reduce (fn [content {:keys [block/string node/title]}] + (when content + (string/replace content + (if title + (str "[[" title "]]") + (str "((" string "))")) + (or title string)))) + (:node/title refering-block) + refered-blocks)] + (cond-> refering-block + (seq updated-string-content) (assoc :block/string updated-string-content) + (seq updated-title-content) (assoc :node/title updated-title-content))))) + refering-blocks))) + + +(defn get-page-document + "Retrieves whole page 'document', meaning with children." + [db eid] + (when (d/entity db eid) + (-> db + (d/pull node-document-pull-vector eid) + sort-block-children + add-property-map))) + + +(defn uid-and-embed-id + [uid] + (or (some->> uid + (re-find #"^(.+)-embed-(.+)") + rest vec) + [uid nil])) + + +(defn nth-child + "Find child that has order n in parent." + [db parent-uid order] + (d/q '[:find (pull ?child [*]) . + :in $ ?parent-uid ?order + :where + [?parent :block/uid ?parent-uid] + [?parent :block/children ?child] + [?child :block/order ?order]] + db parent-uid order)) + + +(defn get-page-title + [db uid] + (-> db + (d/entity [:block/uid uid]) + :node/title)) + + +(defn get-page-title-from-eid + [db eid] + (-> db + (d/entity eid) + :node/title)) + + +(defn get-block-string + [db uid] + (-> db + (d/entity [:block/uid uid]) + :block/string)) + + +(defn same-parent? + "Given a coll of uids, determine if uids are all direct children of the same parent." + [db uids] + #_(log/debug "same parent") + (let [parents (->> uids + (mapv (comp first uid-and-embed-id)) + (d/q '[:find ?parents + :in $ [?uids ...] + :where + [?e :block/uid ?uids] + [?parents :block/children ?e]] + db))] + (= (count parents) 1))) + + +;; TODO: support removing two string refs from the same block. +(defn retract-uid-recursively-tx + "Retract all blocks of a page, including the page. + Replaces block string refs to removed entities by their ref text." + [db event-ref uid] + (let [block (get-block db [:block/uid uid]) + parent (->> [:block/uid uid] (get-parent db)) + descendants (concat [] (:block/children block) (:block/_property-of block)) + has-descendants? (seq descendants) + descendants-uids (when has-descendants? + (loop [acc [] + to-look-at descendants] + (if-let [look-at (first to-look-at)] + (let [c-uid (:block/uid look-at) + c-block (get-block db [:block/uid c-uid])] + (recur (conj acc c-uid) + (concat (rest to-look-at) + (:block/children c-block) + (:block/_property-of c-block)))) + acc))) + all-uids-to-remove (conj (set descendants-uids) uid) + uid->refs (->> all-uids-to-remove + (map (fn [uid] + (let [block (get-block db [:block/uid uid]) + rev-refs (set (:block/_refs block))] + (when-not (empty? rev-refs) + [uid (set rev-refs)])))) + (remove nil?) + (into {})) + ref-eids (mapcat second uid->refs) + eids->uids (->> ref-eids + (map (fn [{id :db/id}] + [id (v-by-ea db id :block/uid)])) + (into {})) + removed-uid->uid-refs (->> uid->refs + (map (fn [[k refs]] + [k (set + (for [{eid :db/id} refs + :let [uid (eids->uids eid)] + :when (not (contains? all-uids-to-remove uid))] + uid))])) + (remove #(empty? (second %))) + (into {})) + asserts (->> removed-uid->uid-refs + (mapcat (fn [[removed-uid referenced-uids]] + (let [removed-string (v-by-ea db [:block/uid removed-uid] :block/string) + from-string (str "((" removed-uid "))")] + (map (fn [uid] + (if-not removed-string + {:block/uid uid} + (let [string (get-block-string db uid) + title (get-page-title db uid)] + (cond-> {:block/uid uid} + string (merge {:block/string (string/replace string from-string removed-string)}) + title (merge {:node/title (string/replace title from-string removed-string)}))))) + referenced-uids))))) + has-asserts? (seq asserts) + retract-kids (mapv (fn [uid] + [:db/retractEntity [:block/uid uid]]) + (reverse descendants-uids)) + edit-parent (when parent + (merge {:block/edits event-ref} + (if-let [title (:node/title parent)] + {:node/title title} + {:block/uid (:block/uid parent)}))) + retract-entity [:db/retractEntity [:block/uid uid]] + txs (cond-> [] + has-descendants? (into retract-kids) + has-asserts? (into asserts) + edit-parent (conj edit-parent) + true (conj retract-entity))] + txs)) + + +(defn dissoc-on-match + [m [k f]] + (if (f m) + (dissoc m k) + m)) + + +(defn get-internal-representation + "Returns internal representation for eid in db." + [db eid] + (when (d/entity db eid) + (let [rename-ks {:block/open :block/open? + :node/title :page/title} + remove-ks [:db/id :page/sidebar :block/order + :block/refs :block/_refs + :block/key :block/_key + :block/_property-of + :block/create :block/edits] + remove-ks-on-match [[:block/open? :block/open?] + [:block/uid :page/title]]] + ;; NB: get-page-document retrieves all keys in get-block-document as well + (->> (get-page-document db eid) + (walk/postwalk-replace rename-ks) + (walk/prewalk (fn [node] + (if (map? node) + (as-> node n + (apply dissoc n remove-ks) + (reduce dissoc-on-match n remove-ks-on-match)) + node))))))) + + +(defn get-linked-refs-by-page-title + [db page-title] + (when (d/entity db [:node/title page-title]) + (->> (d/pull db '[* :block/_refs] [:node/title page-title]) + :block/_refs + (mapv :db/id) + (mapv #(d/pull db '[:db/id :node/title :block/uid :block/string] %))))) + + +(def all-pages-pull-vector + [:block/uid :node/title + {:block/edits [{:event/time [:time/ts]}]} + {:block/create [{:event/time [:time/ts]}]} + ;; Get all block refs, we need them to count totals. + ;; Without specifying a limit pull will only return first 1000. + ;; https://docs.datomic.com/on-prem/query/pull.html#limit-option + [:block/_refs :limit nil]]) + + +(defn get-all-pages + [db] + (->> (d/datoms db :aevt :node/title) + (map first) + (d/pull-many db all-pages-pull-vector))) + + +(defn compat-position + "Build a position by coercing incompatible arguments into compatible ones. + uid to a page will instead use that page's title. + Integer relation will be converted to :first if 0, or :after (with matching uid) if not. + Relative positions to properties will be converted to :first on the parent. + Accepts the `{:block/uid :relation }` old format based on order number. + Output position will be athens.common-events.graph.schema/child-position for the first block, + and athens.common-events.graph.schema/sibling-position for others. + It's safe to use a position that does not need coercing of any arguments, like the output formats." + [db {:keys [relation block/uid page/title] :as pos}] + (let [[coerced-ref-uid + coerced-relation] (cond + (integer? relation) + (if (= relation 0) + [nil :first] + (let [parent-uid (or uid (get-page-uid db title)) + prev-uid (:block/uid (nth-child db parent-uid (dec relation)))] + (if prev-uid + [prev-uid :after] + ;; Can't find the previous block, just put it on last. + [nil :last]))) + (and uid + (#{:before :after} relation) + (property-key db [:block/uid uid])) + [(second (get-parent-eid db [:block/uid uid])) :first]) + coerced-title (when (not title) + (get-page-title db (or coerced-ref-uid uid))) + new-pos (when (or coerced-ref-uid coerced-relation coerced-title) + (merge + {:relation (or coerced-relation relation)} + (if-let [title' (or coerced-title title)] + {:page/title title'} + {:block/uid (or coerced-ref-uid uid)})))] + (or new-pos pos))) + + +(defn get-position + "Get the position for block-uid in db. + Position will be athens.common-events.graph.schema/child-position for the first block, + and athens.common-events.graph.schema/sibling-position or /property-position for others." + [db block-uid] + (let [{:block/keys [order key] + :db/keys [id]} (get-block db [:block/uid block-uid]) + parent-uid (->> id (get-parent db) :block/uid) + position (compat-position db {:block/uid parent-uid + :relation (or order {:page/title (:node/title key)})})] + position)) + + +(defn validate-position + [db {:keys [relation block/uid page/title] :as position}] + (let [title->uid (get-page-uid db title) + uid->title (get-page-title db uid) + key (:page/title relation) + block (get-block db [:block/uid (or uid title->uid)]) + keys (->> block :block/properties keys set)] + ;; Fail on error conditions. + (when-some [fail-msg (cond + (and uid uid->title) + "Location uid is a page, location must use title instead." + + ;; TODO: this could be idempotent instead and create the page. + (and title (not title->uid)) + (str "Location title does not exist:" title) + + (and uid (not (e-by-av db :block/uid uid))) + (str "Location uid does not exist:" uid) + + ;; TODO: this could be idempotent and instead overwrite the name. + (and key (keys key)) + (str "Location already contains key: " key) + + (and (#{:before :after} relation) (:block/key block)) + (str "Location is a property, cannot use :after/:before relation."))] + (throw (ex-info fail-msg position))))) + + +(defn position->uid+parent + [db {:keys [relation block/uid page/title] :as position}] + ;; Validate the position itself before determining the parent. + (validate-position db position) + (let [;; Pages must be referenced by title but internally we still use uids for them. + uid (or uid (get-page-uid db title)) + {parent-uid :block/uid} (if (or (#{:first :last} relation) + (:page/title relation)) + ;; We already know the blocks exists because of validate-position + (get-block db [:block/uid uid]) + (if-let [parent (get-parent db [:block/uid uid])] + parent + (throw (ex-info "Ref block does not have parent" {:block/uid uid}))))] + [uid parent-uid])) + + +(defn drop-prop-position + [db uid target-uid rel] + (let [target-uid' (if (#{:first :last} rel) + target-uid + (:block/uid (get-parent db [:block/uid target-uid]))) + k (property-key db [:block/uid uid])] + (compat-position db {:block/uid target-uid' + :relation {:page/title k}}))) + + +(defn orphan-block-uids + [db] + (d/q '[:find ?uid + :where + [?e :block/uid ?uid] + [(missing? $ ?e :node/title)] + [(missing? $ ?e :block/_children)] + [(missing? $ ?e :block/property-of)]] + db)) + + +(defn breadcrumb-string + [db uid] + (let [{:block/keys [key string] + :keys [node/title]} (d/entity db [:block/uid uid]) + prop (:node/title key) + prop-fragment (when prop (str ": " prop " - "))] + (or title (str prop-fragment string)))) + + +(defn has-descendants? + [{:block/keys [children properties]}] + (or children properties)) + + +(defn descendants + [{:block/keys [children properties]}] + (concat children properties)) + + +(defn time-range + [db eid] + (let [all-times (->> (d/pull db '[{:block/edits [{:event/time [:time/ts]}]} + {:block/children ...} + {:block/_property-of ...}] + eid) + (tree-seq has-descendants? descendants) + (mapcat :block/edits) + (map (comp :time/ts :event/time)) + sort)] + [(first all-times) (last all-times)])) + + +(defn extract-tag-values + "Extracts `tag` values from `children-fn` children with `extractor-fn` from parser AST." + [ast tag-selector children-fn extractor-fn] + (->> (tree-seq vector? children-fn ast) + (filter vector?) + (keep #(when (tag-selector (first %)) + (extractor-fn %))) + set)) + + +(defn strip-markup + "Remove `start` and `end` from s if present. + Returns nil if markup was not present." + [s start end] + (when (and (string? s) + (string/starts-with? s start) + (string/ends-with? s end)) + (subs s (count start) (- (count s) (count end))))) + + +(defn string->lookup-refs + "Given string s, compute the set of refs expressed as Datalog lookup refs." + [s] + (let [ast (parser/structure-parse-to-ast s) + block-lookups (into #{} + (map (fn [uid] [:block/uid uid])) + (extract-tag-values ast #{:block-ref} identity #(-> % second :string))) + page-lookups (into #{} + (map (fn [title] [:node/title title])) + (extract-tag-values ast #{:page-link :hashtag} identity #(-> % second :string)))] + (set/union block-lookups page-lookups))) + + +(defn eid->lookup-ref + "Return the :block/uid based lookup ref for entity eid in db. + eid can be either an entity id or a lookup ref. + Returns nil if there's no entity, or if entity does not have :block/uid." + [db eid] + (-> (d/entity db eid) + (select-keys [:block/uid]) + seq + first)) + + +(defn update-refs-tx + "Return the tx that will update lookup ref's :block/refs from before to after. + Both before and after should be sets of lookup refs." + [lookup-ref before after] + (let [[only-before only-after] (data/diff before after) + to-tx (fn [type ref] [type lookup-ref :block/refs ref])] + (set (concat (map (partial to-tx :db/retract) only-before) + (map (partial to-tx :db/add) only-after))))) + + +(comment + (string->lookup-refs "one [[two]] ((three)) #four #[[five [[six]]]]") + (parser/parse-to-ast "one [[two]] ((three)) #four #[[five [[six]]]]") + (update-refs-tx [:block/uid "one"] #{[:node/title "foo"]} #{[:block/uid "bar"] [:node/title "baz"]})) + + +(defn block-refs-as-lookup-refs + [db eid-or-lookup-ref] + (when-some [ent (d/entity db eid-or-lookup-ref)] + (into #{} (comp (mapcat second) + (map :db/id) + (map (partial eid->lookup-ref db))) + (d/pull db '[:block/refs] (:db/id ent))))) + + +(defn string-as-lookup-refs + [db string] + (into #{} (comp (mapcat string->lookup-refs) + (map (partial eid->lookup-ref db)) + (remove nil?)) + [string])) + + +(defn- parseable-string-datom + [[eid attr value _time added?]] + (when (and added? (#{:block/string :node/title} attr)) + [eid value])) + + +(defn find-page-links + [s] + (->> (string->lookup-refs s) + (filter #(= :node/title (first %))) + (map second) + (into #{}))) + + +(defn linkmaker-error-handler + [e input-tx] + (log/error e "❌ Linkmaker failure.") + (log/debug "Linkmaker original TX:\n" (with-out-str + (pp/pprint input-tx))) + ;; Return the original, un-modified, input tx so that transactions can still move forward. + ;; We can always run linkmaker again later over all strings if we think the db is not correctly linked. + ;; TODO(reporting): report the error type, without any identifiable information. + input-tx) + + +(defn update-refs + "Returns updated refs for eid. + Returns nil if eid is no longer in db." + [db [eid string]] + (when-let [lookup-ref (eid->lookup-ref db eid)] + (let [before (block-refs-as-lookup-refs db lookup-ref) + after (string-as-lookup-refs db string)] + (update-refs-tx lookup-ref before after)))) + + +(defn linkmaker + "Maintains the linked nature of Knowledge Graph. + + Returns Datascript transactions to be transacted in order to maintain links. + + Arguments: + - `db`: Current Datascript DB value + - `input-tx` (optional): Graph structure modifying TX, analyzed for link updates + + If `input-tx` is provided, linkmaker will only update links related to that tx. + If `input-tx` is not provided, all links in the db are checked for updates. + + Named after [Keymaker](https://en.wikipedia.org/wiki/Keymaker). " + + ([db] + (try + (let [datoms (d/datoms db :eavt) + linkmaker-txs (into [] + (comp (keep parseable-string-datom) + (mapcat (partial update-refs db))) + datoms)] + #_(log/debug "linkmaker:" + "\nall:" (with-out-str (pp/pprint datoms)) + "\nlinkmaker-txs:" (with-out-str (pp/pprint linkmaker-txs))) + linkmaker-txs) + (catch #?(:cljs :default + :clj Exception) e + (linkmaker-error-handler e [])))) + + ([db input-tx] + (try + (let [{:keys [db-after + tx-data]} (d/with db input-tx) + linkmaker-txs (into [] + (comp (keep parseable-string-datom) + ;; Use db-after for the before-refs to ensure retracted + ;; entities are already removed, otherwise we get + ;; entity-missing errors from trying to retract refs + ;; with lookup-refs to missing entities. + (mapcat (partial update-refs db-after))) + tx-data) + with-linkmaker-txs (into (vec input-tx) linkmaker-txs)] + #_(log/debug "linkmaker:" + "\ninput-tx:" (with-out-str (pp/pprint input-tx)) + "\ntx-data:" (with-out-str (pp/pprint tx-data)) + "\nlinkmaker-txs:" (with-out-str (pp/pprint linkmaker-txs)) + "\nwith-linkmaker-txs:" (with-out-str (pp/pprint with-linkmaker-txs))) + with-linkmaker-txs) + (catch #?(:cljs :default + :clj Exception) e + (linkmaker-error-handler e input-tx))))) + + +(defn fix-block-order + [{:block/keys [children] :as parent-block}] + (let [sorted-kids (->> children + (sort-by #(vector (:block/order %) + (:block/uid %)))) + indexed-kids (map-indexed vector sorted-kids) + block-fixes (keep (fn [[idx {:block/keys [uid order]}]] + (when-not (= idx order) + {:block/uid uid + :block/order idx})) + indexed-kids)] + #_(log/debug "indexed-kids:" (with-out-str + (pp/pprint indexed-kids)) + "\nblock-fixes:" (with-out-str + (pp/pprint block-fixes))) + (when-not (empty? block-fixes) + (log/error "\nNeeded to fix block-order:\n" (with-out-str + (pp/pprint block-fixes)) + "\nOf parent:\n" (with-out-str + (pp/pprint parent-block)))) + block-fixes)) + + +(defn keep-block-order + "Checks for `:block/order` violations and generates fixing TXs. + + Arguments: whatever it takes" + [{:keys [db-before db-after tx-data]}] + (let [mod-eids (->> tx-data + (keep (fn [[eid attr]] + (when (= :block/order attr) + (eid->lookup-ref db-after eid)))) + set) + old-parents (when db-before + (->> mod-eids + (map #(get-parent-eid db-before %)) + (remove #(string/blank? (v-by-ea db-after % :block/uid))) + set)) + new-parents (->> mod-eids + (map #(get-parent-eid db-after %)) + set) + both-parents (set/union old-parents new-parents) + parents-blocks (->> both-parents + (remove nil?) + (map #(get-block db-after %))) + new-violations (doall + (remove #(or (empty? %) + (nil? (:block/uid %))) + (mapcat fix-block-order parents-blocks)))] + #_(log/debug "keep-block-order:" + "\ntx-data:" (with-out-str + (pp/pprint tx-data)) + "\nmod-eids:" (pr-str mod-eids) + "\nold-parents:" (pr-str old-parents) + "\nnew-parents:" (pr-str new-parents) + "\nparents-blocks:\n" (with-out-str + (pp/pprint parents-blocks)) + "\nnew-violations:" (pr-str new-violations)) + new-violations)) + + +(defn orderkeeper-error + [ex input-tx] + (log/error ex "❌ Orderkeeper failure.") + (log/debug "Orderkeeper original TX:\n" (with-out-str + (pp/pprint input-tx))) + input-tx) + + +(defn orderkeeper + "Maintains the order in Knowledge Graph. + + Returns Datascript transactions to be transacted in order to maintain order. + + Arguments: + - `db`: Current Datascript DB value + - `input-tx`: (optional): Graph structure modifying TX, analyzed for `:block/order` mistakes + + If `input-tx` is provided, orderkeeper will only update `:block/order` related to that TX. + If `input-tx` is not provided, all `:block/order` will be checked." + ([db] + (try + (let [datoms (d/datoms db :eavt) + orderkeeper-txs (into [] + (keep-block-order {:db-after db + :tx-data datoms}))] + orderkeeper-txs) + (catch #?(:cljs :default + :clj Exception) e + (orderkeeper-error e []) + []))) + + ([db input-tx] + (try + (let [tx-report (d/with db input-tx) + orderkeeper-txs (into [] + (keep-block-order tx-report)) + with-orderkeeper-txs (into (vec input-tx) orderkeeper-txs)] + with-orderkeeper-txs) + (catch #?(:cljs :default + :clj Exception) e + (orderkeeper-error e input-tx) + input-tx)))) + + +(defn block-uid-nil-eater-error + [ex input-tx] + (log/error ex "❌ `:block/uid nil` eater error") + input-tx) + + +(defn block-uid-nil-eater + "Eats (removes) all block with `:block/order` nil" + ([db] + (block-uid-nil-eater db [])) + ([db input-tx] + (try + (let [tx-report (d/with db input-tx) + violating-db-ids (d/q '[:find ?eid + :keys db/id + :where [?eid :block/order] + (not [?eid :block/uid])] + (:db-after tx-report)) + retractions (into [] + (for [{eid :db/id} violating-db-ids] + (do + (log/warn "block-uid-nil-eater, have to remove :db/id" eid) + [:db/retractEntity eid]))) + with-eater (into (vec input-tx) retractions)] + with-eater) + (catch #?(:cljs :default + :clj Exception) e + (block-uid-nil-eater-error e input-tx))))) + + +(defn tx-with-middleware + [db tx-data] + #?(:cljs + (as-> tx-data $ + ;; Hasn't really found any problems in a while, and + ;; does a full index scan so it's pretty slow. + #_(wrap-span "block-uid-nil-eater" + (block-uid-nil-eater db $)) + (wrap-span "linkmaker" + (linkmaker db $)) + (wrap-span "orderkeeper" + (orderkeeper db $))) + :clj (->> tx-data + #_(block-uid-nil-eater db) + (linkmaker db) + (orderkeeper db)))) + + +(defn transact-with-middleware! + "Transact tx-data enriched with middleware txs into conn." + [conn tx-data] + ;; 🎶 Sia "Cheap Thrills" + (let [processed-tx-data #?(:cljs (wrap-span "tx-with-middleware" + (tx-with-middleware @conn tx-data)) + :clj (tx-with-middleware @conn tx-data))] + #?(:cljs (wrap-span "ds/transact!" + (d/transact! conn processed-tx-data)) + :clj (d/transact! conn processed-tx-data)))) + + +(defn health-check + [conn] + ;; NB: these could be events as well, and then we wouldn't always rerun them. + ;; But rerunning them after replaying all events helps us find events that produce + ;; states that need fixing. + (log/info "Knowledge graph health check...") + (let [linkmaker-txs #?(:cljs (wrap-span-no-new-tx "linkmaker" + (linkmaker @conn)) + :clj (linkmaker @conn)) + orderkeeper-txs #?(:cljs (wrap-span-no-new-tx "orderkeeper" + (orderkeeper @conn)) + :clj (orderkeeper @conn)) + block-nil-eater-txs #?(:cljs (wrap-span-no-new-tx "nil-eater" + (block-uid-nil-eater @conn)) + :clj (block-uid-nil-eater @conn))] + (when-not (empty? linkmaker-txs) + (log/warn "linkmaker fixes#:" (count linkmaker-txs)) + (log/info "linkmaker fixes:" (pr-str linkmaker-txs)) + #?(:cljs (wrap-span-no-new-tx "transact linkmaker" + (d/transact! conn linkmaker-txs)) + :clj (d/transact! conn linkmaker-txs))) + (when-not (empty? orderkeeper-txs) + (log/warn "orderkeeper fixes#:" (count orderkeeper-txs)) + (log/info "orderkeeper fixes:" (pr-str orderkeeper-txs)) + #?(:cljs (wrap-span-no-new-tx "transact orderkeeper" + (d/transact! conn orderkeeper-txs)) + :clj (d/transact! conn orderkeeper-txs))) + (when-not (empty? block-nil-eater-txs) + (log/warn "block-uid-nil-eater fixes#:" (count block-nil-eater-txs)) + (log/info "block-uid-nil-eater fixes:" (pr-str block-nil-eater-txs)) + #?(:cljs (wrap-span-no-new-tx "transact nil-eater" + (d/transact! conn block-nil-eater-txs)) + :clj (d/transact! conn block-nil-eater-txs))) + (log/info "✅ Knowledge graph health check."))) diff --git a/src/cljc/athens/common_events.cljc b/src/cljc/athens/common_events.cljc new file mode 100644 index 0000000000..785437e858 --- /dev/null +++ b/src/cljc/athens/common_events.cljc @@ -0,0 +1,202 @@ +(ns athens.common-events + "Event as Verbs executed on Knowledge Graph" + (:require + [athens.common.utils :as utils] + [cognitect.transit :as transit] + #?(:cljs [com.cognitect.transit.types :as ty])) + #?(:clj + (:import + (java.io + ByteArrayInputStream + ByteArrayOutputStream)))) + + +;; Limits + +;; Fluree default max size over websocket is ~2mb. +;; There doesn't seem to be a max for nginx +;; https://serverfault.com/questions/1034906/can-nginx-limit-incoming-websocket-message-size +;; Was able to transmit 500mb over websocket from the server to client. +;; Let's settle on a nice sensible 1MB limit for now. +(def max-event-size-in-bytes (* 1 1000 1000)) + + +(defn valid-serialized-event? + [serialized-event] + (< (count serialized-event) max-event-size-in-bytes)) + + +(defn validate-serialized-event + [serialized-event] + (when-not (valid-serialized-event? serialized-event) + (ex-info "Serialized event is larger than 1 MB" {}))) + + +(defn ignore-serialized-event-validation? + [event] + (-> event :event/type + ;; db-dump is sending the whole database and can (easily) go over max-event-size-in-bytes. + ;; Only real solution for this is to break down the db-dump into smaller pieces, + ;; possibly transitioning to partial loading by default in the future. + #{:datascript/db-dump})) + + +;; serialization and limits + +;; Really shouldn't need these UUID and datom-reader, but we still send datoms via db-dump. +#?(:cljs + ;; see https://github.com/cognitect/transit-cljs/issues/41#issuecomment-503287258 + (extend-type ty/UUID IUUID)) + + +(def ^:private datom-reader + (transit/read-handler + (fn [[e a v tx added]] + {:e e + :a a + :v v + :tx tx + :added added}))) + + +(def serialization-type :json) +(def serialization-opts {:handlers {:datom datom-reader}}) + + +(defn serialize + [event] + #?(:cljs (-> (transit/writer serialization-type) + (transit/write event)) + :clj (let [out (ByteArrayOutputStream. 4096) + writer (transit/writer out serialization-type)] + (transit/write writer event) + (.toString out)))) + + +(defn deserialize + [serialized-event] + #?(:cljs (-> (transit/reader serialization-type serialization-opts) + (transit/read serialized-event)) + :clj (let [in (ByteArrayInputStream. (.getBytes serialized-event)) + reader (transit/reader in serialization-type serialization-opts)] + (transit/read reader)))) + + +;; building events + +;; - confirmation events + +(defn build-event-accepted + "Builds ACK Event Response accepting this event." + [id] + {:event/id id + :event/status :accepted}) + + +(defn build-event-rejected + "Builds Rejection Event Response with `:reject/reason & :reject/data`." + [id message data] + {:event/id id + :event/status :rejected + :reject/reason message + :reject/data data}) + + +;; - datascript events + +(defn build-db-dump-event + "Builds `:datascript/db-dump` events with `datoms`." + [datoms] + (let [event-id (utils/gen-event-id)] + {:event/id event-id + :event/type :datascript/db-dump + :event/args {:datoms datoms}})) + + +;; - presence events + +(defn build-presence-hello-event + "Builds `:presence/hello` event with `session-intro` and `password` (optional)." + ([session-intro] + (build-presence-hello-event session-intro nil)) + ([session-intro password] + (let [event-id (utils/gen-event-id)] + {:event/id event-id + :event/type :presence/hello + :event/args (cond-> {:session-intro session-intro} + password (merge {:password password}))}))) + + +(defn build-presence-session-id-event + "Builds `:presence/session-id` event with `session-id` for the client." + [session-id] + (let [event-id (utils/gen-event-id)] + {:event/id event-id + :event/type :presence/session-id + :event/args {:session-id session-id}})) + + +(defn build-presence-online-event + "Builds `:presence/online` event with `session` that went online." + [session] + (let [event-id (utils/gen-event-id)] + {:event/id event-id + :event/type :presence/online + :event/args session})) + + +(defn build-presence-all-online-event + "Builds `:presence/all-online` event with all active users." + [sessions] + (let [event-id (utils/gen-event-id)] + {:event/id event-id + :event/type :presence/all-online + :event/args (vec sessions)})) + + +(defn build-presence-offline-event + [session] + (let [event (build-presence-online-event session)] + (assoc event :event/type :presence/offline))) + + +(defn build-presence-update-event + "Builds `:presence/update` event with `session-id` and map of session props to update." + [session-id updates] + (let [event-id (utils/gen-event-id)] + {:event/id event-id + :event/type :presence/update + :event/args (merge {:session-id session-id} + updates)})) + + +(defn build-atomic-event + "Builds atomic graph operation" + [atomic-op] + (let [event-id (utils/gen-event-id)] + {:event/id event-id + :event/type :op/atomic + :event/op atomic-op + :event/create-time (utils/now-ts)})) + + +(defn add-presence + [event presence-id] + (merge event {:event/presence-id presence-id})) + + +(defn find-event-or-atomic-op-type + "Finds `:event/type` or type of atomic op" + [event-or-op] + (let [event? (and (contains? event-or-op :event/type) + (not= :op/atomic (:event/type event-or-op)))] + (if event? + (:event/type event-or-op) + (let [op (or (:event/op event-or-op) + event-or-op) + atomic? (:op/atomic? op)] + (if atomic? + (:op/type op) + (let [trigger (:op/trigger op)] + (or (:op/type trigger) + trigger))))))) diff --git a/src/cljc/athens/common_events/bfs.cljc b/src/cljc/athens/common_events/bfs.cljc new file mode 100644 index 0000000000..657b1a3cc8 --- /dev/null +++ b/src/cljc/athens/common_events/bfs.cljc @@ -0,0 +1,202 @@ +(ns athens.common-events.bfs + (:refer-clojure :exclude [descendants]) + (:require + [athens.common-db :as common-db] + [athens.common-events :as common-events] + [athens.common-events.graph.atomic :as atomic] + [athens.common-events.graph.composite :as composite] + [athens.common-events.graph.ops :as graph-ops] + [athens.common-events.resolver.atomic :as atomic-resolver] + [athens.common.utils :as common.utils] + [clojure.string :as string] + [clojure.walk :as walk])) + + +(defn enhance-block + [block previous parent] + (merge block + {:parent parent} + {:previous (select-keys previous [:block/uid])})) + + +(defn parent-lookup + [{:keys [page/title block/uid]}] + (if title + [:page/title title] + [:block/uid uid])) + + +(defn enhance-children + [children parent] + ;; Partition by 2 with nil first/last elements to get each [previous current] pair. + ;; https://stackoverflow.com/a/41925223/2116927 + (->> (concat [nil] children [nil]) + (partition 2 1) + (map (fn [[previous current]] + (when current (enhance-block current previous (parent-lookup parent))))) + (remove nil?) + vec)) + + +(defn- enhance-props + [properties parent] + (->> properties + (map (fn [[k v]] (assoc v :parent (parent-lookup parent) :key k))) + vec)) + + +(defn enhance-internal-representation + "Enhance an internal representations' individual elements with a reference to parent and previous elements. + Parents will be referenced by maps with either :page/title or :block/uid, previous will be :block/uid. + Toplevel pages will be sorted after all toplevel blocks. " + [internal-representation] + (let [{blocks true + pages false} (group-by (comp nil? :page/title) internal-representation) + ;; Enhance toplevel blocks as if they had a nil parent. + ;; The first block will have neither a parent or a previous. + blocks (or blocks []) + pages (or pages []) + blocks' (enhance-children blocks nil)] + (walk/postwalk + (fn [x] + (if (map? x) + (let [{:block/keys [children properties]} x] + (cond-> x + children (assoc :block/children (enhance-children children x)) + properties (assoc :block/properties (enhance-props properties x)))) + x)) + (concat blocks' pages)))) + + +(defn enhanced-internal-representation->atomic-ops + "Takes the enhanced internal representation and creates :page/new or :block/new and :block/save atomic events. + Throws if default-position is nil and position cannot be determined." + [db default-position {:keys [page/title previous parent key] :block/keys [uid string open?] :as eir}] + (if title + [(atomic/make-page-new-op title)] + (let [[parent-type parent-id] parent + previous-uid (:block/uid previous) + prop-or-last (if key + {:page/title key} + :last) + position (cond + ;; There's a block before this one that we can add this one after. + previous-uid {:block/uid previous-uid :relation :after} + ;; There's no previous block, but we can add it to the end of the parent or as prop. + parent-id {parent-type parent-id :relation prop-or-last} + ;; There's a default place where we can drop blocks, use it. + default-position default-position + :else (throw (ex-info "Cannot determine position for enhanced internal representation" eir))) + new-op (graph-ops/build-block-new-op db uid position) + atomic-new-ops (if (graph-ops/atomic-composite? new-op) + (graph-ops/extract-atomics new-op) + [new-op]) + save-op (graph-ops/build-block-save-op db uid string) + atomic-save-ops (if (graph-ops/atomic-composite? save-op) + (graph-ops/extract-atomics save-op) + [save-op])] + (cond-> (into atomic-new-ops atomic-save-ops) + (= open? false) (conj (atomic/make-block-open-op uid false)))))) + + +(defn move-save-ops-to-end + [coll] + (let [{save true + not-save false} (group-by #(= (:op/type %) :block/save) coll)] + (concat [] not-save save))) + + +(defn add-missing-block-uids + [internal-representation] + (walk/postwalk + (fn [x] + (if (and (map? x) + ;; looks like a block + (or (:block/string x) + (:block/properties x) + (:block/children x) + (:block/open? x)) + ;; but doesn't have uid + (not (:block/uid x))) + ;; add it + (assoc x :block/uid (common.utils/gen-block-uid)) + x)) + internal-representation)) + + +(defn internal-representation->atomic-ops + "Convert internal representation to the vector of atomic operations that would create it. + :block/save operations are grouped at the end so that any ref'd entities are already created." + [db internal-representation default-position] + (when-not (or (vector? internal-representation) + (list? internal-representation)) + (throw "Internal representation must be a vector")) + (->> internal-representation + add-missing-block-uids + enhance-internal-representation + (mapcat (partial tree-seq common-db/has-descendants? common-db/descendants)) + (map (partial enhanced-internal-representation->atomic-ops db default-position)) + flatten + distinct + move-save-ops-to-end + vec)) + + +(defn build-paste-op + "For blocks creates `:block/new` and `:block/save` event and for page creates `:page/new` + Arguments: + - `db` db value + - `uid` uid of the block where the internal representation needs to be pasted + - `internal-representation` of the pages/blocks selected" + + ([db internal-representation] + (composite/make-consequence-op {:op/type :block/paste} (internal-representation->atomic-ops db internal-representation nil))) + ([db uid local-str internal-representation] + (let [current-block-parent-uid (:block/uid (common-db/get-parent db [:block/uid uid])) + {:block/keys [order + children + open + string]} (common-db/get-block db [:block/uid uid]) + ;; The parent of block depends on: + ;; - if the current block is open and has chidren : if this is the case then we want the blocks to be pasted + ;; under the current block as its first children + ;; - else the parent is the current block's parent + current-block-parent? (and children + open) + empty-block? (and (string/blank? local-str) + (empty? children)) + new-block-str? (not= local-str string) + ;; If block has a new local-str, write that + block-save-op (when new-block-str? + (atomic/make-block-save-op uid local-str)) + ;; - If the block is empty then we delete the empty block and add new blocks. So in this case + ;; the block order for the new blocks is the same as deleted blocks order. + ;; - If the block is parent then we want the blocks to be pasted as this blocks first children + ;; - If the block is not empty then add the new blocks after the current one. + new-block-order (cond + empty-block? order + current-block-parent? 0 + :else (inc order)) + block-position (cond + empty-block? current-block-parent-uid + current-block-parent? uid + :else current-block-parent-uid) + default-position (common-db/compat-position db {:block/uid block-position + :relation new-block-order}) + ir-ops (internal-representation->atomic-ops db internal-representation default-position) + remove-op (when empty-block? + (graph-ops/build-block-remove-op db uid))] + (composite/make-consequence-op {:op/type :block/paste} + (cond-> ir-ops + new-block-str? (conj block-save-op) + empty-block? (conj remove-op)))))) + + +(defn db-from-repr + [repr] + (let [conn (common-db/create-conn)] + (->> repr + (build-paste-op @conn) + common-events/build-atomic-event + (atomic-resolver/resolve-transact! conn)) + @conn)) diff --git a/src/cljc/athens/common_events/graph/atomic.cljc b/src/cljc/athens/common_events/graph/atomic.cljc new file mode 100644 index 0000000000..17a86c0a8e --- /dev/null +++ b/src/cljc/athens/common_events/graph/atomic.cljc @@ -0,0 +1,141 @@ +(ns athens.common-events.graph.atomic + "⚛️ Atomic Graph Ops. + + 3 groups of Graph Ops: + * block + * page + * shortcut") + + +;; Block Ops + +(defn make-block-new-op + "Creates `:block/new` atomic op. + - `block-uid` - `:block/uid` of new block to be created + - `position` - new blocks position + - for siblings: `:before` or `:after` together with `ref-uid` + - for children: `:first`, `:last` together with `:block/uid` or `:page/title`" + [block-uid position] + {:op/type :block/new + :op/atomic? true + :op/args {:block/uid block-uid + :block/position position}}) + + +(defn make-block-save-op + "Creates `:block/save` atomic op. + - `block-uid` - `:block/uid` of block to be saved + - `string` - new value of `:block/string` to be saved" + [block-uid string] + {:op/type :block/save + :op/atomic? true + :op/args {:block/uid block-uid + :block/string string}}) + + +(defn make-block-open-op + "Creates `:block/open` atomic op. + - `block-uid` - `:block/uid` of block to be opened/closed + - `open?` - should we open or close the block" + [block-uid open?] + {:op/type :block/open + :op/atomic? true + :op/args {:block/uid block-uid + :block/open? open?}}) + + +(defn make-block-remove-op + "Creates `:block/remove` atomic op. + - `block-uid` - `:block/uid` of block to be removed" + [block-uid] + {:op/type :block/remove + :op/atomic? true + :op/args {:block/uid block-uid}}) + + +(defn make-block-move-op + "Creates `:block/move` atomic op. + - `block-uid` - `:block/uid` of block to move + - `position` - new blocks position + - for siblings: `:before` or `:after` together with `ref-uid` + - for children: `:first`, `:last` together with `:block/uid` or `:page/title`" + [block-uid position] + {:op/type :block/move + :op/atomic? true + :op/args {:block/uid block-uid + :block/position position}}) + + +;; Page Ops + +(defn make-page-new-op + "Creates `:page/new` atomic op. + - `title` - Page title to be created " + [title] + {:op/type :page/new + :op/atomic? true + :op/args {:page/title title}}) + + +(defn make-page-rename-op + "Creates `:page/rename` atomic op. + - `title` - Page title before rename, + - `new-title` - Page should have this title after operation" + [title new-title] + {:op/type :page/rename + :op/atomic? true + :op/args {:page/title title + :target {:page/title new-title}}}) + + +(defn make-page-merge-op + "Creates `:page/merge` atomic op. + - `title` - title of page to be merged into `to-title` + - `to-title` - title merge to this page" + [title to-title] + {:op/type :page/merge + :op/atomic? true + :op/args {:page/title title + :target {:page/title to-title}}}) + + +(defn make-page-remove-op + "Creates `:page/remove` atomic op. + - `title` - title of page to be deleted" + [title] + {:op/type :page/remove + :op/atomic? true + :op/args {:page/title title}}) + + +;; Shortcut + +(defn make-shortcut-new-op + "Creates `:shortcut/new` atomic op. + - `title` - title of page to be added to shortcuts" + [title] + {:op/type :shortcut/new + :op/atomic? true + :op/args {:page/title title}}) + + +(defn make-shortcut-remove-op + "Creates `:shortcut/remove` atomic op. + - `title` - title of page to be removed from shortcuts" + [title] + {:op/type :shortcut/remove + :op/atomic? true + :op/args {:page/title title}}) + + +(defn make-shortcut-move-op + "Creates `:shortcut/move` atomic op. + - `title` - title of page to be moved to new position in shortcuts + - `position` - new position for shortcut + - `:page/title` - title of page relative to which source page is to be moved + - `relation` - move the source-name :above or :below title" + [title position] + {:op/type :shortcut/move + :op/atomic? true + :op/args {:page/title title + :shortcut/position position}}) diff --git a/src/cljc/athens/common_events/graph/composite.cljc b/src/cljc/athens/common_events/graph/composite.cljc new file mode 100644 index 0000000000..59cd0b91c4 --- /dev/null +++ b/src/cljc/athens/common_events/graph/composite.cljc @@ -0,0 +1,13 @@ +(ns athens.common-events.graph.composite + "⎄ Composite Graph Ops.") + + +(defn make-consequence-op + "Creates Consequence Operation. + - `trigger` - trigger event, either semantic event or another composite operation. + - `consequences` - seq of consequence operation (atomic or composite operations)" + [trigger consequences] + {:op/type :composite/consequence + :op/atomic? false + :op/trigger trigger + :op/consequences consequences}) diff --git a/src/cljc/athens/common_events/graph/ops.cljc b/src/cljc/athens/common_events/graph/ops.cljc new file mode 100644 index 0000000000..8a325f57d4 --- /dev/null +++ b/src/cljc/athens/common_events/graph/ops.cljc @@ -0,0 +1,380 @@ +(ns athens.common-events.graph.ops + "Building (including contextual resolution) Graph Ops like a boss." + (:require + [athens.common-db :as common-db] + [athens.common-events.graph.atomic :as atomic] + [athens.common-events.graph.composite :as composite] + [athens.common.utils :as common.utils] + [athens.parser.structure :as structure] + [clojure.set :as set])) + + +(defn build-location-op + "Creates composite op with `:page/new` for any missing page in location. + If no page creation is needed, returns original op." + [db location original-op] + (let [parent-page-title (-> location :page/title) + prop-page-title (-> location :relation :page/title) + create-parent? (and parent-page-title (not (common-db/e-by-av db :node/title parent-page-title))) + create-prop? (and prop-page-title (not (common-db/e-by-av db :node/title prop-page-title)))] + (if (or create-parent? create-prop?) + (composite/make-consequence-op {:op/type (:op/type original-op)} + (cond-> [] + create-parent? (conj (atomic/make-page-new-op parent-page-title)) + create-prop? (conj (atomic/make-page-new-op prop-page-title)) + true (conj original-op))) + original-op))) + + +(defn build-page-new-op + "Creates `:page/new` & optionally `:block/new` ops. + If page already exists, just creates atomic `:block/new`. + If page doesn't exist, generates composite of atomic `:page/new` & `:block/new`." + ([_db page-title] + (atomic/make-page-new-op page-title)) + ([db page-title block-uid] + (let [location (common-db/compat-position db {:page/title page-title + :relation :first})] + (->> (atomic/make-block-new-op block-uid location) + (build-location-op db location))))) + + +(defn build-page-rename-op + "Creates `:page/rename` & optionally `:page/new` ops." + [db title-from title-to] + (let [links-old (common-db/find-page-links title-from) + links-new (common-db/find-page-links title-to) + just-new (set/difference links-new links-old) + new-titles (remove #(seq (common-db/get-page-uid db %)) + just-new) + atomic-pages (when-not (empty? new-titles) + (into [] + (for [title new-titles] + (build-page-new-op db title)))) + atomic-rename (atomic/make-page-rename-op title-from title-to) + page-rename-op (if (empty? atomic-pages) + atomic-rename + (composite/make-consequence-op {:op/type :page/rename} + (conj atomic-pages + atomic-rename)))] + page-rename-op)) + + +(defn build-block-new-op + [db block-uid location] + (->> (atomic/make-block-new-op block-uid location) + (build-location-op db location))) + + +(defn build-block-move-op + [db block-uid position] + (->> (atomic/make-block-move-op block-uid position) + (build-location-op db position))) + + +(defn build-block-save-op + "Creates `:block/save` op, taking into account context. + So it might be a composite or atomic event, depending if new page link is present and if pages exist." + [db block-uid string] + (let [old-string (common-db/get-block-string db block-uid) + links-in-old (common-db/find-page-links old-string) + links-in-new (common-db/find-page-links string) + link-diff (set/difference links-in-new links-in-old) + new-page-titles (remove #(seq (common-db/get-page-uid db %)) + link-diff) + atomic-pages (when-not (empty? new-page-titles) + (into [] + (for [title new-page-titles] + (build-page-new-op db title)))) + atomic-save (atomic/make-block-save-op block-uid string) + block-save-op (if (empty? atomic-pages) + atomic-save + (composite/make-consequence-op {:op/type :block/save} + (conj atomic-pages + atomic-save)))] + block-save-op)) + + +(defn build-block-remove-op + "Creates `:block/remove` op." + [db delete-uid] + (when (common-db/e-by-av db :block/uid delete-uid) + (atomic/make-block-remove-op delete-uid))) + + +(defn build-block-merge-with-updated-op + "Creates `:block/remove` & `:block/save` ops." + [db remove-uid merge-uid value update-value] + (let [;; block/remove atomic op + block-remove-op (build-block-remove-op db + remove-uid) + block-save-op (build-block-save-op db merge-uid (str update-value value)) + children-to-move (:block/children (common-db/get-block db [:block/uid remove-uid])) + block-move-ops (into [] + (for [{block-uid :block/uid} children-to-move] + (atomic/make-block-move-op block-uid {:block/uid merge-uid + :relation :last}))) + delete-and-merge-op (composite/make-consequence-op {:op/type :block/remove-merge-update} + (concat (if (seq block-move-ops) + block-move-ops + []) + [block-remove-op + block-save-op]))] + delete-and-merge-op)) + + +(defn build-block-remove-merge-op + "Creates `:block/remove` & `:block/save` ops. + Arguments: + - `db` db value + - `remove-uid` `:block/uid` to delete + - `merge-uid` `:block/uid` to merge (postfix) `value` to + - `value`: string to be postfixed to `:block/string` of `merge-uid`" + [db remove-uid merge-uid value] + (let [;; block/remove atomic op + block-remove-op (build-block-remove-op db + remove-uid) + ;; block/save atomic op + existing-string (common-db/v-by-ea db + [:block/uid merge-uid] + :block/string) + block-save-op (build-block-save-op db merge-uid (str existing-string value)) + children-to-move (:block/children (common-db/get-block db [:block/uid remove-uid])) + block-move-ops (into [] + (for [{block-uid :block/uid} children-to-move] + (atomic/make-block-move-op block-uid {:block/uid merge-uid + :relation :last}))) + delete-and-merge-op (composite/make-consequence-op {:op/type :block/remove-and-merge} + (concat (if (seq block-move-ops) + block-move-ops + []) + [block-remove-op + block-save-op]))] + delete-and-merge-op)) + + +(defn atomic-composite? + [event] + (or + ;; semantic event + (and (= :op/atomic (:event/type event)) + (= :composite/consequence (-> event :event/op :op/type))) + ;; atomic graph op + (and (contains? event :op/atomic?) + (not (:op/atomic? event))))) + + +(defn extract-atomics + [operation] + (into [] + (mapcat (fn [consequence] + (if (:op/atomic? consequence) + [consequence] + ;; this is plain recursion, maybe do loop recur instead + (extract-atomics consequence))) + (or (:op/consequences operation) + (-> operation :event/op :op/consequences) + [(or (:event/op operation) + operation)])))) + + +(defn contains-op? + [op op-type] + (let [atomics (extract-atomics op) + filtered (filter #(= op-type (:op/type %)) atomics)] + (seq filtered))) + + +(defn- split-props-from-blocks + [db uids] + (let [group-f #(if (common-db/property-key db [:block/uid %]) + :props + :blocks) + {:keys [props blocks]} (group-by group-f uids)] + [props blocks])) + + +(defn block-move-chain + [db target-uid source-uids first-rel] + (let [[prop-uids block-uids] (split-props-from-blocks db source-uids)] + (composite/make-consequence-op {:op/type :block/move-chain} + (concat (for [uid prop-uids] + (->> (common-db/drop-prop-position db uid target-uid first-rel) + (atomic/make-block-move-op uid))) + + (when (seq block-uids) + [(atomic/make-block-move-op (first block-uids) + {:block/uid target-uid + :relation first-rel})]) + (for [[one two] (partition 2 1 block-uids)] + (atomic/make-block-move-op two + {:block/uid one + :relation :after})))))) + + +(defn build-block-split-op + "Creates `:block/split` composite op, taking into account context. + If old-block has children, pass them on to new-block. + If old-block is open or closed, pass that state on to new-block. + Ignores both behaviours above if old-block is a property." + [db {:keys [old-block-uid new-block-uid + string index relation + navigation-uid]}] + (let [save-block-op (build-block-save-op db old-block-uid (subs string 0 index)) + new-block-op (atomic/make-block-new-op new-block-uid {:block/uid (or navigation-uid + old-block-uid) + :relation relation}) + new-block-save-op (build-block-save-op db new-block-uid (subs string index)) + {:block/keys [open key]} (common-db/get-block db [:block/uid old-block-uid]) + children (when-not key + (common-db/get-children-uids db [:block/uid old-block-uid])) + children? (seq children) + move-children-op (when children? + (block-move-chain db new-block-uid children :first)) + close-new-block-op (when children? + (atomic/make-block-open-op new-block-uid open)) + split-block-op (composite/make-consequence-op {:op/type :block/split} + (cond-> [save-block-op + new-block-op + new-block-save-op] + children? (conj move-children-op) + children? (conj close-new-block-op)))] + split-block-op)) + + +(defn ops->new-page-titles + "Reduces Graph Ops into a set of titles of newly created pages." + [ops] + (let [page-new-ops (contains-op? ops :page/new) + new-titles (->> page-new-ops + (map :op/args) + (map :page/title) + set)] + new-titles)) + + +(defn ops->new-block-uids + "Reduces Graph Ops into a set of block/uids of newly created blocks." + [ops] + (let [block-new-ops (contains-op? ops :block/new) + new-uids (->> block-new-ops + (map :op/args) + (map :block/new) + set)] + new-uids)) + + +(defn structural-diff + "Calculates removed and added links (block refs & page links)" + [db ops] + (let [block-save-ops (contains-op? ops :block/save) + page-new-ops (contains-op? ops :page/new) + page-rename-ops (contains-op? ops :page/rename) + new-blocks (->> block-save-ops + (map #(select-keys (:op/args %) + [:block/uid :block/string]))) + new-block-uids (->> new-blocks + (map :block/uid) + set) + new-page-titles (->> (concat page-new-ops page-rename-ops) + (map #(or (get-in (:op/args %) [:target :page/title]) + (get-in (:op/args %) [:page/title])))) + old-page-titles (->> page-rename-ops + (map :page/title) + set) + new-block-structures (->> new-blocks + (map :block/string) + (map structure/structure-parser->ast)) + new-title-structures (->> new-page-titles + (map structure/structure-parser->ast)) + old-block-strings (->> new-block-uids + (map #(common-db/get-block-string db %))) + old-title-structures (->> old-page-titles + (map structure/structure-parser->ast)) + old-structures (->> old-block-strings + (map structure/structure-parser->ast)) + links (fn [structs names renames] + (->> structs + (mapcat (partial tree-seq vector? identity)) + (filter #(and (vector? %) + (contains? names (first %)))) + (map #(vector (get renames (first %) (first %)) + (-> % second :string))) + set)) + old-links (links (concat old-structures old-title-structures) + #{:page-link :hashtag :block-ref} + {:hashtag :page-link}) + new-links (links (concat new-block-structures new-title-structures) + #{:page-link :hashtag :block-ref} + {:hashtag :page-link}) + removed-links (set/difference old-links new-links) + added-links (set/difference new-links old-links)] + [removed-links added-links])) + + +(defn throw-unknown-k + [k] + (throw (str "Key " k " must be either string or ::first/::last."))) + + +(defn- new-prop + [db [a v :as uid-or-eid] next-uid k] + (let [uid? (-> uid-or-eid vector? not) + uid (if uid? + uid-or-eid + (common-db/get-block-uid db uid-or-eid)) + title (or (common-db/get-page-title db uid) + (and (= a :node/title) v)) + ;; here too + position (merge {:relation (cond + (= ::first k) :first + (= ::last k) :last + (string? k) {:page/title k} + :else (throw-unknown-k k))} + (if title + {:page/title title} + {:block/uid uid}))] + (build-block-new-op db next-uid position))) + + +(defn build-path + "Return uid at ks path and operations to create path, if needed, as [uid ops]. + uid can be a string or a datascript eid. + ks can be properties names as strings, or ::first/::last for children." + ([db uid ks] + (build-path db uid ks [])) + ([db uid-or-eid [k & ks] ops] + (if-not k + [uid-or-eid ops] + (let [uid? (-> uid-or-eid vector? not) + block (common-db/get-block db (if uid? + [:block/uid uid-or-eid] + uid-or-eid)) + next-block (cond + (= ::first k) (-> block :block/children first) + (= ::last k) (-> block :block/children last) + (string? k) (-> block :block/properties (get k)) + :else (throw-unknown-k k)) + next-uid (or (:block/uid next-block) + (common.utils/gen-block-uid)) + ops' (cond-> ops + (not next-block) (conj (new-prop db uid-or-eid next-uid k)))] + (recur db next-uid ks ops'))))) + + +(defn get-path + "Return uid at ks path." + [db uid-or-eid [k & ks]] + (if-not (and uid-or-eid k) + uid-or-eid + (let [uid? (-> uid-or-eid vector? not) + block (common-db/get-block db (if uid? + [:block/uid uid-or-eid] + uid-or-eid)) + next-block (cond + (= ::first k) (-> block :block/children first) + (= ::last k) (-> block :block/children last) + (string? k) (-> block :block/properties (get k)) + :else (throw-unknown-k k)) + next-uid (:block/uid next-block)] + (recur db next-uid ks)))) diff --git a/src/cljc/athens/common_events/graph/schema.cljc b/src/cljc/athens/common_events/graph/schema.cljc new file mode 100644 index 0000000000..c80d8d0494 --- /dev/null +++ b/src/cljc/athens/common_events/graph/schema.cljc @@ -0,0 +1,224 @@ +(ns athens.common-events.graph.schema + (:require + [malli.core :as m] + [malli.error :as me] + [malli.util :as mu])) + + +(def atomic-op-types + [:enum + :block/new ; ✓ + :block/save ; ✓ + :block/open ; ✓ + :block/remove ; ✓ + :block/move ; ✓ + :page/new ; ✓ + :page/rename ; ✓ + :page/merge ; ✓ + :page/remove ; ✓ + :shortcut/new + :shortcut/remove + :shortcut/move]) + + +;; Identity + +(def block-id [:block/uid string?]) + +(def page-id [:page/title string?]) + + +;; Block + +(def child-position + [:or + [:map + page-id + [:relation [:enum + :first + :last]]] + [:map + block-id + [:relation [:enum + :first + :last]]]]) + + +(def sibling-position + [:map + block-id + [:relation [:enum + :before + :after]]]) + + +(def property-position + [:or + [:map + block-id + [:relation [:map page-id]]] + [:map + page-id + [:relation [:map page-id]]]]) + + +(def block-position + [:or + child-position + sibling-position + property-position]) + + +(def op-block-new + [:map + [:op/args + [:map + block-id + [:block/position block-position]]]]) + + +(def op-block-save + [:map + [:op/args + [:map + block-id + [:block/string string?]]]]) + + +(def op-block-open + [:map + [:op/args + [:map + block-id + [:block/open? boolean?]]]]) + + +(def op-block-remove + [:map + [:op/args + [:map + block-id]]]) + + +(def op-block-move + [:map + [:op/args + [:map + block-id + [:block/position block-position]]]]) + + +;; Page + +(def op-page-new + [:map + [:op/args + [:map + page-id]]]) + + +(def op-page-rename + [:map + [:op/args + [:map + page-id + [:target + [:map + page-id]]]]]) + + +(def op-page-merge + [:map + [:op/args + [:map + page-id + [:target + [:map + page-id]]]]]) + + +(def op-page-remove + [:map + [:op/args + [:map + page-id]]]) + + +;; Shortcut + +(def shortcut-position + [:map + page-id + [:relation [:enum + :before + :after]]]) + + +(def op-shortcut-new + [:map + [:op/args + [:map + page-id]]]) + + +(def op-shortcut-remove + [:map + [:op/args + [:map + page-id]]]) + + +(def op-shortcut-move + [:map + [:op/args + [:map + page-id + [:shortcut/position shortcut-position]]]]) + + +;; Registry + +(def op-type-atomic-common + [:map + [:op/type atomic-op-types] + [:op/atomic? true?]]) + + +(def with-common + (partial mu/merge op-type-atomic-common)) + + +(def atomic-op + [:schema + {:registry + {::atomic-op [:multi {:dispatch :op/type} + [:block/new (with-common op-block-new)] + [:block/save (with-common op-block-save)] + [:block/open (with-common op-block-open)] + [:block/remove (with-common op-block-remove)] + [:block/move (with-common op-block-move)] + [:page/new (with-common op-page-new)] + [:page/rename (with-common op-page-rename)] + [:page/merge (with-common op-page-merge)] + [:page/remove (with-common op-page-remove)] + [:shortcut/new (with-common op-shortcut-new)] + [:shortcut/remove (with-common op-shortcut-remove)] + [:shortcut/move (with-common op-shortcut-move)] + [:composite/consequence [:ref ::composite-op]]] + ::composite-op [:map + [:op/type [:enum :composite/consequence]] + [:op/atomic? false?] + [:op/trigger map?] + [:op/consequences [:sequential [:ref ::atomic-op]]]]}} + ::atomic-op]) + + +(def valid-atomic-op? + (m/validator atomic-op)) + + +(defn explain-atomic-op + [data] + (-> atomic-op + (m/explain data) + (me/humanize))) diff --git a/src/cljc/athens/common_events/resolver/atomic.cljc b/src/cljc/athens/common_events/resolver/atomic.cljc new file mode 100644 index 0000000000..b54272cafc --- /dev/null +++ b/src/cljc/athens/common_events/resolver/atomic.cljc @@ -0,0 +1,354 @@ +(ns athens.common-events.resolver.atomic + (:require + [athens.common-db :as common-db] + [athens.common-events.graph.ops :as graph-ops] + [athens.common-events.resolver.order :as order] + [athens.common-events.resolver.position :as position] + [athens.common.logging :as log] + [athens.common.utils :as utils] + [athens.dates :as dates] + [clojure.pprint :as pp] + [datascript.core :as d])) + + +(defmulti resolve-atomic-op-to-tx + "Resolves ⚛️ Atomic Graph Ops to TXs." + (fn [_db event _event-ref] (:op/type event))) + + +(defmethod resolve-atomic-op-to-tx :block/new + [db {:op/keys [args]} event-ref] + (let [{:block/keys [uid position]} args] + (if (common-db/block-exists? db [:block/uid uid]) + ;; Treast :block/new on an existing block as a :block/move instead. + (resolve-atomic-op-to-tx db {:op/type :block/move + :op/args args} + event-ref) + (let [new-block {:block/uid uid + :block/string "" + :block/open true + :block/create event-ref + :block/edits event-ref} + position-tx (condp = (position/position-type position) + :child (position/add-child db uid position event-ref) + :property (position/add-property db uid position event-ref) + (throw (ex-info "Can't determine position type for :block/new" position))) + tx-data (into [new-block] position-tx)] + tx-data)))) + + +;; This is Atomic Graph Op, there is also composite version of it +(defmethod resolve-atomic-op-to-tx :block/save + [_db {:op/keys [args]} event-ref] + (let [{:block/keys [uid string]} args] + [{:block/uid uid + :block/string string + :block/edits event-ref}])) + + +(defmethod resolve-atomic-op-to-tx :block/open + [db {:op/keys [args]} event-ref] + (log/debug "atomic-resolver :block/open args:" (pr-str args)) + (let [{:block/keys [uid open?]} args + block-eid (common-db/e-by-av db :block/uid uid) + current-open? (when (int? block-eid) + (common-db/v-by-ea db block-eid :block/open))] + (if (= current-open? open?) + (do + (log/info ":block/open already at desired state, :block/open" open?) + []) + [{:block/uid uid + :block/open open? + :block/edits event-ref}]))) + + +(defmethod resolve-atomic-op-to-tx :block/move + [db {:op/keys [args]} event-ref] + (log/debug "atomic-resolver :block/move args:" (pr-str args)) + (let [{:block/keys [uid position]} args + _valid-block-uid (when (common-db/get-page-title db uid) + (throw (ex-info "Block to be moved is a page, cannot move pages." args))) + [_ new-parent-uid] (common-db/position->uid+parent db position) + {old-parent-uid :block/uid} (common-db/get-parent db [:block/uid uid]) + _move-parent-to-child (when ((set (common-db/get-parent-eids db [:block/uid new-parent-uid])) + [:block/uid uid]) + (throw (ex-info "Cannot move parent under own children." args))) + same-parent? (= new-parent-uid old-parent-uid) + old-position-type (-> (common-db/get-position db uid) + position/position-type) + new-position-type (position/position-type position) + updated-block' {:block/uid uid + :block/edits event-ref} + position-tx (condp = [old-position-type new-position-type] + [:child :child] + (if same-parent? + (position/move-child-within + db old-parent-uid uid position event-ref) + (concat (position/remove-child db uid old-parent-uid event-ref) + (position/add-child db uid position event-ref))) + + [:child :property] + (concat + (position/remove-child db uid old-parent-uid event-ref) + (position/add-property db uid position event-ref)) + + [:property :child] + (concat + (position/remove-property db uid old-parent-uid event-ref) + (position/add-child db uid position event-ref)) + + [:property :property] + ;; No need to remove previous name, schema ensures + ;; a block has a single name. + (position/add-property db uid position event-ref) + + ;; Couldn't determine the previous position type. + ;; Maybe it's an orphan block? Anyway, just add it as the new type. + [nil :child] + (position/add-child db uid position event-ref) + + [nil :property] + (position/add-property db uid position event-ref))] + (into [updated-block'] position-tx))) + + +(defmethod resolve-atomic-op-to-tx :block/remove + [db {:op/keys [args]} event-ref] + (let [{:block/keys [uid]} args + block-exists? (common-db/e-by-av db :block/uid uid) + block (when block-exists? + (common-db/get-block db [:block/uid uid])) + parent-eid (when block-exists? + (common-db/get-parent-eid db [:block/uid uid])) + parent-uid (when parent-eid + (common-db/v-by-ea db parent-eid :block/uid)) + ;; Reorder parent children if needed. + children-tx (position/remove-child db uid parent-uid event-ref) + retract-parents-child (when parent-uid + [:db/retract [:block/uid parent-uid] :block/children [:block/uid uid]]) + retract-uid (when block-exists? + (common-db/retract-uid-recursively-tx db event-ref uid)) + txs (when block-exists? + (cond-> [] + parent-uid (conj retract-parents-child) + children-tx (into children-tx) + true (into retract-uid)))] + (log/debug ":block/remove block-uid:" (pr-str uid) + "\nblock:" (with-out-str + (pp/pprint block)) + "\nparent-eid:" (pr-str parent-eid) + "\nparent-uid:" (pr-str parent-uid) + "\nretract-uid:" (pr-str retract-uid) + "\nresolved to txs:" (with-out-str + (pp/pprint txs))) + txs)) + + +(defmethod resolve-atomic-op-to-tx :page/new + [db {:op/keys [args]} event-ref] + (let [{:page/keys [title]} args + page-exists? (common-db/e-by-av db :node/title title) + page-uid (or (-> title dates/title-to-date dates/date-to-day :uid) + (utils/gen-block-uid)) + page {:node/title title + :block/uid page-uid + :block/children [] + :block/create event-ref + :block/edits event-ref} + txs (if page-exists? + [] + [page])] + txs)) + + +(defmethod resolve-atomic-op-to-tx :page/rename + [db {:op/keys [args]} event-ref] + (let [old-name (-> args :page/title) + new-name (-> args :target :page/title) + page-eid (common-db/e-by-av db :node/title old-name) + page-exists? (int? page-eid) + page (when page-exists? + (common-db/get-block db [:node/title old-name])) + linked-refs (common-db/get-linked-refs-by-page-title db old-name) + new-linked-refs (common-db/map-new-refs linked-refs old-name new-name) + updated-page (when page-exists? + {:db/id [:block/uid (:block/uid page)] + :node/title new-name + :block/edits event-ref}) + txs (concat [updated-page] new-linked-refs)] + (if page-exists? + txs + (throw (ex-info "Page you've tried to rename doesn't exist." args))))) + + +(defmethod resolve-atomic-op-to-tx :page/merge + [db {:op/keys [args]} event-ref] + (let [from-name (-> args :page/title) + to-name (-> args :target :page/title) + linked-refs (common-db/get-linked-refs-by-page-title db from-name) + new-linked-refs (common-db/map-new-refs linked-refs from-name to-name) + from-children (common-db/get-children-uids db [:node/title from-name]) + to-children (common-db/get-children-uids db [:node/title to-name]) + to-children' (reduce #(order/insert %1 %2 :last nil) to-children from-children) + reorder-map-fn (fn [n x] + {:block/uid x + :block/order n + :block/_children [:node/title to-name]}) + reorder (order/reorder to-children to-children' reorder-map-fn) + ;; Move paged properties, or delete if key is already there. + from-properties (->> [:node/title from-name] (common-db/get-page db) :block/properties) + to-property-ks (->> [:node/title to-name] (common-db/get-page db) :block/properties keys set) + properties (->> from-properties + (mapcat (fn [[k {:block/keys [uid]}]] + (if (to-property-ks k) + (common-db/retract-uid-recursively-tx db event-ref uid) + (position/add-property db uid {:page/title to-name + :relation {:page/title k}} event-ref))))) + ;; Delete linked props that would end up duplicated on parent. + linked-props (->> [:node/title from-name] + (common-db/get-page db) + :block/_key + (map :db/id) + (map (partial common-db/get-parent db)) + (mapcat (fn [{:block/keys [properties]}] + (if (get properties to-name) + (->> (get properties from-name) + :block/uid + (common-db/retract-uid-recursively-tx db event-ref)) + [])))) + delete-page [:db/retractEntity [:node/title from-name]] + new-datoms (concat [] + new-linked-refs + reorder + properties + linked-props + [delete-page])] + + (log/debug ":page/merge args:" (pr-str args) ", resolved-tx:" (pr-str new-datoms)) + new-datoms)) + + +(defmethod resolve-atomic-op-to-tx :page/remove + [db {:op/keys [args]} event-ref] + (log/debug "atomic-resolver: :page/remove: " (pr-str args)) + (let [{:page/keys [title]} args + page-uid (common-db/get-page-uid db title) + retract-blocks (when page-uid + (common-db/retract-uid-recursively-tx db event-ref page-uid)) + delete-linked-refs (when page-uid + (->> page-uid + (vector :block/uid) + (common-db/get-block db) + vector + (common-db/replace-linked-refs-tx db))) + delete-linked-props (when page-uid + (->> [:node/title title] + (common-db/get-page db) + :block/_key + (map :db/id) + (map (partial common-db/get-block-uid db)) + (mapcat (partial common-db/retract-uid-recursively-tx db event-ref)))) + tx-data (if page-uid + (concat retract-blocks + delete-linked-refs + delete-linked-props) + [])] + tx-data)) + + +(defmethod resolve-atomic-op-to-tx :shortcut/new + [db {:op/keys [args]} _event-ref] + (let [{:page/keys [title]} args + titles (common-db/get-sidebar-titles db) + titles' (order/insert titles title :last nil) + reorder (order/reorder titles titles' order/shortcut-map-fn) + tx-data reorder] + tx-data)) + + +(defmethod resolve-atomic-op-to-tx :shortcut/remove + [db {:op/keys [args]} _event-ref] + (let [{:page/keys [title]} args + titles (common-db/get-sidebar-titles db) + titles' (order/remove titles title) + reorder (order/reorder titles titles' order/shortcut-map-fn) + page-uid (common-db/get-page-uid db title) + remove-shortcut-tx [:db/retract [:block/uid page-uid] :page/sidebar] + tx-data (conj reorder remove-shortcut-tx)] + tx-data)) + + +(defmethod resolve-atomic-op-to-tx :shortcut/move + [db {:op/keys [args]} _event-ref] + (let [{title :page/title + ref-position :shortcut/position} args + {relation :relation + ref-title :page/title} ref-position + titles (common-db/get-sidebar-titles db) + titles' (order/move-within titles title relation ref-title) + reorder (order/reorder titles titles' order/shortcut-map-fn) + tx-data reorder] + tx-data)) + + +(defmethod resolve-atomic-op-to-tx :composite/consequence + [_db composite _event-ref] + (throw (ex-info "Can't resolve Composite Graph Operation, only Atomic Graph Ops are allowed." + (select-keys composite [:op/type :op/trigger])))) + + +(defn resolve-to-tx + "This expects either Semantic Events or Atomic Graph Ops, but not Composite Graph Ops. + Call location should break up composites into atomic ops and call this multiple times, + once per atomic operation." + ([db event] + ;; If there's no event-ref, use just use empty entity. This should only happen in tests though. + (resolve-to-tx db event {})) + ([db {:event/keys [type op] :as event} event-ref] + (if (or (contains? #{:op/atomic} type) + (:op/atomic? event)) + (resolve-atomic-op-to-tx db (if (:op/atomic? event) event op) event-ref) + (throw (ex-info "Can't resolve event, only Atomic Graph Ops are allowed." event))))) + + +(defn resolve-event-tx + [{:event/keys [id create-time presence-id]}] + (let [uid (str id) + ref [:event/uid uid] + tx [(merge {:event/uid uid} + (when create-time {:event/time {:time/ts create-time}}) + (when presence-id {:event/auth {:presence/id presence-id}}))]] + [tx ref])) + + +(defn resolve-transact! + "Iteratively resolve and transact event optionally with middleware (defaults to true). + Returns :tx-data from datascript/transact!." + ([conn event] + (resolve-transact! conn event true)) + ([conn {:event/keys [id] :as event} middleware?] + (log/debug "resolve-transact! event-id:" (pr-str id)) + (let [transact! (if middleware? + common-db/transact-with-middleware! + d/transact!) + ;; Using an atom as an accumulator here isn't very kosher, but it is + ;; the right way of observing the doseq semantics while using transact! + tx-data (atom []) + transact-and-store! (fn [txs] + (->> (transact! conn txs) + :tx-data + (swap! tx-data concat))) + [event-tx event-ref] (resolve-event-tx event)] + (utils/log-time + (str "resolve-transact! event-id: " (pr-str id) " took") + (do + ;; Transact the event entity first. + (transact-and-store! event-tx) + ;; Transact each atomic op, storing the tx-report. + (doseq [atomic (if (graph-ops/atomic-composite? event) + (graph-ops/extract-atomics event) + [event]) + :let [atomic-txs (resolve-to-tx @conn atomic event-ref)]] + (transact-and-store! atomic-txs)) + ;; Return the concatenated tx-reports. + @tx-data))))) diff --git a/src/cljc/athens/common_events/resolver/order.cljc b/src/cljc/athens/common_events/resolver/order.cljc new file mode 100644 index 0000000000..4db932015c --- /dev/null +++ b/src/cljc/athens/common_events/resolver/order.cljc @@ -0,0 +1,86 @@ +(ns athens.common-events.resolver.order + (:refer-clojure :exclude [get remove]) + (:require + [clojure.core :as c])) + + +(defn remove + "Remove x from v." + [v x] + (vec (c/remove #{x} v))) + + +(defn- insert-at + [v x n] + (vec (concat (take n v) [x] (drop n v)))) + + +(defn- index-of + [v x] + (let [n (.indexOf v x)] + (if (= n -1) + nil + n))) + + +(defn get + "Get position defined by relation to target in v." + [v relation target] + (let [n (when (and target (#{:before :after} relation)) + (index-of v target))] + (cond + (= relation :first) (first v) + (= relation :last) (last v) + (and n + (= relation :before) + (> n 0)) (nth v (dec n)) + (and n + (= relation :after) + (< n (dec (count v)))) (nth v (inc n))))) + + +(defn insert + "Insert x in v, in a position defined by relation to target. + See athens.common-events.graph.schema for position values." + [v x relation target] + (let [n (when (and target (#{:before :after} relation)) + (index-of v target))] + (cond + (= relation :first) (into [x] v) + (= relation :last) (into v [x]) + (and n (= relation :before)) (insert-at v x n) + (and n (= relation :after)) (insert-at v x (inc n)) + :else v))) + + +(defn move-within + "Move x within v, to a position defined by relation to target. + See athens.common-events.graph.schema for position values. + Returns modified v." + [v x relation target] + (-> v + (remove x) + (insert x relation target))) + + +(defn block-map-fn + [n x] + {:block/uid x + :block/order n}) + + +(defn shortcut-map-fn + [n x] + {:node/title x + :page/sidebar n}) + + +(defn reorder + "Maps each element in before and after using map-indexed over map-fn. + Returns all elements in after that are not in before. + Use with block-map-fn and shortcut-map-fn to obtain valid datascript + transactions that will reorder those elements using absolute positions." + [before after map-fn] + (let [before' (map-indexed map-fn before) + after' (map-indexed map-fn after)] + (vec (c/remove (set before') after')))) diff --git a/src/cljc/athens/common_events/resolver/position.cljc b/src/cljc/athens/common_events/resolver/position.cljc new file mode 100644 index 0000000000..d0eb5a507b --- /dev/null +++ b/src/cljc/athens/common_events/resolver/position.cljc @@ -0,0 +1,70 @@ +(ns athens.common-events.resolver.position + (:require + [athens.common-db :as common-db] + [athens.common-events.resolver.order :as order])) + + +(defn position-type + [{:keys [relation]}] + (cond (#{:last :first :before :after} relation) :child + (:page/title relation) :property)) + + +(defn add-child + [db uid position event-ref] + (let [{:keys [relation]} position + [ref-uid parent-uid] (common-db/position->uid+parent db position) + children (common-db/get-children-uids db [:block/uid parent-uid]) + children' (order/insert children uid relation ref-uid) + reorder (order/reorder children children' order/block-map-fn) + add-child {:block/uid parent-uid + :block/children [{:block/uid uid}] + :block/edits event-ref}] + (concat [add-child] reorder))) + + +(defn remove-child + [db uid parent-uid event-ref] + (let [parent-children (common-db/get-children-uids db [:block/uid parent-uid]) + parent-children' (order/remove parent-children uid) + reorder (order/reorder parent-children parent-children' order/block-map-fn) + update-parent [[:db/retract [:block/uid parent-uid] :block/children [:block/uid uid]]] + remove-order [[:db/retract [:block/uid uid] :block/order]] + edit [{:block/uid parent-uid + :block/edits event-ref}]] + (concat reorder update-parent remove-order edit))) + + +(defn move-child-within + [db parent-uid uid position event-ref] + (let [{:keys [relation]} position + [ref-uid] (common-db/position->uid+parent db position) + children (common-db/get-children-uids db [:block/uid parent-uid]) + children' (order/move-within children uid relation ref-uid) + reorder (order/reorder children children' order/block-map-fn) + edit [{:block/uid parent-uid + :block/edits event-ref}]] + (concat reorder edit))) + + +(defn add-property + "Add uid as property under position. Transaction will fail if a property for position already exists." + [db uid position event-ref] + (let [title (->> position :relation :page/title) + [_ parent-uid] (common-db/position->uid+parent db position) + add-child {:block/uid uid + :block/key [:node/title title] + :block/property-of {:block/uid parent-uid + :block/edits event-ref}}] + [add-child])) + + +(defn remove-property + [_db uid parent-uid event-ref] + (let [remove-key [[:db/retract [:block/uid uid] :block/key] + [:db/retract [:block/uid uid] :block/property-of]] + update-edits [{:block/uid uid + :block/edits event-ref} + {:block/uid parent-uid + :block/edits event-ref}]] + (concat remove-key update-edits))) diff --git a/src/cljc/athens/common_events/resolver/undo.cljc b/src/cljc/athens/common_events/resolver/undo.cljc new file mode 100644 index 0000000000..7572a69552 --- /dev/null +++ b/src/cljc/athens/common_events/resolver/undo.cljc @@ -0,0 +1,205 @@ +(ns athens.common-events.resolver.undo + (:require + [athens.common-db :as common-db] + [athens.common-events :as common-events] + [athens.common-events.bfs :as bfs] + [athens.common-events.graph.atomic :as atomic-graph-ops] + [athens.common-events.graph.composite :as composite] + [athens.common-events.graph.ops :as graph-ops] + [athens.common.logging :as log] + [clojure.pprint :as pp] + [datascript.core :as d])) + + +(defn undo? + [event] + (-> event :event/op :op/trigger :op/undo)) + + +(defn- restore-shortcut + [evt-db title] + (let [new-op (atomic-graph-ops/make-shortcut-new-op title) + neighbors (common-db/get-shortcut-neighbors evt-db title) + neighbor-position (common-db/flip-neighbor-position neighbors) + move-op (cond neighbors + (atomic-graph-ops/make-shortcut-move-op title neighbor-position))] + (cond-> [new-op] + neighbor-position (conj move-op)))) + + +;; Impl according to https://github.com/athensresearch/athens/blob/main/doc/adr/0021-undo-redo.md#approach +(defmulti resolve-atomic-op-to-undo-ops + #(:op/type %3)) + + +(defmethod resolve-atomic-op-to-undo-ops :block/save + [db evt-db {:op/keys [args]}] + (let [{:block/keys [uid]} args + {:block/keys [string]} (common-db/get-block evt-db [:block/uid uid])] + ;; if block wasn't present in `event-db` + (if string + [(graph-ops/build-block-save-op db uid string)] + []))) + + +(defmethod resolve-atomic-op-to-undo-ops :block/remove + [_db evt-db {:op/keys [args]}] + (let [{:block/keys [uid]} args + {backrefs :block/_refs} (common-db/get-block evt-db [:block/uid uid]) + position (common-db/get-position evt-db uid) + repr [(common-db/get-internal-representation evt-db [:block/uid uid])] + repr-ops (bfs/internal-representation->atomic-ops evt-db repr position) + save-ops (->> backrefs + (map :db/id) + (map (partial common-db/get-block evt-db)) + (map (fn [{:block/keys [uid string]}] + (atomic-graph-ops/make-block-save-op uid string))))] + (vec (concat repr-ops save-ops)))) + + +(defmethod resolve-atomic-op-to-undo-ops :block/move + [_ evt-db {:op/keys [args]}] + (let [{:block/keys [uid]} args + position (common-db/get-position evt-db uid)] + [(atomic-graph-ops/make-block-move-op uid position)])) + + +(defmethod resolve-atomic-op-to-undo-ops :block/open + [_db evt-db {:op/keys [args]}] + (let [{:block/keys [uid]} args + {:block/keys [open]} (common-db/get-block evt-db [:block/uid uid])] + [(atomic-graph-ops/make-block-open-op uid open)])) + + +(defmethod resolve-atomic-op-to-undo-ops :block/new + [_db _evt-db {:op/keys [args]}] + (let [{:block/keys [uid]} args] + [(atomic-graph-ops/make-block-remove-op uid)])) + + +(defmethod resolve-atomic-op-to-undo-ops :page/remove + [_db evt-db {:op/keys [args]}] + (let [{:page/keys [title]} args + {sidebar :page/sidebar + page-refs :block/_refs} (common-db/get-page-document evt-db [:node/title title]) + page-repr [(common-db/get-internal-representation evt-db (:db/id (d/entity evt-db [:node/title title])))] + repr-ops (bfs/internal-representation->atomic-ops evt-db page-repr nil) + save-ops (->> page-refs + (map :db/id) + (map (partial common-db/get-block evt-db)) + (map (fn [{:block/keys [uid string]}] + (atomic-graph-ops/make-block-save-op uid string)))) + shortcut-ops (when sidebar + (restore-shortcut evt-db (:page/title args)))] + (vec (concat repr-ops save-ops shortcut-ops)))) + + +(defmethod resolve-atomic-op-to-undo-ops :page/rename + [db _event-db {:op/keys [args]}] + (let [from-title (:page/title args) + to-title (get-in args [:target :page/title]) + reverse-op (graph-ops/build-page-rename-op db to-title from-title)] + [reverse-op])) + + +(defmethod resolve-atomic-op-to-undo-ops :page/merge + [_db evt-db {:op/keys [args]}] + (let [{from :page/title} args + {children :block/children + sidebar :page/sidebar + backrefs :block/_refs} (common-db/get-page evt-db [:node/title from]) + page-new (atomic-graph-ops/make-page-new-op from) + save-ops (->> backrefs + (map :db/id) + (map (partial common-db/get-block evt-db)) + (map (fn [{:block/keys [uid string]}] + (atomic-graph-ops/make-block-save-op uid string)))) + move-ops (->> children + (sort-by :block/order) + (map :block/uid) + (map #(atomic-graph-ops/make-block-move-op % {:page/title from + :relation :last}))) + shortcut-ops (when sidebar + (restore-shortcut evt-db (:page/title args)))] + (vec (concat [page-new] move-ops save-ops shortcut-ops)))) + + +(defmethod resolve-atomic-op-to-undo-ops :page/new + [_db _evt-db {:op/keys [args]}] + (let [{:page/keys [title]} args] + [(atomic-graph-ops/make-page-remove-op title)])) + + +(defmethod resolve-atomic-op-to-undo-ops :shortcut/new + [_db _evt-db {:op/keys [args]}] + (let [{:page/keys [title]} args] + [(atomic-graph-ops/make-shortcut-remove-op title)])) + + +(defmethod resolve-atomic-op-to-undo-ops :shortcut/remove + [_db evt-db {:op/keys [args]}] + (restore-shortcut evt-db (:page/title args))) + + +(defmethod resolve-atomic-op-to-undo-ops :shortcut/move + [_db evt-db {:op/keys [args]}] + (let [{moved-title :page/title} args + neighbors (common-db/get-shortcut-neighbors evt-db moved-title) + neighbor-position (common-db/flip-neighbor-position neighbors) + move-op (atomic-graph-ops/make-shortcut-move-op moved-title neighbor-position)] + [move-op])) + + +(defn reorder-ops + "Reverse the order of operations in coll. + Then, for all contiguous operations involving positions, restore their original relative order. + e.g.: a b m1 m2 m3 c -> c m1 m2 m3 b a + Position operations keep their relative order to ensure that chains of relative moves still work. + This is in part a quirk of the `forward bias` in our location resolution that favors :first + and :after positions, and is not meant to be a universal solution. + There are valid combination of relative moves that will still not be correctly undone." + [coll] + (let [position-op? #(-> % :op/type #{:block/move :block/new + ;; Neither :block/save or :block/remove use positions, + ;; but :block/remove is undo to :block/new followed by + ;; :block/save, and thus these two types end up being + ;; part of sequential move operations. + :block/save :block/remove}) + restore-move-order #(if (position-op? (first %)) + (reverse %) + %)] + (->> coll + reverse + (partition-by position-op?) + (map restore-move-order) + (apply concat) + (into [])))) + + +(defmethod resolve-atomic-op-to-undo-ops :composite/consequence + [db evt-db {:op/keys [_consequences] :as op}] + (let [atomic-ops (graph-ops/extract-atomics op) + undo-ops (->> atomic-ops + reorder-ops + (mapcat (partial resolve-atomic-op-to-undo-ops db evt-db)) + (into []))] + undo-ops)) + + +;; TODO: should there be a distinction between undo and redo? +(defn build-undo-event + [db evt-db {:event/keys [id type op] :as event}] + (log/debug "build-undo-event\n" + (with-out-str + (pp/pprint event))) + (if-not (contains? #{:op/atomic} type) + (throw (ex-info "Cannot undo non-atomic event" event)) + (let [undo-ops (->> op + (resolve-atomic-op-to-undo-ops db evt-db) + (composite/make-consequence-op {:op/undo id}) + common-events/build-atomic-event)] + (log/debug "undo-ops:\n" + (with-out-str + (pp/pprint undo-ops))) + undo-ops))) + diff --git a/src/cljc/athens/common_events/schema.cljc b/src/cljc/athens/common_events/schema.cljc new file mode 100644 index 0000000000..e3282ef7ef --- /dev/null +++ b/src/cljc/athens/common_events/schema.cljc @@ -0,0 +1,220 @@ +(ns athens.common-events.schema + (:require + [athens.common-events.graph.schema :as graph-schema] + [malli.core :as m] + [malli.error :as me] + [malli.util :as mu])) + + +(def event-type-presence-client + [:enum + :presence/hello + :presence/update]) + + +(def event-type-presence-server + [:enum + :presence/session-id + :presence/online + :presence/all-online + :presence/offline + :presence/update]) + + +(def event-type-graph-server + [:enum + :datascript/db-dump]) + + +(def event-type-atomic + [:enum + :op/atomic]) + + +(def event-common + [:map + [:event/id uuid?] + [:event/type [:or + event-type-presence-client + event-type-atomic]] + [:event/create-time {:optional true} int?] + [:event/presence-id {:optional true} string?]]) + + +(def event-common-server + [:map + [:event/id uuid?] + [:event/type [:or + event-type-graph-server + event-type-presence-server + event-type-atomic]]]) + + +(defn dispatch + ([type args] + (dispatch type args false)) + ([type args server?] + [type (mu/merge + (if server? + event-common-server + event-common) + args)])) + + +(def session-id + [:session-id string?]) + + +;; Having all keys optional enables us to have +;; anonymous or third party clients. +;; These are the keys our client uses, if present. +(def session-intro + [:map + [:username {:optional true} string?] + [:color {:optional true} string?] + [:block-uid {:optional true} string?]]) + + +(def session + (mu/merge + session-intro + [:map + session-id])) + + +(def presence-hello + [:map + [:event/args + [:map + [:session-intro session-intro] + [:password {:optional true} string?]]]]) + + +(def presence-session-id + [:map + [:event/args + [:map + session-id]]]) + + +(def presence-update + [:map + [:event/args + session]]) + + +(def presence-online + [:map + [:event/args + session]]) + + +(def presence-all-online + [:map + [:event/args + [:vector + session]]]) + + +(def presence-offline + presence-online) + + +(def graph-ops-atomic + [:map + [:event/op graph-schema/atomic-op]]) + + +(def event + [:multi {:dispatch :event/type} + (dispatch :presence/hello presence-hello) + (dispatch :presence/update presence-update) + (dispatch :op/atomic graph-ops-atomic)]) + + +(def valid-event? + (m/validator event)) + + +(defn explain-event + [data] + (-> event + (m/explain data) + (me/humanize))) + + +(def event-status + [:enum :rejected :accepted]) + + +(def event-response-common + [:map + [:event/id uuid?] + [:event/status event-status]]) + + +(def rejection-reason + [:enum :introduce-yourself :stale-client]) + + +(def response-rejected + [:map + [:reject/reason [:or string? rejection-reason]] + [:reject/data {:optional true} map?]]) + + +(def event-response + [:multi {:dispatch :event/status} + [:accepted event-response-common] + [:rejected (mu/merge event-response-common + response-rejected)]]) + + +(def valid-event-response? + (m/validator event-response)) + + +(defn explain-event-response + [data] + (-> event-response + (m/explain data) + (me/humanize))) + + +(def datom + [:vector any?]) + + +(def db-dump + [:map + [:event/args + [:map + [:datoms + ;; NOTE: this is because after serialization & deserialization data is represented differently + [:sequential datom]]]]]) + + +(def server-event + [:multi {:dispatch :event/type} + ;; server specific graph events + (dispatch :datascript/db-dump db-dump true) + ;; server specific presence events + (dispatch :presence/session-id presence-session-id true) + (dispatch :presence/online presence-online true) + (dispatch :presence/all-online presence-all-online true) + (dispatch :presence/offline presence-offline true) + (dispatch :presence/update presence-update true) + + ;; ⚛️ Atomic Graph Ops + (dispatch :op/atomic graph-ops-atomic true)]) + + +(def valid-server-event? + (m/validator server-event)) + + +(defn explain-server-event + [data] + (-> server-event + (m/explain data) + (me/humanize))) diff --git a/src/cljc/athens/dates.cljc b/src/cljc/athens/dates.cljc new file mode 100644 index 0000000000..2b7c4001df --- /dev/null +++ b/src/cljc/athens/dates.cljc @@ -0,0 +1,69 @@ +(ns athens.dates + (:require + [cljc.java-time.local-date :as local-date] + [clojure.string :as string] + [tick.core :as t] + [tick.locale-en-us])) + + +(def date-col-format (t/formatter "LLLL dd, yyyy h':'mma")) +(def US-format (t/formatter "MM-dd-yyyy")) +(def title-format (t/formatter "LLLL dd, yyyy")) + + +(defn get-day + "Returns today's date or a date OFFSET days before today" + ([] (get-day 0)) + ([offset] + (get-day (t/date) offset)) + ([date offset] + (let [day (t/<< + (-> date (t/at "00:00")) + (t/new-duration offset :days))] + {:uid (t/format US-format day) + :title (t/format title-format day) + :inst (t/inst day)}))) + + +(defn date-string + [ts] + (if (not ts) + [:span "(unknown date)"] + (as-> + (t/instant ts) x + (t/date-time x) + (t/format date-col-format x) + (string/replace x #"AM" "am") + (string/replace x #"PM" "pm")))) + + +(defn uid-to-date + [uid] + (try + (let [[m d y] (string/split uid #"-") + rejoin (string/join "-" [y m d])] + (t/date rejoin)) + (catch #?(:cljs :default + :clj Exception) _ nil))) + + +(defn title-to-date + [title] + (try + (local-date/parse title title-format) + (catch #?(:cljs :default + :clj Exception) _ nil))) + + +(defn date-to-day + [date] + (try + (get-day date 0) + (catch #?(:cljs :default + :clj Exception) _ nil))) + + +(defn is-daily-note + [uid] + (boolean (uid-to-date uid))) + diff --git a/src/cljc/athens/macros.cljc b/src/cljc/athens/macros.cljc new file mode 100644 index 0000000000..119540e0b4 --- /dev/null +++ b/src/cljc/athens/macros.cljc @@ -0,0 +1,64 @@ +(ns athens.macros + "Macro helper fns." + (:require + [clojure.core.specs.alpha :as specs] + [clojure.spec.alpha :as s])) + + +;; Fix the defn spec so that s/unform creates valid arg lists. +;; https://blog.klipse.tech/clojure/2019/03/08/spec-custom-defn.html#args-of-defn-macro + +(defn arg-list-unformer + [a] + (vec + (if (and (coll? (last a)) (= '& (first (last a)))) + (concat (drop-last a) (last a)) + a))) + + +(s/def ::specs/arg-list + (s/and + vector? + (s/conformer identity arg-list-unformer) + (s/cat :args (s/* ::specs/binding-form) + :varargs (s/? (s/cat :amp #{'&} :form ::specs/binding-form))))) + + +(defn defn-args-xform + "Transform defn args using xform. + Args will be conformed using spec prior to xform, and then + unformed back to be used in macro code. + You can use a spy function, like athens.common.utils/spy, to pretty print + the conformed args for inspection." + [xform args] + (let [conf (s/conform ::specs/defn-args args) + name (:name conf) + conf' (xform conf name)] + (s/unform ::specs/defn-args conf'))) + + +(defn update-bodies + "Updates the body of conformed defn args. Supports multiple arities." + [{[arity] :bs :as conf} body-update-fn] + (case arity + :arity-1 (update-in conf [:bs 1 :body] body-update-fn) + :arity-n (update-in conf [:bs 1 :bodies] + (fn [bodies] + (map (fn [body] (update body :body body-update-fn)) bodies))))) + + +(defn add-prepost + "Add a prepost form to a conformed defn body." + [form [k v :as body]] + (case k + :body [:prepost+body {:prepost form + :body v}] + :prepost+body (throw (ex-info "add-prepost does not yet support composing prepost" body)))) + + +(defn update-body-body + "Updates the body form inside a conformed defn body." + [xform [k _ :as body]] + (case k + :body (update body 1 xform) + :prepost+body (update-in body [1 :body] xform))) diff --git a/src/cljc/athens/parser.cljc b/src/cljc/athens/parser.cljc new file mode 100644 index 0000000000..cba08ad6e0 --- /dev/null +++ b/src/cljc/athens/parser.cljc @@ -0,0 +1,16 @@ +(ns athens.parser + (:require + [athens.parser.impl :as impl] + [athens.parser.structure :as structure])) + + +(defn parse-to-ast + "Converts a string of block syntax to an abstract syntax tree for Athens Flavoured Markdown." + [string] + (impl/staged-parser->ast string)) + + +(defn structure-parse-to-ast + "Converts a string to structure elements in it, AST of course." + [string] + (structure/structure-parser->ast string)) diff --git a/src/cljc/athens/parser/impl.cljc b/src/cljc/athens/parser/impl.cljc new file mode 100644 index 0000000000..9e241f4ee5 --- /dev/null +++ b/src/cljc/athens/parser/impl.cljc @@ -0,0 +1,504 @@ +(ns athens.parser.impl + "3 pass parser implementation. + + 1st pass: block structure + 2nd pass: inline structure + 3rd pass: raw urls" + (:require + [athens.common.logging :as log] + #?(:cljs [athens.config :as config]) + [clojure.string :as string] + [clojure.walk :as walk] + #?(:cljs [instaparse.core :as insta :refer-macros [defparser]] + :clj [instaparse.core :as insta :refer [defparser]])) + #?(:clj + (:import + (java.time + LocalDateTime)))) + + +(defparser block-parser + " +block = (thematic-break / + heading / + indented-code-block / + fenced-code-block / + block-quote / + paragraph-text / + newline)* +thematic-break = #'[_-]{3}' +heading = #'[#]+' #'.+' * +indented-code-block = (<' '> code-text)+ +fenced-code-block = <'```'> #'(?s)(.+(?=(```|\\n))|\\n)+' <'```'> +block-quote = (<#' {0,3}' #'> ?'> #'.*' ?)+ ? + +paragraph-text = (<#' {0,3}'> #'.+' ?)+ ? +code-text = #'.+' ? +space = ' ' +blankline = #'\\n\\n' +newline = #'\\n'") + + +(defparser inline-parser + "(* inline spans parser, processes `:paragraph-text` from phase 1 *) + +(* root of parse tree *) +inline = recur + +(* `recur` so we can recursively parse inline formatting w/o bringing `:inline`. *) + = (backslash-escapes / + text-run / + code-span / + strong-emphasis / + emphasis / + highlight / + strikethrough / + block-ref / + page-link / + link / + image / + autolink / + hashtag-braced / + hashtag-naked / + component / + latex / + special-char / + newline)* + + = #'\\\\\\p{Punct}' + +(* all inline-spans have `x` character (or pair) that is a boundary for this span *) +(* opening `x` has: *) +(* - `(? + #'(?s)([^`]|\\B`(?=\\s))+' + <#'`(?!\\w)'> + +strong-emphasis = <#'\\*\\*(?!\\s)'> + recur + <#'\\*\\*(?!\\w)'> + +emphasis = <#'\\*(?!\\s)'> + recur + <#'\\*(?!\\w)'> + +highlight = <#'\\^\\^(?!\\s)'> + recur + <#'\\^\\^(?!\\w)'> + +strikethrough = <#'~~(?!\\s)'> + recur + <#'~~(?!\\w)'> + +link = md-link +image = <'!'> md-link + + = <#'\\[(?!\\s)'> + link-text + <#'\\]\\((?!\\s)'> + link-target + (<' '> link-title)? + <#'\\)(?!\\w)'> + +link-text = #'([^\\]]|\\\\\\])*?(?=\\]\\()' +link-target = ( #'[^\\s\\(\\)]+' | '(' #'[^\\s\\)]*' ')' | '\\\\' ( '(' | ')' ) | #'\\s(?![\"\\'\\(])' )+ +link-title = <'\"'> #'[^\"]+' <'\"'> + | <'\\''> #'[^\\']+' <'\\''> + | <'('> #'[^\\)]+' <')'> + +autolink = <#'<(?!\\s)'> + #'[^>\\s]+' + <#'>(?!\\w)'> + +block-ref = title? + <#'\\(\\((?!\\s)'> + #'.+?(?=\\)\\))' + <#'\\)\\)'> + +page-link = title? + <#'\\[\\[(?!\\s)'> + (#'[^\\[\\]\\#\\n]+' | page-link | hashtag-naked | hashtag-braced)+ + <#'\\]\\](?!\\w)'> + +hashtag-naked = <#'\\#(?!\\s)'> + #'[^\\ \\+\\!\\@\\#\\$\\%\\^\\&\\*\\(\\)\\?\\\"\\;\\:\\]\\[]+(?!\\w)' + +hashtag-braced = <#'\\#\\[\\[(?!\\s)'> + (#'[^\\[\\]\\#\\n]+' | page-link | hashtag-naked | hashtag-braced)+ + <#'\\]\\](?!\\w)'> + +component = <#'\\{\\{(?!\\s)'> + (page-link / block-ref / #'.+(?=\\}\\})') + <#'\\}\\}(?!\\w)'> + +title = <#'\\[(?!\\s)'> + #'([^\\]]|\\\\\\])+(?=\\])' + <#'\\](?!\\s)'> + +latex = <#'\\$\\$(?!\\s)'> + #'(?s).+?(?=\\$\\$)' + <#'\\$\\$(?!\\w)'> + +(* characters with meaning (special chars) *) +(* every delimiter used as inline span boundary has to be added below *) + +(* anything but special chars *) +text-run = #'(?:[^\\*`\\^~\\[!<\\(\\#\\$\\{\\r\\n]|\\b[`!\\#\\$\\{])+' + +(* any special char *) + = #'[\\*`^~\\[!<\\(\\#\\$\\{]' + +newline = #'\\n' +") + + +(defn- transform-heading + [atx p-text] + [:heading {:n (count atx) + :from (str atx " " p-text)} + [:paragraph-text (string/trim p-text)]]) + + +(defn- transform-indented-code-block + [& code-texts] + (let [seconds (map second code-texts)] + [:indented-code-block + {:from (->> seconds + (map #(str " " %)) + (string/join "\n"))} + [:code-text (string/join "\n" seconds)]])) + + +(defn- transform-fenced-code-block + [code-text] + (let [lang (-> code-text + (string/split #"\n") + first) + text (string/join + "\n" + (-> code-text + (string/split #"\n") + rest))] + (if (string/blank? text) + [:fenced-code-block {:lang ""} lang] + [:fenced-code-block {:lang lang} + [:code-text text]]))) + + +(defn- transform-paragraph-text + [& strings] + [:paragraph-text (->> strings + (map string/triml) + (string/join "\n"))]) + + +(declare block-parser->ast) + + +(defn- transform-block-quote + [& strings] + (into [:block-quote] + (rest (block-parser->ast (string/join "\n" strings))))) + + +(def stage-1-transformations + {:heading transform-heading + :indented-code-block transform-indented-code-block + :fenced-code-block transform-fenced-code-block + :paragraph-text transform-paragraph-text + :block-quote transform-block-quote}) + + +(defn block-parser->ast + "Stage 1. Parse `in` string with `block-parser`." + [in] + (->> in + (insta/parse block-parser) + (insta/transform stage-1-transformations))) + + +(defn- string-representation + [& contents] + (->> contents + (map (fn [content] + (if (string? content) + content + (-> content + second + :from)))) + string/join)) + + +(defn- block-ref-transform + [& contents] + (let [title? (= :title (ffirst contents)) + title (when title? (-> contents first second)) + contents (if title? + (drop 1 contents) + contents)] + (apply conj [:block-ref (cond-> {:from (str (when title? + (str "[" title "]")) + "((" (string/join contents) "))")} + title? (assoc :title title))] + contents))) + + +(defn- page-link-transform + [& contents] + (let [title? (= :title (ffirst contents)) + title (when title? (-> contents first second)) + contents (if title? + (drop 1 contents) + contents)] + (apply conj [:page-link (cond-> {:from (str (when title? + (str "[" title "]")) + "[[" (apply string-representation contents) "]]")} + title? (assoc :title title))] + contents))) + + +(defn- hashtag-braced-transform + [& contents] + (apply conj [:hashtag {:from (str "#[[" (apply string-representation contents) "]]")}] + contents)) + + +(defn- hashtag-naked-transform + [& contents] + (apply conj [:hashtag {:from (str "#" (string/join contents))}] + contents)) + + +(defn- walker-hlb-candidate + [candidate?] + (fn [x] + (if (and (vector? x) + (= 2 (count x))) + (let [[t s] x] + (cond + (and (= :text-run t) (string/ends-with? s " ")) + (do + (reset! candidate? true) + x) + + (and (= :newline t) @candidate?) + (do + (reset! candidate? false) + [:hard-line-break]) + + (and (= :newline t) (not @candidate?)) + (do + (reset! candidate? true) + x) + + :else + (do + (reset! candidate? false) + x))) + x))) + + +(defn- inline-transform + [& contents] + (let [hlb-candidate? (atom false) + result (apply conj [:paragraph] + (->> contents + (map #(walk/postwalk (walker-hlb-candidate hlb-candidate?) %)) + (reduce (fn [acc el] + (let [last-el (last acc) + new-val (cond + (string? el) + el + + (and (vector? el) + (< 1 (count el)) + (= :text-run (first el))) + (string/join (rest el)) + + :else + el)] + (if (and (string? new-val) + (or (nil? last-el) + (= :text-run (first last-el)))) + (conj (if (nil? last-el) + acc + (pop acc)) + [:text-run (string/join [(or (second last-el) "") new-val])]) + (conj acc el)))) + [])))] + result)) + + +(defn- link-parts->map + [link-parts] + (let [safe-parts (->> link-parts + (remove #(= :link-target (first %))) + (into {})) + link-target-rest (->> link-parts + (filter #(= :link-target (first %))) + first + rest) + link-target (if (= 1 (count link-target-rest)) + (first link-target-rest) + (string/join link-target-rest))] + (assoc safe-parts :link-target link-target))) + + +(defn- link-transform + [& link-parts] + (let [{:keys [link-text link-target link-title]} (link-parts->map link-parts)] + [:link (cond-> {:text link-text + :target link-target} + link-title (assoc :title link-title))])) + + +(defn- image-transform + [& link-parts] + (let [{:keys [link-text link-target link-title]} (link-parts->map link-parts)] + [:url-image (cond-> {:alt link-text + :src link-target} + link-title (assoc :title link-title))])) + + +(defn- autolink-transform + [url] + [:autolink {:text url + :target (if (string/includes? url "@") + (str "mailto:" url) + url)}]) + + +(defn- component-transform + [contents] + [:component (if (vector? contents) + (let [[tag text] contents] + (cond + (= :page-link tag) + (str "[[" (nth contents 2) "]]") + + (= :block-ref tag) + (str "((" (nth contents 2) "))") + + :else + text)) + contents) + contents]) + + +(def stage-2-internal-transformations + {:block-ref block-ref-transform + :page-link page-link-transform + :hashtag-braced hashtag-braced-transform + :hashtag-naked hashtag-naked-transform + :inline inline-transform + :link link-transform + :image image-transform + :autolink autolink-transform + :component component-transform}) + + +(defn inline-parser->ast + [in] + (let [parse-result (insta/parse inline-parser in)] + (if-not (insta/failure? parse-result) + (insta/transform stage-2-internal-transformations parse-result) + ^{:parse-error (insta/get-failure parse-result)} + [:paragraph + [:text-run in]]))) + + +(def stage-2-transformations + {:paragraph-text inline-parser->ast}) + + +(def uri-pattern + #"(?i)(https?|ftp)://[^\s/\$\.\?\#].[^\s]*") + + +(defn- append-link + ([acc before uri] (append-link acc before uri nil)) + ([acc before uri after] + (cond-> acc + (and (seq before) + (pos? (count before))) + (conj before) + + :true + (conj [:link {:text uri + :target uri}]) + + (and (seq after) + (pos? (count after))) + (conj after)))) + + +(defn- text-run-transform + [text-run] + (let [matches (re-seq uri-pattern text-run)] + (if (seq matches) + (into [:text-run] + (loop [t text-run + m matches + acc []] + (let [uri (ffirst m) + uri-index (string/index-of t uri) + before (subs t 0 uri-index) + after (subs t + (+ uri-index (count uri)) + (count t))] + (if (seq (rest m)) + (recur after + (rest m) + (append-link acc before uri)) + (append-link acc before uri after))))) + text-run))) + + +(def stage-3-transformations + {:text-run text-run-transform + ;; TODO move below transformations to rendering when we're sure to use this parser + :strong-emphasis (fn [& contents] + (apply conj [:bold] contents)) + :emphasis (fn [& contents] + (apply conj [:italic] contents)) + :hard-line-break (fn [] + [:br]) + :block-quote (fn [& contents] + (apply conj [:blockquote] contents)) + :code-span (fn [text] + [:inline-pre-formatted text])}) + + +(defn- timed + [name fn-to-time] + (fn [arg] + #?(:cljs + (let [t-0 (js/performance.now) + result (fn-to-time arg) + t-1 (js/performance.now)] + (when config/measure-parser? + (log/info name ", time:" (- t-1 t-0))) + result) + :clj + (let [t-0 (.getNano (LocalDateTime/now)) + result (fn-to-time arg) + t-1 (.getNano (LocalDateTime/now))] + (when false + (log/info name ", time:" (/ (- t-1 t-0) + 1000000) "milliseconds")) + result)))) + + +(defn staged-parser->ast + [in] + (->> in + ((timed :block #(insta/parse block-parser %))) + ((timed :stage-1 #(insta/transform stage-1-transformations %))) + ((timed :stage-2 #(insta/transform stage-2-transformations %))) + ((timed :stage-3 #(insta/transform stage-3-transformations %))))) diff --git a/src/cljc/athens/parser/structure.cljc b/src/cljc/athens/parser/structure.cljc new file mode 100644 index 0000000000..efd7c98042 --- /dev/null +++ b/src/cljc/athens/parser/structure.cljc @@ -0,0 +1,150 @@ +(ns athens.parser.structure + "Graph Structure Parser. + + Discovers following structure references: + * [x] `[[...]]` page links + * [x] `#...` naked hashtags + * [x] `#[[...]]` braced hashtags + * [x] `((...))` block refs + * [x] Ignore structure when in code blocks + * [x] `{{type: ((...))}}` typed block refs + * [ ] `{{type: ((...)), atr1: val1}}` typed block refs with optional attributes" + (:require + [clojure.string :as string] + #?(:cljs [instaparse.core :as insta :refer-macros [defparser]] + :clj [instaparse.core :as insta :refer [defparser]]))) + + +(defparser structure-parser + "text-or = ( code-block / + code-span / + page-link / + braced-hashtag / + naked-hashtag / + block-ref / + typed-block-ref / + text-run )* + (* below we need to list all significant groups in lookbehind + $ *) + text-run = #'.+?(?=(\\[\\[|\\]\\]|#|\\(\\(|\\)\\)|$|\\`))\\n?' + code-span = < '`' > text-or < '`' > + code-block = < '```' > + ( text-or | '\\n' )+ + < '```' > + page-link = < double-square-open > + ( text-till-double-square-close / + page-link / + braced-hashtag / + naked-hashtag )+ + < double-square-close > + naked-hashtag = < hash > #'[^\\ \\+\\!\\@\\#\\$\\%\\^\\&\\*\\(\\)\\?\\\"\\;\\:\\]\\[]+' + braced-hashtag = < hash double-square-open > + ( text-till-double-square-close / + page-link / + braced-hashtag / + naked-hashtag)+ + < double-square-close > + block-ref = < double-paren-open > + text-till-double-paren-close + < double-paren-close > + < text-till-double-square-close > = #'[^\\n$\\[\\]\\#]+?(?=(\\]\\]|\\[\\[|#))' + < text-till-double-paren-close > = #'[^\\s]+?(?=(\\)\\)))' + typed-block-ref = < double-curly-open > + ref-type < #':\\s*' > block-ref + < double-curly-close > + ref-type = #'[^:]+' + hash = '#' + double-square-open = '[[' + double-square-close = ']]' + double-paren-open = '((' + double-paren-close = '))' + double-curly-open = '{{' + double-curly-close = '}}'") + + +(defn- string-representation + [& contents] + (->> contents + (map (fn [content] + (if (string? content) + content + (-> content + second + :from)))) + string/join)) + + +(defn text-or-transform + [& contents] + (apply conj [:paragraph] contents)) + + +(defn code-span-transform + [& _contents] + ;; simply ignore code contents for structure + [:code-span]) + + +(defn code-block-transform + [& _contents] + ;; simply ignore code contents for structure + [:code-block]) + + +(defn page-link-transform + [& contents] + (let [string-repr (apply string-representation contents)] + (apply conj [:page-link {:from (str "[[" string-repr "]]") + :string string-repr}] + contents))) + + +(defn naked-hashtag-transform + [& contents] + (let [string-repr (apply string-representation contents)] + (apply conj [:hashtag {:from (str "#" string-repr) + :string string-repr}] + contents))) + + +(defn braced-hashtag-transform + [& contents] + (let [string-repr (apply string-representation contents)] + (apply conj [:hashtag {:from (str "#[[" string-repr "]]") + :string string-repr}] + contents))) + + +(defn block-ref-transform + [block-ref-str] + [:block-ref {:from (str "((" block-ref-str "))") + :string block-ref-str} + block-ref-str]) + + +(defn typed-block-ref-transform + [ref-type-el block-ref-el] + (let [ref-type (second ref-type-el) + block-ref-from (-> block-ref-el second :from) + string-repr (str ref-type ": " block-ref-from)] + [:typed-block-ref {:from (str "{{" string-repr "}}") + :string string-repr} + ref-type-el + block-ref-el])) + + +(def transformations + {:text-or text-or-transform + :code-span code-span-transform + :code-block code-block-transform + :page-link page-link-transform + :naked-hashtag naked-hashtag-transform + :braced-hashtag braced-hashtag-transform + :block-ref block-ref-transform + :typed-block-ref typed-block-ref-transform}) + + +(defn structure-parser->ast + [in] + (let [parse-result (insta/parse structure-parser in :total true)] + (insta/transform transformations parse-result))) + diff --git a/src/cljc/athens/patterns.cljc b/src/cljc/athens/patterns.cljc new file mode 100644 index 0000000000..97a683e4a2 --- /dev/null +++ b/src/cljc/athens/patterns.cljc @@ -0,0 +1,107 @@ +(ns athens.patterns + (:require + [clojure.string :as string])) + + +(defn date + [str] + (re-find #"(?=\d{2}-\d{2}-\d{4}).*" str)) + + +(defn date-block-string + [str] + (re-find #"\b(?:January|February|March|April|May|June|July|August|September|October|November|December)\s\d{1,2}(?:st|nd|rd|th),\s\d{4}\b" str)) + + +(def ordinal->number + {"1st" "1" + "2nd" "2" + "3rd" "3" + "4th" "4" + "5th" "5" + "6th" "6" + "7th" "7" + "8th" "8" + "9th" "9" + "10th" "10" + "11th" "11" + "12th" "12" + "13th" "13" + "14th" "14" + "15th" "15" + "16th" "16" + "17th" "17" + "18th" "18" + "19th" "19" + "20th" "20" + "21st" "21" + "22nd" "22" + "23rd" "23" + "24th" "24" + "25th" "25" + "26th" "26" + "27th" "27" + "28th" "28" + "29th" "29" + "30th" "30" + "31st" "31"}) + + +(defn replace-roam-date + [string] + (string/replace string #"\d?\d(?:st|nd|rd|th)" #(or (ordinal->number %) %))) + + +;; https://stackoverflow.com/a/11672480 +(def regex-esc-char-map + (let [esc-chars "()*&^%$#![]"] + (zipmap esc-chars + (map #(str "\\" %) esc-chars)))) + + +;; TODO: consider https://clojuredocs.org/clojure.string/re-quote-replacement if this causes problems. +(defn escape-str + "Take a string and escape all regex special characters in it" + [str] + (string/escape str regex-esc-char-map)) + + +(defn contains-unlinked? + "Returns true if string contains title unlinked (e.g. not as #title or [[title]])." + [title string] + ;; This would be easier with a lookbehind: (re-pattern (str "(?i)(?!#)(?!\\[\\[)" string "(?!\\]\\])")) + ;; But Safari doesn't support lookbehinds, so we're using a more complex trick + ;; https://www.rexegg.com/regex-best-trick.html#pseudoregex. + ;; The regex to find unlinked foo bar would be #foo bar|\[\[foo bar\]\]|(foo bar) + ;; the general formula is NotThis|NotThat|GoAway|(WeWantThis) + ;; The way it works is that the bad cases fall outside the capture group, so the capture + ;; group will only contain the right thing. + ;; We need to look inside the capture groups with this method though. + (let [t (escape-str title)] + (-> (re-pattern (str "(?i)" "#" t "|\\[\\[" t "\\]\\]|(" t ")")) + (re-find string) + second + boolean))) + + +(defn re-case-insensitive + "More options here https://clojuredocs.org/clojure.core/re-pattern" + [query] + (re-pattern (str "(?i)" (escape-str query)))) + + +(defn split-on + "Splits string whenever value is encountered. Returns all substrings including value." + [s value] + (loop [last-idx 0 + word-start-idx (string/index-of s value) + ret []] + (if word-start-idx + (let [word-end-idx' (+ word-start-idx (count value))] + (recur word-end-idx' + (string/index-of s value word-end-idx') + (-> ret + (conj (subs s last-idx word-start-idx)) + (conj (subs s word-start-idx word-end-idx'))))) + (conj ret (subs s last-idx))))) + diff --git a/src/cljc/athens/walk.cljc b/src/cljc/athens/walk.cljc new file mode 100644 index 0000000000..5a7cd4b2f3 --- /dev/null +++ b/src/cljc/athens/walk.cljc @@ -0,0 +1,33 @@ +(ns athens.walk + (:require + [athens.common.logging :as log] + [athens.parser :as parser] + [clojure.string :as str] + [instaparse.core :as parse])) + + +;; NOTE: collecting +;; - :node/titles +;; - :page/refs +;; - :block/refs + +(defn walk-string + "Walk previous and new strings to delete or add links, block references, etc. to datascript." + [string] + (let [data (atom {})] + (parse/transform + {:page-link (fn [{_from :from} & title] + (let [inner-title (str/join "" title)] + (swap! data update :node/titles #(conj % inner-title)) + (swap! data update :page/refs #(conj % [:node/title inner-title])) + (str "[[" inner-title "]]"))) + :hashtag (fn [{_from :from} & title] + (let [inner-title (str/join "" title)] + (swap! data update :node/titles #(conj % inner-title)) + (swap! data update :page/refs #(conj % [:node/title inner-title])) + (str "#" inner-title))) + :block-ref (fn [{_from :from} uid] (swap! data update :block/refs #(conj % uid)))} + (parser/parse-to-ast string)) + (log/debug "walk-string" (pr-str @data)) + @data)) + diff --git a/src/cljc/event_sync/README.md b/src/cljc/event_sync/README.md new file mode 100644 index 0000000000..f01ad62ff3 --- /dev/null +++ b/src/cljc/event_sync/README.md @@ -0,0 +1,608 @@ +# event-sync + +EventSync is a multi-writer event log synchronizer model. +This is useful for event-based distributed system architectures that want to apply events optimistically while synchronizing them in the background. + +It gives you a way to reason about event synchronization, inspect the current synchronization state, and reactively perform synchronization actions that ensure each writer's events remain ordered. + +It does not give you a way to represent any state besides the event logs being synchronized, a way to save events, or to handle semantic conflicts. + + +## The problem, and the approach + +[Athens](https://github.com/athensresearch/athens) works as client app where you can take notes structured as a graph, backed by an in-memory graph database. +A lot of the rich functionality and responsiveness comes from having direct access to this in-memory database. + +Athens supports multi-user functionality, as well as queries over a server. +Synchronization between all participants is a necessity. +Athens settled on an [event log as source of truth](0018-athens-protocol-principles.md), where state changes deterministically via operations. + +Given such an event log, it is straightforward enough to update clients. +But effecting operations from clients in a responsive manner is not so straightforward due to the distributed nature of the system. +There's many tradeoffs around responsiveness, staleness of data, user flows, user expectations, and data integrity to be made in this space. +It would be ideal if each client could largely operate as standalone. + +EventSync aims to simplify such systems by modelling their state as a event log with a mutable tip. +There is a known set of events that will not change in order, but beyond that point there is an optimistic set of events that might change. +A deterministic state can be obtained on each client by applying both set of events. +By having a way to know when the order changed, the client can decide how to react to go back to a correct state. + + +## Model + +The core idea of EventSync is that you can represent events across multiple synchronizing logs as if it was a single concatenated log. + +The resulting log can have new events inserted in the middle of the log. +This situation represents events from other writers that arrived at that log before existing events from the current writer. +Events from each writer are guaranteed to preserve their relative order. + +The important terms in this document are: +- Event: this is an event in your system that you want to synchronize. +- Stage: a log of events that you want to synchronize with another one such log. +- Event ID: a unique identifier for an event across all stages. +- Order number: a monotonically increasing number for each event within a stage. +- Insert: operation that adds a new event to stage. +- Remove: operation that removes the oldest event from a stage. +- Promote: operation that removes from a stage while inserting it in the next stage. +- State: a self-consistent view of events by stage, and of the last operation. +- State atom: a Clojure atom containing the changing state, over which you can perform operations. +- Log: ordered sequence of unique events across all stages in a state. +- Application state: state maintained by an application when interpreting events. +- Subscription: an ordered stream of events from a log starting at a given order number. + +As a writer, you start by creating new EventSync state atom with one stage for each of your logs, and setup subscriptions for each of your logs. + +Whenever your subscriptions show a new event was added to a log, you add it to the matching stage in the EventSync state atom. +The EventSync API will determine if it's an insert or promote. + +Each time the atom changes you look at the last operation to decide what to do in your application: +- promotions and insertions mean you need to save that event to the next stage +- insertions and removals mean you need to check if the sync log and update your application state according to the new log + +Notably, application state does not need to be updated on promotion because, by definition, the log will not change. +The event moved from one stage to the next, but its order on the log remains the same. + + +## Examples + +To get a feel for what synchronization with EventSync looks like, let's look at a series of examples. + +In these examples we have three stages (in-memory, local storage and server) and two writers (Alice and Bob). +Bob is not connected to the same local storage as Alice, but both are connected to the same server. +The examples happen one after the other, but you can read them separately. + +This is a common set of stages for an offline-first browser application, but you can imagine different sets of stages. +An application without any offline capabilities would only have an in-memory and server stages. +You can also have an application that only synchronizes between two servers, or even chain multiple applications using the EventSync model. + + +### Describing states + +We can talk about EventSync by representing the sequence of states each writer has. +Each state looks like this: + +``` +Writer ID : Alice +Log : a4 a3 b1 a2 a1 +Stage 1 - In-memory : a4 +Stage 2 - Local storage: a3 b1 a2 +Stage 3 - Server : a1 +Last operation : promote 2 a3 +Operation count: : 15 +``` + +In this description events are represented by their event ID and ordered from newest (left-most) to oldest (right-most) within a stage and on the log. +The operation shows its name, followed by stage it operated over and the event ID. +The operation count serves as a notion of time for this state within its state atom, in this case 15 operations have happened. + +Event names in these examples are the first letter of the writer name followed by the order number the writer inserted them in. +Thus `a3` means this is the third event that Alice inserted. +This format is meant to make it easy to read and reason about the events. +Real event IDs can be anything, as long as they are unique. + +A starting state has no events in any stage and its last operation was initialization: + +``` +Writer ID : Alice +Log : +Stage 1 - In-memory : +Stage 2 - Local storage: +Stage 3 - Server : +Last operation : initialization +Operation count : 0 +``` + + +### Something simple + +Both Alice and Bob start with the same state, except for the Writer ID: + +Alice starts by adding an event to the first (in-memory) stage. + +``` +Writer ID : Alice +Log : a1 +Stage 1 - In-memory : a1 +Stage 2 - Local storage: +Stage 3 - Server : +Last operation : insert 1 a1 +Operation count : 1 +``` + +Alice sees the insertion, and saves that event to the second (local storage) stage. +Alice also sees the log has changed, and thus it should update its application state with `a1`. + +When Alice's subscription to local storages shows that `a1` arrived, she promotes it: + +``` +Writer ID : Alice +Log : a1 +Stage 1 - In-memory : +Stage 2 - Local storage: a1 +Stage 3 - Server : +Last operation : promote 2 a1 +Operation count : 2 +``` + +This repeats itself with the third (server) stage: + +``` +Writer ID : Alice +Log : a1 +Stage 1 - In-memory : +Stage 2 - Local storage: +Stage 3 - Server : a1 +Last operation : promote 3 a1 +Operation count : 3 +``` + +The last two states did not change the log, so Alice did not need to update application state. + +Meanwhile, Bob's subscription sees `a1` was added to the server: + +``` +Writer ID : Bob +Log : a1 +Stage 1 - In-memory : +Stage 2 - Local storage: +Stage 3 - Server : a1 +Last operation : insert 3 a1 +Operation count : 1 +``` + +This is the first change that Bob sees because he isn't connected to the same local storage as Alice. +Bob will need to update his application state with it. + + +### Concurrency + +Both Alice and Bob inserted an event roughly at the same time: + +``` +Writer ID : Alice +Log : a2 a1 +Stage 1 - In-memory : a2 +Stage 2 - Local storage: +Stage 3 - Server : a1 +Last operation : inset 1 a2 +Operation count : 4 +``` + +``` +Writer ID : Bob +Log : b1 a1 +Stage 1 - In-memory : b1 +Stage 2 - Local storage: +Stage 3 - Server : a1 +Last operation : insert 1 b1 +Operation count : 2 +``` + +Although we say "at the same time" what matters here is not so much that they happened at the same real-world time, but rather at the same logical time. +As far as the state in each application is concerned, their own events were inserted before the other one. + +This can happen for many known reasons: it really was the same real-time, the network was slow, the computer was slow, the application was slow, the application is bugged, the server was slow, the local storage was slow, one or both writers were offline, there was a network partition, etc. +There's also unknown reasons for this happening. +Concurrent events in a distributed system can be minimized but they can never be eliminated. + +Bob's event goes through local storage and arrives at the server first: + +``` +Writer ID : Bob +Log : b1 a1 +Stage 1 - In-memory : +Stage 2 - Local storage: b1 +Stage 3 - Server : a1 +Last operation : promote 2 b1 +Operation count : 3 +``` + +``` +Writer ID : Bob +Log : b1 a1 +Stage 1 - In-memory : +Stage 2 - Local storage: +Stage 3 - Server : b1 a1 +Last operation : promote 3 b1 +Operation count : 4 +``` + +Alice sees Bob's event before her own `a2` reached local storage: + +``` +Writer ID : Alice +Log : a2 b1 a1 +Stage 1 - In-memory : a2 +Stage 2 - Local storage: +Stage 3 - Server : b1 a1 +Last operation : insert 3 b1 +Operation count : 5 +``` + +For Alice, this insertion changed the order of the log from `a2 a1` to `a2 b1 a1`. +It's up to Alice's application to decide what to do with this changed order. + +Here's a couple of options: +- replay all the events in the log and thus rebuild her application state from scratch. +- if she has an intermediate application state saved after resolving `a1`, she can use it to replay only `a2 b1` on top of it. +- decide that it's ok to resolve `b1` on top of the previous `a2 a1` instead of following the real order. +- defer this decision until later in order to reduce the number of computations being done right now. + +Whatever Alice decides to do with the application state will not affect the EventSync state. +It will continue to sync `a2` to the server: + +``` +Writer ID : Alice +Log : a2 b1 a1 +Stage 1 - In-memory : +Stage 2 - Local storage: a2 +Stage 3 - Server : b1 a1 +Last operation : promote 2 a2 +Operation count : 6 +``` + +``` +Writer ID : Alice +Log : a2 b1 a1 +Stage 1 - In-memory : +Stage 2 - Local storage: +Stage 3 - Server : a2 b1 a1 +Last operation : promote 3 a2 +Operation count : 7 +``` + +At which point Bob will also see `a2`, and end up with the same log as Alice: + +``` +Writer ID : Bob +Log : a2 b1 a1 +Stage 1 - In-memory : +Stage 2 - Local storage: +Stage 3 - Server : a2 b1 a1 +Last operation : insert 3 a2 +Operation count : 5 +``` + + +### Alice is Offline + +Alice has written a few new events (omitted for brevity), but they don't seem to be reaching the server: + +``` +Writer ID : Alice +Log : a5 a4 a3 a2 b1 a1 +Stage 1 - In-memory : +Stage 2 - Local storage: a5 a4 a3 +Stage 3 - Server : a2 b1 a1 +Last operation : promote 2 a5 +Operation count : 13 +``` + +The `a5 a4 a3` events are stuck in the local storage stage. + +It looks like Alice is offline. +It might also be that the server itself is offline. +Or maybe something is just slow. +In fact, offline is indistinguishable from slow after waiting for whatever is considered a reasonable amount of time. + +But Alice can continue work and write new events on top of the last known state, and they will be saved at least up to the local storage state. + +``` +Writer ID : Alice +Log : a7 a6 a5 a4 a3 a2 b1 a1 +Stage 1 - In-memory : a7 a6 +Stage 2 - Local storage: a5 a4 a3 +Stage 3 - Server : a2 b1 a1 +Last operation : promote 1 a7 +Operation count : 15 +``` + +``` +Writer ID : Alice +Log : a7 a6 a5 a4 a3 a2 b1 a1 +Stage 1 - In-memory : +Stage 2 - Local storage: a7 a6 a5 a4 a3 +Stage 3 - Server : a2 b1 a1 +Last operation : promote 3 a7 +Operation count : 17 +``` + +Meanwhile Bob still seems to be online, and has been writting his own events that Alice has not yet seen: + +``` +Writer ID : Bob +Log : b4 b3 b2 a2 b1 a1 +Stage 1 - In-memory : +Stage 2 - Local storage: +Stage 3 - Server : b4 b3 b2 a2 b1 a1 +Last operation : insert 3 b4 +Operation count : 14 +``` + +Alice and Bob see different things because Alice hasn't yet gotten the latest events from the server. + + +### Two offline Alices + +While offline, Alice opened another instance of the application. +This instance is connected to the same local storage. + +Even though both instances are controlled by the same person, they are different instances. +Let's call the second one Elsa instead. + +``` +Writer ID : Elsa +Log : +Stage 1 - In-memory : +Stage 2 - Local storage: +Stage 3 - Server : +Last operation : initialization +Operation count : 0 +``` + +Even though Elsa is offline, her server subscription keeps a cache so she's able to see the events Alice had seen. +Together with the local storage subscription, Elsa gets up to the same log as Alice. + +``` +Writer ID : Elsa +Log : a7 a6 a5 a4 a3 a2 b1 a1 +Stage 1 - In-memory : +Stage 2 - Local storage: a7 a6 a5 a4 a3 +Stage 3 - Server : a2 b1 a1 +Last operation : promote 3 a7 +Operation count : 8 +``` + +When Elsa writes `e1`, Alice will be able to see it via the local storage subscription. + +``` +Writer ID : Elsa +Log : e1 a7 a6 a5 a4 a3 a2 b1 a1 +Stage 1 - In-memory : e1 +Stage 2 - Local storage: a7 a6 a5 a4 a3 +Stage 3 - Server : a2 b1 a1 +Last operation : insert 1 e1 +Operation count : 9 +``` + +``` +Writer ID : Elsa +Log : e1 a7 a6 a5 a4 a3 a2 b1 a1 +Stage 1 - In-memory : +Stage 2 - Local storage: e1 a7 a6 a5 a4 a3 +Stage 3 - Server : a2 b1 a1 +Last operation : insert 2 e1 +Operation count : 10 +``` + +``` +Writer ID : Alice +Log : e1 a7 a6 a5 a4 a3 a2 b1 a1 +Stage 1 - In-memory : +Stage 2 - Local storage: e1 a7 a6 a5 a4 a3 +Stage 3 - Server : a2 b1 a1 +Last operation : insert 2 e1 +Operation count : 18 +``` + +Both Alice and Elsa are up to date with each other, but they are not synced with Bob. + + +### Back online + +Alice and Elsa are back online and started to get events from the server: + +``` +Writer ID : Alice +Log : e1 a7 a6 a5 a4 a3 b4 b3 b2 a2 b1 a1 +Stage 1 - In-memory : +Stage 2 - Local storage: e1 a7 a6 a5 a4 a3 +Stage 3 - Server : b4 b3 b2 a2 b1 a1 +Last operation : insert 3 b4 +Operation count : 21 +``` + +``` +Writer ID : Elsa +Log : e1 a7 a6 a5 a4 a3 b4 b3 b2 a2 b1 a1 +Stage 1 - In-memory : +Stage 2 - Local storage: e1 a7 a6 a5 a4 a3 +Stage 3 - Server : b4 b3 b2 a2 b1 a1 +Last operation : insert 3 b4 +Operation count : 13 +``` + +Bob's events changed the order of Alice and Elsa's log. +It's up to their applications to decide what to do with this changed order. + +The server subscription shows each of the events sent from Alice and Elsa's local storage stage: + +``` +Writer ID : Alice +Log : e1 a7 a6 a5 a4 a3 b4 b3 b2 a2 b1 a1 +Stage 1 - In-memory : +Stage 2 - Local storage: +Stage 3 - Server : e1 a7 a6 a5 a4 a3 b4 b3 b2 a2 b1 a1 +Last operation : promote 3 e1 +Operation count : 27 +``` + +``` +Writer ID : Elsa +Log : e1 a7 a6 a5 a4 a3 b4 b3 b2 a2 b1 a1 +Stage 1 - In-memory : +Stage 2 - Local storage: +Stage 3 - Server : e1 a7 a6 a5 a4 a3 b4 b3 b2 a2 b1 a1 +Last operation : promote 3 e1 +Operation count : 20 +``` + +``` +Writer ID : Bob +Log : e1 a7 a6 a5 a4 a3 b4 b3 b2 a2 b1 a1 +Stage 1 - In-memory : +Stage 2 - Local storage: +Stage 3 - Server : e1 a7 a6 a5 a4 a3 b4 b3 b2 a2 b1 a1 +Last operation : insert 3 e1 +Operation count : 20 +``` + +Alice and Elsa's log did not change as their events reached the server. +Bob's log did not change the order of previous events, but got new events. + +Alice, Bob, and Elsa are all synced to the same log. + + +## Requirements + +To use EventSync you need ensure: +- create Event IDs, e.g. a UUID generator or equivalent. +- idempotency of event saves to a stage's log, because multiple EventSync writers can be writing the same event. +- monotonically increasing ordering of events within a stage's log. +- subscription capability over a stage's log. + +While not a strict necessity, the order number within a stage's log events is very useful to ensure subscriptions can remain partial, and to detect how out of date caches are. + +Additionally, for offline-first applications, you also need to ensure a local cache for each non-local log. +This cache will need to store the all events in that stage that are not known to have been removed, and be able to retrieve on subscription. +Without it you will not be able restart the application and get back to the same EventSync state. + +You can use extra stages to sync to these caches. +In the example above, you could cache the server events by adding `Stage 4 - Server local cache`. +This would give you the same sync semantics as before. + + +## FAQ + +### How can I detect if EventSync is losing events? + +EventSync guarantees that each event from a given writer remain ordered on each stage. +You can keep track of the last event by the writer on the application, and pass the id of previous event on each event. +This allows you to query any log to see if the previous log is there as well. + +It's still possible to lose data whenever data deleted from any given stage's log if there's a live subscription that hasn't seen it yet. +This is a strong argument for immutability of stored data, but impractical for caches. +If you delete data from any log or cache make sure it's not pending a subscription read. + +maybe error on promoting non-tip? + + +### How can I handle conflicts? + +TODO: only semantic events, not at ES level + + +### Should I subscribe to all events for each stage on startup? + +TODO: need some kind of tracking on each stage store of last one that was verified to be saved +TODO: also detect already saved on startup? hard problem, might never show up if very old but already saved + + +### Why do I need to wait for the subscription to show an event after saving it to the log? + +Besides signalling that an event is present on a stage, the subscription also shows what the ordering of events was. +The latter is what's really important. + +Consider the [Back online](#back-online) example. +When Alice came back online, she had to send 6 events from herself and Elsa to the server, and receive from the server Bob's 3 events. +Even though her events might be saved already, for the purpose of ordering the logs they can only be removed from a stage after the next stage shows it in the correct order. + + +### Won't the last stage accumulate events forever? + +TODO: remove events when incorporated into app state to keep it small. + + +### How many stages should I have? + +One for in-memory only events if this application will write events, plus one for each persistent log you have. + + +### How do I save events between stages? + +TODO: watch atom, check last op, make own save fns that ensure idempotency + + +### How do I recover if a stage is unresponsive? + +TODO: hard, considerations below + +stop syncing, make a new ES without that stage, start listening to the stages again? +-no, this loses the events in that stage, can't read them... need some kind of cache in local if we never want to lose them +-same problem as restart and stage is gone +-even for the purpose of own events sync, you will lose events like this... maybe keep recording of which hit the last stage? + +drain then restart? +- then just back to generic problem on reload if a stage is gone +- stage cache makes more sense then + +maybe no restart at all, just backed up stuff? +- then you just have the reload problem + +maybe do nothing, just infinite lag? + +cache on further stages also ensures on load you see everything right +- if caches are busted, then maybe you have a problem +- but that's actually the real problem, that losing any data is bad, and the answer is not to duplicate it further + +failover can just mean that stage auto-promotes to next stage via the subs itself +- e.g. any write to it just shows up on its sub, thus moving it to the next stage + +need to figure out better model of thinking about subs to prevent complicated problems on intermediate stages not having some events + + +### Is it possible for an event to be in multiple stages at once? + +TODO: possible for concurrent clients to be sending events on weird cadence? think not, bake into test, maybe make invariant +TODO: log should not show repeats anyway + + +### What happens is a stage refuses an event? + +TODO: API denied save in a non-recoverable manner, tricky case + + +### Synchronization is hard, how is EventSync tested for robustness? + +TODO: generative tests, previous event tracking + + +### What things to I have to look out for when setting up subscriptions? + +intermediate stage subs needs to start at the first event that's not on subsequent stages +- otherwise you can run into cases where you're waiting for subsequent subs to tell you something is there, but that will never happen +- principle is sub needs to start at last unseen event from further stages +- also some considerations here of when to clear the local caches for non-local stages (i.e. when SOT gives you a new starting point) + + +### Can I batch event saving and subscriptions? + +The specific semantics of how events are saved and retrieved are up to your code. +You can batch operations as long as you end up calling the EventSync API. + +### What happens when an event skips a stage? + +e.g. on race condition +e.g. on different stage topologies + +TODO: test this is ok diff --git a/src/cljc/event_sync/core.cljc b/src/cljc/event_sync/core.cljc new file mode 100644 index 0000000000..31cb775b89 --- /dev/null +++ b/src/cljc/event_sync/core.cljc @@ -0,0 +1,238 @@ +;; TODO: rename to something better, candidates below: +;; optimistic-queue +;; multi-stage-queue +;; staged-queue +(ns event-sync.core + (:refer-clojure :exclude [print remove type add-watch remove-watch]) + (:require + [clojure.pprint :as pprint] + [flatland.ordered.map :refer [ordered-map]])) + + +(defn op + "Create an operation in the format of [type stage-id event-id event noop?]. + type can be one of :add, :promote, :remove. noop? is true if the operation did nothing." + [type stage-id event-id event noop?] + (vector type stage-id event-id event noop?)) + + +(defn update-op + "Set last operation in state to op and increase operation count." + [state op] + (-> state + (assoc :last-op op) + (update :op-count inc))) + + +(defn event-stage + "Return the stage an event is in, if any." + [state event-id] + (some (fn [[stage-id events]] + (when (get events event-id) + stage-id)) + (:stages state))) + + +(defn first-match + "Returns the first match in matches to appear in coll." + [coll matches] + (let [s (set matches)] + (some s coll))) + + +(defn stage< + "Returns true if s1 appears before s2 in stage-ids. + Does not verify both s1 and s2 are in stage-ids." + [stage-ids s1 s2] + (and + (not (= s1 s2)) + (= s1 (first-match stage-ids [s1 s2])))) + + +(defn promotion? + "Returns true if adding event-id to-stage is a promotion given event-id is in from-stage." + [state from-stage to-stage event-id] + (and + ;; is to-stage immediately after from-stage? + (= to-stage (second (drop-while #(not= % from-stage) (-> state :stages keys)))) + ;; does event-id match the first event in from-stage? + ;; NB: in ordered map first = oldest, last = newest + (= event-id (ffirst (get-in state [:stages from-stage]))))) + + +;; API + +(defn create-state + "Create a state with id and the stages in stage-ids. + NB: use log and print to show events in newest-to-oldest order." + [id stage-ids] + {:id id + :stages (into (ordered-map) (map #(vector % (ordered-map)) stage-ids)) + :last-op :initialization + :op-count 0}) + + +(defn add + "Add event to stage-id and remove it from previous stages. + If this addition would be a promotion, the resulting operation will be :promote instead of :add. + If the event is already in a further stage last-op will be marked as a noop (last element is true)." + [state stage-id event-id event] + (let [current (event-stage state event-id) + noop? (boolean (and current + (or (= stage-id current) + (stage< (-> state :stages keys) stage-id current)))) + type (if (and current + (not noop?) + (promotion? state current stage-id event-id)) + :promote + :add)] + (cond-> state + ;; remove from current stage + (and current (not noop?)) (update-in [:stages current] dissoc event-id) + ;; add to the new stage + (not noop?) (update-in [:stages stage-id] assoc event-id event) + ;; update last operation + true (update-op (op type stage-id event-id event noop?))))) + + +(defn remove + "Remove event from stage-id. + If the event is not there last-op will be marked as a noop (last element is true)." + [state stage-id event-id event] + (let [current (event-stage state event-id) + remove? (= stage-id current)] + (cond-> state + ;; remove from current stage + remove? (update-in [:stages current] dissoc event-id) + ;; update last operation + true (update-op (op :remove stage-id event-id event (not remove?)))))) + + +(defn stage-log + "Returns a vector of all events in a stage, as [event-id event] pairs, from newest to oldest." + [state stage] + (when-let [events (-> state :stages (get stage))] + ;; Reverse the order of each event list before concatenating to show newest to oldest. + (-> events reverse vec))) + + +(defn log + "Returns a vector of all events in state, as [event-id event] pairs, from newest to oldest." + [state] + (vec (mapcat (comp (partial stage-log state) first) (:stages state)))) + + +(defn print + "Pretty prints a state, with added log. + Events are shown newest to oldest for readability." + [state] + (pprint/pprint + (reduce (fn [state k] + (update-in state [:stages k] (comp vec rseq))) + (assoc state :log (log state)) + (-> state :stages keys)))) + + +;; Mutable API + +(defn create-state-atom + "Create a mutable atom from create-state." + [id stage-ids] + (atom (create-state id stage-ids))) + + +(defn add! + "Mutate state-atom via add. " + [state-atom stage-id event-id event] + (swap! state-atom #(add % stage-id event-id event))) + + +(defn remove! + "Mutate state-atom via remove. " + [state-atom stage-id event-id event] + (swap! state-atom #(remove % stage-id event-id event))) + + +(defn add-watch + "Add a watch fn to state-atom under key. + on-add, on-promote, on-remove are fns that receive last-op and state." + [state-atom key on-add on-promote on-remove] + (let [f (fn state-atom-watcher + [_ _ _ {:keys [last-op] :as new-state}] + (condp = (first last-op) + :add (on-add last-op new-state) + :promote (on-promote last-op new-state) + :remove (on-remove last-op new-state)))] + (clojure.core/add-watch state-atom key f))) + + +(defn remove-watch + "Remove watcher under key added via add-watch." + [state-atom key] + (clojure.core/remove-watch state-atom key)) + + +(comment + (-> (create-state :mario [:one :two :three]) + (add :one "event-id-1" "event-1") + (add :one "event-id-2" "event-2") + (add :two "event-id-1" "event-1") + print + ) + + ;; from readme, up to Alice's last state in "Two Offline Alices" + (-> (create-state :alice [:in-memory :local-storage :server]) + ;; something simple + (add :in-memory "a1" "a1") + (add :local-storage "a1" "a1") + (add :server "a1" "a1") + ;; concurrency + (add :in-memory "a2" "a2") + (add :server "b1" "b1") + (add :local-storage "a2" "a2") + (add :server "a2" "a2") + ;; alice is offline + (add :in-memory "a3" "a3") + (add :in-memory "a4" "a4") + (add :in-memory "a5" "a5") + (add :local-storage "a3" "a3") + (add :local-storage "a4" "a4") + (add :local-storage "a5" "a5") + (add :in-memory "a6" "a6") + (add :in-memory "a7" "a7") + (add :local-storage "a6" "a6") + (add :local-storage "a7" "a7") + ;; two offline alices + (add :local-storage "e1" "e1") + print) + + ;; prints + ;; {:id :alice, + ;; :stages + ;; {:in-memory [], + ;; :local-storage + ;; [["e1" "e1"] + ;; ["a7" "a7"] + ;; ["a6" "a6"] + ;; ["a5" "a5"] + ;; ["a4" "a4"] + ;; ["a3" "a3"]], + ;; :server [["a2" "a2"] ["b1" "b1"] ["a1" "a1"]]}, + ;; :last-op [:add :local-storage "e1" "e1" false], + ;; :op-count 18, + ;; :log + ;; [["e1" "e1"] + ;; ["a7" "a7"] + ;; ["a6" "a6"] + ;; ["a5" "a5"] + ;; ["a4" "a4"] + ;; ["a3" "a3"] + ;; ["a2" "a2"] + ;; ["b1" "b1"] + ;; ["a1" "a1"]]} + + (stage< [:one :two :three] :two 1) + (vals (ordered-map :a 1 :b 2)) + ; + ) + diff --git a/src/cljs/athens/coeffects.cljs b/src/cljs/athens/coeffects.cljs new file mode 100644 index 0000000000..4e950e106f --- /dev/null +++ b/src/cljs/athens/coeffects.cljs @@ -0,0 +1,11 @@ +(ns athens.coeffects + (:require + [athens.util :as util] + [re-frame.core :as rf])) + + +(rf/reg-cofx + :local-storage + (fn [coeffects k] + (assoc coeffects :local-storage (util/local-storage-get (str k))))) + diff --git a/src/cljs/athens/components.cljs b/src/cljs/athens/components.cljs new file mode 100644 index 0000000000..c314ea8a37 --- /dev/null +++ b/src/cljs/athens/components.cljs @@ -0,0 +1,94 @@ +(ns athens.components + (:require + ["@chakra-ui/react" :refer [Checkbox Button]] + [athens.db :as db] + [athens.parse-renderer :refer [component]] + [athens.reactive :as reactive] + [athens.types.core :as types] + [athens.types.dispatcher :as block-type-dispatcher] + [athens.views.blocks.core :as blocks] + [clojure.string :as str] + [re-frame.core :as rf])) + + +(defn todo-on-click + [uid from-str to-str] + (let [current-block-content (:block/string (db/get-block [:block/uid uid])) + new-block-content (str/replace current-block-content + from-str + to-str)] + (rf/dispatch [:block/save {:uid uid + :string new-block-content + :add-time? true + :source :todo-click}]))) + + +(defn span-click-stop + "Stop clicks from propagating to textarea and thus preventing edit mode + TODO() - might be a good idea to keep an edit icon at top right + for every component." + [children] + [:span {:style {:display "contents"} + :on-click (fn [e] (.. e stopPropagation))} + children]) + + +(defmethod component :todo + [_content uid] + [span-click-stop + [:> Checkbox {:isChecked false + :verticalAlign "middle" + :transform "translateY(-1px)" + :onChange #(todo-on-click uid #"\{\{\[\[TODO\]\]\}\}" "{{[[DONE]]}}")}]]) + + +(defmethod component :done + [_content uid] + [span-click-stop + [:> Checkbox {:isChecked true + :verticalAlign "middle" + :transform "translateY(-1px)" + :onChange #(todo-on-click uid #"\{\{\[\[DONE\]\]\}\}" "{{[[TODO]]}}")}]]) + + +(defmethod component :youtube + [content _uid] + [span-click-stop + [:div.media-16-9 + [:iframe {:src (str "https://www.youtube.com/embed/" (get (re-find #".*v=([a-zA-Z0-9_\-]+)" content) 1)) + :allow "accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture"}]]]) + + +(defmethod component :iframe + [content _uid] + [span-click-stop + [:div.media-16-9 + [:iframe {:src (re-find #"http.*" content)}]]]) + + +(defmethod component :self + [content _uid] + [span-click-stop + [:> Button {:variant "link" + :color "red"} + content]]) + + +(defmethod component :block-embed + [content uid] + ;; bindings are eval only once in with-let + ;; which is needed to keep embed integrity else it will update on + ;; each re-render. Similar to ref-comp + (let [block-uid (last (re-find #"\(\((.+)\)\)" content)) + block-eid (db/e-by-av :block/uid block-uid)] + (if block-eid + (let [block-type (reactive/reactive-get-entity-type [:block/uid block-uid]) + ff @(rf/subscribe [:feature-flags]) + renderer-k (block-type-dispatcher/block-type->protocol-k block-type ff) + renderer (block-type-dispatcher/block-type->protocol renderer-k {})] + ^{:key renderer-k} + [:f> types/transclusion-view renderer blocks/block-el block-uid {:transcluding-block-uid uid} :embed]) + ;; roam actually hides the brackets around [[embed]] + [:span "{{" content "}}"]))) + + diff --git a/src/cljs/athens/config.cljs b/src/cljs/athens/config.cljs index dcdbb8bbe8..a3c1ed09e7 100644 --- a/src/cljs/athens/config.cljs +++ b/src/cljs/athens/config.cljs @@ -1,4 +1,12 @@ (ns athens.config) + (def debug? ^boolean goog.DEBUG) + + +(goog-define MEASURE_PARSER false) + + +(def measure-parser? + ^boolean MEASURE_PARSER) diff --git a/src/cljs/athens/core.cljs b/src/cljs/athens/core.cljs index 3b315cae27..db91b1c4bc 100644 --- a/src/cljs/athens/core.cljs +++ b/src/cljs/athens/core.cljs @@ -1,31 +1,103 @@ (ns athens.core (:require - [athens.events] - [athens.subs] - [athens.views :as views] - [athens.config :as config] - [athens.db :as db] - [athens.router :as router] - [athens.parser :refer [parser]] - [reagent.core :as reagent] - [re-frame.core :as rf] - [re-posh.core :as rp] - )) - -(defn dev-setup [] + ["@sentry/integrations" :as integrations] + ["@sentry/react" :as Sentry] + ["@sentry/tracing" :as tracing] + [athens.coeffects] + [athens.common.logging :as log] + [athens.components] + [athens.config :as config] + [athens.db :refer [dsdb]] + [athens.effects] + [athens.electron.core] + [athens.electron.utils :as electron.utils] + [athens.events] + [athens.interceptors] + [athens.listeners :as listeners] + [athens.router :as router] + [athens.style :as style] + [athens.subs] + [athens.util :as util] + [athens.views :as views] + [datalog-console.integrations.datascript :as datalog-console] + [goog.dom :refer [getElement]] + [re-frame.core :as rf] + [reagent.dom :as r-dom])) + + +(goog-define SENTRY_DSN "") + + +(defn dev-setup + [] (when config/debug? - (println "dev mode"))) + (log/info "dev mode"))) -(defn ^:dev/after-load mount-root [] + +(defn ^:dev/after-load mount-root + [first-boot?] (rf/clear-subscription-cache!) - (router/init-routes!) - (reagent/render [views/main-panel] - (.getElementById js/document "app"))) + (when-not first-boot? + (router/init-routes!)) + (r-dom/render [views/main] + (getElement "app"))) + + +(defn sentry-on? + "Checks localStorage to see if sentry is on. Sentry is disabled/enabled in settings along with Posthog." + [] + (not= "off" (js/localStorage.getItem "sentry"))) + + +(defn init-sentry + "Two checks for sentry: once on init and once on beforeSend." + [] + (when (sentry-on?) + (.init Sentry (clj->js {:dsn SENTRY_DSN + :release (str "athens@" (util/athens-version)) + :integrations [(tracing/Integrations.BrowserTracing.) + (Sentry/Integrations.Breadcrumbs. (clj->js {:console false})) + ;; NOTE This configuration is not working, we're not capturing these levels + (integrations/CaptureConsole. (clj->js {:levels ["warn" "error" "assert"]})) + (integrations/ReportingObserver. (clj->js {:types ["crash"]}))] + :environment (if config/debug? "development" "production") + :beforeSend #(when (sentry-on?) %) + :tracesSampleRate 1.0})))) + + +(defn set-global-alert! + "Alerts user if there's an uncaught error. + https://developer.mozilla.org/en-US/docs/Web/API/GlobalEventHandlers/onerror " + [] + (set! js/window.onerror (fn [message, source, lineno, colno, error] + (js/alert (str "message=" message "\nsource=" source "\nlineno=" lineno "\ncolno=" colno "\nerror=" error))))) + + +(defn init-ipcRenderer + [] + (when electron.utils/electron? + (let [update-available? (.sendSync (electron.utils/ipcRenderer) "check-update" "renderer")] + (when update-available? + (when (js/window.confirm "Update available. Would you like to update and restart to the latest version?") + (.sendSync (electron.utils/ipcRenderer) "confirm-update")))))) + + +(defn init-styles + [] + (util/add-body-classes (util/app-classes {:os (util/get-os) + :electron? electron.utils/electron?}))) + -(defn init [] - (rf/dispatch-sync [:init-rfdb]) - ;; when dev, download datoms directly +(defn init + [] + (set-global-alert!) + (init-sentry) + (init-ipcRenderer) + (style/init) + (init-styles) + (listeners/init) (when config/debug? - (rf/dispatch [:boot])) + (datalog-console/enable! {:conn dsdb})) + (rf/dispatch-sync [:boot true]) (dev-setup) - (mount-root)) + (mount-root true)) diff --git a/src/cljs/athens/db.cljs b/src/cljs/athens/db.cljs index eb7d052412..bedca1619f 100644 --- a/src/cljs/athens/db.cljs +++ b/src/cljs/athens/db.cljs @@ -1,9 +1,177 @@ (ns athens.db - (:require [datascript.core :as d] - [clojure.edn :as edn] - [re-posh.core :as re-posh] - [re-frame.core :as re-frame] - )) + (:require + [athens.common-db :as common-db] + [athens.common-events.resolver.order :as order] + [athens.common.logging :as log] + [athens.common.sentry :refer-macros [defntrace]] + [athens.electron.utils :as electron.utils] + [athens.patterns :as patterns] + [athens.types.tasks.db :as tasks-db] + [clojure.edn :as edn] + [clojure.string :as string] + [datascript.core :as d] + [re-frame.core :as rf])) + + +;; -- Example Roam DBs --------------------------------------------------- + +(def help-url "https://raw.githubusercontent.com/athensresearch/athens/master/data/help.datoms") +(def ego-url "https://raw.githubusercontent.com/athensresearch/athens/master/data/ego.datoms") + + +;; -- seed data ----------------------------------------------------------- + + +(def default-graph-conf + {:hlt-link-levels 1 + :link-distance 50 + :charge-strength -15 + :local-depth 1 + :root-links-only? false + :orphans? true + :daily-notes? true}) + + +(def default-pallete + #{"#DDA74C" + "#C45042" + "#611A58" + "#21A469" + "#009FB8" + "#0062BE"}) + + +(def greek-pantheon + #{"Zeus" + "Hera" + "Poseidon" + "Demeter" + "Athena" + "Apollo" + "Artemis" + "Ares" + "Aphrodite" + "Hephaestus" + "Hermes" + "Dionysus" + ;; Technically not part of the Olympians, but a cool guy nonetheless. + "Hades" + ;; Son of either Zeus and Persephone or Hades and persephone. + ;; Nothing to do with a recent video game at all. + "Zagreus"}) + + +(def default-settings + {:email nil + :username (rand-nth (vec greek-pantheon)) + :color (rand-nth (vec default-pallete)) + :monitoring true + :backup-time 15}) + + +(def default-athens-persist + {;; Increase this number when making breaking changes to the persistence format. + :persist/version 2 + :theme/dark false + :graph-conf default-graph-conf + :settings default-settings}) + + +;; -- update ------------------------------------------------------------- + +(defn update-legacy-to-latest + "Add settings in the legacy format to the latest persist format." + [latest] + ;; This value was not saved in local storage proper, saving it there + ;; to enable use of update-when-found. + (js/localStorage.setItem "monitoring" (not (try (.. js/window -posthog has_opted_out_capturing) + (catch :default _ true)))) + (let [update-when-found (fn [x keyseq k xf] + (if-some [v (js/localStorage.getItem k)] + (assoc-in x keyseq (xf v)) + x)) + str->boolean (fn [x] (= x "true"))] + (-> latest + (update-when-found [:settings :email] "auth/email" identity) + (update-when-found [:settings :username] "user/name" identity) + (update-when-found [:settings :backup-time] "debounce-save-time" js/Number) + (update-when-found [:settings :monitoring] "monitoring" str->boolean) + (update-when-found [:theme/dark] "theme/dark" str->boolean) + (update-when-found [:graph-conf] "graph-conf" edn/read-string)))) + + +(defn update-v1-to-v2 + [persisted] + (-> persisted + (assoc-in [:settings :color] (:color default-settings)) + (assoc :persist/version 2))) + + +(defn- recreate-self-hosted-dbs + [dbs] + (into {} (map (fn [[k {:keys [name url password] :as db}]] + [k (if (electron.utils/remote-db? db) + (electron.utils/self-hosted-db name url password) + db)]) + dbs))) + + +(defn update-v2-to-v3 + [persisted] + (-> persisted + (update :db-picker/all-dbs recreate-self-hosted-dbs) + (assoc :persist/version 3))) + + +(defn update-persisted + "Updates persisted to the latest format." + [{:keys [:persist/version] :as persisted}] + ;; Anything saved under the :athens/persist key will be automatically + ;; persisted and loaded between sessions. + (if-not version + ;; Legacy is updated to latest directly by cherry-picking data + ;; from local storage into the latest format. + (update-legacy-to-latest default-athens-persist) + ;; Data saved in previous versions of the current format need to be updated. + (let [v< #(< version %)] + (cond-> persisted + ;; Update persisted by applying each update fn incrementally. + (v< 2) update-v1-to-v2 + (v< 3) update-v2-to-v3)))) + + +;; -- re-frame ----------------------------------------------------------- + +(defonce rfdb {:db/synced true + :db/mtime nil + :current-route nil + :loading? true + :modal false + :alert nil + :win-maximized? false + :win-fullscreen? false + :win-focused? true + :athena/open false + :athena/recent-items '() + ;; todo: some value initialization like athens/persist + ;; :right-sidebar/width 32 + :mouse-down false + :daily-notes/items [] + :selection {:items []} + :help/open? false + :zoom-level 0 + :fs/watcher nil + :presence {} + :connection-status :disconnected + :comment/show-comments? true}) + + +(defn init-app-db + [persisted] + (merge rfdb {:athens/persist (update-persisted persisted)})) + + +;; -- JSON Parsing ---------------------------------------------------- (def str-kw-mappings "Maps attributes from \"Export All as JSON\" to original datascript attributes." @@ -24,9 +192,12 @@ "users" nil "heading" :block/heading}) -(defn convert-key [k] + +(defn convert-key + [k] (get str-kw-mappings k k)) + (defn parse-hms "Parses JSON retrieved from Roam's \"Export all as JSON\". Not fully functional." [hms] @@ -38,6 +209,7 @@ %) hms))) + (defn parse-tuples "Parse tuples exported via method specified in https://roamresearch.com/#/app/ego/page/eJ14YtH2G." [tuples] @@ -47,6 +219,7 @@ (map #(map edn/read-string %)) (map #(cons :db/add %)))) + (defn json-str-to-edn "Convert a JSON str to EDN. May receive JSON through an HTTP request or file upload." [json-str] @@ -54,6 +227,7 @@ (js/JSON.parse) (js->clj))) + (defn str-to-db-tx "Deserializes a JSON string into EDN and then Datoms." [json-str] @@ -62,20 +236,396 @@ (parse-hms edn-data) (parse-tuples edn-data)))) -(def athens-url "https://raw.githubusercontent.com/athensresearch/athens/master/data/athens.datoms") -(def help-url "https://raw.githubusercontent.com/athensresearch/athens/master/data/help.datoms") -(def ego-url "https://raw.githubusercontent.com/athensresearch/athens/master/data/ego.datoms") -(def schema {:block/uid {:db/unique :db.unique/identity} - :node/title {:db/unique :db.unique/identity} - :attrs/lookup {:db/cardinality :db.cardinality/many} - :block/children {:db/cardinality :db.cardinality/many - :db/valueType :db.type/ref}}) +;; -- Datascript and Posh ------------------------------------------------ + +(defonce dsdb (common-db/create-conn)) + + +(defn e-by-av + [a v] + (-> (d/datoms @dsdb :avet a v) first :e)) + + +(defn v-by-ea + [e a] + (-> (d/datoms @dsdb :eavt e a) first :v)) + + +(defn uid-and-embed-id + [uid] + (or (some->> uid + (re-find #"^(.+)-embed-(.+)") + rest vec) + [uid nil])) + + +(defn sort-block-children + [block] + (if-let [children (seq (:block/children block))] + (assoc block :block/children + (vec (sort-by :block/order (map sort-block-children children)))) + block)) + + +(defntrace shape-parent-query + "Normalize path from deeply nested block to root node." + [pull-results] + (->> (loop [b pull-results + res []] + (cond + ;; There's no page in these pull results, log and exit. + (nil? b) (do + (log/warn "No parent found in" (pr-str pull-results)) + []) + ;; Found the page. + (:node/title b) (conj res b) + ;; Recur with the parent. + :else (recur (or (first (:block/_children b)) + (:block/property-of b)) + (conj res (dissoc b :block/_children :block/property-of))))) + (rest) + (reverse) + vec)) + + +(defntrace get-parents-recursively + [id] + (when (d/entity @dsdb id) + (->> (d/pull @dsdb '[:db/id :node/title :block/uid :block/string + {:block/edits [{:event/time [:time/ts]}]} + {:block/property-of ...} + {:block/_children ...}] + id) + shape-parent-query))) + + +(defntrace get-root-parent-page + "Returns the root parent page or returns the block because this block is a page." + [uid] + ;; make sure block first exists + (when-let [block (d/entity @dsdb [:block/uid uid])] + (let [opt1 (first (get-parents-recursively [:block/uid uid]))] + (or opt1 block)))) + + +(defntrace get-block + [id] + (common-db/get-block @dsdb id)) + + +(defntrace get-parent + [id] + (-> (common-db/get-parent-eid @dsdb id) + get-block)) + + +(defntrace deepest-child-block + [id] + (let [db @dsdb] + (loop [uid (common-db/get-block-uid db id)] + (let [eid [:block/uid uid] + open (-> db (d/entity eid) :block/open) + props? @(rf/subscribe [:feature-flags/enabled? :properties]) + children (if props? + (common-db/sorted-prop+children-uids db eid) + (common-db/get-children-uids db eid))] + (if (or (zero? (count children)) + (not open)) + (common-db/get-block db eid) + (recur (last children))))))) + + +(defntrace search-exact-node-title + [query] + (d/entity @dsdb [:node/title query])) + + +(defn search-in-node-title + ([query] (search-in-node-title query 20 false)) + ([query n] (search-in-node-title query n false)) + ([query n exclude-exact-match?] + (if (string/blank? query) + (vector) + (let [exact-match (when exclude-exact-match? query) + case-insensitive-query (patterns/re-case-insensitive query)] + (sequence + (comp + (filter (every-pred + #(re-find case-insensitive-query (:v %)) + #(not= exact-match (:v %)))) + (take n) + (map #(d/entity @dsdb (:e %)))) + (d/datoms @dsdb :aevt :node/title)))))) + + +(defn get-root-parent-node-from-block + [db {:keys [block/uid] :as block}] + (cond + (= "[[athens/comment]]" (common-db/get-entity-type db [:block/uid uid])) + (let [commented-block (-> block + ;; comments are a child of :comments/threads + :block/_children first + ;; :comments/threads is a prop on the commented block + :block/property-of + ;; get rid of the extra ascendant data on it though + (dissoc :block/_children :block/property-of))] + (assoc block + :block/parent commented-block + :block-search/navigate-uid (:block/uid commented-block))) + + :else + (loop [b block] + (cond + (:node/title b) (assoc block :block/parent b) + (:block/_children b) (recur (first (:block/_children b))) + (:block/property-of b) (recur (:block/property-of b)) + ;; protect against orphaned nodes + :else nil)))) + + +(defn search-in-block-content + ([query] (search-in-block-content query 20)) + ([query n] + (if (string/blank? query) + (vector) + (let [db @dsdb + case-insensitive-query (patterns/re-case-insensitive query) + block-search-result (->> + (d/datoms db :aevt :block/string) + (sequence + (comp + (filter #(re-find case-insensitive-query (:v %))) + (take n) + (map #(:e %)))) + (d/pull-many db '[:db/id :block/uid :block/string :node/title + {:block/_children ...} {:block/property-of ...}]) + (sequence + (comp + (keep (partial get-root-parent-node-from-block db)) + (map #(dissoc % :block/_children :block/property-of)))))] + block-search-result)))) + + +(defn sibling-uids + [uid] + (let [props-enabled? @(rf/subscribe [:feature-flags/enabled? :properties]) + eid (->> [:block/uid uid] + get-parent + :block/uid + (vector :block/uid))] + (if props-enabled? + (common-db/sorted-prop+children-uids @dsdb eid) + (common-db/get-children-uids @dsdb eid)))) + + +(defn nth-sibling + "Find sibling that has relation to current block. + Relation can be :before or :after." + ([uid relation] + (-> (sibling-uids uid) + (order/get relation uid) + (->> (vector :block/uid)) + get-block)) + ([siblings uid relation] + (-> siblings + (order/get relation uid) + (->> (vector :block/uid)) + get-block))) + + +(defntrace prev-block-uid + "If first sibling, go to parent (if not a page). + If block is closed, go to prev sibling. + If block is OPEN, go to prev sibling's deepest child." + [uid] + (let [[uid embed-id] (uid-and-embed-id uid) + siblings (sibling-uids uid) + oldest-sibling? (= uid (first siblings)) + parent (when oldest-sibling? + (get-parent [:block/uid uid])) + parent-type (when parent + (common-db/get-entity-type @dsdb [:block/uid (:block/uid parent)])) + prev-sibling (nth-sibling siblings uid :before) + {:block/keys [open children] + prev-sibling-uid :block/uid} prev-sibling + prev-sibling-children? (seq children) + prev-sibling-type (when prev-sibling-uid + (common-db/get-entity-type @dsdb [:block/uid prev-sibling-uid])) + prev-sibling-task? (= "[[athens/task]]" prev-sibling-type) + deepest-child (when open + (deepest-child-block [:block/uid prev-sibling-uid])) + deepest-child-type (when deepest-child + (common-db/get-entity-type @dsdb [:block/uid (:block/uid deepest-child)])) + prev-block (cond + (and oldest-sibling? + (= "[[athens/task]]" parent-type)) (tasks-db/get-title-block-of-task @dsdb (:block/uid parent)) + oldest-sibling? parent + (and prev-sibling-task? prev-sibling-children?) deepest-child + prev-sibling-task? (tasks-db/get-title-block-of-task @dsdb prev-sibling-uid) + (false? open) prev-sibling + (= "[[athens/task]]" deepest-child-type) (tasks-db/get-title-block-of-task @dsdb (:block/uid deepest-child)) + (true? open) deepest-child) + prev-block-uid (:block/uid prev-block)] + (when (and prev-block-uid + (not (:node/title prev-block))) + (cond-> prev-block-uid + embed-id (str "-embed-" embed-id))))) + + +(defntrace next-sibling-recursively + "Search for next sibling. If not there (i.e. is last child), find sibling of parent. + If parent is root, go to next sibling." + [uid] + (loop [uid uid] + (let [sib (nth-sibling uid :after) + parent (get-parent [:block/uid uid]) + {node :node/title} (get-block [:block/uid uid])] + (if (or sib (:node/title parent) node) + sib + (recur (:block/uid parent)))))) + + +(defn next-block-uid + "1-arity: + if open and children, go to first sibling + else recursively find next sibling of parent + 2-arity: + used for multi-block-selection; ignores child blocks" + ([uid] + (let [[uid embed-id] (uid-and-embed-id uid) + props-enabled? @(rf/subscribe [:feature-flags/enabled? :properties]) + props+children (if props-enabled? + (common-db/sorted-prop+children-uids @dsdb [:block/uid uid]) + (common-db/get-children-uids @dsdb [:block/uid uid])) + {:block/keys [open] + node :node/title} (get-block [:block/uid uid]) + next-block-recursive (next-sibling-recursively uid) + next-entity-type (when next-block-recursive + (common-db/get-entity-type @dsdb [:block/uid (:block/uid next-block-recursive)])) + next-child+prop-uid (first props+children) + next-child+prop-type (when next-child+prop-uid + (common-db/get-entity-type @dsdb [:block/uid next-child+prop-uid])) + next-block (cond + (and next-block-recursive + (= "[[athens/task]]" next-entity-type)) + (tasks-db/get-title-block-of-task @dsdb (:block/uid next-block-recursive)) + + (and next-child+prop-uid + (= "[[athens/task]]" next-child+prop-type)) + (tasks-db/get-title-block-of-task @dsdb next-child+prop-uid) + + (and (or open node) + next-child+prop-uid) + (get-block [:block/uid next-child+prop-uid]) + + next-block-recursive + next-block-recursive)] + #_(log/debug "next-block-uid:" (pr-str {:next-block-recursive-uid (:block/uid next-block-recursive) + :next-entity-type next-entity-type})) + (cond-> (:block/uid next-block) + + ;; only go to next block if it's part of current embed scheme + (and embed-id (js/document.querySelector (str "#editable-uid-" (:block/uid next-block) "-embed-" embed-id))) + (str "-embed-" embed-id)))) + ([uid selection?] + (if selection? + (let [[o-uid embed-id] (uid-and-embed-id uid) + next-block-recursive (next-sibling-recursively o-uid)] + (cond-> (:block/uid next-block-recursive) + + ;; only go to next block if it's part of current embed scheme + (and embed-id (js/document.querySelector (str "#editable-uid-" (:block/uid next-block-recursive) "-embed-" embed-id))) + (str "-embed-" embed-id))) + (next-block-uid uid)))) + + +(defntrace get-sorted-children + [uid db] + (when uid + (try + (->> (d/pull db [{:block/children [:block/uid :block/order :block/open]}] [:block/uid uid]) + sort-block-children + :block/children) + (catch :default _)))) + + +(defn get-first-child-uid + [uid db] + (-> (get-sorted-children uid db) + first + :block/uid)) + + +(defn get-last-child-uid + [parent-uid db] + (let [{:block/keys [uid open]} (-> (get-sorted-children parent-uid db) last)] + (cond + (not uid) parent-uid + open (recur uid db) + :else uid))) + + +;; -- Linked & Unlinked References ---------- + +(defntrace get-ref-ids + [unlinked-f] + (d/q '[:find [?e ...] + :in $ ?unlinked-f + :where + [?e :block/string ?s] + [(?unlinked-f ?s)]] + @dsdb + unlinked-f)) + + +(defn merge-parents-and-block + [ref-ids] + (let [parents (reduce-kv (fn [m _ v] (assoc m v (get-parents-recursively v))) + {} + ref-ids) + blocks (map (fn [id] (get-block id)) ref-ids)] + (mapv + (fn [block] + (merge block {:block/parents (get parents (:db/id block))})) + blocks))) + + +(defn group-by-parent + [blocks] + (group-by (fn [x] + (let [parent (-> x + :block/parents + first)] + [(:node/title parent) (->> parent :block/edits (map (comp :time/ts :event/time)) sort last (or 0))])) + blocks)) + + +(defn eids->groups + [eids] + (->> eids + merge-parents-and-block + group-by-parent + (sort-by #(-> % first second)) + (map #(vector (ffirst %) (second %))) + vec + rseq)) + + +(defntrace get-unlinked-references + "For node-page references UI." + [title] + (-> (partial patterns/contains-unlinked? title) get-ref-ids merge-parents-and-block group-by-parent seq)) + + +;; -- save ------------------------------------------------------------ -(defonce rfdb {:user "Jeff" - :current-route nil - :loading true - :errors {}}) -(defonce dsdb (d/create-conn schema)) -(re-posh/connect! dsdb) \ No newline at end of file +(defn transact-state-for-uid + "uid -> Current block + new-string -> new `:block/string` value + source -> reporting source" + [uid new-string source] + (rf/dispatch [:block/save {:uid uid + :string new-string + :source source}])) diff --git a/src/cljs/athens/devcards.cljs b/src/cljs/athens/devcards.cljs deleted file mode 100644 index cf933fa19c..0000000000 --- a/src/cljs/athens/devcards.cljs +++ /dev/null @@ -1,53 +0,0 @@ -(ns athens.devcards - (:require - [cljsjs.react] - [cljsjs.react.dom] - [reagent.core :as r :include-macros true] - [devcards.core :as devcards :include-macros true :refer [defcard]])) - -(def bmi-data (r/atom {:height 180 :weight 80})) - -(defn calc-bmi [bmi-data] - (let [{:keys [height weight bmi] :as data} bmi-data - h (/ height 100)] - (if (nil? bmi) - (assoc data :bmi (/ weight (* h h))) - (assoc data :weight (* bmi h h))))) - -(defn slider [bmi-data param value min max] - [:input {:type "range" :value value :min min :max max - :style {:width "100%"} - :on-change (fn [e] - (swap! bmi-data assoc param (.. e -target -value)) - (when (not= param :bmi) - (swap! bmi-data assoc :bmi nil)))}]) - -(defn bmi-component [bmi-data] - (let [{:keys [weight height bmi]} (calc-bmi @bmi-data) - [color diagnose] (cond - (< bmi 18.5) ["orange" "underweight"] - (< bmi 25) ["inherit" "normal"] - (< bmi 30) ["orange" "overweight"] - :else ["red" "obese"])] - [:div - [:h3 "BM calculator"] - [:div - "Height: " (int height) "cm" - [slider bmi-data :height height 100 220]] - [:div - "Weight: " (int weight) "kg" - [slider bmi-data :weight weight 30 150]] - [:div - "BMI: " (int bmi) " " - [:span {:style {:color color}} diagnose] - [slider bmi-data :bmi bmi 10 50]]])) - -(defcard bmi-calculator - "*Code taken from the Reagent readme.*" - (devcards/reagent bmi-component) - bmi-data - {:inspect-data true - :frame true - :history true}) - -(defn ^:export main [] (devcards.core/start-devcard-ui!)) \ No newline at end of file diff --git a/src/cljs/athens/effects.cljs b/src/cljs/athens/effects.cljs new file mode 100644 index 0000000000..f6dcc3114e --- /dev/null +++ b/src/cljs/athens/effects.cljs @@ -0,0 +1,216 @@ +(ns athens.effects + (:require + [athens.async :as async] + [athens.common-db :as common-db] + [athens.common-events.schema :as schema] + [athens.common.logging :as log] + [athens.common.sentry :refer-macros [wrap-span-no-new-tx]] + [athens.db :as db] + [athens.reactive :as reactive] + [athens.self-hosted.client :as client] + [cljs-http.client :as http] + [cljs.core.async :refer [go ws-url + client/new-ws-client + component/start))) + (fn [] + (log/warn ":remote/client-connect! health-check failure") + (rf/dispatch [:remote/connection-failed]) + (rf/dispatch [:stage/fail-db-load]))))) + + +(rf/reg-fx + :remote/client-disconnect! + (fn [] + (log/debug ":remote/client-disconnect!") + (when @self-hosted-client + (component/stop @self-hosted-client) + (reset! self-hosted-client nil)))) + + +(rf/reg-fx + :remote/send-event-fx! + (fn [event] + (if (schema/valid-event? event) + ;; valid event let's send it + (do + (log/debug "Sending event:" (pr-str event)) + (let [ret (client/send! event)] + (when (= :rejected (:result ret)) + (rf/dispatch [:remote/reject-forwarded-event event]) + (log/warn "Tried to send invalid event. Error:" (pr-str (:reason ret)))))) + (let [explanation (-> schema/event + (m/explain event) + (me/humanize))] + (log/warn "Tried to send invalid event. Error:" (pr-str explanation)))))) + + +(rf/reg-fx + :invoke-callback + (fn [callback] + (log/debug "Invoking callback") + (callback))) diff --git a/src/cljs/athens/electron/boot.cljs b/src/cljs/athens/electron/boot.cljs new file mode 100644 index 0000000000..3f255537c8 --- /dev/null +++ b/src/cljs/athens/electron/boot.cljs @@ -0,0 +1,125 @@ +(ns athens.electron.boot + (:require + [athens.common.sentry :refer-macros [wrap-span-no-new-tx]] + [athens.db :as db] + [athens.electron.db-picker :as db-picker] + [athens.electron.utils :as utils] + [athens.router :as router] + [athens.utils.sentry :as sentry] + [re-frame.core :as rf])) + + +(rf/reg-event-fx + :boot + [(rf/inject-cofx :local-storage :athens/persist)] + (fn [{:keys [local-storage]} [_ first-boot?]] + (let [boot-tx (sentry/transaction-start "boot-sequence") + param-db (when-let [graph-params (router/consume-graph-params)] + (apply utils/self-hosted-db graph-params)) + init-app-db (cond-> + ;; Init it from local storage. + (wrap-span-no-new-tx "db/init-app-db" (db/init-app-db local-storage)) + ;; Select the db in id-param if there. + param-db (db-picker/add-and-select param-db)) + all-dbs (db-picker/all-dbs init-app-db) + selected-db (db-picker/selected-db init-app-db) + default-db (utils/get-default-db) + selected-db-exists? (utils/db-exists? selected-db) + default-db-exists? (utils/db-exists? default-db) + first-event (cond + ;; DB is in-memory, just create a new one. + (utils/in-memory-db? selected-db) + [:create-in-memory-conn] + + ;; DB is remote, attempt to connect to it. + (utils/remote-db? selected-db) + [:remote/connect! selected-db] + + ;; No selected db but there are dbs listed. + ;; Load the first one. + (and (not selected-db-exists?) + (seq all-dbs)) + [:fs/add-read-and-watch (-> all-dbs first second)] + + ;; Selected db not found in local storage, but default db found. + ;; Add default db and load it. + (and (not selected-db-exists?) + default-db-exists?) + [:fs/add-read-and-watch default-db] + + ;; Selected db not found in local storage, no default db found. + ;; Create new db and load it. + (and (not selected-db-exists?) + (not default-db-exists?)) + [:fs/create-and-watch default-db] + + ;; Selected found in local storage and on filesystem. + ;; Load it. + selected-db-exists? + [:fs/read-and-watch selected-db] + + ;; Selected db found in local storage but not on filesystem, or no matching condition. + ;; Open open-dialog. + :else [:fs/open-dialog selected-db])] + + + ;; output => [:reset-conn] OR [:fs/create-and-watch] + {:db init-app-db + :dispatch-n [[:theme/set] + [:loading/set]] + :async-flow {:id :boot-async-flow + :db-path [:async-flow :boot/desktop] + :first-dispatch first-event + :rules [;; if first time, go to Daily Pages and open left-sidebar + {:when :seen? + :events :fs/create-and-watch + :dispatch-n [[:left-sidebar/toggle]]} + + ;; if nth time, remember dark/light theme + {:when :seen? + :events :stage/success-db-load + :dispatch-n [[:fs/update-write-db] + [:db/sync] + ;; [:restore-navigation] ; This functionality is there but unreliable we can use it once we make it reliable + [:reset-undo-redo] + ;; Only init the router after the db + ;; is loaded, otherwise we can't check + ;; if titles/uids in the URL exist. + [:init-routes!] + (when-not first-boot? + ;; Go to home on graph change, but not + ;; on the first boot. + ;; We might have a permalink to follow + ;; on first boot. + [:navigate :home]) + [:posthog/set-super-properties] + [:loading/unset]]} + {:when :seen-all-of? + :events [[:fs/update-write-db] + [:db/sync] + [:reset-undo-redo] + [:posthog/set-super-properties] + [:loading/unset]] + :dispatch [:sentry/end-tx boot-tx] + :halt? true} + + ;; halt when started connecting to remote + {:when :seen? + :events [:stage/fail-db-load] + :dispatch-n [[:posthog/set-super-properties] + [:loading/unset] + [:sentry/end-tx boot-tx]] + :halt? true} + + ;; whether first or nth time, update athens pages + #_{:when :seen-any-of? + :events [:fs/create-and-watch :reset-conn] + :dispatch-n [[:db/retract-athens-pages] + [:db/transact-athens-pages]]} + + ;; bind windows toolbar electron buttons + #_{:when :seen-any-of? + :events [:fs/create-and-watch :reset-conn] + :dispatch [:bind-win-listeners]}]}}))) + + diff --git a/src/cljs/athens/electron/core.cljs b/src/cljs/athens/electron/core.cljs new file mode 100644 index 0000000000..da0bee7823 --- /dev/null +++ b/src/cljs/athens/electron/core.cljs @@ -0,0 +1,28 @@ +(ns athens.electron.core + (:require + [athens.electron.boot] + [athens.electron.db-picker] + [athens.electron.fs] + [athens.electron.monitoring.core] + [athens.electron.window] + [day8.re-frame.async-flow-fx] + [re-frame.core :as rf])) + + +;; XXX: most of these operations are effectful. They _should_ be re-written with effects, but feels like too much boilerplate. + +;; Subs + +(rf/reg-sub + :db/mtime + (fn [db _] + (:db/mtime db))) + + +;; This event isn't used at the moment in boot. Figure out if it's still needed next +;; time boot is revisited. +#_(rf/reg-event-fx + :db/retract-athens-pages + (fn [] + {:dispatch [:transact (concat (db/retract-page-recursively "Welcome") + (db/retract-page-recursively "Changelog"))]})) diff --git a/src/cljs/athens/electron/db_menu/core.cljs b/src/cljs/athens/electron/db_menu/core.cljs new file mode 100644 index 0000000000..7a6a98308d --- /dev/null +++ b/src/cljs/athens/electron/db_menu/core.cljs @@ -0,0 +1,110 @@ +(ns athens.electron.db-menu.core + (:require + ["@chakra-ui/react" :refer [Box IconButton Spinner Text Tooltip Heading VStack ButtonGroup PopoverTrigger ButtonGroup Popover PopoverContent Portal Button]] + [athens.electron.db-menu.db-icon :refer [db-icon]] + [athens.electron.db-menu.db-list-item :refer [db-list-item]] + [athens.electron.dialogs :as dialogs] + [athens.electron.utils :as electron.utils] + [athens.import.roam :as import.roam] + [re-frame.core :refer [dispatch subscribe]] + [reagent.core :as r])) + + +;; Components + +(defn current-db-tools + ([{:keys [db]} all-dbs merge-open?] + (when-not (:is-remote db) + [:> ButtonGroup {:size "xs" + :pr 4 + :pl 10 + :ml "auto" + :width "100%"} + (when electron.utils/electron? [:> Button {:onClick #(dialogs/move-dialog!)} "Move"]) + [:> Button {:mr "auto" + :onClick #(reset! merge-open? true)} "Merge from Roam"] + (when-not (= :in-memory (:type db)) + [:> Tooltip {:label "Can't remove the last workspace" + :placement "right" + :isDisabled (< 1 (count all-dbs))} + [:> Button {:isDisabled (= 1 (count all-dbs)) + :onClick #(dialogs/delete-dialog! db)} + "Remove"]])]))) + + +(defn db-menu + [] + (let [all-dbs @(subscribe [:db-picker/all-dbs]) + merge-open? (r/atom false) + active-db @(subscribe [:db-picker/selected-db]) + inactive-dbs (dissoc all-dbs (:id active-db)) + sync-status (if @(subscribe [:db/synced]) + :running + :synchronising)] + [:<> + [import.roam/merge-modal merge-open?] + [:> Popover {:placement "bottom-start" + :isLazy true} + [:> PopoverTrigger + [:> IconButton {:p 0 + :variant "ghost" + :aria-label "Workspaces menu"} + ;; DB Icon + Dropdown toggle + [db-icon {:db active-db + :status sync-status}]]] + ;; Dropdown menu + [:> Portal + [:> PopoverContent {:overflow-y "auto"} + [:> VStack {:align "stretch" + :overflow "hidden" + :spacing 0} + ;; Show active DB first + [:> Box {:bg "background.floor" + :pb 4} + [db-list-item {:db active-db + :is-current true + :key (:id active-db)}] + [current-db-tools {:db active-db} all-dbs merge-open?]] + ;; Show all inactive DBs and a separator + [:> Heading {:fontSize "xs" + :py 4 + :pb 3 + :borderTop "1px solid" + :borderTopColor "separator.divider" + :px 10 + :letterSpacing "wide" + :textTransform "uppercase" + :fontWeight "bold" + :color "foreground.secondary"} + "Other workspaces"] + [:> VStack {:align "stretch" + :position "relative" + :spacing 0 + :overflow-y "auto"} + (doall + (for [[key db] inactive-dbs] + [db-list-item {:db db + :is-disabled (= sync-status :synchronising) + :is-current false + :key key}])) + (when (= :synchronising sync-status) + [:> VStack {:align "center" + :background "background.vibrancy" + :backdropFilter "blur(0.25ch)" + :justify "center" + :position "absolute" + :inset 0} + [:> Spinner] + [:> Text "Syncing..."]])] + ;; Add DB control + [:> ButtonGroup {:borderTop "1px solid" + :borderTopColor "separator.divider" + :p 2 + :pt 0 + :pl 10 + :size "sm" + :width "100%" + :ml 10 + :justifyContent "flex-start"} + [:> Button {:onClick #(dispatch [:modal/toggle])} + "Add Workspace"]]]]]]])) diff --git a/src/cljs/athens/electron/db_menu/db_icon.cljs b/src/cljs/athens/electron/db_menu/db_icon.cljs new file mode 100644 index 0000000000..a53d7a0a1e --- /dev/null +++ b/src/cljs/athens/electron/db_menu/db_icon.cljs @@ -0,0 +1,37 @@ +(ns athens.electron.db-menu.db-icon + (:require + ["@chakra-ui/react" :refer [Box]] + [athens.electron.db-menu.status-indicator :refer [status-indicator]])) + + +(defn db-icon + [{:keys [db status]}] + [:> Box {:class "icon" + :position "relative" + :flex "0 0 auto" + :width "1.75em" + :height "1.75em" + :sx {"text" {:fontSize "16px"}}} + [:> Box {:as "svg" + :viewBox "0 0 24 24" + :margin 0} + [:> Box + {:as "rect" + :fill "var(--link-color)" + :height "100%" + :width "100%" + :rx "4" + :x "0" + :y "0"}] + [:> Box + {:as "text" + :fill "white" + :fontSize "100%" + :fontWeight "bold" + :textAnchor "middle" + :vectorEffect "non-scaling-stroke" + :style {:text-transform "uppercase"} + :x "50%" + :y "75%"} + (nth (:name db) 0)]] + (when (and status (not= status :running)) [status-indicator {:status status}])]) diff --git a/src/cljs/athens/electron/db_menu/db_list_item.cljs b/src/cljs/athens/electron/db_menu/db_list_item.cljs new file mode 100644 index 0000000000..948f60cba9 --- /dev/null +++ b/src/cljs/athens/electron/db_menu/db_list_item.cljs @@ -0,0 +1,99 @@ +(ns athens.electron.db-menu.db-list-item + (:require + ["/components/Icons/Icons" :refer [XmarkIcon]] + ["@chakra-ui/react" :refer [VStack Box Flex Text Button IconButton]] + [athens.electron.db-menu.db-icon :refer [db-icon]] + [athens.electron.dialogs :as dialogs] + [re-frame.core :refer [dispatch]])) + + +(defn active-db + [{:keys [db]}] + [:> Flex {:gap 2 + :p 2 + :borderRadius "none" + :whiteSpace "nowrap" + :height "auto" + :align "stretch" + :background "transparent" + :justifyContent "stretch" + :textAlign "left"} + [db-icon {:db db}] + [:> VStack {:align "stretch" + :flex "1 1 100%" + :overflow "hidden" + :spacing 0 + :textOverflow "ellipsis"} + [:> Text {:textOverflow "ellipsis" + :overflow "hidden" + :fontWeight "bold"} + (:name db)] + [:> Text {:textOverflow "ellipsis" + :fontSize "sm" + :color "foreground.secondary" + :overflow "hidden" + :title (:id db)} + (:id db)]]]) + + +(defn db-item + [{:keys [db on-click on-remove is-disabled]}] + [:> Box {:display "grid" + :_notFirst {:borderTopWidth "1px" + :borderTopStyle "solid" + :borderTopColor "separator.divider"} + :gridTemplateAreas "'main'"} + [:> Button {:onClick (when on-click on-click) + :isDisabled (or is-disabled (not on-click)) + :gridArea "main" + :whiteSpace "nowrap" + :bg "transparent" + :display "flex" + :gap 2 + :py 2 + :pr 10 + :borderRadius "none" + :height "auto" + :align "stretch" + :justifyContent "stretch" + :_focusVisible {:boxShadow "focusInset"} + :textAlign "left"} + [db-icon {:db db}] + [:> VStack {:align "stretch" + :flex "1 1 100%" + :spacing 1 + :overflow "hidden" + :textOverflow "ellipsis"} + [:> Text {:textOverflow "ellipsis" + :fontWeight "bold" + :overflow "hidden"} (:name db)] + [:> Text {:textOverflow "ellipsis" + :size "sm" + :color "foreground.secondary" + :overflow "hidden" + :title (:id db)} + (:id db)]]] + (when on-remove + [:> IconButton + {:onClick on-remove + :gridArea "main" + :alignSelf "center" + :justifySelf "flex-end" + :size "sm" + :mr 2 + :bg "transparent"} + [:> XmarkIcon]])]) + + +(defn db-list-item + [{:keys [db is-current is-disabled]}] + (let [remove-db-click-handler (fn [e] + (dialogs/delete-dialog! db) + (.. e stopPropagation))] + (if is-current + [active-db {:db db}] + [db-item {:db db + :is-disabled is-disabled + :on-click #(dispatch [:db-picker/select-db db]) + :on-remove (when-not (= :in-memory (:type db)) remove-db-click-handler)}]))) + diff --git a/src/cljs/athens/electron/db_menu/status_indicator.cljs b/src/cljs/athens/electron/db_menu/status_indicator.cljs new file mode 100644 index 0000000000..c265a81c33 --- /dev/null +++ b/src/cljs/athens/electron/db_menu/status_indicator.cljs @@ -0,0 +1,35 @@ +(ns athens.electron.db-menu.status-indicator + (:require + ["/components/Icons/Icons" :refer [CheckmarkCircleFillIcon ExclamationCircleFillIcon]] + ["@chakra-ui/react" :refer [Box Tooltip Spinner]])) + + +(defn status-indicator + [{:keys [status]}] + [:> Box {:p 0 + :m 0 + :color (cond + (:closed status) "error" + (:running status) "foreground.primary" + :else "foreground.secondary") + :fontSize "1em" + :height "1em" + :width "1em" + :transform "translate(25%, 25%)" + :position "absolute" + :bottom 0 + :right 0 + :borderRadius "full" + :sx {"svg" {:fontSize "1em" + :background "background.floor" + :borderRadius "full"}}} + (cond + (= status :closed) [:> Tooltip + {:label "Disconnected"} + [:> ExclamationCircleFillIcon]] + (= status :running) [:> Tooltip + {:label "Synced"} + [:> CheckmarkCircleFillIcon]] + :else [:> Tooltip + {:label "Synchronizing..."} + [:> Spinner {:emptyColor "background.vibrancy" :speed "2s" :size "xs"}]])]) diff --git a/src/cljs/athens/electron/db_modal.cljs b/src/cljs/athens/electron/db_modal.cljs new file mode 100644 index 0000000000..86e8d59275 --- /dev/null +++ b/src/cljs/athens/electron/db_modal.cljs @@ -0,0 +1,133 @@ +(ns athens.electron.db-modal + (:require + ["@chakra-ui/react" :refer [HStack VStack FormControl FormLabel Input Button Box Tabs Tab TabList TabPanel TabPanels Text Modal ModalOverlay VStack ModalContent ModalHeader ModalFooter ModalBody ModalCloseButton ButtonGroup]] + [athens.electron.dialogs :as dialogs] + [athens.electron.utils :as utils] + [athens.subs] + [athens.util :refer [js-event->val]] + [re-frame.core :refer [subscribe dispatch] :as rf] + [reagent.core :as r])) + + +(defn form-container + [content footer] + [:> Box {:as "form" + :display "contents"} + [:> Box {:p 5 :pt 4} + content] + [:> ModalFooter {:borderTop "1px solid" + :borderColor "separator.divider" + :p 2 + :pr 5} footer]]) + + +(defn open-local-comp + [loading db] + [form-container + [:> FormControl {:isReadOnly true} + [:> FormLabel (if @loading + "No DB Found At" + "Current workspace location")] + [:> HStack + [:> Text {:as "output" + :borderRadius "md" + :cursor "default" + :bg "background.floor" + :color "foreground.secondary" + :flex "1 1 100%" + :py 1.5 + :px 2.5 + :display "flex"} + (:id db)] + [:> Button {:isDisabled @loading + :size "sm" + :onClick #(dialogs/move-dialog!)} + "Move"]]] + [:> ButtonGroup + [:> Button {:onClick #(dialogs/open-dialog!)} + "Open from file"]]]) + + +(defn create-new-local + [state] + [form-container + [:> FormControl + [:> FormLabel "Name"] + [:> Input {:onChange #(swap! state assoc :input (js-event->val %))}]] + [:> ButtonGroup + [:> Button {:value (:input @state) + :isDisabled (clojure.string/blank? (:input @state)) + :onClick #(dialogs/create-dialog! (:input @state))} + "Choose folder"]]]) + + +(defn join-remote-comp + [] + (let [name (r/atom "") + address (r/atom "") + password (r/atom "")] + (fn [] + [form-container + (->> + [:> VStack {:spacing 4} + [:> FormControl + [:> FormLabel "Workspace name"] + [:> Input {:defaultValue @name + :onChange #(reset! name (js-event->val %))}]] + [:> FormControl + [:> FormLabel "Remote address"] + [:> Input {:defaultValue @address + :onChange #(reset! address (js-event->val %))}]] + [:> FormControl {:flexDirection "row"} + [:> FormLabel "Password"] + [:> Input {:defaultValue @password + :type "password" + :onChange #(reset! password (js-event->val %))}]]] + doall) + [:> ButtonGroup + [:> Button {:type "submit" + :isDisabled (or (clojure.string/blank? @name) + (clojure.string/blank? @address)) + :onClick #(rf/dispatch [:db-picker/add-and-select-db (utils/self-hosted-db @name @address @password)])} + "Join"]]]))) + + +(defn window + "If loading is true, then that means the user has opened the modal and the db was not found on the filesystem. + If loading is false, do not allow user to exit modal, and show slightly different UI." + [] + (let [loading (subscribe [:loading?]) + close-modal (fn [] + (when-not @loading + (dispatch [:modal/toggle]))) + selected-db @(subscribe [:db-picker/selected-db]) + state (r/atom {:input ""})] + (fn [] + [:> Modal {:isOpen loading + :motionPreset "scale" + :onClose close-modal} + [:> ModalOverlay] + [:> ModalContent + [:> ModalHeader + "Add Workspace"] + (when-not @loading + [:> ModalCloseButton]) + [:> ModalBody {:display "contents"} + ;; TODO: this is hacky, we're just hiding the picker and forcing + ;; tab 2 for the web client. Instead we should use Stuart's + ;; redesigned DB picker. + [:> Tabs {:isFitted true + :display "contents" + :defaultIndex (if utils/electron? 0 1)} + (when utils/electron? + [:> TabList {:px 2} + [:> Tab "Open from file"] + [:> Tab "Join remote "] + [:> Tab "Create new"]]) + [:> TabPanels {:display "contents"} + [:> TabPanel {:display "contents"} + [open-local-comp loading selected-db]] + [:> TabPanel {:display "contents"} + [join-remote-comp]] + [:> TabPanel {:display "contents"} + [create-new-local state]]]]]]]))) diff --git a/src/cljs/athens/electron/db_picker.cljs b/src/cljs/athens/electron/db_picker.cljs new file mode 100644 index 0000000000..1deade3472 --- /dev/null +++ b/src/cljs/athens/electron/db_picker.cljs @@ -0,0 +1,134 @@ +(ns athens.electron.db-picker + (:require + [athens.electron.fs] + [athens.electron.utils :as utils] + [athens.electron.window] + [re-frame.core :as rf])) + + +(defn all-dbs + [db] + (-> db :athens/persist :db-picker/all-dbs)) + + +(defn selected-db + [db] + (when-let [selected-db-id (-> db :athens/persist :db-picker/selected-db-id)] + (get-in db [:athens/persist :db-picker/all-dbs selected-db-id]))) + + +(defn select-db + [db id] + (assoc-in db [:athens/persist :db-picker/selected-db-id] id)) + + +(defn remote-db? + [rfdb] + (-> rfdb + selected-db + utils/remote-db?)) + + +(defn add-db + [rfdb {:keys [id] :as db}] + (assoc-in rfdb [:athens/persist :db-picker/all-dbs id] db)) + + +(defn contains-db? + [rfdb id] + (get-in rfdb [:athens/persist :db-picker/all-dbs id])) + + +(defn add-and-select + [rfdb {:keys [id] :as db}] + (cond-> rfdb + (not (contains-db? rfdb id)) (add-db db) + true (select-db id))) + + +(rf/reg-sub + :db-picker/all-dbs + (fn [db _] + (all-dbs db))) + + +(rf/reg-sub + :db-picker/selected-db + (fn [db _] + (selected-db db))) + + +(rf/reg-sub + :db-picker/remote-db? + (fn [db _] + (remote-db? db))) + + +;; Add a db to the db picker list and select it as the current db. +;; Adding a db with the same id will overwrite the previous one. +(rf/reg-event-fx + :db-picker/add-and-select-db + (fn [{:keys [db]} [_ added-db]] + {:db (add-db db added-db) + :dispatch [:db-picker/select-db added-db]})) + + +;; Select a db from the all db list and reboot the app into it. +;; If the db is no longer in the db picker, alert the user to add it again, +;; If the selected db is deleted from disk then show an alert describing the +;; situation and remove this db from db list. +;; Unless ignore-sync-check? is true, prevent selecting another db when sync +;; is happening and instead shows an alert. +(rf/reg-event-fx + :db-picker/select-db + (fn [{:keys [db]} [_ {:keys [id] :as target-db} ignore-sync-check?]] + (let [synced? (or ignore-sync-check? (:db/synced db)) + curr-selected-db (selected-db db) + db-exists? (utils/db-exists? target-db)] + (cond + (not synced?) + {:dispatch [:alert/js "Athens is saving your changes, if you switch now your changes will not be saved."]} + + db-exists? + {:db (select-db db id) + :dispatch-n [(when (utils/remote-db? curr-selected-db) + [:remote/disconnect!]) + [:boot]]} + + :else + {:dispatch-n [[:alert/js "This workspace does not exist. It will be removed from the list."] + [:db-picker/remove-db target-db]]})))) + + +(rf/reg-event-fx + :db-picker/select-most-recent-db + (fn [{:keys [db]} [_]] + ;; TODO: this is just getting the first one, not the most recent + (let [most-recent-db (second (first (get-in db [:athens/persist :db-picker/all-dbs])))] + {:dispatch (if most-recent-db + [:db-picker/select-db most-recent-db true] + [:fs/open-dialog])}))) + + +(rf/reg-event-fx + :db-picker/select-default-db + (fn [_ [_]] + {:dispatch [:db-picker/add-and-select-db (utils/get-default-db)]})) + + +;; Delete a db from the db-picker. +(rf/reg-event-fx + :db-picker/remove-db + (fn [{:keys [db]} [_ {:keys [id]}]] + (let [new-db (update-in db [:athens/persist :db-picker/all-dbs] dissoc id)] + {:db new-db + ;; reboot, and run default bd logic, when removing the db leaves db picker empty + :dispatch-n [(when (empty? (all-dbs new-db)) [:boot])]}))) + + +;; Select no db, leave it to the boot sequence to decide what to do. +(rf/reg-event-fx + :db-picker/remove-selection + (fn [{:keys [db]} [_]] + {:db (update-in db [:athens/persist] dissoc :db-picker/selected-db-id) + :dispatch [:boot]})) diff --git a/src/cljs/athens/electron/dialogs.cljs b/src/cljs/athens/electron/dialogs.cljs new file mode 100644 index 0000000000..63d8af2849 --- /dev/null +++ b/src/cljs/athens/electron/dialogs.cljs @@ -0,0 +1,109 @@ +(ns athens.electron.dialogs + (:require + [athens.electron.utils :as utils] + [re-frame.core :as rf])) + + +(rf/reg-event-fx + :fs/open-dialog + (fn [_ {:keys [location]}] + (js/alert (str (if location + (str "No DB found at " location ".") + "No DB found.") + "\nPlease open or create a new db.")) + {:dispatch-n [[:modal/toggle]]})) + + +(defn graph-already-exists-alert + [{:keys [base-dir name]}] + (js/alert (str "Directory " base-dir " already contains the " name " graph, sorry."))) + + +(def open-dir-opts + (clj->js (when utils/electron? + {:defaultPath (utils/default-dbs-dir) + :properties ["openDirectory"]}))) + + +(defn move-dialog! + "If new-dir/athens already exists, no-op and alert user. + Else copy db to new db location. When there is an images folder, copy /images folder and all images. + file:// image urls in block/string don't get updated, so if original images are deleted, links will be broken." + [] + (let [res (.showOpenDialogSync (utils/dialog) open-dir-opts) + new-dir (first res)] + (when new-dir + (let [{name :name + curr-images-dir :images-dir + curr-db-path :db-path + curr-base-dir :base-dir + :as curr-db} @(rf/subscribe [:db-picker/selected-db]) + ;; Merge the new local db info into the current db to utils/preserve any other information there. + {new-base-dir :base-dir + new-images-dir :images-dir + new-db-path :db-path + :as new-db} (merge curr-db (utils/local-db (.resolve (utils/path) new-dir name)))] + (if (utils/local-db-dir-exists? new-db) + (graph-already-exists-alert new-db) + (do (.mkdirSync (utils/fs) new-base-dir) + (.copyFileSync (utils/fs) curr-db-path new-db-path) + (when (.existsSync (utils/fs) curr-images-dir) + (.mkdirSync (utils/fs) new-images-dir) + (let [imgs (->> (.readdirSync (utils/fs) curr-images-dir) + array-seq + (map (fn [x] + [(.join (utils/path) curr-images-dir x) + (.join (utils/path) new-images-dir x)])))] + (doseq [[curr new] imgs] + (.copyFileSync (utils/fs) curr new)))) + (.rmSync (utils/fs) curr-base-dir #js {:recursive true :force true}) + (rf/dispatch [:db-picker/remove-db curr-db]) + (rf/dispatch [:db-picker/add-and-select-db new-db]))))))) + + +(defn open-dialog! + "Allow user to open workspace elsewhere from filesystem." + [] + (let [res (.showOpenDialogSync (utils/dialog) open-dir-opts) + db-location (first res)] + (when (and db-location (.existsSync (utils/fs) db-location)) + (rf/dispatch [:db-picker/add-and-select-db (utils/local-db db-location)])))) + + +(defn create-dialog! + "Create a new workspace" + [db-name] + (let [res (.showOpenDialogSync (utils/dialog) open-dir-opts) + db-location (first res)] + (when (and db-location (not-empty db-name)) + (let [base-dir (.resolve (utils/path) db-location db-name) + local-db (utils/local-db base-dir)] + (if (utils/local-db-dir-exists? local-db) + (graph-already-exists-alert local-db) + (rf/dispatch [:fs/create-and-watch local-db])))))) + + +(defn- delete-msg-prompt + [{:keys [name base-dir url] :as db}] + (let [remote-db? (utils/remote-db? db) + part-1 (str "Remove \"" name "\"?\n\n") + part-2 (if remote-db? + (str "The data will still be available at " url ".") + (str "The files will still be available at \"" base-dir "\"."))] + (str part-1 part-2))) + + +(defn delete-dialog! + "Delete an existing workspace. Select the first db of the remaining ones if user is deleting the currently selected db." + [{:keys [id] :as db}] + (let [remote-db? (utils/remote-db? db) + confirmation-msg (delete-msg-prompt db) + current-db-id (-> @(rf/subscribe [:db-picker/selected-db]) + :id) + delete-current-db? (= id current-db-id)] + (when (.confirm js/window confirmation-msg) + (when remote-db? + (rf/dispatch [:remote/disconnect!])) + (rf/dispatch [:db-picker/remove-db db]) + (when delete-current-db? + (rf/dispatch [:db-picker/select-most-recent-db]))))) diff --git a/src/cljs/athens/electron/fs.cljs b/src/cljs/athens/electron/fs.cljs new file mode 100644 index 0000000000..bc2bba6f1d --- /dev/null +++ b/src/cljs/athens/electron/fs.cljs @@ -0,0 +1,186 @@ +(ns athens.electron.fs + (:require + [athens.athens-datoms :as athens-datoms] + [athens.common-db :as common-db] + [athens.common-events.resolver.atomic :as atomic-resolver] + [athens.db :as db] + [athens.electron.utils :as utils] + [athens.interceptors :as interceptors] + [datascript.transit :as dt] + [goog.functions :refer [debounce]] + [re-frame.core :as rf])) + + +(declare write-bkp) + + +(defn sync-db-from-fs + "If modified time is newer, update app-db with m-time. Prevents sync happening after db is written from the app." + [filepath _filename] + (let [prev-mtime @(rf/subscribe [:db/mtime]) + curr-mtime (try (.-mtime (.statSync (utils/fs) filepath)) + (catch :default _)) + newer? (< prev-mtime curr-mtime)] + (when (and prev-mtime curr-mtime newer?) + (let [block-text js/document.activeElement.value + ;; TODO we should not interact with clipboard without clear user interaction, + ;; this is most common error we see right now in Sentry + ;; It would be better to store below information in re-frame + _ (.. js/navigator -clipboard (writeText block-text)) + _ (write-bkp) + confirm (js/window.confirm (str "New file found. Copying your current block to the clipboard, and saving your current db." + "\n\n" + "Accept changes?"))] + (when confirm + (rf/dispatch [:db/update-mtime curr-mtime]) + (let [read-db (.readFileSync (utils/fs) filepath) + db (dt/read-transit-str read-db)] + (rf/dispatch [:reset-conn db]))))))) + + +(def debounce-sync-db-from-fs + (debounce sync-db-from-fs 1000)) + + +;; Watches directory that db is located in. If db file is updated, sync-db-from-fs. +;; Watching db file directly doesn't always work, so watch directory and regex match. +;; Debounce because files can be changed multiple times per save. +;; Adding a new watcher removes the previous one. +(rf/reg-event-fx + :fs/watch + (fn [{:keys [db]} [_ filepath]] + (let [old-watcher (:fs/watcher db) + dirpath (.dirname (utils/path) filepath) + new-watcher (.. (utils/fs) (watch dirpath (fn [_event filename] + ;; when filename matches last part of filepath + ;; e.g. "first-db.transit" matches "home/u/Documents/athens/first-db.transit" + (when (re-find #"conflict" (or filename "")) + (throw "Conflict file created by Dropbox")) + (when (re-find (re-pattern (str "\\b" filename "$")) filepath) + (debounce-sync-db-from-fs filepath filename)))))] + (when old-watcher (.close old-watcher)) + {:db (assoc db :fs/watcher new-watcher)}))) + + +(rf/reg-event-fx + :fs/create-and-watch + (fn [_ [_ {:keys [base-dir images-dir db-path] :as local-db}]] + (let [conn (common-db/create-conn)] + (doseq [[_id data] athens-datoms/welcome-events] + (atomic-resolver/resolve-transact! conn data)) + (utils/create-dir-if-needed! base-dir) + (utils/create-dir-if-needed! images-dir) + (.writeFileSync (utils/fs) db-path (dt/write-transit-str @conn)) + {:dispatch [:db-picker/add-and-select-db local-db]}))) + + +(rf/reg-event-fx + :fs/read-and-watch + [(interceptors/sentry-span-no-new-tx "fs/read-and-watch")] + (fn [_ [_ {:keys [db-path]}]] + (let [datoms (-> (.readFileSync (utils/fs) db-path) + dt/read-transit-str)] + {:async-flow {:id :fs-read-and-watch-async-flow + :db-path [:async-flow :fs/read-and-watch] + :first-dispatch [:reset-conn datoms] + :rules [{:when :seen? + :events :success-reset-conn + :dispatch-n [[:fs/watch db-path] + [:stage/success-db-load]] + :halt? true}]}}))) + + +(rf/reg-event-fx + :fs/add-read-and-watch + (fn [_ [_ local-db]] + {:dispatch [:db-picker/add-and-select-db local-db]})) + + +(rf/reg-event-db + :db/update-mtime + (fn [db [_ mtime1]] + (let [{:db/keys [filepath]} db + mtime (or mtime1 (.. (utils/fs) (statSync filepath) -mtime))] + (assoc db :db/mtime mtime)))) + + +;; Effects + +(defn os-username + [] + (.. (utils/os) userInfo -username)) + + +(defn write-db + "Tries to create a write utils/stream to {timestamp}-index.transit.bkp. Then tries to copy backup to index.transit. + If the write operation fails, the backup file is corrupted and no copy is attempted, thus index.transit is assumed to be untouched. + If the write operation succeeds, a backup is created and index.transit is overwritten. + Reading and writing will occur asynchronously. + Path and data to be written are retrieved from the reframe db directly, not passed as arguments. + User should eventually have MANY backups files. It's their job to manage these backups :)" + [copy?] + (let [selected-db @(rf/subscribe [:db-picker/selected-db]) + ;; See test/e2e/electron-test.ts for details about this flag. + e2e-ignore-save? (= (js/localStorage.getItem "E2E_IGNORE_SAVE") "true")] + (when (and (utils/local-db? selected-db) + (not e2e-ignore-save?)) + (let [filepath (:db-path selected-db) + data (dt/write-transit-str @db/dsdb) + r (.. (utils/stream) -Readable (from data)) + dirname (.dirname (utils/path) filepath) + time (.. (js/Date.) getTime) + bkp-filename (str time "-" (os-username) "-" "index.transit.bkp") + bkp-filepath (.resolve (utils/path) dirname bkp-filename) + w (.createWriteStream (utils/fs) bkp-filepath) + error-cb (fn [err] + (when err + (js/alert (js/Error. err)) + (js/console.error (js/Error. err))))] + (.setEncoding r "utf8") + (.on r "error" error-cb) + (.on w "error" error-cb) + (.on w "finish" (fn [] + ;; copyFile is not atomic, unlike rename, but is still a short operation and has the nice side effect of creating a backup file + ;; If copy fails, by default, node.js deletes the destination file (index.transit): https://nodejs.org/api/fs.html#fs_fs_copyfilesync_src_dest_mode + (when copy? + (.. (utils/fs) (copyFileSync bkp-filepath filepath)) + (let [mtime (.-mtime (.statSync (utils/fs) filepath))] + (rf/dispatch-sync [:db/update-mtime mtime]) + (rf/dispatch [:db/sync]))))) + (.pipe r w))))) + + +(defn write-bkp + [] + (write-db false)) + + +(defn default-debounce-write-db + [] + (debounce write-db (* 1000 15))) + + +(rf/reg-sub + :fs/write-db + (fn [db _] + (or (-> db :fs/debounce-write-db) + ;; TODO: This default shouldn't be needed, but write! seems to be called + ;; before boot is finished sometimes and I'm not sure why. + (default-debounce-write-db)))) + + +(rf/reg-event-fx + :fs/update-write-db + (fn [{:keys [db]} _] + (let [backup-time (-> db :athens/persist :settings :backup-time) + f (debounce write-db (* 1000 backup-time))] + {:db (assoc db :fs/debounce-write-db f)}))) + + +;; The write happens asynchronously due to the debounce and write-db both being asynchronous. +;; write-db also takes the value of dsdb and filepath at the time it actually runs, not when +;; this is called. +(rf/reg-fx + :fs/write! + (fn [] + (@(rf/subscribe [:fs/write-db]) true))) diff --git a/src/cljs/athens/electron/images.cljs b/src/cljs/athens/electron/images.cljs new file mode 100644 index 0000000000..aa39122db3 --- /dev/null +++ b/src/cljs/athens/electron/images.cljs @@ -0,0 +1,40 @@ +(ns athens.electron.images + (:require + [athens.common.utils :as common.utils] + [athens.electron.utils :as electron.utils] + [re-frame.core :as rf])) + + +;; Image Paste +(defn save-image + ([item extension] + (save-image "" "" item extension)) + ([head tail item extension] + (let [{:keys [images-dir name]} @(rf/subscribe [:db-picker/selected-db]) + _ (prn head tail images-dir name item extension) + file (.getAsFile item) + img-filename (.resolve (electron.utils/path) images-dir (str "img-" name "-" (common.utils/gen-block-uid) "." extension)) + reader (js/FileReader.) + new-str (str head "![](" "file://" img-filename ")" tail) + cb (fn [e] + (let [img-data (as-> + (.. e -target -result) x + (clojure.string/replace-first x #"data:image/(jpeg|gif|png);base64," "") + (js/Buffer. x "base64"))] + (when-not (.existsSync (electron.utils/fs) images-dir) + (.mkdirSync (electron.utils/fs) images-dir)) + (.writeFileSync (electron.utils/fs) img-filename img-data)))] + (set! (.. reader -onload) cb) + (.readAsDataURL reader file) + new-str))) + + +(defn dnd-image + [target-uid drag-target item extension] + (let [new-str (save-image item extension)] + ;; delay because you want to create block *after* the file has been saved to filesystem + ;; otherwise, is created too fast, and no image is rendered + ;; TODO: this functionality needs to create an event instead and upload the file to work with RTC. + (js/setTimeout #(rf/dispatch [:graph/add-internal-representation + [{:block/string new-str}] + {:block/uid target-uid :relation drag-target}]) 50))) diff --git a/src/cljs/athens/electron/monitoring/core.cljs b/src/cljs/athens/electron/monitoring/core.cljs new file mode 100644 index 0000000000..5b5d85a3a0 --- /dev/null +++ b/src/cljs/athens/electron/monitoring/core.cljs @@ -0,0 +1,91 @@ +(ns athens.electron.monitoring.core + (:require + [athens.electron.db-picker :as db-picker] + [athens.electron.utils :as utils] + [athens.util :as util] + [re-frame.core :as rf])) + + +;; https://posthog.com/docs/integrate/client/js#super-properties +(rf/reg-event-fx + :posthog/set-super-properties + (fn [{:keys [db]} _] + (let [selected-db (db-picker/selected-db db) + remote-db? (utils/remote-db? selected-db) + graph-type {:graph-type (if remote-db? + :self-hosted + :local)} + electron-build {:electron-build-version (util/athens-version)} + super-properties (merge graph-type electron-build) + js-super-properties (clj->js super-properties)] + (.. js/posthog (register js-super-properties))))) + + +(rf/reg-fx + :posthog/capture-event! + (fn [{:keys [event-name opts-map]}] + (when-not (js/posthog.has_opted_out_capturing) + (js/posthog.capture event-name (clj->js opts-map))))) + + +(rf/reg-event-fx + :posthog/report-feature + (fn [{:keys [_db]} [_ feature on?]] + {:posthog/capture-event! {:event-name (str "feature/" (name feature)) + :opts-map {feature on?}}})) + + +(rf/reg-event-fx + :reporting/navigation + (fn [{:keys [_db]} [_ {:keys [source target pane]}]] + {:posthog/capture-event! {:event-name "feature/navigate" + :opts-map {:source source + :target target + :pane pane}}})) + + +(rf/reg-event-fx + :reporting/page.create + (fn [{:keys [_db]} [_ {:keys [source count]}]] + {:posthog/capture-event! {:event-name "feature/page.create" + :opts-map {:source source + :pages-created count}}})) + + +(rf/reg-event-fx + :reporting/block.create + (fn [{:keys [_db]} [_ {:keys [source count]}]] + {:posthog/capture-event! {:event-name "feature/block.create" + :opts-map {:source source + :blocks-created count}}})) + + +(rf/reg-event-fx + :reporting/page.link + (fn [{:keys [_db]} [_ {:keys [source count]}]] + {:posthog/capture-event! {:event-name "feature/page.link" + :opts-map {:source source + :links-created count}}})) + + +(rf/reg-event-fx + :reporting/block.link + (fn [{:keys [_db]} [_ {:keys [source count]}]] + {:posthog/capture-event! {:event-name "feature/block.link" + :opts-map {:source source + :links-created count}}})) + + +(defn build-reporting-link-creation + [added source] + (let [{:keys [page-link block-ref]} (reduce (fn [agg [link-type _]] + (update agg link-type (fnil inc 0))) + {} + added)] + (cond-> [] + (pos? page-link) + (conj [:reporting/page.link {:source source + :count page-link}]) + (pos? block-ref) + (conj [:reporting/block.link {:source source + :count block-ref}])))) diff --git a/src/cljs/athens/electron/utils.cljc b/src/cljs/athens/electron/utils.cljc new file mode 100644 index 0000000000..1d754f8965 --- /dev/null +++ b/src/cljs/athens/electron/utils.cljc @@ -0,0 +1,158 @@ +(ns athens.electron.utils + (:require + [clojure.string :as str])) + + +;; Electron node libs + +(def electron? + #?(:electron true + :cljs false)) + + +(def platform-require-error-msg "Platform does not support Electron requires.") + + +(defn require-or-error + "Returns the result of js/require when in a electron environment, otherwise throws." + [_x] + #?(;; See shadow-cljs.edn reader-features for details. + :electron (js/require _x) + :cljs ^js (throw (new js/Error platform-require-error-msg)))) + + +(def electron #(require-or-error "electron")) +(def ipcRenderer #(.. (electron) -ipcRenderer)) +(def remote #(.. (electron) -remote)) +(def app #(.. (remote) -app)) +(def version #(.. (remote) -app getVersion)) +(def dialog #(.. (remote) -dialog)) + +(def path #(require-or-error "path")) +(def fs #(require-or-error "fs")) +(def os #(require-or-error "os")) +(def stream #(require-or-error "stream")) +(def log #(require-or-error "electron-log")) + + +;; Electron ipcMain Channels + +(def ipcMainChannels + {:toggle-max-or-min-win-channel "toggle-max-or-min-active-win" + :close-win-channel "close-win" + :exit-fullscreen-win-channel "exit-fullscreen-win"}) + + +;; DB utils + +(def DB-INDEX "index.transit") +(def IMAGES-DIR-NAME "images") + + +(defn default-dbs-dir + "~/Documents on Linux/Mac + C:\\\\User\\Documents on Windows" + [] + (.getPath (app) "documents")) + + +(defn default-base-dir + [] + (.resolve (path) (default-dbs-dir) "athens")) + + +(defn local-db + "Returns a map representing a local db. + Local dbs are uniquely identified by the base-dir." + [base-dir] + {:type :local + :name (.basename (path) base-dir) + :id base-dir + :base-dir base-dir + :images-dir (.resolve (path) base-dir IMAGES-DIR-NAME) + :db-path (.resolve (path) base-dir DB-INDEX)}) + + +(defn in-memory-db + "Returns a map representing an in-memory db. + In-memory dbs are uniquely identified by their name." + [name] + {:type :in-memory + :name name + :id name}) + + +(defn local-db-exists? + [{:keys [db-path] :as db}] + (when db db-path (.existsSync (fs) db-path))) + + +(defn local-db-dir-exists? + [{:keys [base-dir] :as db}] + (when db base-dir (.existsSync (fs) base-dir))) + + +(defn create-dir-if-needed! + [dir] + (when (not (.existsSync (fs) dir)) + (.mkdirSync (fs) dir))) + + +(defn resolve-http-url + [url] + (if (or (str/starts-with? url "http://") + (str/starts-with? url "https://")) + url + (str/join ["http://" url]))) + + +(defn resolve-ws-url + [url] + (cond + (str/starts-with? url "http://") (str "ws://" (last (str/split url #"http://")) "/ws") + (str/starts-with? url "https://") (str "wss://" (last (str/split url #"https://")) "/ws") + :else (str "ws://" url "/ws"))) + + +(defn self-hosted-db + "Returns a map representing a self-hosted db. + Self-hosted dbs are uniquely identified by the url." + [name url password] + {:type :self-hosted + :name name + :id url + :url url + :password password + :http-url (resolve-http-url url) + :ws-url (resolve-ws-url url)}) + + +(defn local-db? + [db] + (-> db :type (= :local))) + + +(defn remote-db? + [db] + (-> db :type (= :self-hosted))) + + +(defn in-memory-db? + [db] + (-> db :type (= :in-memory))) + + +(defn db-exists? + [db] + (condp = (:type db) + :local (local-db-exists? db) + :self-hosted true + :in-memory true + false)) + + +(defn get-default-db + [] + (if electron? + (local-db (default-base-dir)) + (in-memory-db "In-memory DB"))) diff --git a/src/cljs/athens/electron/window.cljs b/src/cljs/athens/electron/window.cljs new file mode 100644 index 0000000000..3172298e81 --- /dev/null +++ b/src/cljs/athens/electron/window.cljs @@ -0,0 +1,125 @@ +(ns athens.electron.window + (:require + [athens.electron.utils :as electron.utils] + [athens.style :refer [zoom-level-min zoom-level-max]] + [re-frame.core :as rf])) + + +(rf/reg-event-db + :zoom/in + (fn [db _] + (update db :zoom-level #(min (inc %) zoom-level-max)))) + + +(rf/reg-event-db + :zoom/out + (fn [db _] + (update db :zoom-level #(max (dec %) zoom-level-min)))) + + +(rf/reg-event-db + :zoom/set + (fn [db [_ level]] + (assoc db :zoom-level level))) + + +(rf/reg-event-db + :zoom/reset + (fn [db _] + (assoc db :zoom-level 0))) + + +(rf/reg-event-fx + :toggle-max-min-win + (fn [_ [_ toggle-min?]] + {:invoke-win! {:channel (:toggle-max-or-min-win-channel electron.utils/ipcMainChannels) + :arg (clj->js toggle-min?)}})) + + +(rf/reg-event-fx + :minimize-win + (fn [_ _] + {:invoke-win! {:channel (:toggle-max-or-min-win-channel electron.utils/ipcMainChannels) + :arg (clj->js true)}})) + + +(rf/reg-event-fx + :bind-win-listeners + (fn [_ _] + {:bind-win-listeners! {}})) + + +(rf/reg-event-fx + :exit-fullscreen-win + (fn [_ _] + {:invoke-win! {:channel (:exit-fullscreen-win-channel electron.utils/ipcMainChannels)}})) + + +(rf/reg-event-fx + :close-win + (fn [_ _] + {:invoke-win! {:channel (:close-win-channel electron.utils/ipcMainChannels)}})) + + +(rf/reg-event-db + :toggle-win-maximized + (fn [db [_ maximized?]] + (assoc db :win-maximized? maximized?))) + + +(rf/reg-event-db + :toggle-win-fullscreen + (fn [db [_ fullscreen?]] + (assoc db :win-fullscreen? fullscreen?))) + + +(rf/reg-event-db + :toggle-win-focused + (fn [db [_ focused?]] + (assoc db :win-focused? focused?))) + + +(rf/reg-sub + :win-maximized? + (fn [db _] + (:win-maximized? db))) + + +(rf/reg-sub + :win-fullscreen? + (fn [db _] + (:win-fullscreen? db))) + + +(rf/reg-sub + :win-focused? + (fn [db _] + (:win-focused? db))) + + +(rf/reg-fx + :invoke-win! + (fn [{:keys [channel arg]} _] + (if arg + (.. (electron.utils/ipcRenderer) (invoke channel arg)) + (.. (electron.utils/ipcRenderer) (invoke channel))))) + + +(rf/reg-fx + :close-win! + (fn [] + (let [window (.. (electron.utils/electron) -BrowserWindow getFocusedWindow)] + (.close window)))) + + +(rf/reg-fx + :bind-win-listeners! + (fn [] + (let [active-win (.getCurrentWindow (electron.utils/remote))] + (doto ^js/BrowserWindow active-win + (.on "maximize" #(rf/dispatch-sync [:toggle-win-maximized true])) + (.on "unmaximize" #(rf/dispatch-sync [:toggle-win-maximized false])) + (.on "blur" #(rf/dispatch-sync [:toggle-win-focused false])) + (.on "focus" #(rf/dispatch-sync [:toggle-win-focused true])) + (.on "enter-full-screen" #(rf/dispatch-sync [:toggle-win-fullscreen true])) + (.on "leave-full-screen" #(rf/dispatch-sync [:toggle-win-fullscreen false])))))) diff --git a/src/cljs/athens/events.cljs b/src/cljs/athens/events.cljs index f545f5288e..90702f585b 100644 --- a/src/cljs/athens/events.cljs +++ b/src/cljs/athens/events.cljs @@ -1,75 +1,1599 @@ (ns athens.events (:require - [athens.db :as db] - [datascript.core :as d] - [re-frame.core :as rf :refer [dispatch reg-fx reg-event-db reg-event-fx reg-sub]] - [re-posh.core :as rp :refer [reg-event-ds]] - [day8.re-frame.tracing :refer-macros [fn-traced]] - [cljs-http.client :as http] - [cljs.core.async :refer [go original-uid uid)}])]}))) + + +(reg-event-fx + :editing/target + [(interceptors/sentry-span-no-new-tx "editing/target")] + (fn [_ [_ target]] + (let [uid (-> (.. target -id) + (string/split "editable-uid-") + second)] + {:dispatch [:editing/uid uid]}))) + + +(reg-event-fx + :editing/first-child + [(interceptors/sentry-span-no-new-tx "editing/first-child")] + (fn [_ [_ uid]] + (when-let [first-block-uid (db/get-first-child-uid uid @db/dsdb)] + {:dispatch [:editing/uid first-block-uid]}))) + + +(reg-event-fx + :editing/last-child + [(interceptors/sentry-span-no-new-tx "editing/last-child")] + (fn [_ [_ uid]] + (when-let [last-block-uid (db/get-last-child-uid uid @db/dsdb)] + {:dispatch [:editing/uid last-block-uid]}))) + + +(defn select-up + [selected-items] + (let [first-item (first selected-items) + [_ o-embed] (db/uid-and-embed-id first-item) + prev-block-uid (db/prev-block-uid first-item) + prev-block-o-uid (-> prev-block-uid db/uid-and-embed-id first) + prev-block (db/get-block [:block/uid prev-block-o-uid]) + parent (db/get-parent [:block/uid (-> first-item db/uid-and-embed-id first)]) + editing-uid @(subscribe [:editing/uid]) + editing-idx (first (keep-indexed (fn [idx x] + (when (= x editing-uid) + idx)) + selected-items)) + n (count selected-items) + new-items (cond + ;; if prev-block is root node TODO: (OR context root), don't do anything + (and (zero? editing-idx) (> n 1)) (pop selected-items) + (:node/title prev-block) selected-items + ;; if prev block is parent, replace editing/uid and first item w parent; remove children + (= (:block/uid parent) prev-block-o-uid) (let [parent-children (-> (common-db/sorted-prop+children-uids @db/dsdb [:block/uid prev-block-uid]) + set) + to-keep (->> selected-items + (map #(-> % db/uid-and-embed-id first)) + (filter (fn [x] (not (contains? parent-children x))))) + new-vec (into [prev-block-uid] to-keep)] + new-vec) + + ;; shift up started from inside the embed should not go outside embed block + o-embed (let [selected-uid (str prev-block-o-uid "-embed-" o-embed) + html-el (js/document.querySelector (str "#editable-uid-" prev-block-o-uid "-embed-" o-embed))] + (if html-el + (into [selected-uid] selected-items) + selected-items)) + + :else (into [prev-block-uid] selected-items))] + new-items)) + + +(reg-event-db + :selected/up + [(interceptors/sentry-span-no-new-tx "selected/up")] + (fn [db [_ selected-items]] + (assoc-in db [:selection :items] (select-up selected-items)))) + + +;; using a set or a hash map, we would need a secondary editing/uid to maintain the head/tail position +;; this would let us know if the operation is additive or subtractive +(reg-event-db + :selected/down + [(interceptors/sentry-span-no-new-tx "selected/down")] + (fn [db [_ selected-items]] + (let [last-item (last selected-items) + next-block-uid (db/next-block-uid last-item true) + ordered-selection (cond-> (into [] selected-items) + next-block-uid (into [next-block-uid]))] + (log/debug ":selected/down, new-selection:" (pr-str ordered-selection)) + (assoc-in db [:selection :items] ordered-selection)))) + + +(reg-event-fx + :alert/js + (fn [_ [_ message]] + {:alert/js! message})) + + +(reg-event-fx + :confirm/js + (fn [_ [_ message true-cb false-cb]] + {:confirm/js! [message true-cb false-cb]})) + + +;; Modal + (reg-event-db - :alert-failure - (fn-traced [db error] - (assoc-in db [:errors] error))) + :modal/toggle + (fn [db _] + (update db :modal not))) + + +;; Loading (reg-event-db - :clear-errors + :loading/set (fn-traced [db] - (assoc-in db [:errors] {}))) + (assoc-in db [:loading?] true))) + (reg-event-db - :clear-loading + :loading/unset (fn-traced [db] - (assoc-in db [:loading] false))) + (assoc-in db [:loading?] false))) + + +(reg-event-db + :tooltip/uid + (fn [db [_ uid]] + (assoc db :tooltip/uid uid))) + + +;; Connection status + +(reg-event-fx + :conn-status + (fn [{:keys [db]} [_ to-status]] + (let [from-status (:connection-status db)] + {:db (assoc db :connection-status to-status) + :dispatch-n [(condp = [from-status to-status] + [:reconnecting :connected] [:loading/unset] + [:connected :reconnecting] [:loading/set] + nil)]}))) + + +;; Daily Notes + +(reg-event-db + :daily-note/reset + (fn [db [_ uids]] + (assoc db :daily-notes/items uids))) + + +(reg-event-db + :daily-note/add + (fn [db [_ uid]] + (update db :daily-notes/items (comp vec rseq sort distinct conj) uid))) + + +(reg-event-fx + :daily-note/ensure-day + (fn [_ [_ {:keys [uid title]}]] + (when-not (db/e-by-av :block/uid uid) + {:dispatch [:page/new {:title title + :block-uid (common.utils/gen-block-uid) + :source :auto-make-daily-note}]}))) + + +(reg-event-fx + :daily-note/prev + (fn [{:keys [db]} [_ {:keys [uid] :as day}]] + (let [new-db (update db :daily-notes/items (fn [items] + (into [uid] items)))] + {:db new-db + :dispatch [:daily-note/ensure-day day]}))) + + +(reg-event-fx + :daily-note/next + (fn [_ [_ {:keys [uid] :as day}]] + {:dispatch-n [[:daily-note/ensure-day day] + [:daily-note/add uid]]})) + + +(reg-event-fx + :daily-note/delete + (fn [{:keys [db]} [_ uid title]] + (let [filtered-dn (filterv #(not= % uid) (:daily-notes/items db)) ; Filter current date from daily note vec + new-db (assoc db :daily-notes/items filtered-dn)] + {:fx [[:dispatch [:page/delete title]]] + :db new-db}))) + + +(reg-event-fx + :daily-note/scroll + (fn [_ [_]] + (let [daily-notes @(subscribe [:daily-notes/items]) + el (getElement "daily-notes")] + (when el + (let [offset-top (.. el -offsetTop) + rect (.. el getBoundingClientRect) + from-bottom (.. rect -bottom) + from-top (.. rect -top) + doc-height (.. js/document -documentElement -scrollHeight) + top-delta (- offset-top from-top) + bottom-delta (- from-bottom doc-height)] + ;; Don't allow user to scroll up for now. + (cond + (< top-delta 1) nil #_(dispatch [:daily-note/prev (get-day (uid-to-date (first daily-notes)) -1)]) + (< bottom-delta 1) {:fx [[:dispatch [:daily-note/next (dates/get-day (dates/uid-to-date (last daily-notes)) 1)]]]})))))) + + +;; -- event-fx and Datascript Transactions ------------------------------- + +;; Import/Export + + +(reg-event-fx + :http-success/get-db + [(interceptors/sentry-span-no-new-tx "http-success/get-db")] + (fn [_ [_ json-str]] + (let [datoms (db/str-to-db-tx json-str) + new-db (d/db-with common-db/empty-db datoms)] + {:dispatch [:reset-conn new-db]}))) + + +(reg-event-fx + :theme/set + [(interceptors/sentry-span-no-new-tx "theme/set")] + (fn [{:keys [db]} _] + (util/switch-body-classes (if (-> db :athens/persist :theme/dark) + ["is-theme-light" "is-theme-dark"] + ["is-theme-dark" "is-theme-light"])) + {})) + + +(reg-event-fx + :theme/toggle + [(interceptors/sentry-span-no-new-tx "theme/toggle")] + (fn [{:keys [db]} _] + {:db (update-in db [:athens/persist :theme/dark] not) + :dispatch-n [[:theme/set] + [:posthog/report-feature :theme]]})) + + +;; Datascript + +;; These events are used for async flows, so we know when changes are in the +;; datascript db. +;; If you need to know which event was resolved, check the arg as +;; shown in https://github.com/day8/re-frame-async-flow-fx#advanced-use. +(rf/reg-event-fx + :success-resolve-forward-transact + (fn [_ [_ _event]] + {})) + + +(rf/reg-event-fx + :fail-resolve-transact-forward + (fn [_ [_ _event]] + {})) + + +(reg-event-fx + :reset-conn + [(interceptors/sentry-span-no-new-tx "reset-conn")] + (fn-traced [_ [_ db skip-health-check?]] + {:reset-conn! [db skip-health-check?]})) + + +(rf/reg-event-fx + :success-reset-conn + (fn [_ _] + (js/console.debug ":success-reset-conn") + {})) + + +(defn datom->tx-entry + [[e a v :as datom]] + (if (and (string/includes? (name a) "+") + (nil? (second v))) + (log/warn "Offending attribute entity (it has `nil` for `:block/key` value):" (pr-str datom)) + [:db/add e a v])) + + +(rf/reg-event-fx + :db-dump-handler + (fn-traced [{:keys [db]} [_ datoms]] + (let [existing-tx (sentry/transaction-get-current) + sentry-tx (if existing-tx + existing-tx + (sentry/transaction-start "db-dump-handler")) + conversion-span (sentry/span-start sentry-tx "convert-datoms") + ;; TODO: this new-db should be derived from an internal representation transact event instead. + new-db (d/db-with common-db/empty-db + (into [] (map datom->tx-entry) datoms))] + (sentry/span-finish conversion-span) + {:db db + :async-flow {:id :db-dump-handler-async-flow ; NOTE do not ever use id that is defined event + :db-path [:async-flow :db-dump-handler] + :first-dispatch [:reset-conn new-db true] + :rules [{:when :seen? + :events :success-reset-conn + :dispatch-n [[:remote/start-event-sync] + [:db/sync] + [:remote/connected]]} + {:when :seen-all-of? + :events [:success-reset-conn + :remote/start-event-sync + :db/sync + :remote/connected] + :dispatch-n (cond-> [[:stage/success-db-load]] + (not existing-tx) (conj [:sentry/end-tx sentry-tx])) + :halt? true}]}}))) + + +(reg-event-fx + :electron-sync + [(interceptors/sentry-span-no-new-tx "electron-sync")] + (fn [_ _] + (let [synced? @(subscribe [:db/synced]) + electron? electron.utils/electron?] + (merge {} + (when (and synced? electron?) + {:fx [[:dispatch [:db/not-synced]] + [:dispatch [:save]]]}))))) + + +(reg-event-fx + :resolve-transact-forward + [(interceptors/sentry-span "resolve-transact-forward")] + (fn [{:keys [db]} [_ event]] + (let [remote? (db-picker/remote-db? db) + valid? (schema/valid-event? event) + dsdb @db/dsdb + undo? (undo-resolver/undo? event) + presence-id (-> (subscribe [:presence/current-user]) deref :username) + event (if (and remote? presence-id) + (common-events/add-presence event presence-id) + event)] + (log/debug ":resolve-transact-forward event:" (pr-str event) + "remote?" (pr-str remote?) + "valid?" (pr-str valid?) + "undo?" (pr-str undo?)) + (if-not valid? + ;; Don't try to process invalid events, just log them. + (let [explanation (-> schema/event + (m/explain event) + (me/humanize))] + (log/warn "Not sending invalid event. Error:" (with-out-str (pp/pprint explanation)) + "\nInvalid event was:" (with-out-str (pp/pprint event))) + {:fx [[:dispatch [:fail-resolve-forward-transact event]]]}) + + + (try + ;; Seems valid, lets process it. + (let [;; First, resolve it into dsdb. + db' (if remote? + ;; Remote db events have to be managed via the synchronizer in events.remote. + (first (events-remote/add-memory-event! [db db/dsdb] event)) + (do + ;; For local dbs, just transact it directly into dsdb. + (atomic-resolver/resolve-transact! db/dsdb event) + db)) + + ;; Then figure out the undo situation. + db'' (if undo? + ;; For undos, let the undo/redo handlers manage db state. + db' + ;; Otherwise wipe the redo stack and add the new event. + (-> db' + undo/reset-redo + (undo/push-undo (:event/id event) [dsdb event])))] + + ;; Wrap it up. + (merge + {:db db'' + :fx [;; Local dbs will need to be synced via electron. + (when-not remote? [:dispatch [:electron-sync]]) + ;; Remote dbs just wait for the event to be confirmed by the server. + (when remote? [:dispatch [:db/not-synced]]) + ;; Processing has finished successfully at this point, signal the async flows. + [:dispatch [:success-resolve-forward-transact event]]]} + ;; Remote dbs need to actually send the event via the network. + (when remote? {:remote/send-event-fx! event}))) + + ;; Bork bork, still need to clean up. + (catch :default e + (log/error ":resolve-transact-forward failed with event " event " with error " e) + {:fx [[:dispatch [:fail-resolve-forward-transact event]]]})))))) + + +(reg-event-fx + :page/delete + [(interceptors/sentry-span "page/delete")] + (fn [_ [_ title]] + (log/debug ":page/delete:" title) + (let [event (common-events/build-atomic-event (atomic-graph-ops/make-page-remove-op title))] + {:fx [[:dispatch [:resolve-transact-forward event]]]}))) + + +(reg-event-fx + :save + [(interceptors/sentry-span-no-new-tx "save")] + (fn [_ _] + {:fs/write! nil})) + + +(reg-event-fx + :undo + [(interceptors/sentry-span-no-new-tx "undo")] + (fn [{:keys [db]} _] + (try + (log/debug ":undo count" (undo/count-undo db)) + (if-some [[undo db'] (undo/pop-undo db)] + (let [[evt-dsdb evt] undo + evt-id (:event/id evt) + dsdb @db/dsdb + undo-evt (undo-resolver/build-undo-event dsdb evt-dsdb evt) + undo-ops (:event/op undo-evt) + new-titles (graph-ops/ops->new-page-titles undo-ops) + new-uids (graph-ops/ops->new-block-uids undo-ops) + [_rm add] (graph-ops/structural-diff @db/dsdb undo-ops) + undo-evt-id (:event/id undo-evt) + db'' (undo/push-redo db' undo-evt-id [dsdb undo-evt])] + (log/debug ":undo evt" (pr-str evt-id) "as" (pr-str undo-evt-id)) + {:db db'' + :fx [[:dispatch-n (cond-> [[:resolve-transact-forward undo-evt]] + (seq new-titles) + (conj [:reporting/page.create {:source :undo + :count (count new-titles)}]) + (seq new-uids) + (conj [:reporting/block.create {:source :undo + :count (count new-uids)}]) + (seq add) + (concat (monitoring/build-reporting-link-creation add :undo)))]]}) + {}) + (catch :default _ + {:fx (util/toast (clj->js {:status "error" + :title "Couldn't undo" + :description "Undo for this operation not supported in Lan-Party, yet."}))})))) + + +(reg-event-fx + :redo + [(interceptors/sentry-span-no-new-tx "redo")] + (fn [{:keys [db]} _] + (log/debug ":redo") + (try + (log/debug ":redo count" (undo/count-redo db)) + (if-some [[redo db'] (undo/pop-redo db)] + (let [[evt-dsdb evt] redo + evt-id (:event/id evt) + dsdb @db/dsdb + undo-evt (undo-resolver/build-undo-event dsdb evt-dsdb evt) + undo-ops (:event/op undo-evt) + undo-evt-id (:event/id undo-evt) + new-titles (graph-ops/ops->new-page-titles undo-ops) + new-uids (graph-ops/ops->new-block-uids undo-ops) + [_rm add] (graph-ops/structural-diff @db/dsdb undo-ops) + db'' (undo/push-undo db' undo-evt-id [dsdb undo-evt])] + (log/debug ":redo evt" (pr-str evt-id) "as" (pr-str undo-evt-id)) + {:db db'' + :fx [[:dispatch-n (cond-> [[:resolve-transact-forward undo-evt]] + (seq new-titles) + (conj [:reporting/page.create {:source :redo + :count (count new-titles)}]) + (seq new-uids) + (conj [:reporting/block.create {:source :redo + :count (count new-uids)}]) + (seq add) + (concat (monitoring/build-reporting-link-creation add :redo)))]]}) + {}) + (catch :default _ + {:fx (util/toast (clj->js {:status "error" + :title "Couldn't redo" + :description "Redo for this operation not supported in Lan-Party, yet."}))})))) + + +(reg-event-fx + :reset-undo-redo + [(interceptors/sentry-span-no-new-tx "reset-undo-redo")] + (fn [{:keys [db]} _] + {:db (undo/reset db)})) + + +(defn window-uid? + "Returns true if uid matches the toplevel window. + Only works for the main window." + [uid] + (let [[uid _] (db/uid-and-embed-id uid) + window-uid @(subscribe [:current-route/uid-compat])] + (and uid window-uid (= uid window-uid)))) + + +(reg-event-fx + :up + [(interceptors/sentry-span-no-new-tx "up")] + (fn [_ [_ uid target-pos]] + (let [prev-block-uid (db/prev-block-uid uid) + prev-block-uid' (when-not (window-uid? prev-block-uid) + prev-block-uid)] + {:dispatch [:editing/uid (or prev-block-uid' uid) target-pos]}))) + + +(reg-event-fx + :down + [(interceptors/sentry-span-no-new-tx "down")] + (fn [_ [_ uid target-pos]] + (let [next-block-uid (db/next-block-uid uid)] + #_(log/debug ::down (pr-str {:uid uid :target-pos target-pos :next-block-uid next-block-uid})) + {:dispatch [:editing/uid (or next-block-uid uid) target-pos]}))) + + +(defn backspace + "If root and 0th child, 1) if value, no-op, 2) if blank value, delete only block. + No-op if parent is missing. + No-op if parent is prev-block and block has children. + No-op if prev-sibling-block has children. + Otherwise delete block and join with previous block + If prev-block has children" + ([uid value] + (backspace uid value nil)) + ([uid value maybe-local-updates] + (let [root-embed? (= (some-> (str "#editable-uid-" uid) + js/document.querySelector + (.. (closest ".block-embed")) + (. -firstChild) + (.getAttribute "data-uid")) + uid) + db @db/dsdb + [uid embed-id] (common-db/uid-and-embed-id uid) + block (common-db/get-block db [:block/uid uid]) + children-uids (common-db/sorted-prop+children-uids @db/dsdb [:block/uid uid]) + parent (common-db/get-parent db [:block/uid uid]) + prev-block-uid (db/prev-block-uid uid) + prev-block (common-db/get-block db [:block/uid prev-block-uid]) + prev-sib (db/nth-sibling uid :before) + prev-sib-children-uids (common-db/sorted-prop+children-uids @db/dsdb [:block/uid (:block/uid prev-sib)]) + event (cond + (or (not parent) + root-embed? + (and (seq children-uids) (seq prev-sib-children-uids)) + (and (seq children-uids) (= parent prev-block))) + nil + + (:block/key block) + [:block/move {:source-uid uid + :target-uid (:block/uid parent) + :target-rel :first + :local-string value}] + + (and (empty? children-uids) (:node/title parent) + (= uid (first children-uids)) (clojure.string/blank? value)) + [:backspace/delete-only-child uid] + + maybe-local-updates + [:backspace/delete-merge-block-with-save {:uid uid + :value value + :prev-block-uid prev-block-uid + :embed-id embed-id + :prev-block prev-block + :local-update maybe-local-updates}] + :else + [:backspace/delete-merge-block {:uid uid + :value value + :prev-block-uid prev-block-uid + :embed-id embed-id + :prev-block prev-block}])] + (log/debug "[Backspace] args:" (pr-str {:uid uid + :value value}) + ", event:" (pr-str event)) + (when event + {:fx [[:dispatch event]]})))) + + +;; todo(abhinav) -- stateless backspace +;; will pick db value of backspace/delete instead of current state +;; which might not be same as blur is not yet called +(reg-event-fx + :backspace + [(interceptors/sentry-span-no-new-tx "backspace")] + (fn [_ [_ uid value maybe-local-updates]] + (backspace uid value maybe-local-updates))) + + +;; Atomic events start ========== + +(defn- wait-for-rft + [sentry-tx success-dispatch-n] + [{:when :seen? + :events :fail-resolve-forward-transact + :dispatch [:sentry/end-tx sentry-tx] + :halt? true} + {:when :seen? + :events :success-resolve-forward-transact + :dispatch-n (into [[:sentry/end-tx sentry-tx]] success-dispatch-n) + :halt? true}]) + + +(defn- transact-async-flow + [id-kw event sentry-tx success-dispatch-n] + [:async-flow {:id (keyword (str (name id-kw) "-async-flow")) + :db-path [:async-flow id-kw] + :first-dispatch [:resolve-transact-forward event] + :rules (wait-for-rft sentry-tx success-dispatch-n)}]) + + +(defn- close-and-get-sentry-tx + "Always closes old running transaction and starts new one" + [name] + (let [running-tx? (sentry/tx-running?)] + (when running-tx? + (sentry/transaction-finish (sentry/transaction-get-current))) + (sentry/transaction-start name))) + + +(defn- focus-on-uid + ([uid embed-id] + [:editing/uid + (str uid (when embed-id + (str "-embed-" embed-id)))]) + ([uid embed-id idx] + [:editing/uid + (str uid (when embed-id + (str "-embed-" embed-id))) + idx])) + + +(reg-event-fx + :backspace/delete-only-child + (fn [_ [_ uid]] + (log/debug ":backspace/delete-only-child:" (pr-str uid)) + (let [sentry-tx (close-and-get-sentry-tx "backspace/delete-only-child") + op (wrap-span-no-new-tx "build-block-remove-op" + (graph-ops/build-block-remove-op @db/dsdb uid)) + event (common-events/build-atomic-event op)] + {:fx [(transact-async-flow :backspace-delete-only-child event sentry-tx [[:editing/uid nil]])]}))) + + +(reg-event-fx + :enter/new-block + (fn [_ [_ {:keys [block parent new-uid embed-id]}]] + (log/debug ":enter/new-block" (pr-str block) (pr-str parent) (pr-str new-uid)) + (let [sentry-tx (close-and-get-sentry-tx "enter/new-block") + op (atomic-graph-ops/make-block-new-op new-uid {:block/uid (:block/uid block) + :relation :after}) + event (common-events/build-atomic-event op)] + {:fx [(transact-async-flow :enter-new-block event sentry-tx [(focus-on-uid new-uid embed-id)]) + [:dispatch [:reporting/block.create {:source :enter-new-block + :count 1}]]]}))) + + +(reg-event-fx + :check-for-mentions + (fn [_ [_ uid string]] + (let [username (rf/subscribe [:username]) + mentions (comments/get-all-mentions string @username) + mention-op (when (not-empty mentions) + (comments/create-notification-op-for-users {:db @db/dsdb + :parent-block-uid uid + :notification-for-users mentions + :author @username + :trigger-block-uid uid + :notification-type "athens/notification/type/mention"})) + event (common-events/build-atomic-event (composite-ops/make-consequence-op {:op/type :mention-notifications} + mention-op))] + (when mention-op + {:fx [[:dispatch [:resolve-transact-forward event]]]})))) + + +(reg-event-fx + :notification-for-assigned-task + (fn [{:keys [db]} [_ uid assignee]] + (let [username (-> db :athens/persist :settings :username) + assignee-op (when assignee + (comments/create-notification-op-for-users {:db @db/dsdb + :parent-block-uid uid + :notification-for-users [assignee] + :author username + :trigger-block-uid uid + :notification-type "athens/notification/type/task/assigned/to"})) + task-creator-op (when (not= assignee (str "[[@" username "]]")) + (comments/create-notification-op-for-users {:db @db/dsdb + :parent-block-uid uid + :notification-for-users [(str "[[@" username "]]")] + :author username + :trigger-block-uid uid + :notification-type "athens/notification/type/task/assigned/by"})) + event (common-events/build-atomic-event (composite-ops/make-consequence-op {:op/type :mention-notifications} + (concat + assignee-op + task-creator-op)))] + (when assignee-op + {:fx [[:dispatch [:resolve-transact-forward event]]]})))) + + +(reg-event-fx + :block/save + (fn [{:keys [db]} [_ {:keys [uid string source] :as args}]] + (log/debug ":block/save args" (pr-str args)) + (let [local? (not (db-picker/remote-db? db)) + block-eid (common-db/e-by-av @db/dsdb :block/uid uid) + old-string (->> block-eid + (d/entity @db/dsdb) + :block/string) + do-nothing? (or (not block-eid) + (= old-string string)) + op (graph-ops/build-block-save-op @db/dsdb uid string) + new-titles (graph-ops/ops->new-page-titles op) + [_rm add] (graph-ops/structural-diff @db/dsdb op) + event (common-events/build-atomic-event op)] + (log/debug ":block/save local?" local? + ", do-nothing?" do-nothing?) + (when-not do-nothing? + {:fx [[:dispatch-n (cond-> [[:resolve-transact-forward event] + [:check-for-mentions uid string]] + (seq new-titles) + (conj [:reporting/page.create {:source (or source :unknown-block-save) + :count (count new-titles)}]) + (seq add) + (concat (monitoring/build-reporting-link-creation add (or source :unknown-block-save))))]]})))) + + +(reg-event-fx + :page/new + (fn [_ [_ {:keys [title block-uid shift? source] + :or {shift? false + source :unknown-page-new} + :as args}]] + (log/debug ":page/new args" (pr-str args)) + (let [new-page-op (graph-ops/build-page-new-op @db/dsdb + title + block-uid) + new-titles (graph-ops/ops->new-page-titles new-page-op) + event (common-events/build-atomic-event new-page-op)] + {:fx [[:dispatch-n [[:resolve-transact-forward event] + [:page/new-followup title shift?] + [:editing/uid block-uid] + [:reporting/page.create {:source source + :count (count new-titles)}]]]]}))) + + +(reg-event-fx + :page/rename + (fn [_ [_ {:keys [old-name new-name callback] :as args}]] + (log/debug ":page/rename args:" (pr-str (select-keys args [:old-name :new-name]))) + (let [event (common-events/build-atomic-event (graph-ops/build-page-rename-op @db/dsdb old-name new-name))] + {:fx [[:dispatch [:resolve-transact-forward event]] + [:invoke-callback callback]]}))) + + +(reg-event-fx + :page/merge + (fn [_ [_ {:keys [from-name to-name callback] :as args}]] + (log/debug ":page/merge args:" (pr-str (select-keys args [:from-name :to-name]))) + (let [event (common-events/build-atomic-event (atomic-graph-ops/make-page-merge-op from-name to-name))] + {:fx [[:dispatch [:resolve-transact-forward event]] + [:invoke-callback callback]]}))) + + +(reg-event-fx + :page/new-followup + (fn [_ [_ title shift?]] + (log/debug ":page/new-followup title" title "shift?" shift?) + (let [page-uid (common-db/get-page-uid @db/dsdb title)] + {:fx [[:dispatch-n [(cond + shift? + [:right-sidebar/open-item [:node/title title]] + + (not (dates/is-daily-note page-uid)) + [:navigate :page {:id page-uid}])]]]}))) + + +(reg-event-fx + :backspace/delete-merge-block + (fn [_ [_ {:keys [uid value prev-block-uid embed-id prev-block] :as args}]] + (log/debug ":backspace/delete-merge-block args:" (pr-str args)) + (let [sentry-tx (close-and-get-sentry-tx "backspace/delete-merge-block") + op (wrap-span-no-new-tx "build-block-remove-merge-op" + (graph-ops/build-block-remove-merge-op @db/dsdb + uid + prev-block-uid + value)) + new-titles (graph-ops/ops->new-page-titles op) + [_rm add] (graph-ops/structural-diff @db/dsdb op) + event (common-events/build-atomic-event op)] + {:fx [(transact-async-flow :backspace-delete-merge-block event sentry-tx + [(focus-on-uid prev-block-uid embed-id + (count (:block/string prev-block)))]) + [:dispatch-n (cond-> [] + (seq new-titles) + (conj [:reporting/page.create {:source :kbd-backspace-merge + :count (count new-titles)}]) + (seq add) + (concat (monitoring/build-reporting-link-creation add :kbd-backspace-merge)))]]}))) + + +(reg-event-fx + :backspace/delete-merge-block-with-save + (fn [_ [_ {:keys [uid value prev-block-uid embed-id local-update] :as args}]] + (log/debug ":backspace/delete-merge-block-with-save args:" (pr-str args)) + (let [sentry-tx (close-and-get-sentry-tx "backspace/delete-merge-block-with-save") + op (wrap-span-no-new-tx "build-block-merge-with-updated-op" + (graph-ops/build-block-merge-with-updated-op @db/dsdb + uid + prev-block-uid + value + local-update)) + new-titles (graph-ops/ops->new-page-titles op) + [_rm add] (graph-ops/structural-diff @db/dsdb op) + event (common-events/build-atomic-event op)] + {:fx [(transact-async-flow :backspace-delete-merge-block-with-save event sentry-tx + [(focus-on-uid prev-block-uid embed-id (count local-update))]) + [:dispatch-n (cond-> [] + (seq new-titles) + (conj [:dispatch [:reporting/page.create {:source :kbd-backspace-merge-with-save + :count (count new-titles)}]]) + (seq add) + (concat (monitoring/build-reporting-link-creation add :kbd-backspace-merge-with-save)))]]}))) + + +;; Atomic events end ========== + + +(reg-event-fx + :enter/add-child + (fn [_ [_ {:keys [block new-uid embed-id navigation-uid] :as args}]] + (log/debug ":enter/add-child args:" (pr-str args)) + (let [sentry-tx (close-and-get-sentry-tx "enter/add-child") + position (wrap-span-no-new-tx "compat-position" + (common-db/compat-position @db/dsdb {:block/uid (or navigation-uid + (:block/uid block)) + :relation :first})) + event (common-events/build-atomic-event (atomic-graph-ops/make-block-new-op new-uid position))] + {:fx [(transact-async-flow :enter-add-child event sentry-tx [(focus-on-uid new-uid embed-id)]) + [:dispatch [:reporting/block.create {:source :enter-add-child + :count 1}]]]}))) + + +(reg-event-fx + :enter/split-block + (fn [_ [_ {:keys [uid new-uid value index embed-id navigation-uid relation] :as args}]] + (log/debug ":enter/split-block" (pr-str args)) + (let [sentry-tx (close-and-get-sentry-tx "enter/split-block") + op (wrap-span-no-new-tx "build-block-split-op" + (graph-ops/build-block-split-op @db/dsdb + {:old-block-uid uid + :new-block-uid new-uid + :string value + :index index + :navigation-uid navigation-uid + :relation relation})) + new-titles (graph-ops/ops->new-page-titles op) + [_rm add] (graph-ops/structural-diff @db/dsdb op) + event (common-events/build-atomic-event op)] + {:fx [(transact-async-flow :enter-split-block event sentry-tx [(focus-on-uid new-uid embed-id)]) + [:dispatch-n (cond-> [[:reporting/block.create {:source :enter-split + :count 1}] + [:check-for-mentions uid value]] + (seq new-titles) + (conj [:reporting/page.create {:source :enter-split + :count (count new-titles)}]) + (seq add) + (concat (monitoring/build-reporting-link-creation add :enter-split)))]]}))) + + +(reg-event-fx + :enter/bump-up + (fn [_ [_ {:keys [uid new-uid embed-id navigation-uid] :as args}]] + (log/debug ":enter/bump-up args" (pr-str args)) + (let [sentry-tx (close-and-get-sentry-tx "enter/bump-up") + position (wrap-span-no-new-tx "compat-position" + (common-db/compat-position @db/dsdb {:block/uid (or navigation-uid uid) + :relation :before})) + event (common-events/build-atomic-event (atomic-graph-ops/make-block-new-op new-uid position))] + {:fx [(transact-async-flow :enter-bump-up event sentry-tx [(focus-on-uid new-uid embed-id)]) + [:dispatch [:reporting/block.create {:source :enter-bump-up + :count 1}]]]}))) + + +(reg-event-fx + :enter/open-block-add-child + (fn [_ [_ {:keys [block new-uid embed-id navigation-uid]}]] + ;; Triggered when there is a closed embeded block with no content in the top level block + ;; and then one presses enter in the embeded block. + (log/debug ":enter/open-block-add-child" (pr-str block) (pr-str new-uid)) + (let [sentry-tx (close-and-get-sentry-tx "enter/open-block-add-child") + block-uid (:block/uid block) + block-open-op (atomic-graph-ops/make-block-open-op block-uid + true) + position (wrap-span-no-new-tx "compat-position" + (common-db/compat-position @db/dsdb {:block/uid (or navigation-uid + (:block/uid block)) + :relation :first})) + add-child-op (atomic-graph-ops/make-block-new-op new-uid position) + open-block-add-child-op (composite-ops/make-consequence-op {:op/type :open-block-add-child} + [block-open-op + add-child-op]) + event (common-events/build-atomic-event open-block-add-child-op)] + {:fx [(transact-async-flow :enter-open-block-add-child event sentry-tx [(focus-on-uid new-uid embed-id)]) + [:dispatch [:reporting/block.create {:source :enter-open-block-add-child + :count 1}]]]}))) + + +(defn enter + "- If block is a property, always open and create a new child + - If block is open, has children, and caret at end, create new child + - If block is CLOSED, has children, and caret at end, add a sibling block. + - If value is empty and a root block, add a sibling block. + - If caret is not at start, split block in half. + - If block has children and is closed, if at end, just add another child. + - If block has children and is closed and is in middle of block, split block. + - If value is empty, unindent. + - If caret is at start and there is a value, create new block below but keep same block index." + [rfdb uid d-key-down navigation-uid] + (let [root-embed? (= (some-> d-key-down :target + (.. (closest ".block-embed")) + (. -firstChild) + (.getAttribute "data-uid")) + uid) + [uid embed-id] (db/uid-and-embed-id uid) + block (db/get-block [:block/uid uid]) + block-properties (common-db/get-block-property-document @db/dsdb [:block/uid uid]) + has-comments? (not-empty (get block-properties ":comment/threads")) + block-has-comments-but-no-children? (and has-comments? + (empty? (:block/children block))) + {parent-uid :block/uid + :as parent} (db/get-parent [:block/uid uid]) + is-parent-root-embed? (= (some-> d-key-down :target + (.. (closest ".block-embed")) + (. -firstChild) + (.getAttribute "data-uid")) + (str parent-uid "-embed-" embed-id)) + root-block? (boolean (:node/title parent)) + context-root-uid (get-in rfdb [:current-route :path-params :id]) + new-uid (common.utils/gen-block-uid) + has-children? (seq (common-db/sorted-prop+children-uids @db/dsdb [:block/uid uid])) + {:keys [value start]} d-key-down + caret-at-the-end-of-text (= start + (count value)) + caret-at-the-start-of-text (and (zero? start) + value) + event (cond + (and block-has-comments-but-no-children? + caret-at-the-end-of-text) + [:enter/new-block {:block block + :parent parent + :new-uid new-uid + :embed-id embed-id + :navigation-uid navigation-uid}] + + (:block/key block) + [:enter/split-block {:uid uid + :value value + :index start + :new-uid new-uid + :embed-id embed-id + :navigation-uid navigation-uid + :relation :first}] + + (and (:block/open block) + has-children? + caret-at-the-end-of-text) + [:enter/add-child {:block block + :new-uid new-uid + :embed-id embed-id + :navigation-uid navigation-uid}] + + (and embed-id root-embed? + caret-at-the-end-of-text) + [:enter/open-block-add-child {:block block + :new-uid new-uid + :embed-id embed-id + :navigation-uid navigation-uid}] + + (and (not (:block/open block)) + has-children? + caret-at-the-end-of-text) + [:enter/new-block {:block block + :parent parent + :new-uid new-uid + :embed-id embed-id + :navigation-uid navigation-uid}] + + (and (empty? value) + (or (= context-root-uid (:block/uid parent)) + root-block?)) + [:enter/new-block {:block block + :parent parent + :new-uid new-uid + :embed-id embed-id + :navigation-uid navigation-uid}] + + (and (:block/open block) + embed-id root-embed? + (not caret-at-the-end-of-text)) + [:enter/split-block {:uid uid + :value value + :index start + :new-uid new-uid + :embed-id embed-id + :navigation-uid navigation-uid + :relation :first}] + + (and (empty? value) embed-id (not is-parent-root-embed?)) + [:unindent {:uid uid + :d-key-down d-key-down + :context-root-uid context-root-uid + :embed-id embed-id + :local-string "" + :navigation-uid navigation-uid}] + + (and (empty? value) embed-id is-parent-root-embed?) + [:enter/new-block {:block block + :parent parent + :new-uid new-uid + :embed-id embed-id + :navigation-uid navigation-uid}] + + (not caret-at-the-start-of-text) + [:enter/split-block {:uid uid + :value value + :index start + :new-uid new-uid + :embed-id embed-id + :navigation-uid navigation-uid + :relation :after}] + + (empty? value) + [:unindent {:uid uid + :d-key-down d-key-down + :context-root-uid context-root-uid + :embed-id embed-id + :local-string "" + :navigation-uid navigation-uid}] + + caret-at-the-start-of-text + [:enter/bump-up {:uid uid + :new-uid new-uid + :embed-id embed-id + :navigation-uid navigation-uid}])] + (log/debug "[Enter] ->" (pr-str event)) + (assert parent-uid (str "[Enter] no parent for block-uid: " uid)) + {:fx [[:dispatch event]]})) + + +(reg-event-fx + :enter + [(interceptors/sentry-span-no-new-tx "enter")] + (fn [{rfdb :db} [_ uid d-event navigation-uid]] + (enter rfdb uid d-event navigation-uid))) + + +(defn get-prev-block-uid-and-target-rel + [uid] + (let [db @db/dsdb + prev-block-uid (:block/uid (db/nth-sibling uid :before)) + prev-block-children? (if prev-block-uid + (seq (common-db/sorted-prop+children-uids db [:block/uid prev-block-uid])) + nil) + prop-key (common-db/property-key db [:block/uid uid]) + target-rel (cond + prop-key {:page/title prop-key} + prev-block-children? :last + :else :first)] + [prev-block-uid target-rel])) + + +(defn block-save-block-move-composite-op + [source-uid ref-uid relation string] + (let [db @db/dsdb + block-save-op (graph-ops/build-block-save-op db source-uid string) + position (common-db/compat-position db {:block/uid ref-uid + :relation relation}) + block-move-op (graph-ops/build-block-move-op db source-uid position) + block-save-block-move-op (composite-ops/make-consequence-op {:op/type :block-save-block-move} + [block-save-op + block-move-op])] + block-save-block-move-op)) + + +(reg-event-fx + :indent + (fn [{:keys [_db]} [_ {:keys [uid d-key-down local-string editing-uid] :as args}]] + ;; - `block-zero`: The first block in a page + ;; - `value` : The current string inside the block being indented. Otherwise, if user changes block string and indents, + ;; the local string is reset to original value, since it has not been unfocused yet (which is currently the + ;; transaction that updates the string). + (let [sentry-tx (close-and-get-sentry-tx "indent") + first-block? (= uid (first (db/sibling-uids uid))) + [prev-block-uid + target-rel] (wrap-span-no-new-tx "get-prev-block-uid-and-target-rel" + (get-prev-block-uid-and-target-rel uid)) + sib-block (wrap-span-no-new-tx "get-block-sib-block" + (common-db/get-block @db/dsdb [:block/uid prev-block-uid])) + ;; if sibling block is closed with children, open + {sib-open :block/open + sib-uid :block/uid} sib-block + block-closed? (and (not sib-open) + (common-db/sorted-prop+children-uids @db/dsdb [:block/uid prev-block-uid])) + sib-block-open-op (when block-closed? + (atomic-graph-ops/make-block-open-op sib-uid true)) + {:keys [start end]} d-key-down + block-save-block-move-op (block-save-block-move-composite-op uid + prev-block-uid + target-rel + local-string) + composite-ops (composite-ops/make-consequence-op {:op/type :indent} + (cond-> [block-save-block-move-op] + block-closed? (conj sib-block-open-op))) + new-titles (graph-ops/ops->new-page-titles composite-ops) + [_rm add] (graph-ops/structural-diff @db/dsdb composite-ops) + event (common-events/build-atomic-event composite-ops)] + (log/debug "null-sib-uid" (and first-block? + prev-block-uid) + ", args:" (pr-str args) + ", first-block?" first-block?) + (when (and prev-block-uid + (not first-block?)) + {:fx [(transact-async-flow :indent event sentry-tx []) + [:set-cursor-position [(or editing-uid uid) start end]] + [:dispatch-n (cond-> [] + (seq new-titles) + (conj [:reporting/page.create {:source :indent + :count (count new-titles)}]) + (seq add) + (concat (monitoring/build-reporting-link-creation add :indent)))]]})))) + + +(reg-event-fx + :indent/multi + (fn [_ [_ {:keys [uids]}]] + (log/debug ":indent/multi" (pr-str uids)) + (let [sentry-tx (close-and-get-sentry-tx "indent/multi") + sanitized-selected-uids (mapv (comp first common-db/uid-and-embed-id) uids) + f-uid (first sanitized-selected-uids) + dsdb @db/dsdb + [prev-block-uid + target-rel] (wrap-span-no-new-tx "get-prev-block-uid-and-target-rel" + (get-prev-block-uid-and-target-rel f-uid)) + same-parent? (wrap-span-no-new-tx "same-parent" + (common-db/same-parent? dsdb sanitized-selected-uids)) + first-block? (= f-uid (first (db/sibling-uids f-uid)))] + (log/debug ":indent/multi same-parent?" same-parent? + ", not first-block?" (not first-block?)) + (when (and same-parent? (not first-block?)) + {:fx [[:async-flow {:id :indent-multi-async-flow + :db-path [:async-flow :indent-multi] + :first-dispatch [:drop-multi/sibling {:source-uids sanitized-selected-uids + :target-uid prev-block-uid + :drag-target target-rel}] + :rules (wait-for-rft sentry-tx [])}]]})))) + + +(reg-event-fx + :unindent + (fn [{:keys [_db]} [_ {:keys [uid d-key-down context-root-uid embed-id local-string editing-uid] :as args}]] + (log/debug ":unindent args" (pr-str args)) + (let [sentry-tx (close-and-get-sentry-tx "unindent") + db @db/dsdb + property-key (common-db/property-key db [:block/uid uid]) + parent (wrap-span-no-new-tx "parent" + (common-db/get-parent db (common-db/e-by-av db :block/uid uid))) + is-parent-property? (:block/key parent) + parent-of-parent (->> parent + :db/id + (common-db/get-parent db) + :block/uid) + is-parent-root-embed? (= (some-> d-key-down + :target + (.. (closest ".block-embed")) + (. -firstChild) + (.getAttribute "data-uid")) + (str (:block/uid parent) "-embed-" embed-id)) + do-nothing? (or is-parent-root-embed? + (:node/title parent) + (= context-root-uid (:block/uid parent))) + {:keys [start end]} d-key-down + block-save-block-move-op (cond + property-key (block-save-block-move-composite-op uid parent-of-parent {:page/title property-key} local-string) + is-parent-property? (block-save-block-move-composite-op uid parent-of-parent :first local-string) + :else (block-save-block-move-composite-op uid (:block/uid parent) :after local-string)) + new-titles (graph-ops/ops->new-page-titles block-save-block-move-op) + [_rm add] (graph-ops/structural-diff @db/dsdb block-save-block-move-op) + event (common-events/build-atomic-event block-save-block-move-op)] + (log/debug ":unindent do-nothing?" do-nothing?) + (when-not do-nothing? + {:fx [(transact-async-flow :unindent event sentry-tx [(focus-on-uid (or editing-uid uid) embed-id)]) + [:set-cursor-position [(or editing-uid uid) start end]] + [:dispatch-n (cond-> [] + (seq new-titles) + (conj [:reporting/page.create {:source :unindent + :count (count new-titles)}]) + (seq add) + (concat (monitoring/build-reporting-link-creation add :unindent)))]]})))) + + +(reg-event-fx + :unindent/multi + (fn [{:keys [db]} [_ {:keys [uids]}]] + (log/debug ":unindent/multi" uids) + (let [sentry-tx (close-and-get-sentry-tx "unindent/multi") + [f-uid f-embed-id] (wrap-span-no-new-tx "uid-and-embed-id" + (common-db/uid-and-embed-id (first uids))) + sanitized-selected-uids (mapv (comp + first + common-db/uid-and-embed-id) uids) + {parent-title :node/title + parent-uid :block/uid} (wrap-span-no-new-tx "get-parent" + (common-db/get-parent @db/dsdb [:block/uid f-uid])) + same-parent? (wrap-span-no-new-tx "same-parent" + (common-db/same-parent? @db/dsdb sanitized-selected-uids)) + is-parent-root-embed? (when same-parent? + (some-> "#editable-uid-" + (str f-uid "-embed-" f-embed-id) + js/document.querySelector + (.. (closest ".block-embed")) + (. -firstChild) + (.getAttribute "data-uid") + (= (str parent-uid "-embed-" f-embed-id)))) + context-root-uid (get-in db [:current-route :path-params :id]) + do-nothing? (or parent-title + (not same-parent?) + (and same-parent? is-parent-root-embed?) + (= parent-uid context-root-uid))] + (log/debug ":unindent/multi do-nothing?" do-nothing?) + (when-not do-nothing? + {:fx [[:async-flow {:id :unindent-multi-async-flow + :db-path [:async-flow :unindent-multi] + :first-dispatch [:drop-multi/sibling {:source-uids sanitized-selected-uids + :target-uid parent-uid + :drag-target :after}] + :rules (wait-for-rft sentry-tx [])}]]})))) + + +(reg-event-fx + :block/move + (fn [_ [_ {:keys [source-uid target-uid target-rel local-string] :as args}]] + (log/debug ":block/move args" (pr-str args)) + (let [sentry-tx (close-and-get-sentry-tx "block/move") + local-string (or local-string + (:block/string (common-db/get-block-document @db/dsdb [:block/uid source-uid]))) + event (-> (block-save-block-move-composite-op source-uid target-uid target-rel local-string) + common-events/build-atomic-event)] + {:fx [(transact-async-flow :block-move event sentry-tx [(focus-on-uid source-uid nil)])]}))) + + +(reg-event-fx + :block/link + (fn [_ [_ {:keys [source-uid target-uid target-rel] :as args}]] + (log/debug ":block/link args" (pr-str args)) + (let [block-uid (common.utils/gen-block-uid) + atomic-event (common-events/build-atomic-event + (composite-ops/make-consequence-op {:op/type :block/link} + [(atomic-graph-ops/make-block-new-op block-uid + {:block/uid target-uid + :relation target-rel}) + (atomic-graph-ops/make-block-save-op block-uid + (str "((" source-uid "))"))]))] + {:fx [[:dispatch-n [[:resolve-transact-forward atomic-event] + [:reporting/block.create {:source :bullet-drop + :count 1}]]]]}))) ; TODO :reporting/block.link + + +(reg-event-fx + :drop-multi/child + (fn [_ [_ {:keys [source-uids target-uid] :as args}]] + (log/debug ":drop-multi/child args" (pr-str args)) + (let [atomic-op (graph-ops/block-move-chain @db/dsdb target-uid source-uids :first) + event (common-events/build-atomic-event atomic-op)] + {:fx [[:dispatch [:resolve-transact-forward event]]]}))) + + +(reg-event-fx + :drop-multi/sibling + (fn [_ [_ {:keys [source-uids target-uid drag-target] :as args}]] + ;; When the selected blocks have same parent and are DnD under the same parent this event is fired. + ;; This also applies if on selects multiple Zero level blocks and change the order among other Zero level blocks. + (log/debug ":drop-multi/sibling args" (pr-str args)) + (let [rel-position drag-target + atomic-op (graph-ops/block-move-chain @db/dsdb target-uid source-uids rel-position) + event (common-events/build-atomic-event atomic-op)] + {:fx [[:dispatch [:resolve-transact-forward event]]]}))) + + +(reg-event-fx + :paste-internal + [(interceptors/sentry-span-no-new-tx "paste-internal")] + (fn [_ [_ uid local-str internal-representation]] + (when (seq internal-representation) + (let [[uid] (db/uid-and-embed-id uid) + op (bfs/build-paste-op @db/dsdb + uid + local-str + internal-representation) + new-titles (graph-ops/ops->new-page-titles op) + new-uids (graph-ops/ops->new-block-uids op) + [_rm add] (graph-ops/structural-diff @db/dsdb op) + event (common-events/build-atomic-event op) + focus-uid (-> (graph-ops/contains-op? op :block/new) + first + :op/args + :block/uid)] + (log/debug "paste internal event is" (pr-str event)) + {:fx [[:async-flow {:id :paste-internal-async-flow + :db-path [:async-flow :paste-internal] + :first-dispatch [:resolve-transact-forward event] + :rules [{:when :seen? + :events :fail-resolve-forward-transact + :halt? true} + {:when :seen? + :events :success-resolve-forward-transact + :dispatch-n [(when focus-uid + [:editing/uid focus-uid])] + :halt? true}]}] + [:dispatch-n (cond-> [] + + (seq new-titles) + (conj [:reporting/page.create {:source :paste-internal + :count (count new-titles)}]) + + (seq new-uids) + (conj [:reporting/block.create {:source :paste-internal + :count (count new-uids)}]) + (seq add) + (concat (monitoring/build-reporting-link-creation add :paste-internal)))]]})))) + + +(reg-event-fx + :paste-image + [(interceptors/sentry-span-no-new-tx "paste-image")] + (fn [{:keys [db]} [_ items head tail callback]] + (let [local? (not (db-picker/remote-db? db)) + img-regex #"(?i)^image/(p?jpeg|gif|png)$"] + (log/debug ":paste-image : local?" local?) + (if local? + (do + (mapv (fn [item] + (let [datatype (.. item -type)] + (cond + (re-find img-regex datatype) (when electron.utils/electron? + (let [new-str (images/save-image head tail item "png")] + (callback new-str))) + (re-find #"text/html" datatype) (.getAsString item (fn [_] #_(prn "getAsString" _)))))) + items) + {}) + {:fx (util/toast (clj->js {:status "error" + :title "Couldn't paste" + :description "Image paste is not supported in Lan-Party, yet."}))})))) + + +(reg-event-fx + :paste-verbatim + [(interceptors/sentry-span-no-new-tx "paste-verbatim")] + (fn [_ [_ uid text]] + ;; NOTE: use of `value` is questionable, it's the DOM so it's what users sees, + ;; but what users sees should taken from DB. How would `value` behave with multiple editors? + (let [{:keys [start + value]} (textarea-keydown/destruct-target js/document.activeElement) + block-empty? (string/blank? value) + block-start? (zero? start) + new-string (cond + block-empty? text + (and (not block-empty?) + block-start?) (str text value) + :else (str (subs value 0 start) + text + (subs value start))) + op (graph-ops/build-block-save-op @db/dsdb uid new-string) + new-titles (graph-ops/ops->new-page-titles op) + [_rm add] (graph-ops/structural-diff @db/dsdb op) + event (common-events/build-atomic-event op)] + {:fx [[:dispatch-n (cond-> [[:resolve-transact-forward event]] + (seq new-titles) + (conj [:reporting/page.create {:source :paste-verbatim + :count (count new-titles)}]) + (seq add) + (concat (monitoring/build-reporting-link-creation add :paste-verbatim)))]]}))) + + +(reg-event-fx + :unlinked-references/link + (fn [_ [_ {:block/keys [string uid]} title]] + (log/debug ":unlinked-references/link:" uid) + (let [ignore-case-title (re-pattern (str "(?i)" title)) + new-str (string/replace string ignore-case-title (str "[[" title "]]")) + op (graph-ops/build-block-save-op @db/dsdb + uid + new-str) + [_rm add] (graph-ops/structural-diff @db/dsdb op) + event (common-events/build-atomic-event op)] + {:fx [[:dispatch-n (cond-> [[:resolve-transact-forward event] + [:posthog/report-feature :unlinked-references]] + (seq add) + (concat (monitoring/build-reporting-link-creation add :unlinked-refs-link)))]]}))) + + +(reg-event-fx + :unlinked-references/link-all + (fn [_ [_ unlinked-refs title]] + (log/debug ":unlinked-references/link:" title) + (let [block-save-ops (mapv + (fn [{:block/keys [string uid]}] + (let [ignore-case-title (re-pattern (str "(?i)" title)) + new-str (string/replace string ignore-case-title (str "[[" title "]]"))] + (graph-ops/build-block-save-op @db/dsdb + uid + new-str))) + unlinked-refs) + link-all-op (composite-ops/make-consequence-op {:op/type :block/unlinked-refs-link-all} + block-save-ops) + [_rm add] (graph-ops/structural-diff @db/dsdb link-all-op) + event (common-events/build-atomic-event link-all-op)] + {:fx [[:dispatch-n (cond-> [[:resolve-transact-forward event] + [:posthog/report-feature :unlinked-references]] + (seq add) + (concat (monitoring/build-reporting-link-creation add :unlinked-refs-link-all)))]]}))) + + +(rf/reg-event-fx + :block/open + (fn [_ [_ {:keys [block-uid open?] :as args}]] + (log/debug ":block/open args" args) + (let [event (common-events/build-atomic-event + (atomic-graph-ops/make-block-open-op block-uid open?))] + {:fx [[:dispatch [:resolve-transact-forward event]]]}))) + + +;; Works like clojure's update-in. +;; Calls (f db uid), where uid is the existing block uid, or a uid that will be created in ks property path. +;; (f db uid) should return a seq of operations to perform. If no operations are returned, nothing is transacted. +(reg-event-fx + :graph/update-in + [(interceptors/sentry-span-no-new-tx "graph/update-in")] + (fn [_ [_ eid ks f]] + (log/debug ":graph/update-in args" eid ks) + (when (seq ks) + (let [db @db/dsdb + [prop-uid path-ops] (graph-ops/build-path db eid ks) + f-ops (f db prop-uid)] + (when (seq f-ops) + {:fx [[:dispatch-n [[:resolve-transact-forward (->> (into path-ops f-ops) + (composite-ops/make-consequence-op {:op/type :graph/update-in}) + common-events/build-atomic-event)]]]]}))))) -(defn boot-flow [] - {:first-dispatch - [:get-datoms] - :rules [{:when :seen? :events :parse-datoms :dispatch [:clear-loading] :halt? true} - {:when :seen? :events :api-request-error :dispatch [:alert-failure "Boot Error"] :halt? true}]}) +;; Add internal representation to graph, using default-position for blocks without pages. (reg-event-fx - :boot - (fn-traced [_ _] - {:async-flow (boot-flow)})) \ No newline at end of file + :graph/add-internal-representation + [(interceptors/sentry-span-no-new-tx "graph/add-internal-representation")] + (fn [_ [_ internal-representation default-position]] + (log/debug ":graph/add-internal-representation args" internal-representation default-position) + (when (seq internal-representation) + {:fx [[:dispatch-n [[:resolve-transact-forward (->> (bfs/internal-representation->atomic-ops @db/dsdb internal-representation default-position) + (composite-ops/make-consequence-op {:op/type :graph/add-internal-representation}) + common-events/build-atomic-event)]]]]}))) diff --git a/src/cljs/athens/events/dragging.cljs b/src/cljs/athens/events/dragging.cljs new file mode 100644 index 0000000000..0a2783332c --- /dev/null +++ b/src/cljs/athens/events/dragging.cljs @@ -0,0 +1,21 @@ +(ns athens.events.dragging + (:require + [re-frame.core :as rf])) + + +(rf/reg-event-db + ::cleanup! + (fn [db [_ uid]] + (update db :dragging dissoc uid))) + + +(rf/reg-event-db + ::set-dragging! + (fn [db [_ uid dragging?]] + (assoc-in db [:dragging uid :dragging?] dragging?))) + + +(rf/reg-event-db + ::set-drag-target! + (fn [db [_ uid drag-target]] + (assoc-in db [:dragging uid :drag-target] drag-target))) diff --git a/src/cljs/athens/events/inline_refs.cljs b/src/cljs/athens/events/inline_refs.cljs new file mode 100644 index 0000000000..fd381029a1 --- /dev/null +++ b/src/cljs/athens/events/inline_refs.cljs @@ -0,0 +1,52 @@ +(ns athens.events.inline-refs + (:require + [re-frame.core :as rf])) + + +(rf/reg-event-db + ::cleanup! + (fn [db [_ uid]] + (update db :inline-refs dissoc uid))) + + +(rf/reg-event-db + ::set-open! + (fn [db [_ uid open?]] + (assoc-in db [:inline-refs uid :open?] open?))) + + +(rf/reg-event-db + ::toggle-open! + (fn [db [_ uid]] + (update-in db [:inline-refs uid :open?] not))) + + +(rf/reg-event-db + ::set-state! + (fn [db [_ uid state]] + (assoc-in db [:inline-refs uid :state] state))) + + +(rf/reg-event-db + ::toggle-state-open! + (fn [db [_ uid]] + (update-in db [:inline-refs uid :state :open?] not))) + + +(rf/reg-event-db + ::set-block! + (fn [db [_ uid block]] + (assoc-in db [:inline-refs uid :state :block] block))) + + +(rf/reg-event-db + ::set-parents! + (fn [db [_ uid parents]] + (assoc-in db [:inline-refs uid :state :parents] parents))) + + +(rf/reg-event-db + ::set-focus! + (fn [db [_ uid focus?]] + (assoc-in db [:inline-refs uid :state :focus?] focus?))) + diff --git a/src/cljs/athens/events/inline_search.cljs b/src/cljs/athens/events/inline_search.cljs new file mode 100644 index 0000000000..6ddab98e63 --- /dev/null +++ b/src/cljs/athens/events/inline_search.cljs @@ -0,0 +1,46 @@ +(ns athens.events.inline-search + "Inline Search Events" + (:require + [re-frame.core :as rf])) + + +(rf/reg-event-db + ::set-type! + (fn [db [_ uid type]] + (assoc-in db [:inline-search uid :type] type))) + + +(rf/reg-event-db + ::close! + (fn [db [_ uid]] + (assoc-in db [:inline-search uid :type] nil))) + + +(rf/reg-event-db + ::set-index! + (fn [db [_ uid index]] + (assoc-in db [:inline-search uid :index] index))) + + +(rf/reg-event-db + ::set-results! + (fn [db [_ uid results]] + (assoc-in db [:inline-search uid :results] results))) + + +(rf/reg-event-db + ::clear-results! + (fn [db [_ uid]] + (assoc-in db [:inline-search uid :results] []))) + + +(rf/reg-event-db + ::set-query! + (fn [db [_ uid query]] + (assoc-in db [:inline-search uid :query] query))) + + +(rf/reg-event-db + ::clear-query! + (fn [db [_ uid]] + (assoc-in db [:inline-search uid :query] ""))) diff --git a/src/cljs/athens/events/linked_refs.cljs b/src/cljs/athens/events/linked_refs.cljs new file mode 100644 index 0000000000..ad74423af1 --- /dev/null +++ b/src/cljs/athens/events/linked_refs.cljs @@ -0,0 +1,21 @@ +(ns athens.events.linked-refs + (:require + [re-frame.core :as rf])) + + +(rf/reg-event-db + ::cleanup! + (fn [db [_ uid]] + (update db :linked-ref dissoc uid))) + + +(rf/reg-event-db + ::set-open! + (fn [db [_ uid open?]] + (assoc-in db [:linked-ref uid] open?))) + + +(rf/reg-event-db + ::toggle-open! + (fn [db [_ uid]] + (update-in db [:linked-ref uid] (fnil not false)))) diff --git a/src/cljs/athens/events/remote.cljs b/src/cljs/athens/events/remote.cljs new file mode 100644 index 0000000000..1c6fac5185 --- /dev/null +++ b/src/cljs/athens/events/remote.cljs @@ -0,0 +1,309 @@ +(ns athens.events.remote + "`re-frame` events related to `:remote/*`." + (:require + [athens.common-db :as common-db] + [athens.common-events.graph.ops :as graph-ops] + [athens.common-events.resolver.atomic :as atomic-resolver] + [athens.common.logging :as log] + [athens.common.utils :as utils] + [athens.db :as db] + [athens.interceptors :as interceptors] + [athens.undo :as undo] + [clojure.pprint :as pp] + [clojure.string :as string] + [datascript.core :as d] + [event-sync.core :as event-sync] + [re-frame.core :as rf])) + + +;; Connection Management + +(rf/reg-event-fx + :remote/connect! + (fn [_ [_ remote-db]] + (log/info ":remote/connect!" (pr-str (:url remote-db))) + {:remote/client-connect! remote-db + :fx [[:dispatch [:conn-status :connecting]]]})) + + +(rf/reg-event-fx + :remote/connected + (fn [_ _] + (log/info ":remote/connected") + {:fx [[:dispatch [:conn-status :connected]]]})) + + +(rf/reg-event-fx + :remote/connection-failed + (fn [_ _] + (log/warn ":remote/connection-failed") + {:fx [[:dispatch-n [[:alert/js "Couldn't connect to remote workspace."] + [:conn-status :disconnected] + [:db-picker/select-default-db]]]]})) + + +(rf/reg-event-fx + :remote/disconnect! + (fn [_ _] + {:remote/client-disconnect! nil + :dispatch-n [[:remote/stop-event-sync] + [:presence/clear]]})) + + +;; Optimistic state management + +;; These fns update the dsdb optimistic state to reflect the events in event-sync. +;; We apply the new server event (if any) by rolling back all the optimistic events, applying +;; the new one, and then reapplying all the optimistic ones on top. +;; So for instance, given the sequence of events below +;; [s1 s2 s3 o1 o2 o3] +;; where s* events are server events, and o* events are optimistic events, if we +;; receive s4 we want to +;; - rollback o1 o2 o3 +;; - apply s4 on top of s1 s2 s3 +;; - apply o1 o2 o3 on top +;; This way we end with +;; [s1 s2 s3 s4 o1 o2 o3] +;; Rollback here is performed by undoing the previous transactions. +;; This ensures rollback time is proportional to the size of optimistic txs. +;; After each rollback, we have to save the new tx-data for the next undo. +;; Another approach is to rollback by keeping the last known db state with all the +;; server events, but then we'd have to reset the db state and that would cause +;; all listeners to process a whole new db state, instead of an incremental one. + + +(defn- undo-datom + [[e a v _t added?]] + [(if added? :db/retract :db/add) e a v]) + + +(defn- undo-tx-data + "Returns tx-data with all added datoms removed, and all removed datoms added. + Datoms are returned in reversed order, so the first datom in the undo tx reverses + the last datom in the original. + Only works over plain datoms, such as the ones returned from a transaction in :tx-data." + [tx-data] + (->> tx-data + (map undo-datom) + reverse + vec)) + + +(defn- promotion? + [[type _ _ _ noop?]] + (and (= type :promote) (not noop?))) + + +(defn- rollback! + "Revert all events in the :memory stage of event-sync via undo. + Should only be used followed by rollforward." + [[db conn]] + (let [in-memory-events (-> db :remote/event-sync :stages :memory) + rollback-tx-data (-> db :remote/rollback-tx-data) + count-in-memory-events (count in-memory-events) + rolled-back-events (atom #{})] + + (if (= count-in-memory-events 0) + (log/debug "rollback skipped, nothing to rollback") + (do + (log/debug "rollback" count-in-memory-events "events") + ;; Undo events in the reverse order (e.g. first undo reverse last event). + (doseq [[id event] (reverse in-memory-events)] + (let [id (utils/uuid->string id) + tx (rollback-tx-data id) + rollback-tx (undo-tx-data tx)] + (if (nil? tx) + (do + ;; It's very bad if this happens. It means we're not rolling back one of the events. + ;; It shouldn't ever happen, but we should keep an eye out for it in the beta console logs. + (log/warn "rollback no tx-data found for" id) + (log/warn "rollback in-memory-events ids" (keys in-memory-events)) + (log/warn "rollback rollback-tx-data ids" (keys rollback-tx-data))) + (do + (log/debug "rollback event id" (pr-str id)) + (log/debug "rollback event:") + (log/debug (with-out-str (pp/pprint event))) + (log/debug "rollback original tx:") + (log/debug (with-out-str (pp/pprint tx))) + (log/debug "rollback rollback tx:") + (log/debug (with-out-str (pp/pprint rollback-tx))) + (d/transact! conn rollback-tx) + (swap! rolled-back-events conj id))))))) + + ;; Remove rolled back txs from rollback-tx-data. + (let [db' (update db :remote/rollback-tx-data #(apply dissoc % @rolled-back-events))] + [db' conn]))) + + +(defn- rollforward! + "Apply all events in the :memory stage of event-sync via resolve. + Should only be used preceded by rollback." + [[db conn]] + (let [in-memory-events (-> db :remote/event-sync :stages :memory) + count-in-memory-events (count in-memory-events) + reapplied-tx-data (atom {})] + + (if (= count-in-memory-events 0) + (do + (log/debug "rollforward skipped, nothing to rollforward") + [db conn]) + (do + (log/debug "rollforward" count-in-memory-events "events") + + ;; Reapply the optimistic events. + (doseq [[id event] in-memory-events] + (log/debug "rollforward apply event id" (pr-str id)) + (swap! reapplied-tx-data assoc + (utils/uuid->string id) + (atomic-resolver/resolve-transact! conn event))) + + ;; Add new txs to rollback-tx-data. + (let [db' (update db :remote/rollback-tx-data merge @reapplied-tx-data)] + [db' conn]))))) + + +(defn add-memory-event! + "Add an event to the memory stage." + [[db conn] {:event/keys [id] :as event}] + ;; Apply the new event, store it in the memory stage, and save the tx-data for rollback. + (log/debug "add-memory-event apply event id" (pr-str id)) + (let [id' (utils/uuid->string id) + tx-data (atomic-resolver/resolve-transact! conn event) + db' (-> db + (update :remote/event-sync #(event-sync/add % :memory id' event)) + (update :remote/rollback-tx-data assoc id' tx-data))] + [db' conn])) + + +(defn- add-server-event! + "Add an event to the server stage. + Should only be used between rollback and rollforward." + [[db conn] {:event/keys [id] :as event}] + ;; Apply the new event and store it in the server stage. + (log/debug "add-server-event apply event id" (pr-str (:event/id event))) + (let [id' (utils/uuid->string id) + _ (atomic-resolver/resolve-transact! conn event) + db' (-> db + (update :remote/event-sync #(event-sync/add % :server id' event)) + (update :remote/rollback-tx-data dissoc id'))] + [db' conn])) + + +(defn- promote-event! + "Promotes an event to the server stage. + Should only be used without rollback and rollforward. + Returns nil if event is not a promotion." + [[db conn] {:event/keys [id] :as event}] + ;; Apply the new event and store it in the server stage. + (log/debug "promote-event apply event id" (pr-str (:event/id event))) + (let [id' (utils/uuid->string id) + db' (-> db + (update :remote/event-sync #(event-sync/add % :server id' event)) + (update :remote/rollback-tx-data dissoc id'))] + (if (promotion? (-> db' :remote/event-sync :last-op)) + [db' conn] + nil))) + + +(defn- remove-memory-event! + "Remove an event from the memory stage. + Should only be used between rollback and rollforward." + [[db conn] {:event/keys [id] :as event}] + (log/debug "remove-memory-event apply event id" (pr-str id)) + (let [id' (utils/uuid->string id) + _ (atomic-resolver/resolve-transact! conn event) + db' (-> db + (update :remote/event-sync #(event-sync/remove % :memory id' event)) + (update :remote/rollback-tx-data dissoc id'))] + [db' conn])) + + +;; Remote graph related events + +(rf/reg-event-db + :remote/start-event-sync + [(interceptors/sentry-span-no-new-tx ":remote/start-event-sync")] + (fn [db _] + (assoc db + :remote/event-sync (event-sync/create-state :athens [:memory :server]) + :remote/rollback-tx-data {}))) + + +(rf/reg-event-db + :remote/stop-event-sync + [(interceptors/sentry-span-no-new-tx ":remote/stop-event-sync")] + (fn [db _] + (dissoc db :remote/event-sync :remote/rollback-tx-data))) + + +(rf/reg-event-fx + :remote/clear-server-event + [(interceptors/sentry-span-no-new-tx ":remote/clear-server-event")] + (fn [{db :db} [_ event]] + {:db (update db :remote/event-sync #(event-sync/remove % :server (:event/id event) event))})) + + +(rf/reg-event-fx + :remote/reject-forwarded-event + [(interceptors/sentry-span ":remote/reject-forwarded-event")] + (fn [{db :db} [_ event]] + (log/debug ":remote/reject-forwarded-event event:" (pr-str event)) + (let [[db'] (-> [db db/dsdb] + rollback! + (remove-memory-event! event) + rollforward!) + db'' (undo/remove db (:event/id event)) + memory-log (event-sync/stage-log (:remote/event-sync db') :memory)] + {:db db'' + ;; If there's no events to reapply mark as synced. + :fx [[:dispatch-n (when (empty? memory-log) + [[:db/sync]])]]}))) + + +(defn current-page-removed? + [db title] + (let [route-template (get-in db [:current-route :template]) + page-title? (string/starts-with? route-template "/page-t/") + current-page-title (if page-title? + (get-in db [:current-route :path-params :title]) + (when-let [block-uid (get-in db [:current-route :path-params :id])] + (loop [block (common-db/get-block @db/dsdb [:block/uid block-uid])] + (cond + (nil? block) nil + (:node/title block) (:node/title block) + :else (recur (common-db/get-parent @db/dsdb [:block/uid (:block/uid block)]))))))] + (= current-page-title title))) + + +(rf/reg-event-fx + :remote/apply-forwarded-event + [(interceptors/sentry-span ":remote/apply-forwarded-event")] + (fn [{db :db} [_ event]] + (log/debug ":remote/apply-forwarded-event event:" (pr-str event)) + (let [;; Check if the current page will be removed before processing the event. + page-removed (graph-ops/contains-op? (:event/op event) :page/remove) + page-title (when page-removed + (-> page-removed first :op/args :page/title)) + removed? (when page-title + (current-page-removed? db page-title)) + ;; Process events. + [db'] (or (promote-event! [db db/dsdb] event) + (-> [db db/dsdb] + rollback! + (add-server-event! event) + rollforward!)) + memory-log (event-sync/stage-log (:remote/event-sync db') :memory)] + {:db db' + :fx [[:dispatch-n (cond-> [] + ;; Mark as synced if there's no events left in memory. + (empty? memory-log) + (into [[:db/sync]]) + + ;; When the current page was removed + removed? + (into [[:alert/js (str "This page \"" page-title "\" has being deleted by other player.")] + [:navigate :home]]) + + ;; Remove the server event after everything is done. + true + (into [[:remote/clear-server-event event]]))]]}))) diff --git a/src/cljs/athens/events/selection.cljs b/src/cljs/athens/events/selection.cljs new file mode 100644 index 0000000000..e117b2b58c --- /dev/null +++ b/src/cljs/athens/events/selection.cljs @@ -0,0 +1,63 @@ +(ns athens.events.selection + (:require + [athens.common-events :as common-events] + [athens.common-events.graph.composite :as composite-ops] + [athens.common-events.graph.ops :as graph-ops] + [athens.db :as db] + [re-frame.core :as rf])) + + +(rf/reg-event-db + ::set-items + (fn [rf-db [_ uids]] + (assoc-in rf-db [:selection :items] (into [] uids)))) + + +(rf/reg-event-db + ::add-item + (fn [rf-db [_ uid position]] + (let [selected-items (get-in rf-db [:selection :items]) + selected-count (count selected-items)] + (assoc-in rf-db [:selection :items] + (cond + (= :last position) + (into selected-items [uid]) + + (= :first position) + (into [uid] selected-items) + + (int? position) + (if (<= 0 position selected-count) + (let [new-selected-items (into [] (concat + (subvec selected-items 0 position) + [uid] + (subvec selected-items position selected-count)))] + (js/console.debug ::add-item "new-selected-items:" (pr-str new-selected-items)) + new-selected-items) + (let [message (str "Invalid insert position:" (pr-str position) + ". Tried to add uid:" (pr-str uid) + ", to selected-items:" (pr-str selected-items))] + ;; Error, invalid insert position + (js/console.error message) + selected-items))))))) + + +(rf/reg-event-fx + ::delete + (fn [{:keys [db]} _] + (let [selected-uids (get-in db [:selection :items]) + sanitized-uids (map (comp first db/uid-and-embed-id) selected-uids)] + (js/console.debug ::delete "args" selected-uids) + (let [ops (map #(graph-ops/build-block-remove-op @db/dsdb %) sanitized-uids) + composite-op (composite-ops/make-consequence-op {:op/type :selection/delete} ops) + event (common-events/build-atomic-event composite-op)] + (println "delete" ops) + {:fx [[:dispatch-n [[:resolve-transact-forward event] + [:editing/uid nil]]]] + :db (assoc-in db [:selection :items] [])})))) + + +(rf/reg-event-db + ::clear + (fn [rf-db _] + (assoc-in rf-db [:selection :items] []))) diff --git a/src/cljs/athens/events/sentry.cljs b/src/cljs/athens/events/sentry.cljs new file mode 100644 index 0000000000..60a178fd1c --- /dev/null +++ b/src/cljs/athens/events/sentry.cljs @@ -0,0 +1,11 @@ +(ns athens.events.sentry + (:require + [athens.utils.sentry :as sentry] + [re-frame.core :as rf])) + + +(rf/reg-event-fx + :sentry/end-tx + (fn [_ [_ tx]] + (sentry/transaction-finish tx) + {})) diff --git a/src/cljs/athens/import/roam.cljs b/src/cljs/athens/import/roam.cljs new file mode 100644 index 0000000000..61b0ce6ec6 --- /dev/null +++ b/src/cljs/athens/import/roam.cljs @@ -0,0 +1,244 @@ +(ns athens.import.roam + (:require + ["@chakra-ui/react" :refer [VStack Button Box Text Modal ModalOverlay Divider VStack Heading ModalContent ModalHeader ModalFooter ModalBody ModalCloseButton ButtonGroup]] + [athens.common-db :as common-db] + [athens.common-events :as common-events] + [athens.common-events.bfs :as bfs] + [athens.common-events.graph.composite :as composite-ops] + [athens.common-events.graph.ops :as graph-ops] + [athens.dates :as dates] + [athens.db :as db] + [athens.patterns :as patterns] + [clojure.data :as data] + [clojure.edn :as edn] + [clojure.walk :as walk] + [datascript.core :as d] + [re-frame.core :refer [dispatch]] + [reagent.core :as r])) + + +(defn update-roam-db-dates + "Strips the ordinal suffixes of Roam dates from block strings and dates. + e.g. January 18th, 2021 -> January 18, 2021" + [db] + (let [date-pages (d/q '[:find ?t ?u + :keys node/title block/uid + :in $ ?date + :where + [?e :node/title ?t] + [(?date ?t)] + [?e :block/uid ?u]] + db + patterns/date-block-string) + date-block-strings (d/q '[:find ?s ?u + :keys block/string block/uid + :in $ ?date + :where + [?e :block/string ?s] + [(?date ?s)] + [?e :block/uid ?u]] + db + patterns/date-block-string) + date-concat (concat date-pages date-block-strings) + tx-data (map (fn [{:keys [block/string node/title block/uid]}] + (cond-> {:db/id [:block/uid uid]} + string (assoc :block/string (patterns/replace-roam-date string)) + title (assoc :node/title (patterns/replace-roam-date title)))) + date-concat)] + ;; tx-data)) + (d/db-with db tx-data))) + + +(defn file-cb + [e transformed-db roam-db-filename] + (let [fr (js/FileReader.) + file (.. e -target -files (item 0))] + (set! (.-onload fr) + (fn [e] + (let [edn-data (.. e -target -result) + filename (.-name file) + db (edn/read-string {:readers datascript.core/data-readers} edn-data) + transformed-dates-roam-db (update-roam-db-dates db)] + (reset! roam-db-filename filename) + (reset! transformed-db transformed-dates-roam-db)))) + (.readAsText fr file))) + + +(def roam-node-document-pull-vector + '[:node/title :block/uid :block/string :block/open :block/order {:block/children ...}]) + + +(defn get-roam-node-document + [db eid] + (->> (d/pull db roam-node-document-pull-vector eid) + db/sort-block-children)) + + +(defn get-roam-internal-representation + "Like common-db/get-internal representation but for roam dbs." + [db eid] + (when (d/entity db eid) + (let [rename-ks {:block/open :block/open? + :node/title :page/title} + remove-ks [:db/id :block/order] + remove-ks-on-match [[:block/open? :block/open?] + [:block/uid :page/title]]] + (->> (get-roam-node-document db eid) + (walk/postwalk-replace rename-ks) + (walk/prewalk (fn [node] + (if (map? node) + (as-> node n + (apply dissoc n remove-ks) + (reduce common-db/dissoc-on-match n remove-ks-on-match)) + node))))))) + + +(defn get-page-titles + [db] + (->> (d/datoms db :avet :node/title) + (map #(nth % 2)) + set)) + + +(defn shared-and-non-shared-pages + [athens-db roam-db] + (let [athens-pages (get-page-titles athens-db) + roam-pages (get-page-titles roam-db) + [non-shared _ shared] (data/diff roam-pages athens-pages)] + [shared non-shared])) + + +(defn page->ops + [athens-db roam-db roam-db-filename title] + (let [default-position {:page/title title :relation :last} + internal-repr (get-roam-internal-representation roam-db [:node/title title]) + internal-repr (cond-> internal-repr + + ;; Page exists and has blocks in both athens-db and roam-db. + ;; Wrap IR in a new block that mentions the import. + (and (:block/children internal-repr) + (graph-ops/get-path athens-db [:node/title title] [::graph-ops/last])) + (-> (select-keys [:block/children]) + (merge {:block/string (str "[[Roam Import]] " + "[[" (:title (dates/get-day)) "]] " + "[[" roam-db-filename "]]")})))] + (bfs/internal-representation->atomic-ops @db/dsdb [internal-repr] default-position))) + + +;; 90% of max, enough for some wiggle roam with the consequence op. +(def max-payload-size (* 0.9 common-events/max-event-size-in-bytes)) + + +(defn dispatch-payload + [payload] + (dispatch [:resolve-transact-forward (->> payload + (composite-ops/make-consequence-op {:op/type :import/roam}) + common-events/build-atomic-event)])) + + +(defn process-import + "Import roam pages as events under the common-events/max-event-size-in-bytes limit." + [athens-db roam-db roam-db-filename _progress] + (loop [[op & ops] (->> (get-page-titles roam-db) + (mapcat (partial page->ops athens-db roam-db roam-db-filename))) + payload [] + payload-size 0] + (let [op-size (-> op common-events/serialize count) + payload-size-with-op (+ payload-size op-size)] + + (cond + ;; There's no more operations to process. + ;; Send the last payload, if any. + (nil? op) + (when (seq payload) + (dispatch-payload payload)) + + ;; This single op is too big, likely a >1mb string. + ;; We don't really support this, so ignore the op. + ;; TODO: maybe report that we ignored it? + (> op-size max-payload-size) + (recur ops payload payload-size) + + ;; This op can't fit in the current payload. + ;; Dispatch current payload, and add op to next payload. + (> payload-size-with-op max-payload-size) + (do + (dispatch-payload payload) + (recur ops [op] op-size)) + + ;; This op still fits on the payload, add it and + ;; go look at the next one. + :else + (recur ops (conj payload op) payload-size-with-op))))) + + +(defn merge-modal + [open?] + (let [close-modal #(reset! open? false) + transformed-roam-db (r/atom nil) + roam-db-filename (r/atom "") + progress (r/atom 0) ; TODO + ] + (fn [] + [:> Modal {:isOpen @open? + :onClose close-modal + :closeOnOverlayClick false + :size "lg"} + [:> ModalOverlay] + [:> ModalContent + [:> ModalHeader + "Merge from Roam"] + [:> ModalCloseButton] + (if (nil? @transformed-roam-db) + (let [inputRef (atom nil)] + [:> ModalBody + [:input {:ref #(reset! inputRef %) + :style {:display "none"} + :type "file" + :accept ".edn" + :on-change #(file-cb % transformed-roam-db roam-db-filename)}] + [:> Heading {:size "md" :as "h2"} "How to merge from Roam"] + [:> Box {:position "relative" + :padding-bottom "56.25%" + :margin "1rem 0 0" + :borderRadius "8px" + :overflow "hidden" + :flex "1 1 100%" + :width "100%"} + [:iframe {:src "https://www.loom.com/embed/787ed48da52c4149b031efb8e17c0939?hide_owner=true&hide_share=true&hide_title=true&hideEmbedTopBar=true" + :frameBorder "0" + :webkitallowfullscreen "true" + :mozallowfullscreen "true" + :allowFullScreen true + :style {:position "absolute" + :top 0 + :left 0 + :width "100%" + :height "100%"}}]] + [:> ModalFooter + [:> ButtonGroup + [:> Button + {:onClick #(.click @inputRef)} + "Upload workspace"]]]]) + (let [athens-db @athens.db/dsdb + roam-db @transformed-roam-db + [shared-pages roam-pages] (shared-and-non-shared-pages athens-db roam-db)] + [:> ModalBody + [:> Text {:size "md"} (str "Your Roam DB had " (count roam-pages)) " pages. " (count shared-pages) " of these pages were also found in your Athens DB. Press Merge to continue merging your DB."] + [:> Divider {:my 4}] + [:> Heading {:size "md" :as "h3"} "Shared Pages"] + [:> VStack {:as "ol" + :align "stretch" + :maxHeight "400px" + :overflowY "auto"} + (for [x shared-pages] + ^{:key x} + [:li [:> Text (str "[[" x "]]")]])] + [:> ModalFooter + [:> ButtonGroup + [:> Button {:onClick (fn [] + (process-import athens-db roam-db @roam-db-filename progress) + (close-modal))} + + "Merge"]]]]))]]))) + diff --git a/src/cljs/athens/interceptors.cljs b/src/cljs/athens/interceptors.cljs new file mode 100644 index 0000000000..8f8866dc72 --- /dev/null +++ b/src/cljs/athens/interceptors.cljs @@ -0,0 +1,77 @@ +(ns athens.interceptors + (:require + [athens.util :as util] + [athens.utils.sentry :as sentry] + [re-frame.core :as rf])) + + +(def persist-db + "Saves the :athens/persist key in db to persistent storage. + This special key is used for two main reasons: + - performance, by using identical instead of map comparison + - clarity, to make it obvious on access that it will be persisted" + (rf/->interceptor + :id :persist + :after (fn [{:keys [coeffects effects] :as context}] + (let [k :athens/persist + before (-> coeffects :db k) + after (-> effects :db k)] + (when (and after (not (identical? before after))) + (util/local-storage-set! k after))) + context))) + + +(defn sentry-span + "Wraps Event Handler into Sentry Span for measurement." + [span-name] + (rf/->interceptor + :id :sentry-span + :before (fn [context] + (let [tx-running? (sentry/tx-running?) + auto-tx (if tx-running? + (sentry/transaction-get-current) + (sentry/transaction-start (str span-name "-auto-tx"))) + existing-span (sentry/span-active) + sentry-span (sentry/span-start (or existing-span + auto-tx) + span-name)] + (cond-> (assoc context :sentry-span sentry-span) + (not tx-running?) (assoc :sentry-auto-tx auto-tx)))) + :after (fn [context] + (let [sentry-span (:sentry-span context) + auto-tx (:sentry-auto-tx context)] + (when sentry-span + (sentry/span-finish sentry-span)) + (when auto-tx + (sentry/transaction-finish auto-tx)) + (cond-> (dissoc context :sentry-span) + auto-tx (dissoc :sentry-auto-tx)))))) + + +(defn sentry-span-no-new-tx + "Wraps Event Handler into Sentry Span for measurement when there is no running tx." + [span-name] + (rf/->interceptor + :id :sentry-span + :before (fn [context] + (let [tx-running? (sentry/tx-running?) + auto-tx (when tx-running? + (sentry/transaction-get-current)) + existing-span (sentry/span-active) + sentry-span (sentry/span-start (or existing-span + auto-tx) + span-name)] + (cond-> (assoc context :sentry-span sentry-span) + (not tx-running?) (assoc :sentry-auto-tx auto-tx)))) + :after (fn [context] + (let [sentry-span (:sentry-span context) + auto-tx (:sentry-auto-tx context)] + (when sentry-span + (sentry/span-finish sentry-span)) + (when auto-tx + (sentry/transaction-finish auto-tx)) + (cond-> (dissoc context :sentry-span) + auto-tx (dissoc :sentry-auto-tx)))))) + + +(rf/reg-global-interceptor persist-db) diff --git a/src/cljs/athens/listeners.cljs b/src/cljs/athens/listeners.cljs new file mode 100644 index 0000000000..2f34767675 --- /dev/null +++ b/src/cljs/athens/listeners.cljs @@ -0,0 +1,308 @@ +(ns athens.listeners + (:require + [athens.common-db :as common-db] + [athens.db :as db] + [athens.electron.utils :as electron.utils] + [athens.events.selection :as select-events] + [athens.router :as router] + [athens.subs.selection :as select-subs] + [athens.util :as util] + [clojure.string :as string] + [goog.events :as events] + [re-frame.core :as rf :refer [dispatch dispatch-sync subscribe]]) + (:import + (goog.events + EventType + KeyCodes))) + + +(defn multi-block-selection + "When blocks are selected, handle various keypresses: + - shift+up/down: increase/decrease selection. + - enter: deselect and begin editing textarea + - backspace: delete all blocks + - up/down: change editing textarea + - tab: indent/unindent blocks + Can't use textarea-key-down from keybindings.cljs because textarea is no longer focused." + [e] + (let [selected-items @(subscribe [::select-subs/items])] + (when (not-empty selected-items) + (let [shift (.. e -shiftKey) + key-code (.. e -keyCode) + enter? (= key-code KeyCodes.ENTER) + bksp? (= key-code KeyCodes.BACKSPACE) + up? (= key-code KeyCodes.UP) + down? (= key-code KeyCodes.DOWN) + tab? (= key-code KeyCodes.TAB) + delete? (= key-code KeyCodes.DELETE)] + (cond + enter? (do + (dispatch [:editing/uid (first selected-items)]) + (dispatch [::select-events/clear])) + (or bksp? delete?) (do + (dispatch [::select-events/delete]) + (dispatch [::select-events/clear])) + tab? (do + (.preventDefault e) + (if shift + (dispatch [:unindent/multi {:uids selected-items}]) + (dispatch [:indent/multi {:uids selected-items}]))) + (and shift up?) (dispatch [:selected/up selected-items]) + (and shift down?) (dispatch [:selected/down selected-items]) + (or up? down?) (do + (.preventDefault e) + (dispatch [::select-events/clear]) + (if up? + (dispatch [:up (first selected-items) e]) + (dispatch [:down (last selected-items) e])))))))) + + +(defn unfocus + "Clears editing/uid when user clicks anywhere besides bullets, header, or on a block. + Clears selected/items when user clicks somewhere besides a bullet point." + [e] + (let [selected-items? (not-empty @(subscribe [::select-subs/items])) + editing-uid @(subscribe [:editing/uid]) + closest-block (.. e -target (closest ".block-content")) + closest-block-header (.. e -target (closest ".block-header")) + closest-page-header (.. e -target (closest ".page-header")) + closest-bullet (.. e -target (closest ".anchor")) + closest-dropdown (.. e -target (closest "#dropdown-menu")) + closest-context-menu (.. e -target (closest ".app-context-menu")) + closest (or closest-block closest-block-header closest-page-header closest-dropdown closest-context-menu)] + (when (and selected-items? + (nil? (or closest-bullet closest-context-menu))) + (dispatch [::select-events/clear])) + (when (and (nil? closest) + editing-uid) + (dispatch [:editing/uid nil])))) + + +;; -- Hotkeys ------------------------------------------------------------ + + +(defn key-down! + [e] + (let [{:keys [key-code + ctrl + meta + shift + alt] + :as destruct-keys} (util/destruct-key-down e) + editing-uid @(subscribe [:editing/uid]) + window-uid (or @(subscribe [:current-route/uid-compat]) + (when (= @(subscribe [:current-route/name]) :home) + ;; On daily notes, assume you're on the first note. + (-> @(subscribe [:daily-notes/items]) + first)))] + (cond + (and (nil? editing-uid) + window-uid + (= key-code KeyCodes.UP)) (dispatch [:editing/last-child window-uid]) + + (and (nil? editing-uid) + window-uid + (= key-code KeyCodes.DOWN)) (dispatch [:editing/first-child window-uid]) + + (util/navigate-key? destruct-keys) (condp = key-code + KeyCodes.LEFT (when (nil? editing-uid) + (.back js/window.history)) + KeyCodes.RIGHT (when (nil? editing-uid) + (.forward js/window.history)) + nil) + (util/shortcut-key? meta ctrl) (condp = key-code + KeyCodes.S (dispatch [:save]) + KeyCodes.EQUALS (dispatch [:zoom/in]) + KeyCodes.DASH (dispatch [:zoom/out]) + KeyCodes.ZERO (dispatch [:zoom/reset]) + KeyCodes.K (do + (dispatch [:athena/toggle]) + (.. e preventDefault)) + KeyCodes.Z (do + ;; Disable the default undo behaviour. + ;; Chrome has a textarea undo that does not behave like + ;; we want undo to behave. + (.. e preventDefault) + ;; Dispatch our custom undo/redo. + (if shift + (dispatch [:redo]) + (dispatch [:undo]))) + KeyCodes.O (do + ;; Disable the default "Open file..." behaviour. + ;; We use this for navigation instead. + (.. e preventDefault) + (when alt + ;; When alt is also pressed, zoom out of current block page + (when-let [parent-uid (->> [:block/uid @(subscribe [:current-route/uid])] + (common-db/get-parent-eid @db/dsdb) + second)] + (rf/dispatch [:reporting/navigation {:source :kbd-ctrl-alt-o + :target :block + :pane (if shift + :right-pane + :main-pane)}]) + (router/navigate-uid parent-uid e)))) + KeyCodes.BACKSLASH (if shift + (dispatch [:right-sidebar/toggle]) + (dispatch [:left-sidebar/toggle])) + KeyCodes.COMMA (do + (rf/dispatch [:reporting/navigation {:source :kbd-ctrl-comma + :target :settings + :pane :main-pane}]) + (router/navigate :settings)) + KeyCodes.T (util/toggle-10x) + nil) + alt (condp = key-code + KeyCodes.D (do + (rf/dispatch [:reporting/navigation {:source :kbd-alt-d + :target :home + :pane :main-pane}]) + (router/nav-daily-notes) + (.. e preventDefault)) + KeyCodes.G (do + (rf/dispatch [:reporting/navigation {:source :kbd-alt-g + :target :graph + :pane :main-pane}]) + (router/navigate :graph)) + KeyCodes.A (do + (rf/dispatch [:reporting/navigation {:source :kbd-alt-a + :target :all-pages + :pane :main-pane}]) + (router/navigate :pages)) + KeyCodes.T (dispatch [:theme/toggle]) + nil)))) + + +;; -- Clipboard ---------------------------------------------------------- + +(defn unformat-double-brackets + "https://github.com/ryanguill/roam-tools/blob/eda72040622555b52e40f7a28a14744bce0496e5/src/index.js#L336-L345" + [s] + (-> s + (string/replace #"\[([^\[\]]+)\]\((\[\[|\(\()([^\[\]]+)(\]\]|\)\))\)" "$1") + (string/replace #"\[\[([^\[\]]+)\]\]" "$1"))) + + +(defn block-refs-to-plain-text + "If there is a valid ((uid)), find the original block's string. + If invalid ((uid)), no-op. + TODO: If deep block ref, convert deep block ref to plain-text. + + Want to put this in athens.util, but circular dependency from athens.db" + [s] + (let [replacements (->> s + (re-seq #"\(\(([^\(\)]+)\)\)") + (map (fn [[orig-str match-str]] + (let [eid (db/e-by-av :block/uid match-str)] + (if eid + [orig-str (str "((" (db/v-by-ea eid :block/string) "))")] + [orig-str (str "((" match-str "))")])))))] + (loop [replacements replacements + s s] + (let [[orig-str replace-str] (first replacements)] + (if (empty? replacements) + s + (recur (rest replacements) + (clojure.string/replace s orig-str replace-str))))))) + + +(defn blocks-to-clipboard-data + "Four spaces per depth level." + ([depth node] + (blocks-to-clipboard-data depth node false)) + ([depth node unformat?] + (let [{:block/keys [string + children + _header]} node + left-offset (apply str (repeat depth " ")) + walk-children (apply str (map #(blocks-to-clipboard-data (inc depth) % unformat?) + children)) + string (if unformat? + (-> string + unformat-double-brackets + block-refs-to-plain-text) + (block-refs-to-plain-text string)) + dash (if unformat? "" "- ")] + (str left-offset dash string "\n" walk-children)))) + + +(defn copy + "If blocks are selected, copy blocks as markdown list. + Use -event_ because goog events quirk " + [^js e] + (let [uids @(subscribe [::select-subs/items])] + (when (not-empty uids) + (let [uids (mapv (comp first db/uid-and-embed-id) uids) + copy-data (->> uids + (map #(common-db/get-block-document @db/dsdb [:block/uid %])) + (map #(blocks-to-clipboard-data 0 %)) + (apply str)) + clipboard-data (.. e -event_ -clipboardData) + copied-blocks (mapv + #(common-db/get-internal-representation @db/dsdb [:block/uid %]) + uids)] + + (doto clipboard-data + (.setData "text/plain" copy-data) + (.setData "application/athens-representation" (pr-str copied-blocks)) + (.setData "application/athens" (pr-str {:uids uids}))) + (.preventDefault e))))) + + +(defn cut + "Cut is essentially copy AND delete selected blocks" + [^js e] + (let [uids @(subscribe [::select-subs/items])] + (when (not-empty uids) + (copy e) + (dispatch [::select-events/delete])))) + + +(def force-leave (atom false)) + + +(defn prevent-save + "Google Closure's events/listen isn't working for some reason anymore. + + beforeunload is called before unload, where the window would be redirected/refreshed/quit. + https://developer.mozilla.org/en-US/docs/Web/API/Window/beforeunload_event " + [] + (js/window.addEventListener + EventType.BEFOREUNLOAD + (fn [e] + (let [synced? @(subscribe [:db/synced]) + ;; See test/e2e/electron-test.ts for details about this flag. + e2e-ignore-save? (= (js/localStorage.getItem "E2E_IGNORE_SAVE") "true") + remote? (electron.utils/remote-db? @(subscribe [:db-picker/selected-db]))] + (cond + (and (not synced?) + (not @force-leave) + (not e2e-ignore-save?)) + (do + ;; The browser blocks the confirm window during beforeunload, so + ;; instead we always cancel unload and separately show a confirm window + ;; that allows closing the window. + (dispatch [:confirm/js + (str "Athens hasn't finished saving yet. Athens is finished saving when the sync dot is green. " + "Try refreshing or quitting again once the sync is complete. " + "Press Cancel to wait, or OK to leave without saving (will cause data loss!).") + (fn [] + (reset! force-leave true) + (js/window.close)) + #()]) + (.. e preventDefault) + (set! (.. e -returnValue) "Setting e.returnValue to string prevents exit for some browsers.") + "Returning a string also prevents exit on other browsers.") + + remote? + (dispatch-sync [:remote/disconnect!])))))) + + +(defn init + [] + (events/listen js/document EventType.MOUSEDOWN unfocus) + (events/listen js/window EventType.KEYDOWN multi-block-selection) + (events/listen js/window EventType.KEYDOWN key-down!) + (events/listen js/window EventType.COPY copy) + (events/listen js/window EventType.CUT cut) + (prevent-save)) diff --git a/src/cljs/athens/main/core.cljs b/src/cljs/athens/main/core.cljs new file mode 100644 index 0000000000..98c74bdff9 --- /dev/null +++ b/src/cljs/athens/main/core.cljs @@ -0,0 +1,192 @@ +^:cljstyle/ignore +(ns athens.main.core + (:require + ["electron" :refer [app BrowserWindow Menu ipcMain shell]] + ["electron-updater" :refer [autoUpdater]] + ["electron-window-state" :as electron-window-state] + [athens.electron.utils :as electron.utils] + [athens.menu :refer [menu-template]])) + + +;; This flag controls whether we check for updates on startup. +;; We use electron-updater with github releases for updates. +;; This setup does not support the use of electron-updater release channels, +;; and instead supports only the release/prerelease distinction. +;; +;; If AUTO_UPDATE is false, the app will never check for updates. +;; If AUTO_UPDATE is true: +;; - if the app version contains prerelease components (e.g. 1.0.0-beta.20, where beta.20 +;; is the prerelease component), electron-updater will check github releases+prereleases, +;; otherwise it will just check releases. +;; - releases without latest*.yml files are ignored (these files are controlled via the +;; electron-builder -c.publish.publishAutoUpdate=true arg). +;; - if there's a bigger (according to semver) release, show an update prompt. +;; +;; These flags can be set on .github/workflows/build.yml: +;; - AUTO_UPDATE can be set on the build-app job together with other defines +;; - -c.publish.publishAutoUpdate=true can be set on the release-electron job on the +;; action-electron-builder args +(goog-define AUTO_UPDATE true) + +(set! (.. autoUpdater -logger) (electron.utils/log)) +(set! (.. autoUpdater -logger -transports -file -level) "info") +(set! (.. autoUpdater -autoDownload) false) +(set! (.. autoUpdater -autoInstallOnAppQuit) false) + +(.. (electron.utils/log) (info (str "Athens starting... " "version=" (.getVersion app)))) + + +(defonce main-window (atom nil)) +(defonce update-available? (atom nil)) + + +(defn send-status-to-window + [text] + (.. (electron.utils/log) (info text)) + (when @main-window + (.. ^js @main-window -webContents (send text)))) + + +(defn init-electron-handlers + [] + (doto ipcMain + (.handle (:toggle-max-or-min-win-channel electron.utils/ipcMainChannels) + (fn [_ toggle-min?] + (when-let [active-win (.getFocusedWindow BrowserWindow)] + (if toggle-min? + (if (.isMinimized active-win) + (.restore active-win) + (.minimize active-win)) + (if (.isMaximized active-win) + (.unmaximize active-win) + (.maximize active-win)))))) + (.handle (:close-win-channel electron.utils/ipcMainChannels) + (fn [] + (.quit app))) + (.handle (:exit-fullscreen-win-channel electron.utils/ipcMainChannels) + (fn [] + (when-let [active-win (.getFocusedWindow BrowserWindow)] + (.setFullScreen active-win false))))) + + ;; Future intent to refactor statup to use startup and teardown effects + ;; Below is an example of the teardown effect for this init fn + ;; #(doall (.removeHandler ipcMain toggle-max-or-min-win-channel) + ;; (.removeHandler ipcMain close-win-channel) + ;; (.removeHandler ipcMain exit-fullscreen-win-channel)) + ) + + +(def quitting (atom false)) + + +(defn init-browser + [] + (let [main-window-state (electron-window-state #js {:defaultWidth 800 + :defaultHeight 600})] + (reset! main-window (BrowserWindow. + (clj->js {:x (.-x main-window-state) + :y (.-y main-window-state) + :width (.-width main-window-state) + :height (.-height main-window-state) + :minWidth 400 ; Minimum width before clipping in toolbar + :minHeight 300 + :backgroundColor "#1A1A1A" + :autoHideMenuBar true + :frame false + :titleBarStyle "hidden" + :trafficLightPosition {:x 19, :y 33} + :webPreferences {:contextIsolation false + :nodeIntegration true + :worldSafeExecuteJavaScript true + ;; Using the remote module is slow can can lead to suble race conditions. + ;; https://nornagon.medium.com/electrons-remote-module-considered-harmful-70d69500f31 + ;; If we're seeing weird race conditions on node modules, check this article. + :enableRemoteModule true + ;; Remove OverlayScrollbars and instances of `overflow-y: overlay` + ;; after `scollbar-gutter` is implemented in browsers. + :enableBlinkFeatures 'OverlayScrollbars' + :nodeIntegrationWorker true}}))) + (.manage main-window-state @main-window) + ;; Path is relative to the compiled js file (main.js in our case) + (.loadURL ^js @main-window (str "file://" js/__dirname "/public/index.html")) + (.on ^js @main-window "closed" #(reset! main-window nil)) + ;; On mac, hide the window instead of closing to keep transient state. + ;; https://stackoverflow.com/a/45156004/2116927 + ;; Also see remaining code from the example in the `main` fn below. + (.on ^js @main-window "close" (fn [e] + (when (and (= js/process.platform "darwin") + (not @quitting)) + (.. e preventDefault) + (.. ^js @main-window hide)))) + (.. ^js @main-window -webContents (on "new-window" (fn [e url] + (.. e preventDefault) + (.. shell (openExternal url))))))) + + +(defn init-updater + [] + (.on autoUpdater "checking-for-update" + (fn [] + (send-status-to-window "Checking for update..."))) + + (.on autoUpdater "update-available" + (fn [_] + (reset! update-available? true) + (send-status-to-window "Update available."))) + + (.on autoUpdater "update-not-available" + (fn [_] + (reset! update-available? false) + (send-status-to-window "Update not available."))) + + (.on autoUpdater "error" + (fn [e] + (send-status-to-window (str "Error in auto-updater. " e)))) + + (.on autoUpdater "download-progress" + (fn [progress-obj] + (let [progress-clj (js->clj progress-obj) + {:keys [bytesPerSecond percent transferred total]} progress-clj + msg (str "Download speed: " bytesPerSecond + " - Downloaded " percent "%" + " (" transferred "/" total ")")] + (send-status-to-window msg)))) + + (.on autoUpdater "update-downloaded" + (fn [_] + (send-status-to-window "Update downloaded.") + (.. autoUpdater quitAndInstall)))) + + +(defn init-ipcMain + [] + (.on ipcMain "check-update" + (fn [e _] + (set! (.. e -returnValue) @update-available?))) + (.on ipcMain "confirm-update" + (fn [_ _] + (.. autoUpdater downloadUpdate)))) + + +(defn init-menu + [] + (.setApplicationMenu Menu (.buildFromTemplate Menu menu-template))) + + +(defn main + [] + (.on app "window-all-closed" #(when-not (= js/process.platform "darwin") + (.quit app))) + (.on app "before-quit" #(reset! quitting true)) + (.on app "activate" (fn [] + (if (nil? @main-window) + (init-browser) + (.show @main-window)))) + (.on app "ready" (fn [] + (init-ipcMain) + (init-menu) + (init-browser) + (init-electron-handlers) + (when AUTO_UPDATE + (init-updater) + (.. autoUpdater checkForUpdates))))) diff --git a/src/cljs/athens/menu.cljs b/src/cljs/athens/menu.cljs new file mode 100644 index 0000000000..9adb81619f --- /dev/null +++ b/src/cljs/athens/menu.cljs @@ -0,0 +1,36 @@ +(ns athens.menu + (:require + ["electron" :refer [shell]])) + + +(def isMac (= (.-platform js/process) "darwin")) + + +(def menu-template + (clj->js (into [] (concat + (when isMac [{:role "appMenu"}]) + [{:role "fileMenu"} + {:role "editMenu"} + {:label "View" + :submenu [{:role "reload"} + {:role "forceReload"} + {:role "toggleDevTools"} + {:type "separator"} + ;; Default zoom tools disabled so we can own + ;; zoom control internally. It would be better + ;; to remap these items to commands which + ;; perform the described action, using our + ;; internal zoom events. + ;; {:role "resetZoom"} + ;; {:role "zoomIn"} + ;; {:role "zoomOut"} + {:type "separator"} + {:role "togglefullscreen"}]} + {:role "windowMenu"}] + [(if isMac + {:role "help" + :submenu [{:label "Learn More" + :click #(.openExternal shell "https://github.com/athensresearch/athens")}]} + {:label "Help" + :submenu [{:label "Learn More" + :click #(.openExternal shell "https://github.com/athensresearch/athens")}]})])))) diff --git a/src/cljs/athens/page.cljs b/src/cljs/athens/page.cljs deleted file mode 100644 index e068799ab5..0000000000 --- a/src/cljs/athens/page.cljs +++ /dev/null @@ -1,108 +0,0 @@ -(ns athens.page - (:require [athens.parser :refer [parse]] - [reagent.core :as reagent] - [re-frame.core :refer [subscribe dispatch]] - [reitit.frontend.easy :as rfee])) - -(defn render-blocks [block-uid] - (fn [block-uid] - (let [block (subscribe [:block/children-sorted [:block/uid block-uid]])] - [:div - (doall - (for [ch (:block/children @block)] - (let [{:block/keys [uid string open children] dbid :db/id} ch - children? (not (zero? (count children)))] - ^{:key uid} - [:div - [:div.block {:style {:display "flex"}} - [:div.controls {:style {:display "flex" :align-items "flex-start" :padding-top 5}} - (cond - (and children? open) [:span.arrow-down {:style {:width 0 :height 0 - :border-left "5px solid transparent" - :border-right "5px solid transparent" - :border-top "5px solid black" - :cursor "pointer" - :margin-top 4} - :on-click #(dispatch [:block/toggle-open dbid open])}] - (and children? (not open)) [:span.arrow-right {:style {:width 0 :height 0 - :border-top "5px solid transparent" - :border-bottom "5px solid transparent" - :border-left "5px solid black" - :cursor "pointer" - :margin-right 4} - :on-click #(dispatch [:block/toggle-open dbid open])}] - :else [:span {:style {:width 10}}]) - [:span {:style {:height 12 :width 12 :border-radius "50%" :margin-right 5 - :cursor "pointer" :display "flex" :background-color (if (not open) "lightgray" nil) - :vertical-align "middle" :align-items "center" :justify-content "center"}} - [:span.controls {:style {:height 5 :width 5 :border-radius "50%" - :cursor "pointer" :display "inline-block" :background-color "black" - :vertical-align "middle"} - :on-click #(dispatch [:navigate :page {:id uid}])}]]] - [:span (parse string)]] - (when open - [:div {:style {:margin-left 20}} - [render-blocks uid]])])))]))) - -; match [[title]] or #title or #[[title]] -(defn linked-pattern [string] - (re-pattern (str "(" - "\\[{2}" string "\\]{2}" - "|" "#" string - "|" "#" "\\[{2}" string "\\[{2}" - ")"))) - -; also excludes [title] :( -(defn unlinked-pattern [string] - (re-pattern (str "[^\\[|#]" string))) - -(defn block-page [id] - (fn [id] - (let [node (subscribe [:node [:block/uid id]]) - parents (subscribe [:block/_children2 [:block/uid id]])] - [:div - [:span {:style {:color "gray"} } - (interpose " > " - (map (fn [b] - (let [{:block/keys [uid string] :node/keys [title]} b] - ^{:key uid} - [:span - {:style {:cursor "pointer"} - :on-click #(dispatch [:navigate :page {:id uid}])} - (or string title)])) - @parents))] - [:h2 {:style {:margin 0}} (str "• " (:block/string @node))] - [:div {:style {:margin-left 20}} - [render-blocks (:block/uid @node)]]]))) - -(defn node-page [node] - (fn [node] - (let [linked-refs (subscribe [:node/refs (linked-pattern (:node/title node))]) - unlinked-refs (subscribe [:node/refs (unlinked-pattern (:node/title node))])] - [:div - [:h2 (:node/title node)] - [render-blocks (:block/uid node)] - [:div - [:h3 "Linked References"] - [:div - (for [id (reduce into [] @linked-refs)] - ^{:key id} - [:div {:style {:background-color "lightblue" :margin "15px 0px" :padding 5}} - [block-page id]])]] - [:div - [:h3 "Unlinked References"] - [:div - (for [id (reduce into [] @unlinked-refs)] - ^{:key id} - [:div {:style {:background-color "lightblue" :margin "15px 0px" :padding 5}} - [block-page id]])]]]))) - -(defn main [] - (let [current-route (subscribe [:current-route])] - (fn [] - (let [node (subscribe [:node [:block/uid (-> @current-route :path-params :id)]])] - [:div - ;;[:h1 "Page Panel"] - (if (:node/title @node) - [node-page @node] - [block-page (:block/uid @node)])])))) diff --git a/src/cljs/athens/parse_renderer.cljs b/src/cljs/athens/parse_renderer.cljs new file mode 100644 index 0000000000..eb992ce7d5 --- /dev/null +++ b/src/cljs/athens/parse_renderer.cljs @@ -0,0 +1,424 @@ +^:cljstyle/ignore +(ns athens.parse-renderer + (:require + ["@chakra-ui/react" :refer [Link Button Text Box]] + ["katex" :as katex] + ["katex/dist/contrib/mhchem"] + [athens.config :as config] + [athens.parser.impl :as parser-impl] + [athens.reactive :as reactive] + [athens.router :as router] + [athens.types.core :as types] + [athens.types.dispatcher :as block-type-dispatcher] + [clojure.string :as str] + [instaparse.core :as insta] + [re-frame.core :as rf] + [reagent.core :as r])) + + +(declare parse-and-render) + + +(def fm-props + {:as "b" + :class "fmt" + :whiteSpace "nowrap" + :fontWeight "normal" + :opacity "0.3"}) + + +(def link-props + {:color "link" + :borderRadius "1px" + :variant "link" + :minWidth "0" + :whiteSpace "inherit" + :wordBreak "inherit" + :alignItems "flex-start" + :justifyContent "flex-start" + :lineHeight "unset" + :textAlign "inherit" + :fontSize "inherit" + :fontWeight "inherit" + :textDecoration "none"}) + + +(defn page-link-el + [] + (let [this (r/current-component)] + (into [:> Box {:as "span" + :sx {".link" {:color "link" + :borderRadius "1px" + :cursor "pointer" + :minWidth "0" + :whiteSpace "inherit" + :wordBreak "inherit" + :alignItems "flex-start" + :justifyContent "flex-start" + :lineHeight "unset" + :position "relative" + :textAlign "inherit" + :display "inline" + :fontSize "inherit" + :fontWeight "inherit" + :textDecoration "none"} + ".fmt" (merge {:whiteSpace "nowrap" + :display "inline" + :color "foreground.secondary" + :fontWeight "normal" + :opacity "0.3"})}}] + (r/children this)))) + + +(defn parse-title + "Title coll is a sequence of plain strings or hiccup elements. If string, return string, otherwise parse the hiccup + for its plain-text representation." + [title-coll] + (->> (map (fn [el] + (if (string? el) + el + (str "[[" (str/join (get-in el [2 2])) "]]"))) title-coll) + (str/join ""))) + + +(defn render-page-link + "Renders a page link given the title of the page." + [{:keys [from title]} title-coll] + [page-link-el + [:span {:class "fmt"} "[["] + (cond + (not (str/blank? title)) + [:span {:class "link" + :title from + :on-click (fn [e] + (let [parsed-title (parse-title title-coll) + shift? (.-shiftKey e)] + (.. e stopPropagation) ; prevent bubbling up click handler for nested links + (rf/dispatch [:reporting/navigation {:source :pr-page-link + :target :page + :pane (if shift? + :right-pane + :main-pane)}]) + (router/navigate-page parsed-title e)))} + title] + + :else + (into [:span {:class "link" + :title from + :on-click (fn [e] + (let [parsed-title (parse-title title-coll) + shift? (.-shiftKey e)] + (.. e stopPropagation) ; prevent bubbling up click handler for nested links + (rf/dispatch [:reporting/navigation {:source :pr-page-link + :target :page + :pane (if shift? + :right-pane + :main-pane)}]) + (router/navigate-page parsed-title e)))}] + title-coll)) + [:span {:class "fmt"} "]]"]]) + + +;; -- Component --- + +(def components + {#"\[\[TODO\]\]" :todo + #"\[\[DONE\]\]" :done + #"\[\[youtube\]\]\:.*" :youtube + #"iframe\:.*" :iframe + #"SELF" :self + #"\[\[embed\]\]: \(\(.+\)\)" :block-embed}) + + +(defmulti component + (fn [content _uid] + (some (fn [[pattern render]] + (when (re-matches pattern content) + render)) + components))) + + +(defmethod component :default + [content _] + [:button content]) + + +;; Components + + +(defn- clean-single-p-appending + [parent & contents] + (if (and (= 1 (count contents)) + (= :p (ffirst contents))) + (let [rest-of-p (-> contents first rest)] + (apply conj parent rest-of-p)) + (apply conj parent contents))) + + +;; Instaparse transforming docs: https://github.com/Engelberg/instaparse#transforming-the-tree +(defn transform + "Transforms Instaparse output to Hiccup." + [tree uid] + (insta/transform + {:block (fn [& contents] + (apply clean-single-p-appending + [:span {:class "block"}] + contents)) + :heading (fn [{n :n} & contents] + (apply clean-single-p-appending + [({1 :h1 + 2 :h2 + 3 :h3 + 4 :h4 + 5 :h5 + 6 :h6} n)] + contents)) + + ;; for more information regarding how custom components are parsed, see + ;; https://athensresearch.gitbook.io/handbook/athens/athens-components-documentation/ + :component (fn [& contents] + (let [content (first contents)] + ^{:key content} + [component (first contents) uid])) + :page-link (fn [{_from :from :as attr} & title-coll] + (render-page-link attr title-coll)) + :hashtag (fn [{_from :from} & title-coll] + [:> Button (merge link-props + {:variant "link" + :class "hashtag" + :color "inherit" + :fontWeight "inherit" + :_hover {:textDecoration "none"} + :onClick (fn [e] + (let [parsed-title (parse-title title-coll) + shift? (.-shiftKey e)] + (rf/dispatch [:reporting/navigation {:source :pr-hashtag + :target :hashtag + :pane (if shift? + :right-pane + :main-pane)}]) + (router/navigate-page parsed-title e)))}) + [:> Text fm-props "#"] + [:span {:class "contents"} title-coll]]) + :block-ref (fn [{_from :from :as attr} ref-uid] + (let [block (reactive/get-reactive-block-or-page-by-uid ref-uid) + block-type (reactive/reactive-get-entity-type [:block/uid ref-uid]) + ff @(rf/subscribe [:feature-flags]) + renderer-k (block-type-dispatcher/block-type->protocol-k block-type ff) + renderer (block-type-dispatcher/block-type->protocol renderer-k {})] + ^{:key renderer-k} + [types/inline-ref-view renderer block attr ref-uid uid {} true])) + :url-image (fn [{url :src alt :alt}] + [:> Box {:class "url-image" + :as "img" + :borderRadius "md" + :alt alt + :src url}]) + :url-link (fn [{url :url} text] + [:> Button + (merge link-props {:class "url-link" + :href url + :target "_blank"}) + text]) + :link (fn [{:keys [text target title]}] + [:> Button (cond-> (merge link-props + {:class "url-link contents" + :as "a" + :href target + :target "_blank"}) + (string? title) + (assoc :title title)) + text]) + :autolink (fn [{:keys [text target]}] + [:<> + [:> Text fm-props "<"] + [:> Link (merge + link-props + {:class "autolink contents" + :href target + :target "_blank"}) + text] + [:> Text fm-props ">"]]) + :text-run (fn [& contents] + (apply conj [:span {:class "text-run"}] contents)) + :paragraph (fn [& contents] + (apply conj [:p] contents)) + :bold (fn [& contents] + (apply conj [:strong {:class "contents bold"}] contents)) + :italic (fn [& contents] + (apply conj [:i {:class "contents italic"}] contents)) + :strikethrough (fn [& contents] + (apply conj [:del {:class "contents del"}] contents)) + :underline (fn [& contents] + (apply conj [:u {:class "contents underline"}] contents)) + :highlight (fn [& contents] + (apply conj [:mark {:class "contents highlight"}] contents)) + :pre-formatted (fn [text] + [:code text]) + :inline-pre-formatted (fn [text] + [:code text]) + :indented-code-block (fn [{:keys [_from]} code-text] + (let [text (second code-text)] + [:pre + [:code text]])) + :fenced-code-block (fn [{lang :lang} code-text] + (let [mode (or lang "javascript") + text (second code-text)] + (when config/debug? + (js/console.log "Block code, original-mode:" lang + ", mode:" mode + ", text:" text)) + [:pre + [:code text]] + ;; TODO: Followup issue: #989 "Integrate with CodeMirror for code blocks" + #_[:> CodeMirror {:value text + :options {:mode mode + :lineNumbers true + :matchBrackets true + :autoCloseBrackets true + :extraKeys #js {"Esc" (fn [editor] + ;; TODO: save when needed + (js/console.log "[Esc]") + (if (= text @local-value) + (js/console.log "[Esc] no changes") + (do + ;; TODO Save + )))}} + :on-change (fn [editor data value] + (js/console.log "on-change" editor (pr-str data) (pr-str value)) + (when-not (= @local-value value) + (js/console.log "on-change, updating local state" value) + (reset! local-value value))) + :on-blur (fn [editor event] + (js/console.log "on-blur") + (if (= text @local-value) + (js/console.log "on-blur, content not modified") + (do + (js/console.log "on-blur, content modified" + (pr-str text) + "=>" + (pr-str @local-value)) + ;; update value based on `uid` + )))}])) + + :latex (fn [text] + [:span {:ref (fn [el] + (when el + (try + (katex/render text el (clj->js + {:throwOnError false})) + (catch :default e + (js/console.warn "Unexpected KaTeX error" e) + (aset el "innerHTML" text)))))}]) + :newline (fn [_] + [:br])} + tree)) + + +(defn transform->text + "Transforms Instaparse output to Hiccup." + [tree] + (insta/transform + {:block (fn [& contents] + (str/join contents)) + :heading (fn [{n :n} & contents] + (str (str/join (repeat n "*")) + " " + (str/join contents))) + + ;; for more information regarding how custom components are parsed, see + ;; https://athensresearch.gitbook.io/handbook/athens/athens-components-documentation/ + :component (fn [& contents] + (let [content (first contents)] + (str "{{" content ":" (str/join (rest contents)) "}}"))) + :page-link (fn [_ & title-coll] + (str "[[" (str/join title-coll) "]]")) + :hashtag (fn [{_from :from} & title-coll] + (str "#" (str/join title-coll))) + :block-ref (fn [{_from :from :as attr} ref-uid] + (let [block (reactive/get-reactive-block-or-page-by-uid ref-uid) + block-type (reactive/reactive-get-entity-type [:block/uid ref-uid]) + ff @(rf/subscribe [:feature-flags]) + renderer-k (block-type-dispatcher/block-type->protocol-k block-type ff) + renderer (block-type-dispatcher/block-type->protocol renderer-k {})] + (str "((" (types/text-view renderer block attr) "))"))) + :url-image (fn [{url :src alt :alt}] + (str "![" alt "](" url ")")) + :url-link (fn [{url :url} text] + (str "[" text "](" url ")")) + :link (fn [{:keys [text target title]}] + (str "[" title "](" target + (when (string? text) + (str " " text)) + ")")) + :autolink (fn [{:keys [text _target]}] + (str "<" text ">")) + :text-run (fn [& contents] + (str/join contents)) + :paragraph (fn [& contents] + (str/join contents)) + :bold (fn [& contents] + (str "*" (str/join contents) "*")) + :italic (fn [& contents] + (str "**" (str/join contents) "**")) + :strikethrough (fn [& contents] + (str "~~" (str/join contents) "~~")) + :underline (fn [& contents] + (str "__" (str/join contents) "__")) + :highlight (fn [& contents] + (str "^^" (str/join contents) "^^")) + :pre-formatted (fn [text] + (str "`" text "`")) + :inline-pre-formatted (fn [text] + (str "`" text "`")) + :indented-code-block (fn [{:keys [_from]} code-text] + (->> code-text + (map #(str " " %)) + (str/join "\n"))) + :fenced-code-block (fn [{lang :lang} code-text] + (let [text (second code-text)] + (str "```" lang "\n" + text "\n```"))) + + :latex (fn [text] + (str "$$" text "$$")) + :newline (fn [_] + "\n")} + tree)) + + +(defn parse-and-render + "Converts a string of block syntax to Hiccup, with fallback formatting if it can’t be parsed." + [string uid] + (when config/measure-parser? + (js/console.group string)) + (let [pt-n-1 (js/performance.now) + result (parser-impl/staged-parser->ast string) + pt-n-2 (js/performance.now) + pt-n-total (- pt-n-2 pt-n-1)] + (when config/measure-parser? + (js/console.log "parsing time:" pt-n-total)) + (if (insta/failure? result) + (do + (when config/measure-parser? + (js/console.groupEnd)) + [:abbr {:title (pr-str (insta/get-failure result)) + :style {:color "red"}} + string]) + (let [vt-1 (js/performance.now) + view (transform result uid) + vt-2 (js/performance.now) + vt-total (- vt-2 vt-1)] + (when config/measure-parser? + (js/console.log "view creation:" vt-total) + (js/console.groupEnd)) + view)))) + + +(defn parse-to-text + [string] + (let [ast (parser-impl/staged-parser->ast string) + result (if (insta/failure? ast) + (insta/get-failure ast) + (transform->text ast))] + (str/join result))) diff --git a/src/cljs/athens/parser.cljs b/src/cljs/athens/parser.cljs deleted file mode 100644 index 0a8cf259d9..0000000000 --- a/src/cljs/athens/parser.cljs +++ /dev/null @@ -1,46 +0,0 @@ -(ns athens.parser - (:require [instaparse.core :as insta] - [reitit.frontend.easy :as rfee] - [re-frame.core :refer [subscribe]])) - -(declare transform parse) - -(def parser - (insta/parser - "S = c | link | bref | hash - = #'(\\w|\\s)+' - link = <'[['> c <']]'> - hash = <'#'> c | <'#'> <'[['> c <']]'> - bref = <'(('> c <'))'> - ")) - -(defn transform - "Transforms instaparse output to hiccup." - [tree] - (insta/transform - {:S (fn [x] [:span x]) - :link (fn [title] - (let [id (subscribe [:block/uid [:node/title title]])] - [:span - [:span {:style {:color "gray"}} "[["] - [:a {:href (rfee/href :page {:id (:block/uid @id)}) - :style {:text-decoration "none" :color "dodgerblue"}} title] - [:span {:style {:color "gray"}} "]]"] - ])) - :hash (fn [title] - (let [id (subscribe [:block/uid [:node/title title]])] - [:a {:style {:color "gray" :text-decoration "none" :font-weight "bold"} - :href (rfee/href :page {:id (:block/uid @id)})} - (str "#" title)])) - :bref (fn [id] - (let [string (subscribe [:block/string [:block/uid id]])] - [:span {:style {:font-size "0.9em" :border-bottom "1px solid gray"}} - [:a {:href (rfee/href :page {:id id})} (parse (:block/string @string))]]))} - tree)) - - -(defn parse [str] - (let [result (parser str)] - (if (insta/failure? result) - [:span {:style {:color "red"}} str] - [:span (vec (transform result))]))) diff --git a/src/cljs/athens/reactive.cljs b/src/cljs/athens/reactive.cljs new file mode 100644 index 0000000000..c463482733 --- /dev/null +++ b/src/cljs/athens/reactive.cljs @@ -0,0 +1,209 @@ +(ns athens.reactive + "Functions that will reactively update Reagent components when their DataScript data changes. + Also contains functions to start, stop, and inspect the reactive watchers. + Functions in this namespace should be used very deliberately to avoid performance overheads. + No other namespace should import posh.reagent." + (:require + [athens.common-db :as common-db] + [athens.common.sentry :refer-macros [defntrace]] + [athens.common.utils :as utils] + [athens.dates :as dates] + [athens.db :as db] + [datascript.core :as d] + [posh.reagent :as p])) + + +(defn watch! + "Watch the global datascript database." + [] + (p/posh! db/dsdb)) + + +(defn unwatch! + "Unwatch the global datascript database. + While unwatched, all get-reactive-* fns will return non-reactive pulls and queries." + [] + ;; Watching a new conn will remove all old watchers. + ;; You can verify this by printing watch-state. + (p/posh! (d/create-conn))) + + +(defn init! + "Initialize the reactive watchers. + Must be called before watch-state or any of the get-reactive-* fns, or these will throw." + [] + ;; Add a remove the watcher to the global datascript db once. + ;; This will leave the posh datom connected to it, even after unwatch. + (watch!) + (unwatch!)) + + +(defn watch-state + [] + (-> (p/get-posh-atom db/dsdb) + deref + ;; all keys + ;; (:schema :filters :return :retrieve :txs :cache :dbs + ;; :schemas :ratoms :changed :graph :dcfg :reactions :conns) + + ;; These keys don't matter much. + (dissoc :schema :filters :dbs :conns :schemas :dcfg))) + + +(defn ratoms + "Returns current reactive atoms." + [] + (-> (watch-state) :ratoms)) + + +;; Initialization + +(init!) + + +;; Ratoms +;; NB: p/pull will not throw on missing ident, and will update the ratom when it exists. + + + +(defn get-reactive-linked-references + "For node and block page references UI." + [eid] + (->> @(p/pull db/dsdb '[:block/_refs] eid) + :block/_refs + (mapv :db/id) + db/eids->groups)) + + +(defn get-reactive-linked-properties + "For node page properties references UI." + [eid] + (->> @(p/pull db/dsdb '[:block/_key] eid) + :block/_key + (mapv :db/id) + db/eids->groups)) + + +(defn get-reactive-edited-on-day-blocks + "For node page edited on references UI." + [title] + (let [date (dates/title-to-date title) + day (dates/date-to-day date) + next-day (dates/get-day date -1) + start (-> day :inst inst-ms) + end (-> next-day :inst inst-ms)] + (->> @(p/q '[:find ?b + :in $ ?start ?end + :where + [?t :time/ts ?ts] + [(>= ?ts ?start)] + [(< ?ts ?end)] + [?e :event/time ?t] + [?b :block/edits ?e] + [?b :block/string _]] + db/dsdb start end) + (mapv first) + db/eids->groups))) + + +(def recursive-properties-document-pull-vector + '[{:block/_property-of [:block/uid :block/string :block/order :block/refs + {:block/key [:node/title]} + {:block/edits [{:event/time [:time/ts] + :event/auth [:presence/id]}]} + {:block/children ...} + {:block/_property-of ...}]}]) + + +(def node-document-pull-vector + (vec (concat '[:db/id :block/uid :node/title :page/sidebar + {:block/children [:block/uid :block/order]}] + recursive-properties-document-pull-vector))) + + +(defntrace get-reactive-node-document + [id] + (->> @(p/pull db/dsdb node-document-pull-vector id) + common-db/sort-block-children + common-db/add-property-map)) + + +(def block-document-pull-vector + (vec (concat '[:db/id :block/uid :block/string :block/open :block/_refs + {:block/key [:node/title]} + {:block/children [:block/uid :block/order]} + {:block/create [{:event/time [:time/ts]} + {:event/auth [:presence/id]}]} + {:block/edits [{:event/time [:time/ts]}]}] + recursive-properties-document-pull-vector))) + + +(defntrace get-reactive-block-document + [id] + (->> @(p/pull db/dsdb block-document-pull-vector id) + common-db/sort-block-children + common-db/add-property-map)) + + +(defntrace get-reactive-right-sidebar-item + [id] + (->> @(p/pull db/dsdb '[:db/id :block/uid :block/string :node/title] id))) + + +(defntrace get-reactive-parents-recursively + [id] + (->> @(p/pull db/dsdb '[:db/id :node/title :block/uid :block/string + {:block/edits [{:event/time [:time/ts]}]} + {:block/property-of ...} + {:block/_children ...}] + id) + db/shape-parent-query)) + + +(defntrace get-reactive-shortcuts + [] + (->> @(p/q '[:find ?order ?title + :where + [?e :page/sidebar ?order] + [?e :node/title ?title]] db/dsdb) + seq + (sort-by first))) + + +(defntrace get-reactive-block-or-page-by-uid + [uid] + @(p/pull db/dsdb '[:node/title :block/string :db/id] [:block/uid uid])) + + +(defntrace reactive-get-entity-type + "Reactive version of athens.common-db/get-entity-type." + [eid] + (->> @(p/pull db/dsdb '[{:block/_property-of [:block/string {:block/key [:node/title]}]}] eid) + :block/_property-of + (some (fn [e] + (when (-> e :block/key :node/title (= ":entity/type")) + (:block/string e)))))) + + +(defn get-reactive-instances-of-key-value + "Find all blocks that have key-value matching where + key is a string and value is a string, then find that property block's parent." + [k v] + (->> @(p/q '[:find [?parent ...] + :in $ ?key ?value + :where + [?eid :block/key ?k] + [?k :node/title ?key] + [?eid :block/string ?value] + [?eid :block/property-of ?parent]] + athens.db/dsdb k v) + (mapv get-reactive-block-document))) + + +(comment + ;; Print what ratoms are active. + (-> (ratoms) utils/spy)) + + +;; + diff --git a/src/cljs/athens/router.cljs b/src/cljs/athens/router.cljs index 5fa7de0dd5..ebc2a84d9d 100644 --- a/src/cljs/athens/router.cljs +++ b/src/cljs/athens/router.cljs @@ -1,62 +1,290 @@ (ns athens.router (:require - [athens.views :as views] - [re-frame.core :refer [subscribe dispatch reg-sub reg-event-db reg-event-fx reg-fx]] - [reitit.frontend :as rfe] - [reitit.frontend.easy :as rfee] - [reitit.frontend.controllers :as rfc] - [reitit.coercion.spec :as rss] - [day8.re-frame.tracing :refer-macros [fn-traced]])) + [athens.common-db :as common-db] + [athens.common.logging :as log] + [athens.common.sentry :refer-macros [wrap-span-no-new-tx]] + [athens.dates :as dates] + [athens.db :as db] + [athens.electron.db-picker :as db-picker] + [athens.electron.utils :as electron.utils] + [athens.interceptors :as interceptors] + [athens.utils.sentry :as sentry] + [day8.re-frame.tracing :refer-macros [fn-traced]] + [re-frame.core :as rf :refer [reg-sub reg-event-fx]] + [reitit.coercion.spec :as rss] + [reitit.frontend :as rfe] + [reitit.frontend.controllers :as rfc] + [reitit.frontend.easy :as rfee])) + ;; subs (reg-sub - :current-route - (fn [db] - (:current-route db))) + :current-route + (fn [db] + (-> db :current-route))) + + +(reg-sub + :current-route/uid + (fn [db] + (-> db :current-route :path-params :id))) + + +(rf/reg-sub + :current-route/page-title + (fn [db] + (-> db :current-route :path-params :title))) + + +(reg-sub + :current-route/uid-compat + :<- [:current-route/uid] + :<- [:current-route/page-title] + (fn [[uid title]] + (or uid + (when title + (common-db/get-page-uid @db/dsdb title))))) + + +(reg-sub + :current-route/name + (fn [db] + (-> db :current-route :data :name))) + ;; events +(rf/reg-event-fx + :navigate + [(interceptors/sentry-span-no-new-tx "navigate")] + (fn [{:keys [db]} [_ & route]] + (log/debug ":navigate route:" (pr-str route)) + (let [db-id (-> db db-picker/selected-db :id) + nav-type (first route) + route-id (-> route second :id) + route-title (-> route second :title) + new-db (if db-id + (assoc-in db + [:athens/persist :db-picker/all-dbs db-id (if (= :page-by-title nav-type) + :current-route/title + :current-route/uid)] + (if (= :page-by-title nav-type) + route-title + route-id)) + db)] + {:navigate! route + :db new-db}))) + + +(rf/reg-event-fx + :navigated + [(interceptors/sentry-span "navigated")] + (fn [{:keys [db]} [_ new-match]] + (log/debug "navigated, new-match:" (pr-str new-match)) + (let [sentry-tx (sentry/transaction-get-current) + sentry-tx-name (sentry/transaction-get-current-name) + old-match (:current-route db) + route-name (-> new-match :data :name) + nav-page? (= :page-by-title route-name) + controllers (rfc/apply-controllers (:controllers old-match) new-match) + loading? (:loading? db)] + (if nav-page? + (let [page-title (-> new-match :path-params :title) + page-block (common-db/get-block @db/dsdb [:node/title page-title]) + html-title (str page-title " | Athens")] + (set! (.-title js/document) html-title) + {:db (-> db + (assoc :current-route (assoc new-match :controllers controllers)) + (dissoc :merge-prompt)) + :timeout {:action :clear + :id :merge-prompt} + :dispatch-n [[:editing/first-child (:block/uid page-block)] + (when (= "router/navigate" sentry-tx-name) + [:sentry/end-tx sentry-tx])]}) + (let [uid (-> new-match :path-params :id) + ;; TODO make the page title query work when zoomed in on a block + node-title (common-db/get-page-title @db/dsdb uid) + home? (= route-name :home) + html-title-prefix (cond + node-title node-title + (= route-name :pages) "All Pages" + home? "Daily Notes") + html-title (if html-title-prefix + (str html-title-prefix " | Athens") + "Athens") + today (dates/get-day)] + (set! (.-title js/document) html-title) + {:db (-> db + (assoc :current-route (assoc new-match :controllers controllers)) + (dissoc :merge-prompt)) + :timeout {:action :clear + :id :merge-prompt} + :dispatch-n [(when home? + [:daily-note/ensure-day today]) + (when-let [parent-uid (and (not loading?) + (or uid + (and home? + (:uid today))))] + [:editing/first-child parent-uid]) + (when (= "router/navigate" sentry-tx-name) + [:sentry/end-tx sentry-tx])]}))))) + + +;; doesn't reliably work. notably, Daily Notes are often not remembered as last open page, leading to incorrect restore (reg-event-fx - :navigate - (fn [_ [_ & route]] - {:navigate! route})) - -(reg-event-db - :navigated - (fn [db [_ new-match]] - (let [old-match (:current-route db) - controllers (rfc/apply-controllers (:controllers old-match) new-match) - node (subscribe [:node [:block/uid (-> new-match :path-params :id)]])] ;; TODO make the page title query work when zoomed in on a block - (set! (.-title js/document) (or (:node/title @node) "Athens Research")) ;; TODO make this side effect explicit - (assoc db :current-route (assoc new-match :controllers controllers))))) + :restore-navigation + [(interceptors/sentry-span-no-new-tx "restore-navigation")] + (fn [{:keys [db]} _] + (let [prev-title (-> db db-picker/selected-db :current-route/title) + prev-uid (-> db db-picker/selected-db :current-route/uid)] + (cond + prev-title {:dispatch [:navigate :page-by-title {:title prev-title}]} + prev-uid {:dispatch [:navigate :page {:id prev-uid}]} + :else {:dispatch [:navigate :home]})))) + ;; effects -(reg-fx - :navigate! - (fn-traced [route] - (apply rfee/push-state route))) +(rf/reg-fx + :navigate! + (fn-traced [route] + (wrap-span-no-new-tx "push-state" + (apply rfee/push-state route)))) + ;; router definition (def routes ["/" - ["" {:name :home}] - ["about" {:name :about}] + ["" {:name :home}] + ["settings" {:name :settings}] ["pages" {:name :pages}] - ["page/:id" {:name :page}]]) + ["page-t/:title" {:name :page-by-title}] + ["page/:id" {:name :page}] + ["graph" {:name :graph}]]) + (def router (rfe/router - routes - {:data {:coercion rss/coercion}})) + routes + {:data {:coercion rss/coercion}})) -(defn on-navigate [new-match] + +(defn on-navigate + [new-match] (when new-match - (dispatch [:navigated new-match]))) + (rf/dispatch [:navigated new-match]))) + + +(defn navigate + [page] + (log/debug "navigate:" (pr-str page)) + (when-not (sentry/tx-running?) + (sentry/transaction-start "router/navigate")) + (rf/dispatch [:navigate page])) + + +(defn nav-daily-notes + "When user is already on a date node-page, clicking on daily notes goes to that date and allows scrolling." + [] + (let [route-uid @(rf/subscribe [:current-route/uid])] + (if (dates/is-daily-note route-uid) + (rf/dispatch [:daily-note/reset [route-uid]]) + (rf/dispatch [:daily-note/reset []])) + (navigate :home))) + + +(defn navigate-page + "Navigate to page by it's title" + ([title] + (let [current-route-page-title @(rf/subscribe [:current-route/page-title])] + (when-not (sentry/tx-running?) + ;; NOTE: this name here "router/navigate" is used to close this transaction, check `:navigated` event above + (sentry/transaction-start "router/navigate")) + (log/debug "navigate-page:" (pr-str {:title title + :current-route-page-title current-route-page-title})) + (when-not (= current-route-page-title title) + (rf/dispatch [:navigate :page-by-title {:title title}])))) + ([title e] + (let [shift? (.-shiftKey e)] + (if shift? + (do + (.. js/window getSelection empty) + (.. e preventDefault) + (rf/dispatch [:right-sidebar/open-item [:node/title title]])) + (navigate-page title))))) + + +(defn navigate-uid + "Don't navigate if already on the page." + ([uid] + (let [[uid _embed-id] (db/uid-and-embed-id uid) + current-route-uid @(rf/subscribe [:current-route/uid])] + (when-not (sentry/tx-running?) + ;; NOTE: this name here "router/navigate" is used to close this transaction, check `:navigated` event above + (sentry/transaction-start "router/navigate")) + (when (not= current-route-uid uid) + (rf/dispatch [:navigate :page {:id uid}])))) + ([uid e] + (let [[uid _embed-id] (db/uid-and-embed-id uid) + shift (.. e -shiftKey)] + (if shift + (do + (.. js/window getSelection empty) + (.. e preventDefault) + (rf/dispatch [:right-sidebar/open-item [:block/uid uid]])) + (navigate-uid uid))))) + -(defn init-routes! [] - (prn "Initializing routes") +(defn init-routes! + [] + (log/info "Initializing routes") (rfee/start! - router - on-navigate - {:use-fragment true})) + router + on-navigate + {:use-fragment true})) + + +(rf/reg-event-fx + :init-routes! + (fn [_ _] + (init-routes!) + {})) + + +;; Permalink param processing + +(def graph-name-param-key "graph-name") +(def graph-url-param-key "graph-url") +(def graph-password-param-key "graph-password") + + +(defn consume-graph-params + "Removes and returns the graph params in the current URL, if any." + [] + ;; Note: don't use the reitit.frontend functions here, as the router + ;; it not yet initialized during boot. + (let [window-url (js/URL. js/window.location) + name (.. window-url -searchParams (get graph-name-param-key)) + url (.. window-url -searchParams (get graph-url-param-key)) + password (js/atob (.. window-url -searchParams (get graph-password-param-key)))] + (when url + ;; Replace history with a version without the graph params. + (.. window-url -searchParams (delete graph-name-param-key)) + (.. window-url -searchParams (delete graph-url-param-key)) + (.. window-url -searchParams (delete graph-password-param-key)) + (js/history.replaceState js/history.state nil window-url) + [(or name url) url password]))) + + +(defn create-url-with-graph-params + "Create a URL containing graph-id." + [name url password] + (let [created-url (js/URL. (if electron.utils/electron? + ;; Use live web client + page route on electron. + (str "https://web.athensresearch.org/" + js/window.location.hash) + js/window.location))] + (.. created-url -searchParams (set graph-name-param-key name)) + (.. created-url -searchParams (set graph-url-param-key url)) + (.. created-url -searchParams (set graph-password-param-key (js/btoa password))) + (.toString created-url))) diff --git a/src/cljs/athens/self_hosted/client.cljs b/src/cljs/athens/self_hosted/client.cljs new file mode 100644 index 0000000000..75c2839a14 --- /dev/null +++ b/src/cljs/athens/self_hosted/client.cljs @@ -0,0 +1,377 @@ +(ns athens.self-hosted.client + "Self-Hosted Mode connector." + (:require + [athens.common-events :as common-events] + [athens.common-events.graph.atomic :as atomic-graph-ops] + [athens.common-events.schema :as schema] + [athens.common.logging :as log] + [com.stuartsierra.component :as component] + [re-frame.core :as rf])) + + +(defonce ^:private ws-connection (atom nil)) + + +(declare open-handler) +(declare message-handler) +(declare close-handler) +(declare forwarded-events) + + +(defn- connect-to-self-hosted! + [url] + (log/info "WSClient Connecting to:" (pr-str url)) + (when url + (doto (js/WebSocket. url) + (.addEventListener "open" open-handler) + (.addEventListener "message" message-handler) + (.addEventListener "close" close-handler)))) + + +(def ^:private send-queue (atom [])) + + +(def ^:private reconnect-timer (atom nil)) +(def ^:private MAX_RECONNECT_TRY 2) +(def ^:private reconnect-counter (atom -1)) + + +(defn- reconnecting? + "Checks if WebSocket is awaiting reconnection." + [] + (some? @reconnect-timer)) + + +(defn- delayed-reconnect! + ([url] + (delayed-reconnect! url 3000)) + ([url delay-ms] + (swap! reconnect-counter inc) + (log/info "WSClient scheduling reconnect in" (pr-str delay-ms) "ms to" (pr-str url)) + (if (< @reconnect-counter MAX_RECONNECT_TRY) + (let [timer-id (js/setTimeout (fn [] + (reset! reconnect-timer nil) + (connect-to-self-hosted! url)) + delay-ms)] + (reset! reconnect-timer timer-id)) + (do + (log/warn "Reconnect max tries" (pr-str @reconnect-counter) "reached.") + (rf/dispatch [:remote/connection-failed]))))) + + +(defn- close-reconnect-timer! + [] + (when-let [timer-id @reconnect-timer] + (js/clearTimeout timer-id) + (reset! reconnect-timer nil) + (reset! reconnect-counter -1))) + + +(defn open? + "Checks if `connection` is open. + If no args version called, `ws-connection` connection is checked. + + To close the connection stop the component." + + ([] + (open? @ws-connection)) + + ([connection] + (and (not (nil? connection)) + (= (.-OPEN js/WebSocket) + (.-readyState connection))))) + + +(defn send! + "Sends data over open WebSocket. + 1st argument `connection` is optional, default is `ws-connection`. + `data` is expected to be JSON serializable structure." + + ([data] + (send! @ws-connection data)) + + ([connection data] + (if (schema/valid-event? data) + (if (open? connection) + (do + (log/debug "event-id:" (pr-str (:event/id data)) + ", type:" (pr-str (:event/type data)) + "WSClient sending to server") + (let [serialized-event (common-events/serialize data) + errors (common-events/validate-serialized-event serialized-event)] + (if errors + (do (log/warn "Tried sending invalid serialized event:" (pr-str errors)) + {:result :rejected + :reason :invalid-event-schema}) + (do (.send connection serialized-event) + {:result :sent})))) + (do + (log/warn "event-id:" (pr-str (:event/id data)) + ", type:" (pr-str (:event/type data)) + "Can't send: WSClient not open") + (if (reconnecting?) + (do + (log/info "event-id:" (pr-str (:event/id data)) + ", type:" (pr-str (:event/type data)) + "WSClient already reconnecting, queued.") + (swap! send-queue (fnil conj []) data) + {:result :queued + :reason :client-already-reconnecting}) + (do + (log/warn "event-id:" (pr-str (:event/id data)) + ", type:" (pr-str (:event/type data)) + "WSClient closed & not reconnecting. Reconnecting & queued.") + (delayed-reconnect! (.-url connection) 0) + (swap! send-queue (fnil conj []) data) + {:result :queued + :reason :client-started-reconnecting})))) + (let [explanation (schema/explain-event data)] + (log/warn "event-id:" (pr-str (:event/id data)) + ", type:" (pr-str (:event/type data)) + "Client tried to send invalid event. Explanation: " (pr-str explanation)) + {:result :rejected + :reason :invalid-event-schema})))) + + +(def ^:private await-open-event-id (atom nil)) + + +(defn- open-handler + [event] + (log/info "WSClient Connected") + (let [connection (.-target event) + username @(rf/subscribe [:username]) + color @(rf/subscribe [:color]) + password @(rf/subscribe [:password]) + session-intro {:username username + :color color} + {event-id :event/id + :as hello-event} (common-events/build-presence-hello-event session-intro password)] + (reset! ws-connection connection) + (reset! reconnect-timer nil) + (reset! reconnect-counter -1) + (reset! await-open-event-id event-id) + (send! connection hello-event))) + + +(declare remove-listeners!) + + +(defn- finished-open-handler + [{:event/keys [status] :as event}] + (if (= :accepted status) + (do + (log/info "Successfully connected to Lan-Party.") + (reset! await-open-event-id nil) + (when (seq @send-queue) + (log/info "WSClient sending queued packets #" (pr-str (count @send-queue))) + (doseq [data @send-queue] + (send! @ws-connection data)) + (log/info "WSClient sent queued packets.") + (reset! send-queue []))) + + (do + (log/warn "Server rejected login attempt!") + + (remove-listeners! @ws-connection) + (close-reconnect-timer!) + (.close @ws-connection) + (reset! ws-connection nil) + + (rf/dispatch [:remote/connection-failed]) + (rf/dispatch [:alert/js (str "Server rejected your login attempt.\n" + "Your password simply ain't right.\n" + (pr-str event))])))) + + +(defn- awaited-response-handler + [{:event/keys [id status] :as packet}] + (log/debug "event-id:" (pr-str id) + "WSClient: response status:" (pr-str status)) + ;; is it hello confirmation? + (if (= @await-open-event-id id) + (finished-open-handler packet) + ;; is valid response? + (if (schema/valid-event-response? packet) + (do + (log/debug "event-id:" (pr-str id) + "Received valid response.") + (condp = status + :accepted + (let [{:accepted/keys [tx-id]} packet] + (log/debug "event-id:" (pr-str id) "accepted in tx" tx-id)) + :rejected + (let [{:reject/keys [reason data]} packet] + (log/warn "event-id:" (pr-str id) + "rejected, reason:" (pr-str reason) + ", rejection-data:" (pr-str data)) + (rf/dispatch [:remote/reject-forwarded-event packet])))) + (let [explanation (schema/explain-event-response packet)] + (log/warn "Received invalid response:" (pr-str explanation)))))) + + +(defn- db-dump-handler + [{:keys [datoms]}] + (log/debug "Received DB Dump") + (rf/dispatch [:db-dump-handler datoms])) + + +(defn- presence-session-id-handler + [{:keys [session-id]}] + (log/info "Session id:" (pr-str session-id)) + (rf/dispatch [:presence/add-session-id session-id])) + + +(defn- presence-online-handler + [args] + (let [username (:username args)] + (log/info "User online:" (pr-str username)) + (rf/dispatch [:presence/add-user args]))) + + +(defn- presence-all-online-handler + "args is a vector of users, e.g. [{:username \"Zeus\"}] " + [args] + (rf/dispatch [:presence/all-online args])) + + +(defn- presence-offline-handler + [args] + (let [username (:username args)] + (log/info "User offine:" (pr-str username)) + (rf/dispatch [:presence/remove-user args]))) + + +(defn- presence-update + [args] + (log/debug "User update:" (pr-str args)) + (rf/dispatch [:presence/update args])) + + +(defn- forwarded-event-handler + [args] + (log/debug "Forwarded event-id:" (pr-str (:event/id args))) + (rf/dispatch [:remote/apply-forwarded-event args])) + + +(def forwarded-events + #{:op/atomic}) + + +(defn- server-event-handler + [{:event/keys [id type args] :as packet}] + (log/debug "WSClient received from server." + "event-id:" (pr-str id) ", type:" (pr-str type)) + (if (schema/valid-server-event? packet) + + (condp contains? type + #{:datascript/db-dump} (db-dump-handler args) + #{:presence/session-id} (presence-session-id-handler args) + #{:presence/online} (presence-online-handler args) + #{:presence/all-online} (presence-all-online-handler args) + #{:presence/offline} (presence-offline-handler args) + #{:presence/update} (presence-update args) + forwarded-events (forwarded-event-handler packet)) + + (log/warn "event-id:" (pr-str id) + ", type:" (pr-str type) + "WSClient Received invalid server event, explanation:" (pr-str (schema/explain-server-event packet))))) + + +(defn- message-handler + [event] + (let [serialized-event (.-data event) + data (common-events/deserialize serialized-event) + errors (when-not (common-events/ignore-serialized-event-validation? data) + (common-events/validate-serialized-event serialized-event))] + (cond + errors (log/warn "Received invalid serialized event:" (pr-str errors)) + (schema/valid-event-response? data) (awaited-response-handler data) + :else (server-event-handler data)))) + + +(defn- remove-listeners! + [connection] + (doto connection + (.removeEventListener "close" close-handler) + (.removeEventListener "message" message-handler) + (.removeEventListener "open" open-handler))) + + +(defn- close-handler + [event] + (log/info "WSClient Disconnected unexpectedly, reconnecting:" (pr-str event)) + (let [connection (.-target event) + url (.-url connection)] + (rf/dispatch [:loading/set]) + (rf/dispatch [:presence/clear]) + (rf/dispatch [:conn-status :reconnecting]) + (remove-listeners! connection) + (delayed-reconnect! url))) + + +(defrecord WSClient + [url] + + component/Lifecycle + + (start + [component] + (log/info "WSClient starting with url:" url) + (let [connection (connect-to-self-hosted! url)] + (log/debug "WSClient connection started...") + (reset! ws-connection connection) + component)) + + + (stop + [component] + (log/info "WSClient stopping for url:" url) + (when-let [connection @ws-connection] + (close-reconnect-timer!) + (remove-listeners! connection) + (.close connection) + (log/info "WSClient closed connection") + (reset! ws-connection nil) + (rf/dispatch [:conn-status :disconnected]) + component))) + + +(defn new-ws-client + [url] + (map->WSClient {:url url})) + + +;; REPL Testing +(comment + + (def ws-url "ws://localhost:3010/ws") + + ;; define a client + (def client (new-ws-client ws-url)) + + ;; start a client + (component/start client) + + ;; check if open? + (open?) + + ;; try to send an invalid message + (send! {:some :message}) + ;; => {:result :rejected, :reason :invalid-event-schema} + + ;; send a `:presence/hello` event + (send! {:event/id "test-id" + :event/type :presence/hello + :event/args {:username "Bob's your uncle"}}) + ;; => {:result :sent} + + ;; test atomic op + (send! {:event/id (random-uuid) + :event/type :op/atomic + :event/args #:op{:type :block/new, + :atomic? true, + :args {:parent-uid "test1", :block-uid "test2", :block-order 2}}}) + + (send! (common-events/build-atomic-event + (atomic-graph-ops/make-page-new-op "test title")))) diff --git a/src/cljs/athens/self_hosted/presence/events.cljs b/src/cljs/athens/self_hosted/presence/events.cljs new file mode 100644 index 0000000000..b735973ef6 --- /dev/null +++ b/src/cljs/athens/self_hosted/presence/events.cljs @@ -0,0 +1,53 @@ +(ns athens.self-hosted.presence.events + (:require + [athens.common.logging :as log] + [re-frame.core :as rf])) + + +(rf/reg-event-db + :presence/add-session-id + (fn [db [_ session-id]] + (assoc-in db [:presence :session-id] session-id))) + + +(rf/reg-event-fx + :presence/all-online + (fn [_ [_ users]] + {:fx [[:dispatch-n (mapv (fn [user-map] + [:presence/add-user user-map]) + users)]]})) + + +(rf/reg-event-db + :presence/add-user + (fn [db [_ {:keys [session-id] :as user}]] + (assoc-in db [:presence :users session-id] user))) + + +(rf/reg-event-db + :presence/remove-user + (fn [db [_ {:keys [session-id]}]] + (update-in db [:presence :users] dissoc session-id))) + + +(rf/reg-event-db + :presence/update + (fn [db [_ {:keys [session-id] :as session}]] + (if (get-in db [:presence :users session-id]) + (update-in db [:presence :users session-id] merge session) + (do (log/warn "No matching session-id for update" session) + db)))) + + +(rf/reg-event-db + :presence/clear + (fn [db _] + (dissoc db :presence))) + + +(rf/reg-event-fx + :presence/send-update + (fn [{:keys [db]} [_ m]] + {;; Optimistically update own presence to not have weird delay. + :dispatch [:presence/update (merge m {:session-id (get-in db [:presence :session-id])})] + :fx [[:presence/send-update-fx m]]})) diff --git a/src/cljs/athens/self_hosted/presence/fx.cljs b/src/cljs/athens/self_hosted/presence/fx.cljs new file mode 100644 index 0000000000..1005c71479 --- /dev/null +++ b/src/cljs/athens/self_hosted/presence/fx.cljs @@ -0,0 +1,11 @@ +(ns athens.self-hosted.presence.fx + (:require + [athens.common-events :as common-events] + [athens.self-hosted.client :as client] + [re-frame.core :as rf])) + + +(rf/reg-fx + :presence/send-update-fx + (fn [m] + (client/send! (common-events/build-presence-update-event @(rf/subscribe [:presence/session-id]) m)))) diff --git a/src/cljs/athens/self_hosted/presence/subs.cljs b/src/cljs/athens/self_hosted/presence/subs.cljs new file mode 100644 index 0000000000..f5b5dbcf42 --- /dev/null +++ b/src/cljs/athens/self_hosted/presence/subs.cljs @@ -0,0 +1,113 @@ +(ns athens.self-hosted.presence.subs + (:require + [athens.dates :as dates] + [athens.db :as db] + [re-frame.core :as rf])) + + +(rf/reg-sub + :presence/users + (fn [db _] + (-> db :presence :users))) + + +(rf/reg-sub + :presence/session-id + (fn [db _] + (-> db :presence :session-id))) + + +;; "From :block/uid, derive :page/uid and :page/title. If no :block/uid, give nil" +(rf/reg-sub + :presence/users-with-page-data + :<- [:presence/users] + (fn [users _] + (into {} (mapv (fn [[session-id {:keys [block-uid] :as user}]] + (let [{page-title :node/title page-uid :block/uid} (db/get-root-parent-page block-uid)] + [session-id (assoc user :page/uid page-uid :page/title page-title :block/uid block-uid)])) + users)))) + + +(rf/reg-sub + :presence/current-user + :<- [:presence/users-with-page-data] + :<- [:presence/session-id] + :<- [:db-picker/remote-db?] + (fn [[users session-id remote-db?] [_]] + (if remote-db? + (-> (filter (fn [[_ user]] + (= session-id (:session-id user))) + users) + first + second) + {:username "You"}))) + + +(rf/reg-sub + :presence/current-username + :<- [:presence/current-user] + (fn [current-user _] + (:username current-user))) + + +(rf/reg-sub + :presence/user-page + :<- [:presence/current-username] + (fn [current-user _] + (str "@" current-user))) + + +(defn on-page-uid? + [page-uid [_username user]] + (= page-uid (:page/uid user))) + + +(defn on-daily-page? + [[_username user]] + (dates/is-daily-note (:page/uid user))) + + +(rf/reg-sub + :presence/same-page + :<- [:presence/users-with-page-data] + :<- [:current-route/name] + :<- [:current-route/uid] + (fn [[users current-route-name current-route-uid] _] + (case current-route-name + + :page + (into {} (filterv (partial on-page-uid? current-route-uid) + users)) + + :home + (into {} (filterv on-daily-page? users)) + + {}))) + + +(rf/reg-sub + :presence/diff-page + :<- [:presence/users-with-page-data] + :<- [:current-route/name] + :<- [:current-route/uid] + (fn [[users current-route-name current-route-uid] _] + (case current-route-name + + :page + (into {} (filterv (complement (partial on-page-uid? current-route-uid)) + users)) + + :home + (into {} (filterv (complement on-daily-page?) users)) + + users))) + + +(rf/reg-sub + :presence/has-presence + :<- [:presence/users-with-page-data] + (fn [users [_ uid]] + (keep (fn [[_username user]] + (when (= uid (:block/uid user)) + user)) + users))) diff --git a/src/cljs/athens/self_hosted/presence/views.cljs b/src/cljs/athens/self_hosted/presence/views.cljs new file mode 100644 index 0000000000..79ef0ee3cf --- /dev/null +++ b/src/cljs/athens/self_hosted/presence/views.cljs @@ -0,0 +1,119 @@ +(ns athens.self-hosted.presence.views + (:require + ["/components/PresenceDetails/PresenceDetails" :refer [PresenceDetails]] + ["@chakra-ui/react" :refer [Avatar AvatarGroup Tooltip]] + [athens.router :as router] + [athens.self-hosted.presence.events] + [athens.self-hosted.presence.fx] + [athens.self-hosted.presence.subs] + [athens.util :as util] + [clojure.string :as str] + [re-frame.core :as rf] + [reagent.core :as r])) + + +(defn user->person + [{:keys [session-id username color] + :page/keys [title]}] + (when (and session-id username color) + {:personId session-id + :username username + :color color + :pageTitle title})) + + +(defn copy-host-address-to-clipboard + [host-address] + (.. js/navigator -clipboard (writeText host-address)) + (util/toast (clj->js {:status "info" + :position "top-right" + :title "Host address copied to clipboard"}))) + + +(defn copy-permalink + [] + (let [{:keys [name url password]} @(rf/subscribe [:db-picker/selected-db]) + created-url (router/create-url-with-graph-params name url password)] + (.. js/navigator -clipboard (writeText created-url)) + (util/toast (clj->js {:status "info" + :position "top-right" + :title "Copied permalink to clipboard"})))) + + +(defn go-to-user-block + [all-users js-person] + (let [{_block-uid :block/uid + page-uid :page/uid} + (->> (js->clj js-person :keywordize-keys true) + :personId + (get all-users))] + (if page-uid + ;; TODO: if we support navigating to a block, it should be added here. + (rf/dispatch [:navigate :page {:id page-uid}]) + (util/toast (clj->js {:title "User is not on any page" + :position "top-right" + :status "warning"}))))) + + +(defn edit-current-user + [js-person] + (let [{:keys [username color]} (js->clj js-person :keywordize-keys true)] + (rf/dispatch [:settings/update :username username]) + (rf/dispatch [:settings/update :color color]) + (rf/dispatch [:presence/send-update {:username username + :color color}]))) + + +;; Exports + +(defn toolbar-presence-el + [] + (r/with-let [selected-db (rf/subscribe [:db-picker/selected-db]) + current-user (rf/subscribe [:presence/current-user]) + all-users (rf/subscribe [:presence/users-with-page-data]) + same-page (rf/subscribe [:presence/same-page]) + diff-page (rf/subscribe [:presence/diff-page]) + others-seq #(->> (dissoc % (:session-id @current-user)) + vals + (map user->person) + (remove nil?))] + (fn [] + (let [current-user' (user->person @current-user) + current-page-members (others-seq @same-page) + different-page-members (others-seq @diff-page)] + [:> PresenceDetails {:current-user current-user' + :current-page-members current-page-members + :different-page-members different-page-members + :host-address (:url @selected-db) + :handle-copy-host-address copy-host-address-to-clipboard + :handle-copy-permalink copy-permalink + :handle-press-member #(go-to-user-block @all-users %) + :handle-update-profile #(edit-current-user %) + ;; TODO: show other states when we support them. + :connection-status "connected"}])))) + + +;; inline + +(defn inline-presence-el + [uid] + (let [users (rf/subscribe [:presence/has-presence (util/embed-uid->original-uid uid)])] + (when (seq @users) + (into + [:> Tooltip {:label (->> @users (map user->person) + (remove nil?) + (map (fn [person] (:username person))) + (str/join ", "))} + [:> AvatarGroup {:max 1 + :zIndex 2 + :className "inline-presence" + :size "xs" + :cursor "default"} + (->> @users + (map user->person) + (remove nil?) + (map (fn [{:keys [personId] :as person}] + [:> Avatar {:key personId + :bg (:color person) + :name (:username person)}])))]])))) + diff --git a/src/cljs/athens/style.cljs b/src/cljs/athens/style.cljs new file mode 100644 index 0000000000..83d17f2cfc --- /dev/null +++ b/src/cljs/athens/style.cljs @@ -0,0 +1,61 @@ +(ns athens.style + (:require + [athens.config :as config] + [athens.util :as util] + [re-frame.core :refer [reg-sub subscribe]])) + + +(reg-sub + :zoom-level + (fn [db _] + (:zoom-level db))) + + +;; Zoom levels mirror Google Chrome browser zoom levels. +;; Levels determined by zooming Chrome in/out and recording the zoom percent. +(def zoom-levels + {-5 50 + -4 67 + -3 75 + -2 80 + -1 90 + 0 100 + 1 110 + 2 125 + 3 133 + 4 140 + 5 150 + 6 175 + 7 200 + 8 250 + 9 300 + 10 400 + 11 500}) + + +(def zoom-level-max 11) +(def zoom-level-min -5) + + +(defn get-zoom-pct + [n] + (zoom-levels n)) + + +(defn zoom + [] + (let [zoom-level (subscribe [:zoom-level])] + {:style {:font-size (str (get-zoom-pct @zoom-level) "%")}})) + + +(defn unzoom + [] + (let [zoom-level (subscribe [:zoom-level])] + {:font-size (str "calc(1 / " (get-zoom-pct @zoom-level) " * 100 * 100%)")})) + + +(defn init + [] + ;; hide re-frame-10x by default + (when config/debug? + (util/hide-10x))) diff --git a/src/cljs/athens/subs.cljs b/src/cljs/athens/subs.cljs index 0be8864588..be0063ea8e 100644 --- a/src/cljs/athens/subs.cljs +++ b/src/cljs/athens/subs.cljs @@ -1,145 +1,133 @@ (ns athens.subs (:require - [athens.blocks :as blocks] - [re-frame.core :as re-frame] - [re-posh.core :as re-posh :refer [subscribe reg-query-sub reg-pull-sub reg-pull-many-sub]] - [day8.re-frame.tracing :refer-macros [fn-traced]])) -;; note: not refering reg-sub because re-posh and re-frame have different reg-subs - -;; re-frame subscriptions -(re-frame/reg-sub - :user - (fn [db _] - (:user db) - )) - -(re-frame/reg-sub - :errors + [day8.re-frame.tracing :refer-macros [fn-traced]] + [re-frame.core :as rf])) + + +(rf/reg-sub + :username + (fn [db _] + (-> db :athens/persist :settings :username))) + + +(rf/reg-sub + :color + (fn [db _] + (-> db :athens/persist :settings :color))) + + +(rf/reg-sub + :password + :<- [:db-picker/selected-db] + (fn [selected-db _] + (:password selected-db))) + + +(rf/reg-sub + :db/synced + (fn [db _] + (:db/synced db))) + + +(rf/reg-sub + :theme/dark (fn [db _] - (:errors db) - )) + (-> db :athens/persist :theme/dark))) -(re-frame/reg-sub - :loading + +(rf/reg-sub + :app-db + (fn [db _] + db)) + + +(rf/reg-sub + :alert + (fn [db _] + (:alert db))) + + +(rf/reg-sub + :loading? (fn [db _] - (:loading db) - )) - -;; datascript queries -(reg-query-sub - :nodes - '[:find [?e ...] - :where - [?e :node/title ?t]]) - -(reg-query-sub - :node/refs - '[:find ?id - :in $ ?regex - :where - [?e :block/string ?s] - [(re-find ?regex ?s)] - [?e :block/uid ?id]]) - -(reg-query-sub - :page/sidebar - '[:find ?order ?title ?bid - :where - [?e :page/sidebar ?order] - [?e :node/title ?title] - [?e :block/uid ?bid]]) - -;; datascript pulls -(reg-pull-sub - :node - '[*]) - -(reg-pull-sub - :block/uid - '[:block/uid]) - -(reg-pull-sub - :block/string - '[:block/string]) - -(reg-pull-sub - :blocks - '[:block/string {:block/children ...}]) - -(reg-pull-sub - :block/children - '[:block/uid :block/string :block/order :block/open :db/id {:block/children ...}]) - -(re-frame/reg-sub - :block/children-sorted - (fn [[_ id] _] - (subscribe [:block/children id])) - (fn [block _] - (blocks/sort-block block))) - -(reg-pull-sub - :block/_children - '[:block/uid :block/string :node/title {:block/_children ...}]) - -;; layer 3 subscriptions - -(re-frame/reg-sub - :block/_children2 - (fn [[_ id] _] - (subscribe [:block/_children id])) - (fn [block _] ; find path from nested block to origin node - (reverse - (rest - (loop [b block - res []] - (if (:node/title b) - (conj res b) - (recur (first (:block/_children b)) - (conj res (dissoc b :block/_children))))))))) - -(re-posh/reg-sub - :pull-nodes - :<- [:nodes] - (fn-traced [nodes _] - {:type :pull-many - :pattern '[*] - :ids nodes})) - -(re-frame/reg-sub - :favorites - :<- [:page/sidebar] - (fn-traced [nodes _] - (->> nodes - (into []) - (sort-by first)) - )) - -;; (rp/reg-sub -;; :node/refs2 -;; (fn [[_ regex]] -;; (subscribe [:node/refs regex])) -;; (fn [ids _] ; for all refs, find their parents with reverse lookup -;; {:type :pull-many -;; :pattern '[:node/title :block/uid :block/string {:block/_children ...}] -;; :ids (reduce into [] ids)})) - -;; (rf/reg-sub -;; :node/refs3 -;; (fn [[_ regex]] -;; (subscribe [:node/refs2 regex])) -;; (fn [blocks _] -;; ;; flatten paths like in :block/_children2 (except keep node/title) -;; ;; then normalize refs through group by :node/title -;; (->> blocks -;; (map (fn [block] -;; (reverse -;; (loop [b block -;; res []] -;; (if (:node/title b) -;; (conj res (dissoc b :block/children)) -;; (recur (first (:block/_children b)) -;; (conj res (dissoc b :block/_children)))))))) -;; (group-by #(:node/title (first %))) -;; (reduce-kv (fn [m k v] -;; (assoc m k (map rest v))) {} )) -;; )) + (:loading? db))) + + +(rf/reg-sub + :athena/open + (fn-traced [db _] + (:athena/open db))) + + +(rf/reg-sub + :mouse-down + (fn [db _] + (:mouse-down db))) + + +(rf/reg-sub + :merge-prompt + (fn [db _] + (:merge-prompt db))) + + +;; Note: always prefer a subscription to :editing/is-editing over +;; :editing/uid with a manual check. +;; If you check manually, the component around the subscription will +;; re-render every time :editing/uid changes (aka very often.) +(rf/reg-sub + :editing/uid + (fn-traced [db _] + (:editing/uid db))) + + +(rf/reg-sub + :editing/is-editing + (fn [_] + [(rf/subscribe [:editing/uid])]) + (fn [[editing-uid] [_ uid]] + (= editing-uid uid))) + + +(rf/reg-sub + :daily-notes/items + (fn-traced [db _] + (:daily-notes/items db))) + + +(rf/reg-sub + :athena/get-recent + (fn-traced [db _] + (:athena/recent-items db))) + + +(rf/reg-sub + :modal + (fn [db _] + (:modal db))) + + +(rf/reg-sub + :settings + (fn [db _] + (-> db :athens/persist :settings))) + + +(rf/reg-sub + :feature-flags + :<- [:settings] + (fn [settings _] + (get settings :feature-flags {}))) + + +(rf/reg-sub + :connection-status + (fn [db _] + (:connection-status db))) + + +(rf/reg-sub + :help/open? + (fn [db _] + (:help/open? db))) + diff --git a/src/cljs/athens/subs/dragging.cljs b/src/cljs/athens/subs/dragging.cljs new file mode 100644 index 0000000000..288ba0a131 --- /dev/null +++ b/src/cljs/athens/subs/dragging.cljs @@ -0,0 +1,15 @@ +(ns athens.subs.dragging + (:require + [re-frame.core :as rf])) + + +(rf/reg-sub + ::drag-target + (fn [db [_ uid]] + (get-in db [:dragging uid :drag-target]))) + + +(rf/reg-sub + ::dragging? + (fn [db [_ uid]] + (get-in db [:dragging uid :dragging?]))) diff --git a/src/cljs/athens/subs/inline_refs.cljs b/src/cljs/athens/subs/inline_refs.cljs new file mode 100644 index 0000000000..70f4893daf --- /dev/null +++ b/src/cljs/athens/subs/inline_refs.cljs @@ -0,0 +1,40 @@ +(ns athens.subs.inline-refs + (:require + [re-frame.core :as rf])) + + +(rf/reg-sub + ::open? + (fn [db [_ uid]] + (get-in db [:inline-refs uid :open?] false))) + + +(rf/reg-sub + ::state-open? + (fn [db [_ uid]] + (get-in db [:inline-refs uid :state :open?] false))) + + +(rf/reg-sub + ::state-focus? + (fn [db [_ uid]] + (get-in db [:inline-refs uid :state :focus?] false))) + + +(rf/reg-sub + ::state-block + (fn [db [_ uid]] + (get-in db [:inline-refs uid :state :block]))) + + +(rf/reg-sub + ::state-parents + (fn [db [_ uid]] + (get-in db [:inline-refs uid :state :parents]))) + + +(rf/reg-sub + ::state-embed-id + (fn [db [_ uid]] + (get-in db [:inline-refs uid :state :focus?]))) + diff --git a/src/cljs/athens/subs/inline_search.cljs b/src/cljs/athens/subs/inline_search.cljs new file mode 100644 index 0000000000..a66c643f05 --- /dev/null +++ b/src/cljs/athens/subs/inline_search.cljs @@ -0,0 +1,27 @@ +(ns athens.subs.inline-search + (:require + [re-frame.core :as rf])) + + +(rf/reg-sub + ::type + (fn [db [_ uid]] + (get-in db [:inline-search uid :type]))) + + +(rf/reg-sub + ::index + (fn [db [_ uid]] + (get-in db [:inline-search uid :index]))) + + +(rf/reg-sub + ::results + (fn [db [_ uid]] + (get-in db [:inline-search uid :results]))) + + +(rf/reg-sub + ::query + (fn [db [_ uid]] + (get-in db [:inline-search uid :query]))) diff --git a/src/cljs/athens/subs/linked_refs.cljs b/src/cljs/athens/subs/linked_refs.cljs new file mode 100644 index 0000000000..d63bab22b5 --- /dev/null +++ b/src/cljs/athens/subs/linked_refs.cljs @@ -0,0 +1,9 @@ +(ns athens.subs.linked-refs + (:require + [re-frame.core :as rf])) + + +(rf/reg-sub + ::open? + (fn [db [_ uid]] + (get-in db [:linked-ref uid] false))) diff --git a/src/cljs/athens/subs/selection.cljs b/src/cljs/athens/subs/selection.cljs new file mode 100644 index 0000000000..7c9a317b52 --- /dev/null +++ b/src/cljs/athens/subs/selection.cljs @@ -0,0 +1,17 @@ +(ns athens.subs.selection + (:require + [re-frame.core :as rf])) + + +(rf/reg-sub + ::items + (fn [db _] + (get-in db [:selection :items]))) + + +(rf/reg-sub + ::selected? + :<- [::items] + (fn [selected-items [_ uid]] + (contains? (set selected-items) + uid))) diff --git a/src/cljs/athens/time_controls.cljs b/src/cljs/athens/time_controls.cljs new file mode 100644 index 0000000000..8c313d0847 --- /dev/null +++ b/src/cljs/athens/time_controls.cljs @@ -0,0 +1,104 @@ +(ns athens.time-controls + (:require + ["/components/Time/TimeSlider" :refer [TimeSlider]] + [athens.common-db :as common-db] + [athens.db :as db] + [clojure.math :as math] + [goog.functions :refer [throttle]] + [re-frame.core :refer [reg-sub reg-event-db dispatch subscribe]])) + + +(defn enabled? + [] + (:time-controls @(subscribe [:feature-flags]))) + + +(reg-event-db + :time/set-page-range + (fn [db [_ title]] + (assoc db :time/page-range (common-db/time-range @db/dsdb [:node/title title])))) + + +(reg-sub + :time/page-range + (fn [db _] + (-> db :time/page-range))) + + +;; Slider + +(reg-event-db + :time/toggle-slider + (fn [db _] + (update db :time/slider? not))) + + +(reg-sub + :time/slider? + (fn [db _] + (-> db :time/slider?))) + + +(reg-event-db + :time/set-slider-range + (fn [db [_ range]] + (assoc db :time/slider-range range))) + + +(reg-sub + :time/slider-range + (fn [db _] + (-> db :time/slider-range))) + + +(defn slider + [] + (let [[min max] @(subscribe [:time/page-range])] + (when (and (enabled?) + @(subscribe [:time/slider?]) + min max) + [:div {:style {:padding "0 1.5em 0 1.5em"}} + [:> TimeSlider + {:min min + :max max + :on-change (throttle #(dispatch [:time/set-slider-range (js->clj %)]) 50)}]]))) + + +;; Heatmap + +(reg-event-db + :time/toggle-heatmap + (fn [db _] + (update db :time/heatmap? not))) + + +(reg-sub + :time/heatmap? + (fn [db _] + (-> db :time/heatmap?))) + + +;; Common + +(defn block-styles + [block] + (when (enabled?) + (let [block-time (->> block :block/edits (mapv (comp :time/ts :event/time)) (apply max))] + (when block-time + (cond-> {} + @(subscribe [:time/slider?]) + (merge (let [[start end] @(subscribe [:time/slider-range])] + (when (and start end) + (merge (when-not (and (>= block-time start) + (<= block-time end)) + {:opacity 0.2}))))) + + + @(subscribe [:time/heatmap?]) + (merge (let [[start end] @(subscribe [:time/page-range]) + percent (* 100 (/ (- block-time start) + (- end start))) + hue (math/floor (/ (* (- 100 percent) + 120) + 100))] + {:backgroundColor (str "hsl(" hue ",50%,75%)")}))))))) diff --git a/src/cljs/athens/types/core.cljs b/src/cljs/athens/types/core.cljs new file mode 100644 index 0000000000..c7d6b00875 --- /dev/null +++ b/src/cljs/athens/types/core.cljs @@ -0,0 +1,39 @@ +(ns athens.types.core + "Athens Block/Entity Types") + + +(defprotocol BlockTypeProtocol + "Block/Entity Type Protocol for rendering aspects" + + (text-view + [this block-data attr] + "Renders Block/Entity Type as textual representation. + Recursively resolves references and all.") + + (inline-ref-view + [this block-data attr ref-uid uid callbacks with-breadcrumb?] + "Render Block/Entity Type as inline reference") + + (outline-view + [this block-data callbacks] + "Render Block/Entity Type as outline representation") + + (supported-transclusion-scopes + [this] + "Returns a set of supported `transclusion-scopes`") + + (transclusion-view + [this block-el block-uid callback transclusion-scope] + "Render Block/Entity Type as transclusion") + + (zoomed-in-view + [this block-data callbacks] + "Render Block/Entity Type as zoomed in") + + (supported-breadcrumb-styles + [this] + "Returns a set of supported `breadcrumb-styles`") + + (breadcrumbs-view + [this block-data callbacks breadcrumb-style] + "Render Block/Entity Type as breadcrumbs")) diff --git a/src/cljs/athens/types/default/view.cljs b/src/cljs/athens/types/default/view.cljs new file mode 100644 index 0000000000..0ccab5b944 --- /dev/null +++ b/src/cljs/athens/types/default/view.cljs @@ -0,0 +1,260 @@ +(ns athens.types.default.view + "Default Block Type Renderer. + A.k.a standard `:block/string` blocks" + (:require + ["/components/Icons/Icons" :refer [PencilIcon]] + ["@chakra-ui/react" :refer [Box Button ButtonGroup IconButton]] + [athens.common-db :as common-db] + [athens.common.utils :as utils] + [athens.db :as db] + [athens.parse-renderer :as parser] + [athens.reactive :as reactive] + [athens.router :as router] + [athens.types.core :as types] + [athens.types.dispatcher :as dispatcher] + [athens.util :as util] + [athens.views.blocks.editor :as editor] + [clojure.string :as str] + [goog.functions :as gfns] + [re-frame.core :as rf] + [reagent.core :as r] + [reagent.ratom :as ratom])) + + +(defn- block-breadcrumb-string + [parents] + (->> parents + (map #(or (:node/title %) + (:block/string %))) + (str/join " >\n"))) + + +(defn zoomed-in-view-el + [_this block-data callbacks] + (let [{:block/keys [uid + original-uid + string]} block-data + local-value (r/atom string) + show-edit-atom? (r/atom true) + savep-fn (partial db/transact-state-for-uid (or original-uid uid)) + save-fn #(savep-fn @local-value :block-save) + idle-fn (gfns/debounce #(savep-fn @local-value :autosave) + 2000) + update-fn #(reset! local-value %) + enter-handler (fn [uid d-key-down] + (let [[uid embed-id] (common-db/uid-and-embed-id uid) + new-uid (utils/gen-block-uid) + {:keys [start value]} d-key-down] + (rf/dispatch [:enter/split-block {:uid uid + :value value + :index start + :new-uid new-uid + :embed-id embed-id + :relation :first}]))) + state-hooks (merge callbacks + {:save-fn save-fn + :idle-fn idle-fn + :update-fn update-fn + :show-edit? show-edit-atom? + :read-value local-value + :enter-handler enter-handler})] + [editor/block-editor block-data state-hooks])) + + +(defn inline-ref-view-el + [_this block {:keys [from title]} ref-uid uid _callback _with-breadcrumb?] + (let [parents (reactive/get-reactive-parents-recursively [:block/uid ref-uid]) + bc-string (block-breadcrumb-string parents)] + (if block + [:> Button {:variant "link" + :as "a" + :title (-> from + (str/replace "](" + "]\n---\n(") + (str/replace (str "((" ref-uid "))") + bc-string)) + :class "block-ref" + :display "inline" + :color "unset" + :whiteSpace "unset" + :textAlign "unset" + :minWidth "0" + :fontSize "inherit" + :fontWeight "inherit" + :lineHeight "inherit" + :marginInline "-2px" + :paddingInline "2px" + :borderBottomWidth "1px" + :borderBottomStyle "solid" + :borderBottomColor "ref.foreground" + :cursor "alias" + :sx {"WebkitBoxDecorationBreak" "clone" + :h1 {:marginBlock 0 + "&:not(:last-child)" {:paddingInlineEnd "0.35ch"} + :fontSize "inherit" + :display "inline-block"} + :h2 {:marginBlock 0 + "&:not(:last-child)" {:paddingInlineEnd "0.35ch"} + :fontSize "inherit" + :display "inline-block"} + :h3 {:marginBlock 0 + "&:not(:last-child)" {:paddingInlineEnd "0.35ch"} + :fontSize "inherit" + :display "inline-block"} + :h4 {:marginBlock 0 + "&:not(:last-child)" {:paddingInlineEnd "0.35ch"} + :fontSize "inherit" + :display "inline-block"} + :h5 {:marginBlock 0 + "&:not(:last-child)" {:paddingInlineEnd "0.35ch"} + :fontSize "inherit" + :display "inline-block"} + :h6 {:marginBlock 0 + "&:not(:last-child)" {:paddingInlineEnd "0.35ch"} + :fontSize "inherit" + :display "inline-block"} + :p {:display "inline-block" + :marginBlock 0}} + :_hover {:textDecoration "none" + :borderBottomColor "transparent" + :bg "ref.background"} + :onClick (fn [e] + (.. e stopPropagation) + (let [shift? (.-shiftKey e)] + (rf/dispatch [:reporting/navigation {:source :pr-block-ref + :target :block + :pane (if shift? + :right-pane + :main-pane)}]) + (router/navigate-uid ref-uid e)))} + (cond + (= uid ref-uid) + [parser/parse-and-render "{{SELF}}"] + + (not (str/blank? title)) + [parser/parse-and-render title ref-uid] + + :else + [parser/parse-and-render (:block/string block) ref-uid])] + from))) + + +(defn outline-view-el + [_this block-data callbacks] + (let [{:block/keys [uid + original-uid]} block-data + local-value (r/atom nil) + old-value (r/atom nil) + savep-fn (partial db/transact-state-for-uid (or original-uid uid)) + save-fn #(savep-fn @local-value :block-save) + idle-fn (gfns/debounce #(savep-fn @local-value :autosave) + 2000) + update-fn #(reset! local-value %) + update-old-fn #(reset! old-value %) + read-value (ratom/reaction @local-value) + read-old-value (ratom/reaction @old-value) + state-hooks (merge callbacks + {:save-fn save-fn + :idle-fn idle-fn + :update-fn update-fn + :update-old-fn update-old-fn + :read-value read-value + :read-old-value read-old-value})] + (fn render-block + [_this block _callbacks] + (let [ident [:block/uid (or original-uid uid)] + block-o (reactive/get-reactive-block-document ident) + {:block/keys [string + _refs]} (merge block-o block)] + + ;; (prn uid is-selected) + + ;; If datascript string value does not equal local value, overwrite local value. + ;; Write on initialization + ;; Write also from backspace, which can join bottom block's contents to top the block. + (update-fn string) + + [editor/block-editor block state-hooks])))) + + +(defn tranclusion-view-el + [this block-el block-uid {:keys [transcluding-block-uid] :as _config} transclusion-scope] + (let [supported-trans (types/supported-transclusion-scopes this)] + (if-not (contains? supported-trans transclusion-scope) + (throw (ex-info (str "Invalid transclusion scope: " (pr-str transclusion-scope) + ". Supported transclusion types: " (pr-str supported-trans)) + {:supported-transclusion-scopes supported-trans + :provided-transclusion-scope transclusion-scope})) + (let [embed-id (random-uuid) + block (reactive/get-reactive-block-document [:block/uid block-uid])] + [:> Box {:class "block-embed" + :bg "background.basement" + :flex 1 + :pr 1 + :position "relative" + :display "flex" + :sx {"> .block-container" {:ml 0 + :flex 1 + :pr "1.3rem" + "textarea" {:background "transparent"}}}} + [:<> + [:f> block-el + (util/recursively-modify-block-for-embed block embed-id) + {:linked-ref false} + {:block-embed? true}] + (when-not @(rf/subscribe [:editing/is-editing block-uid]) + [:> ButtonGroup {:height "2em" :size "xs" :flex "0 0 auto" :zIndex "5" :alignItems "center"} + [:> IconButton {:on-click (fn [e] + (.. e stopPropagation) + (rf/dispatch [:editing/uid transcluding-block-uid]))} + [:> PencilIcon]]])]])))) + + +(defrecord DefaultBlockRenderer + [linked-ref-data] + + types/BlockTypeProtocol + + (text-view + [_this {:block/keys [string]} {:keys [_from title]}] + (if (not (str/blank? title)) + (parser/parse-to-text title) + (parser/parse-to-text string))) + + + (inline-ref-view + [_this block attr ref-uid uid _callback _with-breadcrumb?] + [inline-ref-view-el _this block attr ref-uid uid _callback _with-breadcrumb?]) + + + (outline-view + [_this block-data callbacks] + [outline-view-el _this block-data callbacks]) + + + (supported-transclusion-scopes + [_this] + #{:embed}) + + + (transclusion-view + [this block-el block-uid callback transclusion-scope] + [tranclusion-view-el this block-el block-uid callback transclusion-scope]) + + + (zoomed-in-view + [_this block-data callbacks] + [zoomed-in-view-el _this block-data callbacks]) + + + (supported-breadcrumb-styles + [_this] + #{:string}) + + + (breadcrumbs-view + [_this _block-data _callbacks _breadcrumb-style])) + + +(defmethod dispatcher/block-type->protocol :default [_k args-map] + (DefaultBlockRenderer. (:linked-ref-data args-map))) diff --git a/src/cljs/athens/types/dispatcher.cljs b/src/cljs/athens/types/dispatcher.cljs new file mode 100644 index 0000000000..b3a686bdde --- /dev/null +++ b/src/cljs/athens/types/dispatcher.cljs @@ -0,0 +1,27 @@ +(ns athens.types.dispatcher) + + +(defn if-not-disabled + [block-type feature-flags] + (let [type->ff {"[[athens/task]]" :tasks + "[[athens/query]]" :queries}] + (if-let [ff (type->ff block-type)] + (and (feature-flags ff) block-type) + block-type))) + + +(defn block-type->protocol-k + [block-type ff] + (if-not-disabled block-type ff)) + + +(defmulti block-type->protocol + "Returns `BlockTypeProtocol` to be used for rendering based on k. + Use block-type->protocol-k to compute k, and pass it as metadata to reagent components that + use the renderer as an argument (e.g. `^{:key renderer-k} [some-comp renderer ...]`) to make them reactive. + Clojure multimethods are always the same fn (this one) independently of what method will be dispatched. + Thus Reagent/React components will not re-render because the comp arguments did not change. + Setting the key metadata is a workaround for this problem, see https://stackoverflow.com/a/33461346/2116927" + (fn [k _args-map] + #_(println "block-type->protocol:" (pr-str k)) + k)) diff --git a/src/cljs/athens/types/query/kanban.cljs b/src/cljs/athens/types/query/kanban.cljs new file mode 100644 index 0000000000..1cdde6835d --- /dev/null +++ b/src/cljs/athens/types/query/kanban.cljs @@ -0,0 +1,309 @@ +(ns athens.types.query.kanban + "Views for Athens Tasks" + (:require + ["/components/Block/BlockFormInput" :refer [BlockFormInput]] + ["/components/DnD/DndContext" :refer [DragAndDropContext]] + ["/components/DnD/Droppable" :refer [Droppable]] + ["/components/DnD/Sortable" :refer [Sortable]] + ["/components/Icons/Icons" :refer [ArrowRightOnBoxIcon PlusIcon]] + ["/components/ModalInput/ModalInput" :refer [ModalInput]] + ["/components/ModalInput/ModalInputPopover" :refer [ModalInputPopover]] + ["/components/ModalInput/ModalInputTrigger" :refer [ModalInputTrigger]] + ["/components/Query/KanbanBoard" :refer [KanbanBoard + KanbanCard + KanbanSwimlane + KanbanColumn]] + ["@chakra-ui/react" :refer [IconButton + HStack + Heading + ButtonGroup + Flex + VStack + HStack + Text]] + ["@dnd-kit/core" :refer [closestCorners, + DragOverlay,]] + ["@dnd-kit/sortable" :refer [SortableContext, + verticalListSortingStrategy,]] + [athens.common-db :as common-db] + [athens.common-events :as common-events] + [athens.common-events.bfs :as bfs] + [athens.common-events.graph.composite :as composite] + [athens.common-events.graph.ops :as graph-ops] + [athens.common.utils :as utils] + [athens.dates :as dates] + [athens.db :as db] + [athens.parse-renderer :as parse-renderer] + [athens.reactive :as reactive] + [athens.self-hosted.presence.views :as presence] + [athens.types.query.shared :as shared] + [clojure.string :refer []] + [re-frame.core :as rf] + [reagent.core :as r])) + + +(defn context-to-block-properties + [context] + (apply hash-map + (->> context + (map (fn [[k v]] + [k #:block{:string v + :uid (utils/gen-block-uid)}])) + flatten))) + + +(defn new-card + "new-card needs to know the context of where it was pressed. For example, pressing it in a given column and swimlane + would pass along those properties to the new card. Filter conditions would also be passed along. It doesn't matter if + inherited properties are passed throughu group, subgroup, or filters. It just matters that they are true, and the view should be derived properly. + + context == {:task/status 'todo' + :task/project '[[Project: ASD]]'" + [context f-special query-uid] + (let [context (js->clj context) + new-block-props (context-to-block-properties context) + parent-of-new-block (if (= f-special "On this page") + {:block/uid query-uid} + {:page/title (-> (dates/get-day) :title)}) + position (merge {:relation :last} parent-of-new-block) + evt (->> (bfs/internal-representation->atomic-ops + @athens.db/dsdb + [#:block{:uid (utils/gen-block-uid) + :string "" + :properties (merge {":entity/type" #:block{:string "[[athens/task]]" + :uid (utils/gen-block-uid)} + ":task/title" #:block{:string "Untitled task" + :uid (utils/gen-block-uid)}} + new-block-props)}] + position) + (composite/make-consequence-op {:op/type :new-type}) + common-events/build-atomic-event)] + (re-frame.core/dispatch [:resolve-transact-forward evt]))) + + +;; UPDATE + + +(defn update-card-container + [id active-container-context over-container-context] + (let [{active-swimlane-id :swimlane-id active-column-id :column-id} active-container-context + {over-swimlane-id :swimlane-id over-column-id :column-id} over-container-context + diff-column? (not= active-column-id over-column-id) + diff-swimlane? (not= active-swimlane-id over-swimlane-id) + nil-swimlane? (= over-swimlane-id "None") + nil-column? (= over-column-id "None") + new-column (str "((" over-column-id "))")] + (when diff-swimlane? + (rf/dispatch [:graph/update-in [:block/uid id] [":task/assignee"] + (fn [db prop-uid] + [(if nil-swimlane? + (graph-ops/build-block-remove-op db prop-uid) + (graph-ops/build-block-save-op db prop-uid over-swimlane-id))])])) + (when diff-column? + (rf/dispatch [:graph/update-in [:block/uid id] [":task/status"] + (fn [db prop-uid] + [(if nil-column? + (graph-ops/build-block-remove-op db prop-uid) + (graph-ops/build-block-save-op db prop-uid new-column))])])))) + + +(defn- find-container-id + "Accepts event.active or event.over" + [e active-or-over] + (try + (case active-or-over + :active (.. e -active -data -current -sortable -containerId) + :over (.. e -over -data -current -sortable -containerId)) + (catch js/Object _ + (case active-or-over + :active (.. e -active -id) + :over (.. e -over -id))))) + + +(defn- get-container-context + [container-id] + (let [swimlane-id (-> (re-find #"swimlane-(@?\w+)" container-id) second) + column-id (-> (re-find #"column-(\w+)" container-id) second)] + {:swimlane-id swimlane-id + :column-id column-id})) + + +(defn render-card + [uid over?] + (let [card (-> (reactive/get-reactive-block-document [:block/uid uid]) + shared/block-to-flat-map + shared/get-root-page) + title (get card ":task/title") + status (get card ":task/status") + priority (get card ":task/priority") + assignee (get card ":task/assignee") + _page (get card ":task/page") + _due-date (get card ":task/due-date") + assignee-value (shared/parse-for-title assignee) + status-uid (shared/parse-for-uid status) + _status-value (common-db/get-block-string @db/dsdb status-uid) + priority-uid (shared/parse-for-uid priority) + priority-value (common-db/get-block-string @db/dsdb priority-uid) + parent-uid (:block/uid (common-db/get-parent @db/dsdb [:block/uid uid])) + ;; TODO: figure out how to give unique id when one card can show up multiple times on a query, e.g. a card that belongs to multiple projects + ;; could use swimlane and column data for uniqueness + id (str uid)] + [:> Sortable {:id id :key id} + [:> KanbanCard {:isOver over?} + [:> VStack {:spacing 0 + :align "stretch"} + [:> ModalInput {:autoFocus true} + [:> ModalInputTrigger + ;; TODO show something if empty title + [:> Text {:fontWeight "medium" + :onPointerDown #(.stopPropagation %) + :lineHeight "short"} [parse-renderer/parse-and-render title uid]]] + [:> ModalInputPopover {:preventScroll false} + [:> BlockFormInput {:size "md" + :isMultiline true + :onPointerDown #(.stopPropagation %)} + [shared/title-editor uid title] + [presence/inline-presence-el uid]]]] + [:> HStack {:justifyContent "space-between" + :fontSize "sm" + :color "foreground.secondary"} + [:> HStack + [:> Text assignee-value] + [:> Text priority-value]] + [:> ButtonGroup {:justifyContent "space-between" + :size "xs" + :variant "ghost" + :colorScheme "subtle" + :onPointerDown #(.stopPropagation %)} + [:> IconButton {:zIndex 1 + :onClick #(rf/dispatch [:right-sidebar/open-item [:block/uid parent-uid]])} + [:> ArrowRightOnBoxIcon]]]]]]])) + + +(defn DragAndDropKanbanBoard + [_props] + (let [active-id (r/atom nil) + over-id (r/atom nil)] + (fn [props] + (let [{:keys [query-uid f-special boardData all-possible-group-by-columns groupBy subgroupBy]} props] + [:> DragAndDropContext {:collisionDetection closestCorners + :onDragStart (fn [e] + (reset! active-id (.. e -active -id))) + :onDragOver (fn [e] + (reset! over-id (.. e -over -id))) + :onDragEnd (fn [e] + ;; TODO: should context metadata be stored at the card level or the container level? + (let [over-container (find-container-id e :over) + active-container (find-container-id e :active) + over-container-context (get-container-context over-container) + active-container-context (get-container-context active-container)] + (update-card-container @active-id active-container-context over-container-context) + (reset! active-id nil) + (reset! over-id nil)))} + + [:> KanbanBoard + [:> Heading {:size "md"} "TODO: Create title handler for queries"] + (doall + (for [swimlanes boardData] + (let [[swimlane-id swimlane-columns] swimlanes + nil-swimlane-id? (nil? swimlane-id) + ;; TODO: doesn't handle empty assignee well, or values that are not expected + swimlane-id (if swimlane-id swimlane-id "None") + swimlane-key (if nil-swimlane-id? "None" swimlane-id)] + + [:> KanbanSwimlane {:name swimlane-key + :key swimlane-key + :bg "background.basement"} + (doall + (for [possible-group-by-column all-possible-group-by-columns] + (let [{:block/keys [string uid]} possible-group-by-column + cards-from-a-column (if (= string "None") + (get swimlane-columns nil) + (get swimlane-columns uid)) + ;; context-object assumes group-by is always status, because of the uid stuff + context-object (cond-> {} + (and (= groupBy ":task/status") + (not (nil? uid))) (assoc groupBy (str "((" uid "))")) + (not nil-swimlane-id?) (assoc subgroupBy (str "[[" swimlane-id "]]"))) + column-id (if uid uid "None") + column-id (str "swimlane-" swimlane-id "-column-" column-id)] + + [:> Droppable {:key column-id :id column-id} + (fn [over?] + (r/as-element + [:> SortableContext {:id column-id + :items (or cards-from-a-column []) + :strategy verticalListSortingStrategy} + [:> KanbanColumn {:key column-id + :isOver over?} + [:> Flex {:color "foreground.secondary" + :gap 2 + :px 4 + :py 1 + :alignItems "center"} + [:> Heading {:fontWeight "medium" + :mr "auto" + :size "sm"} + string] + [:> Text {:fontWeight "medium" + :fontSize "sm"} + (str (count cards-from-a-column))] + [:> ButtonGroup {:size "sm" + :variant "ghost"} + [:> IconButton {:onClick #(new-card context-object f-special query-uid) + :icon (r/as-element [:> PlusIcon])}]]] + (doall + (for [card cards-from-a-column] + (let [card-uid (get card ":block/uid") + over? (= @over-id card-uid)] + ^{:key card-uid} [render-card card-uid over?])))]]))])))]))) + + [:> DragOverlay + (when @active-id + [:<> + ;; [:h1 @over-id] + [render-card @active-id]])]]])))) + + +#_(defn update-status + [id new-status] + (rf/dispatch [:graph/update-in [:block/uid id] [":task/status"] + (fn [db prop-uid] + [(graph-ops/build-block-save-op db prop-uid new-status)])])) + + +;; All commented out for when we modify kanban columns +#_(defn new-kanban-column + "This creates a new block/child at the property/values key, but the kanban board doesn't trigger a re-render because it isn't aware of property/values yet." + [group-by-id] + (rf/dispatch [:graph/update-in [:node/title group-by-id] [":property/values"] + (fn [db prop-uid] + [(graph-ops/build-block-new-op db (utils/gen-block-uid) {:block/uid prop-uid :relation :last})])])) + + +#_(defn update-many-properties + [db key value new-value] + (->> (common-db/get-instances-of-key-value db key value) + (map #(get-in % [key :block/uid])) + (map (fn [uid] + (graph-ops/build-block-save-op db uid new-value))))) + + +#_(defn update-kanban-column + "Update the property page that is the source of values for a property. + Also update all the blocks that are using that property." + [property-key property-value new-value] + (rf/dispatch [:graph/update-in [:node/title property-key] [":property/values"] + (fn [db prop-uid] + (let [{:block/keys [children]} (common-db/get-block-document db [:block/uid prop-uid]) + update-uid (->> children + (map (fn [{:block/keys [string uid]}] [string uid])) + (filter #(= (first %) property-value)) + (first) + second) + ;; update all blocks that match key:value to key:new-value + update-ops (update-many-properties db property-key property-value new-value)] + + (vec (concat [(graph-ops/build-block-save-op db update-uid new-value)] + update-ops))))])) + diff --git a/src/cljs/athens/types/query/shared.cljs b/src/cljs/athens/types/query/shared.cljs new file mode 100644 index 0000000000..89f90127b5 --- /dev/null +++ b/src/cljs/athens/types/query/shared.cljs @@ -0,0 +1,114 @@ +(ns athens.types.query.shared + (:require + [athens.common-events.graph.ops :as graph-ops] + [athens.db :as db] + [athens.views.blocks.editor :as editor] + [re-frame.core :as rf] + [reagent.core :as r])) + + +(defn get-root-page + [x] + (merge x + {":task/page" (:node/title (db/get-root-parent-page (get x ":block/uid")))})) + + +(defn parse-for-title + "should be able to pass in a plain string, a wikilink, or both?" + [s] + (when (seq s) + (let [re #"\[\[(.*)\]\]"] + (cond + (re-find re s) (second (re-find re s)) + (clojure.string/blank? s) (throw "parse-for-title got an empty string") + :else s)))) + + +(defn parse-for-uid + "should be able to pass in a plain string, a wikilink, or both?" + [s] + (when (seq s) + (let [re #"\(\((.*)\)\)"] + (cond + (re-find re s) (second (re-find re s)) + (clojure.string/blank? s) (throw "parse-for-title got an empty string") + :else s)))) + + +(defn get-create-auth-and-time + [create-event] + {":create/auth" (get-in create-event [:event/auth :presence/id]) + ":create/time" (get-in create-event [:event/time :time/ts])}) + + +(defn get-last-edit-auth-and-time + [edit-events] + (let [last-edit (last edit-events)] + {":last-edit/auth" (get-in last-edit [:event/auth :presence/id]) + ":last-edit/time" (get-in last-edit [:event/time :time/ts])})) + + +(defn block-to-flat-map + [block] + ;; TODO: we could technically give pages all the properties of tasks and put them on a kanban board... + (let [{:block/keys [uid string properties create edits _children] :keys [node/_title]} block + create-auth-and-time (get-create-auth-and-time create) + last-edit-auth-and-time (get-last-edit-auth-and-time edits) + property-keys (keys properties) + props-map (reduce (fn [acc prop-key] + (assoc acc prop-key (get-in properties [prop-key :block/string]))) + {} + property-keys) + merged-map (merge {":block/uid" uid + ":block/string" string} + props-map + create-auth-and-time + last-edit-auth-and-time + {":task/status" (parse-for-uid (get props-map ":task/status"))} + {":task/assignee" (parse-for-title (get props-map ":task/assignee"))})] + #_(when children + (prn (get merged-map ":task/title") uid children)) + merged-map)) + + +(defn update-card-field + [id k new-value] + (rf/dispatch [:graph/update-in [:block/uid id] [k] + (fn [db prop-uid] + [(graph-ops/build-block-save-op db prop-uid new-value)])])) + + +(defn title-editor + [uid title] + (let [value-atom (r/atom (or title "")) + show-edit-atom? (r/atom true) + block-o {:block/uid uid}] + (fn [] + (let [enter-fn! (fn [uid d-key-down] + (let [{:keys [target]} d-key-down] + + (update-card-field uid ":task/title" @value-atom) + (reset! show-edit-atom? false) + ;; side effect + (.blur target))) + save-fn! #(do + (update-card-field uid ":task/title" @value-atom) + (rf/dispatch [:block/save {:uid uid + :string @value-atom}])) + state-hooks {:save-fn save-fn! + :enter-handler enter-fn! + :idle-fn #() + :update-fn #(reset! value-atom %) + :read-value value-atom + :show-edit? show-edit-atom? + :esc-handler (fn [e _uid] + (reset! value-atom title) + (.. e -target blur)) + :tab-handler #() + :backspace-handler #() + :delete-handler #() + :default-verbatim-paste? true + :keyboard-navigation? false + :style {:opacity 1} + :placeholder "Write your task title here"}] + [editor/block-editor block-o state-hooks])))) diff --git a/src/cljs/athens/types/query/table.cljs b/src/cljs/athens/types/query/table.cljs new file mode 100644 index 0000000000..9094076ab2 --- /dev/null +++ b/src/cljs/athens/types/query/table.cljs @@ -0,0 +1,184 @@ +(ns athens.types.query.table + "Views for Athens Tasks" + (:require + ["/components/Block/BlockFormInput" :refer [BlockFormInput]] + ["/components/Block/Taskbox" :refer [Taskbox]] + ["/components/Icons/Icons" :refer [PencilIcon GraphChildIcon]] + ["/components/ModalInput/ModalInput" :refer [ModalInput]] + ["/components/ModalInput/ModalInputAnchor" :refer [ModalInputAnchor]] + ["/components/ModalInput/ModalInputPopover" :refer [ModalInputPopover]] + ["/components/ModalInput/ModalInputTrigger" :refer [ModalInputTrigger]] + ["@chakra-ui/react" :refer [IconButton + HStack + Grid + Heading + Flex + HStack + Text]] + ["@dnd-kit/core" :refer []] + ["@dnd-kit/sortable" :refer []] + [athens.common-db :as common-db] + [athens.db :as db] + [athens.reactive :as reactive] + [athens.types.query.shared :as shared] + [clojure.string :refer []] + [reagent.core :as r])) + + +(def header-row-cell-style + {:position "sticky" + :left 0 + :zIndex 1}) + + +(defn render-entity-row + [uid children indent grid-template-cols] + (let [entity (->> (reactive/get-reactive-block-document [:block/uid uid]) + shared/block-to-flat-map) + page-title (common-db/get-page-title @db/dsdb uid) + task-title (get entity ":task/title") + title (or page-title task-title) + status (get entity ":task/status") + + priority (get entity ":task/priority") + assignee (get entity ":task/assignee") + _page (get entity ":task/page") + due-date (get entity ":task/due-date") + + curr-indent-width (str (* 1 indent) "rem") + + ;; for when we can collapse items + _is-collapsed? false + + assignee-value (shared/parse-for-title assignee) + status-uid (shared/parse-for-uid status) + status-value (common-db/get-block-string @db/dsdb status-uid) + priority-uid (shared/parse-for-uid priority) + priority-value (common-db/get-block-string @db/dsdb priority-uid) + _parent-uid (:block/uid (common-db/get-parent @db/dsdb [:block/uid uid])) + entity-type (common-db/get-entity-type @db/dsdb [:block/uid uid]) + is-root (= entity-type "page")] + + + + [:<> + (case entity-type + "page" + [:> Grid {:templateColumns grid-template-cols + :templateRows "auto" + :pt 8 + :textAlign "start"} + [:> Text (merge header-row-cell-style + {:color "foreground.secondary"}) title] + [:> Text {:alignSelf "stretch" :size "md"} status-value] + [:> Text {:alignSelf "stretch" :size "md"} priority-value] + [:> Text {:alignSelf "stretch" :size "md"} assignee-value] + [:> Text {:alignSelf "stretch" :size "md"} due-date]] + + "block" + [:> Grid {:templateColumns grid-template-cols + :textAlign "start" + :position "relative" + :borderTop "1px solid" + :borderColor "separator.border" + :_hover {:bg "interaction.surface.hover"}} + [:> Text (merge header-row-cell-style + {:color "foreground.secondary" + :pl curr-indent-width + :fontSize "xs"}) + (str " • " (get entity ":block/string"))]] + + "[[athens/task]]" + [:> Grid {:templateColumns grid-template-cols + :textAlign "start" + :position "relative" + :borderTop "1px solid" + :borderColor "separator.border" + :_hover {:bg "interaction.surface.hover"}} + + [:> Flex (merge + header-row-cell-style + {:alignSelf "inline-flex" + :align "center" + :gap 1 + :pr 1 + :pl curr-indent-width}) + + ;; Comment out until we figure out how to persist open/close state on tables + #_[:> IconButton {:size "xs" + :variant "ghost" + :colorScheme "subtle" + :onClick #(js/alert "TODO: implement toggle")} + + [:> ChevronDownVariableIcon {:sx {:path {:strokeWidth "1.5px"}} + :boxSize 3 + :transform (if is-collapsed? "rotate(-90deg)" "")}]] + [:> ModalInput {:placement "right-start" + :autoFocus true} + [:> ModalInputAnchor + [:> Flex {:alignSelf "inline-flex" + :flex "1 1 100%" + :align "center" + :gap 0.5} + (if status-value + [:> Taskbox {:status status-value}] + [:> Taskbox {}]) + [:> Text {:pl 1 + :as "span" + :color (if is-root "foreground.secondary" "foreground.primary")} title] + (when (seq children) + [:> HStack {:spacing 0 + :color "foreground.tertiary"} + [:> GraphChildIcon] + [:> Text {:as "span"} (count children)]])]] + [:> ModalInputTrigger + [:> IconButton {:size "sm" + :variant "ghost" + :colorScheme "subtle" + :icon (r/as-element [:> PencilIcon])}]] + [:> ModalInputPopover {:popoverContentProps {:mx "-5px" :my "-1px"}} + [:> Flex {:align "center" + :gap 0.5} + (if status-value + [:> Taskbox {:mx 1 :my "auto" + :status status-value}] + [:> Taskbox {:mx 1 :my "auto"}]) + [:> BlockFormInput {:variant "unstyled" + :flex "1 1 100%" + :size "md"} + [shared/title-editor uid title]]]]]] + [:> Text {:alignSelf "stretch" :size "md"} status-value] + [:> Text {:alignSelf "stretch" :size "md"} priority-value] + [:> Text {:alignSelf "stretch" :size "md"} assignee-value] + [:> Text {:alignSelf "stretch" :size "md"} due-date]] + + [:> Text {:color "foreground.secondary" + :borderTop "1px solid" + :borderColor "separator.border" + :pl curr-indent-width + :fontSize "xs"} + (str "I am a block with type " entity-type)]) + + [:<> + (for [[uid children] children] + ^{:key uid} + [render-entity-row uid children (inc indent) grid-template-cols])]])) + + +(defn QueryTableV2 + [{:keys [data columns] :as _props}] + (let [grid-template-cols "minmax(20em, 1fr) 9em 9em 9em 9em"] + [:> Flex {:flexDirection "column" :align "stretch" :py 4 :width "100%" :overflowX "auto"} + [:> Grid {:templateColumns grid-template-cols :textAlign "start"} + (for [column columns] + ^{:key column} + [:> Heading (merge + {:size "sm" :fontWeight "normal" :color "foreground.secondary"} + (when (= column "Title") + header-row-cell-style)) + column])] + [:<> + (for [[uid children] data] + ^{:key uid} + [render-entity-row uid children 0 grid-template-cols])]])) + diff --git a/src/cljs/athens/types/query/view.cljs b/src/cljs/athens/types/query/view.cljs new file mode 100644 index 0000000000..cbc2ac6381 --- /dev/null +++ b/src/cljs/athens/types/query/view.cljs @@ -0,0 +1,399 @@ +(ns athens.types.query.view + "Views for Athens Tasks" + (:require + ["/components/Query/Query" :refer [QueryRadioMenu]] + ["@chakra-ui/react" :refer [Box, + ButtonGroup + VStack]] + [athens.common-events.graph.ops :as graph-ops] + [athens.db :as db] + [athens.reactive :as reactive] + [athens.types.core :as types] + [athens.types.dispatcher :as dispatcher] + [athens.types.query.kanban :refer [DragAndDropKanbanBoard]] + [athens.types.query.shared :as shared] + [athens.types.query.table :refer [QueryTableV2]] + [clojure.string :refer []] + [re-frame.core :as rf])) + + +;; CONSTANTS + + +(def base-schema + [":block/uid" ":create/auth" ":create/time" ":last-edit/auth" ":last-edit/time"]) + + +(def SCHEMA + ;; "[[athens/comment-thread]]" (concat base-schema [])}) + {"[[athens/task]]" (concat [":task/title" ":task/page" ":task/status" ":task/assignee" ":task/priority" ":task/due-date"] base-schema)}) + + +(def AUTHORS + ["None" "Sid" "Jeff" "Stuart" "Filipe" "Alex"]) + + +(def LAYOUTS + ["table" "board" #_"list"]) + + +(def SORT_DIRECTIONS + ["asc" "desc"]) + + +(def SPECIAL_FILTERS + ["None" "On this page"]) + + +(def GROUP_BY_OPTIONS + [":task/page" ":task/status" ":task/assignee" ":task/priority" ":create/auth"]) + + +(def DEFAULT-PROPS + {":entity/type" "[[athens/query]]" + "athens/query/layout" "table" + "athens/query/select" "[[athens/comment-thread]]" + "athens/query/filter/author" "None" + "athens/query/filter/special" "None" + "athens/query/sort/by" ":create/time" + "athens/query/sort/direction" "desc" + "athens/query/group/by" ":create/auth" + "athens/query/group/subgroup/by" ":create/auth" + "athens/query/properties/hide" {} + "athens/query/properties/order" nil}) + + +(defn get* + [hm ks] + (->> (map #(str "athens/query/" %) ks) + (map #(get hm %)))) + + +(defn get-schema + [k] + (or (get SCHEMA k) base-schema)) + + +(def ENTITY_TYPES + (keys SCHEMA)) + + +;; Helpers + + +(defn nested-group-by + "You have to pass the first group" + [kw columns] + (->> (map (fn [[k v]] + [k (group-by #(get % kw) v)]) + columns) + (into (hash-map)))) + + +(defn group-stuff + [g sg items] + (->> items + (group-by #(get % sg)) + (nested-group-by g))) + + +(defn tasks-to-trees + [tasks] + (reduce (fn [m [uid parents]] + (if (seq parents) + (assoc-in m parents {uid {}}) + (assoc m uid {}))) + {} + tasks)) + + +;; update properties + +(defn update-query-property + [uid key new-value] + (let [namespaced-key (str "athens/query/" key)] + (rf/dispatch [:graph/update-in [:block/uid uid] [namespaced-key] + (fn [db prop-uid] + [(graph-ops/build-block-save-op db prop-uid new-value)])]))) + + +#_(defn toggle-hidden-property + "If property is hidden, remove key. Otherwise, add property key." + [id hidden-property-id] + (js/alert "not implemented") + #_(rf/dispatch [:graph/update-in [:block/uid id] ["athens/query/properties/hide" hidden-property-id] + (fn [db hidden-prop-uid] + (let [property-hidden? (common-db/block-exists? db [:block/uid hidden-prop-uid])] + [(if property-hidden? + (graph-ops/build-block-remove-op @db/dsdb hidden-prop-uid) + (graph-ops/build-block-save-op db hidden-prop-uid ""))]))])) + + +(defn order-children + [children] + (->> (sort-by :block/order children))) + + +(defn get-query-props + [properties] + (->> properties + (reduce-kv + (fn [acc k {:block/keys [children string] nested-properties :block/properties :as _v}] + (assoc acc k (cond + (and (seq children) (not (clojure.string/blank? string))) {:key string :values (order-children children)} + (seq children) (order-children children) + nested-properties (reduce-kv (fn [acc k v] + (assoc acc k (:block/string v))) + {} + nested-properties) + :else string))) + {}) + (merge DEFAULT-PROPS))) + + +(defn get-reactive-property + [eid property-key] + (let [property-page (reactive/get-reactive-block-document eid) + property (get-in property-page [:block/properties property-key]) + {:block/keys [children properties _string]} property] + (cond + (seq children) (->> children + order-children + (mapv (fn [{:block/keys [uid string]}] + #:block{:uid uid :string string}))) + (seq properties) (keys properties)))) + + +(defn sort-dir-fn + [query-sort-direction] + (if (= query-sort-direction "asc") + compare + (comp - compare))) + + +(defn sort-table + [query-data query-sort-by query-sort-direction] + (->> query-data + (sort-by #(get % query-sort-by) + (sort-dir-fn query-sort-direction)))) + + +;; Views + + +(defn options-el + [{:keys [_properties parsed-properties uid schema]}] + (let [[layout select _p-order _p-hide f-author f-special s-by s-direction g-by g-s-by] + (get* parsed-properties ["layout" "select" "properties/order" "properties/hide" "filter/author" "filter/special" "sort/by" "sort/direction" "group/by" "group/subgroup/by"]) + s-by (shared/parse-for-title s-by) + menus-data [{:heading "Entity Type" + :options ENTITY_TYPES + :onChange #(update-query-property uid "select" %) + :value select} + {:heading "Layout" + :options LAYOUTS + :onChange #(update-query-property uid "layout" %) + :value layout} + {:heading "Filter By Author" + :options AUTHORS + :onChange #(update-query-property uid "filter/author" %) + :value f-author} + {:heading "Special Filters" + :options SPECIAL_FILTERS + :onChange #(update-query-property uid "filter/special" %) + :value f-special} + {:heading "Sort By" + :options schema + :onChange #(update-query-property uid "sort/by" %) + :value s-by} + {:heading "Sort Direction" + :options SORT_DIRECTIONS + :onChange #(update-query-property uid "sort/direction" %) + :value s-direction} + {:heading "Group By (Board)" + :options GROUP_BY_OPTIONS + :onChange #(update-query-property uid "group/by" %) + :value g-by} + {:heading "Subgroup By (Board)" + :options GROUP_BY_OPTIONS + :onChange #(update-query-property uid "group/subgroup/by" %) + :value g-s-by}]] + [:> ButtonGroup {:isAttached true :gap "1px" :size "xs"} + (for [menu menus-data] + ^{:key menu} + (let [{:keys [heading options onChange value]} menu] + [:> QueryRadioMenu {:key heading :heading heading :options options :onChange onChange :value value}]))])) + + +(defn query-el + [{:keys [query-data parsed-properties uid _schema]}] + (let [query-uid uid + [_select layout s-by s-direction f-author f-special _p-order _p-hide] + (get* parsed-properties ["select" "layout" "sort/by" "sort/direction" "filter/author" "filter/special" "properties/order" "properties/hide"]) + s-by (shared/parse-for-title s-by) + filter-author-fn (fn [x] + (let [entity-author (get x ":create/auth")] + (or (= f-author "None") + (= f-author + entity-author)))) + special-filter-fn (fn [x] + (cond + (= f-special "On this page") + (let [comment-uid (get x ":block/uid") + comments-parent-page (-> (db/get-root-parent-page comment-uid) + :node/title) + current-page-of-query (-> (db/get-root-parent-page uid) + :node/title)] + (= comments-parent-page current-page-of-query)) + :else true)) + query-data (filterv special-filter-fn query-data) + query-data (filterv filter-author-fn query-data) + query-data (sort-table query-data s-by s-direction)] + [:> VStack {:className "query-el" :key query-uid :align "stretch"} + (case layout + "board" + (let [[g-by sg-by] (get* parsed-properties ["group/by" "group/subgroup/by"]) + query-group-by (shared/parse-for-title g-by) + query-subgroup-by (shared/parse-for-title sg-by) + all-possible-group-by-columns (concat [{:block/string "None" :block/uid nil}] + (get-reactive-property [:node/title query-group-by] ":property/enum")) + boardData (if (and query-subgroup-by query-group-by) + (group-stuff query-group-by query-subgroup-by query-data) + (group-by #(get % query-group-by) query-data))] + + [DragAndDropKanbanBoard {:query-uid query-uid + :f-special f-special + :boardData boardData + :all-possible-group-by-columns all-possible-group-by-columns + :groupBy g-by + :subgroupBy sg-by}]) + + "list" + [:div "TODO"] + + "table" + (let [get-parents (fn [uid] + (let [parent-uids (->> (db/get-parents-recursively [:block/uid uid]) + (mapv :block/uid))] + [uid parent-uids])) + sort-by-parents-count (fn [x] (-> x second count)) + tasks (->> (reactive/get-reactive-instances-of-key-value ":entity/type" "[[athens/task]]") + ;; query-data + (mapv :block/uid) + (mapv get-parents) + (sort-by sort-by-parents-count)) + task-trees (tasks-to-trees tasks)] + + [QueryTableV2 {:data task-trees + :columns ["Title" "Status" "Priority" "Assignee" "Due Date"]}]))])) + + +(comment "current shape of data for query kanban boards" + ;; e.g. + {"person" {"status" [{"task 1" 1}]}} + ;; aka + {"subgroup" {"group" ["card 1"]}} + + [:map [:map [:vector [:maps]]]] + + {nil {nil [{":block/uid" "1f29dce5b", ":block/string" "test", ":entity/type" "[[athens/task]]", ":create/auth" "Sid", ":create/time" 1660894009080, ":last-edit/auth" nil, ":last-edit/time" 1661011262703, ":task/page" "August 19, 2022"} + {":block/uid" "08-16-2022", ":block/string" nil, ":entity/type" "[[athens/task]]", ":create/auth" "Sid", ":create/time" 1660624243084, ":last-edit/auth" nil, ":last-edit/time" 1660624292449, ":task/page" "August 16, 2022"}]}, + "" {nil [{":block/string" "", ":create/time" 1660904634520, ":create/auth" "Sid", ":last-edit/time" 1661013113312, ":entity/type" "[[athens/task]]", ":last-edit/auth" nil, ":task/page" "August 19, 2022", ":block/uid" "13f68a3b4", ":task/due-date" "asd", ":task/assignee" ""}]}, + "[[@Filipe]]" {"((326893972))" [{":block/string" "", ":create/time" 1661245796014, ":create/auth" "Jeff", ":last-edit/time" 1661245843288, ":entity/type" "[[athens/task]]", ":last-edit/auth" nil, ":task/priority" "((3ae3f4da9))", ":task/page" "Project: Tasks", ":block/uid" "6e2d0b69d", ":task/title" "design api", ":task/due-date" "[[August 22, 2022]] ", ":task/assignee" "[[@Filipe]]", ":task/status" "((326893972))"}]}, + "[[@Jeff]]" {"((5f282d535))" [{":block/string" "", ":create/time" 1661245784436, ":create/auth" "Jeff", ":last-edit/time" 1661245940745, ":entity/type" "[[athens/task]]", ":last-edit/auth" nil, ":task/priority" "((c45df8496))", ":task/page" "Project: Tasks", ":block/uid" "37fcd71e9", ":task/title" "Design UIs", ":task/due-date" "[[August 22, 2022]] ", ":task/assignee" "[[@Jeff]]", ":task/status" "((5f282d535))"}]}, + "[[@Sid]]" {"((c09f1865b))" [{":block/string" "", ":create/time" 1661245808997, ":create/auth" "Jeff", ":last-edit/time" 1661269630733, ":entity/type" "[[athens/task]]", ":last-edit/auth" nil, ":task/priority" "((abf97f9bc))", ":task/page" "Project: Tasks", ":block/uid" "c8c798cea", ":task/title" "announce the release on twitter", ":task/due-date" "[[August 22, 2022]] ", ":task/assignee" "[[@Sid]]", ":task/status" "((c09f1865b))"}]}} + + ;; there are better data structs for this too that i want to try + ;; like more consistency of data structs. always use map or always use vector + [:map [:map [:map [:map [:vector [:maps]]]]]] + {"group-id-1" {"id" "group-id-1" + "subgroups" {"subgroup-id-1" {"id" "subgroup-id-1" + "cards" [{} {}]}}}} + + + ;; or changing the order of group-by/subgroup-by + {"group-id" {"subgroup-id" [{:card 1} {:card 2}]}}) + + +(comment "idk how to solve" + ["ordering of cards" + "ordering of columns" + "vectors vs maps"]) + + +(defn invalid-query? + [parsed-props] + (let [[layout group-by] (get* parsed-props ["layout" "group/by"])] + (and (= layout "board") + (nil? group-by)))) + + +;; TODO: fix properties +;; clicking on them can add an SVG somehow +;; and then if there are block/children, it is no bueno + + +(defn query-block + [block-data] + (let [block-uid (:block/uid block-data) + properties (:block/properties block-data) + parsed-properties (get-query-props properties) + [select] (get* parsed-properties ["select"]) + schema (get-schema select) + query-data (->> (reactive/get-reactive-instances-of-key-value ":entity/type" select) + (map shared/block-to-flat-map) + (map shared/get-root-page))] + + (if (invalid-query? parsed-properties) + [:> Box {:color "red"} "invalid query"] + [:<> + [options-el {:parsed-properties parsed-properties + :properties properties + :schema schema + :query-data query-data + :uid block-uid}] + [query-el {:query-data query-data + :uid block-uid + :schema schema + :parsed-properties parsed-properties}]]))) + + +(defrecord QueryView + [] + + types/BlockTypeProtocol + + (inline-ref-view + [_this _block-data _attr _ref-uid _uid _callbacks _with-breadcrumb?]) + + + (outline-view + [_this block-data _callbacks] + (let [block-uid (:block/uid block-data)] + (fn [_block-data _callbacks] + (let [block (-> [:block/uid block-uid] reactive/get-reactive-block-document)] + [query-block block])))) + + + (supported-transclusion-scopes + [_this]) + + + (transclusion-view + [_this _block-el _block-uid _callback _transclusion-scope]) + + + (zoomed-in-view + [_this _block-data _callbacks]) + + + (supported-breadcrumb-styles + [_this]) + + + (breadcrumbs-view + [_this _block-data _callbacks _breadcrumb-style])) + + +(defmethod dispatcher/block-type->protocol "[[athens/query]]" [_k _args-map] + (QueryView.)) diff --git a/src/cljs/athens/types/tasks/db.cljs b/src/cljs/athens/types/tasks/db.cljs new file mode 100644 index 0000000000..17d77e7a11 --- /dev/null +++ b/src/cljs/athens/types/tasks/db.cljs @@ -0,0 +1,9 @@ +(ns athens.types.tasks.db + (:require + [athens.common-db :as common-db])) + + +(defn get-title-block-of-task + [db uid] + (let [block (common-db/get-block-property-document db [:block/uid uid])] + (get block ":task/title"))) diff --git a/src/cljs/athens/types/tasks/handlers.cljs b/src/cljs/athens/types/tasks/handlers.cljs new file mode 100644 index 0000000000..a2d54edfa9 --- /dev/null +++ b/src/cljs/athens/types/tasks/handlers.cljs @@ -0,0 +1,17 @@ +(ns athens.types.tasks.handlers + (:require + [athens.common-events.graph.ops :as graph-ops] + [athens.types.tasks.shared :as shared] + [re-frame.core :as rf])) + + +(defn update-task-status + [task-uid new-status] + (let [new-status (-> new-status shared/find-status-uid) + new-status (str "((" new-status "))")] + (rf/dispatch [:graph/update-in [:block/uid task-uid] [":task/status"] + (fn [db uid] + [(graph-ops/build-block-save-op db uid new-status)] + #_(if is-checked + [(graph-ops/build-block-save-op db uid (str "((" (find-status-uid "To Do") "))"))] + [(graph-ops/build-block-save-op db uid (str "((" (find-status-uid "Done") "))"))]))]))) diff --git a/src/cljs/athens/types/tasks/shared.cljs b/src/cljs/athens/types/tasks/shared.cljs new file mode 100644 index 0000000000..089c2960f9 --- /dev/null +++ b/src/cljs/athens/types/tasks/shared.cljs @@ -0,0 +1,73 @@ +(ns athens.types.tasks.shared + (:require + [athens.common-db :as common-db] + [athens.common-events.bfs :as bfs] + [athens.reactive :as reactive] + [re-frame.core :as rf])) + + +;; Create default task statuses configuration + +(defn internal-representation-allowed-stauses + [] + [{:block/string "To Do"} + {:block/string "Doing"} + {:block/string "Blocked"} + {:block/string "Done"} + {:block/string "Cancelled"}]) + + +(defn internal-representation-allowed-priorities + [] + [{:block/string "Expedite"} + {:block/string "P1"} + {:block/string "P2"} + {:block/string "P3"} + {:block/string "Nice to have"}]) + + +(defn find-allowed-priorities + [] + (let [task-priority-page (reactive/get-reactive-node-document [:node/title ":task/priority"]) + allowed-prio-blocks (-> task-priority-page + :block/properties + (get ":property/enum") + :block/children) + allowed-priorities (map #(select-keys % [:block/uid :block/string]) allowed-prio-blocks)] + (when-not allowed-prio-blocks + (rf/dispatch [:graph/update-in [:node/title ":task/priority"] [":property/enum"] + (fn [db uid] + (when-not (common-db/block-exists? db [:block/uid uid]) + (bfs/internal-representation->atomic-ops db (internal-representation-allowed-priorities) + {:block/uid uid :relation :first})))])) + (when (seq allowed-priorities) + allowed-priorities))) + + +(defn find-allowed-statuses + [] + (let [task-status-page (reactive/get-reactive-node-document [:node/title ":task/status"]) + allowed-stat-blocks (-> task-status-page + :block/properties + (get ":property/enum") + :block/children) + allowed-statuses (map #(select-keys % [:block/uid :block/string]) allowed-stat-blocks)] + (when-not allowed-stat-blocks + (rf/dispatch [:graph/update-in [:node/title ":task/status"] [":property/enum"] + (fn [db uid] + (when-not (common-db/block-exists? db [:block/uid uid]) + (bfs/internal-representation->atomic-ops db (internal-representation-allowed-stauses) + {:block/uid uid :relation :first})))])) + (when (seq allowed-statuses) + allowed-statuses))) + + +(defn find-status-uid + [status] + (->> (filter (fn [allowed-status] + (= status (:block/string allowed-status))) + (find-allowed-statuses)) + first + :block/uid)) + + diff --git a/src/cljs/athens/types/tasks/view.cljs b/src/cljs/athens/types/tasks/view.cljs new file mode 100644 index 0000000000..1eddfd7765 --- /dev/null +++ b/src/cljs/athens/types/tasks/view.cljs @@ -0,0 +1,374 @@ +(ns athens.types.tasks.view + "Views for Athens Tasks" + (:require + ["/components/Block/Taskbox" :refer [Taskbox]] + ["/components/Icons/Icons" :refer [PencilIcon]] + ["/components/ModalInput/ModalInput" :refer [ModalInput]] + ["/components/ModalInput/ModalInputPopover" :refer [ModalInputPopover]] + ["/components/ModalInput/ModalInputTrigger" :refer [ModalInputTrigger]] + ["@chakra-ui/react" :refer [AvatarGroup + Avatar + Box + Divider + Button + Badge + Flex + FormControl + FormLabel + HStack + Select + Text]] + [athens.common-db :as common-db] + [athens.common-events.graph.ops :as graph-ops] + [athens.common.utils :as common.utils] + [athens.dates :as dates] + [athens.parse-renderer :as parser] + [athens.reactive :as reactive] + [athens.router :as router] + [athens.types.core :as types] + [athens.types.dispatcher :as dispatcher] + [athens.types.tasks.handlers :as handlers] + [athens.types.tasks.shared :as shared] + [athens.types.tasks.view.generic-textarea :as generic-textarea] + [athens.types.tasks.view.inline-task-title :as inline-task-title] + [athens.views.blocks.editor :as editor] + [clojure.string :as str] + [goog.functions :as gfns] + [re-frame.core :as rf] + [reagent.core :as r] + [tick.core :as t])) + + +;; View + +(defn task-priority-view + [parent-block-uid priority-block-uid] + (let [priority-id (str (random-uuid)) + priority-block (reactive/get-reactive-block-document [:block/uid priority-block-uid]) + allowed-priorities (shared/find-allowed-priorities) + priority-string (:block/string priority-block "(())") + priority-uid (subs priority-string 2 (- (count priority-string) 2))] + [:> FormControl {:display "contents"} + [:> FormLabel {:html-for priority-id} + "Priority"] + [:> Box [:> Select {:id priority-id + :value priority-uid + :size "sm" + :placeholder "Select a priority" + :on-change (fn [e] + (let [new-priority (-> e .-target .-value) + priority-ref (str "((" new-priority "))")] + (rf/dispatch [:graph/update-in [:block/uid parent-block-uid] [":task/priority"] + (fn [db uid] [(graph-ops/build-block-save-op db uid priority-ref)])])))} + (doall + (for [{:block/keys [uid string]} allowed-priorities] + ^{:key uid} + [:option {:value uid} + string]))]]])) + + +(defn- task-status-view-v2 + [_task-uid _status-uid] + (let [status-options (->> (shared/find-allowed-statuses) + (map (fn [{:block/keys [string]}] + string))) + status (r/atom nil)] + (fn task-status-view-v2-render + [task-uid status-uid] + (let [status-block (reactive/get-reactive-block-document [:block/uid status-uid])] + (reset! status (:block/string status-block)) + ^{:key @status} + [:> Taskbox {:status @status + :options status-options + :position "relative" + :top "0.2em" + :onClick #(.stopPropagation %) + :onChange #(handlers/update-task-status task-uid %)}])))) + + +(defn task-el + [_this block-data _callbacks _is-ref?] + (let [block-uid (:block/uid block-data)] + (fn [_this _block-data callbacks] + (let [block (-> [:block/uid block-uid] reactive/get-reactive-block-document) + props (-> block :block/properties) + title-uid (-> props (get ":task/title") :block/uid) + assignee-uid (-> props (get ":task/assignee") :block/uid) + priority-uid (-> props (get ":task/priority") :block/uid) + _description-uid (-> props (get ":task/description") :block/uid) + _creator-uid (-> props (get ":task/creator") :block/uid) + due-date-uid (-> props (get ":task/due-date") :block/uid) + creator (-> (:block/create block) :event/auth :presence/id) + time (-> (:block/create block) :event/time :time/ts) + created-date (when time + (-> time + t/instant + t/date + (dates/get-day 0) + :title)) + status-uid (-> props + (get ":task/status") + :block/string + (common-db/strip-markup "((" "))")) + title (-> props (get ":task/title") :block/string) + assignee (-> props (get ":task/assignee") :block/string (common-db/strip-markup "[[" "]]")) + priority (-> [:block/uid (-> props + (get ":task/priority") + :block/string + (common-db/strip-markup "((" "))"))] + (reactive/get-reactive-block-document) + :block/string) + description (-> props (get ":task/description") :block/string) + due-date (-> props + (get ":task/due-date") + :block/string + (common-db/strip-markup "[[" "]]")) + show-assignee? true + show-description? false + show-priority? true + show-creator? false + show-created-date? false + _show-status? true + show-due-date? true] + [:> HStack {:spacing 1 + :gridArea "content" + :borderRadius "md" + :alignItems "baseline" + :transitionProperty "colors" + :transitionDuration "fast" + :transitionTimingFunction "ease-in-out" + :overflow "hidden" + :align "stretch"} + [task-status-view-v2 block-uid status-uid] + [:> Box {:flex "1 1 100%" + :lineHeight "base" + :cursor "text"} + [inline-task-title/inline-task-title + callbacks + block-uid + title-uid + ":task/title" + "Title" + true + false]] + [:> ModalInput {:placement "left-start" + :isLazy true} + [:> ModalInputTrigger + [:> Button {:size "xs" + :alignSelf "flex-start" + :flex "1 0 auto" + :variant "ghost" + :onClick #(.. % stopPropagation) + :lineHeight "unset" + :whiteSpace "unset" + :height "var(--control-height)" + :px 2} + + ;; description + (when (and show-description? description) + [:> Text {:fontSize "sm" :flexGrow 1 :flexBasis "100%" :m 0 :py 1 :lineHeight 1.4 :color "foreground.secondary"} + description]) + + ;; tasking/assignment + (when (and show-priority? priority) + [:> Badge {:size "sm" :variant "primary"} + priority]) + (when (or due-date assignee) + [:> Flex {:gap 1 :align "center"} + (when (and show-assignee? assignee) + [:> AvatarGroup + [:> Avatar {:size "xs" :name assignee}]]) + (when (and show-due-date? due-date) + [:> Text {:fontSize "xs"} due-date])]) + + ;; provenance + [:> Flex {:gap 1 :align "center"} + (when (and show-creator? creator) + [:> AvatarGroup + [:> Avatar {:size "xs" :name creator}]]) + (when (and show-created-date? created-date) + [:> Text {:fontSize "xs"} created-date])] + [:> PencilIcon {:color "foreground.secondary"}]]] + [:> ModalInputPopover {:popoverContentProps + {:display "grid" + :onClick #(.. % stopPropagation) + :gridTemplateColumns "max-content 1fr" + :gap 2 + :py 2 + :px 4 + :maxWidth "20em"}} + [:> HStack {:gridColumn "1 / -1" :align "flex-start"} + [:> Text {:fontSize "sm" + :noOfLines 2 + :color "foreground.secondary"} + title]] + [:> Divider {:gridColumn "1 / -1"}] + [task-priority-view block-uid priority-uid] + [generic-textarea/generic-textarea-view-for-task-props block-uid assignee-uid ":task/assignee" "Assignee" false false] + ;; Making assumption that for now we can add due date manually without date-picker. + [generic-textarea/generic-textarea-view-for-task-props block-uid due-date-uid ":task/due-date" "Due Date" false false] + [:> Divider {:gridColumn "1 / -1"}] + [:> Text {:color "foreground.secondary" :fontSize "sm"} "Created by"] + [:> Flex {:align "center"} [:> Avatar {:size "2xs" :marginInlineEnd 1 :name creator}] [:> Text {:fontSize "sm" :noOfLines 0} creator]] + [:> Text {:color "foreground.secondary" :fontSize "sm"} "Created"] + [:> Text {:fontSize "sm"} created-date]]]])))) + + +(defn task-ref-el + [ref-uid] + (let [{:block/keys [properties]} (reactive/get-reactive-block-document [:block/uid ref-uid]) + title (-> properties + (get ":task/title") + :block/string) + status-uid (-> properties + (get ":task/status") + :block/string + (common-db/strip-markup "((" "))"))] + [:> Flex {:display "inline-flex" + :align "baseline" + :bg "transparent" + :transitionProperty "colors" + :transitionDuration "fast" + :borderRadius "2px" + :transitionTimingFunction "ease-in-out" + :sx {"WebkitBoxDecorationBreak" "clone" + "&:has(.task-title:hover)" + {:textDecoration "none" + :borderBottomColor "transparent" + :bg "ref.background"}} + :alignSelf "baseline" + :gap 1} + [task-status-view-v2 ref-uid status-uid] + [:> Button {:variant "unstyled" + :className "task-title" + :fontWeight "normal" + :whiteSpace "normal" + :minWidth "0" + :display "inline" + :sx {"WebkitBoxDecorationBreak" "clone" + ".block" {"WebkitBoxDecorationBreak" "clone", + :borderBottomWidth "1px" + :borderBottomStyle "solid" + :borderBottomColor "ref.foreground"} + ":hover .block" {:borderBottomColor "transparent"}} + :textAlign "start" + :justifyContent "start" + :height "auto" + :borderRadius "none" + :lineHeight "1.4" + :cursor "alias" + :onClick (fn [e] + (.. e stopPropagation) + (let [shift? (.-shiftKey e)] + (rf/dispatch [:reporting/navigation {:source :pr-task-ref + :target :task + :pane (if shift? + :right-pane + :main-pane)}]) + (router/navigate-uid ref-uid e)))} + [parser/parse-and-render title]]])) + + +(defn zoomed-in-view-el + [_this block-data _callbacks] + (let [parent-block-uid (:block/uid block-data) + props (:block/properties (reactive/get-reactive-block-document [:block/uid parent-block-uid])) + title-uid (-> props (get ":task/title") :block/uid) + title-block (reactive/get-reactive-block-document [:block/uid title-uid]) + title-str (or (:block/string title-block) "") + local-value (r/atom title-str) + _invalid-prop-str? (and (str/blank? title-str) + (not (nil? title-str))) + save-fn (fn + ([] + (rf/dispatch [:graph/update-in [:block/uid parent-block-uid] [":task/title"] + (fn [db uid] [(graph-ops/build-block-save-op db uid @local-value)])])) + ([e] + (let [new-value (-> e .-target .-value)] + (reset! local-value new-value) + (rf/dispatch [:graph/update-in [:block/uid parent-block-uid] [":task/title"] + (fn [db uid] [(graph-ops/build-block-save-op db uid new-value)])]) + (rf/dispatch [:block/save {:uid parent-block-uid + :string new-value}])))) + update-fn #(reset! local-value %) + idle-fn (gfns/debounce #(do + (save-fn)) + 2000) + read-value local-value + show-edit? (r/atom true) + custom-key-handlers {:enter-handler (fn [_uid _d-key-down] + ;; TODO dispatch save and jump to next input + (println "TODO dispatch save and jump to next input") + (update-fn @local-value))} + state-hooks (merge {:save-fn save-fn + :idle-fn idle-fn + :update-fn update-fn + :read-value read-value + :show-edit? show-edit? + :default-verbatim-paste? true + :keyboard-navigation? true + :navigation-uid parent-block-uid + ;; TODO here we add styles + :style {}} + custom-key-handlers)] + [editor/block-editor {:block/uid (or title-uid + ;; NOTE: temporary magic, stripping `:task/` 🤷‍♂️ + (str "tmp-" (subs (or ":task/title" "") + (inc (.indexOf (or ":task/title" "") "/"))) + "-uid-" (common.utils/gen-block-uid)))} + state-hooks])) + + +(defrecord TaskView + [] + + types/BlockTypeProtocol + + (text-view + [_this block-data _attr] + (str "Task: " (:block/uid block-data))) + + + (inline-ref-view + [_this _block-data _attr ref-uid _uid _callbacks _with-breadcrumb?] + (task-ref-el ref-uid)) + + + (outline-view + [_this block-data callbacks] + [task-el _this block-data callbacks false]) + + + (supported-transclusion-scopes + [_this] + #{:embed}) + + + (transclusion-view + [this _block-el block-uid callbacks transclusion-scope] + (let [supported-trans (types/supported-transclusion-scopes this)] + (if-not (contains? supported-trans transclusion-scope) + (throw (ex-info (str "Invalid transclusion scope: " (pr-str transclusion-scope) + ". Supported transclusion types: " (pr-str supported-trans)) + {:supported-transclusion-scopes supported-trans + :provided-transclusion-scope transclusion-scope})) + (let [block (reactive/get-reactive-block-document [:block/uid block-uid])] + [task-el this block callbacks true])))) + + + (zoomed-in-view + [_this block-data _callbacks] + [zoomed-in-view-el _this block-data _callbacks]) + + + (supported-breadcrumb-styles + [_this] + #{:string}) + + + (breadcrumbs-view + [_this _block-data _callbacks _breadcrumb-style])) + + +(defmethod dispatcher/block-type->protocol "[[athens/task]]" [_k _args-map] + (TaskView.)) diff --git a/src/cljs/athens/types/tasks/view/generic_textarea.cljs b/src/cljs/athens/types/tasks/view/generic_textarea.cljs new file mode 100644 index 0000000000..ead94c3019 --- /dev/null +++ b/src/cljs/athens/types/tasks/view/generic_textarea.cljs @@ -0,0 +1,101 @@ +(ns athens.types.tasks.view.generic-textarea + (:require + ["/components/Block/BlockFormInput" :refer [BlockFormInput]] + ["@chakra-ui/react" :refer [Box + FormControl + FormErrorMessage + FormLabel]] + [athens.common-events.graph.ops :as graph-ops] + [athens.common.logging :as log] + [athens.common.utils :as common.utils] + [athens.reactive :as reactive] + [athens.self-hosted.presence.views :as presence] + [athens.views.blocks.editor :as editor] + [clojure.string :as str] + [goog.functions :as gfns] + [re-frame.core :as rf] + [reagent.core :as r])) + + +(defn generic-textarea-view-for-task-props + [_parent-block-uid _prop-block-uid _prop-name _prop-title _required? _multiline?] + (let [prop-id (str (random-uuid)) + local-value (r/atom "")] + (fn [parent-block-uid prop-block-uid prop-name prop-title required? multiline?] + (let [prop-block (reactive/get-reactive-block-document [:block/uid prop-block-uid]) + prop-str (or (:block/string prop-block) "") + invalid-prop-str? (and required? + (str/blank? prop-str) + (not (nil? prop-str))) + save-fn (fn generic-textarea-view-for-task-props-save-fn + ([] + (log/debug prop-name "save-fn" (pr-str @local-value)) + (when (#{":task/title" ":task/description" ":task/due-date"} prop-name) + (rf/dispatch [:graph/update-in [:block/uid parent-block-uid] [prop-name] + (fn [db uid] [(graph-ops/build-block-save-op db uid @local-value)])]))) + ([e] + (let [new-value (-> e .-target .-value)] + (log/debug prop-name "save-fn" (pr-str new-value)) + (reset! local-value new-value) + (when (#{":task/title" + ":task/assignee" + ":task/description" + ":task/due-date"} prop-name) + (rf/dispatch [:graph/update-in [:block/uid parent-block-uid] [prop-name] + (fn [db uid] [(graph-ops/build-block-save-op db uid new-value)])])) + (when (= ":task/title" prop-name) + (rf/dispatch [:block/save {:uid parent-block-uid + :string new-value}]))))) + update-fn #(do + (log/debug prop-name "update-fn:" (pr-str %)) + (reset! local-value %)) + idle-fn (gfns/debounce #(do + (log/debug prop-name "idle-fn" (pr-str @local-value)) + (save-fn)) + 2000) + read-value local-value + show-edit? (r/atom true) + custom-key-handlers {:enter-handler (if multiline? + editor/enter-handler-new-line + (fn [_uid _d-key-down] + ;; TODO dispatch save and jump to next input + (when (= ":task/assignee" + prop-name) + (rf/dispatch [:notification-for-assigned-task parent-block-uid @local-value])) + (update-fn @local-value))) + :tab-handler (fn [_uid _embed-id _d-key-down] + ;; TODO implement focus on next input + (update-fn @local-value))} + state-hooks (merge {:save-fn save-fn + :idle-fn idle-fn + :update-fn update-fn + :read-value read-value + :show-edit? show-edit? + :default-verbatim-paste? true + :keyboard-navigation? false} + custom-key-handlers)] + (reset! local-value prop-str) + [:> FormControl {:is-required required? + :display "contents" + :is-invalid invalid-prop-str?} + [:> FormLabel {:html-for prop-id} + prop-title] + [:> Box [:> BlockFormInput + {:isMultiline multiline? + :size "sm"} + ;; NOTE: we generate temporary uid for prop if it doesn't exist, so editor can work + [editor/block-editor {:block/uid (or prop-block-uid + ;; NOTE: temporary magic, stripping `:task/` 🤷‍♂️ + (str "tmp-" (subs prop-name + (inc (.indexOf prop-name "/"))) + "-uid-" (common.utils/gen-block-uid)))} + state-hooks] + [presence/inline-presence-el prop-block-uid]] + + (when invalid-prop-str? + [:> FormErrorMessage {:gridColumn 2} + (str prop-title " is " (if required? + "required" + "empty"))] + #_ [:> FormHelperText {:gridColumn 2} + (str "Please provide " prop-title)])]])))) diff --git a/src/cljs/athens/types/tasks/view/inline_task_title.cljs b/src/cljs/athens/types/tasks/view/inline_task_title.cljs new file mode 100644 index 0000000000..12ca5aad98 --- /dev/null +++ b/src/cljs/athens/types/tasks/view/inline_task_title.cljs @@ -0,0 +1,158 @@ +(ns athens.types.tasks.view.inline-task-title + (:require + [athens.common-events.graph.ops :as graph-ops] + [athens.common.logging :as log] + [athens.common.utils :as common.utils] + [athens.reactive :as reactive] + [athens.views.blocks.editor :as editor] + [clojure.string :as str] + [goog.functions :as gfns] + [re-frame.core :as rf] + [reagent.core :as r])) + + +(defn inline-task-title + [_state-hooks _parent-block-uid _prop-block-uid _prop-name _prop-title _required? _multiline?] + (let [_prop-id (str (random-uuid)) + local-value (r/atom "")] + (fn [state-hooks parent-block-uid prop-block-uid prop-name _prop-title _required? _multiline?] + (let [prop-block (reactive/get-reactive-block-document [:block/uid prop-block-uid]) + prop-str (:block/string prop-block "") + _invalid-prop-str? (and (str/blank? prop-str) + (not (nil? prop-str))) + save-fn (fn + ([] + (log/debug prop-name "save-fn" (pr-str @local-value)) + (when (#{":task/title" ":task/description" ":task/due-date"} prop-name) + (rf/dispatch [:graph/update-in [:block/uid parent-block-uid] [prop-name] + (fn [db uid] [(graph-ops/build-block-save-op db uid @local-value)])]))) + ([e] + (let [new-value (-> e .-target .-value)] + (log/debug prop-name "save-fn" (pr-str new-value)) + (reset! local-value new-value) + (when (#{":task/title" + ":task/assignee" + ":task/description" + ":task/due-date"} prop-name) + (rf/dispatch [:graph/update-in [:block/uid parent-block-uid] [prop-name] + (fn [db uid] [(graph-ops/build-block-save-op db uid new-value)])])) + (when (= ":task/title" prop-name) + (rf/dispatch [:block/save {:uid parent-block-uid + :string new-value}]))))) + update-fn #(do + (log/debug prop-name "update-fn:" (pr-str %)) + (reset! local-value %)) + idle-fn (gfns/debounce #(do + (log/debug prop-name "idle-fn" (pr-str @local-value)) + (save-fn)) + 2000) + show-edit? (r/atom false) + state-hooks (merge {:save-fn save-fn + :idle-fn idle-fn + :update-fn update-fn + :read-value local-value + :show-edit? show-edit? + :default-verbatim-paste? true + :keyboard-navigation? true + :navigation-uid parent-block-uid} + state-hooks)] + (reset! local-value prop-str) + [editor/block-editor {:block/uid (or prop-block-uid + ;; NOTE: temporary magic, stripping `:task/` 🤷‍♂️ + (str "tmp-" (subs (or prop-name "") + (inc (.indexOf (or prop-name "") "/"))) + "-uid-" (common.utils/gen-block-uid)))} + state-hooks] + #_ [:> FormControl {:is-required required? + :is-invalid invalid-prop-str?} + [:> FormLabel {:html-for prop-id} + prop-title] + [:> BlockFormInput + ;; NOTE: we generate temporary uid for prop if it doesn't exist, so editor can work + + [presence/inline-presence-el prop-block-uid]] + + (if invalid-prop-str? + [:> FormErrorMessage + (str prop-title " is " (if required? + "required" + "empty"))] + [:> FormHelperText + (str "Please provide " prop-title)])])))) + + +;; Will need this in future, see Stuart's comment for better understanding here https://discord.com/channels/708122962422792194/1008156785791742002/1009102458695450654 +#_(defn inline-task-title + [_parent-block-uid _prop-block-uid _prop-name _prop-title _required? _multiline?] + (let [prop-id (str (random-uuid))] + (fn [parent-block-uid prop-block-uid prop-name prop-title required? multiline?] + (let [prop-block (reactive/get-reactive-block-document [:block/uid prop-block-uid]) + prop-str (or (:block/string prop-block) "") + local-value (r/atom prop-str) + invalid-prop-str? (and (str/blank? prop-str) + (not (nil? prop-str))) + save-fn (fn + ([] + (log/debug prop-name "save-fn" (pr-str @local-value)) + (when (#{":task/title" ":task/description" ":task/due-date"} prop-name) + (rf/dispatch [:graph/update-in [:block/uid parent-block-uid] [prop-name] + (fn [db uid] [(graph-ops/build-block-save-op db uid @local-value)])]))) + ([e] + (let [new-value (-> e .-target .-value)] + (log/debug prop-name "save-fn" (pr-str new-value)) + (reset! local-value new-value) + (when (#{":task/title" + ":task/assignee" + ":task/description" + ":task/due-date"} prop-name) + (rf/dispatch [:graph/update-in [:block/uid parent-block-uid] [prop-name] + (fn [db uid] [(graph-ops/build-block-save-op db uid new-value)])]))))) + update-fn #(do + (when-not (= prop-str %) + (log/debug prop-name "update-fn:" (pr-str %)) + (reset! local-value %))) + idle-fn (gfns/debounce #(do + (log/debug prop-name "idle-fn" (pr-str @local-value)) + (save-fn)) + 2000) + read-value local-value + show-edit? (r/atom false) + custom-key-handlers {:enter-handler (if multiline? + editor/enter-handler-new-line + (fn [_uid _d-key-down] + ;; TODO dispatch save and jump to next input + (println "TODO dispatch save and jump to next input") + (update-fn @local-value))) + :tab-handler (fn [_uid _embed-id _d-key-down] + ;; TODO implement focus on next input + (update-fn @local-value))} + state-hooks (merge {:save-fn save-fn + :idle-fn idle-fn + :update-fn update-fn + :read-value read-value + :show-edit? show-edit? + :default-verbatim-paste? true + :keyboard-navigation? false} + custom-key-handlers)] + [:> FormControl {:is-required required? + :is-invalid invalid-prop-str?} + [:> FormLabel {:html-for prop-id} + prop-title] + [:> BlockFormInput + ;; NOTE: we generate temporary uid for prop if it doesn't exist, so editor can work + [editor/block-editor {:block/uid (or prop-block-uid + ;; NOTE: temporary magic, stripping `:task/` 🤷‍♂️ + (str "tmp-" (subs prop-name + (inc (.indexOf prop-name "/"))) + "-uid-" (common.utils/gen-block-uid)))} + state-hooks] + [presence/inline-presence-el prop-block-uid]] + + (if invalid-prop-str? + [:> FormErrorMessage + (str prop-title " is " (if required? + "required" + "empty"))] + [:> FormHelperText + (str "Please provide " prop-title)])])))) + diff --git a/src/cljs/athens/undo.cljs b/src/cljs/athens/undo.cljs new file mode 100644 index 0000000000..be550881d4 --- /dev/null +++ b/src/cljs/athens/undo.cljs @@ -0,0 +1,77 @@ +(ns athens.undo + (:refer-clojure :exclude [remove]) + (:require + [flatland.ordered.map :refer [ordered-map]])) + + +(defn- sliding-assoc + "Assoc k v into om, keeping it at max limit elements. + Older elements are dropped. + om should be an ordered map of size <= limit." + [om limit k v] + (let [om' (cond + (contains? om k) om + (>= (count om) limit) (dissoc om (ffirst om)) + :else om)] + (assoc om' k v))) + + +(def UNDO_LIMIT 20) + + +(defn reset + [db] + (assoc db + ::undo (ordered-map) + ::redo (ordered-map))) + + +(defn reset-redo + [db] + (assoc db ::redo (ordered-map))) + + +(defn count-undo + [db] + (count (::undo db))) + + +(defn push-undo + [db k v] + (update db ::undo sliding-assoc UNDO_LIMIT k v)) + + +(defn pop-undo + [db] + (when-some [[k v] (last (::undo db))] + [v (update db ::undo dissoc k)])) + + +(defn count-redo + [db] + (count (::redo db))) + + +(defn push-redo + [db k v] + (update db ::redo sliding-assoc UNDO_LIMIT k v)) + + +(defn pop-redo + [db] + (when-some [[k v] (last (::redo db))] + [v (update db ::redo dissoc k)])) + + +(defn remove + [db k] + (-> db + (update ::undo dissoc k) + (update ::redo dissoc k))) + + +;; TODO: consider if we need to update the undo/redo stack dbs on rollback/rollforward. +;; If we don't update, then the undo/redo will resolve over the original db the event was applied over. +;; If we update, then the undo/redo will resolve over the latest db the event was applied over. +#_(defn update-val + [_db _k _v]) diff --git a/src/cljs/athens/util.cljs b/src/cljs/athens/util.cljs new file mode 100644 index 0000000000..2f91951fdc --- /dev/null +++ b/src/cljs/athens/util.cljs @@ -0,0 +1,326 @@ +(ns athens.util + (:require + ["/theme/theme" :refer [theme]] + ["@chakra-ui/react" :refer [createStandaloneToast]] + ["textarea-caret" :as getCaretCoordinates] + [athens.config :as config] + [athens.electron.utils :as electron.utils] + [clojure.string :as string] + [cognitect.transit :as tr] + [com.rpl.specter :as s] + [goog.dom :refer [getElement setProperties]]) + (:require-macros + [com.rpl.specter :refer [recursive-path]]) + (:import + (goog.events + KeyCodes))) + + +(def toast (createStandaloneToast (clj->js {:theme theme}))) + + +;; embed block + +(declare specter-recursive-path) + + +(defn embed-uid->original-uid + "Return the original-uid for uid if it is embed, otherwise returns uid. + Embeds have a modified and local uid to make them unique for selection and focus. + But for presence, the original uid is used instead because the embed uid + is not present in other clients." + [uid] + (-> uid (string/split #"-embed-") first)) + + +(defn recursively-modify-block-for-embed + "Modify the block and all the block children to have same embed-id for + referencing the embed block rather than block in original page" + [block embed-id] + (s/transform + (specter-recursive-path #(contains? % :block/uid)) + (fn [{:block/keys [uid] :as block}] + (assoc block :block/uid (str uid "-embed-" embed-id) + :block/original-uid uid)) + block)) + + +;; -- DOM ---------------------------------------------------------------- + +;; TODO: move all these DOM utilities to a .cljs file instead of cljc +(defn scroll-top! + [element pos] + (when pos + (set! (.. element -scrollTop) pos))) + + +(defn scroll-if-needed + ;; https://stackoverflow.com/a/45851497 + [element container] + (when (and element container) + (let [e-top (.. element -offsetTop) + e-height (.. element -offsetHeight) + e-bottom (+ e-top e-height) + cs-top (.. container -scrollTop) + c-height (.. container -offsetHeight) + cs-bottom (+ cs-top c-height)] + (->> (cond + (< e-top cs-top) e-top + (< cs-bottom e-bottom) (- e-bottom c-height)) + (scroll-top! container))))) + + +(defn mouse-offset + "Finds offset between mouse event and container. If container is not passed, use target as container." + ([e] + (mouse-offset e (.. e -target))) + ([e container] + (let [rect (.. container getBoundingClientRect) + offset-x (- (.. e -pageX) (.. rect -left)) + offset-y (- (.. e -pageY) (.. rect -top))] + {:x offset-x :y offset-y}))) + + +(defn vertical-center + [el] + (let [rect (.. el getBoundingClientRect)] + (-> (- (.. rect -bottom) + (.. rect -top)) + (/ 2)))) + + +(defn is-beyond-rect? + "Checks if any part of the element is above or below the container's bounding rect" + [element container] + (when (and element container) + (let [el-box (.. element getBoundingClientRect) + cont-box (.. container getBoundingClientRect)] + (or + (> (.. el-box -bottom) (.. cont-box -bottom)) + (< (.. el-box -top) (.. cont-box -top)))))) + + +(defn scroll-into-view + [element container align-top?] + (when (is-beyond-rect? element container) + (.. element (scrollIntoView align-top? {:behavior "auto"})))) + + +(defn get-dataset-uid + [el] + (let [block (when el (.. el (closest ".block-container"))) + uid (when block (.getAttribute block "data-uid"))] + uid)) + + +(defn get-dataset-children-uids + [el] + (let [block (when el (.. el (closest ".block-container"))) + children-uids (when block + (let [dom-children-uids ^String (.getAttribute block "data-childrenuids")] + (when-not (string/blank? dom-children-uids) + (-> dom-children-uids + (string/split #",") + set))))] + children-uids)) + + +(defn get-caret-position + [target] + (let [selectionEnd (.. target -selectionEnd)] + (js->clj (getCaretCoordinates target selectionEnd) :keywordize-keys true))) + + +(defn dom-parents + "This and common-ancestor taken from https://stackoverflow.com/a/5350888." + [node] + (loop [nodes [node] + node node] + (if (nil? node) + (reverse nodes) + (recur (conj nodes node) (.-parentNode node))))) + + +(defn common-ancestor + [node1 node2] + (let [p1 (dom-parents node1) + p2 (dom-parents node2)] + (if (not= (first p1) (first p2)) + (throw (js/Error. "No common ancestor!")) + (let [n (dec (count p1))] + (loop [i 0] + (cond + (not= (nth p1 i nil) (nth p2 i nil)) + (nth p1 (dec i)) + + (= i n) + (js/Error. "No common ancestor after n loops!") + + :else + (recur (inc i)))))))) + + +(defn destruct-key-down + [e] + (let [key (.. e -keyCode) + ctrl (.. e -ctrlKey) + meta (.. e -metaKey) + shift (.. e -shiftKey) + alt (.. e -altKey)] + {:key-code key + :ctrl ctrl + :meta meta + :shift shift + :alt alt})) + + +(defn js-event->val + [event] + (.. event -target -value)) + + +;; -- specter -------------------------------------------------------- + + +(defn specter-recursive-path + "Navigates across maps and lists to find the sub that + satisfies the function" + [afn] + (recursive-path [] p + (s/cond-path + map? (s/multi-path [s/MAP-VALS p] afn) + sequential? [s/ALL p]))) + + +;; OS + +(defn get-os + [] + (let [os (.. js/window -navigator -appVersion)] + (cond + (re-find #"Windows" os) :windows + (re-find #"Mac" os) :mac + :else :linux))) + + +(defn is-mac? + [] + (= (get-os) :mac)) + + +(defn app-classes + ([{:keys [os electron? theme-dark? win-focused? win-fullscreen? win-maximized?]}] + [(case os + :windows "os-windows" + :mac "os-mac" + :linux "os-linux" + "os-linux") + (if electron? "is-electron" "is-web") + (if theme-dark? "is-theme-dark" "is-theme-light") + (when win-focused? "is-focused") + (when win-fullscreen? "is-fullscreen") + (when win-maximized? "is-maximized")])) + + +(defn add-body-classes + [classes] + (let [cl js/document.body.classList] + (doseq [class (remove nil? classes)] + (.add cl class)))) + + +(defn switch-body-classes + [[from to]] + (let [cl js/document.body.classList] + (.add cl to) + (.remove cl from))) + + +(defn shortcut-key? + "Use meta for mac, ctrl for others." + [meta ctrl] + (let [os (get-os)] + (or (and (= os :mac) meta) + (and (= os :windows) ctrl) + (and (= os :linux) ctrl)))) + + +(defn navigate-key? + "Used to navigate between current and last page + Use meta for mac, alt for others." + [{:keys [key-code + meta + alt]}] + (let [os (get-os)] + (and (#{KeyCodes.LEFT KeyCodes.RIGHT} key-code) + (or (and (= os :mac) meta) + (and (= os :windows) alt) + (and (= os :linux) alt))))) + + +;; re-frame-10x + +(defn re-frame-10x-open? + [] + (when config/debug? + (let [el-10x (getElement "--re-frame-10x--") + display-10x (and el-10x (.. el-10x -style -display))] + (not (= "none" display-10x))))) + + +(defn open-10x + [] + (when config/debug? + (when-let [el (js/document.querySelector "#--re-frame-10x--")] + (setProperties el (clj->js {"style" "display: block"}))))) + + +(defn hide-10x + [] + (when config/debug? + (when-let [el (js/document.querySelector "#--re-frame-10x--")] + (setProperties el (clj->js {"style" "display: none"}))))) + + +(defn toggle-10x + [] + (when config/debug? + (let [open? (re-frame-10x-open?)] + (if open? + (hide-10x) + (open-10x))))) + + +;; (goog-define COMMIT_URL "") + + +(defn athens-version + [] + (cond + electron.utils/electron? (electron.utils/version))) + + +;; (not (string/blank? COMMIT_URL)) COMMIT_URL +;; :else "Web")) + + +;; Local Storage +;; Inspired by intermine/bluegenes: +;; https://github.com/intermine/bluegenes/blob/4589ef8b09b26dcf23d434d4d7d9d56fd01a259f/src/cljs/bluegenes/effects.cljs#L14-L30 + +(defn local-storage-set! + "Set v to local storage under k, replacing the value that was there before. + k is coerced to string, v is written as json-verbose transit." + [k v] + (if (some? v) + (.setItem js/localStorage (str k) (tr/write (tr/writer :json-verbose) v)) + (.removeItem js/localStorage (str k)))) + + +(defn local-storage-get + "Get value from local storage under k. + k is coerced to string, v is read as json-verbose transit." + [k] + (tr/read (tr/reader :json-verbose) (.getItem js/localStorage (str k)))) + + diff --git a/src/cljs/athens/utils/markdown.cljs b/src/cljs/athens/utils/markdown.cljs new file mode 100644 index 0000000000..406abba712 --- /dev/null +++ b/src/cljs/athens/utils/markdown.cljs @@ -0,0 +1,16 @@ +(ns athens.utils.markdown + (:require + ["turndown" :as turndown])) + + +(set! (.-escape (.-prototype turndown)) (fn [string] string)) + + +(defonce turndown-instance + (new turndown)) + + +(defn html->md + "Transforms text to markdown." + [text] + (.turndown turndown-instance text)) diff --git a/src/cljs/athens/utils/sentry.cljs b/src/cljs/athens/utils/sentry.cljs new file mode 100644 index 0000000000..fb3ab59e29 --- /dev/null +++ b/src/cljs/athens/utils/sentry.cljs @@ -0,0 +1,103 @@ +(ns athens.utils.sentry + "Sentry integration utilities." + (:require + ["@sentry/react" :as Sentry] + [athens.common.logging :as log])) + + +(def tx-active (atom nil)) + + +(defn transaction-start + "Starts new Sentry Transaction" + [tx-name] + (let [tx (.startTransaction Sentry (clj->js {:name tx-name}))] + (log/debug "Sentry: Starting TX:" tx-name) + (reset! tx-active tx) + tx)) + + +(defn tx-running? + "Checks if there is TX running" + [] + (not (nil? @tx-active))) + + +(defn transaction-get-current + "Tries to find existing Sentry Transaction" + [] + (let [tx @tx-active] + (if tx + tx + (try + (throw (js/Error. "transaction-get-current called but no TX running")) + (catch js/Error ex + (log/warn ex)))))) + + +(defn transaction-get-current-name + "Tries to find existing Sentry Transaction name" + [] + (aget @tx-active "name")) + + +(defn transaction-finish + "Finishes provided transaction" + ([] + (when-let [tx @tx-active] + (transaction-finish tx))) + ([tx] + (when tx + (log/debug "Sentry: Finishing TX:" (aget tx "name")) + (.finish tx) + (reset! tx-active nil)))) + + +(def span-stack (atom [])) + + +(defn span-active + "Provides active Sentry Span if any exists." + [] + (peek @span-stack)) + + +(defn span-start + "Starts a *span* named `op-name` within given `transaction`, + with optional `op-description`. + + Arguments: + * `op-name`: operation name + * `transaction`: Sentry transaction like object + * `stack?`: (optional - defaults to true) if newly created span should be put on stack" + ([op-name] + (span-start (transaction-get-current) op-name)) + ([transaction op-name] + (span-start transaction op-name true)) + ([transaction op-name stack?] + (when transaction + (let [span (.startChild transaction (clj->js {:op op-name}))] + (when stack? + (swap! span-stack conj span)) + span)))) + + +(defn span-finish + "Finish provided `span`." + ([] + (if-let [active (span-active)] + (span-finish active) + (try + (throw (js/Error. "Can't finish Sentry Span, there is no active span.")) + (catch js/Error ex + (log/warn ex))))) + ([span] + (span-finish span true)) + ([span stack?] + {:pre [(not (nil? span))]} + (let [active-span (span-active)] + (when (and stack? + active-span + (= active-span span)) + (swap! span-stack pop)) + (.finish span)))) diff --git a/src/cljs/athens/views.cljs b/src/cljs/athens/views.cljs index 73ca854f19..31b86329e9 100644 --- a/src/cljs/athens/views.cljs +++ b/src/cljs/athens/views.cljs @@ -1,93 +1,89 @@ (ns athens.views (:require - [athens.subs] - [athens.page :as page] - [re-frame.core :as rf :refer [subscribe dispatch]] - [reitit.frontend :as rfe] - [reitit.frontend.easy :as rfee] - )) + ["/components/App/ContextMenuContext" :refer [ContextMenuProvider]] + ["/components/Layout/MainContent" :refer [MainContent]] + ["/components/Layout/useLayoutState" :refer [LayoutProvider]] + ["/theme/theme" :refer [theme]] + ["@chakra-ui/react" :refer [ChakraProvider Flex VStack HStack Spinner Center]] + [athens.config] + [athens.electron.db-modal :as db-modal] + [athens.style :refer [zoom]] + [athens.subs] + [athens.views.app-toolbar :as app-toolbar] + [athens.views.athena :refer [athena-component]] + [athens.views.help :refer [help-popup]] + [athens.views.left-sidebar.core :as left-sidebar] + [athens.views.pages.core :as pages] + [athens.views.pages.settings :as settings] + [athens.views.right-sidebar.core :as right-sidebar] + [re-frame.core :as rf])) -(defn about-panel [] - [:div [:h1 "About Panel"]]) -(defn file-cb [e] - (let [fr (js/FileReader.) - file (.. e -target -files (item 0))] - (set! (.-onload fr) #(dispatch [:parse-datoms (.. % -target -result)])) - (.readAsText fr file))) - -(defn table - [nodes] - [:table {:style {:width "60%" :margin-top 20}} - [:thead - [:tr - [:th {:style {:text-align "left"}} "Page"] - [:th {:style {:text-align "left"}} "Last Edit"] - [:th {:style {:text-align "left"}} "Created At"]]] - [:tbody - (for [{id :db/id - bid :block/uid - title :node/title - c-time :create/time - e-time :edit/time} nodes] - ^{:key id} - [:tr - [:td {:style {:height 24}} [:a {:href (rfee/href :page {:id bid})} title]] - [:td (.toLocaleString (js/Date. c-time))] - [:td (.toLocaleString (js/Date. e-time))] - ])]]) - -(defn pages-panel [] - (let [nodes (subscribe [:pull-nodes])] - (fn [] - [:div - [:p "Upload your DB " [:a {:href ""} "(tutorial)"]] - [:input {:type "file" - :name "file-input" - :on-change (fn [e] (file-cb e))}] - [table @nodes]]))) - -(defn home-panel [] - (fn [] - [:div - [:h1 "Home Panel"]])) - -(defn left-sidebar - [] - (fn [] - (let [favorites (subscribe [:favorites]) - current-route (subscribe [:current-route])] - [:div {:style {:margin "0 10px" :max-width 250}} - [:div [:a {:href (rfee/href :pages)} "All /pages"]] - [:div [:span {:style {}} "Current Route: " [:b (-> @current-route :path)]]] - [:div {:style {:border-bottom "1px solid gray" :margin "10px 0"}}] - [:ol {:style {:padding 0 :margin 0 :list-style-type "none"}} - (for [[_order title bid] @favorites] - ^{:key bid} [:li [:a {:href (rfee/href :page {:id bid})} title]])] - ]))) +;; Components (defn alert - "When `:errors` subscription is updated, global alert will be called with its contents and then cleared." [] - (let [errors (subscribe [:errors])] - (when (not (empty? @errors)) - (js/alert (str @errors)) - (dispatch [:clear-errors])))) + (let [alert- (rf/subscribe [:alert])] + (when-not (nil? @alert-) + (js/alert (str @alert-)) + (rf/dispatch [:alert/unset])))) -(defn match-panel [name] - [(case name - :about about-panel - :pages pages-panel - :page page/main - pages-panel)]) -(defn main-panel [] - (let [current-route (subscribe [:current-route]) - loading (subscribe [:loading])] +(defn main + [] + (let [loading (rf/subscribe [:loading?]) + modal (rf/subscribe [:modal]) + right-sidebar-open? (rf/subscribe [:right-sidebar/open]) + right-sidebar-width (rf/subscribe [:right-sidebar/width]) + settings-open? (rf/subscribe [:settings/open?])] (fn [] - [alert] - (if @loading - [:h4 "Loading... (at least it'll be faster than Roam)"] - [:div {:style {:display "flex"}} - [left-sidebar] - [match-panel (-> @current-route :data :name)]])))) + [:div (merge {:style {:display "contents"}} + (zoom)) + [:> ChakraProvider {:theme theme, + :bg "background.basement"} + [:> ContextMenuProvider + [:> LayoutProvider {:rightSidebarWidth @right-sidebar-width} + [help-popup] + [alert] + [athena-component] + (cond + (and @loading @modal) [db-modal/window] + + @loading + [:> Center {:height "var(--app-height)"} + [:> Flex {:width 28 + :flexDirection "column" + :gap 2 + :color "foreground.secondary" + :borderRadius "lg" + :placeItems "center" + :placeContent "center" + :height 28} + [:> Spinner {:size "xl"}]]] + + :else [:<> + (when @modal + [db-modal/window]) + (when @settings-open? + [settings/page]) + [:> VStack {:overscrollBehavior "contain" + :id "main-layout" + :spacing 0 + :overflowY "auto" + :height "var(--app-height)" + :bg "background.floor" + :transitionDuration "fast" + :transitionProperty "background" + :transitionTimingFunction "ease-in-out" + :align "stretch" + :position "relative"} + [app-toolbar/app-toolbar] + [:> HStack {:overscrollBehavior "contain" + :align "stretch" + :spacing 0 + :flex 1} + [left-sidebar/left-sidebar] + [:> MainContent {:rightSidebarWidth @right-sidebar-width + :isRightSidebarOpen @right-sidebar-open?} + [pages/view]] + [right-sidebar/right-sidebar]]]])]]]]))) diff --git a/src/cljs/athens/views/app_toolbar.cljs b/src/cljs/athens/views/app_toolbar.cljs new file mode 100644 index 0000000000..881a865c14 --- /dev/null +++ b/src/cljs/athens/views/app_toolbar.cljs @@ -0,0 +1,124 @@ +(ns athens.views.app-toolbar + (:require + ["/components/AppToolbar/AppToolbar" :refer [AppToolbar]] + [athens.common-db :as common-db] + [athens.db :as db] + [athens.electron.db-menu.core :refer [db-menu]] + [athens.electron.utils :as electron.utils] + [athens.router :as router] + [athens.self-hosted.presence.views :refer [toolbar-presence-el]] + [athens.style :refer [unzoom]] + [athens.subs] + [athens.util :as util] + [athens.views.comments.core :as comments] + [athens.views.notifications.core :as notifications] + [athens.views.notifications.popover :refer [notifications-popover]] + [re-frame.core :as rf] + [reagent.core :as r])) + + +(def name-from-route + {:home "Daily Notes" + :graph "Graph"}) + + +(defn app-toolbar + [] + (let [current-page-title (rf/subscribe [:current-route/page-title]) + left-open? (rf/subscribe [:left-sidebar/open]) + right-open? (rf/subscribe [:right-sidebar/open]) + help-open? (rf/subscribe [:help/open?]) + athena-open? (rf/subscribe [:athena/open]) + show-comments? (rf/subscribe [:comment/show-comments?]) + route-name (rf/subscribe [:current-route/name]) + route-uid (rf/subscribe [:current-route/uid]) + theme-dark (rf/subscribe [:theme/dark]) + selected-db (rf/subscribe [:db-picker/selected-db]) + notificationsPopoverOpen? (rf/subscribe [:notification/show-popover?]) + electron? electron.utils/electron? + win-focused? (if electron? + (rf/subscribe [:win-focused?]) + (r/atom false)) + win-maximized? (if electron? + (rf/subscribe [:win-maximized?]) + (r/atom false)) + win-fullscreen? (if electron? + (rf/subscribe [:win-fullscreen?]) + (r/atom false)) + os (util/get-os) + on-left-sidebar-toggle #(rf/dispatch [:left-sidebar/toggle]) + on-back (fn [_] + (rf/dispatch [:reporting/navigation {:source :app-toolbar + :target :back + :pane :main-pane}]) + (.back js/window.history)) + on-forward (fn [_] + (rf/dispatch [:reporting/navigation {:source :app-toolbar + :target :forward + :pane :main-pane}]) + (.forward js/window.history)) + on-daily-pages (fn [_] + (rf/dispatch [:reporting/navigation {:source :app-toolbar + :target :home + :pane :main-pane}]) + (router/nav-daily-notes)) + on-all-pages (fn [_] + (rf/dispatch [:reporting/navigation {:source :app-toolbar + :target :all-pages + :pane :main-pane}]) + (router/navigate :pages)) + on-graph (fn [_] + (rf/dispatch [:reporting/navigation {:source :app-toolbar + :target :graph + :pane :main-pane}]) + (router/navigate :graph)) + on-settings (fn [_] + (rf/dispatch [:settings/toggle-open])) + on-athena #(rf/dispatch [:athena/toggle]) + on-help #(rf/dispatch [:help/toggle]) + on-theme #(rf/dispatch [:theme/toggle]) + on-right-sidebar #(rf/dispatch [:right-sidebar/toggle]) + on-maximize #(rf/dispatch [:toggle-max-min-win]) + on-minimize #(rf/dispatch [:minimize-win]) + on-close #(rf/dispatch [:close-win])] + + [:> AppToolbar + (merge + {:style (unzoom) + :os os + :isElectron electron? + :currentLocationName (or @current-page-title + (common-db/get-block-string @db/dsdb @route-uid) + (name-from-route @route-name)) + :isWinFullscreen @win-fullscreen? + :isWinMaximized @win-maximized? + :isWinFocused @win-focused? + :isHelpOpen @help-open? + :isThemeDark @theme-dark + :isLeftSidebarOpen @left-open? + :isRightSidebarOpen @right-open? + :isCommandBarOpen @athena-open? + :onPressLeftSidebarToggle on-left-sidebar-toggle + :onPressHistoryBack on-back + :onPressHistoryForward on-forward + :onPressDailyNotes on-daily-pages + :onPressAllPages on-all-pages + :onPressGraph on-graph + :onPressCommandBar on-athena + :onPressHelp on-help + :onPressThemeToggle on-theme + :onPressSettings on-settings + :onPressRightSidebarToggle on-right-sidebar + :onPressMaximizeRestore on-maximize + :onPressMinimize on-minimize + :currentPageTitle (or @current-page-title nil) + :onPressClose on-close + :workspacesMenu (r/as-element [db-menu]) + :presenceDetails (when (electron.utils/remote-db? @selected-db) + (r/as-element [toolbar-presence-el]))} + (when (notifications/enabled?) + {:notificationPopover (r/as-element [:f> notifications-popover]) + :isNotificationsPopoverOpen @notificationsPopoverOpen?}) + (when (comments/enabled?) + {:isShowComments @show-comments? + :onClickComments #(rf/dispatch [:comment/toggle-comments])}))])) diff --git a/src/cljs/athens/views/athena.cljs b/src/cljs/athens/views/athena.cljs new file mode 100644 index 0000000000..246c9591b8 --- /dev/null +++ b/src/cljs/athens/views/athena.cljs @@ -0,0 +1,354 @@ +(ns athens.views.athena + (:require + ["/components/Icons/Icons" :refer [PageAddIcon XmarkIcon ArrowRightIcon]] + ["@chakra-ui/react" :refer [Modal ModalContent ModalOverlay VStack Button IconButton Input HStack Heading Text]] + [athens.common.utils :as utils] + [athens.db :as db :refer [search-in-block-content search-exact-node-title search-in-node-title]] + [athens.patterns :as patterns] + [athens.router :as router] + [athens.subs] + [athens.util :refer [scroll-into-view]] + [clojure.string :as str] + [goog.dom :refer [getElement]] + [goog.events :as events] + [re-frame.core :as rf :refer [subscribe dispatch]] + [reagent.core :as r]) + (:import + (goog.events + KeyCodes))) + + +;; Utilities + + +(defn highlight-match + [query txt] + (if-not query + txt + (map-indexed (fn [i part] + (if (= part query) + [:> Text {:as "span" + :background "interaction.surface.hover" + :color "foreground.primary" + :borderRadius "sm" + :py 0 + :px 0.25 + :key i} part] + part)) + (patterns/split-on txt query)))) + + +(defn create-search-handler + [state] + (fn [query] + (if (str/blank? query) + (reset! state {:index 0 + :query nil + :results []}) + (reset! state {:index 0 + :query query + :results (vec + (concat + [(search-exact-node-title query)] + (search-in-node-title query 20 true) + (search-in-block-content query)))})))) + + +(defn key-down-handler + [e state] + (let [key (.. e -keyCode) + shift? (.. e -shiftKey) + {:keys [index query results]} @state + item (get results index) + navigate-uid (or (:block-search/navigate-uid item) + (:block/uid item))] + (cond + (= KeyCodes.ENTER key) (cond + ;; if page doesn't exist, create and open + (and (zero? index) (nil? item)) + (let [block-uid (utils/gen-block-uid)] + (dispatch [:athena/toggle]) + (js/console.debug "athena key down" (pr-str {:block-uid block-uid + :title query})) + (dispatch [:page/new {:title query + :block-uid block-uid + :shift? shift? + :source :athena}]) + (dispatch [:reporting/navigation {:source :athena + :target (str "page/" query) + :pane (if shift? + :right-pane + :main-pane)}])) + ;; if shift: open in right-sidebar + shift? + (do (dispatch [:athena/toggle]) + (let [title (:node/title item)] + (dispatch [:right-sidebar/open-item (if title + [:node/title title] + [:block/uid navigate-uid])])) + (dispatch [:reporting/navigation {:source :athena + :target :page + :pane :right-pane}])) + ;; else open in main view + :else + (let [title (:node/title item)] + (dispatch [:athena/toggle]) + (dispatch [:reporting/navigation {:source :athena + :target (if title + :page + :block) + :pane :main-pane}]) + (if title + (router/navigate-page title) + (router/navigate-uid navigate-uid)) + (dispatch [:editing/uid navigate-uid]))) + + (= key KeyCodes.UP) + (do + (.. e preventDefault) + (swap! state update :index #(dec (if (zero? %) (count results) %))) + (let [cur-index (:index @state) + ;; Search input box + input-el (.. e -target) + ;; Get the result list container which is the last element child + ;; of the whole athena component + result-el (.. input-el (closest "section.athena-modal") -lastElementChild) + ;; Get next element in the result list + next-el (nth (array-seq (.. result-el -children)) cur-index)] + ;; Check if next el is beyond the bounds of the result list and scroll if so + (scroll-into-view next-el result-el (not= cur-index (dec (count results)))))) + + (= key KeyCodes.DOWN) + (do + (.. e preventDefault) + (swap! state update :index #(if (= % (dec (count results))) 0 (inc %))) + (let [cur-index (:index @state) + input-el (.. e -target) + result-el (.. input-el (closest "section.athena-modal") -lastElementChild) + next-el (nth (array-seq (.. result-el -children)) cur-index)] + (scroll-into-view next-el result-el (zero? cur-index)))) + + :else nil))) + + +;; Components + + +(defn result-el + [{:keys [title preview prefix icon query on-click active?]}] + [:> Button {:justifyContent "flex-start" + :fontWeight "normal" + :display "flex" + :height "auto" + :textAlign "start" + :flexDirection "row" + :rightIcon icon + :bg "transparent" + :px 3 + :py 3 + :isActive active? + :onClick on-click + :sx {"span[class*='icon']:last-child" {:ml "auto" + :mr "1rem" + :marginBlock "-0.2rem" + :alignItems "center" + :fontSize "1.5em" + :alignSelf "center"}}} + [:> VStack {:align "stretch" + :spacing 1 + :overflow "hidden"} + [:> Heading {:as "h4" + :size "sm"} + (when prefix + [:> Text {:as "span" + :textTransform "uppercase" + :color "foreground.secondary" + :fontSize "xs" + :letterSpacing "0.1ch" + :mr "1ch"} prefix]) + (highlight-match query title)] + (when preview + [:> Text {:color "foreground.secondary" + :textOverflow "ellipsis" + :overflow "hidden"} (highlight-match query preview)])]]) + + +(defn results-el + [state] + (let [no-query? (str/blank? (:query @state)) + recent-items @(subscribe [:athena/get-recent])] + [:<> [:> HStack {:fontSize "sm" + :px 6 + :py 2 + :color "foreground.secondary" + :borderTop "1px solid" + :borderColor "separator.divider" + :justifyContent "space-between"} + [:> Heading {:size "xs"} + (if no-query? "Recent" "Results")] + [:> Text + "Press " + [:kbd "shift + enter"] + " to open in right sidebar."]] + (when no-query? + [:> VStack {:align "stretch" + :spacing 1 + :borderTopWidth "1px" + :borderTopStyle "solid" + :borderColor "separator.divider" + :p 4 + :overflowY "auto" + :sx {"@supports (overflow-y: overlay)" {:overflowY "overlay"}} + :_empty {:display "none"}} + (doall + (for [[i x] (map-indexed list recent-items)] + (when x + (let [{:keys [query :node/title :block/string]} x] + [result-el {:key i + :title title + :query query + :preview string + :on-click (fn [e] + (rf/dispatch [:reporting/navigation {:source :athena + :target :page + :pane :main-pane}]) + (router/navigate-page title e))}]))))])])) + + +(defn search-results-el + [{:keys [results query index]}] + [:> VStack {:align "stretch" + :borderTopWidth "1px" + :borderTopStyle "solid" + :borderColor "separator.divider" + :spacing 1 + :p 4 + :overflowY "auto" + :sx {"@supports (overflow-y: overlay)" {:overflowY "overlay"}} + :_empty {:display "none"}} + (doall + (for [[i x] (map-indexed list results) + :let [parent (:block/parent x) + type (if parent :block :node) + title (or (:node/title parent) (:node/title x) (:block/string parent)) + uid (or (:block/uid parent) (:block/uid x)) + navigate-to-uid (or (:block-search/navigate-uid x) + (:block/uid x)) + string (:block/string x)]] + (if (nil? x) + ^{:key i} + [result-el {:key i + :title query + :prefix "Create page" + :preview nil + :type :page + :query nil + :icon (r/as-element [:> PageAddIcon]) + :active? (= i index) + :on-click (fn [e] + (let [block-uid (utils/gen-block-uid) + shift? (.-shiftKey e)] + (dispatch [:athena/toggle]) + (dispatch [:page/new {:title query + :block-uid block-uid + :source :athena}]) + (dispatch [:reporting/navigation {:source :athena + :target (if parent + (str "block/" block-uid) + (str "page/" title)) + :pane (if shift? + :right-pane + :main-pane)}])))}] + [result-el {:key i + :title title + :query query + :type type + :icon (when (= i index) (r/as-element [:> ArrowRightIcon])) + :preview string + :active? (= i index) + :on-click (fn [e] + (let [selected-page {:node/title title + :block/uid uid + :block/string string + :query query} + shift? (.-shiftKey e)] + (dispatch [:athena/toggle]) + (dispatch [:athena/update-recent-items selected-page]) + (dispatch [:reporting/navigation {:source :athena + :target (if parent + :block + :page) + :pane (if shift? + :right-pane + :main-pane)}]) + (if parent + (router/navigate-uid navigate-to-uid) + (router/navigate-page title e))))}])))]) + + +(defn athena-component + [] + (let [athena-open? (rf/subscribe [:athena/open]) + state (r/atom {:index 0 + :query nil + :results []}) + search-handler (create-search-handler state)] + (fn [] + [:> Modal {:maxHeight "60vh" + :display "flex" + :scrollBehavior "inside" + :outline "none" + :motionPreset "none" + :closeOnEsc true + :isOpen @athena-open? + :onClose #(dispatch [:athena/toggle])} + [:> ModalOverlay] + [:> ModalContent {:width "45rem" + :class "athena-modal" + :overflow "hidden" + :backdropFilter "blur(20px)" + :bg "background.vibrancy" + :maxWidth "calc(100vw - 4rem)"} + [:> Input + {:type "search" + :autoComplete "off" + :width "100%" + :border 0 + :fontSize "2.375rem" + :fontWeight "300" + :lineHeight "1.3" + :letterSpacing "-0.03em" + :color "inherit" + :background "none" + :borderRadius 0 + :height "auto" + :padding "1.5rem 4rem 1.5rem 1.5rem" + :cursor "text" + :id "athena-input" + :auto-focus true + :required true + :_focus {:outline "none"} + :sx {"::placeholder" {:color "foreground.secondary"} + "::-webkit-search-cancel-button" {:display "none"}} + :placeholder "Find or Create Page" + :on-change (fn [e] (search-handler (.. e -target -value))) + :on-key-down (fn [e] (key-down-handler e state))}] + (when (:query @state) + [:> IconButton {:background "none" + :color "foreground.secondary" + :position "absolute" + :transition "opacity 0.1s ease, background 0.1s ease" + :cursor "pointer" + :border 0 + :right "2rem" + :placeItems "center" + :placeContent "center" + :height "2.5rem" + :width "2.5rem" + :borderRadius "1000px" + :display "flex" + :top "2rem" + :onClick #(set! (.-value (getElement "athena-input")) nil)} + [:> XmarkIcon {:boxSize 6}]]) + [results-el state] + [search-results-el @state]]]))) diff --git a/src/cljs/athens/views/blocks/autocomplete_search.cljs b/src/cljs/athens/views/blocks/autocomplete_search.cljs new file mode 100644 index 0000000000..12442baacc --- /dev/null +++ b/src/cljs/athens/views/blocks/autocomplete_search.cljs @@ -0,0 +1,51 @@ +(ns athens.views.blocks.autocomplete-search + (:require + ["/components/Block/Autocomplete" :refer [Autocomplete AutocompleteButton]] + ["@chakra-ui/react" :refer [Text]] + [athens.events.inline-search :as inline-search.events] + [athens.subs.inline-search :as inline-search.subs] + [athens.views.blocks.textarea-keydown :as textarea-keydown] + [clojure.string :as string] + [re-frame.core :as rf])) + + +(defn inline-item-click + [state-hooks uid expansion] + (let [id (str "#editable-uid-" uid) + target (.. js/document (querySelector id)) + type (rf/subscribe [::inline-search.subs/type uid]) + f (case @type + :hashtag textarea-keydown/auto-complete-hashtag + :template textarea-keydown/auto-complete-template + :property textarea-keydown/auto-complete-property + textarea-keydown/auto-complete-inline)] + (f uid state-hooks target expansion))) + + +(defn inline-search-el + [block {:as state-hooks} last-event] + (let [block-uid (:block/uid block) + inline-search-type (rf/subscribe [::inline-search.subs/type block-uid]) + inline-search-index (rf/subscribe [::inline-search.subs/index block-uid]) + inline-search-results (rf/subscribe [::inline-search.subs/results block-uid]) + inline-search-query (rf/subscribe [::inline-search.subs/query block-uid])] + (fn [block {:as _state-hooks} _last-event _state] + (let [is-open (some #(= % @inline-search-type) [:page :block :hashtag :template :property])] + [:> Autocomplete {:event @last-event + :isOpen is-open + :onClose #(when is-open + (rf/dispatch [::inline-search.events/close! block-uid]))} + (when is-open + (if (or (string/blank? @inline-search-query) + (empty? @inline-search-results)) + [:> Text {:py "0.4rem" + :px "0.8rem" + :fontStyle "italics"} + (str "Search for a " (symbol @inline-search-type))] + (doall + (for [[i {:keys [node/title block/string block/uid text]}] (map-indexed list @inline-search-results)] + [:> AutocompleteButton {:key (str "inline-search-item" uid) + :isActive (= i @inline-search-index) + :onClick (fn [_] (inline-item-click state-hooks (:block/uid block) (or title uid))) + :id (str "inline-search-item" uid)} + (or text title string)]))))])))) diff --git a/src/cljs/athens/views/blocks/autocomplete_slash.cljs b/src/cljs/athens/views/blocks/autocomplete_slash.cljs new file mode 100644 index 0000000000..56ebe750a0 --- /dev/null +++ b/src/cljs/athens/views/blocks/autocomplete_slash.cljs @@ -0,0 +1,42 @@ +(ns athens.views.blocks.autocomplete-slash + (:require + ["/components/Block/Autocomplete" :refer [Autocomplete AutocompleteButton]] + [athens.events.inline-search :as inline-search.events] + [athens.subs.inline-search :as inline-search.subs] + [athens.views.blocks.textarea-keydown :as textarea-keydown] + [re-frame.core :as rf] + [reagent.core :as r] + [reagent.ratom :as ratom])) + + +(defn slash-item-click + [block item] + (let [block-uid (:block/uid block) + id (str "#editable-uid-" block-uid) + target (.. js/document (querySelector id))] + (textarea-keydown/auto-complete-slash block-uid target item))) + + +(defn slash-menu-el + [block last-event] + (let [block-uid (:block/uid block) + inline-search-type (rf/subscribe [::inline-search.subs/type block-uid]) + inline-search-index (rf/subscribe [::inline-search.subs/index block-uid]) + inline-search-results (rf/subscribe [::inline-search.subs/results block-uid]) + open? (ratom/reaction (= @inline-search-type :slash))] + (fn [block _last-event _state] + [:> Autocomplete {:event @last-event + :isOpen @open? + :onClose #(when @open? + (rf/dispatch [::inline-search.events/close! block-uid]))} + (when @open? + (doall + (for [[i [text icon _expansion kbd _pos :as item]] (map-indexed list @inline-search-results)] + [:> AutocompleteButton {:key text + :id (str "dropdown-item-" i) + :command kbd + :isActive (when (= i @inline-search-index) "isActive") + :onClick (fn [_] (slash-item-click block item))} + [:<> + [(r/adapt-react-class icon) {:boxSize 6 :mr 3 :ml 0}] + text]])))]))) diff --git a/src/cljs/athens/views/blocks/bullet.cljs b/src/cljs/athens/views/blocks/bullet.cljs new file mode 100644 index 0000000000..b44bbc7a44 --- /dev/null +++ b/src/cljs/athens/views/blocks/bullet.cljs @@ -0,0 +1,22 @@ +(ns athens.views.blocks.bullet + (:require + [athens.db :as db] + [athens.events.dragging :as drag.events] + [re-frame.core :as rf])) + + +;; Helpers + +(defn bullet-drag-start + "Begin drag event: https://developer.mozilla.org/en-US/docs/Web/API/HTML_Drag_and_Drop_API#Define_the_drags_data" + [e uid] + (let [effect-allowed (if (.. e -shiftKey) "link" "move")] + (set! (.. e -dataTransfer -effectAllowed) effect-allowed)) + (.. e -dataTransfer (setData "text/plain" (-> uid db/uid-and-embed-id first))) + (rf/dispatch [::drag.events/set-dragging! uid true])) + + +(defn bullet-drag-end + "End drag event." + [_e uid] + (rf/dispatch [::drag.events/set-dragging! uid false])) diff --git a/src/cljs/athens/views/blocks/context_menu.cljs b/src/cljs/athens/views/blocks/context_menu.cljs new file mode 100644 index 0000000000..06ecb6b2ac --- /dev/null +++ b/src/cljs/athens/views/blocks/context_menu.cljs @@ -0,0 +1,47 @@ +(ns athens.views.blocks.context-menu + (:require + [athens.db :as db] + [athens.listeners :as listeners] + [athens.subs.selection :as select-subs] + [athens.util :refer [toast]] + [clojure.string :as string] + [re-frame.core :as rf])) + + +(defn handle-copy-refs + [_ uid] + (let [selected-items @(rf/subscribe [::select-subs/items]) + ;; use this when using datascript-transit + ;; uids (map (fn [x] [:block/uid x]) selected-items) + ;; blocks (d/pull-many @db/dsdb '[*] ids) + data (if (empty? selected-items) + (str "((" uid "))") + (->> (map (fn [uid] (str "((" uid "))\n")) selected-items) + (string/join "")))] + (.. js/navigator -clipboard (writeText data)) + (toast (clj->js {:title (if (> (count selected-items) 1) + "Copied refs to clipboard" + "Copied ref to clipboard")})))) + + +(defn handle-copy-unformatted + "If copying only a single block, dissoc children to not copy subtree." + [^js uid] + (let [uids @(rf/subscribe [::select-subs/items])] + (if (empty? uids) + (let [block (dissoc (db/get-block [:block/uid uid]) :block/children) + data (listeners/blocks-to-clipboard-data 0 block true)] + (.. js/navigator -clipboard (writeText data))) + (let [data (->> (map #(db/get-block [:block/uid %]) uids) + (map #(listeners/blocks-to-clipboard-data 0 % true)) + (apply str))] + (.. js/navigator -clipboard (writeText data))))) + (toast (clj->js {:title "Copied content to clipboard" :status "success"}))) + + +(defn handle-click-comment + [e uid] + (rf/dispatch [:comment/show-editor uid]) + (.. e preventDefault)) + + diff --git a/src/cljs/athens/views/blocks/core.cljs b/src/cljs/athens/views/blocks/core.cljs new file mode 100644 index 0000000000..bc78ba9286 --- /dev/null +++ b/src/cljs/athens/views/blocks/core.cljs @@ -0,0 +1,795 @@ +(ns athens.views.blocks.core + (:require + ["/components/App/ContextMenuContext" :refer [ContextMenuContext]] + ["/components/Block/Anchor" :refer [Anchor]] + ["/components/Block/Container" :refer [Container]] + ["/components/Block/PropertyName" :refer [PropertyName]] + ["/components/Block/Reactions" :refer [Reactions]] + ["/components/Block/Toggle" :refer [Toggle]] + ["/components/Icons/Icons" :refer [ArchiveIcon + CheckmarkIcon + ArrowRightOnBoxIcon + BlockEmbedIcon + ChatBubbleIcon + ExpandIcon + TextIcon]] + ["/components/Page/Page" :refer [Page + PageHeader + PageBody + PageFooter + TitleContainer]] + ["/components/References/References" :refer [PageReferences + ReferenceBlock + ReferenceGroup]] + ["@chakra-ui/react" :refer [Box + Breadcrumb + BreadcrumbItem + BreadcrumbLink + UnorderedList + ListItem + Button + HStack + MenuDivider + MenuGroup + MenuItem + VStack]] + ["react" :as react] + ["react-intersection-observer" :refer [useInView]] + [athens.common-db :as common-db] + [athens.common-events.graph.ops :as graph-ops] + [athens.common.logging :as log] + [athens.db :as db] + [athens.electron.images :as images] + [athens.electron.utils :as electron.utils] + [athens.events.dragging :as drag.events] + [athens.events.inline-refs :as inline-refs.events] + [athens.events.linked-refs :as linked-ref.events] + [athens.events.selection :as select-events] + [athens.parse-renderer :as parse-renderer] + [athens.reactive :as reactive] + [athens.router :as router] + [athens.self-hosted.presence.views :as presence] + [athens.subs.dragging :as drag.subs] + [athens.subs.inline-refs :as inline-refs.subs] + [athens.subs.linked-refs :as linked-ref.subs] + [athens.subs.selection :as select-subs] + [athens.time-controls :as time-controls] + [athens.types.core :as types] + ;; need to require it for multimethod participation + [athens.types.default.view] + [athens.types.dispatcher :as block-type-dispatcher] + [athens.types.query.view] + ;; need to require it for multimethod participation + [athens.types.tasks.view] + [athens.util :as util] + [athens.views.blocks.bullet :as block-bullet] + [athens.views.blocks.context-menu :as ctx-menu] + [athens.views.blocks.drop-area-indicator :as drop-area-indicator] + [athens.views.blocks.reactions :as block-reaction] + [athens.views.comments.core :as comments] + [athens.views.comments.inline :as inline-comments] + [athens.views.notifications.actions :as actions] + [com.rpl.specter :as s] + [re-frame.core :as rf] + [reagent.core :as r])) + + +;; Components + + +(defn block-drag-over + "If block or ancestor has CSS dragging class, do not show drop indicator; do not allow block to drop onto itself. + If above midpoint, show drop indicator above block. + If no children and over X pixels from the left, show child drop indicator. + If below midpoint, show drop indicator below." + [e block] + (.. e preventDefault) + (.. e stopPropagation) + (let [{:block/keys [children + uid + open]} block + closest-container (.. e -target (closest ".block-container")) + {:keys [x y]} (util/mouse-offset e closest-container) + middle-y (util/vertical-center closest-container) + dragging-ancestor (.. e -target (closest ".dragging")) + dragging? dragging-ancestor + is-selected? @(rf/subscribe [::select-subs/selected? uid]) + target (cond + dragging? nil + is-selected? nil + (or (neg? y) + (< y middle-y)) :before + (and (< middle-y y) + (> 50 x)) :after + (or (not open) + (and (empty? children) + (< 50 x))) :first) + prev-target @(rf/subscribe [::drag.subs/drag-target uid])] + (when (and target + (not= prev-target target)) + (rf/dispatch [::drag.events/set-drag-target! uid target])))) + + +(defn block-drag-leave + "When mouse leaves block, remove any drop area indicator. + Ignore if target-uid and related-uid are the same — user went over a child component and we don't want flicker." + [e block] + (.. e preventDefault) + (.. e stopPropagation) + (let [{target-uid :block/uid} block + related-uid (util/get-dataset-uid (.. e -relatedTarget))] + (when-not (= related-uid target-uid) + (rf/dispatch [::drag.events/cleanup! target-uid])))) + + +(defn drop-bullet + "Terminology : + - source-uid : The block which is being dropped. + - target-uid : The block on which source is being dropped. + - drag-target : Represents where the block is being dragged. It can be `:first` meaning + dragged as a child, `:before` meaning the source block is dropped above the + target block, `:after` meaning the source block is dropped below the target block. + - action-allowed : There can be 2 types of actions. + - `link` action : When a block is DnD by dragging a bullet while + `shift` key is pressed to create a block link. + - `move` action : When a block is DnD to other part of Athens page. " + + [source-uid target-uid drag-target action-allowed] + (let [move-action? (= action-allowed "move") + event [(if move-action? + :block/move + :block/link) + {:source-uid source-uid + :target-uid target-uid + :target-rel drag-target}]] + (log/debug "drop-bullet" (pr-str {:source-uid source-uid + :target-uid target-uid + :drag-target drag-target + :action-allowed action-allowed + :event event})) + (rf/dispatch event))) + + +(defn drop-bullet-multi + " + Terminology : + - source-uids : Uids of the blocks which are being dropped + - target-uid : Uid of the block on which source is being dropped" + [source-uids target-uid drag-target] + (let [source-uids (mapv (comp first db/uid-and-embed-id) source-uids) + target-uid (first (db/uid-and-embed-id target-uid)) + event (if (= drag-target :first) + [:drop-multi/child {:source-uids source-uids + :target-uid target-uid}] + [:drop-multi/sibling {:source-uids source-uids + :target-uid target-uid + :drag-target drag-target}])] + (rf/dispatch [::select-events/clear]) + (rf/dispatch event))) + + +(defn block-drop + "Handle dom drop events, read more about drop events at: + : https://developer.mozilla.org/en-US/docs/Web/API/HTML_Drag_and_Drop_API#Define_a_drop_zone" + + [e block] + (.. e stopPropagation) + (let [{target-uid :block/uid} block + [target-uid _] (db/uid-and-embed-id target-uid) + drag-target @(rf/subscribe [::drag.subs/drag-target target-uid]) + source-uid (.. e -dataTransfer (getData "text/plain")) + effect-allowed (.. e -dataTransfer -effectAllowed) + items (array-seq (.. e -dataTransfer -items)) + item (first items) + datatype (.. item -type) + img-regex #"(?i)^image/(p?jpeg|gif|png)$" + valid-text-drop (and (not (nil? drag-target)) + (not= source-uid target-uid) + (or (= effect-allowed "link") + (= effect-allowed "move"))) + selected-items @(rf/subscribe [::select-subs/items])] + + (cond + (re-find img-regex datatype) (when electron.utils/electron? + (images/dnd-image target-uid drag-target item (second (re-find img-regex datatype)))) + (re-find #"text/plain" datatype) (when valid-text-drop + (if (empty? selected-items) + (drop-bullet source-uid target-uid drag-target effect-allowed) + (drop-bullet-multi selected-items target-uid drag-target)))) + + (rf/dispatch [:mouse-down/unset]) + (rf/dispatch [::drag.events/cleanup! target-uid]))) + + +(defn- block-open-toggle! + [block-uid open] + (rf/dispatch [:block/open {:block-uid block-uid + :open? open}])) + + +(defn block-refs-count-el + [count click-fn active?] + [:> Button {:gridArea "refs" + :size "xs" + :height "var(--control-height)" + :variant "ghost" + :colorScheme "subtle" + :alignSelf "flex-start" + :zIndex 10 + :visibility (if (pos? count) "visible" "hidden") + :isActive active? + :onClick (fn [e] + (.. e stopPropagation) + (click-fn e))} + count]) + + +(defn ref-comp + [block-el block] + (let [orig-uid (:block/uid block) + has-children? (-> block :block/children boolean) + parents (cond-> (:block/parents block) + ;; If the ref has children, move it to breadcrumbs and show children. + has-children? (conj block)) + state-reset {:block block + :embed-id (random-uuid) + :open? true + :parents parents + :focus? (not has-children?)} + linked-ref-data {:linked-ref true + :initial-open false + :linked-ref-uid (:block/uid block) + :parent-uids (set (map :block/uid (:block/parents block)))} + inline-ref-open? (rf/subscribe [::inline-refs.subs/state-open? orig-uid]) + inline-ref-focus? (rf/subscribe [::inline-refs.subs/state-focus? orig-uid]) + inline-ref-block (rf/subscribe [::inline-refs.subs/state-block orig-uid]) + inline-ref-parents (rf/subscribe [::inline-refs.subs/state-parents orig-uid]) + inline-ref-embed-id (rf/subscribe [::inline-refs.subs/state-embed-id orig-uid])] + ;; Reset state on parent each time the component is created. + ;; To clear state, open/close the inline refs. + (rf/dispatch [::inline-refs.events/set-state! orig-uid state-reset]) + (fn [_ _] + (let [block (reactive/get-reactive-block-document (:db/id @inline-ref-block))] + [:<> + [:> HStack {:lineHeight "1"} + [:> Toggle {:isOpen @inline-ref-open? + :on-click (fn [e] + (.. e stopPropagation) + (rf/dispatch [::inline-refs.events/toggle-state-open! orig-uid]))}] + + [:> Breadcrumb {:fontSize "xs" :color "foreground.secondary"} + (doall + (for [{:keys [block/uid] :as breadcrumb-block} + (if (or @inline-ref-open? + (not @inline-ref-focus?)) + @inline-ref-parents + (conj @inline-ref-parents block))] + [:> BreadcrumbItem {:key (str "breadcrumb-" uid)} + [:> BreadcrumbLink {:onClick (fn [e] + (let [shift? (.-shiftKey e)] + (rf/dispatch [:reporting/navigation {:source :block-bullet + :target :block + :pane (if shift? + :right-pane + :main-pane)}]) + (let [new-B (db/get-block [:block/uid uid]) + new-P (concat + (take-while (fn [b] (not= (:block/uid b) uid)) @inline-ref-parents) + [breadcrumb-block])] + (.. e stopPropagation) + (rf/dispatch [::inline-refs.events/set-block! orig-uid new-B]) + (rf/dispatch [::inline-refs.events/set-parents! orig-uid new-P]) + (rf/dispatch [::inline-refs.events/set-focus! orig-uid false]))))} + [parse-renderer/parse-and-render (common-db/breadcrumb-string @db/dsdb uid) uid]]]))]] + + (when @inline-ref-open? + (if @inline-ref-focus? + + ;; Display the single child block only when focusing. + ;; This is the default behaviour for a ref without children, for brevity. + [:div.block-embed {:fontSize "0.7em"} + [:f> block-el + (util/recursively-modify-block-for-embed block @inline-ref-embed-id) + linked-ref-data + {:block-embed? true}]] + + + ;; Otherwise display children of the parent directly. + (for [child (:block/children block)] + [:<> {:key (:db/id child)} + [:f> block-el + (util/recursively-modify-block-for-embed child @inline-ref-embed-id) + linked-ref-data + {:block-embed? true}]])))])))) + + +(defn is-event-target-current-block-and-not-child + [e block-uid] + ;; If the closest block container to the target has the given block, return true. + (let [target-el (.-target e) + closest-block-container (or (.closest target-el ".block-container") nil) + closest-block-uid (if closest-block-container (.. closest-block-container -dataset -uid) nil)] + (= closest-block-uid block-uid))) + + +(defn inline-linked-refs-el + [block-el uid] + (let [refs (reactive/get-reactive-linked-references [:block/uid uid])] + (when (not-empty refs) + [:> VStack {:as "aside" + :align "stretch" + :spacing 3 + :key "Inline Linked References" + :zIndex 2 + :ml 8 + :pl 4 + :p2 2 + :borderRadius "md" + :background "background.basement"} + (doall + (for [[group-title group] refs] + [:> ReferenceGroup {:title group-title + :key (str "group-" group-title)} + (doall + (for [block' group] + [:> ReferenceBlock {:key (str "ref-" (:block/uid block'))} + [ref-comp block-el block']]))]))]))) + + +(defn convert-anon-block-to-task + [block] + (let [{:block/keys [uid string]} block + entity-type-event [:graph/update-in [:block/uid uid] [":entity/type"] + (fn [db entity-type-uid] + [(graph-ops/build-block-save-op db entity-type-uid "[[athens/task]]")])] + task-title-event [:graph/update-in [:block/uid uid] [":task/title"] + (fn [db task-title-uid] + [(graph-ops/build-block-save-op db task-title-uid string)])]] + (log/debug "convert to task" + (pr-str {:uid uid + :string string + :entity-type-event entity-type-event + :task-title-event task-title-event})) + (rf/dispatch entity-type-event) + (rf/dispatch task-title-event))) + + +(defn block-debug-properties + [sanitized-block] + (let [{:block/keys [uid refs id order open?]} sanitized-block] + [:> UnorderedList + [:> ListItem "uid: " uid] + [:> ListItem "db/id " id] + [:> ListItem "order " order] + [:> ListItem "open?: " open?] + [:> ListItem "refs: " (count refs)]])) + + +(defn block-menu + [selected-items uid block-type uid-sanitized-block comments-enabled? block-o reactions-enabled? user-id properties notifications-enabled? show-debug-details?] + [:> MenuGroup {:title "Block"} + (when (< (count @selected-items) 2) + [:> MenuItem {:children "Open block" + :icon (r/as-element [:> ExpandIcon]) + :onClick (fn [e] + (let [shift? (.-shiftKey e)] + (rf/dispatch [:reporting/navigation {:source :block-bullet + :target :block + :pane (if shift? + :right-pane + :main-pane)}]) + (router/navigate-uid uid e)))}]) + [:> MenuItem {:children "Open in right sidebar" + :icon (r/as-element [:> ArrowRightOnBoxIcon]) + :onClick (fn [_] + (rf/dispatch [:reporting/navigation {:source :block-bullet + :target :block + :pane :right-pane}]) + (rf/dispatch [:right-sidebar/open-item [:block/uid uid]]))}] + (when-not (= block-type "[[athens/task]]") + [:> MenuItem {:children "Convert to Task" + :icon (r/as-element [:> CheckmarkIcon]) + :onClick #(convert-anon-block-to-task block-o)}]) + [:> MenuItem {:children (if (> (count @selected-items) 1) + "Copy selected block refs" + "Copy block ref") + :icon (r/as-element [:> BlockEmbedIcon]) + :onClick #(ctx-menu/handle-copy-refs nil uid)}] + [:> MenuItem {:children "Copy unformatted text" + :icon (r/as-element [:> TextIcon]) + :onClick #(ctx-menu/handle-copy-unformatted uid)}] + (when comments-enabled? + [:> MenuItem {:children "Add comment" + :onClick #(ctx-menu/handle-click-comment % uid) + :icon (r/as-element [:> ChatBubbleIcon])}]) + (when reactions-enabled? + [:<> + [:> MenuDivider] + [block-reaction/reactions-menu-list uid user-id]]) + (when (and notifications-enabled? (actions/is-block-notification? properties)) + [:> MenuItem {:children "Archive" + :icon (r/as-element [:> ArchiveIcon]) + :onClick #(rf/dispatch (actions/update-state-prop uid "athens/notification/is-archived" "true"))}]) + (when show-debug-details? + [block-debug-properties uid-sanitized-block])]) + + +(def CONTAINER_CONTEXT_MENU_FILTERED_TAGS + #js ["A" "BUTTON" "INPUT" "TEXTAREA" "LABEL" "VIDEO" "EMBED" "IFRAME" "IMG"]) + + +(defn block-el + "Two checks dec to make sure block is open or not: children exist and :block/open bool" + ([block] + [:f> block-el block {:linked-ref false} {}]) + ([block linked-ref-data] + [:f> block-el block linked-ref-data {}]) + ([block linked-ref-data _opts] + (let [[block-uid _embed-id] (-> block :block/uid common-db/uid-and-embed-id) + {:keys [initial-open + parent-uids + linked-ref]} linked-ref-data + ident [:block/uid block-uid] + is-hovered-not-child? (r/atom false) + !container-ref (clojure.core/atom nil) + !anchor-ref (clojure.core/atom nil) + linked-ref-open? (rf/subscribe [::linked-ref.subs/open? block-uid]) + editing? (rf/subscribe [:editing/is-editing block-uid]) + dragging? (rf/subscribe [::drag.subs/dragging? block-uid]) + drag-target (rf/subscribe [::drag.subs/drag-target block-uid]) + selected? (rf/subscribe [::select-subs/selected? block-uid]) + selected-items (rf/subscribe [::select-subs/items]) + feature-flags (rf/subscribe [:feature-flags]) + current-user (rf/subscribe [:presence/current-user]) + show-comments? (rf/subscribe [:comment/show-comments?]) + show-textarea? (rf/subscribe [:comment/show-editor? block-uid]) + inline-refs-open? (rf/subscribe [::inline-refs.subs/open? block-uid]) + enable-properties? (rf/subscribe [:feature-flags/enabled? :properties]) + on-block-mount (fn [] + (rf/dispatch [::linked-ref.events/set-open! block-uid (or (false? linked-ref) initial-open)]) + (rf/dispatch [::inline-refs.events/set-open! block-uid false])) + on-unmount-block (fn [] + (rf/dispatch [::linked-ref.events/cleanup! block-uid]) + (rf/dispatch [::inline-refs.events/cleanup! block-uid]))] + + (fn block-core-render + [block linked-ref-data opts] + (let [block-o (reactive/get-reactive-block-document ident) + {:block/keys [uid + open + children + key + properties + _refs]} (merge block block-o) + block-type (reactive/reactive-get-entity-type [:block/uid block-uid]) + children-uids (set (map :block/uid children)) + children? (seq children-uids) + container-ref-ref #js {:current @!container-ref} + anchor-ref-ref #js {:current @!anchor-ref} + comments-enabled? (:comments @feature-flags) + reactions-enabled? (:reactions @feature-flags) + notifications-enabled? (:notifications @feature-flags) + uid-sanitized-block (s/transform + (util/specter-recursive-path #(contains? % :block/uid)) + (fn [{:block/keys [original-uid uid] :as block}] + (assoc block :block/uid (or original-uid uid))) + block) + user-id (or (:username @current-user) + ;; We use empty string for when there is no user information, like in PKM. + "") + reactions (and reactions-enabled? + (block-reaction/props->reactions properties)) + ff @(rf/subscribe [:feature-flags]) + renderer-k (block-type-dispatcher/block-type->protocol-k block-type ff) + renderer (block-type-dispatcher/block-type->protocol renderer-k {:linked-ref-data linked-ref-data}) + context-menu (react/useContext ContextMenuContext) + has-menu-open? (.getIsMenuOpen context-menu container-ref-ref) + show-debug-details? (util/re-frame-10x-open?) + menu-component (r/as-element [block-menu selected-items uid block-type uid-sanitized-block comments-enabled? block-o reactions-enabled? user-id properties notifications-enabled? show-debug-details?]) + [ref in-view?] (useInView {:delay 250}) + _ (react/useEffect (fn [] + (on-block-mount) + on-unmount-block) + #js [])] + + #_(log/debug "block open render: block-o:" (pr-str (:block/open block-o)) + "block:" (pr-str (:block/open block)) + "merge:" (pr-str (:block/open (merge block-o block)))) + + [:> Container {:isActive has-menu-open? + :isHoveredNotChild @is-hovered-not-child? + :isOpen open + :isEditing @editing? + :isSelected @selected? + :hasChildren (seq children) + :uid uid + :ref (fn [el] (reset! !container-ref el)) + :childrenUids children-uids + :isDragging (and @dragging? (not @selected?)) + :onMouseOver (fn [e] + (reset! is-hovered-not-child? (is-event-target-current-block-and-not-child e uid))) + :onMouseLeave (fn [_] (reset! is-hovered-not-child? false)) + :onDragOver (fn [e] + (block-drag-over e block)) + :onDragLeave (fn [e] + (block-drag-leave e block)) + :onDrop (fn [e] + (block-drop e block)) + :onContextMenu (fn [e] + (if + (.includes CONTAINER_CONTEXT_MENU_FILTERED_TAGS (.-tagName (.-target e))) + (.preventDefault e) + (.addToContextMenu context-menu + #js {:ref container-ref-ref + :event e + :component menu-component + :key "block"}))) + :sx (merge {} (time-controls/block-styles block-o))} + (when (= @drag-target :before) [drop-area-indicator/drop-area-indicator {:placement "above"}]) + + [:<> + [:div.block-body {:ref ref} + (when (and children? + (or (seq children) + (seq properties))) + (when in-view? + [:> Toggle {:isOpen (if (or (and (true? linked-ref) @linked-ref-open?) + (and (false? linked-ref) open)) + true + false) + :onClick (fn [e] + (.. e stopPropagation) + (if (true? linked-ref) + (rf/dispatch [::linked-ref.events/toggle-open! uid]) + (block-open-toggle! uid (not open))))}])) + + (when key + [:> PropertyName {:name (:node/title key) + :onClick (fn [e] + (let [shift? (.-shiftKey e)] + (rf/dispatch [:reporting/navigation {:source :block-property + :target :page + :pane (if shift? + :right-pane + :main-pane)}]) + (router/navigate-page (:node/title key) e))) + :on-drag-start (fn [e] + (block-bullet/bullet-drag-start e uid)) + :on-drag-stop (fn [e] + (block-bullet/bullet-drag-end e uid))}]) + + [:> Anchor {:isClosedWithChildren (when (and (seq children) + (or (and (true? linked-ref) (not @linked-ref-open?)) + (and (false? linked-ref) (not open)))) + "closed-with-children") + :draggable true + :ref (fn [el] (reset! !anchor-ref el)) + :onContextMenu (fn [e] + (.addToContextMenu context-menu + #js {:ref container-ref-ref + :event e + :anchorEl anchor-ref-ref + :component menu-component + :key "block"})) + :onClick (fn [e] + (.addToContextMenu context-menu + #js {:ref container-ref-ref + :event e + :anchorEl anchor-ref-ref + :component menu-component + :key "block"})) + :onDoubleClick (fn [e] + (let [shift? (.-shiftKey e)] + (.onCloseMenu context-menu) + (rf/dispatch [:reporting/navigation {:source :block-bullet + :target :block + :pane (if shift? + :right-pane + :main-pane)}]) + (router/navigate-uid uid e))) + :onDragStart (fn [e] + (block-bullet/bullet-drag-start e uid)) + :onDragEnd (fn [e] + (block-bullet/bullet-drag-end e uid))}] + + ;; `BlockTypeProtocol` dispatch placement + [:> Box {:gridArea "content" :overflow "hidden"} + ^{:key renderer-k} + [types/outline-view renderer block {:show-edit? is-hovered-not-child?}]] + + (when (and in-view? reactions-enabled? reactions) + [:> Reactions {:reactions (clj->js reactions) + :currentUser user-id + :onToggleReaction (partial block-reaction/toggle-reaction [:block/uid uid])}]) + + ;; Show comments when the toggle is on + (when (and @show-comments? + (or @show-textarea? + (comments/get-comment-thread-uid @db/dsdb uid))) + (cond + @show-textarea? [inline-comments/inline-comments (comments/get-comments-in-thread @db/dsdb (comments/get-comment-thread-uid @db/dsdb uid)) uid false] + :else [inline-comments/inline-comments (comments/get-comments-in-thread @db/dsdb (comments/get-comment-thread-uid @db/dsdb uid)) uid true])) + + (when (and in-view? + (> (count _refs) 0) + (not= :block-embed? opts)) + [block-refs-count-el + (count _refs) + (fn [e] + (if (.. e -shiftKey) + (rf/dispatch [:right-sidebar/open-item [:block/uid uid]]) + (rf/dispatch [::inline-refs.events/toggle-open! uid]))) + @inline-refs-open?]) + + (when in-view? + [presence/inline-presence-el uid])] + + + ;; Inline refs + (when (and in-view? + (> (count _refs) 0) + (not= :block-embed? opts) + @inline-refs-open?) + [inline-linked-refs-el block-el uid]) + + ;; Properties + (when (and @enable-properties? + (or (and (true? linked-ref) @linked-ref-open?) + (and (false? linked-ref) open))) + (for [prop (common-db/sort-block-properties properties)] + ^{:key (:db/id prop)} + [:f> block-el prop + (assoc linked-ref-data :initial-open (contains? parent-uids (:block/uid prop))) + opts])) + + ;; Children + (when (and (seq children) + (or (and (true? linked-ref) @linked-ref-open?) + (and (false? linked-ref) open))) + (for [child children + :let [child-uid (:block/uid child)]] + ^{:key (:db/id child)} + [:f> block-el child + (assoc linked-ref-data :initial-open (contains? parent-uids child-uid)) + opts]))] + + (when (= @drag-target :first) [drop-area-indicator/drop-area-indicator {:placement "below" :child? true}]) + (when (= @drag-target :after) [drop-area-indicator/drop-area-indicator {:placement "below"}])]))))) + + +;; Block page + + +(defn breadcrumb-handle-click + "If block is in main, navigate to page. If in right sidebar, replace right sidebar item." + [e uid breadcrumb-uid breadcrumb-node-title] + (let [right-sidebar? (.. e -target (closest ".right-sidebar"))] + (rf/dispatch [:reporting/navigation {:source :block-page-breadcrumb + :target :block + :pane (if right-sidebar? + :right-pane + :main-pane)}]) + (if right-sidebar? + (let [eid (if breadcrumb-node-title + [:node/title breadcrumb-node-title] + [:block/uid breadcrumb-uid])] + (rf/dispatch [:right-sidebar/navigate-item uid eid])) + (router/navigate-uid breadcrumb-uid e)))) + + +(defn linked-refs-el + [id] + (let [linked-refs (reactive/get-reactive-linked-references id)] + (when (seq linked-refs) + [:> PageReferences {:title "Linked References" + :count (count linked-refs)} + (doall + (for [[group-title group] linked-refs] + [:> ReferenceGroup {:key (str "group-" group-title) + :title group-title + :onClickTitle (fn [e] + (let [shift? (.-shiftKey e) + parsed-title (parse-renderer/parse-title group-title)] + (rf/dispatch [:reporting/navigation {:source :block-page-linked-refs + :target :page + :pane (if shift? + :right-pane + :main-pane)}]) + (router/navigate-page parsed-title)))} + (doall + (for [block group] + [:> ReferenceBlock {:key (str "ref-" (:block/uid block))} + [ref-comp block]]))]))]))) + + +(defn parents-el + [uid id] + (let [parents (reactive/get-reactive-parents-recursively id)] + [:> Breadcrumb {:gridArea "breadcrumb" :opacity 0.75} + (doall + (for [{breadcrumb-uid :block/uid breadcrumb-node-title :node/title} parents] + ^{:key breadcrumb-uid} + [:> BreadcrumbItem {:key (str "breadcrumb-" breadcrumb-uid)} + [:> BreadcrumbLink {:onClick #(breadcrumb-handle-click % uid breadcrumb-uid breadcrumb-node-title)} + [:span {:style {:pointer-events "none"}} + [parse-renderer/parse-and-render (common-db/breadcrumb-string @db/dsdb breadcrumb-uid)]]]]))])) + + +(defn block-page-el + [block opts] + (let [state (r/atom {:string/local nil + :string/previous nil}) + uid (:block/uid block) + show-comments? (rf/subscribe [:comment/show-comments?]) + show-textarea? (rf/subscribe [:comment/show-editor? uid]) + is-editing? (rf/subscribe [:editing/is-editing uid]) + _right-sidebar-contains-items? (rf/subscribe [:right-sidebar/contains-item? [:block/uid uid]]) + properties-enabled? (rf/subscribe [:feature-flags/enabled? :properties])] + + (fn [block] + (let [{:block/keys [string + children + uid + properties] + :db/keys [id]} block + thread-uid (comments/get-comment-thread-uid @db/dsdb uid) + comments-data (comments/get-comments-in-thread @db/dsdb thread-uid) + block-type (reactive/reactive-get-entity-type [:block/uid uid]) + ff @(rf/subscribe [:feature-flags]) + renderer-k (block-type-dispatcher/block-type->protocol-k block-type ff) + renderer (block-type-dispatcher/block-type->protocol renderer-k {})] + + (when (not= string (:string/previous @state)) + (swap! state assoc :string/previous string :string/local string)) + + [:> Page (merge opts {:class "block-page"}) + + ;; Header + [:> PageHeader + + ;; Parent Context + [parents-el uid id] + [:> TitleContainer {:isEditing @is-editing? + :onClick (fn [e] + (.. e preventDefault) + (if (.. e -shiftKey) + (do + (rf/dispatch [:reporting/navigation {:source :block-page + :target :block + :pane :right-pane}]) + (router/navigate-uid uid e)) + + (rf/dispatch [:editing/uid uid])))} + ^{:key (str renderer-k uid)} + [types/zoomed-in-view renderer block {}]]] + + ;; Show comments when the toggle is on + [:> PageBody + (when (or @show-textarea? + (and @show-comments? + thread-uid)) + ^{:key uid} + [inline-comments/inline-comments comments-data uid false]) + + ;; Properties + (when (and @properties-enabled? + (seq properties)) + (for [prop (common-db/sort-block-properties properties)] + ^{:key (:db/id prop)} + [:f> block-el prop])) + + + ;; Children + (for [child children] + (let [{:keys [db/id]} child] + ^{:key id} [:f> block-el child]))] + + ;; Refs + [:> PageFooter + [linked-refs-el id]]])))) + + +(defn page + [ident opts] + (let [block (reactive/get-reactive-block-document ident)] + [block-page-el block opts])) diff --git a/src/cljs/athens/views/blocks/drop_area_indicator.cljs b/src/cljs/athens/views/blocks/drop_area_indicator.cljs new file mode 100644 index 0000000000..2d9c532a96 --- /dev/null +++ b/src/cljs/athens/views/blocks/drop_area_indicator.cljs @@ -0,0 +1,17 @@ +(ns athens.views.blocks.drop-area-indicator + (:require + ["@chakra-ui/react" :refer [Box]])) + + +(defn drop-area-indicator + ([{:keys [placement child?]}] + [:> Box {:display "block" + :height "1px" + :pointerEvents "none" + :background "link" + :gridArea (if (= placement "above") "above" "below") + :marginLeft (if child? "4rem" "2rem") + :marginBottom "-1px" + :position "relative" + :transformOrigin "left" + :zIndex 3}])) diff --git a/src/cljs/athens/views/blocks/editor.cljs b/src/cljs/athens/views/blocks/editor.cljs new file mode 100644 index 0000000000..23b8f10ce2 --- /dev/null +++ b/src/cljs/athens/views/blocks/editor.cljs @@ -0,0 +1,289 @@ +(ns athens.views.blocks.editor + (:require + ["/components/Block/Content" :refer [Content]] + [athens.config :as config] + [athens.db :as db] + [athens.events.selection :as select-events] + [athens.parse-renderer :refer [parse-and-render]] + [athens.subs.inline-search :as inline-search.subs] + [athens.subs.selection :as select-subs] + [athens.util :as util] + [athens.utils.markdown :as markdown] + [athens.views.blocks.autocomplete-search :as autocomplete-search] + [athens.views.blocks.autocomplete-slash :as autocomplete-slash] + [athens.views.blocks.internal-representation :as internal-representation] + [athens.views.blocks.textarea-keydown :as textarea-keydown] + [clojure.edn :as edn] + [clojure.set :as set] + [clojure.string :as str] + [goog.events :as goog-events] + [komponentit.autosize :as autosize] + [re-frame.core :as rf] + [reagent.core :as r]) + (:import + (goog.events + EventType))) + + +(defn enter-handler-new-line + [_uid _d-key-down] + (textarea-keydown/replace-selection-with "\n")) + + +(defn find-selected-items + "Used by both shift-click and click-drag for multi-block-selection. + Given a mouse event, a source block, and a target block, highlight blocks. + Find all blocks on the page using the DOM. + Determine if direction is up or down. + Algorithm: call select-up or select-down until start and end of vector are source and target. + + Bug: there isn't an algorithmic path for all pairs of source and target blocks, because sometimes the parent is + highlighted, meaning a child block might not be selected itself. Rather, it inherits selection from parent. + + e.g.: 1 and 3 as source and target, or vice versa. + • 1 + • 2 + • 3 + Because of this bug, add additional exit cases to prevent stack overflow." + [e source-uid target-uid] + (let [target (.. e -target) + page (or (.. target (closest ".node-page")) + (.. target (closest ".block-page"))) + blocks (some-> page + (.. (querySelectorAll ".block-container")) + array-seq + vec) + uids (map util/get-dataset-uid blocks) + uids->children-uids (->> (zipmap uids + (map util/get-dataset-children-uids blocks)) + (remove #(-> % second empty?)) + (into {})) + indexed-uids (map-indexed vector uids) + start-index (->> indexed-uids + (filter (fn [[_idx uid]] + (= source-uid uid))) + ffirst) + end-index (->> indexed-uids + (filter (fn [[_idx uid]] + (= target-uid uid))) + ffirst) + selected-uids (set @(rf/subscribe [::select-subs/items])) + candidate-uids (->> indexed-uids + (filter (fn [[idx _uid]] + (<= (min start-index end-index) + idx + (max start-index end-index)))) + (map second) + (into #{})) + descendants-uids (loop [descendants #{} + ancestors-uids candidate-uids] + (if (seq ancestors-uids) + (let [ancestors-children (->> ancestors-uids + (mapcat #(get uids->children-uids %)) + (into #{}))] + (recur (set/union descendants ancestors-children) + ancestors-children)) + descendants)) + to-remove-uids (set/intersection selected-uids descendants-uids) + selection-new-uids (set/difference candidate-uids descendants-uids) + new-selected-uids (-> selected-uids + (set/difference to-remove-uids) + (set/union selection-new-uids)) + selection-order (->> indexed-uids + (filter (fn [[_k v]] + (contains? new-selected-uids v))) + (mapv second))] + (when config/debug? + (js/console.debug (str "selection: " (pr-str selected-uids) + ", candidates: " (pr-str candidate-uids) + ", descendants: " (pr-str descendants-uids) + ", rm: " (pr-str to-remove-uids) + ", add: " (pr-str selection-new-uids))) + (js/console.debug :find-selected-items (pr-str {:source-uid source-uid + :target-uid target-uid + :selection-order selection-order}))) + (when (and start-index end-index) + (rf/dispatch [::select-events/set-items selection-order])))) + + +;; Event Handlers + +(defn textarea-paste + "Clipboard data can only be accessed if user triggers JavaScript paste event. + Uses previous keydown event to determine if shift was held, since the paste event has no knowledge of shift key. + + Image Cases: + - items N=1, image/png + - items N=2, text/html and image/png + For both of these, just write image to filesystem. Roam behavior is to copy the src and alt of the copied picture. + Roam's approach is useful to preserve the original source url and description, but is unsafe in case the link breaks. + Writing to filesystem (or to Firebase a la Roam) is useful, but has storage costs. + Writing to filesystem each time for now until get feedback otherwise that user doesn't want to save the image. + Can eventually become a setting. + + Plaintext cases: + - User pastes and last keydown has shift -> default + - User pastes and clipboard data doesn't have new lines -> default + - User pastes without shift and clipboard data has new line characters -> PREVENT default and convert to outliner blocks" + [e uid {:keys [update-fn read-value default-verbatim-paste?] + :or {default-verbatim-paste? false} + :as _state-hooks} last-key-w-shift?] + (let [data (.. e -clipboardData) + md-data (-> data + (.getData "text/html") + (markdown/html->md)) + text-data (.getData data "text/plain") + + ;; With internal representation + internal-representation (some-> (.getData data "application/athens-representation") + edn/read-string) + internal? (seq internal-representation) + new-uids (internal-representation/new-uids-map internal-representation) + repr-with-new-uids (into [] (internal-representation/update-uids internal-representation new-uids)) + + ;; For images in clipboard + items (array-seq (.. e -clipboardData -items)) + {:keys [head tail]} (athens.views.blocks.textarea-keydown/destruct-target (.-target e)) + img-regex #"(?i)^image/(p?jpeg|gif|png)$" + callback (fn [new-str] + (js/setTimeout #(update-fn new-str) + 50)) + + ;; External to internal representation + text-to-inter (cond + (not (str/blank? md-data)) + (internal-representation/text-to-internal-representation md-data) + (not (str/blank? text-data)) + (internal-representation/text-to-internal-representation text-data)) + line-breaks (re-find #"\r?\n" text-data) + no-shift (not @last-key-w-shift?) + local-value @read-value] + + + (cond + ;; For internal representation + internal? + (do + (.. e preventDefault) + (if default-verbatim-paste? + (textarea-keydown/replace-selection-with text-data) + (rf/dispatch [:paste-internal uid local-value repr-with-new-uids]))) + + ;; For images + (seq (filter (fn [item] + (let [datatype (.. item -type)] + (re-find img-regex datatype))) items)) + ;; Need dispatch-sync because with dispatch we lose the clipboard data context + ;; on callee side + (rf/dispatch-sync [:paste-image items head tail callback]) + + ;; For external copy-paste + (and line-breaks no-shift) + (do + (.. e preventDefault) + (if default-verbatim-paste? + (textarea-keydown/replace-selection-with text-data) + (rf/dispatch [:paste-internal uid local-value text-to-inter]))) + + (not no-shift) + (do + (.. e preventDefault) + (rf/dispatch [:paste-verbatim uid text-data])) + + :else + nil))) + + +(defn textarea-change + [e _uid {:keys [idle-fn update-fn]}] + (update-fn (.. e -target -value)) + (idle-fn)) + + +(defn textarea-click + "If shift key is held when user clicks across multiple blocks, select the blocks." + [e target-uid] + (let [[target-uid _] (db/uid-and-embed-id target-uid) + source-uid @(rf/subscribe [:editing/uid]) + shift? (.-shiftKey e)] + (if (and shift? + source-uid + target-uid + (not= source-uid target-uid)) + (find-selected-items e source-uid target-uid) + (rf/dispatch [::select-events/clear])))) + + +(defn global-mouseup + "Detach global mouseup listener (self)." + [_] + (goog-events/unlisten js/document EventType.MOUSEUP global-mouseup) + (rf/dispatch [:mouse-down/unset])) + + +(defn textarea-mouse-down + "Attach global mouseup listener. Listener can't be local because user might let go of mousedown off of a block. + See https://javascript.info/mouse-events-basics#events-order" + [e _uid] + (.. e stopPropagation) + (when (false? (.. e -shiftKey)) + (rf/dispatch [:editing/target (.. e -target)]) + (let [mouse-down @(rf/subscribe [:mouse-down])] + (when (false? mouse-down) + (rf/dispatch [:mouse-down/set]) + (goog-events/listen js/document EventType.MOUSEUP global-mouseup))))) + + +(defn textarea-mouse-enter + "When mouse-down, user is selecting multiple blocks with click+drag. + Use same algorithm as shift-enter, only updating the source and target." + [e target-uid] + (let [source-uid @(rf/subscribe [:editing/uid]) + mouse-down @(rf/subscribe [:mouse-down])] + (when mouse-down + (rf/dispatch [::select-events/clear]) + (find-selected-items e source-uid target-uid)))) + + +;; View + +(defn block-editor + "Actual string contents. Two elements, one for reading and one for writing. + The CSS class is-editing is used for many things, such as block selection. + Opacity is 0 when block is selected, so that the block is entirely blue, rather than darkened like normal editing. + is-editing can be used for shift up/down, so it is used in both editing and selection." + [block {:keys [save-fn read-value show-edit? placeholder style] :as state-hooks}] + (let [{:block/keys [uid original-uid]} block + is-editing? (rf/subscribe [:editing/is-editing uid]) + selected-items (rf/subscribe [::select-subs/items]) + inline-search-type (rf/subscribe [::inline-search.subs/type uid]) + caret-position (r/atom nil) + last-key-w-shift? (r/atom nil) + last-event (r/atom nil)] + (fn block-editor-render + [_block _state-hooks] + (let [editing? (or @show-edit? + @is-editing? + @inline-search-type)] + [:<> + [:> Content {:on-click (fn [e] (.. e stopPropagation) (rf/dispatch [:editing/uid uid]))} + ;; NOTE: komponentit forces reflow, likely a performance bottle neck + ;; When block is in editing mode or the editing DOM elements are rendered + (when editing? + [autosize/textarea {:value @read-value + :placeholder (or placeholder "") + :class ["block-input-textarea" (when (and (empty? @selected-items) @is-editing?) "is-editing")] + :style style + ;; :auto-focus true + :id (str "editable-uid-" uid) + :on-change (fn [e] (textarea-change e uid state-hooks)) + :on-paste (fn [e] (textarea-paste e uid state-hooks last-key-w-shift?)) + :on-key-down (fn [e] (textarea-keydown/textarea-key-down e uid state-hooks caret-position last-key-w-shift? last-event)) + :on-blur save-fn + :on-click (fn [e] (textarea-click e uid)) + :on-mouse-enter (fn [e] (textarea-mouse-enter e uid)) + :on-mouse-down (fn [e] (textarea-mouse-down e uid))}]) + [parse-and-render @read-value (or original-uid uid)]] + [autocomplete-search/inline-search-el block state-hooks last-event] + [autocomplete-slash/slash-menu-el block last-event]])))) + diff --git a/src/cljs/athens/views/blocks/internal_representation.cljs b/src/cljs/athens/views/blocks/internal_representation.cljs new file mode 100644 index 0000000000..cf83144dbd --- /dev/null +++ b/src/cljs/athens/views/blocks/internal_representation.cljs @@ -0,0 +1,211 @@ +(ns athens.views.blocks.internal-representation + (:refer-clojure :exclude [descendants]) + (:require + [athens.common-db :as common-db] + [athens.common.utils :as utils] + [athens.parser :as parser] + [clojure.set :as set] + [clojure.string :as str] + [clojure.walk :as walk] + [datascript.core :as d])) + + +(defn descendants + [{:block/keys [children properties]}] + (concat children (vals properties))) + + +(defn new-uids-map + "From Athens representation, extract the uids and create a mapping to new uids." + [tree] + (let [all-old-uids (mapcat #(->> % + (tree-seq common-db/has-descendants? descendants) + (mapv :block/uid)) + tree) + mapped-uids (reduce #(assoc %1 %2 (utils/gen-block-uid)) {} all-old-uids)] ; Replace with zipmap + mapped-uids)) + + +(defn string->block-lookups + "Given string s, compute the set of block refs and block embeds." + [s] + (let [ast (parser/parse-to-ast s) + block-ref-str->uid #(common-db/strip-markup % "((" "))") + block-lookups (into #{} + (map (fn [uid] uid)) + (common-db/extract-tag-values ast + #{:block-ref :component} + identity + #(let [arg (second %)] + (cond + ;; If it is a uid + (:from arg) + [:uid (block-ref-str->uid (:from arg))] + + ;; If it is a block embed + (= "[[embed]]:" + (first (str/split arg #" "))) + [:embed (block-ref-str->uid (last (str/split arg #" ")))]))))] + (set/union block-lookups))) + + +(defn wrap-uid-in-pattern + [pattern-type uid] + (if (= :embed pattern-type) + (str "{{[[embed]]: ((" uid "))}}") + (str "((" uid "))"))) + + +(defn update-strings-with-new-uids + "Takes a string of text and parses it for block refs, block embeds using regex. Then replace the matched pattern + with new refs. + + Could also use block ref information from the db instead (refs->uids->replacements). + Just something to keep in mind for the future if this gets hard to maintain. + + Pattern: Strings should not have a space before, after or in between the block uid + In the following example no pattern is valid: + (()) (( uid)) ((uid )) (( uid )) ((Uid with space)) + + To understand the regex pattern like lookback etc. checkout this link: https://stackoverflow.com/questions/2973436/regex-lookahead-lookbehind-and-atomic-groups + " + [block-string mapped-uids] + (let [parsed-uids (string->block-lookups block-string) + replaced-string (reduce (fn [block-string ref] + (let [embed? (= :embed + (first ref)) + uid (last ref) + current-ref (if embed? + (wrap-uid-in-pattern :embed uid) + (wrap-uid-in-pattern :uid uid)) + new-uid (get mapped-uids uid nil) + replace-with (cond + (and embed? new-uid) (wrap-uid-in-pattern :embed new-uid) + (and (not embed?) new-uid) (wrap-uid-in-pattern :uid new-uid) + :else current-ref)] + (if new-uid + (str/replace block-string + current-ref + replace-with) + block-string))) + block-string + parsed-uids)] + replaced-string)) + + +(defn walk-tree-to-replace + "Walk the internal representation and replace specific key-value pairs. This is inspired from the + `walk/postwalk-replace` implementation." + [tree mapped-uids replace-keyword] + (walk/postwalk (fn [x] + (if (and (vector? x) + (= (first x) replace-keyword)) + (cond + (= replace-keyword :block/uid) [:block/uid (mapped-uids (last x))] + (= replace-keyword :block/string) [:block/string (update-strings-with-new-uids (last x) + mapped-uids)]) + x)) + tree)) + + +(defn update-uids + "In the internal representation replace the uids and block-strings with new uids." + [tree mapped-uids] + (let [block-uids-replaced (walk-tree-to-replace tree + mapped-uids + :block/uid) + blocks-with-replaced-strings (walk-tree-to-replace block-uids-replaced + mapped-uids + :block/string)] + blocks-with-replaced-strings)) + + +(defn text-to-blocks + [text uid root-order] + (let [;; Split raw text by line + lines (->> (clojure.string/split-lines text) + (filter (comp not clojure.string/blank?))) + ;; Count left offset + left-counts (->> lines + (map #(re-find #"^\s*(-|\*)?" %)) + (map #(-> % first count))) + ;; Trim * - and whitespace + sanitize (map (fn [x] (clojure.string/replace x #"^\s*(-|\*)?\s*" "")) + lines) + ;; Generate blocks with tempids + blocks (map-indexed (fn [idx x] + {:db/id (dec (* -1 idx)) + :block/string x + :block/open true + :block/uid (utils/gen-block-uid)}) ; TODO(BUG): UID generation during resolution + sanitize) + top_uids [] + ;; Count blocks + n (count blocks) + ;; Assign parents + parents (loop [i 1 + res [(first blocks)]] + (if (= n i) + res + ;; Nested loop: worst-case O(n^2) + (recur (inc i) + (loop [j (dec i)] + ;; If j is negative, that means the loop has been compared to every previous line, + ;; and there are no previous lines with smaller left-offsets, which means block i + ;; should be a root block. + ;; Otherwise, block i's parent is the first block with a smaller left-offset + (if (neg? j) + (do + (conj top_uids (nth blocks i)) + (conj res (nth blocks i))) + (let [curr-count (nth left-counts i) + prev-count (nth left-counts j nil)] + (if (< prev-count curr-count) + (conj res {:db/id (:db/id (nth blocks j)) + :block/children (nth blocks i)}) + (recur (dec j))))))))) + ;; assign orders for children. order can be local or based on outer context where paste originated + ;; if local, look at order within group. if outer, use root-order + tx-data (->> (group-by :db/id parents) + ;; maps smaller than size 8 are ordered, larger are not https://stackoverflow.com/a/15500064 + (into (sorted-map-by >)) + (mapcat (fn [[_tempid blocks]] + (loop [order 0 + res [] + data blocks] + (let [{:block/keys [children] :as block} (first data)] + (cond + (nil? block) res + (nil? children) (let [new-res (conj res {:db/id [:block/uid uid] + :block/children (assoc block :block/order @root-order)})] + (swap! root-order inc) + (recur order + new-res + (next data))) + :else (recur (inc order) + (conj res (assoc-in block [:block/children :block/order] order)) + (next data))))))))] + (into [] tx-data))) + + +(defn text-to-internal-representation + [text] + (let [cpdb (common-db/create-conn) + copy-paste-block [{:db/id -1 + :block/uid "copy-paste-uid" + :block/children [] + :block/string "Block for copy paste"}] + tx-data (text-to-blocks text + "copy-paste-uid" + (atom 0))] + ;; transact first block + (d/transact! cpdb copy-paste-block) + + ;; transact the copied blocks + (d/transact! cpdb tx-data) + + ;; get the internal representation + ;; we need the eid of the copy-paste-block because that is where all the blocks are added to + ;; all the copied data will be added as the children of the `copy-paste-block` + (:block/children (common-db/get-internal-representation @cpdb + (:db/id (common-db/get-block @cpdb [:block/uid "copy-paste-uid"])))))) diff --git a/src/cljs/athens/views/blocks/reactions.cljs b/src/cljs/athens/views/blocks/reactions.cljs new file mode 100644 index 0000000000..3beebc45fe --- /dev/null +++ b/src/cljs/athens/views/blocks/reactions.cljs @@ -0,0 +1,89 @@ +(ns athens.views.blocks.reactions + (:require + ["@chakra-ui/react" :refer [Box + Tooltip + VStack + HStack + MenuItem + MenuGroup + Text]] + [athens.common-db :as common-db] + [athens.common-events.graph.ops :as graph-ops] + [athens.db :as db] + [re-frame.core :as rf] + [reagent.core :as r])) + + +(def common-reactions ["👍" "👎" "❤️" "🔥" "😂" "😲" "😢" "😡"]) + + +(defn toggle-reaction + "Toggle reaction on block uid. Cleans up when toggling the last one off. + Stores emojis in the [:reactions/emojis reaction user-id] property path." + [id reaction user-id] + (rf/dispatch [:graph/update-in id [":reactions" reaction user-id] + (fn [db user-reaction-uid] + (let [user-reacted? (common-db/block-exists? db [:block/uid user-reaction-uid]) + reaction (when user-reacted? + (->> [:block/uid user-reaction-uid] + (common-db/get-parent-eid db) + (common-db/get-block db))) + reactions (when reaction + (->> (:db/id reaction) + (common-db/get-parent-eid db) + (common-db/get-block db))) + last-user-reaction? (= 1 (count (-> reaction :block/properties))) + last-reaction? (= 1 (count (-> reactions :block/properties)))] + [(cond + ;; This reaction doesn't exist yet, so we add it. + (not user-reacted?) + (graph-ops/build-block-save-op db user-reaction-uid "") + + ;; This was the last of all reactions, remove the reactions property + ;; on the parent. + (and last-user-reaction? last-reaction?) + (graph-ops/build-block-remove-op @db/dsdb (:block/uid reactions)) + + ;; This was the last user reaction of this type, but not the last + ;; of all reactions. Remove reaction block. + last-user-reaction? + (graph-ops/build-block-remove-op @db/dsdb (:block/uid reaction)) + + ;; Just remove this particular user reaction. + :else + (graph-ops/build-block-remove-op @db/dsdb user-reaction-uid))]))])) + + +(defn props->reactions + [props] + (->> (get props ":reactions") + :block/properties + (map (fn [[k {props :block/properties}]] + [k (->> props + (map (fn [[user-id block]] + [(-> block :block/edits last :event/time :time/ts) + user-id])) + (sort-by first) + (mapv second))])) + (sort-by first) + (into []))) + + +(defn reactions-menu-list-item + [props] + (let [{:keys [icon fn command]} props] + [:> Box {:display "inline-flex" :flex 1} + [:> Tooltip {:closeOnMouseDown true + :label (r/as-element [:> VStack {:align "center"} + [:> Text "React with '" icon "'"] + (when command [:> Text command])])} + [:> MenuItem {:justifyContent "center" :on-click #(fn)} icon]]])) + + +(defn reactions-menu-list + [uid user-id] + [:> MenuGroup {:title "Add reaction"} + [:> HStack {:spacing 0 :justifyContent "stretch"} + (for [reaction-icon common-reactions] + ^{:key reaction-icon} + [reactions-menu-list-item {:icon reaction-icon :fn #(toggle-reaction uid reaction-icon user-id)}])]]) diff --git a/src/cljs/athens/views/blocks/textarea_keydown.cljs b/src/cljs/athens/views/blocks/textarea_keydown.cljs new file mode 100644 index 0000000000..59a5659d49 --- /dev/null +++ b/src/cljs/athens/views/blocks/textarea_keydown.cljs @@ -0,0 +1,940 @@ +(ns athens.views.blocks.textarea-keydown + (:require + ["/components/Icons/Icons" :refer [TimeNowIcon PersonIcon CheckboxIcon CalendarNowIcon CalendarTomorrowIcon CalendarYesterdayIcon BlockEmbedIcon TemplateIcon HTMLEmbedIcon YoutubeIcon]] + [athens.common-db :as common-db] + [athens.common.utils :as common.utils] + [athens.dates :as dates] + [athens.db :as db] + [athens.events.inline-search :as inline-search.events] + [athens.events.selection :as select-events] + [athens.patterns :as patterns] + [athens.router :as router] + [athens.subs.inline-search :as inline-search.subs] + [athens.subs.selection :as select-subs] + [athens.util :as util :refer [scroll-if-needed get-caret-position shortcut-key?]] + [athens.views.blocks.internal-representation :as internal-representation] + [clojure.string :refer [replace-first blank? includes? lower-case]] + [goog.dom :refer [getElement]] + [goog.dom.selection :refer [setStart setEnd getText setCursorPosition getEndPoints]] + [goog.events.KeyCodes :refer [isCharacterKey]] + [goog.functions :refer [throttle]] + [re-frame.core :as rf :refer [dispatch dispatch-sync subscribe]]) + (:import + (goog.events + KeyCodes))) + + +;; Event Helpers + + +(defn modifier-keys + [e] + (let [shift (.. e -shiftKey) + meta (.. e -metaKey) + ctrl (.. e -ctrlKey) + alt (.. e -altKey)] + {:shift shift :meta meta :ctrl ctrl :alt alt})) + + +(defn get-end-points + [target] + (js->clj (getEndPoints target))) + + +(defn set-cursor-position + [target idx] + (setCursorPosition target idx)) + + +(defn destruct-target + "Get the current value of a textarea (`:value`) and + the start (`:start`) and end (`:end`) index of the selection. + Furthermore, split the selection into three parts: + text before the selection (`:head`), + the selection itself (`:selection`), + and text after the selection (`:tail`)." + [target] + (let [value (.. target -value) + [start end] (get-end-points target) + selection (getText target) + head (subs value 0 start) + tail (subs value end)] + (merge {:value value} + {:start start :end end} + {:head head :tail tail} + {:selection selection}))) + + +(defn destruct-key-down + [e] + (let [key (.. e -key) + key-code (.. e -keyCode) + target (.. e -target) + value (.. target -value) + event {:key key :key-code key-code :target target :value value} + modifiers (modifier-keys e) + target-data (destruct-target target)] + (merge modifiers + event + target-data))) + + +(def ARROW-KEYS + #{KeyCodes.UP + KeyCodes.LEFT + KeyCodes.DOWN + KeyCodes.RIGHT}) + + +(defn arrow-key-direction + [e] + (contains? ARROW-KEYS (.. e -keyCode))) + + +;; Dropdown: inline-search and slash commands +;; TODO: some expansions require caret placement after +(defn slash-options + [] + (cond-> + [["Add Todo" CheckboxIcon "{{[[TODO]]}} " "cmd-enter" nil] + ["Current Time" TimeNowIcon (fn [] (.. (js/Date.) (toLocaleTimeString [] (clj->js {"timeStyle" "short"})))) nil nil] + ["Today" CalendarNowIcon (fn [] (str "[[" (:title (dates/get-day 0)) "]] ")) nil nil] + ["Tomorrow" CalendarTomorrowIcon (fn [] (str "[[" (:title (dates/get-day -1)) "]]")) nil nil] + ["Yesterday" CalendarYesterdayIcon (fn [] (str "[[" (:title (dates/get-day 1)) "]]")) nil nil] + ["YouTube Embed" YoutubeIcon "{{[[youtube]]: }}" nil 2] + ["iframe Embed" HTMLEmbedIcon "{{iframe: }}" nil 2] + ["Block Embed" BlockEmbedIcon "{{[[embed]]: (())}}" nil 4] + ["Template" TemplateIcon ";;" nil nil] + ["Property" TemplateIcon "::" nil nil]] + @(subscribe [:db-picker/remote-db?]) + (conj (let [username (:username @(rf/subscribe [:presence/current-user]))] + [(str "Me (" username ")") PersonIcon (fn [] (str "[[" username "]]")) nil nil])))) + + +;; [ "Block Embed" #(str "[[" (:title (dates/get-day 1)) "]]")] +;; [DateRange "Date Picker"] +;; [Attachment "Upload Image or File"] +;; [ExposurePlus1 "Word Count"] + + +(defn filter-slash-options + [query] + (if (blank? query) + (slash-options) + (filterv (fn [[text]] + (includes? (lower-case text) (lower-case query))) + (slash-options)))) + + +(defn search-or-create-node-title + [query] + (let [results (db/search-in-node-title query) + create (if (and (seq query) + (not (some #(= query (:node/title %)) results))) + [{:text (str "Create property: " query)}] + [])] + (into create results))) + + +(defn update-query + "Used by backspace and write-char. + write-char appends key character. Pass empty string during backspace. + query-start is determined by doing a greedy regex find up to head. + Head goes up to the text caret position." + [block-uid head key type] + (let [query-fn (case type + :block db/search-in-block-content + :page db/search-in-node-title + :hashtag db/search-in-node-title + :template db/search-in-block-content + :property search-or-create-node-title + :slash filter-slash-options) + regex (case type + :block #"(?s).*\(\(" + :page #"(?s).*\[\[" + :hashtag #"(?s).*#" + :template #"(?s).*;;" + :property #"(?s)[^:]*::" + :slash #"(?s).*/") + find (re-find regex head) + query-start-idx (count find) + new-query (str (subs head query-start-idx) key) + results (query-fn new-query)] + (if (and (= type :slash) (empty? results)) + (rf/dispatch [::inline-search.events/close! block-uid]) + (do + (rf/dispatch [::inline-search.events/set-index! block-uid 0]) + (rf/dispatch [::inline-search.events/set-results! block-uid results]) + (rf/dispatch [::inline-search.events/set-query! block-uid new-query]))))) + + +;; https://developer.mozilla.org/en-US/docs/Web/API/Document/execCommand +;; textarea setval will lose ability to undo/redo + +;; execCommand is obsolete: +;; be wary before updating electron - as chromium might drop support for execCommand +;; electron 11 - uses chromium < 90(latest) which supports execCommand +(defn replace-selection-with + "replace the current selection with `new-text`" + [new-text] + (.execCommand js/document "insertText" false new-text)) + + +(defn set-selection + "select text from `start` to `end` in the textarea `target`" + [target start end] + (setStart target start) + (setEnd target end)) + + +;; 1- if no results, just hide slash commands so this doesnt get triggered +;; 2- if results, do find and replace properly +(defn auto-complete-slash + ;; this signature is called to process keyboard events. + ([block-uid e] + (let [target (.. e -target) + inline-search-index (rf/subscribe [::inline-search.subs/index block-uid]) + inline-search-results (rf/subscribe [::inline-search.subs/results block-uid]) + item (nth @inline-search-results @inline-search-index)] + (auto-complete-slash block-uid target item))) + ;; here comes the autocompletion logic itself, + ;; independent of the input method the user used. + ;; `expansion` is the identifier of the page or block + ;; (i.e., UID of block or title of page) that shall be + ;; inserted. + ([block-uid target item] + (let [{:keys [start head]} (destruct-target target) + [caption _ expansion _ pos] item + expand (if (fn? expansion) (expansion) expansion) + ;; the regex is evaluated greedily, yielding the last + ;; occurrence in head (head = text up to cursor) + start-idx (dec (count (re-find #"(?s).*/" head)))] + (rf/dispatch [::inline-search.events/close! block-uid]) + + (set-selection target start-idx start) + (replace-selection-with expand) + (when pos + (let [new-idx (+ start-idx (count expand) (- pos))] + (set-cursor-position target new-idx) + (when (= caption "Block Embed") + (rf/dispatch [::inline-search.events/set-type! block-uid :block]) + (rf/dispatch [::inline-search.events/clear-results! block-uid]) + (rf/dispatch [::inline-search.events/clear-query! block-uid])))) + (when (= caption "Template") + (rf/dispatch [::inline-search.events/set-type! block-uid :template]) + (rf/dispatch [::inline-search.events/clear-results! block-uid]) + (rf/dispatch [::inline-search.events/clear-query! block-uid])) + (when (= caption "Property") + (rf/dispatch [::inline-search.events/set-type! block-uid :property]) + (rf/dispatch [::inline-search.events/clear-results! block-uid]) + (rf/dispatch [::inline-search.events/clear-query! block-uid]))))) + + +;; see `auto-complete-slash` for how this arity-overloaded +;; function is used. +(defn auto-complete-hashtag + ([block-uid state-hooks e] + (let [inline-search-index (rf/subscribe [::inline-search.subs/index block-uid]) + inline-search-results (rf/subscribe [::inline-search.subs/results block-uid]) + target (.. e -target) + {:keys [node/title block/uid]} (nth @inline-search-results @inline-search-index nil) + expansion (or title uid)] + (auto-complete-hashtag block-uid state-hooks target expansion))) + + ([block-uid {:as _state-hooks} target expansion] + (let [{:keys [start head]} (destruct-target target) + start-idx (count (re-find #"(?s).*#" head))] + (if (nil? expansion) + (rf/dispatch [::inline-search.events/close! block-uid]) + (do + (set-selection target start-idx start) + (replace-selection-with (str "[[" expansion "]]")) + (rf/dispatch [::inline-search.events/close! block-uid])))))) + + +;; see `auto-complete-slash` for how this arity-overloaded +;; function is used. +(defn auto-complete-inline + ([block-uid _state-hooks e] + (let [inline-search-index (rf/subscribe [::inline-search.subs/index block-uid]) + inline-search-results (rf/subscribe [::inline-search.subs/results block-uid]) + ;; (nth results (or index 0) nil) returns the index-th result + ;; If (= index nil) or index is out of bounds, returns nil + ;; For example, index can be nil if (= results []) + {:keys [node/title block/uid]} (nth @inline-search-results (or @inline-search-index 0) nil) + target (.. e -target) + expansion (or title uid)] + (auto-complete-inline block-uid _state-hooks target expansion))) + + ([block-uid _state-hooks target expansion] + (let [query @(rf/subscribe [::inline-search.subs/query block-uid]) + {:keys [end]} (destruct-target target) + query (patterns/escape-str query)] + + ;; assumption: cursor or selection is immediately before the closing brackets + + (when (not (nil? expansion)) + (set-selection target (- end (count query)) end) + (replace-selection-with expansion)) + (let [new-cursor-pos (+ end + (- (count query)) + ;; Add the expansion count if we have it, but if we + ;; don't just add back the query itself so the cursor + ;; doesn't move back. + (count (or expansion + query)) + 2)] + (set-cursor-position target new-cursor-pos)) + (rf/dispatch [::inline-search.events/close! block-uid])))) + + +;; see `auto-complete-slash` for how this arity-overloaded +;; function is used. +(defn auto-complete-template + ([block-uid {:as state-hooks} e] + (let [inline-search-index (rf/subscribe [::inline-search.subs/index block-uid]) + inline-search-results (rf/subscribe [::inline-search.subs/results block-uid]) + target (.. e -target) + {:keys [block/uid]} (nth @inline-search-results @inline-search-index nil) + expansion uid] + (auto-complete-template block-uid state-hooks target expansion))) + + ([block-uid {:keys [read-value] :as _state-hooks} target expansion] + (let [{:keys [start head]} (destruct-target target) + start-idx (count (re-find #"(?s).*;;" head)) + source-ir (->> [:block/uid expansion] + (common-db/get-internal-representation @db/dsdb) + :block/children) + target-ir (->> source-ir + internal-representation/new-uids-map + (internal-representation/update-uids source-ir) + (into []))] + (if (or (nil? expansion) + (nil? target-ir)) + (rf/dispatch [::inline-search.events/close! block-uid]) + (do + (set-selection target (- start-idx 2) start) + (replace-selection-with "") + (dispatch [:paste-internal block-uid @read-value target-ir]) + (rf/dispatch [::inline-search.events/close! block-uid])))))) + + +;; see `auto-complete-slash` for how this arity-overloaded +;; function is used. +(defn auto-complete-property + ([block-uid {:as state-hooks} e] + (let [inline-search-index (rf/subscribe [::inline-search.subs/index block-uid]) + inline-search-results (rf/subscribe [::inline-search.subs/results block-uid]) + target (.. e -target) + {:keys [block/uid]} (nth @inline-search-results @inline-search-index nil) + expansion uid] + (auto-complete-property block-uid state-hooks target expansion))) + + ([block-uid {:keys [read-value] :as _state-hooks} target expansion] + (let [{:keys [start head]} (destruct-target target) + start-idx (count (re-find #"(?s)[^:]*::" head)) + {:keys [end]} (destruct-target target) + parent-uid (->> [:block/uid block-uid] + (common-db/get-parent @db/dsdb) + :block/uid) + query @(rf/subscribe [::inline-search.subs/query block-uid]) + title (or (common-db/get-page-title @db/dsdb expansion) query)] + (if (or (empty? title) + (nil? parent-uid)) + (rf/dispatch [::inline-search.events/close! block-uid]) + (do + (set-selection target (- start-idx 2) start) + (replace-selection-with "") + (dispatch [:block/move {:source-uid block-uid + :target-uid parent-uid + :target-rel {:page/title title} + :local-string (str (subs @read-value 0 start-idx) + (subs @read-value end))}]) + (rf/dispatch [::inline-search.events/close! block-uid])))))) + + +;; Arrow Keys + + +(defn block-start? + [e] + (let [[start _] (get-end-points (.. e -target))] + (zero? start))) + + +(defn block-end? + [e] + (let [{:keys [value end]} (destruct-key-down e)] + (= end (count value)))) + + +(defn dec-cycle + [min max idx] + (if (<= idx min) + max + (dec idx))) + + +(defn inc-cycle + [min max idx] + (if (>= idx max) + min + (inc idx))) + + +(defn cycle-list + "If user has slash menu or inline search dropdown open: + - pressing down increments index + - pressing up decrements index + 0 is typically min index + max index is collection length minus 1" + [min max idx up? down?] + (let [f (cond up? dec-cycle + down? inc-cycle)] + (f min max idx))) + + +(defn max-idx + [coll] + (-> coll count dec)) + + +(defn handle-arrow-key + [e uid {:keys [keyboard-navigation? + navigation-uid] + :or {keyboard-navigation? true} + :as _state-hooks} + caret-position] + (let [{:keys [key-code + shift + meta + ctrl + target + selection]} (destruct-key-down e) + selection? (not (blank? selection)) + start? (block-start? e) + end? (block-end? e) + type @(rf/subscribe [::inline-search.subs/type uid]) + results @(rf/subscribe [::inline-search.subs/results uid]) + index @(rf/subscribe [::inline-search.subs/index uid]) + textarea-height (.. target -offsetHeight) ; this height is accurate, but caret-position height is not updating + {:keys [top height]} @caret-position + rows (js/Math.round (/ textarea-height height)) + row (js/Math.ceil (/ top height)) + top-row? (= row 1) + bottom-row? (= row rows) + up? (= key-code KeyCodes.UP) + down? (= key-code KeyCodes.DOWN) + left? (= key-code KeyCodes.LEFT) + right? (= key-code KeyCodes.RIGHT) + [char-offset _] (get-end-points target)] + + (cond + ;; Shift: select block if leaving block content boundaries (top or bottom rows). Otherwise select textarea text (default) + shift (cond + left? nil + right? nil + (or (and up? top-row?) + (and down? bottom-row?)) (do + (.. target blur) + (dispatch [::select-events/add-item uid (cond + up? :first + down? :last)]))) + + ;; Control (Command on mac): fold or unfold blocks + (shortcut-key? meta ctrl) + (cond + left? nil + right? nil + (or up? down?) (let [[uid _] (db/uid-and-embed-id uid) + new-open-state (cond + up? false + down? true) + event [:block/open {:block-uid uid + :open? new-open-state}]] + (.. e preventDefault) + (dispatch event))) + + ;; Type, one of #{:slash :block :page}: If slash commands or inline search is open, cycle through options + type (cond + (or left? right?) (do + (rf/dispatch [::inline-search.events/close! uid]) + (rf/dispatch [::inline-search.events/set-index! uid 0])) + (or up? down?) (let [cur-index index + min-index 0 + max-index (max-idx results) + next-index (cycle-list min-index max-index cur-index up? down?) + container-el (getElement "dropdown-menu") + target-el (getElement (str "dropdown-item-" next-index))] + (.. e preventDefault) + (rf/dispatch [::inline-search.events/set-index! uid next-index]) + (scroll-if-needed target-el container-el))) + + selection? nil + + ;; Else: navigate across blocks + ;; FIX: always navigates up or down for header because get-caret-position for some reason returns the wrong value for top + + ;; going LEFT at **0th index** should always go to **last index** of block **above** + ;; last index is special - always go to last index when going up or down + + + (or (and left? start?) + (and up? end?)) (when keyboard-navigation? + (.. e preventDefault) + (dispatch [:up (or navigation-uid uid) :end])) + + (and down? end?) (when keyboard-navigation? + (.. e preventDefault) + (dispatch [:down (or navigation-uid uid) :end])) + + ;; going RIGHT at last index should always go to index 0 of block below + (and right? end?) (when keyboard-navigation? + (.. e preventDefault) + (dispatch [:down (or navigation-uid uid) 0])) + + ;; index 0 is special - always go to index 0 when going up or down + ;; when caret is anywhere between start and end preserve the position and offset by char + (and up? top-row?) (when keyboard-navigation? + (.. e preventDefault) + (dispatch [:up (or navigation-uid uid) char-offset])) + (and down? bottom-row?) (when keyboard-navigation? + (.. e preventDefault) + (dispatch [:down (or navigation-uid uid) char-offset]))))) + + +;; Tab + +(defn handle-tab + "Bug: indenting sets the cursor position to 0, likely because a new textarea element is created on the DOM. Set selection appropriately. + See :indent event for why value must be passed as well." + [e uid {:keys [read-value tab-handler navigation-uid] :as _state-hooks}] + (.. e preventDefault) + (let [{:keys [shift] :as d-key-down} (destruct-key-down e) + selected-items @(subscribe [::select-subs/items]) + current-root-uid @(subscribe [:current-route/uid]) + [uid embed-id] (db/uid-and-embed-id uid) + local-string @read-value] + (when (empty? selected-items) + (if (fn? tab-handler) + (tab-handler uid embed-id d-key-down) + (if shift + (dispatch [:unindent {:uid (or navigation-uid uid) + :editing-uid uid + :d-key-down d-key-down + :context-root-uid current-root-uid + :embed-id embed-id + :local-string local-string}]) + (dispatch [:indent {:uid (or navigation-uid uid) + :editing-uid uid + :d-key-down d-key-down + :local-string local-string}])))))) + + +(defn handle-escape + "BUG: escape is fired 24 times for some reason." + [e uid {:keys [esc-handler] :as _state-hooks}] + (.. e preventDefault) + (if (fn? esc-handler) + (esc-handler e uid) + (if @(rf/subscribe [::inline-search.subs/type uid]) + (rf/dispatch [::inline-search.events/close! uid]) + (dispatch [:editing/uid nil])))) + + +(def throttled-dispatch-sync + (throttle #(dispatch-sync %) 50)) + + +(defn handle-enter + [e uid {:keys [enter-handler navigation-uid] :as state-hooks}] + (let [{:keys [shift + ctrl + meta + value + start] + :as d-key-down} (destruct-key-down e) + type @(rf/subscribe [::inline-search.subs/type uid])] + (.. e preventDefault) + (cond + type (case type + :slash (auto-complete-slash uid e) + :page (auto-complete-inline uid state-hooks e) + :block (auto-complete-inline uid state-hooks e) + :hashtag (auto-complete-hashtag uid state-hooks e) + :template (auto-complete-template uid state-hooks e) + :property (auto-complete-property uid state-hooks e)) + ;; shift-enter: add line break to textarea and move cursor to the next line. + shift (replace-selection-with "\n") + ;; cmd-enter: cycle todo states, then move cursor to the end of the line. + ;; 13 is the length of the {{[[TODO]]}} and {{[[DONE]]}} string + ;; this trick depends on the fact that they are of the same length. + (shortcut-key? meta ctrl) (let [todo-prefix "{{[[TODO]]}} " + done-prefix "{{[[DONE]]}} " + no-prefix "" + first (subs value 0 13) + current-prefix (cond (= first todo-prefix) todo-prefix + (= first done-prefix) done-prefix + :else no-prefix) + new-prefix (cond (= current-prefix no-prefix) todo-prefix + (= current-prefix todo-prefix) done-prefix + (= current-prefix done-prefix) no-prefix) + new-cursor-position (+ start (- (count current-prefix)) (count new-prefix))] + (set-selection (.. e -target) 0 (count current-prefix)) + (replace-selection-with new-prefix) + (set-cursor-position (.. e -target) new-cursor-position)) + ;; default: may mutate blocks, important action, no delay on 1st event, then throttled + :else (if (fn? enter-handler) + (enter-handler uid d-key-down) + (throttled-dispatch-sync [:enter uid d-key-down navigation-uid]))))) + + +;; Pair Chars: auto-balance for backspace and writing chars + +(def PAIR-CHARS + {"(" ")" + "[" "]" + "{" "}" + "\"" "\""}) + + +;; "`" "`" +;; "*" "*" +;; "_" "_"}) + + +(defn surround + "https://github.com/tpope/vim-surround" + [selection around] + (if-let [complement (get PAIR-CHARS around)] + (str around selection complement) + (str around selection around))) + + +(defn surround-and-set + ;; Default to n=2 because it's more common. + ([e surround-text] + (surround-and-set e surround-text 2)) + ([e surround-text n] + (let [{:keys [selection start end target]} (destruct-key-down e) + selection? (not= start end)] + (.preventDefault e) + (.stopPropagation e) + (let [selection (surround selection surround-text)] + + (replace-selection-with selection) + (if selection? + (set-selection target (+ n start) (+ n end)) + (set-cursor-position target (+ start n))))))) + + +;; TODO: put text caret in correct position +(defn handle-shortcuts + [e uid {:keys [save-fn] :as _state-hooks}] + (let [{:keys [key-code head tail selection target value shift alt]} (destruct-key-down e)] + (cond + (and (= key-code KeyCodes.A) (= selection value)) (let [closest-node-page (.. target (closest ".node-page")) + closest-block-page (.. target (closest ".block-page")) + closest (or closest-node-page closest-block-page) + block (db/get-block [:block/uid (.getAttribute closest "data-uid")]) + children (->> (:block/children block) + (sort-by :block/order) + (mapv :block/uid))] + (dispatch [::select-events/set-items children])) + + (= key-code KeyCodes.B) (surround-and-set e "**") + + (= key-code KeyCodes.I) (surround-and-set e "*" 1) + + (= key-code KeyCodes.Y) (surround-and-set e "~~") + + (= key-code KeyCodes.U) (surround-and-set e "--") + + (= key-code KeyCodes.H) (surround-and-set e "^^") + + ;; if alt is pressed, zoom out of current block page + ;; if caret within [[brackets]] or #[[brackets]], navigate to that page + ;; if caret on a #hashtag, navigate to that page + ;; if caret within ((uid)), navigate to that uid + ;; otherwise zoom into current block + + (= key-code KeyCodes.O) (let [[uid _] (db/uid-and-embed-id uid) + link (str (replace-first head #"(?s)(.*)\[\[" "") + (replace-first tail #"(?s)\]\](.*)" "")) + hashtag (str (replace-first head #"(?s).*#" "") + (replace-first tail #"(?s)\s(.*)" "")) + block-ref (str (replace-first head #"(?s)(.*)\(\(" "") + (replace-first tail #"(?s)\)\)(.*)" ""))] + + (.. e preventDefault) + + ;; save block before navigating away + (save-fn) + + (cond + alt + (when-let [parent-uid (->> [:block/uid @(subscribe [:current-route/uid])] + (common-db/get-parent-eid @db/dsdb) + second)] + (rf/dispatch [:reporting/navigation {:source :kbd-ctrl-alt-o + :target :block + :pane (if shift + :right-pane + :main-pane)}]) + (router/navigate-uid parent-uid e)) + + + (and (re-find #"(?s)\[\[" head) + (re-find #"(?s)\]\]" tail) + (nil? (re-find #"(?s)\[" link)) + (nil? (re-find #"(?s)\]" link))) + (let [eid (db/e-by-av :node/title link)] + (if eid + (do + (rf/dispatch [:reporting/navigation {:source :kbd-ctrl-o + :target :page + :pane (if shift + :right-pane + :main-pane)}]) + (router/navigate-page link e)) + (let [block-uid (common.utils/gen-block-uid)] + (.blur target) + (dispatch [:page/new {:title link + :block-uid block-uid + :shift? shift + :source :kbd-ctrl-o}])))) + + ;; same logic as link + (and (re-find #"(?s)#" head) + (re-find #"(?s)\s" tail)) + (let [eid (db/e-by-av :node/title hashtag)] + (if eid + (do + (rf/dispatch [:reporting/navigation {:source :kbd-ctrl-o + :target :hashtag + :pane (if shift + :right-pane + :main-pane)}]) + (router/navigate-page hashtag e)) + (let [block-uid (common.utils/gen-block-uid)] + (.blur target) + (dispatch [:page/new {:title link + :block-uid block-uid + :shift? shift + :source :kbd-ctrl-o}])))) + + (and (re-find #"(?s)\(\(" head) + (re-find #"(?s)\)\)" tail) + (nil? (re-find #"(?s)\(" block-ref)) + (nil? (re-find #"(?s)\)" block-ref)) + (db/e-by-av :block/uid block-ref)) + (do + (rf/dispatch [:reporting/navigation {:source :kbd-ctrl-o + :target :block + :pane (if shift + :right-pane + :main-pane)}]) + (router/navigate-uid block-ref e)) + + :else (do + (rf/dispatch [:reporting/navigation {:source :kbd-ctrl-o + :target :block + :pane (if shift + :right-pane + :main-pane)}]) + (router/navigate-uid uid e))))))) + + +(defn pair-char? + [e] + (let [{:keys [key]} (destruct-key-down e) + pair-char-set (-> PAIR-CHARS + seq + flatten + set)] + (pair-char-set key))) + + +(defn handle-pair-char + [e uid {:keys [read-value]}] + (let [{:keys [key target start end selection value]} (destruct-key-down e) + close-pair (get PAIR-CHARS key) + lookbehind-char (nth value start nil)] + (.. e preventDefault) + + (cond + ;; when close char, increment caret index without writing more + (some #(= % key lookbehind-char) + [")" "}" "\"" "]"]) (do (set-cursor-position target (inc start)) + (rf/dispatch [::inline-search.events/close! uid])) + + (= selection "") (let [new-idx (inc start)] + (replace-selection-with (str key close-pair)) + (set-cursor-position target new-idx) + (when (>= (count @read-value) 4) + (let [four-char (subs @read-value (dec start) (+ start 3)) + double-brackets? (= "[[]]" four-char) + double-parens? (= "(())" four-char) + type (cond double-brackets? :page + double-parens? :block)] + (when type + (rf/dispatch [::inline-search.events/set-type! uid type]) + ;; It's cleaner to explicitly set this to nil to avoid + ;; seemingly nondeterministic behavior caused by a + ;; previous value of :search/index + (rf/dispatch [::inline-search.events/set-index! uid nil]) + (rf/dispatch [::inline-search.events/clear-results! uid]) + (rf/dispatch [::inline-search.events/clear-query! uid]))))) + + (not= selection "") (let [surround-selection (surround selection key)] + (replace-selection-with surround-selection) + (set-selection target (inc start) (inc end)) + (let [four-char (str (subs @read-value (dec start) (inc start)) + (subs @read-value (+ end 1) (+ end 3))) + double-brackets? (= "[[]]" four-char) + double-parens? (= "(())" four-char) + type (cond double-brackets? :page + double-parens? :block) + query-fn (cond double-brackets? db/search-in-node-title + double-parens? db/search-in-block-content)] + (when type + (rf/dispatch [::inline-search.events/set-type! uid type]) + (rf/dispatch [::inline-search.events/set-index! uid 0]) + (rf/dispatch [::inline-search.events/set-results! uid (query-fn selection)]) + (rf/dispatch [::inline-search.events/set-query! uid selection]))))))) + + +;; Backspace + +(defn handle-backspace + [e uid {:keys [backspace-handler] :as _state-hooks}] + (let [{:keys [start value target end]} (destruct-key-down e) + no-selection? (= start end) + sub-str (subs value (dec start) (inc start)) + possible-pair (#{"[]" "{}" "()"} sub-str) + head (subs value 0 (dec start)) + type @(rf/subscribe [::inline-search.subs/type uid]) + look-behind-char (nth value (dec start) nil)] + + (cond + (and (block-start? e) no-selection?) (if (fn? backspace-handler) + (backspace-handler uid value) + (dispatch [:backspace uid value])) + ;; pair char: hide inline search and auto-balance + possible-pair (do + (.. e preventDefault) + (rf/dispatch [::inline-search.events/close! uid]) + (set-selection target (dec start) (inc start)) + (replace-selection-with "")) + + ;; slash: close dropdown + (and (= "/" look-behind-char) (= type :slash)) (rf/dispatch [::inline-search.events/close! uid]) + ;; hashtag: close dropdown + (and (= "#" look-behind-char) (= type :hashtag)) (rf/dispatch [::inline-search.events/close! uid]) + ;; semicolon: close dropdown + (and (= ";" look-behind-char) (= type :template)) (rf/dispatch [::inline-search.events/close! uid]) + ;; colon: close dropdown + (and (= ":" look-behind-char) (= type :property)) (rf/dispatch [::inline-search.events/close! uid]) + ;; dropdown is open: update query + type (update-query uid head "" type)))) + + +;; Character: for queries + +(defn is-character-key? + "Closure returns true even when using modifier keys. We do not make that assumption." + [e] + (let [{:keys [meta ctrl alt key-code]} (destruct-key-down e)] + (and (not meta) (not ctrl) (not alt) + (isCharacterKey key-code)))) + + +(defn write-char + "When user types /, trigger slash menu. + If user writes a character while there is a slash/type, update query and results." + [e uid] + (let [{:keys [head key value start]} (destruct-key-down e) + type @(rf/subscribe [::inline-search.subs/type uid]) + look-behind-char (nth value (dec start) nil)] + (cond + (and (= key " ") (= type :hashtag)) (do + (rf/dispatch [::inline-search.events/close! uid]) + (rf/dispatch [::inline-search.events/clear-results! uid])) + (and (= key "/") (nil? type)) (do + (rf/dispatch [::inline-search.events/set-type! uid :slash]) + (rf/dispatch [::inline-search.events/set-index! uid 0]) + (rf/dispatch [::inline-search.events/set-results! uid (slash-options)]) + (rf/dispatch [::inline-search.events/clear-query! uid])) + (and (= key "#") (nil? type)) (do + (rf/dispatch [::inline-search.events/set-type! uid :hashtag]) + (rf/dispatch [::inline-search.events/set-index! uid 0]) + (rf/dispatch [::inline-search.events/clear-results! uid]) + (rf/dispatch [::inline-search.events/clear-query! uid])) + (and (= key ";" look-behind-char) + (nil? type)) (do + (rf/dispatch [::inline-search.events/set-type! uid :template]) + (rf/dispatch [::inline-search.events/set-index! uid 0]) + (rf/dispatch [::inline-search.events/clear-results! uid]) + (rf/dispatch [::inline-search.events/clear-query! uid])) + (and @(rf/subscribe [:feature-flags/enabled? :properties]) + (= key ":" look-behind-char) + (nil? type)) (do + (rf/dispatch [::inline-search.events/set-type! uid :property]) + (rf/dispatch [::inline-search.events/set-index! uid 0]) + (rf/dispatch [::inline-search.events/clear-results! uid]) + (rf/dispatch [::inline-search.events/clear-query! uid])) + + type (update-query uid head key type)))) + + +(defn handle-delete + "Delete has the same behavior as pressing backspace on the next block." + [e uid {:keys [read-value read-old-value delete-handler]}] + (let [{:keys [start end value] :as d-key-down} (destruct-key-down e)] + (if (fn? delete-handler) + (delete-handler uid d-key-down) + (let [no-selection? (= start end) + end? (= end (count value)) + ;; using original block uid(o-uid) data to get next block + [o-uid embed-id] (db/uid-and-embed-id uid) + next-block-uid (db/next-block-uid o-uid)] + (when (and no-selection? end? next-block-uid) + (let [next-block (db/get-block [:block/uid (-> next-block-uid db/uid-and-embed-id first)])] + (dispatch [:backspace + (cond-> next-block-uid + embed-id (str "-embed-" embed-id)) + (:block/string next-block) + (when-not (= @read-value @read-old-value) + @read-value)]))))))) + + +(defn textarea-key-down + [e uid {:as state-hooks} caret-position last-key-w-shift? last-event] + ;; don't process key events from block that lost focus (quick Enter & Tab) + (when @(subscribe [:editing/is-editing uid]) + (let [d-event (destruct-key-down e) + {:keys [meta ctrl shift key-code]} d-event] + + (reset! last-event e) + (reset! last-key-w-shift? shift) + + ;; update caret position for search dropdowns and for up/down + (when (nil? @(rf/subscribe [::inline-search.subs/type uid])) + (let [caret-pos (get-caret-position (.. e -target))] + (reset! caret-position caret-pos))) + + ;; dispatch center + ;; only when nothing is selected or duplicate/events dispatched + ;; after some ops(like delete) can cause errors + (when (empty? @(subscribe [::select-subs/items])) + (cond + (arrow-key-direction e) (handle-arrow-key e uid state-hooks caret-position) + (pair-char? e) (handle-pair-char e uid state-hooks) + (= key-code KeyCodes.TAB) (handle-tab e uid state-hooks) + (= key-code KeyCodes.ENTER) (handle-enter e uid state-hooks) + (= key-code KeyCodes.BACKSPACE) (handle-backspace e uid state-hooks) + (= key-code KeyCodes.DELETE) (handle-delete e uid state-hooks) + (= key-code KeyCodes.ESC) (handle-escape e uid state-hooks) + (shortcut-key? meta ctrl) (handle-shortcuts e uid state-hooks) + (is-character-key? e) (write-char e uid)))))) + diff --git a/src/cljs/athens/views/comments/core.cljs b/src/cljs/athens/views/comments/core.cljs new file mode 100644 index 0000000000..0c7ca83adc --- /dev/null +++ b/src/cljs/athens/views/comments/core.cljs @@ -0,0 +1,324 @@ +(ns athens.views.comments.core + (:require + [athens.common-db :as common-db] + [athens.common-events :as common-events] + [athens.common-events.bfs :as bfs] + [athens.common-events.graph.composite :as composite] + [athens.common-events.graph.ops :as graph-ops] + [athens.common.utils :as common.utils] + [athens.db :as db] + [athens.views.notifications.core :refer [get-userpage-data new-notification]] + [re-frame.core :as rf])) + + +(defn enabled? + [] + (:comments @(rf/subscribe [:feature-flags]))) + + +(rf/reg-sub + :comment/show-editor? + (fn [db [_ uid]] + (= uid (:comment/show-editor db)))) + + +(rf/reg-event-fx + :comment/show-editor + (fn [{:keys [db]} [_ uid]] + {:db (assoc db :comment/show-editor uid)})) + + +(rf/reg-event-fx + :comment/hide-editor + (fn [{:keys [db]} [_]] + {:db (assoc db :comment/show-editor nil)})) + + +(rf/reg-sub + :comment/show-comments? + (fn [db [_]] + (= true (:comment/show-comments? db)))) + + +(rf/reg-event-db + :comment/toggle-comments + (fn [db [_]] + (update db :comment/show-comments? not))) + + +(defn thread-child->comment + [comment-block] + (let [{:block/keys [uid string create properties]} comment-block] + {:block/uid uid + :string string + :author (-> create :event/auth :presence/id) + :time (-> create :event/time :time/ts) + :edited? (boolean (get properties "athens/comment/edited"))})) + + +(defn add-is-follow-up? + [comments-data] + (let [is-followups (for [i (range (count comments-data))] + (let [prev-item (nth comments-data (dec i) nil) + curr-item (nth comments-data i) + {prev-author :author prev-time :time} prev-item + {curr-author :author curr-time :time} curr-item + time-delta (- curr-time prev-time) + ;; hard-code to 30 minutes for now (* 1000 60 30) + greater-than-time-delta? (> time-delta 1800000)] + {:is-followup? (cond + (zero? i) false + greater-than-time-delta? false + (and (= prev-author curr-author) + (seq prev-author) + (seq curr-author)) true + :else false)}))] + (mapv merge comments-data is-followups))) + + +(defn get-comments-in-thread + [db thread-uid] + (->> (common-db/get-block-document db [:block/uid thread-uid]) + :block/children + (map thread-child->comment) + add-is-follow-up?)) + + +(defn get-comment-thread-uid + [_db parent-block-uid] + (-> (common-db/get-block-property-document @db/dsdb [:block/uid parent-block-uid]) + ;; TODO Multiple threads + ;; I think for multiple we would have a top level property for all threads + ;; Individual threads are child of the property + (get ":comment/threads") + :block/uid)) + + +(defn new-comment + [db thread-uid comment-string] + (let [comment-uid (common.utils/gen-block-uid)] + {:comment-uid comment-uid + :comment-op (->> (bfs/internal-representation->atomic-ops db + [#:block{:uid comment-uid + :string comment-string + :properties + {":entity/type" #:block{:string "[[athens/comment]]" + :uid (common.utils/gen-block-uid)}}}] + {:block/uid thread-uid + :relation :last}) + (composite/make-consequence-op {:op/type :new-comment}))})) + + +(defn new-thread + [db thread-uid thread-name block-uid author] + (let [members-prop-uid (common.utils/gen-block-uid) + subs-prop-uid (common.utils/gen-block-uid) + new-thread-op (->> (bfs/internal-representation->atomic-ops db + [#:block{:uid thread-uid + :string thread-name + :properties + {":entity/type" #:block{:string "[[athens/comment-thread]]" + :uid (common.utils/gen-block-uid)} + "athens/comment-thread/members" #:block{:string "" + :uid members-prop-uid + :children [#:block{:string (str "[[@" author "]]") + :uid (common.utils/gen-block-uid)}]} + "athens/comment-thread/subscribers" #:block{:string "" + :uid subs-prop-uid + :children [#:block{:string (str "[[@" author "]]") + :uid (common.utils/gen-block-uid)}]}}}] + {:block/uid block-uid + :relation :last}) + (composite/make-consequence-op {:op/type :new-thread}))] + {:members-prop-uid members-prop-uid + :subscribers-prop-uid subs-prop-uid + :new-thread-op new-thread-op})) + + +(defn get-thread-property + [db thread-uid property-name] + (get (common-db/get-block-property-document db [:block/uid thread-uid]) property-name)) + + +(defn user-in-thread-as? + [db member-or-subscriber thread-uid userpage] + (filter + #(= userpage (:block/string %)) + (:block/children (get-thread-property db thread-uid member-or-subscriber)))) + + +(defn add-new-member-or-subscriber-to-thread + [db thread-uid property-name userpage] + (let [thread-prop-uid (:block/uid (get-thread-property db thread-uid property-name))] + (bfs/internal-representation->atomic-ops db + [#:block{:uid (common.utils/gen-block-uid) + :string userpage}] + {:block/uid thread-prop-uid + :relation :last}))) + + +(defn add-new-member-or-subscriber-to-prop-uid + [db thread-prop-uid userpage] + (bfs/internal-representation->atomic-ops db + [#:block{:uid (common.utils/gen-block-uid) + :string userpage}] + {:block/uid thread-prop-uid + :relation :last})) + + +#_(defn unsubscribe-from-thread + [db thread-uid userpage] + (->> (:block/children (get-thread-property db thread-uid "athens/comment-thread/subscribers")) + (filter #(= userpage (:block/string %))) + (:block/uid) + [:block/uid] + [:db/retractEntity])) + + +(defn add-user-as-member-or-subscriber? + [db thread-uid userpage] + (let [user-member? (user-in-thread-as? db "athens/comment-thread/members" thread-uid userpage) + user-subscriber? (user-in-thread-as? db "athens/comment-thread/subscribers" thread-uid userpage)] + (cond-> [] + (empty? user-member?) (concat (add-new-member-or-subscriber-to-thread db thread-uid "athens/comment-thread/members" userpage)) + (empty? user-subscriber?) (concat (add-new-member-or-subscriber-to-thread db thread-uid "athens/comment-thread/subscribers" userpage))))) + + +;; Notifications + +(defn get-subscribers-for-notifying + [db thread-uid author] + (->> (:block/children (get-thread-property db thread-uid "athens/comment-thread/subscribers")) + (filter #(not= (:block/string %) (str "[[@" author "]]"))) + (map #(:block/string %)))) + + +(defn create-notification-op-for-users + ;; Find all the subscribed members to the thread + ;; If the user does not have a userpage or inbox we create it + ;; Find the uid of the inbox for these notifications for all the subscribers + ;; Create a notification for all the subscribers, apart from the subscriber who wrote the comment. + [{:keys [db parent-block-uid notification-for-users author trigger-block-uid notification-type]}] + (let [subscriber-data (map + #(get-userpage-data db %) + notification-for-users) + notifications (mapv + #(let [{:keys [inbox-uid userpage userpage-inbox-op]} %] + (composite/make-consequence-op {:op/type :userpage-notification-op} + (concat userpage-inbox-op + [(new-notification {:db db + :inbox-block-uid inbox-uid + :notification-position :first + :notification-type notification-type + :notification-state "unread" + :notification-trigger-uid trigger-block-uid + :notification-trigger-parent parent-block-uid + :notification-trigger-author author + :notification-for-user userpage})]))) + subscriber-data) + ops notifications] + ops)) + + +(def athens-users + ["[[@Stuart]]" "[[@Alex]]" "[[@Jeff]]" "[[@Filipe]]" "[[@Sid]]"]) + + +(defn get-all-mentions + [block-string exclude] + (filter + #(when (and (clojure.string/includes? block-string %) + (not (= exclude (common-db/strip-markup % "[[@" "]]")))) + %) + athens-users)) + + +(defn add-mentioned-users-as-member-and-subscriber + [db thread-members-uid thread-subs-uid comment-string thread-uid thread-exists? author] + (if thread-exists? + (mapcat + #(add-user-as-member-or-subscriber? @db/dsdb thread-uid %) + (get-all-mentions comment-string author)) + (mapcat + #(concat [] + (add-new-member-or-subscriber-to-prop-uid db thread-members-uid %) + (add-new-member-or-subscriber-to-prop-uid db thread-subs-uid %)) + (get-all-mentions comment-string author)))) + + +(defn create-notification-op-for-comment + ;; Find all the subscribed members to the thread + ;; Find the uid of the inbox for these notifications for all the subscribers + ;; Create a notification for all the subscribers, apart from the subscriber who wrote the comment. + [db parent-block-uid thread-uid author comment-string comment-block-uid] + (let [subscribers (if (empty? (get-all-mentions comment-string author)) + (get-subscribers-for-notifying db thread-uid author) + (set (concat (get-subscribers-for-notifying db thread-uid author) + (get-all-mentions comment-string author))))] + (when subscribers + (create-notification-op-for-users {:db @db/dsdb + :parent-block-uid parent-block-uid + :notification-for-users subscribers + :author author + :trigger-block-uid comment-block-uid + :notification-type "athens/notification/type/comment"})))) + + +(rf/reg-event-fx + :comment/write-comment + ;; There is a sequence for how the operations are to be executed because for some ops, information + ;; related to prior op is needed. The sequence is: + ;; - Create a thread if it does not exist with the block and the comment author as member and subscriber to the thread. + ;; - Add comment to the thread. + ;; - If the comment contains mentions to users not subscribed to the thread, then add them as subscribers and members. + ;; - If this is not the first comment on the thread then add the author of comment as subscriber and member to the thread. + ;; - Create notifications for the subscribed members. + + (fn [{_db :db} [_ uid comment-string author]] + (let [thread-exists? (get-comment-thread-uid @db/dsdb uid) + thread-uid (or thread-exists? + (common.utils/gen-block-uid)) + block-author (-> (common-db/get-block-document @db/dsdb [:block/uid uid]) + :block/create + :event/auth + :presence/id) + {thread-members-uid :members-prop-uid + thread-subs-uid :subscribers-prop-uid + new-thread-op :new-thread-op} (when (not thread-exists?) + (new-thread @db/dsdb thread-uid "" uid author)) + new-thread-op (if thread-exists? + [] + [new-thread-op + (graph-ops/build-block-move-op @db/dsdb thread-uid {:block/uid uid + :relation {:page/title ":comment/threads"}})]) + {comment-uid :comment-uid + comment-op :comment-op} (new-comment @db/dsdb thread-uid comment-string) + add-mentions-in-str-as-mem-subs-op (add-mentioned-users-as-member-and-subscriber @db/dsdb thread-members-uid thread-subs-uid comment-string thread-uid thread-exists? author) + add-author-as-mem-or-subs (when thread-exists? + (add-user-as-member-or-subscriber? @db/dsdb thread-uid (str "[[@" author "]]"))) + [add-block-author-as-sub-and-mem + block-author-notification-op] (when (and + (not= author block-author) + (not thread-exists?)) + [(concat [] + (add-new-member-or-subscriber-to-prop-uid @db/dsdb thread-members-uid (str "[[@" block-author "]]")) + (add-new-member-or-subscriber-to-prop-uid @db/dsdb thread-subs-uid (str "[[@" block-author "]]"))) + (create-notification-op-for-users {:db @db/dsdb + :parent-block-uid uid + :notification-for-users [(str "[[@" block-author "]]")] + :author author + :trigger-block-uid comment-uid + :notification-type "athens/notification/type/comment"})]) + notification-op (create-notification-op-for-comment @db/dsdb uid thread-uid author comment-string comment-uid) + ops (concat add-author-as-mem-or-subs + new-thread-op + [comment-op] + add-mentions-in-str-as-mem-subs-op + add-block-author-as-sub-and-mem + block-author-notification-op + notification-op) + + comment-notif-op (composite/make-consequence-op {:op/type :comment-notif-op} + ops) + event (common-events/build-atomic-event comment-notif-op)] + {:fx [[:dispatch [:resolve-transact-forward event]]]}))) diff --git a/src/cljs/athens/views/comments/inline.cljs b/src/cljs/athens/views/comments/inline.cljs new file mode 100644 index 0000000000..940f7aff96 --- /dev/null +++ b/src/cljs/athens/views/comments/inline.cljs @@ -0,0 +1,313 @@ +(ns athens.views.comments.inline + (:require + ["/components/Block/Reactions" :refer [Reactions]] + ["/components/Comments/Comments" :refer [CommentContainer CommentAnchor]] + ["/components/Icons/Icons" :refer [ChevronDownIcon ChevronRightIcon BlockEmbedIcon PencilIcon TrashIcon]] + ["/timeAgo.js" :refer [timeAgo]] + ["@chakra-ui/react" :refer [MenuGroup MenuItem AvatarGroup Button Box MenuDivider Text VStack Avatar HStack Badge]] + [athens.common-events :as common-events] + [athens.common-events.graph.ops :as graph-ops] + [athens.common.logging :as log] + [athens.common.utils :as common.utils] + [athens.db :as db] + [athens.parse-renderer :as parse-renderer] + [athens.reactive :as reactive] + [athens.util :as util] + [athens.views.blocks.editor :as editor] + [athens.views.blocks.reactions :as block-reaction] + [athens.views.comments.core :as comments.core] + [clojure.string :as str] + [re-frame.core :as rf] + [reagent.core :as r])) + + +(defn copy-comment-uid + [uid] + (let [ref (str "((" uid "))")] + (.. js/navigator -clipboard (writeText ref)) + (util/toast (clj->js {:status "info" + :position "top-right" + :title "Copied uid to clipboard"})))) + + +(rf/reg-event-fx + :comment/remove-comment + (fn [_ [_ uid]] + (log/debug ":comment/remove-comment:" uid) + {:fx [[:dispatch [:resolve-transact-forward (-> (graph-ops/build-block-remove-op @db/dsdb uid) + (common-events/build-atomic-event))]]]})) + + +(rf/reg-event-fx + :comment/edit-comment + (fn [_ [_ uid]] + {:fx [[:dispatch [:editing/uid uid :end]]]})) + + +(rf/reg-event-fx + :comment/update-comment + (fn [_ [_ uid string]] + (log/debug ":comment/update-comment:" uid string) + {:fx [[:dispatch-n [[:resolve-transact-forward (-> (graph-ops/build-block-save-op @db/dsdb uid string) + (common-events/build-atomic-event))] + [:graph/update-in [:block/uid uid] ["athens/comment/edited"] (fn [db prop-uid] + [(graph-ops/build-block-save-op db prop-uid "")])] + [:editing/uid nil]]]]})) + + +(defn create-menu + [{:keys [block/uid]} current-user-is-author? user-id] + [:> MenuGroup + [:> MenuItem {:icon (r/as-element [:> BlockEmbedIcon]) + :onClick #(copy-comment-uid uid)} + "Copy comment ref"] + (when current-user-is-author? + [:> MenuItem {:icon (r/as-element [:> PencilIcon]) + :onClick #(rf/dispatch [:comment/edit-comment uid])} + "Edit"]) + (when current-user-is-author? + [:> MenuItem {:icon (r/as-element [:> TrashIcon]) + :onClick #(rf/dispatch [:comment/remove-comment uid])} + "Delete"]) + [:> MenuGroup + [:> MenuDivider] + [block-reaction/reactions-menu-list uid user-id]]]) + + +(defn comment-el + [item] + (let [{:keys [string time author block/uid is-followup? edited?]} item + linked-refs (reactive/get-reactive-linked-references [:block/uid uid]) + linked-refs-count (count linked-refs) + current-username (rf/subscribe [:presence/current-username]) + human-timestamp (timeAgo time) + is-editing (rf/subscribe [:editing/is-editing uid]) + value-atom (r/atom string) + feature-flags (rf/subscribe [:feature-flags]) + current-user (rf/subscribe [:presence/current-user]) + show-edit-atom? (r/atom true)] + + (fn [] + (let [current-user-is-author? (= author @current-username) + reactions-enabled? (:reactions @feature-flags) + user-id (or (:username @current-user) + ;; We use empty string for when there is no user information, like in PKM. + "") + properties (:block/properties (reactive/get-reactive-block-document [:block/uid uid])) + reactions (and reactions-enabled? + (block-reaction/props->reactions properties)) + menu (r/as-element (create-menu item current-user-is-author? user-id))] + [:> CommentContainer {:menu menu :isFollowUp is-followup? :isEdited edited?} + + ;; if is-followup?, hide byline and avatar + ;; else show avatar and byline + (when-not is-followup? + [:> HStack {:gridArea "byline" + :alignItems "center" + :spacing 2 + :lineHeight 1.25} + [:<> + [:> Avatar {:name author :color "#fff" :size "xs"}] + [:> Text {:fontWeight "bold" + :fontSize "sm" + :noOfLines 0} + author] + [:> Text {:fontSize "xs" + :color "foreground.secondary"} + human-timestamp]]]) + + [:> CommentAnchor {:menu menu + :w 4 + :mx 1 + :mb "auto" + :alignSelf "center" + :height "1.5em"}] + [:> Box {:flex "1 1 100%" + :gridArea "comment" + :alignItems "center" + :display "flex" + :overflow "hidden" + :fontSize "sm" + ;; :py 1 + ;; :ml 1 + :sx {"> *" {:lineHeight 1.5}}} + ;; In future this should be rendered differently for reply type and ref-type + (if-not @is-editing + [:<> + [athens.parse-renderer/parse-and-render string uid] + (when edited? + [:> Text {:fontSize "xs" + :as "span" + :ml 2 + :mb "auto" + :color "foreground.tertiary"} + "(edited)"])] + (let [block-o {:block/uid uid + :block/string string + :block/children []} + blur-fn #(prn "blur-fn") + save-fn #(reset! value-atom %) + enter-handler (fn jetsam-enter-handler + [_uid _d-key-down] + (rf/dispatch [:comment/update-comment uid @value-atom])) + tab-handler (fn jetsam-tab-handler + [_uid _embed-id _d-key-down]) + backspace-handler (fn jetsam-backspace-handler + [_uid _value]) + delete-handler (fn jetsam-delete-handler + [_uid _d-key-down]) + state-hooks {:save-fn blur-fn + :update-fn #(save-fn %) + :idle-fn #(println "idle-fn" (pr-str %)) + :read-value value-atom + :show-edit? show-edit-atom? + :enter-handler enter-handler + :tab-handler tab-handler + :backspace-handler backspace-handler + :delete-handler delete-handler + :default-verbatim-paste? true + :keyboard-navigation? false + :style {:opacity 1 + :background-color "var(--chakra-colors-background-attic)" + :minHeight "100%" + :border-radius "5px"}}] + [editor/block-editor block-o state-hooks]))] + + (when (pos? linked-refs-count) + [:> Badge {:size "xs" + :ml 1.5 + :mr 0 + :alignSelf "baseline" + :lineHeight "1.5" + :gridArea "refs"} linked-refs-count]) + + (when (and reactions-enabled? reactions) + [:> Reactions {:reactions (clj->js reactions) + :currentUser user-id + :onToggleReaction (partial block-reaction/toggle-reaction [:block/uid uid])}])])))) + + +(defn comments-disclosure + [hide? num-comments last-comment] + [:> Button (merge + {:justifyContent "flex-start" + :color "foreground.secondary" + :variant "ghost" + :size "xs" + :minHeight 7 + :flex "1 0 auto" + :bg "background.upper" + :borderRadius "none" + :leftIcon (if @hide? + (r/as-element [:> ChevronRightIcon {:ml 1}]) + (r/as-element [:> ChevronDownIcon {:ml 1}])) + :sx {":after" {:content "''" + :opacity (if @hide? 0 0) + :position "absolute" + :bottom 0 + :transition "inherit" + :left 9 + :right 0 + :borderBottom "1px solid" + :borderBottomColor "separator.divider"} + ":hover:after" {:opacity 0}} + :onClick #(reset! hide? (not @hide?))} + (when @hide? + {:bg "transparent" + :borderColor "transparent"})) + [:> HStack + [:> AvatarGroup [:> Avatar {:size "xs" :name (:author last-comment)}]] + [:> Text (str num-comments " comments")] + [:> Text {:color "foreground.tertiary"} + (timeAgo (:time last-comment))]]]) + + +(defn inline-comments + [_data _comment-block-uid hide?] + (when (comments.core/enabled?) + (let [hide? (r/atom hide?) + block-uid (common.utils/gen-block-uid) + value-atom (r/atom "") + show-edit-atom? (r/atom true)] + (fn [data comment-block-uid _hide?] + (let [num-comments (count data) + username (rf/subscribe [:username]) + last-comment (last data) + ;; hacky way to detect if user just wanted to start the first comment, but the block-uid of the textarea + ;; isn't accessible globally + focus-textarea-if-opening-first-time #(when (zero? num-comments) + (rf/dispatch [:editing/uid block-uid]))] + + [:> VStack (merge + {:gridArea "comments" + :color "foreground.secondary" + :flex "1 0 auto" + :bg "background.upper" + :my 2 + :borderWidth "1px" + :borderStyle "solid" + :borderColor "separator.border" + :overflow "hidden" + :spacing 0 + :borderRadius "md" + :align "stretch"} + (when @hide? + {:bg "transparent" + :borderColor "separator.divider"})) + + [comments-disclosure hide? num-comments last-comment] + + (when-not @hide? + [:> Box {:p 2} + (for [item data] + ^{:key item} + [comment-el item]) + + (let [block-o {:block/uid block-uid + ;; :block/string @value-atom + :block/children []} + save-fn #(when (not (seq @value-atom)) + (rf/dispatch [:comment/hide-editor])) + update-fn #(reset! value-atom %) + idle-fn #(println "idle-fn" (pr-str %)) + enter-handler (fn jetsam-enter-handler + [_uid _d-key-down] + (when (not (str/blank? @value-atom)) + ;; Passing username because we need the username for other ops before the block is created. + (rf/dispatch [:comment/write-comment comment-block-uid @value-atom @username]) + (reset! value-atom "") + (rf/dispatch [:editing/uid block-uid]))) + tab-handler (fn jetsam-tab-handler + [_uid _embed-id _d-key-down]) + backspace-handler (fn jetsam-backspace-handler + [_uid _value]) + delete-handler (fn jetsam-delete-handler + [_uid _d-key-down]) + state-hooks {:save-fn save-fn + :update-fn update-fn + :idle-fn idle-fn + :read-value value-atom + :show-edit? show-edit-atom? + :enter-handler enter-handler + :tab-handler tab-handler + :backspace-handler backspace-handler + :delete-handler delete-handler + :default-verbatim-paste? true + :keyboard-navigation? false + :style {:opacity 1} + :placeholder "Write your comment here"}] + (focus-textarea-if-opening-first-time) + [:> Box {:px 2 + :pl 2 + :ml 8 + :mt 4 + :borderRadius "sm" + :bg "background.attic" + :cursor "text" + :transitionTimingFunction "ease-in-out" + :transitionProperty "common" + :transitionDuration "fast" + :sx {".block-content" {:p 1}} + :shadow "focusPlaceholder" + :_focusWithin {:shadow "focus"}} + [editor/block-editor block-o state-hooks]])])]))))) diff --git a/src/cljs/athens/views/help.cljs b/src/cljs/athens/views/help.cljs new file mode 100644 index 0000000000..9d4962f541 --- /dev/null +++ b/src/cljs/athens/views/help.cljs @@ -0,0 +1,346 @@ +(ns athens.views.help + (:require + ["@chakra-ui/react" :refer [Text Heading Box Modal ModalOverlay ModalContent ModalHeader ModalBody ModalCloseButton]] + [athens.util :as util] + [clojure.string :as str] + [re-frame.core :refer [dispatch subscribe]] + [reagent.core :as r])) + + +;; Helpers to create the help content +;; ========================== +(defn faded-text + [text] + [:> Text {:as "span" + :color "foreground.secondary" + :fontWeight "normal"} + text]) + + +(defn space + [] + [:> Box {:as "i" + :width "0.5em" + :display "inline-block" + :marginInline "0.125em" + :background "currentColor" + :height "1px" + :opacity "0.5"}]) + + +(defn- add-keys + [sequence] + (doall + (for [el sequence] + ^{:keys (hash el)} + el))) + + +(defn example + [template & args] + (let [faded-texts (map #(r/as-element [faded-text %]) args) + space-component (r/as-element [space]) + insert-spaces (fn [str-or-vec] + (if (and (string? str-or-vec) + (str/includes? str-or-vec "$space")) + (into [:<>] + (-> str-or-vec + (str/split #"\$space") + (interleave (repeat space-component)) + add-keys)) + str-or-vec))] + [:> Text {:fontSize "85%" + :fontWeight "bold" + :userSelect "all" + :wordBreak "break-word"} + (as-> template t + (str/split t #"\$text") + (interleave t (concat faded-texts [nil])) + (map insert-spaces t) + (add-keys t) + (into [:<>] t))])) + + +;; Help content +;; The examples use a small template language to insert the text (opaque) in the examples. +;; $text -> placeholder that will be replaced with opaque text, there can be many. The args passed +;; passed after the template will replace the $text placeholders in order +;; $space -> small utility to render a space symbol. +;; ========================== +(def syntax-groups + [{:name "Bidirectional Link, Block References, and Tags" + :items [{:description "Bidirectional Links" + :example [example "[[$text]]" "Athens"]} + {:description "Labeled Bidirectional Link" + :example [example "[$text][[$text]]" "AS" "Alice Smith"]} + {:description "Block Reference" + :example [example "(($text))" "Block ID"]} + {:description "Labeled Block Reference" + :example [example "[$text](($text))" "Block text" "Block ID"]} + {:description "Tag" + :example [example "#$text" "Athens"]} + {:description "Tagged Link" + :example [example "#[[$text]]" "Athens"]}]} + {:name "Embeds" + :items [{:description "Block" + :example [example "{{[[embed]]:(($text))}}" "reference to a block on a page"]} + {:description "Image by URL" + :example [example "![$text]($text)" "Athens Logo" "https://avatars.githubusercontent.com/u/8952138"]} + {:description "Youtube Video" + :example [example "{{[[youtube]]:$text]]}}" "https://youtube.com/..."]} + {:description "Web Page" + :example [example "{{iframe:}}" "https://github.com/athensresearch/"]} + {:description "Checkbox" + :example [example "{{[[TODO]]}}$space$text" "Label"]}]} + {:name "Markdown Formatting" + :items [{:description "Labeled Link" + :example [example "[$text]($text)" "Athens" "http://athensresearch.org/"]} + {:description "Link" + :example [example "<$text>" "http://athensresearch.org/"]} + {:description "LaTeX" + :example [example "$$$text$$" "Your equation or mathematical symbol"]} + {:description "Inline code" + :example [example "`$text`" "Inline Code"]} + {:description "Highlight" + :example [example "^^$text^^" "Athens"]} + {:description "Italicize" + :example [example "__$text__" "Athens"]} + {:description "Bold" + :example [example "**$text**" "Athens"]} + {:description "Underline" + :example [example "--$text--" "Athens"]} + {:description "Strikethrough" + :example [example "~~$text~~" "Athens"]} + {:description "Heading level 1" + :example [example "#$space$text" "Athens"]} + {:description "Heading level 2" + :example [example "##$space$text" "Athens"]} + {:description "Heading level 3" + :example [example "###$space$text" "Athens"]} + {:description "Heading level 4" + :example [example "####$space$text" "Athens"]} + {:description "Heading level 5" + :example [example "#####$space$text" "Athens"]} + {:description "Heading level 6" + :example [example "######$space$text" "Athens"]}]}]) + + +(def shortcut-groups + [{:name "App" + :items [{:description "Toggle Search" + :shortcut "mod+k"} + {:description "Toggle Left Sidebar" + :shortcut "mod+\\"} + {:description "Toggle Right Sidebar" + :shortcut "mod+shift+\\"} + {:description "Increase Text Size" + :shortcut "mod+plus"} + {:description "Decrease Text Size" + :shortcut "mod+minus"} + {:description "Reset Text Size" + :shortcut "mod+0"}]} + {:name "Input" + :items [{:description "Autocomplete Menu" + :shortcut "/"} + {:description "Indent selected block" + :shortcut "tab"} + {:description "Unindent selected block" + :shortcut "shift+tab"} + {:description "Undo" + :shortcut "mod+z"} + {:description "Redo" + :shortcut "mod+shift+z"} + {:description "Copy" + :shortcut "mod+c"} + {:description "Paste" + :shortcut "mod+v"} + {:description "Paste without formatting" + :shortcut "mod+shift+v"} + {:description "Convert to checkbox" + :shortcut "mod+enter"}]} + {:name "Navigation" + :items [{:description "Zoom into current block" + :shortcut "mod+o"} + {:description "Zoom out of current block" + :shortcut "mod+alt+o"} + {:description "Open page or block link" + :shortcut "mod+o"} + {:description "Fold block" + :shortcut "mod+up"} + {:description "Unfold block" + :shortcut "mod+down"}]} + {:name "Selection" + :items [{:description "Select previous block" + :shortcut "shift+up"} + {:description "Select next block" + :shortcut "shift+down"} + {:description "Select all blocks" + :shortcut "mod+a"}]} + {:name "Formatting" + :items [{:description "Bold" + :example [:strong "Athens"] + :shortcut "mod+b"} + {:description "Italics" + :example [:i "Athens"] + :shortcut "mod+i"} + ;; Underline is currently not working. Uncomment when it does. + ;; {:description "Underline" + ;; :example [:span (use-style {:text-decoration "underline"}) "Athens"] + ;; :shortcut "mod+u"} + {:description "Strikethrough" + :example [:> Text {:as "span" :textDecoration "line-through"} "Athens"] + :shortcut "mod+y"} + {:description "Highlight" + :example [:> Text {:as "span" + :background "highlight" + :color "highlightContrast" + :borderRadius "0.1rem" + :padding "0 0.125em"} + "Athens"] + :shortcut "mod+h"}]} + {:name "Graph" + :items [{:description "Open Node in Sidebar" + :shortcut "shift+click"} + {:description "Move Node" + :shortcut "click+drag"} + {:description "Zoom in/out" + :shortcut "scroll up/down"}]}]) + + +(def content + [{:name "Syntax", + :groups syntax-groups} + {:name "Keyboard Shortcuts" + :groups shortcut-groups}]) + + +;; Components to render content +;; ============================= +(def mod-key + (if (util/is-mac?) "⌘" "CTRL")) + + +(def alt-key + (if (util/is-mac?) "⌥" "Alt")) + + +(defn shortcut + [shortcut-str] + (let [key-to-display (fn [key] + (-> key + (str/replace #"mod" mod-key) + (str/replace #"alt" alt-key) + (str/replace #"shift" "⇧") + (str/replace #"minus" "-") + (str/replace #"plus" "+"))) + keys (as-> shortcut-str s + (str/split s #"\+") + (map key-to-display s))] + [:> Box {:display "flex" + :alignItems "center" + :gap "0.3rem"} + + (doall + (for [key keys] + ^{:key key} + [:> Text {:fontFamily "inherit" + :display "inline-flex" + :gap "0.3em" + :textTransform "uppercase" + :fontSize "0.8em" + :paddingInline "0.35em" + :background "background.basement" + :borderRadius "0.25rem" + :fontWeight 600} + key]))])) + + +(defn help-section + [title & children] + [:> Box {:as "section"} + [:> Heading {:as "h2" + :color "foreground.primary" + :textTransform "uppercase" + :letterSpacing "0.06rem" + :margin 0 + :font-weight 600 + :font-size "100%" + :padding "1rem 1.5rem"} + title] + (doall + (for [child children] + ^{:key (hash child)} + child))]) + + +(defn help-section-group + [title & children] + [:> Box {:display "grid" + :padding "1.5rem" + :gridTemplateColumns "12rem 1fr" + :columnGap "1rem" + :borderTop "1px solid" + :borderColor "separator.divider"} + [:> Heading {:fontSize "1.5em" + :as "h3" + :margin 0 + :font-weight "bold"} + title] + [:div + (doall + (for [child children] + ^{:key (hash child)} + child))]]) + + +(defn help-item + [item] + [:> Box {:borderRadius "0.5rem" + :alignItems "center" + :display "grid" + :gap "1rem" + :gridTemplateColumns "12rem 1fr" + :padding "0.25rem 0.5rem" + :sx {"&:nth-of-type(odd)" + {:bg "background.floor"}}} + [:> Text {:display "flex" + :justify-content "space-between"} + ;; Position of the example changes if there is a shortcut or not. + (:description item) + (when (contains? item :shortcut) + (:example item))] + (when (not (contains? item :shortcut)) + (:example item)) + (when (contains? item :shortcut) + [shortcut (:shortcut item)])]) + + +(defn help-popup + [] + (r/with-let [open? (subscribe [:help/open?]) + close #(dispatch [:help/toggle])] + [:> Modal {:isOpen @open? + :onClose close + :scrollBehavior "outside" + :size "full"} + [:> ModalOverlay] + [:> ModalContent {:maxWidth "calc(100% - 8rem)" + :width "max-content" + :my "4rem"} + [:> ModalHeader "Help" + [:> ModalCloseButton]] + [:> ModalBody {:flexDirection "column"} + (doall + (for [section content] + ^{:key section} + [help-section (:name section) + (doall + (for [group (:groups section)] + ^{:key group} + [help-section-group (:name group) + (doall + (for [item (:items group)] + ^{:key item} + [help-item item]))]))]))]]])) + + diff --git a/src/cljs/athens/views/hoc/perf_mon.cljs b/src/cljs/athens/views/hoc/perf_mon.cljs new file mode 100644 index 0000000000..64b1dca6ef --- /dev/null +++ b/src/cljs/athens/views/hoc/perf_mon.cljs @@ -0,0 +1,45 @@ +(ns athens.views.hoc.perf-mon + "Higher Order Component for Performance Monitoring" + (:require + [athens.common.logging :as log] + [athens.utils.sentry :as sentry] + [reagent.core :as r])) + + +(defn hoc-perfmon + "Higher Order Component for Performance Monitoring with Sentry" + [{:keys [span-name]} _component] + (let [tx-present? (sentry/tx-running?) + sentry-tx (if tx-present? + (sentry/transaction-get-current) + (sentry/transaction-start (str span-name "-hoc-auto-tx"))) + sentry-span (sentry/span-start sentry-tx span-name false) + did-mount (fn [_this] + (sentry/span-finish sentry-span false) + (when-not tx-present? + (sentry/transaction-finish sentry-tx)))] + (log/debug "hoc-perfmon:" span-name "setup-finished") + (r/create-class + {:display-name "hoc-perfmon" + :reagent-render (fn [{:keys [_span-name]} component] + [:<> component]) + :component-did-mount did-mount}))) + + +(defn hoc-perfmon-no-new-tx + "Higher Order Component for Performance Monitoring with Sentry which does not create new tx if it does not exist" + [{:keys [span-name]} _component] + (let [tx-present? (sentry/tx-running?) + sentry-tx (when tx-present? + (sentry/transaction-get-current)) + sentry-span (sentry/span-start sentry-tx span-name false) + did-mount (fn [_this] + (when sentry-span + (sentry/span-finish sentry-span false)))] + (when sentry-span + (log/debug "hoc-perfmon-no-new-tx:" span-name "setup-finished")) + (r/create-class + {:display-name "hoc-perfmon-no-new-tx" + :reagent-render (fn [{:keys [_span-name]} component] + [:<> component]) + :component-did-mount did-mount}))) diff --git a/src/cljs/athens/views/jetsam.cljs b/src/cljs/athens/views/jetsam.cljs new file mode 100644 index 0000000000..ec49cf1501 --- /dev/null +++ b/src/cljs/athens/views/jetsam.cljs @@ -0,0 +1,54 @@ +(ns athens.views.jetsam + "This is for experimentation with re-usability of block view/edit." + (:require + [athens.views.blocks.eitor :as editor] + [athens.views.blocks.textarea-keydown :as txt-key-down] + [reagent.core :as r])) + + +(defn jetsam-component + "Experiments with embedding" + [] + (let [value-atom (r/atom "This [[has]] link") + show-edit-atom? (r/atom false) + block-uid "my-random-uid" + block-o {:block/uid block-uid + ;; :block/string @value-atom + :block/children []} + save-fn #(reset! value-atom %) + enter-handler (fn jetsam-enter-handler + [_uid _d-key-down] + (txt-key-down/replace-selection-with "\n")) + tab-handler (fn jetsam-tab-handler + [_uid _embed-id _d-key-down] + (println "tab is here")) + backspace-handler (fn jetsam-backspace-handler + [_uid _value] + (println "backspace is here")) + delete-handler (fn jetsam-delete-handler + [_uid _d-key-down] + (println "delete didn't break shit this time")) + state-hooks {:save-fn #(do + (println "save-fn" (pr-str %))) + :update-fn #(do + (println "update-fn" (pr-str %)) + (save-fn %)) + :idle-fn #(println "idle-fn" (pr-str %)) + :read-value value-atom + :show-edit? show-edit-atom? + :enter-handler enter-handler + :tab-handler tab-handler + :backspace-handler backspace-handler + :delete-handler delete-handler + :default-verbatim-paste? true + :keyboard-navigation? false}] + (fn jetsam-component-render-fn + [] + [:div {:class "jetsam block-container" + :style {:position "absolute" + :left "25vw" + :top "25vh" + :width "50vw" + :height "50vh" + :background-color "lightgreen"}} + [editor/block-editor block-o state-hooks]]))) diff --git a/src/cljs/athens/views/left_sidebar/core.cljs b/src/cljs/athens/views/left_sidebar/core.cljs new file mode 100644 index 0000000000..976c6e1df4 --- /dev/null +++ b/src/cljs/athens/views/left_sidebar/core.cljs @@ -0,0 +1,111 @@ +(ns athens.views.left-sidebar.core + (:require + ["/components/Icons/Icons" :refer [CalendarEditFillIcon AllPagesIcon ContrastIcon SearchIcon GraphIcon SettingsIcon]] + ["/components/Layout/MainSidebar" :refer [MainSidebar]] + ["@chakra-ui/react" :refer [Button Flex VStack ButtonGroup Divider Link IconButton]] + [athens.router :as router] + [athens.util :as util] + [athens.views.left-sidebar.events] + [athens.views.left-sidebar.shortcuts :as shortcuts] + [athens.views.left-sidebar.subs :as left-sidebar-subs] + [athens.views.left-sidebar.tasks :as left-sidebar-tasks] + [re-frame.core :as rf] + [reagent.core :as r])) + + +;; Components + +(defn route-button + [] + (fn [is-active? label icon on-click] + [:> Button {:isActive is-active? + :textAlign "start" + :justifyContent "flex-start" + :variant "ghost" + :leftIcon icon + :onClick on-click} + label])) + + +(defn left-sidebar + [] + (let [current-route-name (rf/subscribe [:current-route/name]) + on-athena #(rf/dispatch [:athena/toggle]) + on-theme #(rf/dispatch [:theme/toggle]) + tasks-enabled? (rf/subscribe [:feature-flags/enabled? :tasks]) + on-settings (fn [_] + (rf/dispatch [:settings/toggle-open])) + route-name @current-route-name + is-open? (left-sidebar-subs/get-sidebar-open?)] + [:> MainSidebar {:isMainSidebarOpen is-open?} + + [:> Flex {:flexDirection "column" :gap 6 :alignItems "stretch" :height "100%"} + + [:> VStack {:spacing 0.5 + :role "nav" + :alignSelf "stretch" + :as ButtonGroup + :size "sm" + :align "stretch" + :px 2.5} + [:> Button {:onClick on-athena + :variant "outline" + :justifyContent "start" + :leftIcon (r/as-element [:> SearchIcon])} + "Find or Create a Page"] + [route-button (= route-name :home) "Daily Notes" (r/as-element [:> CalendarEditFillIcon]) (fn [_] + (rf/dispatch [:reporting/navigation {:source :main-sidebar + :target :home + :pane :main-pane}]) + (router/nav-daily-notes))] + [route-button (= route-name :pages) "All Pages" (r/as-element [:> AllPagesIcon]) (fn [_] + (rf/dispatch [:reporting/navigation {:source :main-sidebar + :target :all-pages + :pane :main-pane}]) + (router/navigate :pages))] + + [route-button (= route-name :graph) "Graph" (r/as-element [:> GraphIcon]) (fn [_] + (rf/dispatch [:reporting/navigation {:source :main-sidebar + :target :graph + :pane :main-pane}]) + (router/navigate :graph))]] + + (when @tasks-enabled? [:f> left-sidebar-tasks/my-tasks]) + + [:f> shortcuts/global-shortcuts] + + + ;; LOGO + BOTTOM BUTTONS + [:> Flex {:as "footer" + :align "stretch" + :position "sticky" + :bottom 0 + :bg "background.vibrancy" + :backdropFilter "blur(20px)" + :flexDirection "column" + :flex "0 0 auto" + :fontSize "xs" + :mt "auto" + :color "foreground.secondary" + :px 6} + [:> Divider] + [:> Flex {:alignItems "center" :py 4} + [:> Flex {:flex 1 :gap 2} + [:> Link {:fontWeight "bold" + :display "inline-block" + :href "https://github.com/athensresearch/athens/issues/new/choose" + :target "_blank"} + "Athens"] + [:> Link {:color "foreground.secondary" + :display "inline-block" + :href "https://github.com/athensresearch/athens/blob/master/CHANGELOG.md" + :target "_blank"} + (util/athens-version)]] + [:> ButtonGroup {:size "xs" + :variant "ghost" + :colorScheme "subtle"} + [:> IconButton {:onClick on-theme + :icon (r/as-element [:> ContrastIcon])}] + + [:> IconButton {:onClick on-settings + :icon (r/as-element [:> SettingsIcon])}]]]]]])) diff --git a/src/cljs/athens/views/left_sidebar/events.cljs b/src/cljs/athens/views/left_sidebar/events.cljs new file mode 100644 index 0000000000..f303c2b0ce --- /dev/null +++ b/src/cljs/athens/views/left_sidebar/events.cljs @@ -0,0 +1,106 @@ +(ns athens.views.left-sidebar.events + (:require + [athens.common-db :as common-db] + [athens.common-events :as common-events] + [athens.common-events.graph.atomic :as atomic-graph-ops] + [athens.common-events.graph.ops :as graph-ops] + [athens.common.logging :as log] + [athens.db :as db] + [athens.interceptors :as interceptors] + [athens.views.left-sidebar.shared :as shared] + [re-frame.core :as rf])) + + +;; Shortcuts + +(rf/reg-event-fx + :left-sidebar/toggle + [(interceptors/sentry-span-no-new-tx "left-sidebar/toggle")] + (fn [_ _] + (let [user-page @(rf/subscribe [:presence/user-page])] + {:fx [[:dispatch [:graph/update-in [:node/title user-page] [(shared/ns-str "/closed?")] + (fn [db uid] + (let [exists? (common-db/block-exists? db [:block/uid uid])] + [(if exists? + (graph-ops/build-block-remove-op db uid) + (graph-ops/build-block-save-op db uid ""))]))]] + [:dispatch [:posthog/report-feature :left-sidebar]]]}))) + + +(rf/reg-event-fx + :left-sidebar/add-shortcut + [(interceptors/sentry-span-no-new-tx "left-sidebar/add-shortcut")] + (fn [_ [_ name]] + (log/debug ":page/add-shortcut:" name) + (let [add-shortcut-op (atomic-graph-ops/make-shortcut-new-op name) + event (common-events/build-atomic-event add-shortcut-op)] + {:fx [[:dispatch [:resolve-transact-forward event]]]}))) + + +(rf/reg-event-fx + :left-sidebar/remove-shortcut + [(interceptors/sentry-span-no-new-tx "left-sidebar/remove-shortcut")] + (fn [_ [_ name]] + (log/debug ":page/remove-shortcut:" name) + (let [remove-shortcut-op (atomic-graph-ops/make-shortcut-remove-op name) + event (common-events/build-atomic-event remove-shortcut-op)] + {:fx [[:dispatch [:resolve-transact-forward event]]]}))) + + +(rf/reg-event-fx + :left-sidebar/drop + [(interceptors/sentry-span-no-new-tx "left-sidebar/drop")] + (fn [_ [_ source-order target-order relation]] + (let [[source-name target-name] (common-db/find-source-target-title @db/dsdb source-order target-order) + drop-op (atomic-graph-ops/make-shortcut-move-op source-name + {:page/title target-name + :relation relation}) + event (common-events/build-atomic-event drop-op)] + {:fx [[:dispatch [:resolve-transact-forward event]] + [:dispatch [:posthog/report-feature :left-sidebar]]]}))) + + +;; Tasks + +(rf/reg-event-fx + :left-sidebar.tasks/set-max-tasks + [(interceptors/sentry-span-no-new-tx "left-sidebar/tasks/set-max-tasks")] + (fn [_ [_ max-tasks]] + (let [user-page @(rf/subscribe [:presence/user-page])] + {:fx [[:dispatch [:graph/update-in [:node/title user-page] [(shared/ns-str "/tasks/max-tasks")] + (fn [db uid] + ;; todo: good place to be using a number primitive type + [(graph-ops/build-block-save-op db uid (str max-tasks))])]] + [:dispatch [:posthog/report-feature "left-sidebar/tasks"]]]}))) + + +;; Widgets + +(rf/reg-event-fx + :left-sidebar.widgets/toggle-widget + [(interceptors/sentry-span-no-new-tx "left-sidebar/widgets/toggle")] + (fn [_ [_ widget-id]] + (let [user-page @(rf/subscribe [:presence/user-page])] + {:fx [[:dispatch [:graph/update-in [:node/title user-page] [(shared/ns-str "/widgets/" widget-id "/closed?")] + (fn [db uid] + (let [exists? (common-db/block-exists? db [:block/uid uid])] + [(if exists? + (graph-ops/build-block-remove-op db uid) + (graph-ops/build-block-save-op db uid ""))]))]] + [:dispatch [:posthog/report-feature "left-sidebar/widgets/toggle"]]]}))) + + +(rf/reg-event-fx + :left-sidebar.tasks.section/toggle + [(interceptors/sentry-span-no-new-tx "left-sidebar/tasks/section/toggle")] + (fn [_ [_ page-id]] + (let [user-page @(rf/subscribe [:presence/user-page])] + {:fx [[:dispatch [:graph/update-in [:node/title user-page] [(shared/ns-str "/tasks/" page-id "/closed?")] + (fn [db uid] + (let [exists? (common-db/block-exists? db [:block/uid uid])] + [(if exists? + (graph-ops/build-block-remove-op db uid) + (graph-ops/build-block-save-op db uid ""))]))]] + [:dispatch [:posthog/report-feature "left-sidebar/tasks/section/toggle"]]]}))) + + diff --git a/src/cljs/athens/views/left_sidebar/shared.cljs b/src/cljs/athens/views/left_sidebar/shared.cljs new file mode 100644 index 0000000000..86a09409fd --- /dev/null +++ b/src/cljs/athens/views/left_sidebar/shared.cljs @@ -0,0 +1,15 @@ +(ns athens.views.left-sidebar.shared) + + +(def NS "athens/left-sidebar") + + +(defn ns-str + ([] + (ns-str "")) + ([s] + (str NS s)) + ([s & ss] + (apply str NS s ss))) + + diff --git a/src/cljs/athens/views/left_sidebar/shortcuts.cljs b/src/cljs/athens/views/left_sidebar/shortcuts.cljs new file mode 100644 index 0000000000..16ed269bd6 --- /dev/null +++ b/src/cljs/athens/views/left_sidebar/shortcuts.cljs @@ -0,0 +1,49 @@ +(ns athens.views.left-sidebar.shortcuts + (:require + ["/components/Empty/Empty" :refer [Empty EmptyTitle EmptyIcon EmptyMessage]] + ["/components/Icons/Icons" :refer [BookmarkIcon]] + ["/components/SidebarShortcuts/List" :refer [List]] + ["/components/Widget/Widget" :refer [Widget WidgetHeader WidgetBody WidgetToggle]] + [athens.reactive :as reactive] + [athens.router :as router] + [athens.views.left-sidebar.subs :as left-sidebar-subs] + [re-frame.core :as rf])) + + +(defn global-shortcuts + [] + (let [shortcuts (reactive/get-reactive-shortcuts) + current-route (rf/subscribe [:current-route]) + current-route-page-name (get-in @current-route [:path-params :title]) + is-open? (left-sidebar-subs/get-widget-open? "shortcuts")] + [:> Widget + {:pr 4 + :defaultIsOpen is-open?} + [:> WidgetHeader {:title "Shortcuts" + :pl 6} + [:> WidgetToggle {:onClick #(rf/dispatch [:left-sidebar.widgets/toggle-widget "shortcuts"])}]] + + [:> WidgetBody + + (if (seq shortcuts) + + + [:> List {:items shortcuts + :currentPageName current-route-page-name + :onOpenItem (fn [e [_order page]] + (let [shift? (.-shiftKey e)] + (rf/dispatch [:reporting/navigation {:source :left-sidebar + :target :page + :pane (if shift? + :right-pane + :main-pane)}]) + (router/navigate-page page e))) + :onUpdateItemsOrder (fn [oldIndex newIndex] + (cond + (< oldIndex newIndex) (rf/dispatch [:left-sidebar/drop oldIndex newIndex :after]) + (> oldIndex newIndex) (rf/dispatch [:left-sidebar/drop oldIndex newIndex :before])))}] + + [:> Empty {:size "sm" :pr 2 :pl 4} + [:> EmptyIcon {:Icon BookmarkIcon}] + [:> EmptyTitle "No shortcuts"] + [:> EmptyMessage "Add shortcuts to mark important pages in your workspace."]])]])) diff --git a/src/cljs/athens/views/left_sidebar/subs.cljs b/src/cljs/athens/views/left_sidebar/subs.cljs new file mode 100644 index 0000000000..4b16dc1df0 --- /dev/null +++ b/src/cljs/athens/views/left_sidebar/subs.cljs @@ -0,0 +1,53 @@ +(ns athens.views.left-sidebar.subs + (:require + [athens.reactive :as reactive] + [athens.views.left-sidebar.shared :as shared] + [re-frame.core :as rf])) + + +(defn get-max-tasks + [] + (let [user-page @(rf/subscribe [:presence/user-page]) + props (-> (reactive/get-reactive-node-document [:node/title user-page]) + :block/properties) + default-max-tasks 3 + max-tasks (-> (get props (shared/ns-str "/tasks/max-tasks")) + :block/string)] + (js/parseInt (or max-tasks default-max-tasks)))) + + +(defn get-sidebar-open? + [] + (let [user-page @(rf/subscribe [:presence/user-page]) + props (-> (reactive/get-reactive-node-document [:node/title user-page]) + :block/properties) + closed? (-> (get props (shared/ns-str "/closed?")) + boolean)] + (not closed?))) + + +(defn get-widget-open? + [widget-id] + (let [user-page @(rf/subscribe [:presence/user-page]) + props (-> (reactive/get-reactive-node-document [:node/title user-page]) + :block/properties) + closed? (-> (get props (shared/ns-str "/widgets/" widget-id "/closed?")) + boolean)] + (not closed?))) + + +(defn get-task-section-open? + "section-id is by page currently" + [section-id] + (let [user-page @(rf/subscribe [:presence/user-page]) + props (-> (reactive/get-reactive-node-document [:node/title user-page]) + :block/properties) + closed? (-> (get props (shared/ns-str "/tasks/" section-id "/closed?")) + boolean)] + (not closed?))) + + +(rf/reg-sub + :left-sidebar/open + (fn [] + (get-sidebar-open?))) diff --git a/src/cljs/athens/views/left_sidebar/tasks.cljs b/src/cljs/athens/views/left_sidebar/tasks.cljs new file mode 100644 index 0000000000..10b4082d96 --- /dev/null +++ b/src/cljs/athens/views/left_sidebar/tasks.cljs @@ -0,0 +1,169 @@ +(ns athens.views.left-sidebar.tasks + (:require + ["/components/Block/Taskbox" :refer [Taskbox]] + ["/components/Empty/Empty" :refer [Empty + EmptyIcon + EmptyTitle + EmptyMessage]] + ["/components/Icons/Icons" :refer [FilterCircleIcon FilterCircleFillIcon CheckboxIcon]] + ["/components/Widget/Widget" :refer [Widget WidgetHeader WidgetBody WidgetTitle WidgetToggle]] + ["@chakra-ui/react" :refer [FormControl Select FormLabel Heading Popover PopoverTrigger PopoverAnchor PopoverContent PopoverBody Portal IconButton Link Text VStack Flex Link Flex]] + ["framer-motion" :refer [motion AnimatePresence]] + [athens.parse-renderer :as parse-renderer] + [athens.reactive :as reactive] + [athens.router :as router] + [athens.types.query.shared :as query] + [athens.types.tasks.handlers :as task-handlers] + [athens.types.tasks.shared :as shared] + [athens.views.left-sidebar.subs :as left-sidebar-subs] + [re-frame.core :as rf] + [reagent.core :as r])) + + +(defn sidebar-task-el + [task] + (let [task-uid (get task ":block/uid") + task-title (get task ":task/title") + status-uid (get task ":task/status") + status-options (->> (shared/find-allowed-statuses) + (map (fn [{:block/keys [string]}] + string))) + status-block (reactive/get-reactive-block-document [:block/uid status-uid]) + status-string (:block/string status-block)] + [:> Flex {:display "inline-flex" + :as (.-div motion) + :initial {:opacity 0 + :height 0} + :animate {:opacity 1 + :height "auto"} + :exit {:opacity 0 + :height 0} + :align "baseline" + :gap 1} + [:> Taskbox {:position "relative" + :top "3px" + :options status-options + :onChange #(task-handlers/update-task-status task-uid %) + :status status-string}] + [:> Link {:fontSize "sm" + :py 1 + :noOfLines 1 + ;; TODO: clicking on refs might take you to ref instead of task + :onClick #(router/navigate-uid task-uid %)} + [parse-renderer/parse-and-render task-title task-uid]]])) + + +(defn sort-tasks-list + [tasks] + (sort-by (juxt #(get % ":task/due") #(get % ":task/priority") #(get % ":task/title")) tasks)) + + +(defn my-tasks + [] + (let [all-tasks (->> (reactive/get-reactive-instances-of-key-value ":entity/type" "[[athens/task]]") + (map query/block-to-flat-map) + (map query/get-root-page)) + me @(rf/subscribe [:presence/current-username]) + max-tasks-shown (left-sidebar-subs/get-max-tasks) + get-is-done (fn [task] + (let [status (get task ":task/status") + status-block (reactive/get-reactive-block-document [:block/uid status]) + status-string (:block/string status-block "(())")] + (or (= status-string "Done") + (= status-string "Cancelled")))) + is-filtered? true + fn-assigned-to-me (fn [task] + (= (str "@" me) + (get task ":task/assignee"))) + tasks-assigned-to-me (filterv fn-assigned-to-me all-tasks) + grouped-tasks (group-by #(get % ":task/page") tasks-assigned-to-me) + set-num-shown (fn [num] + (rf/dispatch [:left-sidebar.tasks/set-max-tasks num])) + ;; sort by due date, then priority, then title + widget-open? (left-sidebar-subs/get-widget-open? "tasks")] + + + [:> Widget {:defaultIsOpen widget-open?} + + ;; Widget header, including settings popover + [:> Popover {:placement "right-start" :size "sm"} + + ;; Widget header + [:> PopoverAnchor + [:> WidgetHeader {:title "Assigned to Me" + :pl 6 + :pb 2 + :pr 4} + [:> PopoverTrigger + [:> IconButton {:icon + (r/as-element (if is-filtered? + [:> FilterCircleFillIcon] + [:> FilterCircleIcon])) + :size "xs" + :colorScheme "subtle" + :variant "ghost"}]] + + ;; Count of shown tasks + [:> Text {:fontSize "xs" + :color "foreground.secondary"} (count tasks-assigned-to-me)] + + ;; standard widget toggle + [:> WidgetToggle {:onClick #(rf/dispatch [:left-sidebar.widgets/toggle-widget "tasks"])}]]] + + ;; Widget settings popover + [:> Portal + [:> PopoverContent {:width "16em"} + [:> PopoverBody + [:> Heading {:size "xs"} + "Display Settings"] + [:> VStack {:py 2} + [:> FormControl {:display "flex" :flexDirection "row"} + [:> FormLabel {:flex "1 1 100%" :py 1} "Tasks per page"] + [:> Select {:value max-tasks-shown + :size "xs" + :onChange #(set-num-shown (-> % .-target .-value js/parseInt))} + [:option {:value 3} "3"] + [:option {:value 5} "5"] + [:option {:value 10} "7"] + [:option {:value 20} "20"]]]]]]]] + + ;; Body of the main widget + [:> WidgetBody {:as VStack + :pl 6 + :pr 4 + :spacing 2 + :align "stretch"} + + (if (seq tasks-assigned-to-me) + (doall + (for [[page tasks] grouped-tasks] + + ;; TODO: filter out pages with no tasks assigned to me + ;; before getting to this point + (when (seq (filterv #(not (get-is-done %)) tasks)) + + ;; Per page of tasks... + (let [incomplete-tasks (filterv #(not (get-is-done %)) tasks) + section-open? (left-sidebar-subs/get-task-section-open? page)] + + ^{:key page} + [:> Widget {:defaultIsOpen section-open? + :borderTop "1px solid" + :borderColor "separator.divider" + :pt 2} + [:> WidgetHeader {:spacing 0} + [:> WidgetTitle [:> Link {:onClick #(router/navigate-page page %)} page]] + [:> Text {:className "shown-on-hover" :fontSize "xs" :color "foreground.secondary"} (count tasks)] + [:> WidgetToggle {:className "shown-on-hover" :onClick #(rf/dispatch [:left-sidebar.tasks.section/toggle page])}]] + [:> WidgetBody + [:> AnimatePresence {:initial false} + (doall + ;; show sorted list of limited number of incomplete tasks + (for [task (take max-tasks-shown (sort-tasks-list incomplete-tasks))] + ^{:key (get task ":block/uid")} + [:f> sidebar-task-el task]))]]])))) + + [:> Empty {:size "sm" :pl 0} + [:> EmptyIcon {:Icon CheckboxIcon}] + [:> EmptyTitle "All done"] + [:> EmptyMessage "Tasks assigned to you will appear here."]])]])) diff --git a/src/cljs/athens/views/notifications/actions.cljs b/src/cljs/athens/views/notifications/actions.cljs new file mode 100644 index 0000000000..26e85c8fcc --- /dev/null +++ b/src/cljs/athens/views/notifications/actions.cljs @@ -0,0 +1,54 @@ +(ns athens.views.notifications.actions + (:require + [athens.common-db :as common-db] + [athens.common-events.graph.ops :as graph-ops] + [athens.db :as db])) + + +(defn is-block-notification? + [properties] + (= "[[athens/notification]]" + (:block/string (get properties ":entity/type")))) + + +#_(defn unread-notification? + [properties] + (= "false" + (:block/string (get properties "athens/notification/is-read")))) + + +#_(defn read-notification? + [properties] + (= "true" + (:block/string (get properties "athens/notification/is-read")))) + + +#_(defn archived-notification? + [properties] + (= "true" + (:block/string (get properties "athens/notification/is-archived")))) + + +;; Mark as +;; uid of the notification +;; new state of the uid + +;; What to do for the case of marking multiple selected uids as something? + +(defn update-state-prop + [uid prop-key new-state] + (let [block-properties (common-db/get-block-property-document @db/dsdb [:block/uid uid]) + ;; Find the prop that needs to be updated due to this action + block-state-prop (cond + ;; for a block + ;; for a page + ;; for a channel + ;; for a thread + ;; for an inbox(assuming inbox is a list of notification blocks) + (is-block-notification? block-properties) prop-key) + updated-prop (cond + ;; Maybe for a block we need to update the prop of the children also + (is-block-notification? block-properties) [:graph/update-in [:block/uid uid] [block-state-prop] + (fn [db prop-uid] + [(graph-ops/build-block-save-op db prop-uid new-state)])])] + updated-prop)) diff --git a/src/cljs/athens/views/notifications/core.cljs b/src/cljs/athens/views/notifications/core.cljs new file mode 100644 index 0000000000..abd26d28b1 --- /dev/null +++ b/src/cljs/athens/views/notifications/core.cljs @@ -0,0 +1,129 @@ +(ns athens.views.notifications.core + (:require + [athens.common-db :as common-db] + [athens.common-events.bfs :as bfs] + [athens.common-events.graph.composite :as composite] + [athens.common-events.graph.ops :as graph-ops] + [athens.common.utils :as common.utils] + [re-frame.core :as rf])) + + +(defn enabled? + [] + (:notifications @(rf/subscribe [:feature-flags]))) + + +(defn create-notif-message + [{:keys [notification-type notification-trigger-uid notification-trigger-parent notification-trigger-author notification-for-user] :as _opts}] + (cond + (= notification-type "athens/notification/type/comment") + (str "**" notification-trigger-author "** " "commented on: " "**((" notification-trigger-parent "))**" "\n" + "***((" notification-trigger-uid "))***") + + (= notification-type "athens/notification/type/mention") + (str "**" notification-trigger-author "** " "mentioned you: " "**((" notification-trigger-uid "))**") + + (= notification-type "athens/notification/type/task/assigned/to") + (str "**" notification-trigger-author "** " "assigned you task: " "***((" notification-trigger-uid "))***") + + (= notification-type "athens/notification/type/task/assigned/by") + (str "You assigned a new task: " "***((" notification-trigger-uid "))*** to " notification-for-user))) + + +(defn new-notification + [{:keys [db inbox-block-uid notification-position notification-for-user notification-type notification-trigger-uid notification-trigger-parent notification-trigger-author] :as opts}] + ;; notification-from-block can be used to show context for the notification + ;; notification-type: "*-notification" for e.g "task-notification", "athens/notification/type/comment" + (->> (bfs/internal-representation->atomic-ops + db + [#:block{:uid (common.utils/gen-block-uid) + :string (create-notif-message opts); Should the string be message or the breadcrumb or something else? + :open? false + :properties {":entity/type" + #:block{:string "[[athens/notification]]" + :uid (common.utils/gen-block-uid)} + "athens/notification/type" + #:block{:string (str "[[" notification-type "]]") + :uid (common.utils/gen-block-uid)} + "athens/notification/trigger" + #:block{:string (str "((" notification-trigger-uid "))") + :uid (common.utils/gen-block-uid)} + "athens/notification/for-user" + #:block{:string notification-for-user + :uid (common.utils/gen-block-uid)} + "athens/notification/trigger/author" + #:block{:string (str "[[@" notification-trigger-author "]]") + :uid (common.utils/gen-block-uid)} + "athens/notification/is-archived" + #:block{:string "false" + :uid (common.utils/gen-block-uid)} + "athens/notification/is-read" + #:block{:string "false" + :uid (common.utils/gen-block-uid)} + "athens/notification/trigger/parent" + #:block{:string (str "((" notification-trigger-parent "))") + :uid (common.utils/gen-block-uid)}}}] + {:block/uid inbox-block-uid + :relation notification-position}) + (composite/make-consequence-op {:op/type :new-notification}))) + + +(defn get-inbox-uid-for-user + [db at-username] + (let [page-uid (common-db/get-page-uid db at-username) + inbox-document (common-db/get-block-property-document db [:block/uid page-uid]) + inbox-uid (:block/uid (get inbox-document "athens/inbox"))] + inbox-uid)) + + +(defn create-comments-inbox + [db at-username] + (let [inbox-uid (common.utils/gen-block-uid)] + [[(->> (bfs/internal-representation->atomic-ops + db + [#:block{:uid inbox-uid + :string "" + :properties {":entity/type" + #:block{:string "[[athens/inbox]]" + :uid (common.utils/gen-block-uid)}}}] + {:relation {:page/title "athens/inbox"} + :page/title at-username}) + (composite/make-consequence-op {:op/type :new-inbox}))] + inbox-uid])) + + +(defn create-userpage + [db at-username] + (let [new-page-op [(graph-ops/build-page-new-op db at-username)] + [comments-inbox-op + inbox-uid] (create-comments-inbox db at-username) + userpage-inbox-op (concat new-page-op + comments-inbox-op)] + [userpage-inbox-op inbox-uid])) + + +(defn get-userpage-data + ;; Returns a list of all the subscriber maps + ;; {:inbox-uid "some-uid" + ;; :name "subscriber-name"} + ;; Someone can subscribe to a thread without having the userpage, inbox or both. + ;; So in that case we need to create these depending on the situation. + [db userpage] + (let [at-username (common-db/strip-markup userpage "[[" "]]") + user-exists? (common-db/get-page-uid db at-username) + [new-userpage-op + comments-inbox-uid] (when (not user-exists?) + (create-userpage db at-username)) + ;; inbox-uid being nil means user page exists but inbox does not + inbox-uid (or (get-inbox-uid-for-user db at-username) + comments-inbox-uid) + [new-inbox-op + inbox-uid] (if (not inbox-uid) + (create-comments-inbox db at-username) + [[] inbox-uid]) + userpage-inbox-op (concat [] + new-userpage-op + new-inbox-op)] + {:inbox-uid inbox-uid + :userpage userpage + :userpage-inbox-op userpage-inbox-op})) diff --git a/src/cljs/athens/views/notifications/popover.cljs b/src/cljs/athens/views/notifications/popover.cljs new file mode 100644 index 0000000000..81a513169b --- /dev/null +++ b/src/cljs/athens/views/notifications/popover.cljs @@ -0,0 +1,205 @@ +(ns athens.views.notifications.popover + (:require + ["/components/Empty/Empty" :refer [Empty EmptyTitle EmptyIcon EmptyMessage]] + ["/components/Icons/Icons" :refer [BellIcon ArrowRightIcon]] + ["/components/Notifications/NotificationItem" :refer [NotificationItem]] + ["/timeAgo.js" :refer [timeAgo]] + ["@chakra-ui/react" :refer [Badge Text Box Heading VStack IconButton PopoverBody PopoverTrigger Popover PopoverContent PopoverCloseButton PopoverHeader Button]] + [athens.common-db :as common-db] + [athens.db :as db] + [athens.parse-renderer :as parse-renderer] + [athens.reactive :as reactive] + [athens.router :as router] + [athens.views.notifications.actions :as actions] + [athens.views.notifications.core :as notifications :refer [get-inbox-uid-for-user]] + [re-frame.core :as rf] + [reagent.core :as r])) + + +(rf/reg-sub + :notification/show-popover? + (fn [db [_]] + (= true (:notification/show-popover db)))) + + +(rf/reg-event-fx + :notification/toggle-popover + (fn [{:keys [db]} [_]] + (println "toggle notification popover") + (let [current-state (:notification/show-popover db)] + {:db (assoc db :notification/show-popover (not current-state))}))) + + +(defn get-notification-type-for-popover + [prop] + (let [type (:block/string (get prop "athens/notification/type"))] + (cond + (= type "[[athens/notification/type/comment]]") "Comments" + (= type "[[athens/notification/type/mention]]") "Mentions" + (= type "[[athens/notification/type/task/assigned/to]]") "Assignments" + (= type "[[athens/notification/type/task/assigned/by]]") "Created"))) + + +(defn get-archive-state + [prop] + (let [state (:block/string (get prop "athens/notification/is-archived"))] + (= state "true"))) + + +(defn get-read-state + [prop] + (let [state (:block/string (get prop "athens/notification/is-read"))] + (= state "true"))) + + +(defn outliner->inbox-notifs + [db notification] + (let [{:block/keys [properties uid create]} notification + notif-type (get-notification-type-for-popover properties) + ;; we could use presence auth instead of notification trigger + _presence-auth (-> create + :event/auth + :presence/id) + create-time (-> create + :event/time + :time/ts + timeAgo) + archive-state (get-archive-state properties) + read-state (get-read-state properties) + trigger-parent-uid (-> (get properties "athens/notification/trigger/parent") + :block/string + (common-db/strip-markup "((" "))")) + trigger-parent-string (-> (common-db/get-block db [:block/uid trigger-parent-uid]) + :block/string) + username (-> (get properties "athens/notification/trigger/author") + :block/string + (common-db/strip-markup "[[" "]]")) + trigger-uid (-> (get properties "athens/notification/trigger") + :block/string + (common-db/strip-markup "((" "))")) + body (-> (common-db/get-block db [:block/uid trigger-uid]) + :block/string)] + {"id" uid + "type" notif-type + "isArchived" archive-state + "notificationTime" create-time + "isRead" read-state + "body" body + "object" {"name" trigger-parent-string + "parentUid" trigger-parent-uid} + "subject" {"username" username}})) + + +(defn filter-hidden-notifs + [inbox-notif] + (not (get inbox-notif "isArchived"))) + + +(def event-verb + {"Comments" "commented on" + "Mentions" "mentioned you in" + "Assignments" "assigned you to" + "Created" "created"}) + + +(defn get-inbox-items-for-popover + [db at-username] + (let [inbox-uid (get-inbox-uid-for-user db at-username) + reactive-inbox (reactive/get-reactive-block-document [:block/uid inbox-uid]) + reactive-inbox-items-ids (->> reactive-inbox + :block/children + (map :db/id)) + reactive-inbox-items (mapv #(reactive/get-reactive-block-document %) reactive-inbox-items-ids) + notifications-for-popover (->> (mapv #(outliner->inbox-notifs db %) reactive-inbox-items) + (filterv filter-hidden-notifs))] + notifications-for-popover)) + + +;; TODO: if already on user page, close +(defn on-click-notification-item + [parent-uid notification-uid] + (router/navigate-uid parent-uid) + (rf/dispatch (actions/update-state-prop notification-uid "athens/notification/is-read" "true"))) + + +(defn notifications-popover + [] + (let [username (rf/subscribe [:username])] + (fn [] + (let [user-page-title (str "@" @username) + notification-list (get-inbox-items-for-popover @db/dsdb user-page-title) + navigate-user-page #(router/navigate-page user-page-title) + notifications-grouped-by-object (group-by #(get % "object") notification-list) + num-notifications (count notification-list)] + + [:> Popover {:closeOnBlur false + :isLazy true + :size "lg"} + [:> PopoverTrigger + [:> Box {:position "relative"} + [:> IconButton {"aria-label" "Notifications" + :onDoubleClick navigate-user-page + :onClick (fn [e] + (when (.. e -shiftKey) + (rf/dispatch [:right-sidebar/open-item [:node/title user-page-title]]))) + :icon (r/as-element [:> BellIcon])}] + (when (> num-notifications 0) + [:> Badge {:position "absolute" + :bg "gold" + :pointerEvents "none" + :color "goldContrast" + :right "-3px" + :bottom "-1px" + :zIndex 1} num-notifications])]] + + [:> PopoverContent {:maxHeight "calc(var(--app-height) - 4rem)"} + [:> PopoverCloseButton] + [:> PopoverHeader + [:> Button {:onClick navigate-user-page :rightIcon (r/as-element [:> ArrowRightIcon])} + "Notifications"]] + [:> VStack {:as PopoverBody + :flexDirection "column" + :align "stretch" + :overflowY "auto" + :overscrollBehavior "contain" + :spacing 6 + :p 2} + + (doall + (if (seq notifications-grouped-by-object) + (for [[object notifs] notifications-grouped-by-object] + + ^{:key (get object "parentUid")} + [:> VStack {:align "stretch" + :key (str (:parentUid object))} + [:> Heading {:size "xs" + :fontWeight "normal" + :noOfLines 1 + :color "foreground.secondary" + :lineHeight "base" + :px 2 + :pt 2} + [parse-renderer/parse-and-render (or (get object "name") (get object "string")) (get object "parentUid")]] + + (for [notification notifs] + + ^{:key (get notification "id")} + [:> NotificationItem + {:notification notification + :onOpenItem on-click-notification-item + :onMarkAsRead #(rf/dispatch (actions/update-state-prop % "athens/notification/is-read" "true")) + :onMarkAsUnread #(rf/dispatch (actions/update-state-prop % "athens/notification/is-read" "false")) + :onArchive #(rf/dispatch (actions/update-state-prop % "athens/notification/is-archived" "true"))} + [:> Text {:fontWeight "bold" :noOfLines 2 :fontSize "sm"} + (str (get-in notification ["subject" "username"]) " " + (get event-verb (get notification "type")) " ") + [parse-renderer/parse-and-render (or + (get object "name") + (get object "string")) + (:id notification)]] + [:> Text [parse-renderer/parse-and-render (get notification "body")]]])]) + + [:> Empty {:size "sm" :py 8} + [:> EmptyIcon] + [:> EmptyTitle "All clear"] + [:> EmptyMessage "Unread notifications will appear here."]]))]]])))) diff --git a/src/cljs/athens/views/pages/all_pages.cljs b/src/cljs/athens/views/pages/all_pages.cljs new file mode 100644 index 0000000000..03ec86102e --- /dev/null +++ b/src/cljs/athens/views/pages/all_pages.cljs @@ -0,0 +1,92 @@ +(ns athens.views.pages.all-pages + (:require + ["/components/AllPagesTable/AllPagesTable" :refer [AllPagesTable]] + [athens.common-db :as common-db] + [athens.dates :as dates] + [athens.db :as db] + [athens.router :as router] + [clojure.string :refer [lower-case]] + [re-frame.core :as rf])) + + +;; Sort state and logic + +(defn- get-sorted-by + [db] + (get db :all-pages/sorted-by :links-count)) + + +(rf/reg-sub + :all-pages/sorted-by + (fn [db _] + (get-sorted-by db))) + + +(rf/reg-sub + :all-pages/sort-order-ascending? + (fn [db _] + (get db :all-pages/sort-order-ascending? false))) + + +(def sort-fn + {:title (fn [x] (-> x :node/title lower-case)) + :links-count (fn [x] (count (:block/_refs x))) + :modified :time/modified + :created :time/created}) + + +(defn add-modified + [{:block/keys [create edits] :as page}] + (assoc page + :time/modified (->> edits + (map (comp :time/ts :event/time)) + last) + :time/created (-> create :event/time :time/ts))) + + +(rf/reg-sub + :all-pages/sorted + :<- [:all-pages/sorted-by] + :<- [:all-pages/sort-order-ascending?] + (fn [[sorted-by growing?] [_ pages]] + (->> pages + (map add-modified) + (sort-by (get sort-fn sorted-by) + (if growing? compare (comp - compare)))))) + + +(rf/reg-event-fx + :all-pages/sort-by + (fn [{:keys [db]} [_ column-id]] + (let [sorted-column (get-sorted-by db) + db' (if (= column-id sorted-column) + (update db :all-pages/sort-order-ascending? not) + (-> db + (assoc :all-pages/sorted-by column-id) + (assoc :all-pages/sort-order-ascending? (= column-id :title))))] + {:db db' + :dispatch [:posthog/report-feature :all-pages]}))) + + +(defn page + [] + (let [all-pages (common-db/get-all-pages @db/dsdb)] + (fn [] + (let [sorted-pages @(rf/subscribe [:all-pages/sorted all-pages])] + [:> AllPagesTable {:sortedPages (clj->js sorted-pages :keyword-fn str) + :sortedBy @(rf/subscribe [:all-pages/sorted-by]) + :dateFormatFn #(dates/date-string %) + :sortDirection (if @(rf/subscribe [:all-pages/sort-order-ascending?]) "asc" "desc") + :onClickSort #(rf/dispatch [:all-pages/sort-by (cond + (= % "title") :title + (= % "links-count") :links-count + (= % "modified") :modified + (= % "created") :created)]) + :onClickItem (fn [e title] + (let [shift? (.-shiftKey e)] + (rf/dispatch [:reporting/navigation {:source :all-pages + :target :page + :pane (if shift? + :right-pane + :main-pane)}]) + (router/navigate-page title e)))}])))) diff --git a/src/cljs/athens/views/pages/core.cljs b/src/cljs/athens/views/pages/core.cljs new file mode 100644 index 0000000000..1b3d6d2b87 --- /dev/null +++ b/src/cljs/athens/views/pages/core.cljs @@ -0,0 +1,34 @@ +(ns athens.views.pages.core + (:require + [athens.util :refer [toast]] + [athens.views.hoc.perf-mon :as perf-mon] + [athens.views.pages.all-pages :as all-pages] + [athens.views.pages.daily-notes :as daily-notes] + [athens.views.pages.graph :as graph] + [athens.views.pages.page :as page] + [re-frame.core :as rf])) + + +;; View + +(defn view + [] + (let [route-name (rf/subscribe [:current-route/name])] + ;; TODO: create a UI to inform the player of the connection status + (when (= @(rf/subscribe [:connection-status]) :reconnecting) + (toast (clj->js {:status "info" + :title "Reconnecting to server..."}))) + [:<> + (case @route-name + :pages [perf-mon/hoc-perfmon-no-new-tx {:span-name "pages/all-pages"} + [all-pages/page]] + :page [perf-mon/hoc-perfmon {:span-name "pages/page"} + [:f> page/page]] + :page-by-title [perf-mon/hoc-perfmon {:span-name "pages/page-by-title"} + [:f> page/page-by-title]] + :home [perf-mon/hoc-perfmon {:span-name "pages/home-page"} + [:f> daily-notes/page]] + :graph [perf-mon/hoc-perfmon-no-new-tx {:span-name "pages/graph"} + [graph/page]] + [perf-mon/hoc-perfmon-no-new-tx {:span-name "pages/default"} + [:f> daily-notes/page]])])) diff --git a/src/cljs/athens/views/pages/daily_notes.cljs b/src/cljs/athens/views/pages/daily_notes.cljs new file mode 100644 index 0000000000..143d421d13 --- /dev/null +++ b/src/cljs/athens/views/pages/daily_notes.cljs @@ -0,0 +1,61 @@ +(ns athens.views.pages.daily-notes + (:require + ["/components/Page/Page" :refer [Page PageHeader PageOverline TitleContainer]] + ["@chakra-ui/react" :refer [Box VStack]] + ["react" :as react] + ["react-intersection-observer" :refer [useInView]] + [athens.dates :as dates] + [athens.reactive :as reactive] + [athens.views.pages.node-page :as node-page] + [re-frame.core :refer [dispatch subscribe]])) + + +(defn reactive-pull-many + "Need a reactive pull because block/uid doesn't exist yet in datascript, but is found in :daily-notes/items. + This happens because (dispatch [:daily-note/next (dates/get-day)]) updates re-frame faster than the datascript tx can happen + + Bug: It's still possible for a day to not get created. The UI for this just shows an empty page without a title. Acceptable bug :)" + [ids] + (->> ids + (keep #(reactive/get-reactive-block-document [:block/uid %])) + (filter :block/uid))) + + +;; Components + + +(defn page + [] + (let [note-refs (subscribe [:daily-notes/items]) + get-another-note #(dispatch [:daily-note/next (dates/get-day (dates/uid-to-date %) 1)])] + (fn [] + (when (empty? @note-refs) + (dispatch [:daily-note/next (dates/get-day)])) + (let [notes (reactive-pull-many @note-refs) + [ref in-view?] (useInView {:delay 250}) + _ (react/useLayoutEffect + (fn [] + (when (and (last @note-refs) in-view?) (get-another-note (last @note-refs))) + js/undefined) + #js [in-view? note-refs])] + [:> VStack {:align "stretch" + :alignSelf "stretch" + :spacing 4 + :pt "4rem" + :px 4} + (doall + (for [{:keys [block/uid]} notes] + ^{:key uid} + [node-page/page + [:block/uid uid] + {:variant "elevated" + :alignSelf "stretch" + :minHeight "calc(var(--app-height) - 6rem)"}])) + + [:> Page {:minHeight "calc(var(--app-height) - 6rem)" + :alignSelf "stretch" + :variant "elevated"} + [:> Box {:ref ref}] + [:> PageHeader + [:> PageOverline "Daily Notes"] + [:> TitleContainer "Loading..."]]]])))) diff --git a/src/cljs/athens/views/pages/graph.cljs b/src/cljs/athens/views/pages/graph.cljs new file mode 100644 index 0000000000..cf816679f8 --- /dev/null +++ b/src/cljs/athens/views/pages/graph.cljs @@ -0,0 +1,438 @@ +^:cljstyle/ignore +(ns ^{:doc + " + Graph and controls are designed to work with local and global graph + global graphs vs local graphs -- local graphs have an explicit root node + and customizations are based on that where as global doesn't have an explicit root"} + athens.views.pages.graph + (:require + ["@chakra-ui/react" :refer [Box Switch VStack FormControl FormLabel Input Accordion AccordionButton AccordionItem AccordionPanel]] + ["react-force-graph-2d" :as ForceGraph2D] + [athens.dates :as dates] + [athens.db :as db] + [athens.router :as router] + [clojure.set :as set] + [datascript.core :as d] + [re-frame.core :as rf :refer [subscribe]] + [reagent.core :as r] + [reagent.dom :as dom])) + + +(def THEME-DARK + {:graph-node-normal "hsla(0, 0%, 100%, 0.57)" + :graph-node-hlt "#498eda" + :graph-link-normal "#ffffff11"}) + + +(def THEME-LIGHT + {:graph-node-normal "#909090" + :graph-node-hlt "#0075E1" + :graph-link-normal "#cfcfcf"}) + + +;; all graph refs(react refs) reside in this atom +;; saving this to re-frame db is not ideal because of serialization +;; and objects losing their refs +(def graph-ref-map (r/atom {})) + + +;; ------------------------------------------------------------------- +;; --- re-frame stuff --- +;; --- read comments at top of file for more --- + + +(rf/reg-sub + :graph/conf + (fn [db _] + (-> db :athens/persist :graph-conf))) + + +(rf/reg-event-fx + :graph/set-conf + (fn [{:keys [db]} [_ k v]] + {:db (update-in db [:athens/persist :graph-conf] #(assoc % k v)) + :dispatch [:posthog/report-feature :graph]})) + + +(rf/reg-sub + :graph/ref + (fn [db [_ key]] + (get-in db [:graph-ref key]))) + + +(rf/reg-event-db + :graph/set-graph-ref + (fn [db [_ key val]] + (assoc-in db [:graph-ref key] val))) + + +;; ------------------------------------------------------------------- +;; --- graph data --- + + +(defn build-nodes + [] + (let [all-nodes (d/q '[:find [?e ...] + :where + [?e :node/title _]] + @db/dsdb) + nodes-with-refs (d/q '[:find [?e ...] + :where + [?e :node/title _] + [_ :block/refs ?e]] + @db/dsdb) + nodes-without-refs (set/difference (set all-nodes) (set nodes-with-refs)) + nodes-with-refs (d/q '[:find ?e ?u ?t (count ?r) + :in $ [?e ...] + :where + [?e :node/title ?t] + [?e :block/uid ?u] + [?r :block/refs ?e]] + @db/dsdb nodes-with-refs) + nodes-without-refs (d/q '[:find ?e ?u ?t ?c + :in $ [?e ...] + :where + [?e :node/title ?t] + [?e :block/uid ?u] + [(get-else $ ?e :always-nil-value 1) ?c]] + @db/dsdb nodes-without-refs) + all-nodes (map (fn [[e u t val]] + {"id" e + "uid" u + "label" t + "val" val}) + (concat nodes-with-refs nodes-without-refs))] + all-nodes)) + + +(defn build-links + [] + (->> (d/q '[:find ?e ?u ?r + :where + [?e :node/title ?t] + [?e :block/uid ?u] + [?r :block/refs ?e]] + @db/dsdb) + (map (fn [[node-eid node-uid ref]] + (let [first-parent (-> ref + db/get-parents-recursively + first)] + {"source" (:db/id first-parent) + "source-uid" node-uid + "target" node-eid + "target-uid" (:block/uid first-parent)}))) + (remove (fn [x] + (or (= (get x "source") nil) + (= (get x "target") nil)))))) + + +(defn linked-nodes + [all-links node-id] + (->> all-links + (map (fn [link] + (cond + (= (get link "source") node-id) + (get link "target") + + (= (get link "target") node-id) + (get link "source") + + :else nil))) + (remove nil?) + set)) + + +(defn n-level-linked + "Nodes that are n levels away from current node" + [all-links node-id levels] + (loop [cur-nodes #{node-id} + levels levels] + (if (= levels 0) + cur-nodes + (recur (apply set/union cur-nodes (mapv #(linked-nodes all-links %) cur-nodes)) + (- levels 1))))) + + +;; ------------------------------------------------------------------- +;; --- comps --- + + +(defn graph-controls + [local-node-eid] + (fn [] + (let [graph-conf @(subscribe [:graph/conf]) + graph-ref (get @graph-ref-map (or local-node-eid :global))] + [:> Accordion {:width "14em" + :position "absolute" + :bg "background.basement" + :overflow "hidden" + :p 0 + :borderRadius "md" + :allowToggle true + :allowMultiple true + :bottom 2 + :right 2} + (when-not local-node-eid + [:> AccordionItem {:borderTop 0} + [:> AccordionButton {:borderRadius "sm"} + "Nodes"] + [:> AccordionPanel + [:> VStack {:align "stretch"} + [:> FormControl + [:> FormLabel "Highlighted link levels"] + [:> Input {:type "number" + :value (or (:hlt-link-levels graph-conf) 1) + :min 1 + :max 5 + :step 1 + :onChange (fn [e] (rf/dispatch [:graph/set-conf :hlt-link-levels (.. e -target -value)]))}]] + [:> Switch {:isChecked (:orphans? graph-conf) + :onChange (fn [e] + (rf/dispatch [:graph/set-conf :orphans? (.. e -target -checked)]) + (.d3ReheatSimulation graph-ref))} + "Orphan nodes"] + [:> Switch {:isChecked (:daily-notes? graph-conf) + :onChange (fn [e] + (rf/dispatch [:graph/set-conf :daily-notes? (.. e -target -checked)]) + (.d3ReheatSimulation graph-ref))} + "Daily notes"]]]]) + [:> AccordionItem + [:> AccordionButton {:borderRadius "sm"} + "Forces"] + [:> AccordionPanel + [:> VStack {:align "stretch"} + [:> FormControl + [:> FormLabel "Link distance"] + [:> Input {:type "number" + :value (:link-distance graph-conf) + :min 5 + :max 95 + :step 10 + :onChange (fn [e] + ((and graph-ref (.. graph-ref (d3Force "link") (distance (.. e -target -value))))) + (.d3ReheatSimulation graph-ref))}]] + [:> FormControl + [:> FormLabel "Attraction force"] + [:> Input {:type "number" + :value (:charge-strength graph-conf) + :min -30 + :max 0 + :step 5 + :onChange (fn [e] + ((and graph-ref (.. graph-ref (d3Force "charge") (distance (.. e -target -value))))) + (.d3ReheatSimulation graph-ref))}]]]]] + (when local-node-eid + [:> AccordionItem + [:> AccordionButton {:borderRadius "sm"} + "Local options"] + [:> AccordionPanel + [:> VStack {:align "stretch"} + [:> FormControl + [:> FormLabel "Local depth"] + [:> Input {:type "number" + :value (:local-depth graph-conf) + :min 1 + :max 5 + :step 1 + :onChange (fn [e] (rf/dispatch [:graph/set-conf :local-depth (.. e -target -value)]))}]] + [:> Switch {:isChecked (:root-links-only? graph-conf) + :onChange (fn [e] + (rf/dispatch [:graph/set-conf :root-links-only? (.. e -target -checked)]))} + "Only root links?"]]]])]))) + + +(defn graph-root + "Main graph-root where react-force-graph comp is rendered + Flow: + build-links -> find nodes based on conf or build all nodes(local, daily and orphan node filter) + further filter down links based on nodes(cleaning up)" + ([] [graph-root nil]) + ([local-node-eid] + (let [highlight-nodes (r/atom #{}) + highlight-links (r/atom #{}) + dimensions (r/atom {})] + (r/create-class + {:component-did-mount + (fn [this] + (let [dom-node (dom/dom-node this) + dom-root (if local-node-eid ".graph-page" "#app") + graph-conf @(subscribe [:graph/conf]) + graph-ref (get @graph-ref-map (or local-node-eid :global))] + ;; set canvas dimensions + (swap! dimensions assoc :width (-> dom-node (.. (closest dom-root)) + .-parentNode .-clientWidth)) + (swap! dimensions assoc :height (-> dom-node (.. (closest dom-root)) + .-parentNode .-clientHeight)) + ;; set init forces for graph + (when graph-ref + (.. (.. graph-ref (d3Force "charge")) + (distanceMax (/ (min (:width @dimensions) + (:height @dimensions)) + 2))) + (let [c-force (.. graph-ref (d3Force "center"))] + (c-force (/ (:width @dimensions) 2) (/ (:height @dimensions) 2))) + + (.. (.. graph-ref (d3Force "charge")) (strength (:charge-strength graph-conf))) + (.. (.. graph-ref (d3Force "link")) (distance (:link-distance graph-conf))) + (.d3ReheatSimulation graph-ref)))) + + :component-will-unmount + (fn [_this] + (swap! graph-ref-map assoc (or local-node-eid :global) nil)) + + :reagent-render + (fn [local-node-eid] + (let [dark? @(rf/subscribe [:theme/dark]) + graph-conf @(subscribe [:graph/conf]) + all-links (build-links) + all-nodes-with-links (->> all-links (mapcat #(vals %)) set) + linked-nodes-without-daily-notes (->> all-links + (remove (fn [link] + (or (dates/is-daily-note (get link "source-uid")) + (dates/is-daily-note (get link "target-uid"))))) + (mapcat #(vals %)) + set) + nodes (cond->> (if local-node-eid + (->> (n-level-linked all-links local-node-eid (:local-depth graph-conf)) + (d/q '[:find ?e ?u ?t (count ?r) + :in $ [?e ...] + :where + [?e :node/title ?t] + [?e :block/uid ?u] + [?r :block/refs ?e]] + @db/dsdb) + (map (fn [[e u t _val]] + {"id" e + "uid" u + "label" t + "val" (if (= e local-node-eid) 8 1)})) + (remove (fn [node-obj] + (nil? (get node-obj "uid")))) + doall) + (build-nodes)) + + (not (:daily-notes? graph-conf)) + (remove (fn [node] + (dates/is-daily-note (get node "uid")))) + + (not (:orphans? graph-conf)) + (filter (fn [node] + (contains? all-nodes-with-links (get node "id")))) + + (and (not (:daily-notes? graph-conf)) + (not (:orphans? graph-conf))) + (filter (fn [node] + (contains? linked-nodes-without-daily-notes (get node "id"))))) + + filtered-nodes-set (->> nodes (map #(get % "id")) set) + + links (cond->> all-links + + (or local-node-eid + (not (:daily-notes? graph-conf)) + (not (:orphans? graph-conf))) + (filter (fn [link-obj] + (and (contains? filtered-nodes-set (get link-obj "source")) + (contains? filtered-nodes-set (get link-obj "target"))))) + + (and local-node-eid + (:root-links-only? graph-conf) + (= (:local-depth graph-conf) 1)) + (filter (fn [link-obj] + (or (= (get link-obj "source") local-node-eid) + (= (get link-obj "target") local-node-eid)))) + + true + (filter (fn [link-obj] + (or (contains? filtered-nodes-set (get link-obj "source")) + (contains? filtered-nodes-set (get link-obj "target")))))) + + theme (if dark? + THEME-DARK + THEME-LIGHT)] + [:> ForceGraph2D + {:graphData {:nodes nodes + :links links} + ;; example data + #_{:nodes [{"id" "foo", "name" "name1", "val" 1} + {"id" "bar", "name" "name2", "val" 10}] + :links [{"source" "foo", "target" "bar"}]} + :width (:width @dimensions) + :height (:height @dimensions) + :ref #(swap! graph-ref-map assoc (or local-node-eid :global) %) + ;; link + :linkColor (fn [] (:graph-link-normal theme)) + ;; node + :nodeCanvasObject (fn [^js node ^js ctx global-scale] + (let [label (.. node -label) + val (.. node -val) + node-id (.. node -id) + x (.. node -x) + y (.. node -y) + scale-factor 3 + font-size (/ 10 global-scale) + text-width (.. ctx (measureText label) -width) + radius (max 1.3 (-> (js/Math.sqrt val) + (/ global-scale) + (* scale-factor))) + highlighted? (contains? @highlight-nodes node-id) + local-root-node? (and local-node-eid node-id (= local-node-eid node-id))] + + ;; node color + (set! (.-fillStyle ctx) + (cond + local-root-node? (:graph-node-hlt theme) + (and highlighted? (not local-node-eid)) (:graph-node-hlt theme) + :else (:graph-node-normal theme))) + + ;; text + (when (> global-scale 1.75) + (set! (.-font ctx) (str (when (and highlighted? (not local-node-eid)) + "bold ") + font-size "px IBM Plex Sans, Sans-Serif")) + (.fillText ctx label + (- x (/ text-width 2)) + (+ y radius font-size))) + + (.beginPath ctx) + ;; https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/arc + (.arc ctx x y radius 0 (* js/Math.PI 2)) + (.fill ctx))) + ;; node actions + :onNodeClick (fn [^js node ^js event] + (let [shift? (.-shiftKey event)] + (rf/dispatch [:reporting/navigation {:source :graph + :target :page + :pane (if shift? + :right-pane + :main-pane)}]) + (router/navigate-page (.. node -label) event))) + :onNodeHover (fn [^js node] + (let [_ (reset! highlight-nodes #{}) + _ (reset! highlight-links #{}) + graph-conf @(rf/subscribe [:graph/conf])] + (when-let [node-id (some-> node (.. -id))] + (reset! highlight-nodes (n-level-linked all-links node-id (:hlt-link-levels graph-conf))))))}]))})))) + + +(defn page + "Designed to work with local or global graphs + Keep in mind block-uid -> db/id (more convenient)" + ([] [page nil]) + ([block-uid] + (let [local-node-eid (when block-uid + (->> [:block/uid block-uid] (d/pull @db/dsdb '[:db/id]) + :db/id))] + [:<> + [:> Box (if local-node-eid + {:class "graph-page" + :alignSelf "stretch" + :justifySelf "stretch" + :overflow "hidden" + :height "20em" + :borderRadius "lg" + :bg "background.basement" + :position "relative"} + {:class "graph-page"}) + [graph-root local-node-eid]] + [graph-controls local-node-eid]]))) diff --git a/src/cljs/athens/views/pages/node_page.cljs b/src/cljs/athens/views/pages/node_page.cljs new file mode 100644 index 0000000000..c9ae2201c0 --- /dev/null +++ b/src/cljs/athens/views/pages/node_page.cljs @@ -0,0 +1,565 @@ +(ns athens.views.pages.node-page + (:require + ["/components/Block/Anchor" :refer [Anchor]] + ["/components/Block/Container" :refer [Container]] + ["/components/Confirmation/Confirmation" :refer [Confirmation]] + ["/components/Icons/Icons" :refer [CalendarIcon ArrowRightOnBoxIcon ArrowLeftOnBoxIcon EllipsisHorizontalIcon GraphIcon BookmarkIcon BookmarkFillIcon TrashIcon ArrowRightOnBoxIcon TimeNowIcon]] + ["/components/Page/Page" :refer [Page PageHeader PageOverline PageHeaderImage PageBody PageFooter TitleContainer]] + ["/components/References/References" :refer [PageReferences ReferenceBlock ReferenceGroup]] + ["@chakra-ui/react" :refer [ButtonGroup Input FormLabel FormControl Button Box HStack Button Portal IconButton MenuDivider MenuButton Menu MenuList MenuItem Breadcrumb BreadcrumbItem BreadcrumbLink VStack]] + [athens.common-db :as common-db] + [athens.common-events.graph.ops :as graph-ops] + [athens.common.sentry :refer-macros [wrap-span-no-new-tx]] + [athens.common.utils :as utils] + [athens.dates :as dates] + [athens.db :as db :refer [get-unlinked-references]] + [athens.parse-renderer :as parse-renderer :refer [parse-and-render]] + [athens.patterns :as patterns] + [athens.reactive :as reactive] + [athens.router :as router] + [athens.time-controls :as time-controls] + [athens.util :refer [get-caret-position recursively-modify-block-for-embed]] + [athens.views.blocks.core :as blocks] + [athens.views.blocks.textarea-keydown :as textarea-keydown] + [athens.views.hoc.perf-mon :as perf-mon] + [clojure.string :as str] + [datascript.core :as d] + [komponentit.autosize :as autosize] + [re-frame.core :as rf :refer [dispatch subscribe]] + [reagent.core :as r]) + (:import + (goog.events + KeyCodes))) + + +;; Helpers + + +(defn handle-new-first-child-block-click + [parent-uid] + (let [new-uid (utils/gen-block-uid) + [parent-uid embed-id] (db/uid-and-embed-id parent-uid) + parent-block (db/get-block [:block/uid parent-uid])] + (dispatch [:enter/add-child {:block parent-block + :new-uid new-uid + :embed-id embed-id}]) + (dispatch [:editing/uid new-uid]))) + + +(defn handle-enter + [e uid _state children] + (.. e preventDefault) + (let [node-page (.. e -target (closest ".node-page")) + block-page (.. e -target (closest ".block-page")) + [uid embed-id] (common-db/uid-and-embed-id uid) + new-uid (utils/gen-block-uid) + {:keys [start value]} (textarea-keydown/destruct-key-down e)] + (cond + block-page (dispatch [:enter/split-block {:uid uid + :value value + :index start + :new-uid new-uid + :embed-id embed-id + :relation :first}]) + node-page (if (empty? children) + (handle-new-first-child-block-click uid) + (dispatch [:down uid]))))) + + +(defn handle-page-arrow-key + [e uid state] + (let [{:keys [key-code target]} (textarea-keydown/destruct-key-down e) + start? (textarea-keydown/block-start? e) + end? (textarea-keydown/block-end? e) + {caret-position :caret-position} @state + textarea-height (.. target -offsetHeight) + {:keys [top height]} caret-position + rows (js/Math.round (/ textarea-height height)) + row (js/Math.ceil (/ top height)) + top-row? (= row 1) + bottom-row? (= row rows) + up? (= key-code KeyCodes.UP) + down? (= key-code KeyCodes.DOWN) + left? (= key-code KeyCodes.LEFT) + right? (= key-code KeyCodes.RIGHT)] + + (cond + (or (and up? top-row?) + (and left? start?)) (do (.. e preventDefault) + (dispatch [:up uid e])) + (or (and down? bottom-row?) + (and right? end?)) (do (.. e preventDefault) + (dispatch [:down uid e]))))) + + +(defn handle-key-down + [e uid state children] + (let [{:keys [key-code shift]} (textarea-keydown/destruct-key-down e) + caret-position (get-caret-position (.. e -target))] + (swap! state assoc :caret-position caret-position) + (cond + (textarea-keydown/arrow-key-direction e) (handle-page-arrow-key e uid state) + (and (not shift) (= key-code KeyCodes.ENTER)) (handle-enter e uid state children)))) + + +(defn auto-inc-untitled + ([] (auto-inc-untitled nil)) + ([n] + (if (empty? (d/q '[:find [?e ...] + :in $ ?t + :where + [?e :node/title ?t]] + @db/dsdb (str "Untitled" (when n (str "-" n))))) + (str "Untitled" (when n (str "-" n))) + (auto-inc-untitled (+ n 1))))) + + +(defn handle-change + [e state] + (let [value (.. e -target -value)] + (swap! state assoc :title/local value))) + + +(declare init-state) + + +(defn handle-blur + "When textarea blurs and its value is different from initial page title: + - if no other page exists, rewrite page title and linked refs + - else page with same title does exists: prompt to merge + - confirm-fn: delete current page, rewrite linked refs, merge blocks, and navigate to existing page + - cancel-fn: reset state + The current blocks will be at the end of the existing page." + [node state] + (let [{page-uid :block/uid} node + {:title/keys [initial + local]} @state + do-nothing? (= initial local)] + (js/console.debug "handle-blur: do-nothing?" do-nothing? + ", local:" (pr-str local) + ", page-uid:" page-uid) + (when-not do-nothing? + (let [existing-page-uid (common-db/get-page-uid @db/dsdb local) + merge? (not (nil? existing-page-uid))] + (js/console.debug "new-page-name:" (pr-str local) + ", existing-page-uid:" (pr-str existing-page-uid)) + (if-not merge? + (dispatch [:page/rename {:page-uid page-uid + :old-name initial + :new-name local + :callback #(swap! state assoc :title/initial local)}]) + + (let [cancel-fn #(swap! state merge init-state) + confirm-fn (fn [] + (rf/dispatch [:reporting/navigation {:source :page-title-merge + :target :page + :pane :main-pane}]) + (router/navigate-page local) + (rf/dispatch [:page/merge {:from-name initial + :to-name local + :callback cancel-fn}]))] + ;; display alert + ;; NOTE: alert should be global reusable component, not local to node_page + (swap! state assoc + :alert/show true + :alert/message (str "\"" local "\"" " already exists. Merge pages?") + :alert/confirm-text "Merge" + :alert/confirm-fn confirm-fn + :alert/cancel-fn cancel-fn))))))) + + +;; Components + +(defn placeholder-block-el + [parent-uid] + [:> Container + [:div.block-body + [:> Anchor] + [:> Button {:flex "1 1 100%" + :py 0 + :height "100%" + :px 2 + :textAlign "left" + :gridArea "content" + :color "foreground.secondary" + :justifyContent "flex-start" + :cursor "text" + :fontWeight "normal" + :onClick #(handle-new-first-child-block-click parent-uid)} + "Type to begin..."]]]) + + +(defn sync-title + "Ensures :title/initial is synced to node/title. + Cases: + - User opens a page for the first time. + - User navigates from a page to another page. + - User merges current page with existing page, navigating to existing page." + [title state] + (when (not= title (:title/initial @state)) + (swap! state assoc :title/initial title :title/local title))) + + +(def init-state + {:menu/show false + :title/initial nil + :title/local nil + :alert/show nil + :alert/message nil + :alert/confirm-fn nil + :alert/cancel-fn nil + :alert/confirm-text nil + "Linked References" true + "Unlinked References" false}) + + +(defn menu-dropdown + [node _daily-note? _on-daily-notes?] + (let [contains-item? (subscribe [:right-sidebar/contains-item? [:node/title (:node/title node)]])] + (fn [node daily-note? on-daily-notes?] + (let [{:block/keys [uid] + sidebar :page/sidebar + title :node/title} node] + [:> Menu {:isLazy true :size "sm"} + [:> MenuButton {:as IconButton + "aria-label" "Page menu" + :gridArea "menu" + :justifySelf "flex-end" + :size "sm" + :alignSelf "center" + :fontSize "70%" + :color "foreground.secondary" + :bg "transparent" + :borderRadius "full" + :sx {"span" {:display "contents"}}} + [:> EllipsisHorizontalIcon]] + [:> Portal + [:> MenuList + [:<> + (if sidebar + [:> MenuItem {:onClick #(dispatch [:left-sidebar/remove-shortcut title]) + :icon (r/as-element [:> BookmarkFillIcon])} + "Remove Shortcut"] + [:> MenuItem {:onClick #(dispatch [:left-sidebar/add-shortcut title]) + :icon (r/as-element [:> BookmarkIcon])} + [:span "Add Shortcut"]]) + [:> MenuItem {:onClick #(dispatch [:right-sidebar/open-item [:node/title title] true]) + :icon (r/as-element [:> GraphIcon])} + "Show Local Graph"] + [:> MenuItem {:onClick #(dispatch [:right-sidebar/open-item [:node/title title]]) + :isDisabled @contains-item? + :icon (r/as-element [:> ArrowRightOnBoxIcon])} + "Open in Sidebar"]] + (when (and (not on-daily-notes?) + (time-controls/enabled?)) + [:<> + [:> MenuItem {:onClick (fn [] + (dispatch [:time/set-page-range title]) + (dispatch [:time/toggle-slider])) + :icon (r/as-element [:> TimeNowIcon])} + "Toggle Time Slider"] + [:> MenuItem {:onClick (fn [] + (dispatch [:time/set-page-range title]) + (dispatch [:time/toggle-heatmap])) + :icon (r/as-element [:> TimeNowIcon])} + "Toggle Time Heatmap"]]) + [:> MenuDivider] + [:> MenuItem {:icon (r/as-element [:> TrashIcon]) + :onClick (fn [] + ;; if page being deleted is in right sidebar, remove from right sidebar + (when @contains-item? + (dispatch [:right-sidebar/close-item [:node/title title]])) + ;; if page being deleted is open, navigate to back + (when (or (= @(subscribe [:current-route/page-title]) title) + (= @(subscribe [:current-route/uid]) uid)) + (rf/dispatch [:reporting/navigation {:source :page-title-delete + :target :back + :pane :main-pane}]) + (.back js/window.history)) + ;; if daily note, delete page and remove from daily notes, otherwise just delete page + (if daily-note? + (dispatch [:daily-note/delete uid title]) + (dispatch [:page/delete title])))} + "Delete Page"]]]])))) + + +(defn ref-comp + [block] + (let [state (r/atom {:block block + :embed-id (random-uuid) + :parents (rest (:block/parents block))}) + linked-ref-data {:linked-ref true + :initial-open true + :linked-ref-uid (:block/uid block) + :parent-uids (set (map :block/uid (:block/parents block)))}] + (fn [_] + (let [{:keys [block parents embed-id]} @state + block (reactive/get-reactive-block-document (:db/id block))] + [:> VStack {:spacing 0 + :align "stretch"} + [:> Breadcrumb {:fontSize "sm" + :variant "strict" + :color "foreground.secondary"} + (doall + (for [{:keys [block/uid]} parents] + [:> BreadcrumbItem {:key (str "breadcrumb-" uid)} + [:> BreadcrumbLink + {:onClick #(let [new-B (db/get-block [:block/uid uid]) + new-P (drop-last parents)] + (swap! state assoc :block new-B :parents new-P))} + [parse-and-render (common-db/breadcrumb-string @db/dsdb uid) uid]]]))] + [:> Box {:class "block-embed"} + [:f> blocks/block-el + (recursively-modify-block-for-embed block embed-id) + linked-ref-data + {:block-embed? true}]]])))) + + +(defn linked-blocks + [header groups start-closed?] + (when (not-empty groups) + [:> PageReferences {:count (count groups) + :title header + :defaultIsOpen (and (> 10 (count groups)) + (not start-closed?))} + (doall + (for [[group-title group] groups] + [:> ReferenceGroup {:key (str "group-" group-title) + :title group-title + :onClickTitle (fn [e] + (let [shift? (.-shiftKey e) + parsed-title (parse-renderer/parse-title group-title)] + (rf/dispatch [:reporting/navigation {:source :main-page-linked-refs ; NOTE: this might be also used in right-pane situation + :target :page + :pane (if shift? + :right-pane + :main-pane)}]) + (router/navigate-page parsed-title e)))} + (doall + (for [block group] + [:> ReferenceBlock {:key (str "ref-" (:block/uid block))} + [ref-comp block]]))]))])) + + +(defn linked-ref-el + [title] + (let [linked-refs (wrap-span-no-new-tx "get-reactive-linked-references" + (reactive/get-reactive-linked-references [:node/title title]))] + + (linked-blocks "Linked References" linked-refs false))) + + +(defn linked-prop-el + [title] + (let [linked-props (wrap-span-no-new-tx "get-reactive-linked-properties" + (reactive/get-reactive-linked-properties [:node/title title]))] + (linked-blocks "Linked Properties" linked-props false))) + + +(defn edited-on-el + [title] + (let [edited-blocks (wrap-span-no-new-tx "get-reactive-linked-properties" + (reactive/get-reactive-edited-on-day-blocks title))] + (linked-blocks "Edited on this day" edited-blocks true))) + + +(defn unlinked-ref-el + [state unlinked-refs title] + (let [unlinked? "Unlinked References" + link-all-unlinked (fn [] + (let [unlinked-str-ids + (->> @unlinked-refs + (mapcat second) + (map #(select-keys % [:block/string :block/uid])))] ; to remove the unnecessary data before dispatching the event + (dispatch [:unlinked-references/link-all unlinked-str-ids title])) + (swap! state assoc unlinked? false) + (reset! unlinked-refs []))] + [:> PageReferences {:defaultIsOpen false + :count (count @unlinked-refs) + :showIfEmpty true + :refs @unlinked-refs + :title "Unlinked References" + :extras (r/as-element [:> Button {:variant "link" + :size "sm" + :flexShrink 0 + :onClick link-all-unlinked} + "Link all"]) + :onOpen #(let [un-refs (get-unlinked-references (patterns/escape-str title))] + (swap! state assoc unlinked? true) + (reset! unlinked-refs un-refs)) + :onClose #(swap! state assoc unlinked? false)} + (doall + (for [[[group-title] group] @unlinked-refs] + [:> ReferenceGroup + {:title group-title + :onClickTitle (fn [e] + (let [shift? (.-shiftKey e) + parsed-title (parse-renderer/parse-title group-title)] + (rf/dispatch [:reporting/navigation {:source :main-unlinked-refs ; NOTE: this isn't always `:main-unlinked-refs` it can also be `:right-pane-unlinked-refs` + :target :page + :pane (if shift? + :right-pane + :main-pane)}]) + (router/navigate-page parsed-title e)))} + (doall + (for [block group] + [:> ReferenceBlock + {:key (str "ref-" (:block/uid block)) + :actions (when unlinked? + (r/as-element [:> Button {:marginTop "1.5em" + :size "xs" + :flex "0 0" + :float "right" + :variant "link" + :onClick (fn [] + (let [hm (into (hash-map) @unlinked-refs) + new-unlinked-refs (->> (update-in hm [group-title] #(filter (fn [{:keys [block/uid]}] + (= uid (:block/uid block))) + %)) + seq)] + ;; ctrl-z doesn't work though, because Unlinked Refs aren't reactive to datascript. + (reset! unlinked-refs new-unlinked-refs) + (dispatch [:unlinked-references/link block title])))} + "Link"]))} + [ref-comp block]]))]))])) + + +;; TODO: where to put page-level link filters? +(defn node-page-el + "title/initial is the title when a page is first loaded. + title/local is the value of the textarea. + We have both, because we want to be able to change the local title without transacting to the db until user confirms. + Similar to atom-string in blocks. Hacky, but state consistency is hard!" + [_] + (let [state (r/atom init-state) + unlinked-refs (r/atom []) + block-uid (r/atom nil) + properties-open? (r/atom false) + properties-enabled? (rf/subscribe [:feature-flags/enabled? :properties]) + cover-photo-enabled? (rf/subscribe [:feature-flags/enabled? :cover-photo])] + (fn [node opts] + (when (not= @block-uid (:block/uid node)) + (reset! state init-state) + (reset! unlinked-refs []) + (reset! block-uid (:block/uid node))) + (let [{:block/keys [children uid properties] title :node/title} node + {:alert/keys [message confirm-fn cancel-fn confirm-text] alert-show :alert/show} @state + daily-note? (dates/is-daily-note uid) + on-daily-notes? (= :home @(subscribe [:current-route/name]))] + + (sync-title title state) + + [:> Page (merge opts {:class "node-page"}) + + [:> Confirmation {:isOpen alert-show + :title message + :confirmText confirm-text + :onConfirm confirm-fn + :onClose cancel-fn}] + + ;; Header + [:> PageHeader + + (when daily-note? + [:> PageOverline [:> CalendarIcon] "Daily Note"]) + + [:> HStack {:justifyContent "space-between" :flexGrow 1} + + [:> TitleContainer {:isEditing @(subscribe [:editing/is-editing uid])} + ;; Prevent editable textarea if a node/title is a date + ;; Don't allow title editing from daily notes, right sidebar, or node-page itself. + + (when-not daily-note? + [autosize/textarea + {:value (:title/local @state) + :id (str "editable-uid-" uid) + :class (when @(subscribe [:editing/is-editing uid]) "is-editing") + :on-blur (fn [_] + ;; add title Untitled-n for empty titles + (when (empty? (:title/local @state)) + (swap! state assoc :title/local (auto-inc-untitled))) + (handle-blur node state)) + :on-key-down (fn [e] (handle-key-down e uid state children)) + :on-change (fn [e] (handle-change e state))}]) + + [:> HStack {:width "fit-content" :gridArea "main"} + ;; empty word break to keep span on full height else it will collapse to 0 height (weird ui) + (if (str/blank? (:title/local @state)) + [:wbr] + [perf-mon/hoc-perfmon {:span-name "parse-and-render"} + [parse-renderer/parse-and-render (:title/local @state) uid]]) + + ;; Dropdown + [menu-dropdown node daily-note? on-daily-notes?]]] + + [:> ButtonGroup {:size "sm" + :colorScheme "subtle" + :variant "ghost"} + (when @cover-photo-enabled? + [:> Button {:onClick #(swap! properties-open? not)} "Properties"]) + (when on-daily-notes? + [:> IconButton {:icon (r/as-element [:> ArrowLeftOnBoxIcon]) + :aria-label "Open in Main View" + :onClick (fn [e] (router/navigate-page title e))}]) + (when-not @(subscribe [:right-sidebar/contains-item? [:node/title title]]) + [:> IconButton {:icon (r/as-element [:> ArrowRightOnBoxIcon]) + :aria-label "Open in Right sidebar" + :onClick #(dispatch [:right-sidebar/open-item [:node/title title]])}])]] + + (when @properties-open? + [:> VStack {:align "stretch"} + [:> FormControl + [:> FormLabel "Header Image URL"] + [:> Input {:defaultValue (-> properties (get ":header/url") :block/string) + :onBlur (fn [e] + (dispatch [:graph/update-in [:node/title title] [":header/url"] + (fn [db uid] [(graph-ops/build-block-save-op db uid (.. e -target -value))])]))}]]]) + + + (when (and @cover-photo-enabled? (-> properties (get ":header/url") :block/string)) + [:> PageHeaderImage {:src (-> properties (get ":header/url") :block/string)}])] + + [:> PageBody + + (when-not on-daily-notes? + [time-controls/slider title]) + + (when (empty? children) + [placeholder-block-el uid]) + + ;; Properties + [:<> + (when (and @properties-enabled? + (seq properties)) + (for [prop (common-db/sort-block-properties properties)] + ^{:key (:db/id prop)} + [:f> blocks/block-el prop]))] + + ;; Children + [:<> + (for [{:block/keys [uid] :as child} children] + ^{:key uid} + [perf-mon/hoc-perfmon {:span-name "block-el"} + [:f> blocks/block-el child]])]] + + ;; References + [:> PageFooter + [:> VStack {:spacing 2 :py 4 :align "stretch"} + [perf-mon/hoc-perfmon-no-new-tx {:span-name "linked-ref-el"} + [linked-ref-el title]] + [perf-mon/hoc-perfmon-no-new-tx {:span-name "linked-prop-el"} + [linked-prop-el title]] + (when (and daily-note? + (not on-daily-notes?)) + [perf-mon/hoc-perfmon-no-new-tx {:span-name "edited-on-el"} + [edited-on-el title]]) + (when-not on-daily-notes? + [perf-mon/hoc-perfmon-no-new-tx {:span-name "unlinked-ref-el"} + [unlinked-ref-el state unlinked-refs title]])]]])))) + + +(defn page + [ident opts] + (let [node (wrap-span-no-new-tx "db/get-reactive-node-document" + (reactive/get-reactive-node-document ident))] + [node-page-el node opts])) diff --git a/src/cljs/athens/views/pages/page.cljs b/src/cljs/athens/views/pages/page.cljs new file mode 100644 index 0000000000..eefd2fe273 --- /dev/null +++ b/src/cljs/athens/views/pages/page.cljs @@ -0,0 +1,31 @@ +(ns athens.views.pages.page + (:require + ["/components/Page/Page" :refer [PageNotFound]] + [athens.common-db :as common-db] + [athens.db :as db] + [athens.reactive :as reactive] + [athens.router :as router] + [athens.views.blocks.core :as blocks] + [athens.views.pages.node-page :as node-page] + [re-frame.core :as rf])) + + +(defn page-by-title + [] + (let [title (rf/subscribe [:current-route/page-title]) + page-eid (common-db/e-by-av @db/dsdb :node/title @title)] + (if (int? page-eid) + [node-page/page page-eid {:pt "1.75rem"}] + [:> PageNotFound {:title @title + :onClickHome #(router/navigate :pages)}]))) + + +(defn page + "Can be a block or a node page." + [] + (let [uid (rf/subscribe [:current-route/uid]) + {:keys [node/title block/string db/id]} (reactive/get-reactive-block-or-page-by-uid @uid)] + (cond + title [node-page/page id {:pt "1.75rem"}] + string [blocks/page id] + :else [:> PageNotFound {:onClickHome #(router/navigate :pages)}]))) diff --git a/src/cljs/athens/views/pages/settings.cljs b/src/cljs/athens/views/pages/settings.cljs new file mode 100644 index 0000000000..6a4af17174 --- /dev/null +++ b/src/cljs/athens/views/pages/settings.cljs @@ -0,0 +1,336 @@ +(ns athens.views.pages.settings + (:require + ["@chakra-ui/react" :refer [Text Heading Box FormControl FormLabel ButtonGroup Grid Input Button Switch Modal ModalOverlay ModalContent ModalHeader ModalBody ModalCloseButton]] + [athens.db :refer [default-athens-persist]] + [athens.util :refer [toast]] + [cljs-http.client :as http] + [cljs.core.async :refer [js {:title "Account connected" + :status "success"}))) + + ;; Open Collective Lambda doesn't find email + (and (:success resp) (false? (:email_exists (:body resp)))) + (do + (update-fn nil) + + (toast (clj->js {:title "Account not found" + :status "error" + :description "No OpenCollective account was found with this email address."}))) + + ;; Something else, e.g. networking error + :else + (toast (clj->js {:title "Unknown error" + :status "error" + :description resp}))))))) + + +(defn handle-reset-email + [value update-fn] + (reset! value "") + (update-fn nil)) + + +(defn monitoring-off + [update-fn] + (.. js/posthog (capture "opt-out")) + (.. js/window -posthog opt_out_capturing) + (js/localStorage.setItem "sentry" "off") + (update-fn)) + + +(defn monitoring-on + [update-fn] + (.. js/window -posthog opt_in_capturing) + (.. js/posthog (capture "opt-in")) + (js/localStorage.setItem "sentry" "on") + (update-fn)) + + +(defn handle-monitoring-click + [monitoring update-fn] + (if monitoring + (monitoring-off (partial update-fn false)) + (monitoring-on (partial update-fn true)))) + + +;; re-frame + +(rf/reg-sub + :feature-flags/enabled? + :<- [:feature-flags] + (fn [a [_ flag]] + (get a flag))) + + +(reg-event-fx + :settings/update + (fn [{:keys [db]} [_ k v]] + {:db (assoc-in db [:athens/persist :settings k] v)})) + + +(reg-event-fx + :settings/update-in + (fn [{:keys [db]} [_ ks v]] + {:db (assoc-in db (into [:athens/persist :settings] ks) v)})) + + +(reg-event-fx + :settings/reset + (fn [{:keys [db]} _] + {:db (assoc db :athens/persist default-athens-persist) + :dispatch [:boot]})) + + +(rf/reg-event-db + :settings/toggle-open + (fn [db _] + (update db :settings/open? not))) + + +(rf/reg-sub + :settings/open? + (fn [db _] + (:settings/open? db))) + + +;; Components + + +(defn title + [children] + [:> Heading {:size "md"} + children]) + + +(defn header + [children] + [:> Box {:gridArea "header"} children]) + + +(defn glance + [children] + [:> Box children]) + + +(defn form + [children] + [:> Box {:gridArea "form"} children]) + + +(defn help + [children] + [:> Text {:color "foreground.secondary" + :gridArea "help"} children]) + + +(defn setting-wrapper + ([children] + [setting-wrapper {} children]) + ([config children] + (let [{:keys [disabled] :as _props} config] + [:> Grid {:as "section" + :py 7 + :gap "1rem" + :gridTemplateColumns "12rem 1fr" + :gridTemplateAreas "'header form' + 'header help'" + :_first {:borderTop "none"} + :_notFirst {:borderTop "1px solid" + :borderColor "separator.divider"} + :sx {"*" {:opacity (if disabled 0.5 1)}}} + children]))) + + +(defn email-comp + "Two email values. One in `init-state`, one is `value`. Only updates init-state email if valid API response. Therefore, + user is not valid if init-state email is empty string." + [email update-fn] + (let [value (r/atom (:email email))] + (fn [] + [setting-wrapper + [:<> + [header + [title "OpenCollective Address"] + [glance (if (clojure.string/blank? email) + "Not set" + email)]] + [form + [:<> [:> FormControl + [:> FormLabel "Email address"] + [:> Input {:type " email " + :width "25em" + :placeholder " Open Collective Email " + :onChange #(reset! value (.. % -target -value)) + :value @value}]] + [:> ButtonGroup {:pt 2} + [:> Button {:isDisabled (not (clojure.string/blank? email)) + :onClick #(handle-submit-email value update-fn)} + "Submit"] + [:> Button {:onClick #(handle-reset-email value update-fn)} + "Reset"]]]] + [help + [:p (if (clojure.string/blank? email) + "You are using the free version of Athens. You are hosting your own data. Please be careful!" + "Thank you for supporting Athens! Backups are coming soon.")]]]]))) + + +(defn monitoring-comp + [monitoring update-fn] + [setting-wrapper + [:<> + [header + [title "Usage and Diagnostics"]] + [form + [:> Switch {:defaultChecked monitoring + :onChange #(handle-monitoring-click monitoring update-fn)} + "Send usage data and diagnostics to Athens"]] + [help + [:<> [:p "Athens has never and will never look at the contents of your workspace."] + [:p "Athens will never ever sell your data."]]]]]) + + +(defn backup-comp + [backup-time update-fn] + [setting-wrapper + [:<> + [header + [title "On-disk Backups"]] + [form + [:> FormControl + [:> FormLabel "Idle time before saving new backup"] + [:> Input {:type "number" + :defaultValue backup-time + :width "6em" + :mr "0.5rem" + :min 0 + :step 15 + :max 100 + :onBlur #(update-fn (.. % -target -value))}] + " seconds"]] + [help + [:<> [:> Text "Changes are saved immediately."] + [:> Text (str "Athens will save a new backup " backup-time " seconds after your last edit.")]]]]]) + + +(defn feature-flags-comp + [{:keys [comments reactions notifications properties cover-photo time-controls tasks queries]} update-fn] + [setting-wrapper + [:<> + [header + [title "Experimental Feature Flags"]] + [form + [:<> + [:> FormControl + [:> Switch {:isChecked comments + :onChange #(update-fn :comments %)} + "Comments"]] + [:> FormControl + [:> Switch {:isChecked tasks + :onChange #(update-fn :tasks %)} + "Tasks"]] + [:> FormControl + [:> Switch {:isChecked queries + :onChange #(update-fn :queries %)} + "Queries"]] + [:> FormControl + [:> Switch {:isChecked reactions + :onChange #(update-fn :reactions %)} + "Reactions"]] + [:> FormControl + [:> Switch {:isChecked notifications + :onChange #(update-fn :notifications %)} + "Notifications"]] + [:> FormControl + [:> Switch {:isChecked properties + :onChange #(update-fn :properties %)} + "Properties"]] + [:> FormControl + [:> Switch {:isChecked cover-photo + :onChange #(update-fn :cover-photo %)} + "Cover Photo"]] + [:> FormControl + [:> Switch {:isChecked time-controls + :onChange #(update-fn :time-controls %)} + "Time Controls"]]]] + + [help + [:<> + [:p "Optional experimental features that aren't ready for prime time, but that you can still enable to try out."] + [:p "We can't guarantee these will continue working or be supported in the future."]]]]]) + + +(defn remote-backups-comp + [] + [setting-wrapper + {:disabled true} + [:<> + [header + [title "Remote Backups"] + [glance "Coming soon to " + [:a {:href "https://opencollective.com/athens" + :target "_blank" + :rel "noreferrer"} + " paid users and sponsors"]]] + [form + [:> Button {:isDisabled true} "Backup my DB to the cloud"]]]]) + + +(defn reset-settings-comp + [reset-fn] + [setting-wrapper + [:<> + [header + [title "Reset settings"]] + [form + [:> Button {:onClick reset-fn} + "Reset all settings to defaults"]] + [help + [:<> [:> Text "All settings saved between sessions will be restored to defaults."] + [:> Text "Workspaces on disk will not be deleted, but you will need to add them to Athens again."] + [:> Text "Athens will restart after reset and open the default workspace path."]]]]]) + + +(defn page + [] + (let [{:keys [email monitoring backup-time feature-flags]} @(subscribe [:settings])] + [:> Modal {:isOpen true + :scrollBehavior "inside" + :onClose #(rf/dispatch [:settings/toggle-open]) + :size "xl"} + [:> ModalOverlay] + [:> ModalContent {:maxWidth "calc(100% - 8rem)" + :width "50rem" + :my "4rem"} + [:> ModalHeader + {:borderBottom "1px solid" :borderColor "separator.divider"} + "Settings" + [:> ModalCloseButton]] + [:> ModalBody {:flexDirection "column"} + [:<> + [email-comp email #(dispatch [:settings/update :email %])] + [monitoring-comp monitoring #(dispatch [:settings/update :monitoring %])] + [backup-comp backup-time (fn [x] + (dispatch [:settings/update :backup-time x]) + (dispatch [:fs/update-write-db]))] + [feature-flags-comp feature-flags (fn [k e] (dispatch [:settings/update-in [:feature-flags k] (.. e -target -checked)]))] + [remote-backups-comp] + [reset-settings-comp #(dispatch [:settings/reset])]]]]])) diff --git a/src/cljs/athens/views/right_sidebar/core.cljs b/src/cljs/athens/views/right_sidebar/core.cljs new file mode 100644 index 0000000000..232ffe8ac5 --- /dev/null +++ b/src/cljs/athens/views/right_sidebar/core.cljs @@ -0,0 +1,37 @@ +(ns athens.views.right-sidebar.core + (:require + ["/components/Empty/Empty" :refer [Empty EmptyIcon EmptyMessage]] + ["/components/Icons/Icons" :refer [RightSidebarAddIcon]] + ["/components/Layout/List" :refer [List]] + ["/components/Layout/RightSidebar" :refer [RightSidebar]] + [athens.views.right-sidebar.events] + [athens.views.right-sidebar.shared :as shared] + [athens.views.right-sidebar.subs] + [re-frame.core :as rf])) + + +;; Components + +(defn right-sidebar-el + "Resizable: use local atom for width, but dispatch value to re-frame on mouse up. Instantiate local value with re-frame width too." + [open? items on-resize rf-width] + [:> RightSidebar + {:isOpen open? + :onResize on-resize + :rightSidebarWidth rf-width} + (if (empty? items) + [:> Empty {:size "lg" :mt 4} + [:> EmptyIcon {:Icon RightSidebarAddIcon}] + [:> EmptyMessage "Hold " [:kbd "shift"] " when clicking a page link to view the page in the sidebar."]] + [:> List {:items (shared/create-sidebar-list items) + :onUpdateItemsOrder (fn [source-uid target-uid old-index new-index] + (rf/dispatch [:right-sidebar/reorder source-uid target-uid old-index new-index]))}])]) + + +(defn right-sidebar + [] + (let [open? (shared/get-open?) + items (shared/get-items) + on-resize #(rf/dispatch [:right-sidebar/set-width %]) + width @(rf/subscribe [:right-sidebar/width])] + [right-sidebar-el open? items on-resize width])) diff --git a/src/cljs/athens/views/right_sidebar/events.cljs b/src/cljs/athens/views/right_sidebar/events.cljs new file mode 100644 index 0000000000..e3dad83e86 --- /dev/null +++ b/src/cljs/athens/views/right_sidebar/events.cljs @@ -0,0 +1,133 @@ +(ns athens.views.right-sidebar.events + (:require + [athens.common-db :as common-db] + [athens.common-events :as common-events] + [athens.common-events.bfs :as bfs] + [athens.common-events.graph.ops :as graph-ops] + [athens.db :as db] + [athens.interceptors :as interceptors] + [athens.views.right-sidebar.shared :as shared] + [re-frame.core :as rf :refer [reg-event-fx]])) + + +;; UI + +(reg-event-fx + :right-sidebar/toggle + [(interceptors/sentry-span-no-new-tx "right-sidebar/toggle")] + (fn [_ _] + (let [user-page @(rf/subscribe [:presence/user-page])] + {:fx [[:dispatch [:graph/update-in [:node/title user-page] [(shared/ns-str "/open?")] + (fn [db uid] + (let [exists? (common-db/block-exists? db [:block/uid uid])] + [(if exists? + (graph-ops/build-block-remove-op db uid) + (graph-ops/build-block-save-op db uid ""))]))]] + [:dispatch [:posthog/report-feature :right-sidebar true]]]}))) + + +(reg-event-fx + :right-sidebar/set-width + [(interceptors/sentry-span-no-new-tx "right-sidebar/set-width")] + (fn [_ [_ width]] + (let [user-page @(rf/subscribe [:presence/user-page])] + {:fx [[:dispatch [:graph/update-in [:node/title user-page] [(shared/ns-str "/width")] + (fn [db uid] + ;; todo: good place to be using a number primitive type + [(graph-ops/build-block-save-op db uid (str width))])]]]}))) + + +(reg-event-fx + :right-sidebar/scroll-top + [(interceptors/sentry-span-no-new-tx "right-sidebar/scroll-top")] + (fn [] + {:right-sidebar/scroll-top nil})) + + +;; ITEM + +(reg-event-fx + :right-sidebar/open-item + [(interceptors/sentry-span-no-new-tx "right-sidebar/open-item")] + (fn [_ [_ eid is-graph?]] + (let [user-page @(rf/subscribe [:presence/user-page]) + name (second eid) + type (cond + is-graph? "graph" + (= (first eid) :node/title) "page" + :else "block") + new-item-ir [#:block{:string name + :properties + {(shared/ns-str "/items/type") {:block/string type} + (shared/ns-str "/items/open?") {:block/string ""}}}]] + + {:dispatch-n [;; add item + [:graph/update-in [:node/title user-page] [(shared/ns-str "/items")] + (fn [db uid] + (bfs/internal-representation->atomic-ops db new-item-ir {:block/uid uid :relation :first}))] + ;; if athens right sidebar is not open, open + [:graph/update-in [:node/title user-page] [(shared/ns-str "/open?")] + (fn [db uid] + (when-not (common-db/block-exists? db [:block/uid uid]) + [(graph-ops/build-block-save-op db uid "")]))] + [:right-sidebar/scroll-top] + [:posthog/report-feature :right-sidebar true]]}))) + + +(reg-event-fx + :right-sidebar/close-item + [(interceptors/sentry-span-no-new-tx "right-sidebar/close-item")] + (fn [_ [_ uid]] + (let [items (shared/get-items) + num-items (count items)] + {:fx [[:dispatch [:resolve-transact-forward (common-events/build-atomic-event + (graph-ops/build-block-remove-op @db/dsdb uid))]] + (when (= num-items 1) + [:dispatch [:right-sidebar/toggle]]) + [:dispatch [:posthog/report-feature :right-sidebar true]]]}))) + + +(reg-event-fx + :right-sidebar/toggle-item + [(interceptors/sentry-span-no-new-tx "right-sidebar/toggle-item")] + (fn [_ [_ uid]] + {:fx [[:dispatch [:graph/update-in [:block/uid uid] ["athens/right-sidebar/items/open?"] + (fn [db uid] + (if (common-db/block-exists? db [:block/uid uid]) + [(graph-ops/build-block-remove-op db uid)] + [(graph-ops/build-block-save-op db uid "")]))]] + [:dispatch [:posthog/report-feature :right-sidebar true]]]})) + + +(reg-event-fx + :right-sidebar/navigate-item + [(interceptors/sentry-span-no-new-tx "right-sidebar/navigate-item")] + (fn [_ [_ block-page-uid breadcrumb-eid]] + (let [[_attr value] breadcrumb-eid + type (shared/eid->type breadcrumb-eid) + update-uid (->> (shared/get-items) + (filter #(= (:name %) block-page-uid)) + first + :source-uid)] + {:fx [[:dispatch [:graph/update-in [:block/uid update-uid] [(shared/ns-str "/items/type")] + (fn [db uid] + ;; update type + [(graph-ops/build-block-save-op db uid type) + ;; update the entity reference + (graph-ops/build-block-save-op @athens.db/dsdb update-uid value)])]] + [:dispatch [:posthog/report-feature :right-sidebar true]]]}))) + + +(reg-event-fx + :right-sidebar/reorder + [(interceptors/sentry-span-no-new-tx "right-sidebar/navigate-item")] + (fn [_ [_ source-uid target-uid old-index new-index]] + (let [before-after (cond + (< old-index new-index) :after + (> old-index new-index) :before) + evt (common-events/build-atomic-event + (graph-ops/build-block-move-op @athens.db/dsdb source-uid {:relation before-after + :block/uid target-uid}))] + {:fx [[:dispatch [:resolve-transact-forward evt]] + [:dispatch [:posthog/report-feature :right-sidebar true]]]}))) + diff --git a/src/cljs/athens/views/right_sidebar/shared.cljs b/src/cljs/athens/views/right_sidebar/shared.cljs new file mode 100644 index 0000000000..7f0b108319 --- /dev/null +++ b/src/cljs/athens/views/right_sidebar/shared.cljs @@ -0,0 +1,121 @@ +(ns athens.views.right-sidebar.shared + (:require + [athens.common-db :as common-db] + [athens.db :as db] + [athens.parse-renderer :as parse-renderer] + [athens.reactive :as reactive] + [athens.router :as router] + [athens.views.blocks.core :as blocks] + [athens.views.pages.graph :as graph] + [athens.views.pages.node-page :as node-page] + [re-frame.core :as rf] + [reagent.core :as r])) + + +(def NS "athens/right-sidebar") + + +(defn ns-str + ([] + (ns-str "")) + ([s] + (str NS s))) + + +(defn get-open? + [] + (let [user-page @(rf/subscribe [:presence/user-page]) + props (-> (reactive/get-reactive-node-document [:node/title user-page]) + :block/properties) + open? (-> (get props (ns-str "/open?")) + boolean)] + + open?)) + + +(defn get-width + [] + (let [user-page @(rf/subscribe [:presence/user-page]) + props (-> (reactive/get-reactive-node-document [:node/title user-page]) + :block/properties) + default-vw (str 32) + width (-> (get props (ns-str "/width")) + :block/string)] + ;; can also use the clamp function in RightSidebarResizeControl.tsx to make sure the width is always bounded + (or width default-vw))) + + +(defn get-eid + [item-block] + (let [{:keys [type name]} item-block] + (cond + (= type "page") [:node/title name] + (= type "graph") [:node/title name] + :else [:block/uid name]))) + + +(defn eid->type + [eid] + (let [[attr _value] eid] + (cond + (= attr :node/title) "page" + :else "block"))) + + +(defn get-items + [] + (let [user-page @(rf/subscribe [:presence/user-page]) + props (-> (reactive/get-reactive-node-document [:node/title user-page]) + :block/properties) + filter-fn (fn [x] + (common-db/block-exists? @db/dsdb (get-eid x))) + map-props-fn (fn [{:block/keys [uid string properties]}] + (let [type (or (-> (get properties (ns-str "/items/type")) + :block/string) + "block") + open? (-> (get properties (ns-str "/items/open?")) + boolean)] + {:source-uid uid + :name string + :type type + :open? open?})) + items (->> (get props (ns-str "/items")) + :block/children + (sort-by :block/order) + (mapv map-props-fn) + (filter filter-fn))] + items)) + + +(defn create-sidebar-list + "Accepts right-sidebar as a map of uids and entities. + Entity contains either the block uid or node title, and additionally open/close state and whether the page is a graph view or not." + [items] + (doall + (mapv (fn [entity] + (let [{:keys [open? + name + source-uid + type]} entity + eid (get-eid entity) + {:keys [node/title + block/string + block/uid]} (reactive/get-reactive-right-sidebar-item eid) + ;; NOTE: add support for BlockTypeProtocol + entity-title (parse-renderer/parse-to-text (or title string))] + {:isOpen open? + :key source-uid + :id source-uid + :type type + :onRemove #(rf/dispatch [:right-sidebar/close-item source-uid]) + :onToggle #(rf/dispatch [:right-sidebar/toggle-item source-uid]) + :onNavigate (if (= type "page") + #(router/navigate-page title %) + #(router/navigate-uid uid %)) + :title entity-title + :children (r/as-element (cond + (= type "graph") [graph/page name] + (= type "page") [node-page/page eid {:size "sm"}] + :else [blocks/page eid {:size "sm"}]))})) + + items))) diff --git a/src/cljs/athens/views/right_sidebar/subs.cljs b/src/cljs/athens/views/right_sidebar/subs.cljs new file mode 100644 index 0000000000..88efd91e3a --- /dev/null +++ b/src/cljs/athens/views/right_sidebar/subs.cljs @@ -0,0 +1,40 @@ +(ns athens.views.right-sidebar.subs + (:require + [athens.views.right-sidebar.shared :as shared] + [day8.re-frame.tracing :refer-macros [fn-traced]] + [re-frame.core :as rf])) + + +(rf/reg-sub + :right-sidebar/open + (fn-traced [_ _] + (shared/get-open?))) + + +(rf/reg-sub + :right-sidebar/items + (fn-traced [_ _] + (shared/get-items))) + + +(rf/reg-sub + :right-sidebar/contains-item? + (fn-traced [_ [_ eid]] + (let [items (shared/get-items) + [attr value] eid + ;; if the block string matches a page or uid, assume it contains + find (filter (fn [{:keys [name type]}] + (and (= value name) + (or (and (= type "page") (= attr :node/title)) + (and (= type "graph") (= attr :node/title)) + (and (= type "block") (= attr :block/uid))))) + items)] + (seq find)))) + + +(rf/reg-sub + :right-sidebar/width + (fn [_db _] + ;; todo: some value initialization like athens/persist + ;; (:right-sidebar/width db) + (shared/get-width))) diff --git a/src/cljsjs/create_react_class.cljs b/src/cljsjs/create_react_class.cljs deleted file mode 100644 index 6062f1cb4d..0000000000 --- a/src/cljsjs/create_react_class.cljs +++ /dev/null @@ -1,7 +0,0 @@ -(ns cljsjs.create-react-class - (:require - ["react" :as react] - ["create-react-class" :as create-react-class])) - -(js/goog.object.set react "createClass" create-react-class) -(js/goog.exportSymbol "createReactClass" create-react-class) \ No newline at end of file diff --git a/src/cljsjs/marked.cljs b/src/cljsjs/marked.cljs deleted file mode 100644 index b68cf02a23..0000000000 --- a/src/cljsjs/marked.cljs +++ /dev/null @@ -1,4 +0,0 @@ -(ns cljsjs.marked - (:require ["marked" :as marked])) - -(js/goog.exportSymbol "marked" marked) \ No newline at end of file diff --git a/src/js/components/AllPagesTable/AllPagesTable.tsx b/src/js/components/AllPagesTable/AllPagesTable.tsx new file mode 100644 index 0000000000..daf167a70e --- /dev/null +++ b/src/js/components/AllPagesTable/AllPagesTable.tsx @@ -0,0 +1,219 @@ +import React from 'react'; +import { ChevronDownIcon, ChevronUpIcon } from '@/Icons/Icons'; +import { FixedSizeList as List } from 'react-window'; +import { Button, Box, Table, Thead, Tbody, Th, Td, Tr } from '@chakra-ui/react'; + +const DISPLAY_TITLES = { + 'title': 'Title', + 'links-count': 'Links', + 'modified': 'Modified', + 'created': 'Created', +} + + +// react-window adds style props to the children, but +// we don't want all of them, and we'd rather not +// override them below. +const removedStyleKeys = ['left', 'right', 'width']; +const filterStyle = (style) => Object.keys(style).reduce((acc, key) => { + if (!removedStyleKeys.includes(key)) { + acc[key] = style[key]; + } + return acc; +}, {}); + + +const renderDate = (date) => { + if (typeof date === 'string') { + return date + } else { + return "—" + } +} + +const RowTd = ({ children, ...props }) => { + return ( + + {children} + + ) +} + +const Row = ({ index, data, style }) => { + + const item = data[index]; + + return ( + + + + + {item[":block/_refs"]?.length || 0} + {renderDate(item[":time/modified"])} + {renderDate(item[":time/created"])} + + ) +}; + +const getHeight = (el) => { + if (el) { + return el.offsetHeight; + } else { + return 500; + } +} + +export const AllPagesTable = ({ sortedPages, onClickItem, sortedBy, sortDirection, onClickSort, dateFormatFn }) => { + const containerRef = React.useRef(); + const [containerHeight, setContainerHeight] = React.useState(500); + const columns = ['title', 'links-count', 'modified', 'created']; + const rows = React.useMemo(() => sortedPages.map((row) => { + return { + ...row, + onClick: (e) => onClickItem(e, row[":node/title"]), + ":time/modified": dateFormatFn(row[":time/modified"]), + ":time/created": dateFormatFn(row[":time/created"]), + } + }), [sortedPages]); + + // Watch the window for resizing + React.useLayoutEffect(() => { + const updateSize = () => { + setContainerHeight(getHeight(containerRef.current)) + }; + window.addEventListener("resize", updateSize); + updateSize(); + return () => window.removeEventListener("resize", updateSize); + }) + + return + *:nth-of-type(1)": { + flex: "0 0 calc(100% - 39rem)" + }, + "tr > *:nth-of-type(2)": { + flex: "0 0 7rem", + color: "foreground.secondary", + fontSize: "sm" + }, + "tr > *:nth-of-type(3)": { + flex: "0 0 16rem", + color: "foreground.secondary", + fontSize: "sm" + }, + "tr > *:nth-of-type(4)": { + flex: "0 0 16rem", + color: "foreground.secondary", + fontSize: "sm" + }, + }} + > + + + {columns.map((column, index) => { + return + })} + + + div > div": { + display: "flex", + justifyContent: "center", + } + }} + > + + {Row} + + +
+ +
+
+} diff --git a/src/js/components/App/ContextMenuContext.tsx b/src/js/components/App/ContextMenuContext.tsx new file mode 100644 index 0000000000..777f7f39d4 --- /dev/null +++ b/src/js/components/App/ContextMenuContext.tsx @@ -0,0 +1,194 @@ +import { Box, Menu, MenuButton, MenuList, Portal, useOutsideClick } from "@chakra-ui/react"; +import * as React from "react"; + +export const ContextMenuContext = React.createContext(null); + +const NULL_STATE = { + isOpen: false, + position: { x: 0, y: 0 }, + components: [], + sources: [], + onCloseFn: null, + isExclusive: false, +} + +interface addToContextMenuProps { + event: React.MouseEvent, + ref: React.MutableRefObject, + component: () => JSX.Element, + onClose: () => void, + anchorEl?: React.MutableRefObject, + isExclusive?: boolean, + key?: string, +} + +const useContextMenuState = () => { + const [menuState, setMenuState] = React.useState(NULL_STATE); + + // Reset the context menu state + const onCloseMenu = () => { + if (typeof menuState?.onCloseFn === "function") menuState.onCloseFn(); + setMenuState(NULL_STATE); + }; + + let components = []; + let sources = []; + let keys = []; + + /** + * Reveal a menu only for the clicked element. + * To reveal a menu for all clicked menu sources, use onContextMenu instead. + */ + const addToContextMenu = React.useCallback((props: addToContextMenuProps) => { + const { event, ref, component, onClose, anchorEl, key, isExclusive } = props; + event.preventDefault(); + + if (keys.includes(key)) { + // Skip if a menu with the same key is already present + return; + } else { + keys.push(key); + } + + if (!component) { + console.warn("No component provided to addToContextMenu"); + return; + } + if (!event) { + console.warn("No event provided to addToContextMenu"); + return; + } + + // When exclusive, don't add to or update the menu + if (menuState.isExclusive && menuState.isOpen) { + return; + } + + // Store the components and sources for this state's menu + // These are updated by the event bubbling through the DOM + components = [...components, component]; + sources = [...sources, ref.current]; + + let position; + if (anchorEl) { + const { left, top, width, height } = anchorEl.current.getBoundingClientRect(); + position = { + left, top, width, height + } + } else { + position = { + left: event.clientX, + top: event.clientY, + width: 0, + height: 0 + } + } + // Exclusive menus set state immediately and then + // stop the event from creating more menus + if (isExclusive && !menuState.isExclusive) { + event.stopPropagation(); + setMenuState({ + isOpen: true, + position, + sources: [ref.current], + components: [component], + onCloseFn: onClose, + isExclusive: true, + }); + return; + } + + // Normal menus set state after all the + // event handlers have been run + setMenuState({ + isOpen: true, + position, + sources, + components, + onCloseFn: onClose, + isExclusive: menuState.isExclusive, + }); + }, [menuState]); + + /** + * Returns true when the menu is open + * If a ref is passed, it will return true if the ref is open + * @param ref?: React.MutableRefObject, + * @returns boolean + */ + const getIsMenuOpen = (ref?: React.MutableRefObject) => { + if (!ref) return menuState?.isOpen; + return menuState?.sources?.includes(ref.current); + }; + + return { + addToContextMenu, + getIsMenuOpen, + onCloseMenu, + contextMenuPosition: menuState.position, + contextMenuSources: menuState.sources, + isContextMenuOpen: menuState.isOpen, + contextMenuComponents: menuState.components, + }; +} + +const MenuSource = ({ position }) => { + return ; +} + +export const ContextMenuProvider = ({ children }) => { + const contextMenuState = useContextMenuState(); + + const { + contextMenuPosition, + isContextMenuOpen, + contextMenuComponents, + onCloseMenu, + } = contextMenuState; + + const menuRef = React.useRef(null); + + // Close when using mousewheel outside of the menu + React.useEffect(() => { + if (isContextMenuOpen) { + window.addEventListener("wheel", onCloseMenu); + window.addEventListener("dblclick", onCloseMenu); + } + return () => { + window.removeEventListener("wheel", onCloseMenu); + window.removeEventListener("dblclick", onCloseMenu); + } + }, [isContextMenuOpen]) + + return ( + + {children} + + + + + {contextMenuComponents.map((Child, index) => { + if (typeof Child === "function") { + return + } else { + return {Child}; + } + })} + + + + + ); +} \ No newline at end of file diff --git a/src/js/components/AppToolbar/AppToolbar.tsx b/src/js/components/AppToolbar/AppToolbar.tsx new file mode 100644 index 0000000000..88d00b6a8b --- /dev/null +++ b/src/js/components/AppToolbar/AppToolbar.tsx @@ -0,0 +1,425 @@ +import React from 'react'; + +import { + RightSidebarIcon, + MenuIcon, + HelpIcon, + ChatBubbleFillIcon, + ChevronLeftIcon, + ChevronRightIcon, + EllipsisHorizontalCircleIcon, + ChatBubbleIcon, +} from '@/Icons/Icons'; + +import { + Portal, + Menu, + MenuButton, + MenuItem, + MenuList, + Tooltip, + Box, + Flex, + Spacer, + IconButton, + ButtonGroup, + useColorMode, + useMediaQuery, + ButtonGroupProps, + useToast, +} from '@chakra-ui/react'; + +import { AnimatePresence, motion } from 'framer-motion'; +import { LayoutContext, layoutAnimationProps, layoutAnimationTransition } from "@/Layout/useLayoutState"; +import { FakeTrafficLights } from './components/FakeTrafficLights'; +import { WindowButtons } from './components/WindowButtons'; +import { LocationIndicator } from './components/LocationIndicator'; +import { reusableToast } from '@/utils/reusableToast'; + +interface ToolbarButtonGroupProps extends ButtonGroupProps { + key: string +} + +const ToolbarButtonGroup = (props: ToolbarButtonGroupProps) => + +export interface AppToolbarProps extends React.HTMLAttributes { + /** + * The application's current route + */ + currentLocationName: string; + /** + * If the app is in Electron, whether or not it has user focus + */ + isWinFocused: boolean; + /** + * If the app is in Electron, whether or not it is fullscreen + */ + isWinFullscreen: boolean; + /** + * If the app is in Electron, whether or not it is maximized + */ + isWinMaximized: boolean; + /** + * The name of the host OS + */ + os: OS; + /** + * Whether the renderer is in Electron or a browser + */ + isElectron: boolean; + /** + * Whether the shortcuts sidebar is open + */ + isLeftSidebarOpen: boolean; + /** + * Whether the reference sidebar is open + */ + isRightSidebarOpen: boolean; + /** + * Whether the search/create command bar is open + */ + isCommandBarOpen: boolean; + /** + * Whether the choose workspaces dialog is open + */ + isWorkspacesDialogOpen: boolean; + /** + * Whether the help dialog is open + */ + isHelpOpen: boolean; + /** + * Whether the theme is set to dark mode + */ + isThemeDark: boolean; + /** + * Whether comments should be shown + */ + isShowComments: boolean; + currentPageTitle?: string; + // Electron only + onPressMinimize?(): void; + onPressClose?(): void; + onPressMaximizeRestore?(): void; + onPressFullscreen?(): void; + onPressHistoryBack(): void; + onPressHistoryForward(): void; + onClickComments(): void; + // Main toolbar + onPressCommandBar(): void; + onPressDailyNotes(): void; + onPressAllPages(): void; + onPressGraph(): void; + onPressHelp(): void; + onPressThemeToggle(): void; + onPressSettings(): void; + onPressHistoryBack(): void; + onPressHistoryForward(): void; + onPressLeftSidebarToggle(): void; + onPressRightSidebarToggle(): void; + onPressNotification(): void; + workspacesMenu?: React.FC; + notificationPopover?: React.FC; + presenceDetails?: React.FC; +} + +const secondaryToolbarItems = (items) => { + return + {items.filter(x => !!x).map((item) => + + )} + +} + +const secondaryToolbarOverflowMenu = (items) => { + return + {({ isOpen }) => <> + } /> + + + {items.filter(x => !!x).map((item) => ( + {item.label} + + ))} + + + + } + +} + +export const AppToolbar = (props: AppToolbarProps): React.ReactElement => { + const { + os, + currentLocationName, + isElectron, + isWinFullscreen, + isWinFocused, + isWinMaximized, + isThemeDark, + isLeftSidebarOpen, + isRightSidebarOpen, + isShowComments, + onClickComments: handleClickComments, + onPressHelp: handlePressHelp, + onPressThemeToggle: handlePressThemeToggle, + onPressSettings: handlePressSettings, + onPressHistoryBack: handlePressHistoryBack, + onPressHistoryForward: handlePressHistoryForward, + onPressLeftSidebarToggle: handlePressLeftSidebarToggle, + onPressRightSidebarToggle: handlePressRightSidebarToggle, + onPressMinimize: handlePressMinimize, + onPressMaximizeRestore: handlePressMaximizeRestore, + onPressClose: handlePressClose, + workspacesMenu, + notificationPopover, + presenceDetails, + } = props; + + const { colorMode, toggleColorMode } = useColorMode(); + const [canShowFullSecondaryMenu] = useMediaQuery('(min-width: 900px)'); + const { + toolbarRef, + toolbarHeight, + mainSidebarWidth, + isScrolledPastTitle, + } = React.useContext(LayoutContext); + const toast = useToast(); + const commentsToggleToastRef = React.useRef(null); + const shouldShowUnderlay = isScrolledPastTitle["mainContent"] || (isScrolledPastTitle["rightSidebar"] && isRightSidebarOpen); + + // If the workspace color mode doesn't match + // the chakra color mode, update the chakra color mode + React.useEffect(() => { + if (isThemeDark && colorMode !== 'dark') { + toggleColorMode() + } else if (!isThemeDark && colorMode !== 'light') { + toggleColorMode() + } + }, [isThemeDark, toggleColorMode]); + + const secondaryTools = [ + handleClickComments && { + label: isShowComments ? "Hide comments" : "Show comments", + onClick: () => { + if (isShowComments) { + handleClickComments(); + reusableToast(toast, commentsToggleToastRef, { + title: "Comments hidden", + status: "info", + duration: 5000, + position: "top-right" + }); + + } else { + handleClickComments(); + reusableToast(toast, commentsToggleToastRef, { + title: "Comments shown", + duration: 5000, + position: "top-right" + }); + + } + }, + icon: isShowComments ? : + }, + { + label: "Help", + onClick: handlePressHelp, + icon: + }, + { + label: 'Show right sidebar', + onClick: handlePressRightSidebarToggle, + icon: + } + ]; + + + const leftSidebarControls = ( + <> + + + {/* Left side */} + + {isElectron && os === "mac" && ( + + )} + + + + + {workspacesMenu} + + {/* Right side */} + {isElectron && ( + + + } + /> + + + } + /> + + ) + } + + + ); + + const rightToolbarControls = ( + + {presenceDetails} + {notificationPopover} + {canShowFullSecondaryMenu + ? secondaryToolbarItems(secondaryTools) + : secondaryToolbarOverflowMenu(secondaryTools)} + + ); + + const currentLocationTools = ( + + + + ) + + const contentControls = ( + + ) + + const variants = { + visible: { + opacity: 1, + transition: layoutAnimationTransition + }, + isLeftSidebarOpen: { + left: mainSidebarWidth, + transition: layoutAnimationTransition + }, + } + + return ( + + + {leftSidebarControls} + {currentLocationTools} + {contentControls || } + {rightToolbarControls} + + {(shouldShowUnderlay && ( + + ))} + + + {isElectron && (os === 'windows' || os === 'linux') && ( + )} + ); +}; diff --git a/src/js/components/AppToolbar/components/FakeTrafficLights.tsx b/src/js/components/AppToolbar/components/FakeTrafficLights.tsx new file mode 100644 index 0000000000..f87fbce8de --- /dev/null +++ b/src/js/components/AppToolbar/components/FakeTrafficLights.tsx @@ -0,0 +1,18 @@ +import { Box, HStack } from '@chakra-ui/react'; + +const light = + +export const FakeTrafficLights = (props) => { + return + {light} + {light} + {light} + +} \ No newline at end of file diff --git a/src/js/components/AppToolbar/components/LocationIndicator.tsx b/src/js/components/AppToolbar/components/LocationIndicator.tsx new file mode 100644 index 0000000000..882e374bc2 --- /dev/null +++ b/src/js/components/AppToolbar/components/LocationIndicator.tsx @@ -0,0 +1,72 @@ +import { ChevronDownIcon } from '@/Icons/Icons'; +import { ActionsListItem, mapActionsToMenuList } from '@/utils/mapActionsToMenuList'; +import { Heading, VStack, Menu, MenuButton, MenuList, Portal, Button } from '@chakra-ui/react'; + +interface LocationIndicatorProps { + currentLocationName: string; + isVisible: boolean; + breadcrumbs?: {}[], + actions?: ActionsListItem[], + uid?: string +} + +export const LocationIndicator = (props: LocationIndicatorProps) => { + const { isVisible, + currentLocationName, + breadcrumbs, + actions, + uid + } = props; + + return + {(actions) ? ( + + span": { + whiteSpace: "nowrap", + overflow: "hidden", + textOverflow: "ellipsis", + alignItems: "flex-start", + justifyContent: "flex-start" + }, + ...(breadcrumbs && { + ".chakra-button__icon": { + alignSelf: "flex-end" + } + }) + }} + {...(breadcrumbs && { + height: "auto", + py: 1, + px: 2 + })} + rightIcon={actions ? : undefined}> + {breadcrumbs && {breadcrumbs[0].label}} + {currentLocationName} + + + + {mapActionsToMenuList({ target: uid, menuItems: actions })} + + + + ) : ( + {currentLocationName} + )} + +} \ No newline at end of file diff --git a/src/js/components/AppToolbar/components/WindowButtons.tsx b/src/js/components/AppToolbar/components/WindowButtons.tsx new file mode 100644 index 0000000000..42c2855749 --- /dev/null +++ b/src/js/components/AppToolbar/components/WindowButtons.tsx @@ -0,0 +1,189 @@ +import { Box, Icon } from '@chakra-ui/react'; + +const Wrapper = ({ children }) => {children}; + +export interface WindowButtonsProps { + handlePressMinimize(): void, +} + +export const WindowButtons = ({ + isWinFocused, + isWinFullscreen, + isWinMaximized, + handlePressMinimize, + handlePressMaximizeRestore, + handlePressClose +}) => { + return ( + {/* Minimize button */} + + {isWinFullscreen ? ( + + ) : ( + + )} + + ) +} diff --git a/src/js/components/AppToolbar/index.ts b/src/js/components/AppToolbar/index.ts new file mode 100644 index 0000000000..01b034cd38 --- /dev/null +++ b/src/js/components/AppToolbar/index.ts @@ -0,0 +1,2 @@ +import { AppToolbar } from './AppToolbar'; +export { AppToolbar }; \ No newline at end of file diff --git a/src/js/components/Block/Actions.tsx b/src/js/components/Block/Actions.tsx new file mode 100644 index 0000000000..7cc13ef3a4 --- /dev/null +++ b/src/js/components/Block/Actions.tsx @@ -0,0 +1,100 @@ +import React from 'react'; +import { Box, Button, ButtonGroup, ButtonProps, Menu, MenuButton, MenuItem, MenuList } from "@chakra-ui/react"; +import { EllipsisHorizontalIcon } from "@/Icons/Icons"; + +interface ActionObject extends ButtonProps { + label: string; + onClick: () => void; + isExtra?: boolean; + children?: React.ReactNode; +} + +type Action = ActionObject | React.ReactElement; + +export interface ActionsProps { + actions: Action[]; + setIsUsingActions: (isUsingActions: boolean) => void; +} + +export const Actions = ({ actions, setIsUsingActions }: ActionsProps): JSX.Element | null => { + if (!actions) return null; + + let componentActions = []; // Components as actions + let defaultActions = []; // Actions as object + let extraActions = []; // Actions as object, but with isExtra: true + + actions.forEach(action => { + if (React.isValidElement(action)) { + componentActions.push(action); + } else if (action.isExtra) { + extraActions.push(action); + } else { + defaultActions.push(action); + } + }) + + return ( + setIsUsingActions(true)} + onBlur={() => setIsUsingActions(false)} + _after={{ + content: "''", + zIndex: -1, + position: "absolute", + top: 0, + left: 0, + width: "100%", + height: "100%", + borderRadius: "inherit", + bg: "background.vibrancy", + backdropFilter: "blur(12px)" + }} + > + {componentActions} + {defaultActions && defaultActions.map(a => + ); +} + +export const Autocomplete = ({ isOpen, onClose, event, children }) => { + const menuRef = React.useRef(null); + const [menuPosition, setMenuPosition] = React.useState({ left: null, top: null }); + + useOutsideClick({ + ref: menuRef, + handler: () => onClose(), + }) + + React.useEffect(() => { + const target = event?.target; + if (isOpen && target) { + const position = getCaretPositionFromKeyDownEvent(event); + if (position.left && position.top) { + setMenuPosition(position); + } else { + onClose(); + } + } + }, [isOpen]); + + if (!isOpen) { + return false; + } + + return ( + + + + + + + + {children} + + + ) +} \ No newline at end of file diff --git a/src/js/components/Block/BlockFormInput.tsx b/src/js/components/Block/BlockFormInput.tsx new file mode 100644 index 0000000000..5ec9397e6a --- /dev/null +++ b/src/js/components/Block/BlockFormInput.tsx @@ -0,0 +1,31 @@ +import { Box, Input, Textarea } from '@chakra-ui/react'; + +export const BlockFormInput = ({ children, isMultiline, ...inputProps }) => { + const Component = isMultiline ? Textarea : Input; + + return ( + + {children} + + ); +} \ No newline at end of file diff --git a/src/js/components/Block/Container.tsx b/src/js/components/Block/Container.tsx new file mode 100644 index 0000000000..dc6a4da3ac --- /dev/null +++ b/src/js/components/Block/Container.tsx @@ -0,0 +1,178 @@ +import React from 'react'; +import { useTheme, chakra, VStack, Alert, AlertIcon, AlertTitle, forwardRef, AlertDescription } from "@chakra-ui/react"; +import { ErrorBoundary } from "react-error-boundary"; + +const ErrorMessage = ({ error }) => { + return ( + + + An error occurred while rendering this block. + {error.toString()} + + ) +}; + +export const Container = forwardRef((props, ref) => { + const { children, isActive, isEditing, isHoveredNotChild, isDragging, isSelected, isOpen, hasChildren, uid, childrenUids, ...rest } = props; + const theme = useTheme(); + const controlHeight = `calc(${theme.fontSizes.md} * ${theme.lineHeights.base})`; + + return + .inline-presence": { + gridArea: "presence", + justifySelf: "flex-end" + }, + ".block-body > .block-toggle": { + opacity: 0 + }, + ".block-body > .block-toggle:focus": { + opacity: 1 + }, + "&.is-hovered-not-child > .block-body > .block-toggle, &.is-editing > .block-body > .block-toggle": { + opacity: "1" + }, + "button.block-edit-toggle": { + position: "absolute", + appearance: "none", + width: "100%", + background: "none", + border: 0, + cursor: "text", + display: "block", + zIndex: 1, + top: 0, + right: 0, + bottom: 0, + left: 0, + }, + ".block-embed": { + borderRadius: "sm", + "--block-surface-color": "background.basement", + bg: "background.basement", + // Blocks nested in an embed get normal indentation... + ".block-container": { marginLeft: 8 }, + // ...except for the first one, where that would be excessive + "& > .block-container": { marginLeft: 0.5 }, + }, + ".block-content": { + gridArea: "content", + }, + "&.isMenuOpen": { bg: "background.attic" }, + ".block-container": { + marginLeft: "2em", + gridArea: "body" + }, + ".block-ref > .block > h2": { + margin: 0, + }, + "&:not(:first-of-type):has(> .block-body .block-content .block > h1)": { + mt: 3 + }, + "&:not(:first-of-type):has(> .block-body .block-content .block > h2)": { + mt: 3 + }, + "&:not(:first-of-type):has(> .block-body .block-content .block > h3)": { + mt: 2 + }, + "&:not(:first-of-type):has(> .block-body .block-content .block > h4)": { + mt: 2 + }, + "&:not(:first-of-type):has(> .block-body .block-content .block > h5)": { + mt: 1 + }, + "&:not(:first-of-type):has(> .block-body .block-content .block > h6)": { + mt: 1 + }, + }} + {...rest} + > + {children} + + +}) diff --git a/src/js/components/Block/Content.tsx b/src/js/components/Block/Content.tsx new file mode 100644 index 0000000000..005b4e2682 --- /dev/null +++ b/src/js/components/Block/Content.tsx @@ -0,0 +1,128 @@ +import { Box } from '@chakra-ui/react'; + +export const Content = ({ children, ...props }) => { + return span": { + gridArea: "main", + }, + // activate interactive content (links, buttons, etc.) + "a, .url-link, .autolink, link, .hashtag, button, label, video, embed, iframe, img": { + pointerEvents: "auto", + zIndex: 2, + }, + // manage the textarea interactions + "textarea.is-editing": { + zIndex: 3, + lineHeight: "inherit", + opacity: 1, + }, + "textarea.is-editing ~ *": { opacity: "0" }, + "& > abbr": { + gridArea: "main", + zIndex: 4, + "& > span": { + position: "relative", + zIndex: 2, + } + }, + // style block children + "code, pre": { + fontFamily: "code", + fontSize: "0.85em", + }, + ".media-16-9": { + height: 0, + width: "calc(100% - 0.25rem)", + zIndex: 1, + transformOrigin: "right center", + transitionDuration: "0.2s", + transitionTimingFunction: "ease-in-out", + transitionProperty: "common", + paddingBottom: "56.25%", + marginBlock: "0.25rem", + marginInlineEnd: "0.25rem", + position: "relative", + }, + "iframe": { + border: 0, + boxShadow: "inset 0 0 0 0.125rem", + position: "absolute", + height: "100%", + width: "100%", + cursor: "default", + top: 0, + right: 0, + left: 0, + bottom: 0, + borderRadius: "0.25rem", + }, + "img": { + borderRadius: "0.25rem", + maxWidth: "calc(100% - 0.25rem)", + }, + "h1": { fontSize: "xl", fontWeight: "600", letterSpacing: "0.025ch", color: "foreground.secondary" }, + "h2": { fontSize: "lg", fontWeight: "600", letterSpacing: "0.025ch", color: "foreground.secondary" }, + "h3": { fontSize: "md", fontWeight: "600", letterSpacing: "0.025ch", color: "foreground.secondary" }, + "h4": { fontSize: "sm", fontWeight: "600", letterSpacing: "0.025ch", color: "foreground.secondary" }, + "h5": { fontSize: "xs", fontWeight: "600", letterSpacing: "0.025ch", color: "foreground.secondary" }, + "h6": { fontSize: "xs", fontWeight: "600", letterSpacing: "0.025ch", color: "foreground.secondary" }, + "blockquote": { + marginInline: "0.5em", + marginBlock: "0.125rem", + paddingBlock: "calc(0.5em - 0.125rem - 0.125rem)", + paddingInline: "1.5em", + borderRadius: "0.25em", + background: "background.basement", + borderInlineStart: "1px solid", + borderColor: "separator.divider", + color: "foreground.primary", + }, + }} + {...props} + > {children} +} diff --git a/src/js/components/Block/PropertyName.tsx b/src/js/components/Block/PropertyName.tsx new file mode 100644 index 0000000000..a3b86cc707 --- /dev/null +++ b/src/js/components/Block/PropertyName.tsx @@ -0,0 +1,65 @@ +import React from 'react' +import { Button } from '@chakra-ui/react'; + +interface PropertyNameProps extends React.HTMLAttributes { + name: string; +} + +export const PropertyName = (props: PropertyNameProps) => { + const { name, onClick, onDragStart, onDragEnd } = props; + + return ( + + ) +}; diff --git a/src/js/components/Block/Reactions.tsx b/src/js/components/Block/Reactions.tsx new file mode 100644 index 0000000000..3ac577f169 --- /dev/null +++ b/src/js/components/Block/Reactions.tsx @@ -0,0 +1,69 @@ +import { + Button, ButtonGroup, Text, Box, Tooltip, +} from "@chakra-ui/react"; +import { formatList } from '@/utils/formatList'; +import { EmojiPickerPopover } from '@/EmojiPicker/EmojiPicker'; + +type ReactionId = string; +type UserId = string; +type Reaction = [ReactionId, UserId[]]; + +export interface ReactionsProps { + reactions: Reaction[]; + currentUser: UserId; + onToggleReaction: (reactionItem: ReactionId, user: UserId) => void; +} + +interface ReactionItemProps { + reaction: Reaction, + currentUser: UserId + onToggleReaction: (reactionItem: ReactionId, userId: UserId) => void, +} + +const ReactionItem = ({ reaction, onToggleReaction, currentUser }: ReactionItemProps) => { + const reactionItem: ReactionId = reaction[0]; + const users: UserId[] = reaction[1]; + const usersCount: number = reaction[1].length; + const isFromCurrentUser: boolean = users.includes(currentUser); + const tooltipText: string = `${formatList(users) || "Someone"} reacted with ${reactionItem}` + + return + + +} + +export const Reactions = ({ reactions, onToggleReaction, currentUser }: ReactionsProps): JSX.Element | null => { + if (!reactions.length) return null; + + return ( + + {reactions.map(reaction => ())} + onToggleReaction(event.detail.unicode, currentUser)} /> + + ); +}; diff --git a/src/js/components/Block/Taskbox.tsx b/src/js/components/Block/Taskbox.tsx new file mode 100644 index 0000000000..2587ed97a1 --- /dev/null +++ b/src/js/components/Block/Taskbox.tsx @@ -0,0 +1,222 @@ +import React from 'react'; +import { ContextMenuContext } from '@/App/ContextMenuContext'; +import { Button, Flex, FlexProps, IconButton, Menu, MenuButton, MenuGroup, MenuItemOption, MenuList, MenuOptionGroup, Portal } from '@chakra-ui/react'; +import { ArrowRightVariableIcon, CheckmarkVariableIcon, ChevronDownVariableIcon, PauseVariableIcon, SquareIcon, XmarkVariableIcon } from '@/Icons/Icons'; +import { AnimatePresence, HTMLMotionProps, motion } from 'framer-motion'; + +interface TaskboxProps extends FlexProps { + status: string; + onChange: (status: string) => void; + options: string[]; +} + +const STATUS_ICON_PROPS: FlexProps & HTMLMotionProps<"div"> = { + as: motion.div, + position: 'absolute', + inset: 0, + sx: { + svg: { + height: "100%", + width: "100%", + }, + "path": { + strokeWidth: 1.5 + } + }, + initial: { + opacity: 0, + transform: 'translateX(100%)', + }, + animate: { + opacity: 1, + transform: 'translateX(0%)', + }, + exit: { + opacity: 0, + transform: 'translateX(-100%)', + }, +} + +const STATUS = { + "To Do": { + icon: , + color: "blue.500", + text: "To Do", + isDone: false, + }, + "Doing": { + icon: , + color: "yellow.500", + text: "Doing", + isDone: false, + }, + "Blocked": { + icon: , + color: "red.500", + text: "Blocked", + isDone: false, + }, + "Done": { + icon: , + color: "green.500", + text: "Done", + isDone: true, + }, + "Cancelled": { + icon: , + color: "gray.500", + text: "Cancelled", + isDone: true, + }, + "Stalled": { + icon: , + color: "brown.500", + text: "Stalled", + isDone: false, + } +} + +export const Taskbox = (props: TaskboxProps) => { + const { status: initialStatus, options, onChange, ...flexProps } = props; + const { addToContextMenu, getIsMenuOpen } = React.useContext(ContextMenuContext); + + const [status, setStatus] = React.useState(initialStatus || "To Do"); + + const isEditable = options?.length > 1; + + const ref = React.useRef(null); + const isMenuOpen = getIsMenuOpen(ref); + + const StatusMenu = () => { + if (!isEditable) { + return null; + } + + return ( + + { + onChange(value as string) + setStatus(value as string); + }}> + {options.map((option) => { + return ( + {option} + ) + })} + + + ); + } + + return { + if (isEditable) { + addToContextMenu({ event, ref, component: StatusMenu, anchorEl: ref, isExclusive: true }); + } + }} + {...flexProps} + > + + {isEditable && ( + + + }> + + + + + + )} + +} \ No newline at end of file diff --git a/src/js/components/Block/Toggle.tsx b/src/js/components/Block/Toggle.tsx new file mode 100644 index 0000000000..e8948c600d --- /dev/null +++ b/src/js/components/Block/Toggle.tsx @@ -0,0 +1,65 @@ +import React from 'react' +import { IconButton, useTheme } from '@chakra-ui/react'; + +interface ToggleProps extends React.HTMLAttributes { + isOpen: boolean; + onClick: () => void; +} + +/** + * Button to toggle the visibility of a block's child blocks. + */ +export const Toggle = (props: ToggleProps) => { + const { isOpen, ...rest } = props; + + return ( + + + + + + ) +}; diff --git a/src/js/components/Comments/Comments.tsx b/src/js/components/Comments/Comments.tsx new file mode 100644 index 0000000000..e6490f57ad --- /dev/null +++ b/src/js/components/Comments/Comments.tsx @@ -0,0 +1,115 @@ +import React from 'react'; +import { Box, Text, HStack, Textarea, Button, MenuItem, MenuGroup } from '@chakra-ui/react' +import { ContextMenuContext } from '@/App/ContextMenuContext'; +import { withErrorBoundary } from "react-error-boundary"; +import { Anchor } from '@/Block/Anchor'; + +interface InlineCommentInputProps { + onSubmitComment: (comment: string) => void +} + +const sanitizeCommentString = (comment: string) => comment.trim().replace(/\n/g, ' ') + +export const InlineCommentInput = ({ onSubmitComment }: InlineCommentInputProps) => { + const [commentString, setCommentString] = React.useState(''); + const textareaRef = React.useRef(null); + + const handleSubmitComment = (e) => { + e.preventDefault(); + onSubmitComment(sanitizeCommentString(commentString)); + setCommentString(''); + textareaRef.current.value = ''; + textareaRef.current.focus(); + } + + return ( +