mirror of
https://github.com/zadam/trilium.git
synced 2026-03-24 04:40:13 +01:00
Compare commits
38 Commits
feature/st
...
feature/tr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99f21f6437 | ||
|
|
9db6509423 | ||
|
|
979bf26b9e | ||
|
|
d4ae10dd82 | ||
|
|
0619bcf35a | ||
|
|
0d37f7419a | ||
|
|
04b2ed9633 | ||
|
|
cf88cc4990 | ||
|
|
de2937af86 | ||
|
|
4dac65d2e4 | ||
|
|
1b9e58a8b0 | ||
|
|
d179616702 | ||
|
|
f53c64f76d | ||
|
|
537b468714 | ||
|
|
6dcef0b1e5 | ||
|
|
622fe33264 | ||
|
|
8ee81b2607 | ||
|
|
620f2e93a4 | ||
|
|
47cb6531ff | ||
|
|
5ed13ff68c | ||
|
|
0f62f864c8 | ||
|
|
47b53335af | ||
|
|
b875924c8e | ||
|
|
7c002e2871 | ||
|
|
666d0e7c08 | ||
|
|
737ea34a24 | ||
|
|
49bff10ac5 | ||
|
|
1b4c01015b | ||
|
|
995cdf330e | ||
|
|
cbea727f08 | ||
|
|
152ec404bf | ||
|
|
5f6b324c00 | ||
|
|
395aa410f2 | ||
|
|
1f81e864bc | ||
|
|
8849d1f4f2 | ||
|
|
ada530cfef | ||
|
|
1057d55e36 | ||
|
|
cafe4254f9 |
@@ -1,6 +1,6 @@
|
||||
root = true
|
||||
|
||||
[*.{js,cjs,ts,tsx,css}]
|
||||
[*.{js,ts,tsx,css}]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
indent_size = 4
|
||||
|
||||
1
.github/actions/build-electron/action.yml
vendored
1
.github/actions/build-electron/action.yml
vendored
@@ -85,7 +85,6 @@ runs:
|
||||
APPLE_ID: ${{ env.APPLE_ID }}
|
||||
APPLE_ID_PASSWORD: ${{ env.APPLE_ID_PASSWORD }}
|
||||
WINDOWS_SIGN_EXECUTABLE: ${{ env.WINDOWS_SIGN_EXECUTABLE }}
|
||||
WINDOWS_SIGN_ERROR_LOG: ${{ env.WINDOWS_SIGN_ERROR_LOG }}
|
||||
TRILIUM_ARTIFACT_NAME_HINT: TriliumNotes-${{ github.ref_name }}-${{ inputs.os }}-${{ inputs.arch }}
|
||||
TARGET_ARCH: ${{ inputs.arch }}
|
||||
run: pnpm run --filter desktop electron-forge:make --arch=${{ inputs.arch }} --platform=${{ inputs.forge_platform }}
|
||||
|
||||
2
.github/actions/build-server/action.yml
vendored
2
.github/actions/build-server/action.yml
vendored
@@ -8,7 +8,7 @@ inputs:
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- uses: pnpm/action-setup@v5
|
||||
- uses: pnpm/action-setup@v4
|
||||
- name: Set up node & dependencies
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
|
||||
2
.github/actions/report-size/action.yml
vendored
2
.github/actions/report-size/action.yml
vendored
@@ -69,7 +69,7 @@ runs:
|
||||
|
||||
# Post github action comment
|
||||
- name: Post comment
|
||||
uses: marocchino/sticky-pull-request-comment@v3
|
||||
uses: marocchino/sticky-pull-request-comment@v2
|
||||
if: ${{ steps.bundleSize.outputs.hasDifferences == 'true' }} # post only in case of changes
|
||||
with:
|
||||
number: ${{ github.event.pull_request.number }}
|
||||
|
||||
67
.github/workflows/deploy-app.yml
vendored
67
.github/workflows/deploy-app.yml
vendored
@@ -1,67 +0,0 @@
|
||||
name: Deploy Standalone App
|
||||
|
||||
on:
|
||||
# Trigger on push to main branch
|
||||
push:
|
||||
branches:
|
||||
- standalone
|
||||
# Only run when app files change
|
||||
paths:
|
||||
- 'apps/client/**'
|
||||
- 'apps/client-standalone/**'
|
||||
- 'packages/trilium-core/**'
|
||||
- '.github/workflows/deploy-app.yml'
|
||||
|
||||
# Allow manual triggering from Actions tab
|
||||
workflow_dispatch:
|
||||
|
||||
# Run on pull requests for preview deployments
|
||||
pull_request:
|
||||
paths:
|
||||
- 'apps/client/**'
|
||||
- 'apps/client-standalone/**'
|
||||
- 'packages/trilium-core/**'
|
||||
- '.github/workflows/deploy-app.yml'
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
name: Build and Deploy App
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
# Required permissions for deployment
|
||||
permissions:
|
||||
contents: read
|
||||
deployments: write
|
||||
pull-requests: write # For PR preview comments
|
||||
id-token: write # For OIDC authentication (if needed)
|
||||
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install Dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Trigger build of app
|
||||
run: pnpm --filter=client-standalone build
|
||||
|
||||
- name: Deploy
|
||||
uses: ./.github/actions/deploy-to-cloudflare-pages
|
||||
if: github.repository == vars.REPO_MAIN
|
||||
with:
|
||||
project_name: "trilium-app"
|
||||
comment_body: "🖥️ App preview is ready"
|
||||
production_url: "https://app.triliumnotes.org"
|
||||
deploy_dir: "apps/client-standalone/dist"
|
||||
cloudflare_api_token: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
cloudflare_account_id: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
2
.github/workflows/deploy-docs.yml
vendored
2
.github/workflows/deploy-docs.yml
vendored
@@ -45,7 +45,7 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v5
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
|
||||
49
.github/workflows/dev.yml
vendored
49
.github/workflows/dev.yml
vendored
@@ -1,9 +1,9 @@
|
||||
name: Dev
|
||||
on:
|
||||
push:
|
||||
branches: [ main, standalone ]
|
||||
branches: [ main ]
|
||||
pull_request:
|
||||
branches: [ main, standalone ]
|
||||
branches: [ main ]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- uses: pnpm/action-setup@v5
|
||||
- uses: pnpm/action-setup@v4
|
||||
- name: Set up node & dependencies
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
@@ -37,35 +37,8 @@ jobs:
|
||||
- name: Typecheck
|
||||
run: pnpm typecheck
|
||||
|
||||
- name: Run the client-side tests
|
||||
run: pnpm run --filter=client test
|
||||
|
||||
- name: Upload client test report
|
||||
uses: actions/upload-artifact@v7
|
||||
if: always()
|
||||
with:
|
||||
name: client-test-report
|
||||
path: apps/client/test-output/vitest/html/
|
||||
retention-days: 30
|
||||
|
||||
- name: Run the server-side tests
|
||||
run: pnpm run --filter=server test
|
||||
|
||||
- name: Upload server test report
|
||||
uses: actions/upload-artifact@v7
|
||||
if: always()
|
||||
with:
|
||||
name: server-test-report
|
||||
path: apps/server/test-output/vitest/html/
|
||||
retention-days: 30
|
||||
|
||||
- name: Run CKEditor e2e tests
|
||||
run: |
|
||||
pnpm run --filter=ckeditor5-mermaid test
|
||||
pnpm run --filter=ckeditor5-math test
|
||||
|
||||
- name: Run the rest of the tests
|
||||
run: pnpm run --filter=\!client --filter=\!server --filter=\!ckeditor5-mermaid --filter=\!ckeditor5-math test
|
||||
- name: Run the unit tests
|
||||
run: pnpm run test:all
|
||||
|
||||
build_docker:
|
||||
name: Build Docker image
|
||||
@@ -74,7 +47,7 @@ jobs:
|
||||
- test_dev
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: pnpm/action-setup@v5
|
||||
- uses: pnpm/action-setup@v4
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Update build info
|
||||
@@ -89,8 +62,8 @@ jobs:
|
||||
key: ${{ secrets.RELATIVE_CI_CLIENT_KEY }}
|
||||
- name: Trigger server build
|
||||
run: pnpm run server:build
|
||||
- uses: docker/setup-buildx-action@v4
|
||||
- uses: docker/build-push-action@v7
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: apps/server
|
||||
cache-from: type=gha
|
||||
@@ -109,7 +82,7 @@ jobs:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- uses: pnpm/action-setup@v5
|
||||
- uses: pnpm/action-setup@v4
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
@@ -124,10 +97,10 @@ jobs:
|
||||
run: echo "TEST_TAG=${TEST_TAG,,}" >> $GITHUB_ENV
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build and export to Docker
|
||||
uses: docker/build-push-action@v7
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: apps/server
|
||||
file: apps/server/${{ matrix.dockerfile }}
|
||||
|
||||
2
.github/workflows/i18n.yml
vendored
2
.github/workflows/i18n.yml
vendored
@@ -18,7 +18,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: pnpm/action-setup@v5
|
||||
- uses: pnpm/action-setup@v4
|
||||
- name: Set up node & dependencies
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
|
||||
146
.github/workflows/main-docker.yml
vendored
146
.github/workflows/main-docker.yml
vendored
@@ -40,9 +40,9 @@ jobs:
|
||||
run: echo "TEST_TAG=${TEST_TAG,,}" >> $GITHUB_ENV
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- uses: pnpm/action-setup@v5
|
||||
- uses: pnpm/action-setup@v4
|
||||
- name: Set up node & dependencies
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
@@ -59,7 +59,7 @@ jobs:
|
||||
run: pnpm run server:build
|
||||
|
||||
- name: Build and export to Docker
|
||||
uses: docker/build-push-action@v7
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: apps/server
|
||||
file: apps/server/${{ matrix.dockerfile }}
|
||||
@@ -86,12 +86,12 @@ jobs:
|
||||
|
||||
- name: Upload Playwright trace
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: Playwright trace (${{ matrix.dockerfile }})
|
||||
path: test-output/playwright/output
|
||||
|
||||
- uses: actions/upload-artifact@v7
|
||||
- uses: actions/upload-artifact@v6
|
||||
if: ${{ !cancelled() }}
|
||||
with:
|
||||
name: Playwright report (${{ matrix.dockerfile }})
|
||||
@@ -142,7 +142,7 @@ jobs:
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
- uses: pnpm/action-setup@v5
|
||||
- uses: pnpm/action-setup@v4
|
||||
- name: Set up node & dependencies
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
@@ -164,9 +164,11 @@ jobs:
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v6
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
images: |
|
||||
${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
${{ env.DOCKERHUB_REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=tag
|
||||
@@ -175,21 +177,28 @@ jobs:
|
||||
latest=false
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v4
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v4
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.GHCR_REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.DOCKERHUB_REGISTRY }}
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push by digest
|
||||
id: build
|
||||
uses: docker/build-push-action@v7
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: apps/server
|
||||
file: apps/server/${{ matrix.dockerfile }}
|
||||
@@ -204,7 +213,7 @@ jobs:
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: digests-${{ env.PLATFORM_PAIR }}-${{ matrix.dockerfile }}
|
||||
path: /tmp/digests/*
|
||||
@@ -218,7 +227,7 @@ jobs:
|
||||
- build
|
||||
steps:
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v8
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: digests-*
|
||||
@@ -228,86 +237,75 @@ jobs:
|
||||
- name: Set TEST_TAG to lowercase
|
||||
run: echo "TEST_TAG=${TEST_TAG,,}" >> $GITHUB_ENV
|
||||
|
||||
- name: Set up crane
|
||||
uses: imjasonh/setup-crane@v0.5
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
${{ env.DOCKERHUB_REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
flavor: |
|
||||
latest=false
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v4
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.GHCR_REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v4
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.DOCKERHUB_REGISTRY }}
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=tag
|
||||
type=sha
|
||||
flavor: |
|
||||
latest=false
|
||||
|
||||
- name: Verify digests exist on GHCR
|
||||
- name: Create manifest list and push
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
echo "Verifying all digests are available on GHCR..."
|
||||
for DIGEST_FILE in *; do
|
||||
DIGEST="sha256:${DIGEST_FILE}"
|
||||
echo -n " ${DIGEST}: "
|
||||
crane manifest "${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}@${DIGEST}" > /dev/null
|
||||
echo "OK"
|
||||
done
|
||||
# Extract the branch or tag name from the ref
|
||||
REF_NAME=$(echo "${GITHUB_REF}" | sed 's/refs\/heads\///' | sed 's/refs\/tags\///')
|
||||
|
||||
- name: Create and push multi-arch manifest
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
GHCR_IMAGE="${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}"
|
||||
DOCKERHUB_IMAGE="${{ env.DOCKERHUB_REGISTRY }}/${{ env.IMAGE_NAME }}"
|
||||
# Create and push the manifest list with both the branch/tag name and the commit SHA
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
-t ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}:${REF_NAME} \
|
||||
$(printf '${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}@sha256:%s ' *)
|
||||
|
||||
# Build -m flags for crane index append from digest files
|
||||
MANIFEST_ARGS=""
|
||||
for d in *; do
|
||||
MANIFEST_ARGS="${MANIFEST_ARGS} -m ${GHCR_IMAGE}@sha256:${d}"
|
||||
done
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
-t ${{ env.DOCKERHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${REF_NAME} \
|
||||
$(printf '${{ env.DOCKERHUB_REGISTRY }}/${{ env.IMAGE_NAME }}@sha256:%s ' *)
|
||||
|
||||
# Create multi-arch manifest for each tag from metadata, plus copy to DockerHub
|
||||
while IFS= read -r TAG; do
|
||||
echo "Creating manifest: ${TAG}"
|
||||
crane index append ${MANIFEST_ARGS} -t "${TAG}"
|
||||
|
||||
SUFFIX="${TAG#*:}"
|
||||
echo "Copying to DockerHub: ${DOCKERHUB_IMAGE}:${SUFFIX}"
|
||||
crane copy "${TAG}" "${DOCKERHUB_IMAGE}:${SUFFIX}"
|
||||
done <<< "${{ steps.meta.outputs.tags }}"
|
||||
|
||||
# For stable releases (tags without hyphens), also create stable + latest
|
||||
REF_NAME="${GITHUB_REF#refs/tags/}"
|
||||
# If the ref is a tag, also tag the image as stable as this is part of a 'release'
|
||||
# and only go in the `if` if there is NOT a `-` in the tag's name, due to tagging of `-alpha`, `-beta`, etc...
|
||||
if [[ "${GITHUB_REF}" == refs/tags/* && ! "${REF_NAME}" =~ - ]]; then
|
||||
echo "Creating stable tags..."
|
||||
crane index append ${MANIFEST_ARGS} -t "${GHCR_IMAGE}:stable"
|
||||
crane copy "${GHCR_IMAGE}:stable" "${DOCKERHUB_IMAGE}:stable"
|
||||
# First create stable tags
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
-t ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}:stable \
|
||||
$(printf '${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}@sha256:%s ' *)
|
||||
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
-t ${{ env.DOCKERHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:stable \
|
||||
$(printf '${{ env.DOCKERHUB_REGISTRY }}/${{ env.IMAGE_NAME }}@sha256:%s ' *)
|
||||
|
||||
# Small delay to ensure stable tag is fully propagated
|
||||
sleep 5
|
||||
|
||||
# Now update latest tags
|
||||
docker buildx imagetools create \
|
||||
-t ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}:latest \
|
||||
${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}:stable
|
||||
|
||||
docker buildx imagetools create \
|
||||
-t ${{ env.DOCKERHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:latest \
|
||||
${{ env.DOCKERHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:stable
|
||||
|
||||
echo "Creating latest tags..."
|
||||
crane copy "${GHCR_IMAGE}:stable" "${GHCR_IMAGE}:latest"
|
||||
crane copy "${GHCR_IMAGE}:latest" "${DOCKERHUB_IMAGE}:latest"
|
||||
fi
|
||||
|
||||
- name: Inspect manifests
|
||||
- name: Inspect image
|
||||
run: |
|
||||
REF_NAME="${GITHUB_REF#refs/heads/}"
|
||||
REF_NAME="${REF_NAME#refs/tags/}"
|
||||
echo "=== GHCR ==="
|
||||
crane manifest "${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}:${REF_NAME}"
|
||||
echo ""
|
||||
echo "=== DockerHub ==="
|
||||
crane manifest "${{ env.DOCKERHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${REF_NAME}"
|
||||
docker buildx imagetools inspect ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}
|
||||
docker buildx imagetools inspect ${{ env.DOCKERHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}
|
||||
|
||||
9
.github/workflows/nightly.yml
vendored
9
.github/workflows/nightly.yml
vendored
@@ -61,7 +61,7 @@ jobs:
|
||||
runs-on: ${{ matrix.os.image }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: pnpm/action-setup@v5
|
||||
- uses: pnpm/action-setup@v4
|
||||
- name: Set up node & dependencies
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
@@ -87,11 +87,10 @@ jobs:
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
|
||||
WINDOWS_SIGN_EXECUTABLE: ${{ vars.WINDOWS_SIGN_EXECUTABLE }}
|
||||
WINDOWS_SIGN_ERROR_LOG: ${{ vars.WINDOWS_SIGN_ERROR_LOG }}
|
||||
GPG_SIGNING_KEY: ${{ secrets.GPG_SIGN_KEY }}
|
||||
|
||||
- name: Publish release
|
||||
uses: softprops/action-gh-release@v2.6.1
|
||||
uses: softprops/action-gh-release@v2.5.0
|
||||
if: ${{ github.event_name != 'pull_request' }}
|
||||
with:
|
||||
make_latest: false
|
||||
@@ -103,7 +102,7 @@ jobs:
|
||||
name: Nightly Build
|
||||
|
||||
- name: Publish artifacts
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@v6
|
||||
if: ${{ github.event_name == 'pull_request' }}
|
||||
with:
|
||||
name: TriliumNotes ${{ matrix.os.name }} ${{ matrix.arch }}
|
||||
@@ -132,7 +131,7 @@ jobs:
|
||||
arch: ${{ matrix.arch }}
|
||||
|
||||
- name: Publish release
|
||||
uses: softprops/action-gh-release@v2.6.1
|
||||
uses: softprops/action-gh-release@v2.5.0
|
||||
if: ${{ github.event_name != 'pull_request' }}
|
||||
with:
|
||||
make_latest: false
|
||||
|
||||
4
.github/workflows/playwright.yml
vendored
4
.github/workflows/playwright.yml
vendored
@@ -38,7 +38,7 @@ jobs:
|
||||
filter: tree:0
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: pnpm/action-setup@v5
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
@@ -77,7 +77,7 @@ jobs:
|
||||
|
||||
- name: Upload test report
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: e2e report ${{ matrix.arch }}
|
||||
path: apps/server-e2e/test-output
|
||||
|
||||
25
.github/workflows/release.yml
vendored
25
.github/workflows/release.yml
vendored
@@ -17,22 +17,10 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: pnpm/action-setup@v5
|
||||
- name: Set up node & dependencies
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --filter source --frozen-lockfile --ignore-scripts
|
||||
|
||||
- name: Check version consistency
|
||||
run: pnpm tsx ${{ github.workspace }}/scripts/check-version-consistency.ts ${{ github.ref_name }}
|
||||
make-electron:
|
||||
name: Make Electron
|
||||
needs:
|
||||
- sanity-check
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -66,7 +54,7 @@ jobs:
|
||||
runs-on: ${{ matrix.os.image }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: pnpm/action-setup@v5
|
||||
- uses: pnpm/action-setup@v4
|
||||
- name: Set up node & dependencies
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
@@ -90,19 +78,16 @@ jobs:
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
|
||||
WINDOWS_SIGN_EXECUTABLE: ${{ vars.WINDOWS_SIGN_EXECUTABLE }}
|
||||
WINDOWS_SIGN_ERROR_LOG: ${{ vars.WINDOWS_SIGN_ERROR_LOG }}
|
||||
GPG_SIGNING_KEY: ${{ secrets.GPG_SIGN_KEY }}
|
||||
|
||||
- name: Upload the artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: release-desktop-${{ matrix.os.name }}-${{ matrix.arch }}
|
||||
path: apps/desktop/upload/*.*
|
||||
|
||||
build_server:
|
||||
name: Build Linux Server
|
||||
needs:
|
||||
- sanity-check
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -123,7 +108,7 @@ jobs:
|
||||
arch: ${{ matrix.arch }}
|
||||
|
||||
- name: Upload the artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: release-server-linux-${{ matrix.arch }}
|
||||
path: upload/*.*
|
||||
@@ -143,14 +128,14 @@ jobs:
|
||||
docs/Release Notes
|
||||
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v8
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
merge-multiple: true
|
||||
pattern: release-*
|
||||
path: upload
|
||||
|
||||
- name: Publish stable release
|
||||
uses: softprops/action-gh-release@v2.6.1
|
||||
uses: softprops/action-gh-release@v2.5.0
|
||||
with:
|
||||
draft: false
|
||||
body_path: docs/Release Notes/Release Notes/${{ github.ref_name }}.md
|
||||
|
||||
6
.github/workflows/web-clipper.yml
vendored
6
.github/workflows/web-clipper.yml
vendored
@@ -32,7 +32,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: pnpm/action-setup@v5
|
||||
- uses: pnpm/action-setup@v4
|
||||
- name: Set up node & dependencies
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
pnpm --filter web-clipper zip:firefox
|
||||
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@v6
|
||||
if: ${{ !startsWith(github.ref, 'refs/tags/web-clipper-v') }}
|
||||
with:
|
||||
name: web-clipper-extension
|
||||
@@ -58,7 +58,7 @@ jobs:
|
||||
compression-level: 0
|
||||
|
||||
- name: Release web clipper extension
|
||||
uses: softprops/action-gh-release@v2.6.1
|
||||
uses: softprops/action-gh-release@v2.5.0
|
||||
if: ${{ startsWith(github.ref, 'refs/tags/web-clipper-v') }}
|
||||
with:
|
||||
draft: false
|
||||
|
||||
2
.github/workflows/website.yml
vendored
2
.github/workflows/website.yml
vendored
@@ -26,7 +26,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: pnpm/action-setup@v5
|
||||
- uses: pnpm/action-setup@v4
|
||||
- name: Set up node & dependencies
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -46,11 +46,9 @@ upload
|
||||
|
||||
/.direnv
|
||||
/result
|
||||
.svelte-kit
|
||||
|
||||
# docs
|
||||
site/
|
||||
apps/*/coverage
|
||||
scripts/translation/.language*.json
|
||||
|
||||
# AI
|
||||
.claude/settings.local.json
|
||||
57
.vscode/launch.json
vendored
57
.vscode/launch.json
vendored
@@ -1,57 +0,0 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Launch client (Chrome)",
|
||||
"request": "launch",
|
||||
"type": "chrome",
|
||||
"url": "http://localhost:8080",
|
||||
"webRoot": "${workspaceFolder}/apps/client"
|
||||
},
|
||||
{
|
||||
"name": "Launch server",
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"program": "${workspaceFolder}/apps/server/src/main.ts",
|
||||
"runtimeExecutable": "${workspaceFolder}/node_modules/.bin/tsx",
|
||||
"env": {
|
||||
"NODE_ENV": "development",
|
||||
"TRILIUM_ENV": "dev",
|
||||
"TRILIUM_DATA_DIR": "${input:trilium_data_dir}",
|
||||
"TRILIUM_RESOURCE_DIR": "${workspaceFolder}/apps/server/src"
|
||||
},
|
||||
"autoAttachChildProcesses": true,
|
||||
"cwd": "${workspaceFolder}",
|
||||
"console": "integratedTerminal",
|
||||
"internalConsoleOptions": "neverOpen",
|
||||
"skipFiles": ["<node_internals>/**", "${workspaceFolder}/node_modules/**"]
|
||||
},
|
||||
{
|
||||
"name": "Launch Vitest with current test file",
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"autoAttachChildProcesses": true,
|
||||
"program": "${workspaceFolder}/node_modules/vitest/vitest.mjs",
|
||||
"args": ["run", "${relativeFile}"],
|
||||
"smartStep": true,
|
||||
"console": "integratedTerminal",
|
||||
"skipFiles": ["<node_internals>/**", "**/node_modules/**"],
|
||||
"cwd": "${workspaceFolder}"
|
||||
}
|
||||
],
|
||||
"compounds": [
|
||||
{
|
||||
"name": "Launch client (Chrome) and server",
|
||||
"configurations": ["Launch server","Launch client (Chrome)"],
|
||||
"stopAll": true
|
||||
}
|
||||
],
|
||||
"inputs": [
|
||||
{
|
||||
"id": "trilium_data_dir",
|
||||
"type": "promptString",
|
||||
"description": "Select Trilum Notes data directory",
|
||||
"default": "${workspaceFolder}/apps/server/data"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -10,5 +10,4 @@ Description above is a general rule and may be altered on case by case basis.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
* For low severity vulnerabilities, they can be reported as GitHub issues.
|
||||
* For severe vulnerabilities, please report it using [GitHub Security Advisories](https://github.com/TriliumNext/Trilium/security/advisories).
|
||||
You can report low severity vulnerabilities as GitHub issues, more severe vulnerabilities should be reported to the email [contact@eliandoran.me](mailto:contact@eliandoran.me)
|
||||
|
||||
@@ -1,28 +1,22 @@
|
||||
{
|
||||
"name": "build-docs",
|
||||
"version": "1.0.0",
|
||||
"description": "Build documentation from Trilium notes",
|
||||
"description": "",
|
||||
"main": "src/main.ts",
|
||||
"bin": {
|
||||
"trilium-build-docs": "dist/cli.js"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "tsx .",
|
||||
"cli": "tsx src/cli.ts",
|
||||
"build": "tsx scripts/build.ts"
|
||||
"start": "tsx ."
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "Elian Doran <contact@eliandoran.me>",
|
||||
"license": "AGPL-3.0-only",
|
||||
"packageManager": "pnpm@10.32.1",
|
||||
"packageManager": "pnpm@10.28.2",
|
||||
"devDependencies": {
|
||||
"@redocly/cli": "2.24.1",
|
||||
"@redocly/cli": "2.15.1",
|
||||
"archiver": "7.0.1",
|
||||
"fs-extra": "11.3.4",
|
||||
"js-yaml": "4.1.1",
|
||||
"fs-extra": "11.3.3",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"typedoc": "0.28.17",
|
||||
"typedoc": "0.28.16",
|
||||
"typedoc-plugin-missing-exports": "4.1.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import BuildHelper from "../../../scripts/build-utils";
|
||||
|
||||
const build = new BuildHelper("apps/build-docs");
|
||||
|
||||
async function main() {
|
||||
// Build the CLI and other TypeScript files
|
||||
await build.buildBackend([
|
||||
"src/cli.ts",
|
||||
"src/main.ts",
|
||||
"src/build-docs.ts",
|
||||
"src/swagger.ts",
|
||||
"src/script-api.ts",
|
||||
"src/context.ts"
|
||||
]);
|
||||
|
||||
// Copy HTML template
|
||||
build.copy("src/index.html", "index.html");
|
||||
|
||||
// Copy node modules dependencies if needed
|
||||
build.copyNodeModules([ "better-sqlite3", "bindings", "file-uri-to-path" ]);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -13,12 +13,8 @@
|
||||
* Make sure to keep in line with backend's `script_context.ts`.
|
||||
*/
|
||||
|
||||
export type {
|
||||
default as AbstractBeccaEntity
|
||||
} from "../../server/src/becca/entities/abstract_becca_entity.js";
|
||||
export type {
|
||||
default as BAttachment
|
||||
} from "../../server/src/becca/entities/battachment.js";
|
||||
export type { default as AbstractBeccaEntity } from "../../server/src/becca/entities/abstract_becca_entity.js";
|
||||
export type { default as BAttachment } from "../../server/src/becca/entities/battachment.js";
|
||||
export type { default as BAttribute } from "../../server/src/becca/entities/battribute.js";
|
||||
export type { default as BBranch } from "../../server/src/becca/entities/bbranch.js";
|
||||
export type { default as BEtapiToken } from "../../server/src/becca/entities/betapi_token.js";
|
||||
@@ -35,7 +31,6 @@ export type { Api };
|
||||
const fakeNote = new BNote();
|
||||
|
||||
/**
|
||||
* The `api` global variable allows access to the backend script API,
|
||||
* which is documented in {@link Api}.
|
||||
* The `api` global variable allows access to the backend script API, which is documented in {@link Api}.
|
||||
*/
|
||||
export const api: Api = new BackendScriptApi(fakeNote, {});
|
||||
|
||||
@@ -1,90 +1,19 @@
|
||||
process.env.TRILIUM_INTEGRATION_TEST = "memory-no-store";
|
||||
// Only set TRILIUM_RESOURCE_DIR if not already set (e.g., by Nix wrapper)
|
||||
if (!process.env.TRILIUM_RESOURCE_DIR) {
|
||||
process.env.TRILIUM_RESOURCE_DIR = "../server/src";
|
||||
}
|
||||
process.env.TRILIUM_RESOURCE_DIR = "../server/src";
|
||||
process.env.NODE_ENV = "development";
|
||||
|
||||
import cls from "@triliumnext/server/src/services/cls.js";
|
||||
import archiver from "archiver";
|
||||
import { execSync } from "child_process";
|
||||
import { WriteStream } from "fs";
|
||||
import { dirname, join, resolve } from "path";
|
||||
import * as fs from "fs/promises";
|
||||
import * as fsExtra from "fs-extra";
|
||||
import yaml from "js-yaml";
|
||||
import { dirname, join, resolve } from "path";
|
||||
|
||||
import archiver from "archiver";
|
||||
import { WriteStream } from "fs";
|
||||
import { execSync } from "child_process";
|
||||
import BuildContext from "./context.js";
|
||||
|
||||
interface NoteMapping {
|
||||
rootNoteId: string;
|
||||
path: string;
|
||||
format: "markdown" | "html" | "share";
|
||||
ignoredFiles?: string[];
|
||||
exportOnly?: boolean;
|
||||
}
|
||||
|
||||
interface Config {
|
||||
baseUrl: string;
|
||||
noteMappings: NoteMapping[];
|
||||
}
|
||||
|
||||
const DOCS_ROOT = "../../../docs";
|
||||
const OUTPUT_DIR = "../../site";
|
||||
|
||||
// Load configuration from edit-docs-config.yaml
|
||||
async function loadConfig(configPath?: string): Promise<Config | null> {
|
||||
const pathsToTry = configPath
|
||||
? [resolve(configPath)]
|
||||
: [
|
||||
join(process.cwd(), "edit-docs-config.yaml"),
|
||||
join(__dirname, "../../../edit-docs-config.yaml")
|
||||
];
|
||||
|
||||
for (const path of pathsToTry) {
|
||||
try {
|
||||
const configContent = await fs.readFile(path, "utf-8");
|
||||
const config = yaml.load(configContent) as Config;
|
||||
|
||||
// Resolve all paths relative to the config file's directory
|
||||
const CONFIG_DIR = dirname(path);
|
||||
config.noteMappings = config.noteMappings.map((mapping) => ({
|
||||
...mapping,
|
||||
path: resolve(CONFIG_DIR, mapping.path)
|
||||
}));
|
||||
|
||||
return config;
|
||||
} catch (error) {
|
||||
if (error.code !== "ENOENT") {
|
||||
throw error; // rethrow unexpected errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null; // No config file found
|
||||
}
|
||||
|
||||
async function exportDocs(
|
||||
noteId: string,
|
||||
format: "markdown" | "html" | "share",
|
||||
outputPath: string,
|
||||
ignoredFiles?: string[]
|
||||
) {
|
||||
const zipFilePath = `output-${noteId}.zip`;
|
||||
try {
|
||||
const { exportToZipFile } = (await import("@triliumnext/server/src/services/export/zip.js"))
|
||||
.default;
|
||||
await exportToZipFile(noteId, format, zipFilePath, {});
|
||||
|
||||
const ignoredSet = ignoredFiles ? new Set(ignoredFiles) : undefined;
|
||||
await extractZip(zipFilePath, outputPath, ignoredSet);
|
||||
} finally {
|
||||
if (await fsExtra.exists(zipFilePath)) {
|
||||
await fsExtra.rm(zipFilePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function importAndExportDocs(sourcePath: string, outputSubDir: string) {
|
||||
const note = await importData(sourcePath);
|
||||
|
||||
@@ -92,18 +21,15 @@ async function importAndExportDocs(sourcePath: string, outputSubDir: string) {
|
||||
const zipName = outputSubDir || "user-guide";
|
||||
const zipFilePath = `output-${zipName}.zip`;
|
||||
try {
|
||||
const { exportToZip } = (await import("@triliumnext/server/src/services/export/zip.js"))
|
||||
.default;
|
||||
const { exportToZip } = (await import("@triliumnext/server/src/services/export/zip.js")).default;
|
||||
const branch = note.getParentBranches()[0];
|
||||
const taskContext = new (await import("@triliumnext/server/src/services/task_context.js"))
|
||||
.default(
|
||||
"no-progress-reporting",
|
||||
"export",
|
||||
null
|
||||
);
|
||||
const taskContext = new (await import("@triliumnext/server/src/services/task_context.js")).default(
|
||||
"no-progress-reporting",
|
||||
"export",
|
||||
null
|
||||
);
|
||||
const fileOutputStream = fsExtra.createWriteStream(zipFilePath);
|
||||
await exportToZip(taskContext, branch, "share", fileOutputStream);
|
||||
const { waitForStreamToFinish } = await import("@triliumnext/server/src/services/utils.js");
|
||||
await waitForStreamToFinish(fileOutputStream);
|
||||
|
||||
// Output to root directory if outputSubDir is empty, otherwise to subdirectory
|
||||
@@ -116,7 +42,7 @@ async function importAndExportDocs(sourcePath: string, outputSubDir: string) {
|
||||
}
|
||||
}
|
||||
|
||||
async function buildDocsInner(config?: Config) {
|
||||
async function buildDocsInner() {
|
||||
const i18n = await import("@triliumnext/server/src/services/i18n.js");
|
||||
await i18n.initializeTranslations();
|
||||
|
||||
@@ -127,49 +53,18 @@ async function buildDocsInner(config?: Config) {
|
||||
const beccaLoader = await import("../../server/src/becca/becca_loader.js");
|
||||
await beccaLoader.beccaLoaded;
|
||||
|
||||
if (config) {
|
||||
// Config-based build (reads from edit-docs-config.yaml)
|
||||
console.log("Building documentation from config file...");
|
||||
// Build User Guide
|
||||
console.log("Building User Guide...");
|
||||
await importAndExportDocs(join(__dirname, DOCS_ROOT, "User Guide"), "user-guide");
|
||||
|
||||
// Import all non-export-only mappings
|
||||
for (const mapping of config.noteMappings) {
|
||||
if (!mapping.exportOnly) {
|
||||
console.log(`Importing from ${mapping.path}...`);
|
||||
await importData(mapping.path);
|
||||
}
|
||||
}
|
||||
// Build Developer Guide
|
||||
console.log("Building Developer Guide...");
|
||||
await importAndExportDocs(join(__dirname, DOCS_ROOT, "Developer Guide"), "developer-guide");
|
||||
|
||||
// Export all mappings
|
||||
for (const mapping of config.noteMappings) {
|
||||
if (mapping.exportOnly) {
|
||||
console.log(`Exporting ${mapping.format} to ${mapping.path}...`);
|
||||
await exportDocs(
|
||||
mapping.rootNoteId,
|
||||
mapping.format,
|
||||
mapping.path,
|
||||
mapping.ignoredFiles
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Legacy hardcoded build (for backward compatibility)
|
||||
console.log("Building User Guide...");
|
||||
await importAndExportDocs(join(__dirname, DOCS_ROOT, "User Guide"), "user-guide");
|
||||
|
||||
console.log("Building Developer Guide...");
|
||||
await importAndExportDocs(
|
||||
join(__dirname, DOCS_ROOT, "Developer Guide"),
|
||||
"developer-guide"
|
||||
);
|
||||
|
||||
// Copy favicon.
|
||||
await fs.copyFile("../../apps/website/src/assets/favicon.ico",
|
||||
join(OUTPUT_DIR, "favicon.ico"));
|
||||
await fs.copyFile("../../apps/website/src/assets/favicon.ico",
|
||||
join(OUTPUT_DIR, "user-guide", "favicon.ico"));
|
||||
await fs.copyFile("../../apps/website/src/assets/favicon.ico",
|
||||
join(OUTPUT_DIR, "developer-guide", "favicon.ico"));
|
||||
}
|
||||
// Copy favicon.
|
||||
await fs.copyFile("../../apps/website/src/assets/favicon.ico", join(OUTPUT_DIR, "favicon.ico"));
|
||||
await fs.copyFile("../../apps/website/src/assets/favicon.ico", join(OUTPUT_DIR, "user-guide", "favicon.ico"));
|
||||
await fs.copyFile("../../apps/website/src/assets/favicon.ico", join(OUTPUT_DIR, "developer-guide", "favicon.ico"));
|
||||
|
||||
console.log("Documentation built successfully!");
|
||||
}
|
||||
@@ -196,13 +91,12 @@ async function createImportZip(path: string) {
|
||||
zlib: { level: 0 }
|
||||
});
|
||||
|
||||
console.log("Archive path is ", resolve(path));
|
||||
console.log("Archive path is ", resolve(path))
|
||||
archive.directory(path, "/");
|
||||
|
||||
const outputStream = fsExtra.createWriteStream(inputFile);
|
||||
archive.pipe(outputStream);
|
||||
archive.finalize();
|
||||
const { waitForStreamToFinish } = await import("@triliumnext/server/src/services/utils.js");
|
||||
await waitForStreamToFinish(outputStream);
|
||||
|
||||
try {
|
||||
@@ -212,15 +106,15 @@ async function createImportZip(path: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function waitForStreamToFinish(stream: WriteStream) {
|
||||
return new Promise<void>((res, rej) => {
|
||||
stream.on("finish", () => res());
|
||||
stream.on("error", (err) => rej(err));
|
||||
});
|
||||
}
|
||||
|
||||
export async function extractZip(
|
||||
zipFilePath: string,
|
||||
outputPath: string,
|
||||
ignoredFiles?: Set<string>
|
||||
) {
|
||||
const { readZipFile, readContent } = (await import(
|
||||
"@triliumnext/server/src/services/import/zip.js"
|
||||
));
|
||||
export async function extractZip(zipFilePath: string, outputPath: string, ignoredFiles?: Set<string>) {
|
||||
const { readZipFile, readContent } = (await import("@triliumnext/server/src/services/import/zip.js"));
|
||||
await readZipFile(await fs.readFile(zipFilePath), async (zip, entry) => {
|
||||
// We ignore directories since they can appear out of order anyway.
|
||||
if (!entry.fileName.endsWith("/") && !ignoredFiles?.has(entry.fileName)) {
|
||||
@@ -235,27 +129,6 @@ export async function extractZip(
|
||||
});
|
||||
}
|
||||
|
||||
export async function buildDocsFromConfig(configPath?: string, gitRootDir?: string) {
|
||||
const config = await loadConfig(configPath);
|
||||
|
||||
if (gitRootDir) {
|
||||
// Build the share theme if we have a gitRootDir (for Trilium project)
|
||||
execSync(`pnpm run --filter share-theme build`, {
|
||||
stdio: "inherit",
|
||||
cwd: gitRootDir
|
||||
});
|
||||
}
|
||||
|
||||
// Trigger the actual build.
|
||||
await new Promise((res, rej) => {
|
||||
cls.init(() => {
|
||||
buildDocsInner(config ?? undefined)
|
||||
.catch(rej)
|
||||
.then(res);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export default async function buildDocs({ gitRootDir }: BuildContext) {
|
||||
// Build the share theme.
|
||||
execSync(`pnpm run --filter share-theme build`, {
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import packageJson from "../package.json" with { type: "json" };
|
||||
import { buildDocsFromConfig } from "./build-docs.js";
|
||||
|
||||
// Parse command-line arguments
|
||||
function parseArgs() {
|
||||
const args = process.argv.slice(2);
|
||||
let configPath: string | undefined;
|
||||
let showHelp = false;
|
||||
let showVersion = false;
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === "--config" || args[i] === "-c") {
|
||||
configPath = args[i + 1];
|
||||
if (!configPath) {
|
||||
console.error("Error: --config/-c requires a path argument");
|
||||
process.exit(1);
|
||||
}
|
||||
i++; // Skip the next argument as it's the value
|
||||
} else if (args[i] === "--help" || args[i] === "-h") {
|
||||
showHelp = true;
|
||||
} else if (args[i] === "--version" || args[i] === "-v") {
|
||||
showVersion = true;
|
||||
}
|
||||
}
|
||||
|
||||
return { configPath, showHelp, showVersion };
|
||||
}
|
||||
|
||||
function getVersion(): string {
|
||||
return packageJson.version;
|
||||
}
|
||||
|
||||
function printHelp() {
|
||||
const version = getVersion();
|
||||
console.log(`
|
||||
Usage: trilium-build-docs [options]
|
||||
|
||||
Options:
|
||||
-c, --config <path> Path to the configuration file
|
||||
(default: edit-docs-config.yaml in current directory)
|
||||
-h, --help Display this help message
|
||||
-v, --version Display version information
|
||||
|
||||
Description:
|
||||
Builds documentation from Trilium note structure and exports to various formats.
|
||||
Configuration file should be in YAML format with the following structure:
|
||||
|
||||
baseUrl: "https://example.com"
|
||||
noteMappings:
|
||||
- rootNoteId: "noteId123"
|
||||
path: "docs"
|
||||
format: "markdown"
|
||||
- rootNoteId: "noteId456"
|
||||
path: "public/docs"
|
||||
format: "share"
|
||||
exportOnly: true
|
||||
|
||||
Version: ${version}
|
||||
`);
|
||||
}
|
||||
|
||||
function printVersion() {
|
||||
const version = getVersion();
|
||||
console.log(version);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { configPath, showHelp, showVersion } = parseArgs();
|
||||
|
||||
if (showHelp) {
|
||||
printHelp();
|
||||
process.exit(0);
|
||||
} else if (showVersion) {
|
||||
printVersion();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
try {
|
||||
await buildDocsFromConfig(configPath);
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error("Error building documentation:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -13,19 +13,16 @@
|
||||
* Make sure to keep in line with frontend's `script_context.ts`.
|
||||
*/
|
||||
|
||||
export type { default as BasicWidget } from "../../client/src/widgets/basic_widget.js";
|
||||
export type { default as FAttachment } from "../../client/src/entities/fattachment.js";
|
||||
export type { default as FAttribute } from "../../client/src/entities/fattribute.js";
|
||||
export type { default as FBranch } from "../../client/src/entities/fbranch.js";
|
||||
export type { default as FNote } from "../../client/src/entities/fnote.js";
|
||||
export type { Api } from "../../client/src/services/frontend_script_api.js";
|
||||
export type { default as BasicWidget } from "../../client/src/widgets/basic_widget.js";
|
||||
export type {
|
||||
default as NoteContextAwareWidget
|
||||
} from "../../client/src/widgets/note_context_aware_widget.js";
|
||||
export type { default as NoteContextAwareWidget } from "../../client/src/widgets/note_context_aware_widget.js";
|
||||
export type { default as RightPanelWidget } from "../../client/src/widgets/right_panel_widget.js";
|
||||
|
||||
import FrontendScriptApi, { type Api } from "../../client/src/services/frontend_script_api.js";
|
||||
|
||||
|
||||
// @ts-expect-error - FrontendScriptApi is not directly exportable as Api without this simulation.
|
||||
//@ts-expect-error
|
||||
export const api: Api = new FrontendScriptApi();
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { cpSync, existsSync, mkdirSync, rmSync } from "fs";
|
||||
import { join } from "path";
|
||||
|
||||
import buildDocs from "./build-docs";
|
||||
import BuildContext from "./context";
|
||||
import buildScriptApi from "./script-api";
|
||||
import buildSwagger from "./swagger";
|
||||
import { cpSync, existsSync, mkdirSync, rmSync } from "fs";
|
||||
import buildDocs from "./build-docs";
|
||||
import buildScriptApi from "./script-api";
|
||||
|
||||
const context: BuildContext = {
|
||||
gitRootDir: join(__dirname, "../../../"),
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { execSync } from "child_process";
|
||||
import { join } from "path";
|
||||
|
||||
import BuildContext from "./context";
|
||||
import { join } from "path";
|
||||
|
||||
export default function buildScriptApi({ baseDir, gitRootDir }: BuildContext) {
|
||||
// Generate types
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import BuildContext from "./context";
|
||||
import { join } from "path";
|
||||
import { execSync } from "child_process";
|
||||
import { mkdirSync } from "fs";
|
||||
import { join } from "path";
|
||||
|
||||
import BuildContext from "./context";
|
||||
|
||||
interface BuildInfo {
|
||||
specPath: string;
|
||||
@@ -28,9 +27,6 @@ export default function buildSwagger({ baseDir, gitRootDir }: BuildContext) {
|
||||
const absSpecPath = join(gitRootDir, specPath);
|
||||
const targetDir = join(baseDir, outDir);
|
||||
mkdirSync(targetDir, { recursive: true });
|
||||
execSync(
|
||||
`pnpm redocly build-docs ${absSpecPath} -o ${targetDir}/index.html`,
|
||||
{ stdio: "inherit" }
|
||||
);
|
||||
execSync(`pnpm redocly build-docs ${absSpecPath} -o ${targetDir}/index.html`, { stdio: "inherit" });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": [
|
||||
"scripts/**/*.ts"
|
||||
],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "../server"
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
"entryPoints": [
|
||||
"src/backend_script_entrypoint.ts"
|
||||
],
|
||||
"tsconfig": "tsconfig.app.json",
|
||||
"plugin": [
|
||||
"typedoc-plugin-missing-exports"
|
||||
]
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
"entryPoints": [
|
||||
"src/frontend_script_entrypoint.ts"
|
||||
],
|
||||
"tsconfig": "tsconfig.app.json",
|
||||
"plugin": [
|
||||
"typedoc-plugin-missing-exports"
|
||||
]
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
# The development license key for premium CKEditor features.
|
||||
# Note: This key must only be used for the Trilium Notes project.
|
||||
VITE_CKEDITOR_KEY=eyJhbGciOiJFUzI1NiJ9.eyJleHAiOjE3ODcyNzA0MDAsImp0aSI6IjkyMWE1MWNlLTliNDMtNGRlMC1iOTQwLTc5ZjM2MDBkYjg1NyIsImRpc3RyaWJ1dGlvbkNoYW5uZWwiOiJ0cmlsaXVtIiwiZmVhdHVyZXMiOlsiVFJJTElVTSJdLCJ2YyI6ImU4YzRhMjBkIn0.hny77p-U4-jTkoqbwPytrEar5ylGCWBN7Ez3SlB8i6_mJCBIeCSTOlVQk_JMiOEq3AGykUMHzWXzjdMFwgniOw
|
||||
VITE_CKEDITOR_ENABLE_INSPECTOR=false
|
||||
@@ -1 +0,0 @@
|
||||
VITE_CKEDITOR_ENABLE_INSPECTOR=false
|
||||
@@ -1,87 +0,0 @@
|
||||
{
|
||||
"name": "@triliumnext/client-standalone",
|
||||
"version": "0.102.1",
|
||||
"description": "Standalone client for TriliumNext with SQLite WASM backend",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-only",
|
||||
"scripts": {
|
||||
"build": "cross-env NODE_OPTIONS=--max-old-space-size=4096 vite build",
|
||||
"dev": "vite dev",
|
||||
"test": "vitest",
|
||||
"start-prod": "pnpm build && pnpm http-server dist -p 8888",
|
||||
"coverage": "vitest --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@excalidraw/excalidraw": "0.18.0",
|
||||
"@fullcalendar/core": "6.1.20",
|
||||
"@fullcalendar/daygrid": "6.1.20",
|
||||
"@fullcalendar/interaction": "6.1.20",
|
||||
"@fullcalendar/list": "6.1.20",
|
||||
"@fullcalendar/multimonth": "6.1.20",
|
||||
"@fullcalendar/timegrid": "6.1.20",
|
||||
"@maplibre/maplibre-gl-leaflet": "0.1.3",
|
||||
"@mermaid-js/layout-elk": "0.2.0",
|
||||
"@mind-elixir/node-menu": "5.0.1",
|
||||
"@popperjs/core": "2.11.8",
|
||||
"@preact/signals": "2.5.1",
|
||||
"@sqlite.org/sqlite-wasm": "3.51.1-build2",
|
||||
"@triliumnext/ckeditor5": "workspace:*",
|
||||
"@triliumnext/codemirror": "workspace:*",
|
||||
"@triliumnext/commons": "workspace:*",
|
||||
"@triliumnext/core": "workspace:*",
|
||||
"@triliumnext/highlightjs": "workspace:*",
|
||||
"@triliumnext/share-theme": "workspace:*",
|
||||
"@triliumnext/split.js": "workspace:*",
|
||||
"@zumer/snapdom": "2.0.1",
|
||||
"autocomplete.js": "0.38.1",
|
||||
"bootstrap": "5.3.8",
|
||||
"boxicons": "2.1.4",
|
||||
"clsx": "2.1.1",
|
||||
"color": "5.0.3",
|
||||
"debounce": "3.0.0",
|
||||
"draggabilly": "3.0.0",
|
||||
"force-graph": "1.51.0",
|
||||
"globals": "17.0.0",
|
||||
"i18next": "25.7.3",
|
||||
"i18next-http-backend": "3.0.2",
|
||||
"jquery": "3.7.1",
|
||||
"jquery.fancytree": "2.38.5",
|
||||
"js-sha1": "0.7.0",
|
||||
"js-sha256": "0.11.1",
|
||||
"js-sha512": "0.9.0",
|
||||
"jsplumb": "2.15.6",
|
||||
"katex": "0.16.27",
|
||||
"knockout": "3.5.1",
|
||||
"leaflet": "1.9.4",
|
||||
"leaflet-gpx": "2.2.0",
|
||||
"mark.js": "8.11.1",
|
||||
"marked": "17.0.1",
|
||||
"mermaid": "11.12.2",
|
||||
"mind-elixir": "5.4.0",
|
||||
"normalize.css": "8.0.1",
|
||||
"panzoom": "9.4.3",
|
||||
"preact": "10.28.2",
|
||||
"react-i18next": "16.5.1",
|
||||
"react-window": "2.2.3",
|
||||
"reveal.js": "5.2.1",
|
||||
"svg-pan-zoom": "3.6.2",
|
||||
"tabulator-tables": "6.3.1",
|
||||
"vanilla-js-wheel-zoom": "9.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ckeditor/ckeditor5-inspector": "5.0.0",
|
||||
"@preact/preset-vite": "2.10.2",
|
||||
"@types/bootstrap": "5.2.10",
|
||||
"@types/jquery": "3.5.33",
|
||||
"@types/leaflet": "1.9.21",
|
||||
"@types/leaflet-gpx": "1.3.8",
|
||||
"@types/mark.js": "8.11.12",
|
||||
"@types/reveal.js": "5.2.2",
|
||||
"@types/tabulator-tables": "6.3.1",
|
||||
"copy-webpack-plugin": "13.0.1",
|
||||
"cross-env": "7.0.3",
|
||||
"happy-dom": "20.0.11",
|
||||
"script-loader": "0.7.2",
|
||||
"vite-plugin-static-copy": "3.1.4"
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
/*
|
||||
Cross-Origin-Opener-Policy: same-origin
|
||||
Cross-Origin-Embedder-Policy: require-corp
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 112 KiB |
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"name": "Trilium Notes",
|
||||
"short_name": "Trilium",
|
||||
"description": "Trilium Notes is a hierarchical note taking application with focus on building large personal knowledge bases.",
|
||||
"theme_color": "#333333",
|
||||
"background_color": "#1F1F1F",
|
||||
"display": "standalone",
|
||||
"scope": "/",
|
||||
"start_url": "/",
|
||||
"display_override": [
|
||||
"window-controls-overlay"
|
||||
],
|
||||
"icons": [
|
||||
{
|
||||
"src": "assets/icon.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
// Re-export desktop from client
|
||||
export * from "../../client/src/desktop";
|
||||
@@ -1,31 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<link rel="shortcut icon" href="favicon.ico">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover" />
|
||||
<link rel="manifest" crossorigin="use-credentials" href="manifest.webmanifest">
|
||||
<title>Trilium Notes</title>
|
||||
</head>
|
||||
|
||||
<body id="trilium-app">
|
||||
<noscript>Trilium requires JavaScript to be enabled.</noscript>
|
||||
|
||||
<div id="context-menu-cover"></div>
|
||||
<div class="dropdown-menu dropdown-menu-sm" id="context-menu-container" style="display: none"></div>
|
||||
|
||||
<!-- Required for match the PWA's top bar color with the theme -->
|
||||
<!-- This works even when the user directly changes --root-background in CSS -->
|
||||
<div id="background-color-tracker" style="position: absolute; visibility: hidden; color: var(--root-background); transition: color 1ms;"></div>
|
||||
|
||||
<!-- Bootstrap (request server for required information) -->
|
||||
<script src="./main.ts" type="module"></script>
|
||||
|
||||
<!-- Required for correct loading of scripts in Electron -->
|
||||
<script>
|
||||
if (typeof module === 'object') {window.module = module; module = undefined;}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,283 +0,0 @@
|
||||
/**
|
||||
* Browser-compatible router that mimics Express routing patterns.
|
||||
* Supports path parameters (e.g., /api/notes/:noteId) and query strings.
|
||||
*/
|
||||
|
||||
import { getContext, routes } from "@triliumnext/core";
|
||||
|
||||
export interface BrowserRequest {
|
||||
method: string;
|
||||
url: string;
|
||||
path: string;
|
||||
params: Record<string, string>;
|
||||
query: Record<string, string | undefined>;
|
||||
headers?: Record<string, string>;
|
||||
body?: unknown;
|
||||
}
|
||||
|
||||
export interface BrowserResponse {
|
||||
status: number;
|
||||
headers: Record<string, string>;
|
||||
body: ArrayBuffer | null;
|
||||
}
|
||||
|
||||
export type RouteHandler = (req: BrowserRequest) => unknown | Promise<unknown>;
|
||||
|
||||
interface Route {
|
||||
method: string;
|
||||
pattern: RegExp;
|
||||
paramNames: string[];
|
||||
handler: RouteHandler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Symbol used to mark a result as an already-formatted response,
|
||||
* so that formatResult passes it through without JSON-serializing.
|
||||
* Must match the symbol exported from browser_routes.ts.
|
||||
*/
|
||||
const RAW_RESPONSE = Symbol.for('RAW_RESPONSE');
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
/**
|
||||
* Convert an Express-style path pattern to a RegExp.
|
||||
* Supports :param syntax for path parameters.
|
||||
*
|
||||
* Examples:
|
||||
* /api/notes/:noteId -> /^\/api\/notes\/([^\/]+)$/
|
||||
* /api/notes/:noteId/revisions -> /^\/api\/notes\/([^\/]+)\/revisions$/
|
||||
*/
|
||||
function pathToRegex(path: string): { pattern: RegExp; paramNames: string[] } {
|
||||
const paramNames: string[] = [];
|
||||
|
||||
// Escape special regex characters except for :param patterns
|
||||
const regexPattern = path
|
||||
.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // Escape special chars
|
||||
.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, (_, paramName) => {
|
||||
paramNames.push(paramName);
|
||||
return '([^/]+)';
|
||||
});
|
||||
|
||||
return {
|
||||
pattern: new RegExp(`^${regexPattern}$`),
|
||||
paramNames
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse query string into an object.
|
||||
*/
|
||||
function parseQuery(search: string): Record<string, string | undefined> {
|
||||
const query: Record<string, string | undefined> = {};
|
||||
if (!search || search === '?') return query;
|
||||
|
||||
const params = new URLSearchParams(search);
|
||||
for (const [key, value] of params) {
|
||||
query[key] = value;
|
||||
}
|
||||
return query;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a result to a JSON response.
|
||||
*/
|
||||
function jsonResponse(obj: unknown, status = 200, extraHeaders: Record<string, string> = {}): BrowserResponse {
|
||||
const parsedObj = routes.convertEntitiesToPojo(obj);
|
||||
const body = encoder.encode(JSON.stringify(parsedObj)).buffer as ArrayBuffer;
|
||||
return {
|
||||
status,
|
||||
headers: { "content-type": "application/json; charset=utf-8", ...extraHeaders },
|
||||
body
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a string to a text response.
|
||||
*/
|
||||
function textResponse(text: string, status = 200, extraHeaders: Record<string, string> = {}): BrowserResponse {
|
||||
const body = encoder.encode(text).buffer as ArrayBuffer;
|
||||
return {
|
||||
status,
|
||||
headers: { "content-type": "text/plain; charset=utf-8", ...extraHeaders },
|
||||
body
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Browser router class that handles route registration and dispatching.
|
||||
*/
|
||||
export class BrowserRouter {
|
||||
private routes: Route[] = [];
|
||||
|
||||
/**
|
||||
* Register a route handler.
|
||||
*/
|
||||
register(method: string, path: string, handler: RouteHandler): void {
|
||||
const { pattern, paramNames } = pathToRegex(path);
|
||||
this.routes.push({
|
||||
method: method.toUpperCase(),
|
||||
pattern,
|
||||
paramNames,
|
||||
handler
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience methods for common HTTP methods.
|
||||
*/
|
||||
get(path: string, handler: RouteHandler): void {
|
||||
this.register('GET', path, handler);
|
||||
}
|
||||
|
||||
post(path: string, handler: RouteHandler): void {
|
||||
this.register('POST', path, handler);
|
||||
}
|
||||
|
||||
put(path: string, handler: RouteHandler): void {
|
||||
this.register('PUT', path, handler);
|
||||
}
|
||||
|
||||
patch(path: string, handler: RouteHandler): void {
|
||||
this.register('PATCH', path, handler);
|
||||
}
|
||||
|
||||
delete(path: string, handler: RouteHandler): void {
|
||||
this.register('DELETE', path, handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch a request to the appropriate handler.
|
||||
*/
|
||||
async dispatch(method: string, urlString: string, body?: unknown, headers?: Record<string, string>): Promise<BrowserResponse> {
|
||||
const url = new URL(urlString);
|
||||
const path = url.pathname;
|
||||
const query = parseQuery(url.search);
|
||||
const upperMethod = method.toUpperCase();
|
||||
|
||||
// Parse JSON body if it's an ArrayBuffer and content-type suggests JSON
|
||||
let parsedBody = body;
|
||||
if (body instanceof ArrayBuffer && headers) {
|
||||
const contentType = headers['content-type'] || headers['Content-Type'] || '';
|
||||
if (contentType.includes('application/json')) {
|
||||
try {
|
||||
const text = new TextDecoder().decode(body);
|
||||
if (text.trim()) {
|
||||
parsedBody = JSON.parse(text);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[Router] Failed to parse JSON body:', e);
|
||||
// Keep original body if JSON parsing fails
|
||||
parsedBody = body;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Find matching route
|
||||
for (const route of this.routes) {
|
||||
if (route.method !== upperMethod) continue;
|
||||
|
||||
const match = path.match(route.pattern);
|
||||
if (!match) continue;
|
||||
|
||||
// Extract path parameters
|
||||
const params: Record<string, string> = {};
|
||||
for (let i = 0; i < route.paramNames.length; i++) {
|
||||
params[route.paramNames[i]] = decodeURIComponent(match[i + 1]);
|
||||
}
|
||||
|
||||
const request: BrowserRequest = {
|
||||
method: upperMethod,
|
||||
url: urlString,
|
||||
path,
|
||||
params,
|
||||
query,
|
||||
headers: headers ?? {},
|
||||
body: parsedBody
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await getContext().init(async () => await route.handler(request));
|
||||
return this.formatResult(result);
|
||||
} catch (error) {
|
||||
return this.formatError(error, `Error handling ${method} ${path}`);
|
||||
}
|
||||
}
|
||||
|
||||
// No route matched
|
||||
return textResponse(`Not found: ${method} ${path}`, 404);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a handler result into a response.
|
||||
* Follows the same patterns as the server's apiResultHandler.
|
||||
*/
|
||||
private formatResult(result: unknown): BrowserResponse {
|
||||
// Handle raw responses (e.g. from image routes that write directly to res)
|
||||
if (result && typeof result === 'object' && RAW_RESPONSE in result) {
|
||||
const raw = result as unknown as { status: number; headers: Record<string, string>; body: unknown };
|
||||
let body: ArrayBuffer | null = null;
|
||||
|
||||
if (raw.body instanceof ArrayBuffer) {
|
||||
body = raw.body;
|
||||
} else if (raw.body instanceof Uint8Array) {
|
||||
body = raw.body.buffer as ArrayBuffer;
|
||||
} else if (typeof raw.body === 'string') {
|
||||
body = encoder.encode(raw.body).buffer as ArrayBuffer;
|
||||
}
|
||||
|
||||
return {
|
||||
status: raw.status,
|
||||
headers: raw.headers,
|
||||
body
|
||||
};
|
||||
}
|
||||
|
||||
// Handle [statusCode, response] format
|
||||
if (Array.isArray(result) && result.length > 0 && Number.isInteger(result[0])) {
|
||||
const [statusCode, response] = result;
|
||||
return jsonResponse(response, statusCode);
|
||||
}
|
||||
|
||||
// Handle undefined (no content) - 204 should have no body
|
||||
if (result === undefined) {
|
||||
return {
|
||||
status: 204,
|
||||
headers: {},
|
||||
body: null
|
||||
};
|
||||
}
|
||||
|
||||
// Default: JSON response with 200
|
||||
return jsonResponse(result, 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format an error into a response.
|
||||
*/
|
||||
private formatError(error: unknown, context: string): BrowserResponse {
|
||||
console.error('[Router] Handler error:', context, error);
|
||||
|
||||
// Check for known error types
|
||||
if (error && typeof error === 'object') {
|
||||
const err = error as { constructor?: { name?: string }; message?: string };
|
||||
|
||||
if (err.constructor?.name === 'NotFoundError') {
|
||||
return jsonResponse({ message: err.message || 'Not found' }, 404);
|
||||
}
|
||||
|
||||
if (err.constructor?.name === 'ValidationError') {
|
||||
return jsonResponse({ message: err.message || 'Validation error' }, 400);
|
||||
}
|
||||
}
|
||||
|
||||
// Generic error
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return jsonResponse({ message }, 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new router instance.
|
||||
*/
|
||||
export function createRouter(): BrowserRouter {
|
||||
return new BrowserRouter();
|
||||
}
|
||||
@@ -1,291 +0,0 @@
|
||||
/**
|
||||
* Browser route definitions.
|
||||
* This integrates with the shared route builder from @triliumnext/core.
|
||||
*/
|
||||
|
||||
import { BootstrapDefinition } from '@triliumnext/commons';
|
||||
import { entity_changes, getContext, getSharedBootstrapItems, getSql, routes, sql_init } from '@triliumnext/core';
|
||||
import { getIconConfig } from '@triliumnext/core/src/services/bootstrap_utils';
|
||||
|
||||
import packageJson from '../../package.json' with { type: 'json' };
|
||||
import { type BrowserRequest, BrowserRouter } from './browser_router';
|
||||
|
||||
/** Minimal response object used by apiResultHandler to capture the processed result. */
|
||||
interface ResultHandlerResponse {
|
||||
headers: Record<string, string>;
|
||||
result: unknown;
|
||||
setHeader(name: string, value: string): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Symbol used to mark a result as an already-formatted BrowserResponse,
|
||||
* so that BrowserRouter.formatResult passes it through without JSON-serializing.
|
||||
* Uses Symbol.for() so the same symbol is shared across modules.
|
||||
*/
|
||||
const RAW_RESPONSE = Symbol.for('RAW_RESPONSE');
|
||||
|
||||
type HttpMethod = 'get' | 'post' | 'put' | 'patch' | 'delete';
|
||||
|
||||
/**
|
||||
* Creates an Express-like request object from a BrowserRequest.
|
||||
*/
|
||||
function toExpressLikeReq(req: BrowserRequest) {
|
||||
return {
|
||||
params: req.params,
|
||||
query: req.query,
|
||||
body: req.body,
|
||||
headers: req.headers ?? {},
|
||||
method: req.method,
|
||||
get originalUrl() { return req.url; }
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts context headers from the request and sets them in the execution context,
|
||||
* mirroring what the server does in route_api.ts.
|
||||
*/
|
||||
function setContextFromHeaders(req: BrowserRequest) {
|
||||
const headers = req.headers ?? {};
|
||||
const ctx = getContext();
|
||||
ctx.set("componentId", headers["trilium-component-id"]);
|
||||
ctx.set("localNowDateTime", headers["trilium-local-now-datetime"]);
|
||||
ctx.set("hoistedNoteId", headers["trilium-hoisted-note-id"] || "root");
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a core route handler to work with the BrowserRouter.
|
||||
* Core handlers expect an Express-like request object with params, query, and body.
|
||||
* Each request is wrapped in an execution context (like cls.init() on the server)
|
||||
* to ensure entity change tracking works correctly.
|
||||
*/
|
||||
function wrapHandler(handler: (req: any) => unknown, transactional: boolean) {
|
||||
return (req: BrowserRequest) => {
|
||||
return getContext().init(() => {
|
||||
setContextFromHeaders(req);
|
||||
const expressLikeReq = toExpressLikeReq(req);
|
||||
if (transactional) {
|
||||
return getSql().transactional(() => handler(expressLikeReq));
|
||||
}
|
||||
return handler(expressLikeReq);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an apiRoute function compatible with buildSharedApiRoutes.
|
||||
* This bridges the core's route registration to the BrowserRouter.
|
||||
*/
|
||||
function createApiRoute(router: BrowserRouter, transactional: boolean) {
|
||||
return (method: HttpMethod, path: string, handler: (req: any) => unknown) => {
|
||||
router.register(method, path, wrapHandler(handler, transactional));
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Low-level route registration matching the server's `route()` signature:
|
||||
* route(method, path, middleware[], handler, resultHandler)
|
||||
*
|
||||
* In standalone mode:
|
||||
* - Middleware (e.g. checkApiAuth) is skipped — there's no authentication.
|
||||
* - The resultHandler is applied to post-process the result (entity conversion, status codes).
|
||||
*/
|
||||
function createRoute(router: BrowserRouter) {
|
||||
return (method: HttpMethod, path: string, _middleware: any[], handler: (req: any, res: any) => unknown, resultHandler?: ((req: any, res: any, result: unknown) => unknown) | null) => {
|
||||
router.register(method, path, (req: BrowserRequest) => {
|
||||
return getContext().init(() => {
|
||||
setContextFromHeaders(req);
|
||||
const expressLikeReq = toExpressLikeReq(req);
|
||||
const mockRes = createMockExpressResponse();
|
||||
const result = getSql().transactional(() => handler(expressLikeReq, mockRes));
|
||||
|
||||
// If the handler used the mock response (e.g. image routes that call res.send()),
|
||||
// return it as a raw response so BrowserRouter doesn't JSON-serialize it.
|
||||
if (mockRes._used) {
|
||||
return {
|
||||
[RAW_RESPONSE]: true as const,
|
||||
status: mockRes._status,
|
||||
headers: mockRes._headers,
|
||||
body: mockRes._body
|
||||
};
|
||||
}
|
||||
|
||||
if (resultHandler) {
|
||||
// Create a minimal response object that captures what apiResultHandler sets.
|
||||
const res = createResultHandlerResponse();
|
||||
resultHandler(expressLikeReq, res, result);
|
||||
return res.result;
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock Express response object that captures calls to set(), send(), sendStatus(), etc.
|
||||
* Used for route handlers (like image routes) that write directly to the response.
|
||||
*/
|
||||
function createMockExpressResponse() {
|
||||
const res = {
|
||||
_used: false,
|
||||
_status: 200,
|
||||
_headers: {} as Record<string, string>,
|
||||
_body: null as unknown,
|
||||
set(name: string, value: string) {
|
||||
res._headers[name] = value;
|
||||
return res;
|
||||
},
|
||||
setHeader(name: string, value: string) {
|
||||
res._headers[name] = value;
|
||||
return res;
|
||||
},
|
||||
status(code: number) {
|
||||
res._status = code;
|
||||
return res;
|
||||
},
|
||||
send(body: unknown) {
|
||||
res._used = true;
|
||||
res._body = body;
|
||||
return res;
|
||||
},
|
||||
sendStatus(code: number) {
|
||||
res._used = true;
|
||||
res._status = code;
|
||||
return res;
|
||||
}
|
||||
};
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* Standalone apiResultHandler matching the server's behavior:
|
||||
* - Converts Becca entities to POJOs
|
||||
* - Handles [statusCode, response] tuple format
|
||||
* - Sets trilium-max-entity-change-id (captured in response headers)
|
||||
*/
|
||||
function apiResultHandler(_req: any, res: ResultHandlerResponse, result: unknown) {
|
||||
res.headers["trilium-max-entity-change-id"] = String(entity_changes.getMaxEntityChangeId());
|
||||
result = routes.convertEntitiesToPojo(result);
|
||||
|
||||
if (Array.isArray(result) && result.length > 0 && Number.isInteger(result[0])) {
|
||||
const [_statusCode, response] = result;
|
||||
res.result = response;
|
||||
} else if (result === undefined) {
|
||||
res.result = "";
|
||||
} else {
|
||||
res.result = result;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* No-op middleware stubs for standalone mode.
|
||||
*
|
||||
* In a browser context there is no network authentication, rate limiting,
|
||||
* or multi-user access, so all auth/rate-limit middleware is a no-op.
|
||||
*
|
||||
* `checkAppNotInitialized` still guards setup routes: if the database is
|
||||
* already initialised the middleware throws so the route handler is never
|
||||
* reached (mirrors the server behaviour).
|
||||
*/
|
||||
function noopMiddleware() {
|
||||
// No-op.
|
||||
}
|
||||
|
||||
function checkAppNotInitialized() {
|
||||
if (sql_init.isDbInitialized()) {
|
||||
throw new Error("App already initialized.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a minimal response-like object for the apiResultHandler.
|
||||
*/
|
||||
function createResultHandlerResponse(): ResultHandlerResponse {
|
||||
return {
|
||||
headers: {},
|
||||
result: undefined,
|
||||
setHeader(name: string, value: string) {
|
||||
this.headers[name] = value;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Register all API routes on the browser router using the shared builder.
|
||||
*
|
||||
* @param router - The browser router instance
|
||||
*/
|
||||
export function registerRoutes(router: BrowserRouter): void {
|
||||
const apiRoute = createApiRoute(router, true);
|
||||
routes.buildSharedApiRoutes({
|
||||
route: createRoute(router),
|
||||
asyncRoute: createRoute(router),
|
||||
apiRoute,
|
||||
asyncApiRoute: createApiRoute(router, false),
|
||||
apiResultHandler,
|
||||
checkApiAuth: noopMiddleware,
|
||||
checkApiAuthOrElectron: noopMiddleware,
|
||||
checkAppNotInitialized,
|
||||
checkCredentials: noopMiddleware,
|
||||
loginRateLimiter: noopMiddleware
|
||||
});
|
||||
apiRoute('get', '/bootstrap', bootstrapRoute);
|
||||
|
||||
// Dummy routes for compatibility.
|
||||
apiRoute("get", "/api/script/widgets", () => []);
|
||||
apiRoute("get", "/api/script/startup", () => []);
|
||||
apiRoute("get", "/api/system-checks", () => ({ isCpuArchMismatch: false }));
|
||||
}
|
||||
|
||||
function bootstrapRoute(): BootstrapDefinition {
|
||||
const assetPath = ".";
|
||||
|
||||
if (!sql_init.isDbInitialized()) {
|
||||
return {
|
||||
dbInitialized: false,
|
||||
baseApiUrl: "../api/",
|
||||
assetPath,
|
||||
themeCssUrl: false,
|
||||
themeUseNextAsBase: "next",
|
||||
...getIconConfig(assetPath)
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
dbInitialized: true,
|
||||
...getSharedBootstrapItems(assetPath),
|
||||
appPath: assetPath,
|
||||
device: false, // Let the client detect device type.
|
||||
csrfToken: "dummy-csrf-token",
|
||||
themeCssUrl: false,
|
||||
themeUseNextAsBase: "next",
|
||||
triliumVersion: packageJson.version,
|
||||
baseApiUrl: "../api/",
|
||||
headingStyle: "plain",
|
||||
layoutOrientation: "vertical",
|
||||
platform: "web",
|
||||
isDev: import.meta.env.DEV,
|
||||
isMainWindow: true,
|
||||
isElectron: false,
|
||||
isStandalone: true,
|
||||
hasNativeTitleBar: false,
|
||||
hasBackgroundEffects: false,
|
||||
|
||||
// TODO: Fill properly
|
||||
currentLocale: { id: "en", name: "English", rtl: false },
|
||||
isRtl: false,
|
||||
instanceName: null,
|
||||
appCssNoteIds: [],
|
||||
TRILIUM_SAFE_MODE: false
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and configure a router with all routes registered.
|
||||
*/
|
||||
export function createConfiguredRouter(): BrowserRouter {
|
||||
const router = new BrowserRouter();
|
||||
registerRoutes(router);
|
||||
return router;
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import { ExecutionContext } from "@triliumnext/core";
|
||||
|
||||
/**
|
||||
* Browser execution context implementation.
|
||||
*
|
||||
* Handles per-request context isolation with support for fire-and-forget async operations
|
||||
* using a context stack and grace-period cleanup to allow unawaited promises to complete.
|
||||
*/
|
||||
export default class BrowserExecutionContext implements ExecutionContext {
|
||||
private contextStack: Map<string, any>[] = [];
|
||||
private cleanupTimers = new WeakMap<Map<string, any>, ReturnType<typeof setTimeout>>();
|
||||
private readonly CLEANUP_GRACE_PERIOD = 1000; // 1 second for fire-and-forget operations
|
||||
|
||||
private getCurrentContext(): Map<string, any> {
|
||||
if (this.contextStack.length === 0) {
|
||||
throw new Error("ExecutionContext not initialized");
|
||||
}
|
||||
return this.contextStack[this.contextStack.length - 1];
|
||||
}
|
||||
|
||||
get<T = any>(key: string): T {
|
||||
return this.getCurrentContext().get(key);
|
||||
}
|
||||
|
||||
set(key: string, value: any): void {
|
||||
this.getCurrentContext().set(key, value);
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.contextStack = [];
|
||||
}
|
||||
|
||||
init<T>(callback: () => T): T {
|
||||
const context = new Map<string, any>();
|
||||
this.contextStack.push(context);
|
||||
|
||||
// Cancel any pending cleanup timer for this context
|
||||
const existingTimer = this.cleanupTimers.get(context);
|
||||
if (existingTimer) {
|
||||
clearTimeout(existingTimer);
|
||||
this.cleanupTimers.delete(context);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = callback();
|
||||
|
||||
// If the result is a Promise
|
||||
if (result && typeof result === 'object' && 'then' in result && 'catch' in result) {
|
||||
const promise = result as unknown as Promise<any>;
|
||||
return promise.finally(() => {
|
||||
this.scheduleContextCleanup(context);
|
||||
}) as T;
|
||||
} else {
|
||||
// For synchronous results, schedule delayed cleanup to allow fire-and-forget operations
|
||||
this.scheduleContextCleanup(context);
|
||||
return result;
|
||||
}
|
||||
} catch (error) {
|
||||
// Always clean up on error with grace period
|
||||
this.scheduleContextCleanup(context);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleContextCleanup(context: Map<string, any>): void {
|
||||
const timer = setTimeout(() => {
|
||||
// Remove from stack if still present
|
||||
const index = this.contextStack.indexOf(context);
|
||||
if (index !== -1) {
|
||||
this.contextStack.splice(index, 1);
|
||||
}
|
||||
this.cleanupTimers.delete(context);
|
||||
}, this.CLEANUP_GRACE_PERIOD);
|
||||
|
||||
this.cleanupTimers.set(context, timer);
|
||||
}
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
import type { CryptoProvider } from "@triliumnext/core";
|
||||
import { sha1 } from "js-sha1";
|
||||
import { sha256 } from "js-sha256";
|
||||
import { sha512 } from "js-sha512";
|
||||
|
||||
interface Cipher {
|
||||
update(data: Uint8Array): Uint8Array;
|
||||
final(): Uint8Array;
|
||||
}
|
||||
|
||||
const CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
||||
|
||||
/**
|
||||
* Crypto provider for browser environments using the Web Crypto API.
|
||||
*/
|
||||
export default class BrowserCryptoProvider implements CryptoProvider {
|
||||
|
||||
createHash(algorithm: "sha1" | "sha512", content: string | Uint8Array): Uint8Array {
|
||||
const data = typeof content === "string" ? content :
|
||||
new TextDecoder().decode(content);
|
||||
|
||||
const hexHash = algorithm === "sha1" ? sha1(data) : sha512(data);
|
||||
|
||||
// Convert hex string to Uint8Array
|
||||
const bytes = new Uint8Array(hexHash.length / 2);
|
||||
for (let i = 0; i < hexHash.length; i += 2) {
|
||||
bytes[i / 2] = parseInt(hexHash.substr(i, 2), 16);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
createCipheriv(algorithm: "aes-128-cbc", key: Uint8Array, iv: Uint8Array): Cipher {
|
||||
// Web Crypto API doesn't support streaming cipher like Node.js
|
||||
// We need to implement a wrapper that collects data and encrypts on final()
|
||||
return new WebCryptoCipher(algorithm, key, iv, "encrypt");
|
||||
}
|
||||
|
||||
createDecipheriv(algorithm: "aes-128-cbc", key: Uint8Array, iv: Uint8Array): Cipher {
|
||||
return new WebCryptoCipher(algorithm, key, iv, "decrypt");
|
||||
}
|
||||
|
||||
randomBytes(size: number): Uint8Array {
|
||||
const bytes = new Uint8Array(size);
|
||||
crypto.getRandomValues(bytes);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
randomString(length: number): string {
|
||||
const bytes = this.randomBytes(length);
|
||||
let result = "";
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += CHARS[bytes[i] % CHARS.length];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
hmac(secret: string | Uint8Array, value: string | Uint8Array): string {
|
||||
const secretStr = typeof secret === "string" ? secret : new TextDecoder().decode(secret);
|
||||
const valueStr = typeof value === "string" ? value : new TextDecoder().decode(value);
|
||||
// sha256.hmac returns hex, convert to base64 to match Node's behavior
|
||||
const hexHash = sha256.hmac(secretStr, valueStr);
|
||||
const bytes = new Uint8Array(hexHash.length / 2);
|
||||
for (let i = 0; i < hexHash.length; i += 2) {
|
||||
bytes[i / 2] = parseInt(hexHash.substr(i, 2), 16);
|
||||
}
|
||||
return btoa(String.fromCharCode(...bytes));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A cipher implementation that wraps Web Crypto API.
|
||||
* Note: This buffers all data until final() is called, which differs from
|
||||
* Node.js's streaming cipher behavior.
|
||||
*/
|
||||
class WebCryptoCipher implements Cipher {
|
||||
private chunks: Uint8Array[] = [];
|
||||
private algorithm: string;
|
||||
private key: Uint8Array;
|
||||
private iv: Uint8Array;
|
||||
private mode: "encrypt" | "decrypt";
|
||||
private finalized = false;
|
||||
|
||||
constructor(
|
||||
algorithm: "aes-128-cbc",
|
||||
key: Uint8Array,
|
||||
iv: Uint8Array,
|
||||
mode: "encrypt" | "decrypt"
|
||||
) {
|
||||
this.algorithm = algorithm;
|
||||
this.key = key;
|
||||
this.iv = iv;
|
||||
this.mode = mode;
|
||||
}
|
||||
|
||||
update(data: Uint8Array): Uint8Array {
|
||||
if (this.finalized) {
|
||||
throw new Error("Cipher has already been finalized");
|
||||
}
|
||||
// Buffer the data - Web Crypto doesn't support streaming
|
||||
this.chunks.push(data);
|
||||
// Return empty array since we process everything in final()
|
||||
return new Uint8Array(0);
|
||||
}
|
||||
|
||||
final(): Uint8Array {
|
||||
if (this.finalized) {
|
||||
throw new Error("Cipher has already been finalized");
|
||||
}
|
||||
this.finalized = true;
|
||||
|
||||
// Web Crypto API is async, but we need sync behavior
|
||||
// This is a fundamental limitation that requires architectural changes
|
||||
// For now, throw an error directing users to use async methods
|
||||
throw new Error(
|
||||
"Synchronous cipher finalization not available in browser. " +
|
||||
"The Web Crypto API is async-only. Use finalizeAsync() instead."
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Async version that actually performs the encryption/decryption.
|
||||
*/
|
||||
async finalizeAsync(): Promise<Uint8Array> {
|
||||
if (this.finalized) {
|
||||
throw new Error("Cipher has already been finalized");
|
||||
}
|
||||
this.finalized = true;
|
||||
|
||||
// Concatenate all chunks
|
||||
const totalLength = this.chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
||||
const data = new Uint8Array(totalLength);
|
||||
let offset = 0;
|
||||
for (const chunk of this.chunks) {
|
||||
data.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
|
||||
// Copy key and iv to ensure they're plain ArrayBuffer-backed
|
||||
const keyBuffer = new Uint8Array(this.key);
|
||||
const ivBuffer = new Uint8Array(this.iv);
|
||||
|
||||
// Import the key
|
||||
const cryptoKey = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
keyBuffer,
|
||||
{ name: "AES-CBC" },
|
||||
false,
|
||||
[this.mode]
|
||||
);
|
||||
|
||||
// Perform encryption/decryption
|
||||
const result = this.mode === "encrypt"
|
||||
? await crypto.subtle.encrypt({ name: "AES-CBC", iv: ivBuffer }, cryptoKey, data)
|
||||
: await crypto.subtle.decrypt({ name: "AES-CBC", iv: ivBuffer }, cryptoKey, data);
|
||||
|
||||
return new Uint8Array(result);
|
||||
}
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
import type { WebSocketMessage } from "@triliumnext/commons";
|
||||
import type { ClientMessageHandler, MessageHandler,MessagingProvider } from "@triliumnext/core";
|
||||
|
||||
/**
|
||||
* Messaging provider for browser Worker environments.
|
||||
*
|
||||
* This provider uses the Worker's postMessage API to communicate
|
||||
* with the main thread. It's designed to be used inside a Web Worker
|
||||
* that runs the core services.
|
||||
*
|
||||
* Message flow:
|
||||
* - Outbound (worker → main): Uses self.postMessage() with type: "WS_MESSAGE"
|
||||
* - Inbound (main → worker): Listens to onmessage for type: "WS_MESSAGE"
|
||||
*/
|
||||
export default class WorkerMessagingProvider implements MessagingProvider {
|
||||
private messageHandlers: MessageHandler[] = [];
|
||||
private clientMessageHandler?: ClientMessageHandler;
|
||||
private isDisposed = false;
|
||||
|
||||
constructor() {
|
||||
// Listen for incoming messages from the main thread
|
||||
self.addEventListener("message", this.handleIncomingMessage);
|
||||
}
|
||||
|
||||
private handleIncomingMessage = (event: MessageEvent) => {
|
||||
if (this.isDisposed) return;
|
||||
|
||||
const { type, message } = event.data || {};
|
||||
|
||||
if (type === "WS_MESSAGE" && message) {
|
||||
// Dispatch to the client message handler (used by ws.ts for log-error, log-info, ping)
|
||||
if (this.clientMessageHandler) {
|
||||
try {
|
||||
this.clientMessageHandler("main-thread", message);
|
||||
} catch (e) {
|
||||
console.error("[WorkerMessagingProvider] Error in client message handler:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Dispatch to all registered handlers
|
||||
for (const handler of this.messageHandlers) {
|
||||
try {
|
||||
handler(message as WebSocketMessage);
|
||||
} catch (e) {
|
||||
console.error("[WorkerMessagingProvider] Error in message handler:", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Send a message to all clients (in this case, the main thread).
|
||||
* The main thread is responsible for further distribution if needed.
|
||||
*/
|
||||
sendMessageToAllClients(message: WebSocketMessage): void {
|
||||
if (this.isDisposed) {
|
||||
console.warn("[WorkerMessagingProvider] Cannot send message - provider is disposed");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
self.postMessage({
|
||||
type: "WS_MESSAGE",
|
||||
message
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("[WorkerMessagingProvider] Error sending message:", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message to a specific client.
|
||||
* In worker context, there's only one client (the main thread), so clientId is ignored.
|
||||
*/
|
||||
sendMessageToClient(_clientId: string, message: WebSocketMessage): boolean {
|
||||
if (this.isDisposed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.sendMessageToAllClients(message);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a handler for incoming client messages.
|
||||
*/
|
||||
setClientMessageHandler(handler: ClientMessageHandler): void {
|
||||
this.clientMessageHandler = handler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to incoming messages from the main thread.
|
||||
*/
|
||||
onMessage(handler: MessageHandler): () => void {
|
||||
this.messageHandlers.push(handler);
|
||||
|
||||
return () => {
|
||||
this.messageHandlers = this.messageHandlers.filter(h => h !== handler);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the number of connected "clients".
|
||||
* In worker context, there's always exactly 1 client (the main thread).
|
||||
*/
|
||||
getClientCount(): number {
|
||||
return this.isDisposed ? 0 : 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up resources.
|
||||
*/
|
||||
dispose(): void {
|
||||
if (this.isDisposed) return;
|
||||
|
||||
this.isDisposed = true;
|
||||
self.removeEventListener("message", this.handleIncomingMessage);
|
||||
this.messageHandlers = [];
|
||||
}
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
import type { ExecOpts, RequestProvider } from "@triliumnext/core";
|
||||
|
||||
/**
|
||||
* Fetch-based implementation of RequestProvider for browser environments.
|
||||
*
|
||||
* Uses the Fetch API instead of Node's http/https modules.
|
||||
* Proxy support is not available in browsers, so the proxy option is ignored.
|
||||
*/
|
||||
export default class FetchRequestProvider implements RequestProvider {
|
||||
|
||||
async exec<T>(opts: ExecOpts): Promise<T> {
|
||||
const paging = opts.paging || {
|
||||
pageCount: 1,
|
||||
pageIndex: 0,
|
||||
requestId: "n/a"
|
||||
};
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": paging.pageCount === 1 ? "application/json" : "text/plain",
|
||||
"pageCount": String(paging.pageCount),
|
||||
"pageIndex": String(paging.pageIndex),
|
||||
"requestId": paging.requestId
|
||||
};
|
||||
|
||||
// Note: the Cookie header is a forbidden header in fetch —
|
||||
// the browser manages cookies automatically via credentials: 'include'.
|
||||
|
||||
if (opts.auth?.password) {
|
||||
headers["trilium-cred"] = btoa(`dummy:${opts.auth.password}`);
|
||||
}
|
||||
|
||||
let body: string | undefined;
|
||||
if (opts.body) {
|
||||
body = typeof opts.body === "object" ? JSON.stringify(opts.body) : opts.body;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = opts.timeout
|
||||
? setTimeout(() => controller.abort(), opts.timeout)
|
||||
: undefined;
|
||||
|
||||
try {
|
||||
const response = await fetch(opts.url, {
|
||||
method: opts.method,
|
||||
headers,
|
||||
body,
|
||||
signal: controller.signal,
|
||||
credentials: "include"
|
||||
});
|
||||
|
||||
if ([200, 201, 204].includes(response.status)) {
|
||||
const text = await response.text();
|
||||
return text.trim() ? JSON.parse(text) : null;
|
||||
} else {
|
||||
const text = await response.text();
|
||||
let errorMessage: string;
|
||||
try {
|
||||
const json = JSON.parse(text);
|
||||
errorMessage = json?.message || "";
|
||||
} catch {
|
||||
errorMessage = text.substring(0, 100);
|
||||
}
|
||||
throw new Error(`Request to ${opts.method} ${opts.url} failed, error: ${response.status} ${response.statusText} ${errorMessage}`);
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (e.name === "AbortError") {
|
||||
throw new Error(`Request to ${opts.method} ${opts.url} failed, error: timeout after ${opts.timeout}ms`);
|
||||
}
|
||||
throw e;
|
||||
} finally {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async getImage(imageUrl: string): Promise<ArrayBuffer> {
|
||||
const response = await fetch(imageUrl);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Request to GET ${imageUrl} failed, error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
return await response.arrayBuffer();
|
||||
}
|
||||
}
|
||||
@@ -1,613 +0,0 @@
|
||||
import { type BindableValue, default as sqlite3InitModule } from "@sqlite.org/sqlite-wasm";
|
||||
import type { DatabaseProvider, RunResult, Statement, Transaction } from "@triliumnext/core";
|
||||
|
||||
// Type definitions for SQLite WASM (the library doesn't export these directly)
|
||||
type Sqlite3Module = Awaited<ReturnType<typeof sqlite3InitModule>>;
|
||||
type Sqlite3Database = InstanceType<Sqlite3Module["oo1"]["DB"]>;
|
||||
type Sqlite3PreparedStatement = ReturnType<Sqlite3Database["prepare"]>;
|
||||
|
||||
/**
|
||||
* Wraps an SQLite WASM PreparedStatement to match the Statement interface
|
||||
* expected by trilium-core.
|
||||
*/
|
||||
class WasmStatement implements Statement {
|
||||
private isRawMode = false;
|
||||
private isPluckMode = false;
|
||||
private isFinalized = false;
|
||||
|
||||
constructor(
|
||||
private stmt: Sqlite3PreparedStatement,
|
||||
private db: Sqlite3Database,
|
||||
private sqlite3: Sqlite3Module,
|
||||
private sql: string
|
||||
) {}
|
||||
|
||||
run(...params: unknown[]): RunResult {
|
||||
if (this.isFinalized) {
|
||||
throw new Error("Cannot call run() on finalized statement");
|
||||
}
|
||||
|
||||
this.bindParams(params);
|
||||
try {
|
||||
// Use step() and then reset instead of stepFinalize()
|
||||
// This allows the statement to be reused
|
||||
this.stmt.step();
|
||||
const changes = this.db.changes();
|
||||
// Get the last insert row ID using the C API
|
||||
const lastInsertRowid = this.db.pointer ? this.sqlite3.capi.sqlite3_last_insert_rowid(this.db.pointer) : 0;
|
||||
this.stmt.reset();
|
||||
return {
|
||||
changes,
|
||||
lastInsertRowid: typeof lastInsertRowid === "bigint" ? Number(lastInsertRowid) : lastInsertRowid
|
||||
};
|
||||
} catch (e) {
|
||||
// Reset on error to allow reuse
|
||||
this.stmt.reset();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
get(params: unknown): unknown {
|
||||
if (this.isFinalized) {
|
||||
throw new Error("Cannot call get() on finalized statement");
|
||||
}
|
||||
|
||||
this.bindParams(Array.isArray(params) ? params : params !== undefined ? [params] : []);
|
||||
try {
|
||||
if (this.stmt.step()) {
|
||||
if (this.isPluckMode) {
|
||||
// In pluck mode, return only the first column value
|
||||
const row = this.stmt.get([]);
|
||||
return Array.isArray(row) && row.length > 0 ? row[0] : undefined;
|
||||
}
|
||||
return this.isRawMode ? this.stmt.get([]) : this.stmt.get({});
|
||||
}
|
||||
return undefined;
|
||||
} finally {
|
||||
this.stmt.reset();
|
||||
}
|
||||
}
|
||||
|
||||
all(...params: unknown[]): unknown[] {
|
||||
if (this.isFinalized) {
|
||||
throw new Error("Cannot call all() on finalized statement");
|
||||
}
|
||||
|
||||
this.bindParams(params);
|
||||
const results: unknown[] = [];
|
||||
try {
|
||||
while (this.stmt.step()) {
|
||||
if (this.isPluckMode) {
|
||||
// In pluck mode, return only the first column value for each row
|
||||
const row = this.stmt.get([]);
|
||||
if (Array.isArray(row) && row.length > 0) {
|
||||
results.push(row[0]);
|
||||
}
|
||||
} else {
|
||||
results.push(this.isRawMode ? this.stmt.get([]) : this.stmt.get({}));
|
||||
}
|
||||
}
|
||||
return results;
|
||||
} finally {
|
||||
this.stmt.reset();
|
||||
}
|
||||
}
|
||||
|
||||
iterate(...params: unknown[]): IterableIterator<unknown> {
|
||||
if (this.isFinalized) {
|
||||
throw new Error("Cannot call iterate() on finalized statement");
|
||||
}
|
||||
|
||||
this.bindParams(params);
|
||||
const stmt = this.stmt;
|
||||
const isRaw = this.isRawMode;
|
||||
const isPluck = this.isPluckMode;
|
||||
|
||||
return {
|
||||
[Symbol.iterator]() {
|
||||
return this;
|
||||
},
|
||||
next(): IteratorResult<unknown> {
|
||||
if (stmt.step()) {
|
||||
if (isPluck) {
|
||||
const row = stmt.get([]);
|
||||
const value = Array.isArray(row) && row.length > 0 ? row[0] : undefined;
|
||||
return { value, done: false };
|
||||
}
|
||||
return { value: isRaw ? stmt.get([]) : stmt.get({}), done: false };
|
||||
}
|
||||
stmt.reset();
|
||||
return { value: undefined, done: true };
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
raw(toggleState?: boolean): this {
|
||||
// In raw mode, rows are returned as arrays instead of objects
|
||||
// If toggleState is undefined, enable raw mode (better-sqlite3 behavior)
|
||||
this.isRawMode = toggleState !== undefined ? toggleState : true;
|
||||
return this;
|
||||
}
|
||||
|
||||
pluck(toggleState?: boolean): this {
|
||||
// In pluck mode, only the first column of each row is returned
|
||||
// If toggleState is undefined, enable pluck mode (better-sqlite3 behavior)
|
||||
this.isPluckMode = toggleState !== undefined ? toggleState : true;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the prefix used for a parameter name in the SQL query.
|
||||
* SQLite supports @name, :name, and $name parameter styles.
|
||||
* Returns the prefix character, or ':' as default if not found.
|
||||
*/
|
||||
private detectParamPrefix(paramName: string): string {
|
||||
// Search for the parameter with each possible prefix
|
||||
for (const prefix of [':', '@', '$']) {
|
||||
// Use word boundary to avoid partial matches
|
||||
const pattern = new RegExp(`\\${prefix}${paramName}(?![a-zA-Z0-9_])`);
|
||||
if (pattern.test(this.sql)) {
|
||||
return prefix;
|
||||
}
|
||||
}
|
||||
// Default to ':' if not found (most common in Trilium)
|
||||
return ':';
|
||||
}
|
||||
|
||||
private bindParams(params: unknown[]): void {
|
||||
this.stmt.clearBindings();
|
||||
if (params.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle single object with named parameters
|
||||
if (params.length === 1 && typeof params[0] === "object" && params[0] !== null && !Array.isArray(params[0])) {
|
||||
const inputBindings = params[0] as { [paramName: string]: BindableValue };
|
||||
|
||||
// SQLite WASM expects parameter names to include the prefix (@ : or $)
|
||||
// We detect the prefix used in the SQL for each parameter
|
||||
const bindings: { [paramName: string]: BindableValue } = {};
|
||||
for (const [key, value] of Object.entries(inputBindings)) {
|
||||
// If the key already has a prefix, use it as-is
|
||||
if (key.startsWith('@') || key.startsWith(':') || key.startsWith('$')) {
|
||||
bindings[key] = value;
|
||||
} else {
|
||||
// Detect the prefix used in the SQL and apply it
|
||||
const prefix = this.detectParamPrefix(key);
|
||||
bindings[`${prefix}${key}`] = value;
|
||||
}
|
||||
}
|
||||
|
||||
this.stmt.bind(bindings);
|
||||
} else {
|
||||
// Handle positional parameters - flatten and cast to BindableValue[]
|
||||
const flatParams = params.flat() as BindableValue[];
|
||||
if (flatParams.length > 0) {
|
||||
this.stmt.bind(flatParams);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
finalize(): void {
|
||||
if (!this.isFinalized) {
|
||||
try {
|
||||
this.stmt.finalize();
|
||||
} catch (e) {
|
||||
console.warn("Error finalizing SQLite statement:", e);
|
||||
} finally {
|
||||
this.isFinalized = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SQLite database provider for browser environments using SQLite WASM.
|
||||
*
|
||||
* This provider wraps the official @sqlite.org/sqlite-wasm package to provide
|
||||
* a DatabaseProvider implementation compatible with trilium-core.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const provider = new BrowserSqlProvider();
|
||||
* await provider.initWasm(); // Initialize SQLite WASM module
|
||||
* provider.loadFromMemory(); // Open an in-memory database
|
||||
* // or
|
||||
* provider.loadFromBuffer(existingDbBuffer); // Load from existing data
|
||||
* ```
|
||||
*/
|
||||
export default class BrowserSqlProvider implements DatabaseProvider {
|
||||
private db?: Sqlite3Database;
|
||||
private sqlite3?: Sqlite3Module;
|
||||
private _inTransaction = false;
|
||||
private initPromise?: Promise<void>;
|
||||
private initError?: Error;
|
||||
private statementCache: Map<string, WasmStatement> = new Map();
|
||||
|
||||
// OPFS state tracking
|
||||
private opfsDbPath?: string;
|
||||
|
||||
/**
|
||||
* Get the SQLite WASM module version info.
|
||||
* Returns undefined if the module hasn't been initialized yet.
|
||||
*/
|
||||
get version(): { libVersion: string; sourceId: string } | undefined {
|
||||
return this.sqlite3?.version;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the SQLite WASM module.
|
||||
* This must be called before using any database operations.
|
||||
* Safe to call multiple times - subsequent calls return the same promise.
|
||||
*
|
||||
* @returns A promise that resolves when the module is initialized
|
||||
* @throws Error if initialization fails
|
||||
*/
|
||||
async initWasm(): Promise<void> {
|
||||
// Return existing promise if already initializing/initialized
|
||||
if (this.initPromise) {
|
||||
return this.initPromise;
|
||||
}
|
||||
|
||||
// Fail fast if we already tried and failed
|
||||
if (this.initError) {
|
||||
throw this.initError;
|
||||
}
|
||||
|
||||
this.initPromise = this.doInitWasm();
|
||||
return this.initPromise;
|
||||
}
|
||||
|
||||
private async doInitWasm(): Promise<void> {
|
||||
try {
|
||||
console.log("[BrowserSqlProvider] Initializing SQLite WASM...");
|
||||
const startTime = performance.now();
|
||||
|
||||
this.sqlite3 = await sqlite3InitModule({
|
||||
print: console.log,
|
||||
printErr: console.error,
|
||||
});
|
||||
|
||||
const initTime = performance.now() - startTime;
|
||||
console.log(
|
||||
`[BrowserSqlProvider] SQLite WASM initialized in ${initTime.toFixed(2)}ms:`,
|
||||
this.sqlite3.version.libVersion
|
||||
);
|
||||
} catch (e) {
|
||||
this.initError = e instanceof Error ? e : new Error(String(e));
|
||||
console.error("[BrowserSqlProvider] SQLite WASM initialization failed:", this.initError);
|
||||
throw this.initError;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the SQLite WASM module has been initialized.
|
||||
*/
|
||||
get isInitialized(): boolean {
|
||||
return this.sqlite3 !== undefined;
|
||||
}
|
||||
|
||||
// ==================== OPFS Support ====================
|
||||
|
||||
/**
|
||||
* Check if the OPFS VFS is available.
|
||||
* This requires:
|
||||
* - Running in a Worker context
|
||||
* - Browser support for OPFS APIs
|
||||
* - COOP/COEP headers sent by the server (for SharedArrayBuffer)
|
||||
*
|
||||
* @returns true if OPFS VFS is available for use
|
||||
*/
|
||||
isOpfsAvailable(): boolean {
|
||||
this.ensureSqlite3();
|
||||
// SQLite WASM automatically installs the OPFS VFS if the environment supports it
|
||||
// We can check for its presence via sqlite3_vfs_find or the OpfsDb class
|
||||
return this.sqlite3!.oo1.OpfsDb !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load or create a database stored in OPFS for persistent storage.
|
||||
* The database will persist across browser sessions.
|
||||
*
|
||||
* Requires COOP/COEP headers to be set by the server:
|
||||
* - Cross-Origin-Opener-Policy: same-origin
|
||||
* - Cross-Origin-Embedder-Policy: require-corp
|
||||
*
|
||||
* @param path - The path for the database file in OPFS (e.g., "/trilium.db")
|
||||
* Paths without a leading slash are treated as relative to OPFS root.
|
||||
* Leading directories are created automatically.
|
||||
* @param options - Additional options
|
||||
* @throws Error if OPFS VFS is not available
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const provider = new BrowserSqlProvider();
|
||||
* await provider.initWasm();
|
||||
* if (provider.isOpfsAvailable()) {
|
||||
* provider.loadFromOpfs("/my-database.db");
|
||||
* } else {
|
||||
* console.warn("OPFS not available, using in-memory database");
|
||||
* provider.loadFromMemory();
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
loadFromOpfs(path: string, options: { createIfNotExists?: boolean } = {}): void {
|
||||
this.ensureSqlite3();
|
||||
|
||||
if (!this.isOpfsAvailable()) {
|
||||
throw new Error(
|
||||
"OPFS VFS is not available. This requires:\n" +
|
||||
"1. Running in a Worker context\n" +
|
||||
"2. Browser support for OPFS (Chrome 102+, Firefox 111+, Safari 17+)\n" +
|
||||
"3. COOP/COEP headers from the server:\n" +
|
||||
" Cross-Origin-Opener-Policy: same-origin\n" +
|
||||
" Cross-Origin-Embedder-Policy: require-corp"
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`[BrowserSqlProvider] Loading database from OPFS: ${path}`);
|
||||
const startTime = performance.now();
|
||||
|
||||
try {
|
||||
// OpfsDb automatically creates directories in the path
|
||||
// Mode 'c' = create if not exists
|
||||
const mode = options.createIfNotExists !== false ? 'c' : '';
|
||||
this.db = new this.sqlite3!.oo1.OpfsDb(path, mode);
|
||||
this.opfsDbPath = path;
|
||||
|
||||
// Configure the database for OPFS
|
||||
// Note: WAL mode requires exclusive locking in OPFS environment
|
||||
this.db.exec("PRAGMA journal_mode = DELETE");
|
||||
this.db.exec("PRAGMA synchronous = NORMAL");
|
||||
|
||||
const loadTime = performance.now() - startTime;
|
||||
console.log(`[BrowserSqlProvider] OPFS database loaded in ${loadTime.toFixed(2)}ms`);
|
||||
} catch (e) {
|
||||
const error = e instanceof Error ? e : new Error(String(e));
|
||||
console.error(`[BrowserSqlProvider] Failed to load OPFS database: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the currently open database is stored in OPFS.
|
||||
*/
|
||||
get isUsingOpfs(): boolean {
|
||||
return this.opfsDbPath !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the OPFS path of the currently open database.
|
||||
* Returns undefined if not using OPFS.
|
||||
*/
|
||||
get currentOpfsPath(): string | undefined {
|
||||
return this.opfsDbPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the database has been initialized with a schema.
|
||||
* This is a simple sanity check that looks for the existence of core tables.
|
||||
*
|
||||
* @returns true if the database appears to be initialized
|
||||
*/
|
||||
isDbInitialized(): boolean {
|
||||
this.ensureDb();
|
||||
|
||||
// Check if the 'notes' table exists (a core table that must exist in an initialized DB)
|
||||
const tableExists = this.db!.selectValue(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'notes'"
|
||||
);
|
||||
|
||||
return tableExists !== undefined;
|
||||
}
|
||||
|
||||
// ==================== End OPFS Support ====================
|
||||
|
||||
loadFromFile(_path: string, _isReadOnly: boolean): void {
|
||||
// Browser environment doesn't have direct file system access.
|
||||
// Use OPFS for persistent storage.
|
||||
throw new Error(
|
||||
"loadFromFile is not supported in browser environment. " +
|
||||
"Use loadFromMemory() for temporary databases, loadFromBuffer() to load from data, " +
|
||||
"or loadFromOpfs() for persistent storage."
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an empty in-memory database.
|
||||
* Data will be lost when the page is closed.
|
||||
*
|
||||
* For persistent storage, use loadFromOpfs() instead.
|
||||
* To load demo data, call initializeDemoDatabase() after this.
|
||||
*/
|
||||
loadFromMemory(): void {
|
||||
this.ensureSqlite3();
|
||||
console.log("[BrowserSqlProvider] Creating in-memory database...");
|
||||
const startTime = performance.now();
|
||||
|
||||
this.db = new this.sqlite3!.oo1.DB(":memory:", "c");
|
||||
this.opfsDbPath = undefined; // Not using OPFS
|
||||
this.db.exec("PRAGMA journal_mode = WAL");
|
||||
|
||||
const loadTime = performance.now() - startTime;
|
||||
console.log(`[BrowserSqlProvider] In-memory database created in ${loadTime.toFixed(2)}ms`);
|
||||
}
|
||||
|
||||
loadFromBuffer(buffer: Uint8Array): void {
|
||||
this.ensureSqlite3();
|
||||
// SQLite WASM can deserialize a database from a byte array
|
||||
const p = this.sqlite3!.wasm.allocFromTypedArray(buffer);
|
||||
try {
|
||||
this.db = new this.sqlite3!.oo1.DB({ filename: ":memory:", flags: "c" });
|
||||
this.opfsDbPath = undefined; // Not using OPFS
|
||||
|
||||
const rc = this.sqlite3!.capi.sqlite3_deserialize(
|
||||
this.db.pointer!,
|
||||
"main",
|
||||
p,
|
||||
buffer.byteLength,
|
||||
buffer.byteLength,
|
||||
this.sqlite3!.capi.SQLITE_DESERIALIZE_FREEONCLOSE |
|
||||
this.sqlite3!.capi.SQLITE_DESERIALIZE_RESIZEABLE
|
||||
);
|
||||
if (rc !== 0) {
|
||||
throw new Error(`Failed to deserialize database: ${rc}`);
|
||||
}
|
||||
} catch (e) {
|
||||
this.sqlite3!.wasm.dealloc(p);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
backup(_destinationFile: string): void {
|
||||
// In browser, we can serialize the database to a byte array
|
||||
// For actual file backup, we'd need to use File System Access API or download
|
||||
throw new Error(
|
||||
"backup to file is not supported in browser environment. " +
|
||||
"Use serialize() to get the database as a Uint8Array instead."
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize the database to a byte array.
|
||||
* This can be used to save the database to IndexedDB, download it, etc.
|
||||
*/
|
||||
serialize(): Uint8Array {
|
||||
this.ensureDb();
|
||||
// Use the convenience wrapper which handles all the memory management
|
||||
return this.sqlite3!.capi.sqlite3_js_db_export(this.db!);
|
||||
}
|
||||
|
||||
prepare(query: string): Statement {
|
||||
this.ensureDb();
|
||||
|
||||
// Check if we already have this statement cached
|
||||
if (this.statementCache.has(query)) {
|
||||
return this.statementCache.get(query)!;
|
||||
}
|
||||
|
||||
// Create new statement and cache it
|
||||
const stmt = this.db!.prepare(query);
|
||||
const wasmStatement = new WasmStatement(stmt, this.db!, this.sqlite3!, query);
|
||||
this.statementCache.set(query, wasmStatement);
|
||||
return wasmStatement;
|
||||
}
|
||||
|
||||
transaction<T>(func: (statement: Statement) => T): Transaction {
|
||||
this.ensureDb();
|
||||
|
||||
const self = this;
|
||||
let savepointCounter = 0;
|
||||
|
||||
// Helper function to execute within a transaction
|
||||
const executeTransaction = (beginStatement: string, ...args: unknown[]): T => {
|
||||
// If we're already in a transaction, use SAVEPOINTs for nesting
|
||||
// This mimics better-sqlite3's behavior
|
||||
if (self._inTransaction) {
|
||||
const savepointName = `sp_${++savepointCounter}_${Date.now()}`;
|
||||
self.db!.exec(`SAVEPOINT ${savepointName}`);
|
||||
try {
|
||||
const result = func.apply(null, args as [Statement]);
|
||||
self.db!.exec(`RELEASE SAVEPOINT ${savepointName}`);
|
||||
return result;
|
||||
} catch (e) {
|
||||
self.db!.exec(`ROLLBACK TO SAVEPOINT ${savepointName}`);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// Not in a transaction, start a new one
|
||||
self._inTransaction = true;
|
||||
self.db!.exec(beginStatement);
|
||||
try {
|
||||
const result = func.apply(null, args as [Statement]);
|
||||
self.db!.exec("COMMIT");
|
||||
return result;
|
||||
} catch (e) {
|
||||
self.db!.exec("ROLLBACK");
|
||||
throw e;
|
||||
} finally {
|
||||
self._inTransaction = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Create the transaction function that acts like better-sqlite3's Transaction interface
|
||||
// In better-sqlite3, the transaction function is callable and has .deferred(), .immediate(), etc.
|
||||
const transactionWrapper = Object.assign(
|
||||
// Default call executes with BEGIN (same as immediate)
|
||||
(...args: unknown[]): T => executeTransaction("BEGIN", ...args),
|
||||
{
|
||||
// Deferred transaction - locks acquired on first data access
|
||||
deferred: (...args: unknown[]): T => executeTransaction("BEGIN DEFERRED", ...args),
|
||||
// Immediate transaction - acquires write lock immediately
|
||||
immediate: (...args: unknown[]): T => executeTransaction("BEGIN IMMEDIATE", ...args),
|
||||
// Exclusive transaction - exclusive lock
|
||||
exclusive: (...args: unknown[]): T => executeTransaction("BEGIN EXCLUSIVE", ...args),
|
||||
// Default is same as calling directly
|
||||
default: (...args: unknown[]): T => executeTransaction("BEGIN", ...args)
|
||||
}
|
||||
);
|
||||
|
||||
return transactionWrapper as unknown as Transaction;
|
||||
}
|
||||
|
||||
get inTransaction(): boolean {
|
||||
return this._inTransaction;
|
||||
}
|
||||
|
||||
exec(query: string): void {
|
||||
this.ensureDb();
|
||||
this.db!.exec(query);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
// Clean up all cached statements first
|
||||
for (const statement of this.statementCache.values()) {
|
||||
try {
|
||||
statement.finalize();
|
||||
} catch (e) {
|
||||
// Ignore errors during cleanup
|
||||
console.warn("Error finalizing statement during cleanup:", e);
|
||||
}
|
||||
}
|
||||
this.statementCache.clear();
|
||||
|
||||
if (this.db) {
|
||||
this.db.close();
|
||||
this.db = undefined;
|
||||
}
|
||||
|
||||
// Reset OPFS state
|
||||
this.opfsDbPath = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the number of rows changed by the last INSERT, UPDATE, or DELETE statement.
|
||||
*/
|
||||
changes(): number {
|
||||
this.ensureDb();
|
||||
return this.db!.changes();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the database is currently open.
|
||||
*/
|
||||
isOpen(): boolean {
|
||||
return this.db !== undefined && this.db.isOpen();
|
||||
}
|
||||
|
||||
private ensureSqlite3(): void {
|
||||
if (!this.sqlite3) {
|
||||
throw new Error(
|
||||
"SQLite WASM module not initialized. Call initialize() first with the sqlite3 module."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private ensureDb(): void {
|
||||
this.ensureSqlite3();
|
||||
if (!this.db) {
|
||||
throw new Error("Database not opened. Call loadFromMemory(), loadFromBuffer(), or loadFromOpfs() first.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { LOCALE_IDS } from "@triliumnext/commons";
|
||||
import type i18next from "i18next";
|
||||
import I18NextHttpBackend from "i18next-http-backend";
|
||||
|
||||
export default async function translationProvider(i18nextInstance: typeof i18next, locale: LOCALE_IDS) {
|
||||
await i18nextInstance.use(I18NextHttpBackend).init({
|
||||
lng: locale,
|
||||
fallbackLng: "en",
|
||||
ns: "server",
|
||||
backend: {
|
||||
loadPath: `${import.meta.resolve("../server-assets/translations")}/{{lng}}/{{ns}}.json`
|
||||
},
|
||||
returnEmptyString: false,
|
||||
debug: true
|
||||
});
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
import LocalServerWorker from "./local-server-worker?worker";
|
||||
let localWorker: Worker | null = null;
|
||||
const pending = new Map();
|
||||
|
||||
export function startLocalServerWorker() {
|
||||
if (localWorker) return localWorker;
|
||||
localWorker = new LocalServerWorker();
|
||||
|
||||
// Handle worker errors during initialization
|
||||
localWorker.onerror = (event) => {
|
||||
console.error("[LocalBridge] Worker error:", event);
|
||||
// Reject all pending requests
|
||||
for (const [, resolver] of pending) {
|
||||
resolver.reject(new Error(`Worker error: ${event.message}`));
|
||||
}
|
||||
pending.clear();
|
||||
};
|
||||
|
||||
localWorker.onmessage = (event) => {
|
||||
const msg = event.data;
|
||||
|
||||
// Handle worker error reports
|
||||
if (msg?.type === "WORKER_ERROR") {
|
||||
console.error("[LocalBridge] Worker reported error:", msg.error);
|
||||
// Reject all pending requests with the error
|
||||
for (const [, resolver] of pending) {
|
||||
resolver.reject(new Error(msg.error?.message || "Unknown worker error"));
|
||||
}
|
||||
pending.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle WebSocket-like messages from the worker (for frontend updates)
|
||||
if (msg?.type === "WS_MESSAGE" && msg.message) {
|
||||
// Dispatch a custom event that ws.ts listens to in standalone mode
|
||||
window.dispatchEvent(new CustomEvent("trilium:ws-message", {
|
||||
detail: msg.message
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!msg || msg.type !== "LOCAL_RESPONSE") return;
|
||||
|
||||
const { id, response, error } = msg;
|
||||
const resolver = pending.get(id);
|
||||
if (!resolver) return;
|
||||
pending.delete(id);
|
||||
|
||||
if (error) resolver.reject(new Error(error));
|
||||
else resolver.resolve(response);
|
||||
};
|
||||
|
||||
return localWorker;
|
||||
}
|
||||
|
||||
export function attachServiceWorkerBridge() {
|
||||
navigator.serviceWorker.addEventListener("message", async (event) => {
|
||||
const msg = event.data;
|
||||
if (!msg || msg.type !== "LOCAL_FETCH") return;
|
||||
|
||||
const port = event.ports && event.ports[0];
|
||||
if (!port) return;
|
||||
|
||||
try {
|
||||
startLocalServerWorker();
|
||||
|
||||
const id = msg.id;
|
||||
const req = msg.request;
|
||||
|
||||
const response = await new Promise<{ body?: ArrayBuffer }>((resolve, reject) => {
|
||||
pending.set(id, { resolve, reject });
|
||||
// Transfer body to worker for efficiency (if present)
|
||||
localWorker!.postMessage({
|
||||
type: "LOCAL_REQUEST",
|
||||
id,
|
||||
request: req
|
||||
}, req.body ? [req.body] : []);
|
||||
});
|
||||
|
||||
port.postMessage({
|
||||
type: "LOCAL_FETCH_RESPONSE",
|
||||
id,
|
||||
response
|
||||
}, response.body ? [response.body] : []);
|
||||
} catch (e: unknown) {
|
||||
const errorMessage = e instanceof Error ? e.message : String(e);
|
||||
port.postMessage({
|
||||
type: "LOCAL_FETCH_RESPONSE",
|
||||
id: msg.id,
|
||||
response: {
|
||||
status: 500,
|
||||
headers: { "content-type": "text/plain; charset=utf-8" },
|
||||
body: new TextEncoder().encode(errorMessage).buffer
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,258 +0,0 @@
|
||||
// =============================================================================
|
||||
// ERROR HANDLERS FIRST - No static imports above this!
|
||||
// ES modules hoist static imports, so they execute BEFORE any code runs.
|
||||
// We use dynamic imports below to ensure error handlers are registered first.
|
||||
// =============================================================================
|
||||
|
||||
self.onerror = (message, source, lineno, colno, error) => {
|
||||
const errorMsg = `[Worker] Uncaught error: ${message}\n at ${source}:${lineno}:${colno}`;
|
||||
console.error(errorMsg, error);
|
||||
try {
|
||||
self.postMessage({
|
||||
type: "WORKER_ERROR",
|
||||
error: {
|
||||
message: String(message),
|
||||
source,
|
||||
lineno,
|
||||
colno,
|
||||
stack: error?.stack || new Error().stack
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("[Worker] Failed to report error:", e);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
self.onunhandledrejection = (event) => {
|
||||
const reason = event.reason;
|
||||
const errorMsg = `[Worker] Unhandled rejection: ${reason?.message || reason}`;
|
||||
console.error(errorMsg, reason);
|
||||
try {
|
||||
self.postMessage({
|
||||
type: "WORKER_ERROR",
|
||||
error: {
|
||||
message: String(reason?.message || reason),
|
||||
stack: reason?.stack || new Error().stack
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("[Worker] Failed to report rejection:", e);
|
||||
}
|
||||
};
|
||||
|
||||
console.log("[Worker] Error handlers installed, loading modules...");
|
||||
|
||||
// =============================================================================
|
||||
// TYPE-ONLY IMPORTS (erased at runtime, safe as static imports)
|
||||
// =============================================================================
|
||||
import type { BrowserRouter } from './lightweight/browser_router';
|
||||
|
||||
// =============================================================================
|
||||
// MODULE STATE (populated by dynamic imports)
|
||||
// =============================================================================
|
||||
let BrowserSqlProvider: typeof import('./lightweight/sql_provider').default;
|
||||
let WorkerMessagingProvider: typeof import('./lightweight/messaging_provider').default;
|
||||
let BrowserExecutionContext: typeof import('./lightweight/cls_provider').default;
|
||||
let BrowserCryptoProvider: typeof import('./lightweight/crypto_provider').default;
|
||||
let FetchRequestProvider: typeof import('./lightweight/request_provider').default;
|
||||
let translationProvider: typeof import('./lightweight/translation_provider').default;
|
||||
let createConfiguredRouter: typeof import('./lightweight/browser_routes').createConfiguredRouter;
|
||||
|
||||
// Instance state
|
||||
let sqlProvider: InstanceType<typeof BrowserSqlProvider> | null = null;
|
||||
let messagingProvider: InstanceType<typeof WorkerMessagingProvider> | null = null;
|
||||
|
||||
// Core module, router, and initialization state
|
||||
let coreModule: typeof import("@triliumnext/core") | null = null;
|
||||
let router: BrowserRouter | null = null;
|
||||
let initPromise: Promise<void> | null = null;
|
||||
let initError: Error | null = null;
|
||||
|
||||
/**
|
||||
* Load all required modules using dynamic imports.
|
||||
* This allows errors to be caught by our error handlers.
|
||||
*/
|
||||
async function loadModules(): Promise<void> {
|
||||
console.log("[Worker] Loading lightweight modules...");
|
||||
const [
|
||||
sqlModule,
|
||||
messagingModule,
|
||||
clsModule,
|
||||
cryptoModule,
|
||||
requestModule,
|
||||
translationModule,
|
||||
routesModule
|
||||
] = await Promise.all([
|
||||
import('./lightweight/sql_provider.js'),
|
||||
import('./lightweight/messaging_provider.js'),
|
||||
import('./lightweight/cls_provider.js'),
|
||||
import('./lightweight/crypto_provider.js'),
|
||||
import('./lightweight/request_provider.js'),
|
||||
import('./lightweight/translation_provider.js'),
|
||||
import('./lightweight/browser_routes.js')
|
||||
]);
|
||||
|
||||
BrowserSqlProvider = sqlModule.default;
|
||||
WorkerMessagingProvider = messagingModule.default;
|
||||
BrowserExecutionContext = clsModule.default;
|
||||
BrowserCryptoProvider = cryptoModule.default;
|
||||
FetchRequestProvider = requestModule.default;
|
||||
translationProvider = translationModule.default;
|
||||
createConfiguredRouter = routesModule.createConfiguredRouter;
|
||||
|
||||
// Create instances
|
||||
sqlProvider = new BrowserSqlProvider();
|
||||
messagingProvider = new WorkerMessagingProvider();
|
||||
|
||||
console.log("[Worker] Lightweight modules loaded successfully");
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize SQLite WASM and load the core module.
|
||||
* This happens once at worker startup.
|
||||
*/
|
||||
async function initialize(): Promise<void> {
|
||||
if (initPromise) {
|
||||
return initPromise; // Already initializing
|
||||
}
|
||||
if (initError) {
|
||||
throw initError; // Failed before, don't retry
|
||||
}
|
||||
|
||||
initPromise = (async () => {
|
||||
try {
|
||||
// First, load all modules dynamically
|
||||
await loadModules();
|
||||
|
||||
console.log("[Worker] Initializing SQLite WASM...");
|
||||
await sqlProvider!.initWasm();
|
||||
|
||||
// Try to use OPFS for persistent storage
|
||||
if (sqlProvider!.isOpfsAvailable()) {
|
||||
console.log("[Worker] OPFS available, loading persistent database...");
|
||||
sqlProvider!.loadFromOpfs("/trilium.db");
|
||||
} else {
|
||||
// Fall back to in-memory database (non-persistent)
|
||||
console.warn("[Worker] OPFS not available, using in-memory database (data will not persist)");
|
||||
console.warn("[Worker] To enable persistence, ensure COOP/COEP headers are set by the server");
|
||||
sqlProvider!.loadFromMemory();
|
||||
}
|
||||
|
||||
console.log("[Worker] Database loaded");
|
||||
|
||||
console.log("[Worker] Loading @triliumnext/core...");
|
||||
coreModule = await import("@triliumnext/core");
|
||||
await coreModule.initializeCore({
|
||||
executionContext: new BrowserExecutionContext(),
|
||||
crypto: new BrowserCryptoProvider(),
|
||||
messaging: messagingProvider!,
|
||||
request: new FetchRequestProvider(),
|
||||
translations: translationProvider,
|
||||
dbConfig: {
|
||||
provider: sqlProvider!,
|
||||
isReadOnly: false,
|
||||
onTransactionCommit: () => {
|
||||
coreModule?.ws.sendTransactionEntityChangesToAllClients();
|
||||
},
|
||||
onTransactionRollback: () => {
|
||||
// No-op for now
|
||||
}
|
||||
}
|
||||
});
|
||||
coreModule.ws.init();
|
||||
|
||||
console.log("[Worker] Supported routes", Object.keys(coreModule.routes));
|
||||
|
||||
// Create and configure the router
|
||||
router = createConfiguredRouter();
|
||||
console.log("[Worker] Router configured");
|
||||
|
||||
// initializeDb runs initDbConnection inside an execution context,
|
||||
// which resolves dbReady — required before beccaLoaded can settle.
|
||||
coreModule.sql_init.initializeDb();
|
||||
|
||||
if (coreModule.sql_init.isDbInitialized()) {
|
||||
console.log("[Worker] Database already initialized, loading becca...");
|
||||
await coreModule.becca_loader.beccaLoaded;
|
||||
} else {
|
||||
console.log("[Worker] Database not initialized, skipping becca load (will be loaded during DB initialization)");
|
||||
}
|
||||
|
||||
console.log("[Worker] Initialization complete");
|
||||
} catch (error) {
|
||||
initError = error instanceof Error ? error : new Error(String(error));
|
||||
console.error("[Worker] Initialization failed:", initError);
|
||||
throw initError;
|
||||
}
|
||||
})();
|
||||
|
||||
return initPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the worker is initialized before processing requests.
|
||||
* Returns the router if initialization was successful.
|
||||
*/
|
||||
async function ensureInitialized() {
|
||||
await initialize();
|
||||
if (!router) {
|
||||
throw new Error("Router not initialized");
|
||||
}
|
||||
return router;
|
||||
}
|
||||
|
||||
interface LocalRequest {
|
||||
method: string;
|
||||
url: string;
|
||||
body?: unknown;
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
// Main dispatch
|
||||
async function dispatch(request: LocalRequest) {
|
||||
// Ensure initialization is complete and get the router
|
||||
const appRouter = await ensureInitialized();
|
||||
|
||||
// Dispatch to the router
|
||||
return appRouter.dispatch(request.method, request.url, request.body, request.headers);
|
||||
}
|
||||
|
||||
// Start initialization immediately when the worker loads
|
||||
console.log("[Worker] Starting initialization...");
|
||||
initialize().catch(err => {
|
||||
console.error("[Worker] Initialization failed:", err);
|
||||
// Post error to main thread
|
||||
self.postMessage({
|
||||
type: "WORKER_ERROR",
|
||||
error: {
|
||||
message: String(err?.message || err),
|
||||
stack: err?.stack
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
self.onmessage = async (event) => {
|
||||
const msg = event.data;
|
||||
if (!msg || msg.type !== "LOCAL_REQUEST") return;
|
||||
|
||||
const { id, request } = msg;
|
||||
|
||||
try {
|
||||
const response = await dispatch(request);
|
||||
|
||||
// Transfer body back (if any) - use options object for proper typing
|
||||
(self as unknown as Worker).postMessage({
|
||||
type: "LOCAL_RESPONSE",
|
||||
id,
|
||||
response
|
||||
}, { transfer: response.body ? [response.body] : [] });
|
||||
} catch (e) {
|
||||
console.error("[Worker] Dispatch error:", e);
|
||||
(self as unknown as Worker).postMessage({
|
||||
type: "LOCAL_RESPONSE",
|
||||
id,
|
||||
error: String((e as Error)?.message || e)
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1,84 +0,0 @@
|
||||
import { attachServiceWorkerBridge, startLocalServerWorker } from "./local-bridge.js";
|
||||
|
||||
async function waitForServiceWorkerControl(): Promise<void> {
|
||||
if (!("serviceWorker" in navigator)) {
|
||||
throw new Error("Service Worker not supported in this browser");
|
||||
}
|
||||
|
||||
// If already controlling, we're good
|
||||
if (navigator.serviceWorker.controller) {
|
||||
console.log("[Bootstrap] Service worker already controlling");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("[Bootstrap] Waiting for service worker to take control...");
|
||||
|
||||
// Register service worker
|
||||
await navigator.serviceWorker.register("./sw.js", { scope: "/" });
|
||||
|
||||
// Wait for it to be ready (installed + activated)
|
||||
await navigator.serviceWorker.ready;
|
||||
|
||||
// Check if we're now controlling
|
||||
if (navigator.serviceWorker.controller) {
|
||||
console.log("[Bootstrap] Service worker now controlling");
|
||||
return;
|
||||
}
|
||||
|
||||
// If not controlling yet, we need to reload the page for SW to take control
|
||||
// This is standard PWA behavior on first install
|
||||
console.log("[Bootstrap] Service worker installed but not controlling yet - reloading page");
|
||||
|
||||
// Wait a tiny bit for SW to fully activate
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Reload to let SW take control
|
||||
window.location.reload();
|
||||
|
||||
// Throw to stop execution (page will reload)
|
||||
throw new Error("Reloading for service worker activation");
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
/* fixes https://github.com/webpack/webpack/issues/10035 */
|
||||
window.global = globalThis;
|
||||
|
||||
try {
|
||||
// 1) Start local worker ASAP (so /bootstrap is fast)
|
||||
startLocalServerWorker();
|
||||
|
||||
// 2) Bridge SW -> local worker
|
||||
attachServiceWorkerBridge();
|
||||
|
||||
// 3) Wait for service worker to control the page (may reload on first install)
|
||||
await waitForServiceWorkerControl();
|
||||
|
||||
await loadScripts();
|
||||
} catch (err) {
|
||||
// If error is from reload, it will stop here (page reloads)
|
||||
// Otherwise, show error to user
|
||||
if (err instanceof Error && err.message.includes("Reloading")) {
|
||||
// Page is reloading, do nothing
|
||||
return;
|
||||
}
|
||||
|
||||
console.error("[Bootstrap] Fatal error:", err);
|
||||
document.body.innerHTML = `
|
||||
<div style="padding: 40px; max-width: 600px; margin: 0 auto; font-family: system-ui, sans-serif;">
|
||||
<h1 style="color: #d32f2f;">Failed to Initialize</h1>
|
||||
<p>The application failed to start. Please check the browser console for details.</p>
|
||||
<pre style="background: #f5f5f5; padding: 16px; border-radius: 4px; overflow: auto;">${err instanceof Error ? err.message : String(err)}</pre>
|
||||
<button onclick="location.reload()" style="padding: 12px 24px; background: #1976d2; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 16px;">
|
||||
Reload Page
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
document.body.style.display = "block";
|
||||
}
|
||||
}
|
||||
|
||||
async function loadScripts() {
|
||||
await import("../../client/src/index.js");
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
@@ -1,185 +0,0 @@
|
||||
// public/sw.js
|
||||
const VERSION = "localserver-v1.4";
|
||||
const STATIC_CACHE = `static-${VERSION}`;
|
||||
|
||||
// Check if running in dev mode (passed via URL parameter)
|
||||
const isDev = true;
|
||||
|
||||
if (isDev) {
|
||||
console.log('[Service Worker] Running in DEV mode - caching disabled');
|
||||
}
|
||||
|
||||
// Adjust these to your routes:
|
||||
const LOCAL_FIRST_PREFIXES = [
|
||||
"/bootstrap",
|
||||
"/api/",
|
||||
"/sync/",
|
||||
"/search/"
|
||||
];
|
||||
|
||||
// Optional: basic precache list (keep small; you can expand later)
|
||||
const PRECACHE_URLS = [
|
||||
// "/",
|
||||
// "/index.html",
|
||||
// "/manifest.webmanifest",
|
||||
// "/favicon.ico",
|
||||
];
|
||||
|
||||
self.addEventListener("install", (event) => {
|
||||
event.waitUntil((async () => {
|
||||
// Skip precaching in dev mode
|
||||
if (!isDev) {
|
||||
const cache = await caches.open(STATIC_CACHE);
|
||||
await cache.addAll(PRECACHE_URLS);
|
||||
}
|
||||
self.skipWaiting();
|
||||
})());
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil((async () => {
|
||||
// Cleanup old caches
|
||||
const keys = await caches.keys();
|
||||
await Promise.all(keys.map((k) => (k === STATIC_CACHE ? Promise.resolve() : caches.delete(k))));
|
||||
await self.clients.claim();
|
||||
})());
|
||||
});
|
||||
|
||||
function isLocalFirst(url) {
|
||||
return LOCAL_FIRST_PREFIXES.some((p) => url.pathname.startsWith(p));
|
||||
}
|
||||
|
||||
async function cacheFirst(request) {
|
||||
// In dev mode, always bypass cache
|
||||
if (isDev) {
|
||||
return fetch(request);
|
||||
}
|
||||
|
||||
const cache = await caches.open(STATIC_CACHE);
|
||||
const cached = await cache.match(request);
|
||||
if (cached) return cached;
|
||||
|
||||
const fresh = await fetch(request);
|
||||
// Cache only successful GETs
|
||||
if (request.method === "GET" && fresh.ok) cache.put(request, fresh.clone());
|
||||
return fresh;
|
||||
}
|
||||
|
||||
async function networkFirst(request) {
|
||||
// In dev mode, always bypass cache
|
||||
if (isDev) {
|
||||
return fetch(request);
|
||||
}
|
||||
|
||||
const cache = await caches.open(STATIC_CACHE);
|
||||
try {
|
||||
const fresh = await fetch(request);
|
||||
// Cache only successful GETs
|
||||
if (request.method === "GET" && fresh.ok) cache.put(request, fresh.clone());
|
||||
return fresh;
|
||||
} catch (error) {
|
||||
// Fallback to cache if network fails
|
||||
const cached = await cache.match(request);
|
||||
if (cached) return cached;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function forwardToClientLocalServer(request, clientId) {
|
||||
// Find a client to handle the request (prefer the initiating client if available)
|
||||
let client = clientId ? await self.clients.get(clientId) : null;
|
||||
|
||||
if (!client) {
|
||||
const all = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
|
||||
client = all[0] || null;
|
||||
}
|
||||
|
||||
// If no page is available, fall back to network
|
||||
if (!client) return fetch(request);
|
||||
|
||||
const reqUrl = request.url;
|
||||
const headersObj = {};
|
||||
for (const [k, v] of request.headers.entries()) headersObj[k] = v;
|
||||
|
||||
const body = (request.method === "GET" || request.method === "HEAD")
|
||||
? null
|
||||
: await request.arrayBuffer();
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const channel = new MessageChannel();
|
||||
|
||||
const responsePromise = new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
reject(new Error("Local server timeout"));
|
||||
}, 30_000);
|
||||
|
||||
channel.port1.onmessage = (event) => {
|
||||
clearTimeout(timeout);
|
||||
resolve(event.data);
|
||||
};
|
||||
channel.port1.onmessageerror = () => {
|
||||
clearTimeout(timeout);
|
||||
reject(new Error("Local server message error"));
|
||||
};
|
||||
});
|
||||
|
||||
// Send to the client with a reply port
|
||||
client.postMessage({
|
||||
type: "LOCAL_FETCH",
|
||||
id,
|
||||
request: {
|
||||
url: reqUrl,
|
||||
method: request.method,
|
||||
headers: headersObj,
|
||||
body // ArrayBuffer or null
|
||||
}
|
||||
}, [channel.port2]);
|
||||
|
||||
const localResp = await responsePromise;
|
||||
|
||||
if (!localResp || localResp.type !== "LOCAL_FETCH_RESPONSE" || localResp.id !== id) {
|
||||
// Protocol mismatch; fall back
|
||||
return fetch(request);
|
||||
}
|
||||
|
||||
// localResp.response: { status, headers, body }
|
||||
const { status, headers, body: respBody } = localResp.response;
|
||||
|
||||
const respHeaders = new Headers();
|
||||
if (headers) {
|
||||
for (const [k, v] of Object.entries(headers)) respHeaders.set(k, String(v));
|
||||
}
|
||||
|
||||
return new Response(respBody ? respBody : null, {
|
||||
status: status || 200,
|
||||
headers: respHeaders
|
||||
});
|
||||
}
|
||||
|
||||
self.addEventListener("fetch", (event) => {
|
||||
const url = new URL(event.request.url);
|
||||
|
||||
// Only handle same-origin
|
||||
if (url.origin !== self.location.origin) return;
|
||||
|
||||
// HTML files: network-first to ensure updates are reflected immediately
|
||||
if (event.request.mode === "navigate" || url.pathname.endsWith(".html")) {
|
||||
event.respondWith(networkFirst(event.request));
|
||||
return;
|
||||
}
|
||||
|
||||
// Static assets: cache-first for performance
|
||||
if (event.request.method === "GET" && !isLocalFirst(url)) {
|
||||
event.respondWith(cacheFirst(event.request));
|
||||
return;
|
||||
}
|
||||
|
||||
// API-ish: local-first via bridge
|
||||
if (isLocalFirst(url)) {
|
||||
event.respondWith(forwardToClientLocalServer(event.request, event.clientId));
|
||||
return;
|
||||
}
|
||||
|
||||
// Default
|
||||
event.respondWith(fetch(event.request));
|
||||
});
|
||||
31
apps/client-standalone/src/vite-env.d.ts
vendored
31
apps/client-standalone/src/vite-env.d.ts
vendored
@@ -1,31 +0,0 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_APP_TITLE: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
|
||||
interface Window {
|
||||
glob: {
|
||||
assetPath: string;
|
||||
themeCssUrl?: string;
|
||||
themeUseNextAsBase?: string;
|
||||
iconPackCss: string;
|
||||
device: string;
|
||||
headingStyle: string;
|
||||
layoutOrientation: string;
|
||||
platform: string;
|
||||
isElectron: boolean;
|
||||
hasNativeTitleBar: boolean;
|
||||
hasBackgroundEffects: boolean;
|
||||
currentLocale: {
|
||||
id: string;
|
||||
rtl: boolean;
|
||||
};
|
||||
activeDialog: any;
|
||||
};
|
||||
global: typeof globalThis;
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"lib": [
|
||||
"ES2022",
|
||||
"dom",
|
||||
"dom.iterable"
|
||||
],
|
||||
"skipLibCheck": true,
|
||||
"types": [
|
||||
"vite/client"
|
||||
],
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "preact"
|
||||
},
|
||||
"include": [
|
||||
"src/**/*",
|
||||
"../client/src/**/*"
|
||||
],
|
||||
"exclude": [
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.test.ts",
|
||||
"../client/src/**/*.spec.ts",
|
||||
"../client/src/**/*.test.ts"
|
||||
]
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.spec.json" }
|
||||
]
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"lib": [
|
||||
"ES2022",
|
||||
"dom",
|
||||
"dom.iterable"
|
||||
],
|
||||
"types": [
|
||||
"vitest/globals",
|
||||
"happy-dom"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.test.ts"
|
||||
]
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
import prefresh from '@prefresh/vite';
|
||||
import { join } from 'path';
|
||||
import { defineConfig } from 'vite';
|
||||
import { viteStaticCopy } from 'vite-plugin-static-copy';
|
||||
|
||||
const clientAssets = ["assets", "stylesheets", "fonts", "translations"];
|
||||
|
||||
const isDev = process.env.NODE_ENV === "development";
|
||||
|
||||
// Watch client files and trigger reload in development
|
||||
const clientWatchPlugin = () => ({
|
||||
name: 'client-watch',
|
||||
configureServer(server: any) {
|
||||
if (isDev) {
|
||||
// Watch client source files (adjusted for new root)
|
||||
server.watcher.add('../../client/src/**/*');
|
||||
server.watcher.on('change', (file: string) => {
|
||||
if (file.includes('../../client/src/')) {
|
||||
server.ws.send({
|
||||
type: 'full-reload'
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Always copy SQLite WASM files so they're available to the module
|
||||
const sqliteWasmPlugin = viteStaticCopy({
|
||||
targets: [
|
||||
{
|
||||
src: "../../../node_modules/@sqlite.org/sqlite-wasm/sqlite-wasm/jswasm/sqlite3.wasm",
|
||||
dest: "assets"
|
||||
},
|
||||
{
|
||||
src: "../../../node_modules/@sqlite.org/sqlite-wasm/sqlite-wasm/jswasm/sqlite3-opfs-async-proxy.js",
|
||||
dest: "assets"
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
let plugins: any = [
|
||||
sqliteWasmPlugin, // Always include SQLite WASM files
|
||||
viteStaticCopy({
|
||||
targets: clientAssets.map((asset) => ({
|
||||
src: `../../client/src/${asset}/*`,
|
||||
dest: asset
|
||||
})),
|
||||
// Enable watching in development
|
||||
...(isDev && {
|
||||
watch: {
|
||||
reloadPageOnChange: true
|
||||
}
|
||||
})
|
||||
}),
|
||||
viteStaticCopy({
|
||||
targets: [
|
||||
{
|
||||
src: "../../server/src/assets/*",
|
||||
dest: "server-assets"
|
||||
}
|
||||
]
|
||||
}),
|
||||
// Watch client files for changes in development
|
||||
...(isDev ? [
|
||||
prefresh(),
|
||||
clientWatchPlugin()
|
||||
] : [])
|
||||
];
|
||||
|
||||
if (!isDev) {
|
||||
plugins = [
|
||||
...plugins,
|
||||
viteStaticCopy({
|
||||
structured: true,
|
||||
targets: [
|
||||
{
|
||||
src: "../../../node_modules/@excalidraw/excalidraw/dist/prod/fonts/*",
|
||||
dest: "",
|
||||
}
|
||||
]
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
export default defineConfig(() => ({
|
||||
root: join(__dirname, 'src'), // Set src as root so index.html is served from /
|
||||
envDir: __dirname, // Load .env files from client-standalone directory, not src/
|
||||
cacheDir: '../../../node_modules/.vite/apps/client-standalone',
|
||||
base: "",
|
||||
plugins,
|
||||
esbuild: {
|
||||
jsx: 'automatic',
|
||||
jsxImportSource: 'preact',
|
||||
jsxDev: isDev
|
||||
},
|
||||
css: {
|
||||
transformer: 'lightningcss',
|
||||
devSourcemap: isDev
|
||||
},
|
||||
publicDir: join(__dirname, 'public'),
|
||||
resolve: {
|
||||
alias: [
|
||||
{
|
||||
find: "react",
|
||||
replacement: "preact/compat"
|
||||
},
|
||||
{
|
||||
find: "react-dom",
|
||||
replacement: "preact/compat"
|
||||
},
|
||||
{
|
||||
find: "@client",
|
||||
replacement: join(__dirname, "../client/src")
|
||||
}
|
||||
],
|
||||
dedupe: [
|
||||
"react",
|
||||
"react-dom",
|
||||
"preact",
|
||||
"preact/compat",
|
||||
"preact/hooks"
|
||||
]
|
||||
},
|
||||
server: {
|
||||
watch: {
|
||||
// Watch workspace packages
|
||||
ignored: ['!**/node_modules/@triliumnext/**'],
|
||||
// Also watch client assets for live reload
|
||||
usePolling: false,
|
||||
interval: 100,
|
||||
binaryInterval: 300
|
||||
},
|
||||
// Watch additional directories for changes
|
||||
fs: {
|
||||
allow: [
|
||||
// Allow access to workspace root
|
||||
'../../../',
|
||||
// Explicitly allow client directory
|
||||
'../../client/src/'
|
||||
]
|
||||
},
|
||||
headers: {
|
||||
// Required for SharedArrayBuffer which is needed by SQLite WASM OPFS VFS
|
||||
// See: https://sqlite.org/wasm/doc/trunk/persistence.md#coop-coep
|
||||
"Cross-Origin-Opener-Policy": "same-origin",
|
||||
"Cross-Origin-Embedder-Policy": "require-corp"
|
||||
}
|
||||
},
|
||||
optimizeDeps: {
|
||||
exclude: ['@sqlite.org/sqlite-wasm', '@triliumnext/core']
|
||||
},
|
||||
worker: {
|
||||
format: "es" as const
|
||||
},
|
||||
commonjsOptions: {
|
||||
transformMixedEsModules: true,
|
||||
},
|
||||
build: {
|
||||
target: "esnext",
|
||||
outDir: join(__dirname, 'dist'),
|
||||
emptyOutDir: true,
|
||||
rollupOptions: {
|
||||
input: {
|
||||
main: join(__dirname, 'src', 'index.html'),
|
||||
sw: join(__dirname, 'src', 'sw.ts'),
|
||||
'local-bridge': join(__dirname, 'src', 'local-bridge.ts'),
|
||||
},
|
||||
output: {
|
||||
entryFileNames: (chunkInfo) => {
|
||||
// Service worker and other workers should be at root level
|
||||
if (chunkInfo.name === 'sw') {
|
||||
return '[name].js';
|
||||
}
|
||||
return 'src/[name].js';
|
||||
},
|
||||
chunkFileNames: "src/[name].js",
|
||||
assetFileNames: "src/[name].[ext]"
|
||||
}
|
||||
}
|
||||
},
|
||||
test: {
|
||||
environment: "happy-dom"
|
||||
},
|
||||
define: {
|
||||
"process.env.IS_PREACT": JSON.stringify("true"),
|
||||
}
|
||||
}));
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@triliumnext/client",
|
||||
"version": "0.102.1",
|
||||
"version": "0.101.3",
|
||||
"description": "JQuery-based client for TriliumNext, used for both web and desktop (via Electron)",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-only",
|
||||
@@ -22,28 +22,19 @@
|
||||
"@fullcalendar/interaction": "6.1.20",
|
||||
"@fullcalendar/list": "6.1.20",
|
||||
"@fullcalendar/multimonth": "6.1.20",
|
||||
"@fullcalendar/rrule": "6.1.20",
|
||||
"@fullcalendar/timegrid": "6.1.20",
|
||||
"@maplibre/maplibre-gl-leaflet": "0.1.3",
|
||||
"@mermaid-js/layout-elk": "0.2.1",
|
||||
"@mermaid-js/layout-elk": "0.2.0",
|
||||
"@mind-elixir/node-menu": "5.0.1",
|
||||
"@popperjs/core": "2.11.8",
|
||||
"@preact/signals": "2.8.2",
|
||||
"@preact/signals": "2.6.2",
|
||||
"@triliumnext/ckeditor5": "workspace:*",
|
||||
"@triliumnext/codemirror": "workspace:*",
|
||||
"@triliumnext/commons": "workspace:*",
|
||||
"@triliumnext/highlightjs": "workspace:*",
|
||||
"@triliumnext/share-theme": "workspace:*",
|
||||
"@triliumnext/split.js": "workspace:*",
|
||||
"@univerjs/preset-sheets-conditional-formatting": "0.18.0",
|
||||
"@univerjs/preset-sheets-core": "0.18.0",
|
||||
"@univerjs/preset-sheets-data-validation": "0.18.0",
|
||||
"@univerjs/preset-sheets-filter": "0.18.0",
|
||||
"@univerjs/preset-sheets-find-replace": "0.18.0",
|
||||
"@univerjs/preset-sheets-note": "0.18.0",
|
||||
"@univerjs/preset-sheets-sort": "0.18.0",
|
||||
"@univerjs/presets": "0.18.0",
|
||||
"@zumer/snapdom": "2.5.0",
|
||||
"@zumer/snapdom": "2.0.2",
|
||||
"autocomplete.js": "0.38.1",
|
||||
"bootstrap": "5.3.8",
|
||||
"boxicons": "2.1.4",
|
||||
@@ -51,44 +42,45 @@
|
||||
"color": "5.0.3",
|
||||
"debounce": "3.0.0",
|
||||
"draggabilly": "3.0.0",
|
||||
"force-graph": "1.51.2",
|
||||
"globals": "17.4.0",
|
||||
"i18next": "25.10.3",
|
||||
"force-graph": "1.51.1",
|
||||
"globals": "17.3.0",
|
||||
"i18next": "25.8.0",
|
||||
"i18next-http-backend": "3.0.2",
|
||||
"jquery": "4.0.0",
|
||||
"jquery.fancytree": "2.38.5",
|
||||
"jsplumb": "2.15.6",
|
||||
"katex": "0.16.40",
|
||||
"katex": "0.16.28",
|
||||
"knockout": "3.5.1",
|
||||
"leaflet": "1.9.4",
|
||||
"leaflet-gpx": "2.2.0",
|
||||
"mark.js": "8.11.1",
|
||||
"marked": "17.0.5",
|
||||
"mermaid": "11.13.0",
|
||||
"mind-elixir": "5.9.3",
|
||||
"marked": "17.0.1",
|
||||
"mermaid": "11.12.2",
|
||||
"mind-elixir": "5.7.1",
|
||||
"normalize.css": "8.0.1",
|
||||
"panzoom": "9.4.3",
|
||||
"preact": "10.29.0",
|
||||
"react-i18next": "16.6.0",
|
||||
"react-window": "2.2.7",
|
||||
"reveal.js": "6.0.0",
|
||||
"rrule": "2.8.1",
|
||||
"preact": "10.28.3",
|
||||
"react-i18next": "16.5.4",
|
||||
"react-window": "2.2.6",
|
||||
"reveal.js": "5.2.1",
|
||||
"svg-pan-zoom": "3.6.2",
|
||||
"tabulator-tables": "6.4.0",
|
||||
"tabulator-tables": "6.3.1",
|
||||
"vanilla-js-wheel-zoom": "9.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ckeditor/ckeditor5-inspector": "5.0.0",
|
||||
"@prefresh/vite": "2.4.12",
|
||||
"@prefresh/vite": "2.4.11",
|
||||
"@types/bootstrap": "5.2.10",
|
||||
"@types/jquery": "4.0.0",
|
||||
"@types/jquery": "3.5.33",
|
||||
"@types/leaflet": "1.9.21",
|
||||
"@types/leaflet-gpx": "1.3.8",
|
||||
"@types/mark.js": "8.11.12",
|
||||
"@types/reveal.js": "5.2.2",
|
||||
"@types/tabulator-tables": "6.3.1",
|
||||
"copy-webpack-plugin": "14.0.0",
|
||||
"happy-dom": "20.8.4",
|
||||
"lightningcss": "1.32.0",
|
||||
"copy-webpack-plugin": "13.0.1",
|
||||
"happy-dom": "20.5.0",
|
||||
"lightningcss": "1.31.1",
|
||||
"script-loader": "0.7.2",
|
||||
"vite-plugin-static-copy": "3.3.0"
|
||||
"vite-plugin-static-copy": "3.2.0"
|
||||
}
|
||||
}
|
||||
@@ -13,12 +13,13 @@ import type LoadResults from "../services/load_results.js";
|
||||
import type { CreateNoteOpts } from "../services/note_create.js";
|
||||
import options from "../services/options.js";
|
||||
import toast from "../services/toast.js";
|
||||
import utils, { hasTouchBar } from "../services/utils.js";
|
||||
import utils, { dynamicRequire, hasTouchBar } from "../services/utils.js";
|
||||
import { ReactWrappedWidget } from "../widgets/basic_widget.js";
|
||||
import type RootContainer from "../widgets/containers/root_container.js";
|
||||
import { AddLinkOpts } from "../widgets/dialogs/add_link.jsx";
|
||||
import type { ConfirmWithMessageOptions, ConfirmWithTitleOptions } from "../widgets/dialogs/confirm.js";
|
||||
import type { ResolveOptions } from "../widgets/dialogs/delete_notes.js";
|
||||
import { ImportPreviewData } from "../widgets/dialogs/import_preview.jsx";
|
||||
import { IncludeNoteOpts } from "../widgets/dialogs/include_note.jsx";
|
||||
import type { InfoProps } from "../widgets/dialogs/info.jsx";
|
||||
import type { MarkdownImportOpts } from "../widgets/dialogs/markdown_import.jsx";
|
||||
@@ -101,6 +102,8 @@ export type CommandMappings = {
|
||||
showRevisions: CommandData & {
|
||||
noteId?: string | null;
|
||||
};
|
||||
showLlmChat: CommandData;
|
||||
createAiChat: CommandData;
|
||||
showOptions: CommandData & {
|
||||
section: string;
|
||||
};
|
||||
@@ -128,6 +131,7 @@ export type CommandMappings = {
|
||||
showConfirmDialog: ConfirmWithMessageOptions;
|
||||
showRecentChanges: CommandData & { ancestorNoteId: string };
|
||||
showImportDialog: CommandData & { noteId: string };
|
||||
showImportPreviewDialog: CommandData & ImportPreviewData;
|
||||
openNewNoteSplit: NoteCommandData;
|
||||
openInWindow: NoteCommandData;
|
||||
openInPopup: CommandData & { noteIdOrPath: string; };
|
||||
@@ -637,6 +641,10 @@ export class AppContext extends Component {
|
||||
this.child(rootWidget as Component);
|
||||
|
||||
this.triggerEvent("initialRenderComplete", {});
|
||||
if (utils.isElectron()) {
|
||||
const { ipcRenderer } = dynamicRequire('electron');
|
||||
ipcRenderer.send("initial-render-complete");
|
||||
}
|
||||
}
|
||||
|
||||
triggerEvent<K extends EventNames>(name: K, data: EventData<K>) {
|
||||
|
||||
@@ -381,10 +381,6 @@ class NoteContext extends Component implements EventListener<"entitiesReloaded">
|
||||
|
||||
// Collections must always display a note list, even if no children.
|
||||
if (note.type === "book") {
|
||||
if (note.isProtected && !protectedSessionHolder.isProtectedSessionAvailable()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const viewType = note.getLabelValue("viewType") ?? "grid";
|
||||
if (!["list", "grid"].includes(viewType)) {
|
||||
return true;
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import dateNoteService from "../services/date_notes.js";
|
||||
import froca from "../services/froca.js";
|
||||
import noteCreateService from "../services/note_create.js";
|
||||
import openService from "../services/open.js";
|
||||
import options from "../services/options.js";
|
||||
import protectedSessionService from "../services/protected_session.js";
|
||||
import toastService from "../services/toast.js";
|
||||
import treeService from "../services/tree.js";
|
||||
import utils, { openInReusableSplit } from "../services/utils.js";
|
||||
import appContext, { type CommandListenerData } from "./app_context.js";
|
||||
@@ -246,4 +248,34 @@ export default class RootCommandExecutor extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
async createAiChatCommand() {
|
||||
try {
|
||||
// Create a new AI Chat note at the root level
|
||||
const rootNoteId = "root";
|
||||
|
||||
const result = await noteCreateService.createNote(rootNoteId, {
|
||||
title: "New AI Chat",
|
||||
type: "aiChat",
|
||||
content: JSON.stringify({
|
||||
messages: [],
|
||||
title: "New AI Chat"
|
||||
})
|
||||
});
|
||||
|
||||
if (!result.note) {
|
||||
toastService.showError("Failed to create AI Chat note");
|
||||
return;
|
||||
}
|
||||
|
||||
await appContext.tabManager.openTabWithNoteWithHoisting(result.note.noteId, {
|
||||
activate: true
|
||||
});
|
||||
|
||||
toastService.showMessage("Created new AI Chat note");
|
||||
}
|
||||
catch (e) {
|
||||
console.error("Error creating AI Chat note:", e);
|
||||
toastService.showError(`Failed to create AI Chat note: ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ const RELATION = "relation";
|
||||
* end user. Those types should be used only for checking against, they are
|
||||
* not for direct use.
|
||||
*/
|
||||
export type NoteType = "file" | "image" | "search" | "noteMap" | "launcher" | "doc" | "contentWidget" | "text" | "relationMap" | "render" | "canvas" | "mermaid" | "book" | "webView" | "code" | "mindMap" | "spreadsheet";
|
||||
export type NoteType = "file" | "image" | "search" | "noteMap" | "launcher" | "doc" | "contentWidget" | "text" | "relationMap" | "render" | "canvas" | "mermaid" | "book" | "webView" | "code" | "mindMap" | "aiChat";
|
||||
|
||||
export interface NotePathRecord {
|
||||
isArchived: boolean;
|
||||
@@ -700,15 +700,6 @@ export default class FNote {
|
||||
return this.hasAttribute(LABEL, name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns `true` if the note has a label with the given name (same as {@link hasOwnedLabel}), or it has a label with the `disabled:` prefix (for example due to a safe import).
|
||||
* @param name the name of the label to look for.
|
||||
* @returns `true` if the label exists, or its version with the `disabled:` prefix.
|
||||
*/
|
||||
hasLabelOrDisabled(name: string) {
|
||||
return this.hasLabel(name) || this.hasLabel(`disabled:${name}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param name - label name
|
||||
* @returns true if label exists (including inherited) and does not have "false" value.
|
||||
|
||||
@@ -36,37 +36,10 @@ async function setupGlob() {
|
||||
window.global = globalThis; /* fixes https://github.com/webpack/webpack/issues/10035 */
|
||||
window.glob = {
|
||||
...json,
|
||||
activeDialog: null,
|
||||
device: json.device || getDevice()
|
||||
activeDialog: null
|
||||
};
|
||||
}
|
||||
|
||||
function getDevice() {
|
||||
// Respect user's manual override via URL.
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
if (urlParams.has("print")) {
|
||||
return "print";
|
||||
} else if (urlParams.has("desktop")) {
|
||||
return "desktop";
|
||||
} else if (urlParams.has("mobile")) {
|
||||
return "mobile";
|
||||
}
|
||||
|
||||
const deviceCookie = document.cookie.split("; ").find(row => row.startsWith("trilium-device="))?.split("=")[1];
|
||||
if (deviceCookie === "desktop" || deviceCookie === "mobile") return deviceCookie;
|
||||
return isMobile() ? "mobile" : "desktop";
|
||||
}
|
||||
|
||||
// https://stackoverflow.com/a/73731646/944162
|
||||
function isMobile() {
|
||||
const mQ = matchMedia?.("(pointer:coarse)");
|
||||
if (mQ?.media === "(pointer:coarse)") return !!mQ.matches;
|
||||
|
||||
if ("orientation" in window) return true;
|
||||
const userAgentsRegEx = /\b(Android|iPhone|iPad|iPod|Windows Phone|BlackBerry|webOS|IEMobile)\b/i;
|
||||
return userAgentsRegEx.test(navigator.userAgent);
|
||||
}
|
||||
|
||||
async function loadBootstrapCss() {
|
||||
// We have to selectively import Bootstrap CSS based on text direction.
|
||||
if (glob.isRtl) {
|
||||
@@ -112,8 +85,6 @@ function loadIcons() {
|
||||
}
|
||||
|
||||
function setBodyAttributes() {
|
||||
if (!glob.dbInitialized) return;
|
||||
|
||||
const { device, headingStyle, layoutOrientation, platform, isElectron, hasNativeTitleBar, hasBackgroundEffects, currentLocale } = window.glob;
|
||||
const classesToSet = [
|
||||
device,
|
||||
@@ -134,11 +105,6 @@ function setBodyAttributes() {
|
||||
}
|
||||
|
||||
async function loadScripts() {
|
||||
if (!glob.dbInitialized) {
|
||||
await import("./setup.js");
|
||||
return;
|
||||
}
|
||||
|
||||
switch (glob.device) {
|
||||
case "mobile":
|
||||
await import("./mobile.js");
|
||||
|
||||
@@ -30,7 +30,6 @@ import SpacerWidget from "../widgets/launch_bar/SpacerWidget.jsx";
|
||||
import InlineTitle from "../widgets/layout/InlineTitle.jsx";
|
||||
import NoteBadges from "../widgets/layout/NoteBadges.jsx";
|
||||
import NoteTitleActions from "../widgets/layout/NoteTitleActions.jsx";
|
||||
import StandaloneWarningBar from "../widgets/layout/StandaloneWarningBar.jsx";
|
||||
import StatusBar from "../widgets/layout/StatusBar.jsx";
|
||||
import NoteIconWidget from "../widgets/note_icon.jsx";
|
||||
import NoteTitleWidget from "../widgets/note_title.jsx";
|
||||
@@ -187,7 +186,6 @@ export default class DesktopLayout {
|
||||
)
|
||||
)
|
||||
.optChild(launcherPaneIsHorizontal && isNewLayout, <StatusBar />)
|
||||
.optChild(glob.isStandalone, <StandaloneWarningBar />)
|
||||
.child(<CloseZenModeButton />)
|
||||
|
||||
// Desktop-specific dialogs.
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
import type RootContainer from "../widgets/containers/root_container.js";
|
||||
|
||||
import AboutDialog from "../widgets/dialogs/about.js";
|
||||
import HelpDialog from "../widgets/dialogs/help.js";
|
||||
import JumpToNoteDialog from "../widgets/dialogs/jump_to_note.js";
|
||||
import RecentChangesDialog from "../widgets/dialogs/recent_changes.js";
|
||||
import PromptDialog from "../widgets/dialogs/prompt.js";
|
||||
import AddLinkDialog from "../widgets/dialogs/add_link.js";
|
||||
import IncludeNoteDialog from "../widgets/dialogs/include_note.js";
|
||||
import BulkActionsDialog from "../widgets/dialogs/bulk_actions.js";
|
||||
import BranchPrefixDialog from "../widgets/dialogs/branch_prefix.js";
|
||||
import SortChildNotesDialog from "../widgets/dialogs/sort_child_notes.js";
|
||||
import NoteTypeChooserDialog from "../widgets/dialogs/note_type_chooser.js";
|
||||
import MoveToDialog from "../widgets/dialogs/move_to.js";
|
||||
import CloneToDialog from "../widgets/dialogs/clone_to.js";
|
||||
import ImportDialog from "../widgets/dialogs/import.js";
|
||||
import ExportDialog from "../widgets/dialogs/export.js";
|
||||
import MarkdownImportDialog from "../widgets/dialogs/markdown_import.js";
|
||||
import ProtectedSessionPasswordDialog from "../widgets/dialogs/protected_session_password.js";
|
||||
import ConfirmDialog from "../widgets/dialogs/confirm.js";
|
||||
import RevisionsDialog from "../widgets/dialogs/revisions.js";
|
||||
import DeleteNotesDialog from "../widgets/dialogs/delete_notes.js";
|
||||
import InfoDialog from "../widgets/dialogs/info.js";
|
||||
import IncorrectCpuArchDialog from "../widgets/dialogs/incorrect_cpu_arch.js";
|
||||
import BulkActionsDialog from "../widgets/dialogs/bulk_actions.js";
|
||||
import CallToActionDialog from "../widgets/dialogs/call_to_action.jsx";
|
||||
import CloneToDialog from "../widgets/dialogs/clone_to.js";
|
||||
import ConfirmDialog from "../widgets/dialogs/confirm.js";
|
||||
import DeleteNotesDialog from "../widgets/dialogs/delete_notes.js";
|
||||
import ExportDialog from "../widgets/dialogs/export.js";
|
||||
import HelpDialog from "../widgets/dialogs/help.js";
|
||||
import ImportDialog from "../widgets/dialogs/import.js";
|
||||
import ImportPreviewDialog from "../widgets/dialogs/import_preview.jsx";
|
||||
import IncludeNoteDialog from "../widgets/dialogs/include_note.js";
|
||||
import IncorrectCpuArchDialog from "../widgets/dialogs/incorrect_cpu_arch.js";
|
||||
import InfoDialog from "../widgets/dialogs/info.js";
|
||||
import JumpToNoteDialog from "../widgets/dialogs/jump_to_note.js";
|
||||
import MarkdownImportDialog from "../widgets/dialogs/markdown_import.js";
|
||||
import MoveToDialog from "../widgets/dialogs/move_to.js";
|
||||
import NoteTypeChooserDialog from "../widgets/dialogs/note_type_chooser.js";
|
||||
import PopupEditorDialog from "../widgets/dialogs/PopupEditor.jsx";
|
||||
import PromptDialog from "../widgets/dialogs/prompt.js";
|
||||
import ProtectedSessionPasswordDialog from "../widgets/dialogs/protected_session_password.js";
|
||||
import RecentChangesDialog from "../widgets/dialogs/recent_changes.js";
|
||||
import RevisionsDialog from "../widgets/dialogs/revisions.js";
|
||||
import SortChildNotesDialog from "../widgets/dialogs/sort_child_notes.js";
|
||||
import ToastContainer from "../widgets/Toast.jsx";
|
||||
|
||||
export function applyModals(rootContainer: RootContainer) {
|
||||
@@ -52,5 +52,6 @@ export function applyModals(rootContainer: RootContainer) {
|
||||
.child(<IncorrectCpuArchDialog />)
|
||||
.child(<PopupEditorDialog />)
|
||||
.child(<CallToActionDialog />)
|
||||
.child(<ToastContainer />);
|
||||
.child(<ToastContainer />)
|
||||
.child(<ImportPreviewDialog />);
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ import LauncherContainer from "../widgets/launch_bar/LauncherContainer.jsx";
|
||||
import InlineTitle from "../widgets/layout/InlineTitle.jsx";
|
||||
import NoteBadges from "../widgets/layout/NoteBadges.jsx";
|
||||
import NoteTitleActions from "../widgets/layout/NoteTitleActions.jsx";
|
||||
import StandaloneWarningBar from "../widgets/layout/StandaloneWarningBar";
|
||||
import MobileDetailMenu from "../widgets/mobile_widgets/mobile_detail_menu.js";
|
||||
import ScreenContainer from "../widgets/mobile_widgets/screen_container.js";
|
||||
import SidebarContainer from "../widgets/mobile_widgets/sidebar_container.js";
|
||||
@@ -24,7 +23,10 @@ import NoteTreeWidget from "../widgets/note_tree.js";
|
||||
import NoteWrapperWidget from "../widgets/note_wrapper.js";
|
||||
import NoteDetail from "../widgets/NoteDetail.jsx";
|
||||
import QuickSearchWidget from "../widgets/quick_search.js";
|
||||
import ScrollPadding from "../widgets/scroll_padding";
|
||||
import { useNoteContext } from "../widgets/react/hooks.jsx";
|
||||
import StandaloneRibbonAdapter from "../widgets/ribbon/components/StandaloneRibbonAdapter.jsx";
|
||||
import FilePropertiesTab from "../widgets/ribbon/FilePropertiesTab.jsx";
|
||||
import SearchDefinitionTab from "../widgets/ribbon/SearchDefinitionTab.jsx";
|
||||
import SearchResult from "../widgets/search_result.jsx";
|
||||
import MobileEditorToolbar from "../widgets/type_widgets/text/mobile_editor_toolbar.jsx";
|
||||
import { applyModals } from "./layout_commons.js";
|
||||
@@ -56,7 +58,6 @@ export default class MobileLayout {
|
||||
.child(
|
||||
new SplitNoteContainer(() =>
|
||||
new NoteWrapperWidget()
|
||||
.optChild(glob.isStandalone, <StandaloneWarningBar />)
|
||||
.child(
|
||||
new FlexContainer("row")
|
||||
.class("title-row note-split-title")
|
||||
@@ -77,7 +78,7 @@ export default class MobileLayout {
|
||||
.child(<NoteDetail />)
|
||||
.child(<NoteList media="screen" />)
|
||||
.child(<SearchResult />)
|
||||
.child(<ScrollPadding />)
|
||||
.child(<FilePropertiesWrapper />)
|
||||
)
|
||||
.child(<MobileEditorToolbar />)
|
||||
.child(new FindWidget())
|
||||
@@ -101,3 +102,13 @@ export default class MobileLayout {
|
||||
return rootContainer;
|
||||
}
|
||||
}
|
||||
|
||||
function FilePropertiesWrapper() {
|
||||
const { note, ntxId } = useNoteContext();
|
||||
|
||||
return (
|
||||
<div>
|
||||
{note?.type === "file" && <FilePropertiesTab note={note} ntxId={ntxId} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,8 +3,6 @@ import options from "../services/options.js";
|
||||
import zoomService from "../components/zoom.js";
|
||||
import contextMenu, { type MenuItem } from "./context_menu.js";
|
||||
import { t } from "../services/i18n.js";
|
||||
import server from "../services/server.js";
|
||||
import * as clipboardExt from "../services/clipboard_ext.js";
|
||||
import type { BrowserWindow } from "electron";
|
||||
import type { CommandNames, AppContext } from "../components/app_context.js";
|
||||
|
||||
@@ -62,33 +60,6 @@ function setupContextMenu() {
|
||||
uiIcon: "bx bx-copy",
|
||||
handler: () => webContents.copy()
|
||||
});
|
||||
|
||||
items.push({
|
||||
enabled: hasText,
|
||||
title: t("electron_context_menu.copy-as-markdown"),
|
||||
uiIcon: "bx bx-copy-alt",
|
||||
handler: async () => {
|
||||
const selection = window.getSelection();
|
||||
if (!selection || !selection.rangeCount) return '';
|
||||
|
||||
const range = selection.getRangeAt(0);
|
||||
const div = document.createElement('div');
|
||||
div.appendChild(range.cloneContents());
|
||||
|
||||
const htmlContent = div.innerHTML;
|
||||
if (htmlContent) {
|
||||
try {
|
||||
const { markdownContent } = await server.post<{ markdownContent: string }>(
|
||||
"other/to-markdown",
|
||||
{ htmlContent }
|
||||
);
|
||||
await clipboardExt.copyTextWithToast(markdownContent);
|
||||
} catch (error) {
|
||||
console.error("Failed to copy as markdown:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!["", "javascript:", "about:blank#blocked"].includes(params.linkURL) && params.mediaType === "none") {
|
||||
|
||||
@@ -18,10 +18,6 @@ export type PrintReport = {
|
||||
} | {
|
||||
type: "collection";
|
||||
ignoredNoteIds: string[];
|
||||
} | {
|
||||
type: "error";
|
||||
message: string;
|
||||
stack?: string;
|
||||
};
|
||||
|
||||
async function main() {
|
||||
|
||||
@@ -168,49 +168,6 @@ function isAffecting(attrRow: AttributeRow, affectedNote: FNote | null | undefin
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles whether a dangerous attribute is enabled or not. When an attribute is disabled, its name is prefixed with `disabled:`.
|
||||
*
|
||||
* Note that this work for non-dangerous attributes as well.
|
||||
*
|
||||
* If there are multiple attributes with the same name, all of them will be toggled at the same time.
|
||||
*
|
||||
* @param note the note whose attribute to change.
|
||||
* @param type the type of dangerous attribute (label or relation).
|
||||
* @param name the name of the dangerous attribute.
|
||||
* @param willEnable whether to enable or disable the attribute.
|
||||
* @returns a promise that will resolve when the request to the server completes.
|
||||
*/
|
||||
async function toggleDangerousAttribute(note: FNote, type: "label" | "relation", name: string, willEnable: boolean) {
|
||||
const attrs = [
|
||||
...note.getOwnedAttributes(type, name),
|
||||
...note.getOwnedAttributes(type, `disabled:${name}`)
|
||||
];
|
||||
|
||||
for (const attr of attrs) {
|
||||
const baseName = getNameWithoutDangerousPrefix(attr.name);
|
||||
const newName = willEnable ? baseName : `disabled:${baseName}`;
|
||||
if (newName === attr.name) continue;
|
||||
|
||||
// We are adding and removing afterwards to avoid a flicker (because for a moment there would be no active content attribute anymore) because the operations are done in sequence and not atomically.
|
||||
if (attr.type === "label") {
|
||||
await setLabel(note.noteId, newName, attr.value);
|
||||
} else {
|
||||
await setRelation(note.noteId, newName, attr.value);
|
||||
}
|
||||
await removeAttributeById(note.noteId, attr.attributeId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the name of an attribute without the `disabled:` prefix, or the same name if it's not disabled.
|
||||
* @param name the name of an attribute.
|
||||
* @returns the name without the `disabled:` prefix.
|
||||
*/
|
||||
function getNameWithoutDangerousPrefix(name: string) {
|
||||
return name.startsWith("disabled:") ? name.substring(9) : name;
|
||||
}
|
||||
|
||||
export default {
|
||||
addLabel,
|
||||
setLabel,
|
||||
@@ -220,7 +177,5 @@ export default {
|
||||
removeAttributeById,
|
||||
removeOwnedLabelByName,
|
||||
removeOwnedRelationByName,
|
||||
isAffecting,
|
||||
toggleDangerousAttribute,
|
||||
getNameWithoutDangerousPrefix
|
||||
isAffecting
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import { h, VNode } from "preact";
|
||||
|
||||
import BasicWidget, { ReactWrappedWidget } from "../widgets/basic_widget.js";
|
||||
import RightPanelWidget from "../widgets/right_panel_widget.js";
|
||||
import froca from "./froca.js";
|
||||
import type { Entity } from "./frontend_script_api.js";
|
||||
import { WidgetDefinitionWithType } from "./frontend_script_api_preact.js";
|
||||
import { t } from "./i18n.js";
|
||||
@@ -37,18 +38,15 @@ async function getAndExecuteBundle(noteId: string, originEntity = null, script =
|
||||
|
||||
export type ParentName = "left-pane" | "center-pane" | "note-detail-pane" | "right-pane";
|
||||
|
||||
export async function executeBundleWithoutErrorHandling(bundle: Bundle, originEntity?: Entity | null, $container?: JQuery<HTMLElement>) {
|
||||
const apiContext = await ScriptContext(bundle.noteId, bundle.allNoteIds, originEntity, $container);
|
||||
return await function () {
|
||||
return eval(`const apiContext = this; (async function() { ${bundle.script}\r\n})()`);
|
||||
}.call(apiContext);
|
||||
}
|
||||
|
||||
export async function executeBundle(bundle: Bundle, originEntity?: Entity | null, $container?: JQuery<HTMLElement>) {
|
||||
const apiContext = await ScriptContext(bundle.noteId, bundle.allNoteIds, originEntity, $container);
|
||||
|
||||
try {
|
||||
return await executeBundleWithoutErrorHandling(bundle, originEntity, $container);
|
||||
} catch (e: unknown) {
|
||||
showErrorForScriptNote(bundle.noteId, t("toast.bundle-error.message", { message: getErrorMessage(e) }));
|
||||
return await function () {
|
||||
return eval(`const apiContext = this; (async function() { ${bundle.script}\r\n})()`);
|
||||
}.call(apiContext);
|
||||
} catch (e: any) {
|
||||
showErrorForScriptNote(bundle.noteId, t("toast.bundle-error.message", { message: e.message }));
|
||||
logError("Widget initialization failed: ", e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import "./content_renderer.css";
|
||||
|
||||
import { normalizeMimeTypeForCKEditor } from "@triliumnext/commons";
|
||||
import { h, render } from "preact";
|
||||
import WheelZoom from 'vanilla-js-wheel-zoom';
|
||||
|
||||
import FAttachment from "../entities/fattachment.js";
|
||||
@@ -16,7 +15,7 @@ import protectedSessionService from "./protected_session.js";
|
||||
import protectedSessionHolder from "./protected_session_holder.js";
|
||||
import renderService from "./render.js";
|
||||
import { applySingleBlockSyntaxHighlight } from "./syntax_highlight.js";
|
||||
import utils, { getErrorMessage } from "./utils.js";
|
||||
import utils from "./utils.js";
|
||||
|
||||
let idCounter = 1;
|
||||
|
||||
@@ -54,19 +53,16 @@ export async function getRenderedContent(this: {} | { ctx: string }, entity: FNo
|
||||
await renderText(entity, $renderedContent, options);
|
||||
} else if (type === "code") {
|
||||
await renderCode(entity, $renderedContent);
|
||||
} else if (["image", "canvas", "mindMap", "spreadsheet"].includes(type)) {
|
||||
} else if (["image", "canvas", "mindMap"].includes(type)) {
|
||||
renderImage(entity, $renderedContent, options);
|
||||
} else if (!options.tooltip && ["file", "pdf", "audio", "video"].includes(type)) {
|
||||
await renderFile(entity, type, $renderedContent);
|
||||
renderFile(entity, type, $renderedContent);
|
||||
} else if (type === "mermaid") {
|
||||
await renderMermaid(entity, $renderedContent);
|
||||
} else if (type === "render" && entity instanceof FNote) {
|
||||
const $content = $("<div>");
|
||||
|
||||
await renderService.render(entity, $content, (e) => {
|
||||
const $error = $("<div>").addClass("admonition caution").text(typeof e === "string" ? e : getErrorMessage(e));
|
||||
$content.empty().append($error);
|
||||
});
|
||||
await renderService.render(entity, $content);
|
||||
|
||||
$renderedContent.append($content);
|
||||
} else if (type === "doc" && "noteId" in entity) {
|
||||
@@ -180,7 +176,7 @@ function renderImage(entity: FNote | FAttachment, $renderedContent: JQuery<HTMLE
|
||||
imageContextMenuService.setupContextMenu($img);
|
||||
}
|
||||
|
||||
async function renderFile(entity: FNote | FAttachment, type: string, $renderedContent: JQuery<HTMLElement>) {
|
||||
function renderFile(entity: FNote | FAttachment, type: string, $renderedContent: JQuery<HTMLElement>) {
|
||||
let entityType, entityId;
|
||||
|
||||
if (entity instanceof FNote) {
|
||||
@@ -193,17 +189,13 @@ async function renderFile(entity: FNote | FAttachment, type: string, $renderedCo
|
||||
throw new Error(`Can't recognize entity type of '${entity}'`);
|
||||
}
|
||||
|
||||
const $content = $('<div style="display: flex; flex-direction: column; height: 100%; justify-content: end;">');
|
||||
const $content = $('<div style="display: flex; flex-direction: column; height: 100%;">');
|
||||
|
||||
if (type === "pdf") {
|
||||
const url = `../../api/${entityType}/${entityId}/open`;
|
||||
const $viewer = $(`<div style="height: 100%">`);
|
||||
const PdfViewer = (await import("../widgets/type_widgets/file/PdfViewer")).default;
|
||||
render(h(PdfViewer, {pdfUrl: url, editable: false}), $viewer.get(0)!);
|
||||
|
||||
$content.append($viewer);
|
||||
|
||||
const $pdfPreview = $('<iframe class="pdf-preview" style="width: 100%; flex-grow: 100;"></iframe>');
|
||||
$pdfPreview.attr("src", openService.getUrlForDownload(`pdfjs/web/viewer.html?file=../../api/${entityType}/${entityId}/open`));
|
||||
|
||||
$content.append($pdfPreview);
|
||||
} else if (type === "audio") {
|
||||
const $audioPreview = $("<audio controls></audio>")
|
||||
.attr("src", openService.getUrlForDownload(`api/${entityType}/${entityId}/open-partial`))
|
||||
|
||||
@@ -120,6 +120,7 @@ async function renderChildrenList($renderedContent: JQuery<HTMLElement>, note: F
|
||||
return;
|
||||
}
|
||||
|
||||
$renderedContent.css("padding", "10px");
|
||||
$renderedContent.addClass("text-with-ellipsis");
|
||||
|
||||
// just load the first 10 child notes
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { dayjs } from "@triliumnext/commons";
|
||||
|
||||
import type { FNoteRow } from "../entities/fnote.js";
|
||||
import froca from "./froca.js";
|
||||
import server from "./server.js";
|
||||
@@ -15,13 +14,8 @@ async function getTodayNote() {
|
||||
return await getDayNote(dayjs().format("YYYY-MM-DD"));
|
||||
}
|
||||
|
||||
async function getDayNote(date: string, calendarRootId?: string) {
|
||||
let url = `special-notes/days/${date}`;
|
||||
if (calendarRootId) {
|
||||
url += `?calendarRootId=${calendarRootId}`;
|
||||
}
|
||||
|
||||
const note = await server.get<FNoteRow>(url, "date-note");
|
||||
async function getDayNote(date: string) {
|
||||
const note = await server.get<FNoteRow>(`special-notes/days/${date}`, "date-note");
|
||||
|
||||
await ws.waitForMaxKnownEntityChangeId();
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { formatCodeBlocks } from "./syntax_highlight.js";
|
||||
|
||||
export default function renderDoc(note: FNote) {
|
||||
return new Promise<JQuery<HTMLElement>>((resolve) => {
|
||||
const docName = note.getLabelValue("docName");
|
||||
let docName = note.getLabelValue("docName");
|
||||
const $content = $("<div>");
|
||||
|
||||
if (docName) {
|
||||
@@ -16,7 +16,7 @@ export default function renderDoc(note: FNote) {
|
||||
if (status === "error") {
|
||||
const fallbackUrl = getUrl(docName, "en");
|
||||
$content.load(fallbackUrl, async () => {
|
||||
await processContent(fallbackUrl, $content);
|
||||
await processContent(fallbackUrl, $content)
|
||||
resolve($content);
|
||||
});
|
||||
return;
|
||||
@@ -37,9 +37,9 @@ async function processContent(url: string, $content: JQuery<HTMLElement>) {
|
||||
const dir = url.substring(0, url.lastIndexOf("/"));
|
||||
|
||||
// Images are relative to the docnote but that will not work when rendered in the application since the path breaks.
|
||||
$content.find("img").each((_i, el) => {
|
||||
$content.find("img").each((i, el) => {
|
||||
const $img = $(el);
|
||||
$img.attr("src", `${dir}/${$img.attr("src")}`);
|
||||
$img.attr("src", dir + "/" + $img.attr("src"));
|
||||
});
|
||||
|
||||
formatCodeBlocks($content);
|
||||
@@ -51,17 +51,7 @@ async function processContent(url: string, $content: JQuery<HTMLElement>) {
|
||||
function getUrl(docNameValue: string, language: string) {
|
||||
// Cannot have spaces in the URL due to how JQuery.load works.
|
||||
docNameValue = docNameValue.replaceAll(" ", "%20");
|
||||
// The user guide is available only in English, so make sure we are requesting correctly since 404s in standalone client are treated differently.
|
||||
if (docNameValue.includes("User%20Guide")) language = "en";
|
||||
return `${getBasePath()}/doc_notes/${language}/${docNameValue}.html`;
|
||||
}
|
||||
|
||||
function getBasePath() {
|
||||
if (window.glob.isStandalone) {
|
||||
return `server-assets`;
|
||||
}
|
||||
if (window.glob.isDev) {
|
||||
return `${window.glob.assetPath }/..`;
|
||||
}
|
||||
return window.glob.assetPath;
|
||||
const basePath = window.glob.isDev ? window.glob.assetPath + "/.." : window.glob.assetPath;
|
||||
return `${basePath}/doc_notes/${language}/${docNameValue}.html`;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import appContext from "../components/app_context.js";
|
||||
import FAttachment, { type FAttachmentRow } from "../entities/fattachment.js";
|
||||
import FAttribute, { type FAttributeRow } from "../entities/fattribute.js";
|
||||
import FBlob, { type FBlobRow } from "../entities/fblob.js";
|
||||
import FBranch, { type FBranchRow } from "../entities/fbranch.js";
|
||||
import FNote, { type FNoteRow } from "../entities/fnote.js";
|
||||
import type { Froca } from "./froca-interface.js";
|
||||
import FAttribute, { type FAttributeRow } from "../entities/fattribute.js";
|
||||
import server from "./server.js";
|
||||
import appContext from "../components/app_context.js";
|
||||
import FBlob, { type FBlobRow } from "../entities/fblob.js";
|
||||
import FAttachment, { type FAttachmentRow } from "../entities/fattachment.js";
|
||||
import type { Froca } from "./froca-interface.js";
|
||||
|
||||
interface SubtreeResponse {
|
||||
notes: FNoteRow[];
|
||||
@@ -44,9 +44,8 @@ class FrocaImpl implements Froca {
|
||||
}
|
||||
|
||||
async loadInitialTree() {
|
||||
if (!glob.dbInitialized) return;
|
||||
|
||||
const resp = await server.get<SubtreeResponse>("tree");
|
||||
|
||||
// clear the cache only directly before adding new content which is important for e.g., switching to protected session
|
||||
this.#clear();
|
||||
this.addResp(resp);
|
||||
@@ -78,7 +77,7 @@ class FrocaImpl implements Froca {
|
||||
for (const noteRow of noteRows) {
|
||||
const { noteId } = noteRow;
|
||||
|
||||
const note = this.notes[noteId];
|
||||
let note = this.notes[noteId];
|
||||
|
||||
if (note) {
|
||||
note.update(noteRow);
|
||||
@@ -241,8 +240,9 @@ class FrocaImpl implements Froca {
|
||||
console.trace(`Can't find note '${noteId}'`);
|
||||
|
||||
return null;
|
||||
} else {
|
||||
return this.notes[noteId];
|
||||
}
|
||||
return this.notes[noteId];
|
||||
})
|
||||
.filter((note) => !!note) as FNote[];
|
||||
}
|
||||
@@ -263,8 +263,9 @@ class FrocaImpl implements Froca {
|
||||
console.trace(`Can't find note '${noteId}'`);
|
||||
|
||||
return null;
|
||||
} else {
|
||||
return this.notes[noteId];
|
||||
}
|
||||
return this.notes[noteId];
|
||||
})
|
||||
.filter((note) => !!note) as FNote[];
|
||||
}
|
||||
@@ -337,10 +338,11 @@ class FrocaImpl implements Froca {
|
||||
attachmentRows = await server.getWithSilentNotFound<FAttachmentRow[]>(`attachments/${attachmentId}/all`);
|
||||
} catch (e: any) {
|
||||
if (silentNotFoundError) {
|
||||
logInfo(`Attachment '${attachmentId}' not found, but silentNotFoundError is enabled: ${ e.message}`);
|
||||
logInfo(`Attachment '${attachmentId}' not found, but silentNotFoundError is enabled: ` + e.message);
|
||||
return null;
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
const attachments = this.processAttachmentRows(attachmentRows);
|
||||
|
||||
@@ -110,12 +110,7 @@ function processNoteChange(loadResults: LoadResults, ec: EntityChange) {
|
||||
}
|
||||
}
|
||||
|
||||
// Only register as a content change if the protection status didn't change.
|
||||
// When isProtected changes, the blobId change is a side effect of re-encryption,
|
||||
// not a content edit. Registering it as content would cause the tree's content-only
|
||||
// filter to incorrectly skip the note update (since both changes share the same
|
||||
// componentId).
|
||||
if (ec.componentId && note.isProtected === (ec.entity as FNoteRow).isProtected) {
|
||||
if (ec.componentId) {
|
||||
loadResults.addNoteContent(note.noteId, ec.componentId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,7 +206,7 @@ export interface Api {
|
||||
* Instance name identifies particular Trilium instance. It can be useful for scripts
|
||||
* if some action needs to happen on only one specific instance.
|
||||
*/
|
||||
getInstanceName(): string | null;
|
||||
getInstanceName(): string;
|
||||
|
||||
/**
|
||||
* @returns date in YYYY-MM-DD format
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import { type Locale, LOCALE_IDS, setDayjsLocale } from "@triliumnext/commons";
|
||||
import options from "./options.js";
|
||||
import i18next from "i18next";
|
||||
import i18nextHttpBackend from "i18next-http-backend";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
|
||||
import options from "./options.js";
|
||||
import server from "./server.js";
|
||||
import { LOCALE_IDS, setDayjsLocale, type Locale } from "@triliumnext/commons";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
|
||||
let locales: Locale[] | null;
|
||||
|
||||
/**
|
||||
* A deferred promise that resolves when translations are initialized.
|
||||
*/
|
||||
export const translationsInitializedPromise = $.Deferred();
|
||||
export let translationsInitializedPromise = $.Deferred();
|
||||
|
||||
export async function initLocale() {
|
||||
const locale = ((options.get("locale") as string) || "en") as LOCALE_IDS;
|
||||
@@ -25,8 +24,7 @@ export async function initLocale() {
|
||||
backend: {
|
||||
loadPath: `${window.glob.assetPath}/translations/{{lng}}/{{ns}}.json`
|
||||
},
|
||||
returnEmptyString: false,
|
||||
showSupportNotice: false
|
||||
returnEmptyString: false
|
||||
});
|
||||
|
||||
await setDayjsLocale(locale);
|
||||
@@ -35,7 +33,7 @@ export async function initLocale() {
|
||||
|
||||
export function getAvailableLocales() {
|
||||
if (!locales) {
|
||||
throw new Error("Tried to load list of locales, but localization is not yet initialized.");
|
||||
throw new Error("Tried to load list of locales, but localization is not yet initialized.")
|
||||
}
|
||||
|
||||
return locales;
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import toastService, { type ToastOptionsWithRequiredId } from "./toast.js";
|
||||
import server from "./server.js";
|
||||
import ws from "./ws.js";
|
||||
import utils from "./utils.js";
|
||||
import { ImportPreviewResponse, WebSocketMessage } from "@triliumnext/commons";
|
||||
|
||||
import appContext from "../components/app_context.js";
|
||||
import { t } from "./i18n.js";
|
||||
import { WebSocketMessage } from "@triliumnext/commons";
|
||||
import server from "./server.js";
|
||||
import toastService, { type ToastOptionsWithRequiredId } from "./toast.js";
|
||||
import utils from "./utils.js";
|
||||
import ws from "./ws.js";
|
||||
|
||||
type BooleanLike = boolean | "true" | "false";
|
||||
type BooleanLike = "true" | "false";
|
||||
|
||||
export interface UploadFilesOptions {
|
||||
safeImport?: BooleanLike;
|
||||
@@ -48,7 +49,7 @@ export async function uploadFiles(entityType: string, parentNoteId: string, file
|
||||
dataType: "json",
|
||||
type: "POST",
|
||||
timeout: 60 * 60 * 1000,
|
||||
error: function (xhr) {
|
||||
error (xhr) {
|
||||
toastService.showError(t("import.failed", { message: xhr.responseText }));
|
||||
},
|
||||
contentType: false, // NEEDED, DON'T REMOVE THIS
|
||||
@@ -57,6 +58,64 @@ export async function uploadFiles(entityType: string, parentNoteId: string, file
|
||||
}
|
||||
}
|
||||
|
||||
export async function uploadFilesWithPreview(files: string[] | File[]) {
|
||||
if (files.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const taskId = utils.randomString(10);
|
||||
const results: ImportPreviewResponse[] = [];
|
||||
for (const file of files) {
|
||||
const formData = new FormData();
|
||||
formData.append("upload", file);
|
||||
formData.append("taskId", taskId);
|
||||
|
||||
results.push(await $.ajax({
|
||||
url: `${window.glob.baseApiUrl}notes/preview-import`,
|
||||
headers: await server.getHeaders(),
|
||||
data: formData,
|
||||
dataType: "json",
|
||||
type: "POST",
|
||||
timeout: 60 * 60 * 1000,
|
||||
error (xhr) {
|
||||
toastService.showError(t("import.failed", { message: xhr.responseText }));
|
||||
},
|
||||
contentType: false, // NEEDED, DON'T REMOVE THIS
|
||||
processData: false // NEEDED, DON'T REMOVE THIS
|
||||
}));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
export async function executeUploadWithPreview(parentNoteId: string, files: ImportPreviewResponse[], options: UploadFilesOptions) {
|
||||
if (files.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const taskId = utils.randomString(10);
|
||||
let counter = 0;
|
||||
|
||||
for (const file of files) {
|
||||
counter++;
|
||||
|
||||
server.post(
|
||||
`notes/${parentNoteId}/execute-import`,
|
||||
{
|
||||
...options,
|
||||
id: file.id,
|
||||
taskId,
|
||||
last: counter === files.length ? "true" : "false"
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function cancelUploadWithPreview(files: ImportPreviewResponse[]) {
|
||||
for (const file of files) {
|
||||
server.remove(`notes/preview-import/${file.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
function makeToast(id: string, message: string): ToastOptionsWithRequiredId {
|
||||
return {
|
||||
id,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { NoteType } from "@triliumnext/commons";
|
||||
|
||||
import FNote from "../entities/fnote";
|
||||
import { ViewTypeOptions } from "../widgets/collections/interface";
|
||||
|
||||
@@ -19,7 +18,7 @@ export const byNoteType: Record<Exclude<NoteType, "book">, string | null> = {
|
||||
search: null,
|
||||
text: null,
|
||||
webView: null,
|
||||
spreadsheet: null
|
||||
aiChat: null
|
||||
};
|
||||
|
||||
export const byBookType: Record<ViewTypeOptions, string | null> = {
|
||||
@@ -40,6 +39,6 @@ export function getHelpUrlForNote(note: FNote | null | undefined) {
|
||||
} else if (note?.hasLabel("textSnippet")) {
|
||||
return "pwc194wlRzcH";
|
||||
} else if (note && note.type === "book") {
|
||||
return byBookType[note.getAttributeValue("label", "viewType") as ViewTypeOptions ?? ""];
|
||||
return byBookType[note.getAttributeValue("label", "viewType") as ViewTypeOptions ?? ""]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
import type { CKTextEditor } from "@triliumnext/ckeditor5";
|
||||
import { AttributeRow } from "@triliumnext/commons";
|
||||
|
||||
import appContext from "../components/app_context.js";
|
||||
import type FBranch from "../entities/fbranch.js";
|
||||
import type FNote from "../entities/fnote.js";
|
||||
import type { ChooseNoteTypeResponse } from "../widgets/dialogs/note_type_chooser.js";
|
||||
import froca from "./froca.js";
|
||||
import { t } from "./i18n.js";
|
||||
import protectedSessionHolder from "./protected_session_holder.js";
|
||||
import server from "./server.js";
|
||||
import toastService from "./toast.js";
|
||||
import treeService from "./tree.js";
|
||||
import ws from "./ws.js";
|
||||
import froca from "./froca.js";
|
||||
import treeService from "./tree.js";
|
||||
import toastService from "./toast.js";
|
||||
import { t } from "./i18n.js";
|
||||
import type FNote from "../entities/fnote.js";
|
||||
import type FBranch from "../entities/fbranch.js";
|
||||
import type { ChooseNoteTypeResponse } from "../widgets/dialogs/note_type_chooser.js";
|
||||
import type { CKTextEditor } from "@triliumnext/ckeditor5";
|
||||
|
||||
export interface CreateNoteOpts {
|
||||
isProtected?: boolean;
|
||||
@@ -26,8 +24,6 @@ export interface CreateNoteOpts {
|
||||
target?: string;
|
||||
targetBranchId?: string;
|
||||
textEditor?: CKTextEditor;
|
||||
/** Attributes to be set on the note. These are set atomically on note creation, so entity changes are not sent for attributes defined here. */
|
||||
attributes?: Omit<AttributeRow, "noteId" | "attributeId">[];
|
||||
}
|
||||
|
||||
interface Response {
|
||||
@@ -41,7 +37,7 @@ interface DuplicateResponse {
|
||||
note: FNote;
|
||||
}
|
||||
|
||||
async function createNote(parentNotePath: string | undefined, options: CreateNoteOpts = {}, componentId?: string) {
|
||||
async function createNote(parentNotePath: string | undefined, options: CreateNoteOpts = {}) {
|
||||
options = Object.assign(
|
||||
{
|
||||
activate: true,
|
||||
@@ -67,15 +63,22 @@ async function createNote(parentNotePath: string | undefined, options: CreateNot
|
||||
|
||||
const parentNoteId = treeService.getNoteIdFromUrl(parentNotePath);
|
||||
|
||||
if (options.type === "mermaid" && !options.content && !options.templateNoteId) {
|
||||
options.content = `graph TD;
|
||||
A-->B;
|
||||
A-->C;
|
||||
B-->D;
|
||||
C-->D;`;
|
||||
}
|
||||
|
||||
const { note, branch } = await server.post<Response>(`notes/${parentNoteId}/children?target=${options.target}&targetBranchId=${options.targetBranchId || ""}`, {
|
||||
title: options.title,
|
||||
content: options.content || "",
|
||||
isProtected: options.isProtected,
|
||||
type: options.type,
|
||||
mime: options.mime,
|
||||
templateNoteId: options.templateNoteId,
|
||||
attributes: options.attributes
|
||||
}, componentId);
|
||||
templateNoteId: options.templateNoteId
|
||||
});
|
||||
|
||||
if (options.saveSelection) {
|
||||
// we remove the selection only after it was saved to server to make sure we don't lose anything
|
||||
@@ -137,8 +140,9 @@ function parseSelectedHtml(selectedHtml: string) {
|
||||
const content = selectedHtml.replace(dom[0].outerHTML, "");
|
||||
|
||||
return [title, content];
|
||||
} else {
|
||||
return [null, selectedHtml];
|
||||
}
|
||||
return [null, selectedHtml];
|
||||
}
|
||||
|
||||
async function duplicateSubtree(noteId: string, parentNotePath: string) {
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import appContext from "../components/app_context.js";
|
||||
import type FNote from "../entities/fnote.js";
|
||||
import treeService from "./tree.js";
|
||||
import linkService from "./link.js";
|
||||
import froca from "./froca.js";
|
||||
import utils from "./utils.js";
|
||||
import attributeRenderer from "./attribute_renderer.js";
|
||||
import contentRenderer from "./content_renderer.js";
|
||||
import froca from "./froca.js";
|
||||
import appContext from "../components/app_context.js";
|
||||
import type FNote from "../entities/fnote.js";
|
||||
import { t } from "./i18n.js";
|
||||
import linkService from "./link.js";
|
||||
import treeService from "./tree.js";
|
||||
import utils from "./utils.js";
|
||||
|
||||
// Track all elements that open tooltips
|
||||
let openTooltipElements: JQuery<HTMLElement>[] = [];
|
||||
let dismissTimer: ReturnType<typeof setTimeout>;
|
||||
|
||||
function setupGlobalTooltip() {
|
||||
$(document).on("pointerenter", "a:not(.no-tooltip-preview)", mouseEnterHandler);
|
||||
$(document).on("pointerenter", "[data-href]:not(.no-tooltip-preview)", mouseEnterHandler);
|
||||
$(document).on("mouseenter", "a:not(.no-tooltip-preview)", mouseEnterHandler);
|
||||
$(document).on("mouseenter", "[data-href]:not(.no-tooltip-preview)", mouseEnterHandler);
|
||||
|
||||
// close any note tooltip after click, this fixes the problem that sometimes tooltips remained on the screen
|
||||
$(document).on("click", (e) => {
|
||||
@@ -37,12 +37,10 @@ function dismissAllTooltips() {
|
||||
}
|
||||
|
||||
function setupElementTooltip($el: JQuery<HTMLElement>) {
|
||||
$el.on("pointerenter", mouseEnterHandler);
|
||||
$el.on("mouseenter", mouseEnterHandler);
|
||||
}
|
||||
|
||||
async function mouseEnterHandler<T>(this: HTMLElement, e: JQuery.TriggeredEvent<T, undefined, T, T>) {
|
||||
if (e.pointerType !== "mouse") return;
|
||||
|
||||
async function mouseEnterHandler(this: HTMLElement) {
|
||||
const $link = $(this);
|
||||
|
||||
if ($link.hasClass("no-tooltip-preview") || $link.hasClass("disabled")) {
|
||||
@@ -93,7 +91,7 @@ async function mouseEnterHandler<T>(this: HTMLElement, e: JQuery.TriggeredEvent<
|
||||
}
|
||||
|
||||
const html = `<div class="note-tooltip-content">${content}</div>`;
|
||||
const tooltipClass = `tooltip-${ Math.floor(Math.random() * 999_999_999)}`;
|
||||
const tooltipClass = "tooltip-" + Math.floor(Math.random() * 999_999_999);
|
||||
|
||||
// we need to check if we're still hovering over the element
|
||||
// since the operation to get tooltip content was async, it is possible that
|
||||
@@ -226,7 +224,7 @@ function renderFootnoteOrAnchor($link: JQuery<HTMLElement>, url: string) {
|
||||
}
|
||||
|
||||
let footnoteContent = $targetContent.html();
|
||||
footnoteContent = `<div class="ck-content">${footnoteContent}</div>`;
|
||||
footnoteContent = `<div class="ck-content">${footnoteContent}</div>`
|
||||
return footnoteContent || "";
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { NoteType } from "../entities/fnote.js";
|
||||
import type { MenuCommandItem, MenuItem, MenuItemBadge, MenuSeparatorItem } from "../menus/context_menu.js";
|
||||
import type { TreeCommandNames } from "../menus/tree_context_menu.js";
|
||||
import froca from "./froca.js";
|
||||
import { t } from "./i18n.js";
|
||||
import froca from "./froca.js";
|
||||
import server from "./server.js";
|
||||
import type { MenuCommandItem, MenuItem, MenuItemBadge, MenuSeparatorItem } from "../menus/context_menu.js";
|
||||
import type { NoteType } from "../entities/fnote.js";
|
||||
import type { TreeCommandNames } from "../menus/tree_context_menu.js";
|
||||
|
||||
export interface NoteTypeMapping {
|
||||
type: NoteType;
|
||||
@@ -26,7 +26,6 @@ export const NOTE_TYPES: NoteTypeMapping[] = [
|
||||
|
||||
// The default note type (always the first item)
|
||||
{ type: "text", mime: "text/html", title: t("note_types.text"), icon: "bx-note" },
|
||||
{ type: "spreadsheet", mime: "application/json", title: t("note_types.spreadsheet"), icon: "bx-table", isBeta: true },
|
||||
|
||||
// Text notes group
|
||||
{ type: "book", mime: "", title: t("note_types.book"), icon: "bx-book" },
|
||||
@@ -54,6 +53,7 @@ export const NOTE_TYPES: NoteTypeMapping[] = [
|
||||
{ type: "file", title: t("note_types.file"), reserved: true },
|
||||
{ type: "image", title: t("note_types.image"), reserved: true },
|
||||
{ type: "launcher", mime: "", title: t("note_types.launcher"), reserved: true },
|
||||
{ type: "aiChat", mime: "application/json", title: t("note_types.ai-chat"), reserved: true }
|
||||
];
|
||||
|
||||
/** The maximum age in days for a template to be marked with the "New" badge */
|
||||
@@ -97,9 +97,9 @@ function getBlankNoteTypes(command?: TreeCommandNames): MenuItem<TreeCommandName
|
||||
title: nt.title,
|
||||
command,
|
||||
type: nt.type,
|
||||
uiIcon: `bx ${nt.icon}`,
|
||||
uiIcon: "bx " + nt.icon,
|
||||
badges: []
|
||||
};
|
||||
}
|
||||
|
||||
if (nt.isNew) {
|
||||
menuItem.badges?.push(NEW_BADGE);
|
||||
@@ -131,7 +131,7 @@ async function getUserTemplates(command?: TreeCommandNames) {
|
||||
const item: MenuItem<TreeCommandNames> = {
|
||||
title: templateNote.title,
|
||||
uiIcon: templateNote.getIcon(),
|
||||
command,
|
||||
command: command,
|
||||
type: templateNote.type,
|
||||
templateNoteId: templateNote.noteId
|
||||
};
|
||||
@@ -160,7 +160,7 @@ async function getBuiltInTemplates(title: string | null, command: TreeCommandNam
|
||||
const items: MenuItem<TreeCommandNames>[] = [];
|
||||
if (title) {
|
||||
items.push({
|
||||
title,
|
||||
title: title,
|
||||
kind: "header"
|
||||
});
|
||||
} else {
|
||||
@@ -176,7 +176,7 @@ async function getBuiltInTemplates(title: string | null, command: TreeCommandNam
|
||||
const item: MenuItem<TreeCommandNames> = {
|
||||
title: templateNote.title,
|
||||
uiIcon: templateNote.getIcon(),
|
||||
command,
|
||||
command: command,
|
||||
type: templateNote.type,
|
||||
templateNoteId: templateNote.noteId
|
||||
};
|
||||
@@ -194,7 +194,7 @@ async function isNewTemplate(templateNoteId) {
|
||||
if (rootCreationDate === undefined) {
|
||||
// Retrieve the root note creation date
|
||||
try {
|
||||
const rootNoteInfo: any = await server.get("notes/root");
|
||||
let rootNoteInfo: any = await server.get("notes/root");
|
||||
if ("dateCreated" in rootNoteInfo) {
|
||||
rootCreationDate = new Date(rootNoteInfo.dateCreated);
|
||||
}
|
||||
@@ -209,7 +209,7 @@ async function isNewTemplate(templateNoteId) {
|
||||
if (creationDate === undefined) {
|
||||
// The creation date isn't available in the cache, try to retrieve it from the server
|
||||
try {
|
||||
const noteInfo: any = await server.get(`notes/${templateNoteId}`);
|
||||
const noteInfo: any = await server.get("notes/" + templateNoteId);
|
||||
if ("dateCreated" in noteInfo) {
|
||||
creationDate = new Date(noteInfo.dateCreated);
|
||||
creationDateCache.set(templateNoteId, creationDate);
|
||||
@@ -231,8 +231,9 @@ async function isNewTemplate(templateNoteId) {
|
||||
const age = (new Date().getTime() - creationDate.getTime()) / DAY_LENGTH;
|
||||
// Return true if the template is at most NEW_TEMPLATE_MAX_AGE days old
|
||||
return (age <= NEW_TEMPLATE_MAX_AGE);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export default {
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
import { DefinitionObject, LabelType, Multiplicity } from "@triliumnext/commons";
|
||||
export type LabelType = "text" | "number" | "boolean" | "date" | "datetime" | "time" | "url" | "color";
|
||||
type Multiplicity = "single" | "multi";
|
||||
|
||||
export interface DefinitionObject {
|
||||
isPromoted?: boolean;
|
||||
labelType?: LabelType;
|
||||
multiplicity?: Multiplicity;
|
||||
numberPrecision?: number;
|
||||
promotedAlias?: string;
|
||||
inverseRelation?: string;
|
||||
}
|
||||
|
||||
function parse(value: string) {
|
||||
const tokens = value.split(",").map((t) => t.trim());
|
||||
@@ -7,7 +17,7 @@ function parse(value: string) {
|
||||
for (const token of tokens) {
|
||||
if (token === "promoted") {
|
||||
defObj.isPromoted = true;
|
||||
} else if (["text", "textarea", "number", "boolean", "date", "datetime", "time", "url", "color"].includes(token)) {
|
||||
} else if (["text", "number", "boolean", "date", "datetime", "time", "url", "color"].includes(token)) {
|
||||
defObj.labelType = token as LabelType;
|
||||
} else if (["single", "multi"].includes(token)) {
|
||||
defObj.multiplicity = token as Multiplicity;
|
||||
|
||||
54
apps/client/src/services/render.ts
Normal file
54
apps/client/src/services/render.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { h, VNode } from "preact";
|
||||
|
||||
import type FNote from "../entities/fnote.js";
|
||||
import { renderReactWidgetAtElement } from "../widgets/react/react_utils.jsx";
|
||||
import bundleService, { type Bundle } from "./bundle.js";
|
||||
import froca from "./froca.js";
|
||||
import server from "./server.js";
|
||||
|
||||
async function render(note: FNote, $el: JQuery<HTMLElement>) {
|
||||
const relations = note.getRelations("renderNote");
|
||||
const renderNoteIds = relations.map((rel) => rel.value).filter((noteId) => noteId);
|
||||
|
||||
$el.empty().toggle(renderNoteIds.length > 0);
|
||||
|
||||
for (const renderNoteId of renderNoteIds) {
|
||||
const bundle = await server.post<Bundle>(`script/bundle/${renderNoteId}`);
|
||||
|
||||
const $scriptContainer = $("<div>");
|
||||
$el.append($scriptContainer);
|
||||
|
||||
$scriptContainer.append(bundle.html);
|
||||
|
||||
// async so that scripts cannot block trilium execution
|
||||
bundleService.executeBundle(bundle, note, $scriptContainer).then(result => {
|
||||
// Render JSX
|
||||
if (bundle.html === "") {
|
||||
renderIfJsx(bundle, result, $el);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return renderNoteIds.length > 0;
|
||||
}
|
||||
|
||||
async function renderIfJsx(bundle: Bundle, result: unknown, $el: JQuery<HTMLElement>) {
|
||||
// Ensure the root script note is actually a JSX.
|
||||
const rootScriptNoteId = await froca.getNote(bundle.noteId);
|
||||
if (rootScriptNoteId?.mime !== "text/jsx") return;
|
||||
|
||||
// Ensure the output is a valid el.
|
||||
if (typeof result !== "function") return;
|
||||
|
||||
// Obtain the parent component.
|
||||
const closestComponent = glob.getComponentByEl($el.closest(".component")[0]);
|
||||
if (!closestComponent) return;
|
||||
|
||||
// Render the element.
|
||||
const el = h(result as () => VNode, {});
|
||||
renderReactWidgetAtElement(closestComponent, el, $el[0]);
|
||||
}
|
||||
|
||||
export default {
|
||||
render
|
||||
};
|
||||
@@ -1,86 +0,0 @@
|
||||
import { Component, h, VNode } from "preact";
|
||||
|
||||
import type FNote from "../entities/fnote.js";
|
||||
import { renderReactWidgetAtElement } from "../widgets/react/react_utils.jsx";
|
||||
import { type Bundle, executeBundleWithoutErrorHandling } from "./bundle.js";
|
||||
import froca from "./froca.js";
|
||||
import server from "./server.js";
|
||||
|
||||
type ErrorHandler = (e: unknown) => void;
|
||||
|
||||
async function render(note: FNote, $el: JQuery<HTMLElement>, onError?: ErrorHandler) {
|
||||
const relations = note.getRelations("renderNote");
|
||||
const renderNoteIds = relations.map((rel) => rel.value).filter((noteId) => noteId);
|
||||
|
||||
$el.empty().toggle(renderNoteIds.length > 0);
|
||||
|
||||
try {
|
||||
for (const renderNoteId of renderNoteIds) {
|
||||
const bundle = await server.postWithSilentInternalServerError<Bundle>(`script/bundle/${renderNoteId}`);
|
||||
|
||||
const $scriptContainer = $("<div>");
|
||||
$el.append($scriptContainer);
|
||||
|
||||
$scriptContainer.append(bundle.html);
|
||||
|
||||
// async so that scripts cannot block trilium execution
|
||||
executeBundleWithoutErrorHandling(bundle, note, $scriptContainer)
|
||||
.catch(onError)
|
||||
.then(result => {
|
||||
// Render JSX
|
||||
if (bundle.html === "") {
|
||||
renderIfJsx(bundle, result, $el, onError).catch(onError);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return renderNoteIds.length > 0;
|
||||
} catch (e) {
|
||||
if (typeof e === "string" && e.startsWith("{") && e.endsWith("}")) {
|
||||
try {
|
||||
onError?.(JSON.parse(e));
|
||||
} catch (e) {
|
||||
onError?.(e);
|
||||
}
|
||||
} else {
|
||||
onError?.(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function renderIfJsx(bundle: Bundle, result: unknown, $el: JQuery<HTMLElement>, onError?: ErrorHandler) {
|
||||
// Ensure the root script note is actually a JSX.
|
||||
const rootScriptNoteId = await froca.getNote(bundle.noteId);
|
||||
if (rootScriptNoteId?.mime !== "text/jsx") return;
|
||||
|
||||
// Ensure the output is a valid el.
|
||||
if (typeof result !== "function") return;
|
||||
|
||||
// Obtain the parent component.
|
||||
const closestComponent = glob.getComponentByEl($el.closest(".component")[0]);
|
||||
if (!closestComponent) return;
|
||||
|
||||
// Render the element.
|
||||
const UserErrorBoundary = class UserErrorBoundary extends Component {
|
||||
constructor(props: object) {
|
||||
super(props);
|
||||
this.state = { error: null };
|
||||
}
|
||||
|
||||
componentDidCatch(error: unknown) {
|
||||
onError?.(error);
|
||||
this.setState({ error });
|
||||
}
|
||||
|
||||
render() {
|
||||
if ("error" in this.state && this.state?.error) return null;
|
||||
return this.props.children;
|
||||
}
|
||||
};
|
||||
const el = h(UserErrorBoundary, {}, h(result as () => VNode, {}));
|
||||
renderReactWidgetAtElement(closestComponent, el, $el[0]);
|
||||
}
|
||||
|
||||
export default {
|
||||
render
|
||||
};
|
||||
@@ -73,10 +73,6 @@ async function post<T>(url: string, data?: unknown, componentId?: string) {
|
||||
return await call<T>("POST", url, componentId, { data });
|
||||
}
|
||||
|
||||
async function postWithSilentInternalServerError<T>(url: string, data?: unknown, componentId?: string) {
|
||||
return await call<T>("POST", url, componentId, { data, silentInternalServerError: true });
|
||||
}
|
||||
|
||||
async function put<T>(url: string, data?: unknown, componentId?: string) {
|
||||
return await call<T>("PUT", url, componentId, { data });
|
||||
}
|
||||
@@ -89,33 +85,21 @@ async function remove<T>(url: string, componentId?: string) {
|
||||
return await call<T>("DELETE", url, componentId);
|
||||
}
|
||||
|
||||
async function upload(url: string, fileToUpload: File, componentId?: string, method = "PUT") {
|
||||
async function upload(url: string, fileToUpload: File, componentId?: string) {
|
||||
const formData = new FormData();
|
||||
formData.append("upload", fileToUpload);
|
||||
|
||||
const doUpload = async () => $.ajax({
|
||||
return await $.ajax({
|
||||
url: window.glob.baseApiUrl + url,
|
||||
headers: await getHeaders(componentId ? {
|
||||
"trilium-component-id": componentId
|
||||
} : undefined),
|
||||
data: formData,
|
||||
type: method,
|
||||
type: "PUT",
|
||||
timeout: 60 * 60 * 1000,
|
||||
contentType: false, // NEEDED, DON'T REMOVE THIS
|
||||
processData: false // NEEDED, DON'T REMOVE THIS
|
||||
});
|
||||
|
||||
try {
|
||||
return await doUpload();
|
||||
} catch (e: unknown) {
|
||||
// jQuery rejects with the jqXHR object
|
||||
const jqXhr = e as JQuery.jqXHR;
|
||||
if (jqXhr?.status && isCsrfError(jqXhr.status, jqXhr.responseText)) {
|
||||
await refreshCsrfToken();
|
||||
return await doUpload();
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
let idCounter = 1;
|
||||
@@ -124,55 +108,11 @@ const idToRequestMap: Record<string, RequestData> = {};
|
||||
|
||||
let maxKnownEntityChangeId = 0;
|
||||
|
||||
let csrfRefreshInProgress: Promise<void> | null = null;
|
||||
|
||||
/**
|
||||
* Re-fetches /bootstrap to obtain a fresh CSRF token. This is needed when the
|
||||
* server session expires (e.g. mobile tab backgrounded for a long time) and the
|
||||
* existing CSRF token is no longer valid.
|
||||
*
|
||||
* Coalesces concurrent calls so only one bootstrap request is in-flight at a time.
|
||||
*/
|
||||
async function refreshCsrfToken(): Promise<void> {
|
||||
if (csrfRefreshInProgress) {
|
||||
return csrfRefreshInProgress;
|
||||
}
|
||||
|
||||
csrfRefreshInProgress = (async () => {
|
||||
try {
|
||||
const response = await fetch(`./bootstrap${window.location.search}`, { cache: "no-store" });
|
||||
if (response.ok) {
|
||||
const json = await response.json();
|
||||
glob.csrfToken = json.csrfToken;
|
||||
}
|
||||
} finally {
|
||||
csrfRefreshInProgress = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return csrfRefreshInProgress;
|
||||
}
|
||||
|
||||
function isCsrfError(status: number, responseText: string): boolean {
|
||||
if (status !== 403) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const body = JSON.parse(responseText);
|
||||
return body.message === "Invalid CSRF token";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
interface CallOptions {
|
||||
data?: unknown;
|
||||
silentNotFound?: boolean;
|
||||
silentInternalServerError?: boolean;
|
||||
// If `true`, the value will be returned as a string instead of a JavaScript object if JSON, XMLDocument if XML, etc.
|
||||
raw?: boolean;
|
||||
/** Used internally to prevent infinite retry loops on CSRF refresh. */
|
||||
csrfRetried?: boolean;
|
||||
}
|
||||
|
||||
async function call<T>(method: string, url: string, componentId?: string, options: CallOptions = {}) {
|
||||
@@ -203,7 +143,7 @@ async function call<T>(method: string, url: string, componentId?: string, option
|
||||
});
|
||||
})) as any;
|
||||
} else {
|
||||
resp = await ajax(url, method, data, headers, options);
|
||||
resp = await ajax(url, method, data, headers, !!options.silentNotFound, options.raw);
|
||||
}
|
||||
|
||||
const maxEntityChangeIdStr = resp.headers["trilium-max-entity-change-id"];
|
||||
@@ -215,14 +155,17 @@ async function call<T>(method: string, url: string, componentId?: string, option
|
||||
return resp.body as T;
|
||||
}
|
||||
|
||||
function ajax(url: string, method: string, data: unknown, headers: Headers, opts: CallOptions): Promise<Response> {
|
||||
/**
|
||||
* @param raw if `true`, the value will be returned as a string instead of a JavaScript object if JSON, XMLDocument if XML, etc.
|
||||
*/
|
||||
function ajax(url: string, method: string, data: unknown, headers: Headers, silentNotFound: boolean, raw?: boolean): Promise<Response> {
|
||||
return new Promise((res, rej) => {
|
||||
const options: JQueryAjaxSettings = {
|
||||
url: window.glob.baseApiUrl + url,
|
||||
type: method,
|
||||
headers,
|
||||
timeout: 60000,
|
||||
success: (body, _textStatus, jqXhr) => {
|
||||
success: (body, textStatus, jqXhr) => {
|
||||
const respHeaders: Headers = {};
|
||||
|
||||
jqXhr
|
||||
@@ -247,27 +190,7 @@ function ajax(url: string, method: string, data: unknown, headers: Headers, opts
|
||||
// don't report requests that are rejected by the browser, usually when the user is refreshing or going to a different page.
|
||||
rej("rejected by browser");
|
||||
return;
|
||||
}
|
||||
|
||||
// If the CSRF token is stale (e.g. session expired while tab was backgrounded),
|
||||
// refresh it and retry the request once.
|
||||
if (!opts.csrfRetried && isCsrfError(jqXhr.status, jqXhr.responseText)) {
|
||||
try {
|
||||
await refreshCsrfToken();
|
||||
// Rebuild headers so the fresh glob.csrfToken is picked up
|
||||
const retryHeaders = await getHeaders({ "trilium-component-id": headers["trilium-component-id"] });
|
||||
const retryResult = await ajax(url, method, data, retryHeaders, { ...opts, csrfRetried: true });
|
||||
res(retryResult);
|
||||
return;
|
||||
} catch (retryErr) {
|
||||
rej(retryErr);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (opts.silentNotFound && jqXhr.status === 404) {
|
||||
// report nothing
|
||||
} else if (opts.silentInternalServerError && jqXhr.status === 500) {
|
||||
} else if (silentNotFound && jqXhr.status === 404) {
|
||||
// report nothing
|
||||
} else {
|
||||
await reportError(method, url, jqXhr.status, jqXhr.responseText);
|
||||
@@ -277,7 +200,7 @@ function ajax(url: string, method: string, data: unknown, headers: Headers, opts
|
||||
}
|
||||
};
|
||||
|
||||
if (opts.raw) {
|
||||
if (raw) {
|
||||
options.dataType = "text";
|
||||
}
|
||||
|
||||
@@ -376,7 +299,6 @@ export default {
|
||||
get,
|
||||
getWithSilentNotFound,
|
||||
post,
|
||||
postWithSilentInternalServerError,
|
||||
put,
|
||||
patch,
|
||||
remove,
|
||||
|
||||
@@ -12,7 +12,6 @@ export default class SpacedUpdate {
|
||||
private updateInterval: number;
|
||||
private changeForbidden?: boolean;
|
||||
private stateCallback?: StateCallback;
|
||||
private lastState: SaveState = "saved";
|
||||
|
||||
constructor(updater: Callback, updateInterval = 1000, stateCallback?: StateCallback) {
|
||||
this.updater = updater;
|
||||
@@ -25,7 +24,7 @@ export default class SpacedUpdate {
|
||||
scheduleUpdate() {
|
||||
if (!this.changeForbidden) {
|
||||
this.changed = true;
|
||||
this.onStateChanged("unsaved");
|
||||
this.stateCallback?.("unsaved");
|
||||
setTimeout(() => this.triggerUpdate());
|
||||
}
|
||||
}
|
||||
@@ -35,12 +34,12 @@ export default class SpacedUpdate {
|
||||
this.changed = false; // optimistic...
|
||||
|
||||
try {
|
||||
this.onStateChanged("saving");
|
||||
this.stateCallback?.("saving");
|
||||
await this.updater();
|
||||
this.onStateChanged("saved");
|
||||
this.stateCallback?.("saved");
|
||||
} catch (e) {
|
||||
this.changed = true;
|
||||
this.onStateChanged("error");
|
||||
this.stateCallback?.("error");
|
||||
logError(getErrorMessage(e));
|
||||
throw e;
|
||||
}
|
||||
@@ -77,13 +76,13 @@ export default class SpacedUpdate {
|
||||
}
|
||||
|
||||
if (Date.now() - this.lastUpdated > this.updateInterval) {
|
||||
this.onStateChanged("saving");
|
||||
this.stateCallback?.("saving");
|
||||
try {
|
||||
await this.updater();
|
||||
this.onStateChanged("saved");
|
||||
this.stateCallback?.("saved");
|
||||
this.changed = false;
|
||||
} catch (e) {
|
||||
this.onStateChanged("error");
|
||||
this.stateCallback?.("error");
|
||||
logError(getErrorMessage(e));
|
||||
}
|
||||
this.lastUpdated = Date.now();
|
||||
@@ -93,13 +92,6 @@ export default class SpacedUpdate {
|
||||
}
|
||||
}
|
||||
|
||||
onStateChanged(state: SaveState) {
|
||||
if (state === this.lastState) return;
|
||||
|
||||
this.stateCallback?.(state);
|
||||
this.lastState = state;
|
||||
}
|
||||
|
||||
async allowUpdateWithoutChange(callback: Callback) {
|
||||
this.changeForbidden = true;
|
||||
|
||||
|
||||
@@ -14,9 +14,7 @@ export function reloadFrontendApp(reason?: string) {
|
||||
}
|
||||
|
||||
if (isElectron()) {
|
||||
for (const window of dynamicRequire("@electron/remote").BrowserWindow.getAllWindows()) {
|
||||
window.reload();
|
||||
}
|
||||
dynamicRequire("@electron/remote").BrowserWindow.getFocusedWindow()?.reload();
|
||||
} else {
|
||||
window.location.reload();
|
||||
}
|
||||
@@ -135,8 +133,6 @@ export function isElectron() {
|
||||
return !!(window && window.process && window.process.type);
|
||||
}
|
||||
|
||||
export const isStandalone = window.glob.isStandalone;
|
||||
|
||||
/**
|
||||
* Returns `true` if the client is running as a PWA, otherwise `false`.
|
||||
*/
|
||||
@@ -818,7 +814,7 @@ function compareVersions(v1: string, v2: string): number {
|
||||
/**
|
||||
* Compares two semantic version strings and returns `true` if the latest version is greater than the current version.
|
||||
*/
|
||||
export function isUpdateAvailable(latestVersion: string | null | undefined, currentVersion: string): boolean {
|
||||
function isUpdateAvailable(latestVersion: string | null | undefined, currentVersion: string): boolean {
|
||||
if (!latestVersion) {
|
||||
return false;
|
||||
}
|
||||
@@ -917,6 +913,10 @@ export function handleRightToLeftPlacement<T extends string>(placement: T) {
|
||||
return placement;
|
||||
}
|
||||
|
||||
export function boolToString(value: boolean) {
|
||||
return value ? "true" : "false";
|
||||
}
|
||||
|
||||
export default {
|
||||
reloadFrontendApp,
|
||||
restartDesktopApp,
|
||||
|
||||
@@ -57,49 +57,6 @@ export function unsubscribeToMessage(messageHandler: MessageHandler) {
|
||||
messageHandlers = messageHandlers.filter(handler => handler !== messageHandler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch a message to all handlers and process it.
|
||||
* This is the main entry point for incoming messages from any provider
|
||||
* (WebSocket, Worker, etc.)
|
||||
*/
|
||||
export async function dispatchMessage(message: WebSocketMessage) {
|
||||
// Notify all subscribers
|
||||
for (const messageHandler of messageHandlers) {
|
||||
messageHandler(message);
|
||||
}
|
||||
|
||||
// Use string type for flexibility - server sends more message types than are typed
|
||||
const messageType = message.type as string;
|
||||
const msg = message as any;
|
||||
|
||||
// Process the message
|
||||
if (messageType === "ping") {
|
||||
lastPingTs = Date.now();
|
||||
} else if (messageType === "reload-frontend") {
|
||||
utils.reloadFrontendApp("received request from backend to reload frontend");
|
||||
} else if (messageType === "frontend-update") {
|
||||
await executeFrontendUpdate(msg.data.entityChanges);
|
||||
} else if (messageType === "sync-hash-check-failed") {
|
||||
toastService.showError(t("ws.sync-check-failed"), 60000);
|
||||
} else if (messageType === "consistency-checks-failed") {
|
||||
toastService.showError(t("ws.consistency-checks-failed"), 50 * 60000);
|
||||
} else if (messageType === "api-log-messages") {
|
||||
appContext.triggerEvent("apiLogMessages", { noteId: msg.noteId, messages: msg.messages });
|
||||
} else if (messageType === "toast") {
|
||||
toastService.showMessage(msg.message);
|
||||
} else if (messageType === "execute-script") {
|
||||
// TODO: Remove after porting the file
|
||||
// @ts-ignore
|
||||
const bundleService = (await import("./bundle.js")).default as any;
|
||||
// TODO: Remove after porting the file
|
||||
// @ts-ignore
|
||||
const froca = (await import("./froca.js")).default as any;
|
||||
const originEntity = msg.originEntityId ? await froca.getNote(msg.originEntityId) : null;
|
||||
|
||||
bundleService.getAndExecuteBundle(msg.currentNoteId, originEntity, msg.script, msg.params);
|
||||
}
|
||||
}
|
||||
|
||||
// used to serialize frontend update operations
|
||||
let consumeQueuePromise: Promise<void> | null = null;
|
||||
|
||||
@@ -155,13 +112,81 @@ async function executeFrontendUpdate(entityChanges: EntityChange[]) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* WebSocket message handler - parses the event and dispatches to generic handler.
|
||||
* This is only used in WebSocket mode (not standalone).
|
||||
*/
|
||||
async function handleWebSocketMessage(event: MessageEvent<string>) {
|
||||
const message = JSON.parse(event.data) as WebSocketMessage;
|
||||
await dispatchMessage(message);
|
||||
async function handleMessage(event: MessageEvent<any>) {
|
||||
const message = JSON.parse(event.data);
|
||||
|
||||
for (const messageHandler of messageHandlers) {
|
||||
messageHandler(message);
|
||||
}
|
||||
|
||||
if (message.type === "ping") {
|
||||
lastPingTs = Date.now();
|
||||
} else if (message.type === "reload-frontend") {
|
||||
utils.reloadFrontendApp("received request from backend to reload frontend");
|
||||
} else if (message.type === "frontend-update") {
|
||||
await executeFrontendUpdate(message.data.entityChanges);
|
||||
} else if (message.type === "sync-hash-check-failed") {
|
||||
toastService.showError(t("ws.sync-check-failed"), 60000);
|
||||
} else if (message.type === "consistency-checks-failed") {
|
||||
toastService.showError(t("ws.consistency-checks-failed"), 50 * 60000);
|
||||
} else if (message.type === "api-log-messages") {
|
||||
appContext.triggerEvent("apiLogMessages", { noteId: message.noteId, messages: message.messages });
|
||||
} else if (message.type === "toast") {
|
||||
toastService.showMessage(message.message);
|
||||
} else if (message.type === "llm-stream") {
|
||||
// ENHANCED LOGGING FOR DEBUGGING
|
||||
console.log(`[WS-CLIENT] >>> RECEIVED LLM STREAM MESSAGE <<<`);
|
||||
console.log(`[WS-CLIENT] Message details: sessionId=${message.sessionId}, hasContent=${!!message.content}, contentLength=${message.content ? message.content.length : 0}, hasThinking=${!!message.thinking}, hasToolExecution=${!!message.toolExecution}, isDone=${!!message.done}`);
|
||||
|
||||
if (message.content) {
|
||||
console.log(`[WS-CLIENT] CONTENT PREVIEW: "${message.content.substring(0, 50)}..."`);
|
||||
}
|
||||
|
||||
// Create the event with detailed logging
|
||||
console.log(`[WS-CLIENT] Creating CustomEvent 'llm-stream-message'`);
|
||||
const llmStreamEvent = new CustomEvent('llm-stream-message', { detail: message });
|
||||
|
||||
// Dispatch to multiple targets to ensure delivery
|
||||
try {
|
||||
console.log(`[WS-CLIENT] Dispatching event to window`);
|
||||
window.dispatchEvent(llmStreamEvent);
|
||||
console.log(`[WS-CLIENT] Event dispatched to window`);
|
||||
|
||||
// Also try document for completeness
|
||||
console.log(`[WS-CLIENT] Dispatching event to document`);
|
||||
document.dispatchEvent(new CustomEvent('llm-stream-message', { detail: message }));
|
||||
console.log(`[WS-CLIENT] Event dispatched to document`);
|
||||
} catch (err) {
|
||||
console.error(`[WS-CLIENT] Error dispatching event:`, err);
|
||||
}
|
||||
|
||||
// Debug current listeners (though we can't directly check for specific event listeners)
|
||||
console.log(`[WS-CLIENT] Active event listeners should receive this message now`);
|
||||
|
||||
// Detailed logging based on message type
|
||||
if (message.content) {
|
||||
console.log(`[WS-CLIENT] Content message: ${message.content.length} chars`);
|
||||
} else if (message.thinking) {
|
||||
console.log(`[WS-CLIENT] Thinking update: "${message.thinking}"`);
|
||||
} else if (message.toolExecution) {
|
||||
console.log(`[WS-CLIENT] Tool execution: action=${message.toolExecution.action}, tool=${message.toolExecution.tool || 'unknown'}`);
|
||||
if (message.toolExecution.result) {
|
||||
console.log(`[WS-CLIENT] Tool result preview: "${String(message.toolExecution.result).substring(0, 50)}..."`);
|
||||
}
|
||||
} else if (message.done) {
|
||||
console.log(`[WS-CLIENT] Completion signal received`);
|
||||
}
|
||||
} else if (message.type === "execute-script") {
|
||||
// TODO: Remove after porting the file
|
||||
// @ts-ignore
|
||||
const bundleService = (await import("./bundle.js")).default as any;
|
||||
// TODO: Remove after porting the file
|
||||
// @ts-ignore
|
||||
const froca = (await import("./froca.js")).default as any;
|
||||
const originEntity = message.originEntityId ? await froca.getNote(message.originEntityId) : null;
|
||||
|
||||
bundleService.getAndExecuteBundle(message.currentNoteId, originEntity, message.script, message.params);
|
||||
}
|
||||
}
|
||||
|
||||
let entityChangeIdReachedListeners: {
|
||||
@@ -246,18 +271,13 @@ function connectWebSocket() {
|
||||
// use wss for secure messaging
|
||||
const ws = new WebSocket(webSocketUri);
|
||||
ws.onopen = () => console.debug(utils.now(), `Connected to server ${webSocketUri} with WebSocket`);
|
||||
ws.onmessage = handleWebSocketMessage;
|
||||
ws.onmessage = handleMessage;
|
||||
// we're not handling ws.onclose here because reconnection is done in sendPing()
|
||||
|
||||
return ws;
|
||||
}
|
||||
|
||||
async function sendPing() {
|
||||
if (!ws) {
|
||||
// In standalone mode, there's no WebSocket — nothing to ping.
|
||||
return;
|
||||
}
|
||||
|
||||
if (Date.now() - lastPingTs > 30000) {
|
||||
console.warn(utils.now(), "Lost websocket connection to the backend");
|
||||
toast.showPersistent({
|
||||
@@ -286,16 +306,6 @@ async function sendPing() {
|
||||
setTimeout(() => {
|
||||
if (glob.device === "print") return;
|
||||
|
||||
if (glob.isStandalone) {
|
||||
// In standalone mode, listen for messages from the local worker via custom event
|
||||
window.addEventListener("trilium:ws-message", ((event: CustomEvent<WebSocketMessage>) => {
|
||||
dispatchMessage(event.detail);
|
||||
}) as EventListener);
|
||||
console.debug(utils.now(), "Standalone mode: listening for worker messages");
|
||||
return;
|
||||
}
|
||||
|
||||
// Normal mode: use WebSocket
|
||||
ws = connectWebSocket();
|
||||
|
||||
lastPingTs = Date.now();
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
body.setup {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
&>div {
|
||||
background: var(--left-pane-background-color);
|
||||
padding: 2em;
|
||||
width: 600px;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
.setup-container {
|
||||
background-color: var(--main-background-color);
|
||||
border-radius: 16px;
|
||||
padding: 2em;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
height: 550px;
|
||||
width: 700px;
|
||||
box-shadow: 0px 10px 20px rgba(0, 0, 0, var(--dropdown-shadow-opacity));
|
||||
|
||||
.setup-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
|
||||
.setup-option-card {
|
||||
padding: 1.5em;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--card-background-hover-color);
|
||||
filter: contrast(105%);
|
||||
transition: background-color .2s ease-out;
|
||||
}
|
||||
|
||||
.tn-icon {
|
||||
font-size: 2.5em;
|
||||
color: var(--muted-text-color);
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.5em;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
p:last-of-type {
|
||||
margin-bottom: 0;
|
||||
color: var(--muted-text-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
|
||||
>main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
>footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
border-top: 1px solid var(--main-border-color);
|
||||
padding-top: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
flex-grow: 1;
|
||||
gap: 1rem;
|
||||
width: 80%;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.form-item-with-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
|
||||
.tn-icon {
|
||||
font-size: 1.5em;
|
||||
color: var(--muted-text-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
128
apps/client/src/setup.ts
Normal file
128
apps/client/src/setup.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import "jquery";
|
||||
import utils from "./services/utils.js";
|
||||
import ko from "knockout";
|
||||
|
||||
// TriliumNextTODO: properly make use of below types
|
||||
// type SetupModelSetupType = "new-document" | "sync-from-desktop" | "sync-from-server" | "";
|
||||
// type SetupModelStep = "sync-in-progress" | "setup-type" | "new-document-in-progress" | "sync-from-desktop";
|
||||
|
||||
class SetupModel {
|
||||
syncInProgress: boolean;
|
||||
step: ko.Observable<string>;
|
||||
setupType: ko.Observable<string>;
|
||||
setupNewDocument: ko.Observable<boolean>;
|
||||
setupSyncFromDesktop: ko.Observable<boolean>;
|
||||
setupSyncFromServer: ko.Observable<boolean>;
|
||||
syncServerHost: ko.Observable<string | undefined>;
|
||||
syncProxy: ko.Observable<string | undefined>;
|
||||
password: ko.Observable<string | undefined>;
|
||||
|
||||
constructor(syncInProgress: boolean) {
|
||||
this.syncInProgress = syncInProgress;
|
||||
this.step = ko.observable(syncInProgress ? "sync-in-progress" : "setup-type");
|
||||
this.setupType = ko.observable("");
|
||||
this.setupNewDocument = ko.observable(false);
|
||||
this.setupSyncFromDesktop = ko.observable(false);
|
||||
this.setupSyncFromServer = ko.observable(false);
|
||||
this.syncServerHost = ko.observable();
|
||||
this.syncProxy = ko.observable();
|
||||
this.password = ko.observable();
|
||||
|
||||
if (this.syncInProgress) {
|
||||
setInterval(checkOutstandingSyncs, 1000);
|
||||
}
|
||||
const serverAddress = `${location.protocol}//${location.host}`;
|
||||
$("#current-host").html(serverAddress);
|
||||
}
|
||||
|
||||
// this is called in setup.ejs
|
||||
setupTypeSelected() {
|
||||
return !!this.setupType();
|
||||
}
|
||||
|
||||
selectSetupType() {
|
||||
if (this.setupType() === "new-document") {
|
||||
this.step("new-document-in-progress");
|
||||
|
||||
$.post("api/setup/new-document").then(() => {
|
||||
window.location.replace("./setup");
|
||||
});
|
||||
} else {
|
||||
this.step(this.setupType());
|
||||
}
|
||||
}
|
||||
|
||||
back() {
|
||||
this.step("setup-type");
|
||||
this.setupType("");
|
||||
}
|
||||
|
||||
async finish() {
|
||||
const syncServerHost = this.syncServerHost();
|
||||
const syncProxy = this.syncProxy();
|
||||
const password = this.password();
|
||||
|
||||
if (!syncServerHost) {
|
||||
showAlert("Trilium server address can't be empty");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!password) {
|
||||
showAlert("Password can't be empty");
|
||||
return;
|
||||
}
|
||||
|
||||
// not using server.js because it loads too many dependencies
|
||||
const resp = await $.post("api/setup/sync-from-server", {
|
||||
syncServerHost: syncServerHost,
|
||||
syncProxy: syncProxy,
|
||||
password: password
|
||||
});
|
||||
|
||||
if (resp.result === "success") {
|
||||
this.step("sync-in-progress");
|
||||
|
||||
setInterval(checkOutstandingSyncs, 1000);
|
||||
|
||||
hideAlert();
|
||||
} else {
|
||||
showAlert(`Sync setup failed: ${resp.error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function checkOutstandingSyncs() {
|
||||
const { outstandingPullCount, initialized } = await $.get("api/sync/stats");
|
||||
|
||||
if (initialized) {
|
||||
if (utils.isElectron()) {
|
||||
const remote = utils.dynamicRequire("@electron/remote");
|
||||
remote.app.relaunch();
|
||||
remote.app.exit(0);
|
||||
} else {
|
||||
utils.reloadFrontendApp();
|
||||
}
|
||||
} else {
|
||||
$("#outstanding-syncs").html(outstandingPullCount);
|
||||
}
|
||||
}
|
||||
|
||||
function showAlert(message: string) {
|
||||
$("#alert").text(message);
|
||||
$("#alert").show();
|
||||
}
|
||||
|
||||
function hideAlert() {
|
||||
$("#alert").hide();
|
||||
}
|
||||
|
||||
function getSyncInProgress() {
|
||||
const el = document.getElementById("syncInProgress");
|
||||
if (!el || !(el instanceof HTMLMetaElement)) return false;
|
||||
return !!parseInt(el.content);
|
||||
}
|
||||
|
||||
addEventListener("DOMContentLoaded", (event) => {
|
||||
ko.applyBindings(new SetupModel(getSyncInProgress()), document.getElementById("setup-dialog"));
|
||||
$("#setup-dialog").show();
|
||||
});
|
||||
@@ -1,129 +0,0 @@
|
||||
import "./setup.css";
|
||||
|
||||
import { ComponentChildren, render } from "preact";
|
||||
import { useState } from "preact/hooks";
|
||||
|
||||
import { initLocale, t } from "./services/i18n";
|
||||
import server from "./services/server";
|
||||
import Button from "./widgets/react/Button";
|
||||
import { CardFrame } from "./widgets/react/Card";
|
||||
import FormTextBox from "./widgets/react/FormTextBox";
|
||||
import Icon from "./widgets/react/Icon";
|
||||
|
||||
async function main() {
|
||||
await initLocale();
|
||||
|
||||
const bodyWrapper = document.createElement("div");
|
||||
document.body.classList.add("setup");
|
||||
render(<App />, bodyWrapper);
|
||||
document.body.replaceChildren(bodyWrapper);
|
||||
}
|
||||
|
||||
type State = "firstOptions" | "syncFromDesktop" | "syncFromServer";
|
||||
|
||||
function App() {
|
||||
const [ state, setState ] = useState<State>("syncFromServer");
|
||||
|
||||
return (
|
||||
<div class="setup-container">
|
||||
{state === "firstOptions" && <SetupOptions setState={setState} />}
|
||||
{state === "syncFromServer" && <SyncFromServer setState={setState} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SetupOptions({ setState }: { setState: (state: State) => void }) {
|
||||
return (
|
||||
<div class="page setup-options-container">
|
||||
<h1>{t("setup.heading")}</h1>
|
||||
|
||||
<div class="setup-options">
|
||||
<SetupOptionCard
|
||||
icon="bx bx-file-blank"
|
||||
title={t("setup.new-document")}
|
||||
description={t("setup.new-document-description")}
|
||||
onClick={async () => {
|
||||
await server.post("setup/new-document");
|
||||
|
||||
// After creating a new document, we can just reload the page to load it.
|
||||
location.reload();
|
||||
}}
|
||||
/>
|
||||
|
||||
<SetupOptionCard
|
||||
icon="bx bx-server"
|
||||
title={t("setup.sync-from-server")}
|
||||
description={t("setup.sync-from-server-description")}
|
||||
onClick={() => setState("syncFromServer")}
|
||||
/>
|
||||
|
||||
<SetupOptionCard
|
||||
icon="bx bx-desktop"
|
||||
title={t("setup.sync-from-desktop")}
|
||||
description={t("setup.sync-from-desktop-description")}
|
||||
onClick={() => setState("syncFromDesktop")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SyncFromServer({ setState }: { setState: (state: State) => void }) {
|
||||
const [serverUrl, setServerUrl] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
|
||||
function handleFinishSetup() {
|
||||
server.post("setup/sync-from-server", {
|
||||
syncServerHost: serverUrl,
|
||||
password
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="page sync-from-server">
|
||||
<h1>{t("setup.sync-from-server-page-title")}</h1>
|
||||
<p>{t("setup.sync-from-server-page-description")}</p>
|
||||
|
||||
<main>
|
||||
<form>
|
||||
<FormItemWithIcon icon="bx bx-server">
|
||||
<FormTextBox placeholder="https://example.com" currentValue={serverUrl} onChange={setServerUrl} />
|
||||
</FormItemWithIcon>
|
||||
|
||||
<FormItemWithIcon icon="bx bx-lock">
|
||||
<FormTextBox placeholder={t("setup.password-placeholder")} type="password" currentValue={password} onChange={setPassword} />
|
||||
</FormItemWithIcon>
|
||||
</form>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<Button text={t("setup.button-back")} onClick={() => setState("firstOptions")} kind="lowProfile" />
|
||||
<Button text={t("setup.button-finish-setup")} kind="primary" onClick={handleFinishSetup} />
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FormItemWithIcon({ icon, children }: { icon: string; children: ComponentChildren }) {
|
||||
return (
|
||||
<div class="form-item-with-icon">
|
||||
<Icon icon={icon} />
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SetupOptionCard({ title, description, icon, onClick }: { title: string; description: string, icon: string, onClick?: () => void }) {
|
||||
return (
|
||||
<CardFrame className="setup-option-card" onClick={onClick}>
|
||||
<Icon icon={icon} />
|
||||
|
||||
<div>
|
||||
<h3>{title}</h3>
|
||||
<p>{description}</p>
|
||||
</div>
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -1,204 +0,0 @@
|
||||
import "jquery";
|
||||
|
||||
import utils from "./services/utils.js";
|
||||
|
||||
type SetupStep = "sync-in-progress" | "setup-type" | "new-document-in-progress" | "sync-from-desktop" | "sync-from-server";
|
||||
type SetupType = "new-document" | "sync-from-desktop" | "sync-from-server" | "";
|
||||
|
||||
class SetupController {
|
||||
private step: SetupStep;
|
||||
private setupType: SetupType = "";
|
||||
private syncPollIntervalId: number | null = null;
|
||||
private rootNode: HTMLElement;
|
||||
private setupTypeForm: HTMLFormElement;
|
||||
private syncFromServerForm: HTMLFormElement;
|
||||
private setupTypeNextButton: HTMLButtonElement;
|
||||
private setupTypeInputs: HTMLInputElement[];
|
||||
private syncServerHostInput: HTMLInputElement;
|
||||
private syncProxyInput: HTMLInputElement;
|
||||
private passwordInput: HTMLInputElement;
|
||||
private sections: Record<SetupStep, HTMLElement>;
|
||||
|
||||
constructor(rootNode: HTMLElement, syncInProgress: boolean) {
|
||||
this.rootNode = rootNode;
|
||||
this.step = syncInProgress ? "sync-in-progress" : "setup-type";
|
||||
this.setupTypeForm = mustGetElement("setup-type-form", HTMLFormElement);
|
||||
this.syncFromServerForm = mustGetElement("sync-from-server-form", HTMLFormElement);
|
||||
this.setupTypeNextButton = mustGetElement("setup-type-next", HTMLButtonElement);
|
||||
this.setupTypeInputs = Array.from(document.querySelectorAll<HTMLInputElement>("input[name='setup-type']"));
|
||||
this.syncServerHostInput = mustGetElement("sync-server-host", HTMLInputElement);
|
||||
this.syncProxyInput = mustGetElement("sync-proxy", HTMLInputElement);
|
||||
this.passwordInput = mustGetElement("password", HTMLInputElement);
|
||||
this.sections = {
|
||||
"setup-type": mustGetElement("setup-type-section", HTMLElement),
|
||||
"new-document-in-progress": mustGetElement("new-document-in-progress-section", HTMLElement),
|
||||
"sync-from-desktop": mustGetElement("sync-from-desktop-section", HTMLElement),
|
||||
"sync-from-server": mustGetElement("sync-from-server-section", HTMLElement),
|
||||
"sync-in-progress": mustGetElement("sync-in-progress-section", HTMLElement)
|
||||
};
|
||||
}
|
||||
|
||||
init() {
|
||||
this.setupTypeForm.addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
void this.selectSetupType();
|
||||
});
|
||||
|
||||
this.syncFromServerForm.addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
void this.finish();
|
||||
});
|
||||
|
||||
for (const input of this.setupTypeInputs) {
|
||||
input.addEventListener("change", () => {
|
||||
this.setupType = input.value as SetupType;
|
||||
this.render();
|
||||
});
|
||||
}
|
||||
|
||||
for (const backButton of document.querySelectorAll<HTMLElement>("[data-action='back']")) {
|
||||
backButton.addEventListener("click", () => {
|
||||
this.back();
|
||||
});
|
||||
}
|
||||
|
||||
const serverAddress = `${location.protocol}//${location.host}`;
|
||||
$("#current-host").html(serverAddress);
|
||||
|
||||
if (this.step === "sync-in-progress") {
|
||||
this.startSyncPolling();
|
||||
}
|
||||
|
||||
this.render();
|
||||
this.rootNode.style.display = "";
|
||||
}
|
||||
|
||||
private async selectSetupType() {
|
||||
if (this.setupType === "new-document") {
|
||||
this.setStep("new-document-in-progress");
|
||||
|
||||
await $.post("api/setup/new-document");
|
||||
window.location.replace("./setup");
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.setupType) {
|
||||
this.setStep(this.setupType);
|
||||
}
|
||||
}
|
||||
|
||||
private back() {
|
||||
this.setStep("setup-type");
|
||||
this.setupType = "";
|
||||
|
||||
for (const input of this.setupTypeInputs) {
|
||||
input.checked = false;
|
||||
}
|
||||
|
||||
this.render();
|
||||
}
|
||||
|
||||
private async finish() {
|
||||
const syncServerHost = this.syncServerHostInput.value.trim();
|
||||
const syncProxy = this.syncProxyInput.value.trim();
|
||||
const password = this.passwordInput.value;
|
||||
|
||||
if (!syncServerHost) {
|
||||
showAlert("Trilium server address can't be empty");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!password) {
|
||||
showAlert("Password can't be empty");
|
||||
return;
|
||||
}
|
||||
|
||||
// not using server.js because it loads too many dependencies
|
||||
const resp = await $.post("api/setup/sync-from-server", {
|
||||
syncServerHost,
|
||||
syncProxy,
|
||||
password
|
||||
});
|
||||
|
||||
if (resp.result === "success") {
|
||||
hideAlert();
|
||||
this.setStep("sync-in-progress");
|
||||
this.startSyncPolling();
|
||||
} else {
|
||||
showAlert(`Sync setup failed: ${resp.error}`);
|
||||
}
|
||||
}
|
||||
|
||||
private setStep(step: SetupStep) {
|
||||
this.step = step;
|
||||
this.render();
|
||||
}
|
||||
|
||||
private render() {
|
||||
for (const [step, section] of Object.entries(this.sections) as [SetupStep, HTMLElement][]) {
|
||||
section.style.display = step === this.step ? "" : "none";
|
||||
}
|
||||
|
||||
this.setupTypeNextButton.disabled = !this.setupType;
|
||||
}
|
||||
|
||||
private getSelectedSetupType(): SetupType {
|
||||
return (this.setupTypeInputs.find((input) => input.checked)?.value ?? "") as SetupType;
|
||||
}
|
||||
|
||||
private startSyncPolling() {
|
||||
if (this.syncPollIntervalId !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.syncPollIntervalId = window.setInterval(checkOutstandingSyncs, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
async function checkOutstandingSyncs() {
|
||||
const { outstandingPullCount, initialized } = await $.get("api/sync/stats");
|
||||
|
||||
if (initialized) {
|
||||
if (utils.isElectron()) {
|
||||
const remote = utils.dynamicRequire("@electron/remote");
|
||||
remote.app.relaunch();
|
||||
remote.app.exit(0);
|
||||
} else {
|
||||
utils.reloadFrontendApp();
|
||||
}
|
||||
} else {
|
||||
$("#outstanding-syncs").html(outstandingPullCount);
|
||||
}
|
||||
}
|
||||
|
||||
function showAlert(message: string) {
|
||||
$("#alert").text(message);
|
||||
$("#alert").show();
|
||||
}
|
||||
|
||||
function hideAlert() {
|
||||
$("#alert").hide();
|
||||
}
|
||||
|
||||
function getSyncInProgress() {
|
||||
const el = document.getElementById("syncInProgress");
|
||||
if (!el || !(el instanceof HTMLMetaElement)) return false;
|
||||
return !!parseInt(el.content);
|
||||
}
|
||||
|
||||
function mustGetElement<T extends typeof HTMLElement>(id: string, ctor: T): InstanceType<T> {
|
||||
const element = document.getElementById(id);
|
||||
|
||||
if (!element || !(element instanceof ctor)) {
|
||||
throw new Error(`Expected element #${id}`);
|
||||
}
|
||||
|
||||
return element as InstanceType<T>;
|
||||
}
|
||||
|
||||
addEventListener("DOMContentLoaded", (event) => {
|
||||
const rootNode = document.getElementById("setup-dialog");
|
||||
if (!rootNode || !(rootNode instanceof HTMLElement)) return;
|
||||
|
||||
new SetupController(rootNode, getSyncInProgress()).init();
|
||||
});
|
||||
450
apps/client/src/stylesheets/llm_chat.css
Normal file
450
apps/client/src/stylesheets/llm_chat.css
Normal file
@@ -0,0 +1,450 @@
|
||||
/* LLM Chat Panel Styles */
|
||||
.note-context-chat {
|
||||
background-color: var(--main-background-color);
|
||||
}
|
||||
|
||||
/* Message Styling */
|
||||
.chat-message {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.message-avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
font-size: 1.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
background-color: var(--input-background-color);
|
||||
color: var(--cmd-button-icon-color);
|
||||
}
|
||||
|
||||
.assistant-avatar {
|
||||
background-color: var(--subtle-border-color, var(--main-border-color));
|
||||
color: var(--hover-item-text-color);
|
||||
}
|
||||
|
||||
.message-content {
|
||||
max-width: calc(100% - 50px);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
color: var(--main-text-color);
|
||||
}
|
||||
|
||||
.user-content {
|
||||
border-radius: 0.5rem 0.5rem 0 0.5rem !important;
|
||||
background-color: var(--input-background-color) !important;
|
||||
}
|
||||
|
||||
.assistant-content {
|
||||
border-radius: 0.5rem 0.5rem 0.5rem 0 !important;
|
||||
background-color: var(--main-background-color);
|
||||
border: 1px solid var(--subtle-border-color, var(--main-border-color));
|
||||
}
|
||||
|
||||
/* Tool Execution Styling */
|
||||
.tool-execution-info {
|
||||
margin-top: 0.75rem;
|
||||
margin-bottom: 1.5rem;
|
||||
border: 1px solid var(--subtle-border-color);
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
|
||||
background-color: var(--main-background-color);
|
||||
/* Add a subtle transition effect */
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.tool-execution-status {
|
||||
background-color: var(--accented-background-color, rgba(0, 0, 0, 0.03)) !important;
|
||||
border-radius: 0 !important;
|
||||
padding: 0.5rem !important;
|
||||
max-height: 250px !important;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.tool-execution-status .d-flex {
|
||||
border-bottom: 1px solid var(--subtle-border-color);
|
||||
padding-bottom: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.tool-step {
|
||||
padding: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
border-radius: 0.375rem;
|
||||
background-color: var(--main-background-color);
|
||||
border: 1px solid var(--subtle-border-color);
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.tool-step:hover {
|
||||
background-color: rgba(0, 0, 0, 0.01);
|
||||
}
|
||||
|
||||
.tool-step:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Tool step specific styling */
|
||||
.tool-step.executing {
|
||||
background-color: rgba(0, 123, 255, 0.05);
|
||||
border-color: rgba(0, 123, 255, 0.2);
|
||||
}
|
||||
|
||||
.tool-step.result {
|
||||
background-color: rgba(40, 167, 69, 0.05);
|
||||
border-color: rgba(40, 167, 69, 0.2);
|
||||
}
|
||||
|
||||
.tool-step.error {
|
||||
background-color: rgba(220, 53, 69, 0.05);
|
||||
border-color: rgba(220, 53, 69, 0.2);
|
||||
}
|
||||
|
||||
/* Tool result formatting */
|
||||
.tool-result pre {
|
||||
margin: 0.5rem 0;
|
||||
padding: 0.5rem;
|
||||
background-color: rgba(0, 0, 0, 0.03);
|
||||
border-radius: 0.25rem;
|
||||
overflow: auto;
|
||||
max-height: 300px;
|
||||
}
|
||||
|
||||
.tool-result code {
|
||||
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.tool-args code {
|
||||
display: block;
|
||||
padding: 0.5rem;
|
||||
background-color: rgba(0, 0, 0, 0.03);
|
||||
border-radius: 0.25rem;
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.85em;
|
||||
color: var(--muted-text-color);
|
||||
white-space: pre-wrap;
|
||||
overflow: auto;
|
||||
max-height: 100px;
|
||||
}
|
||||
|
||||
/* Tool Execution in Chat Styling */
|
||||
.chat-tool-execution {
|
||||
padding: 0 0 0 36px; /* Aligned with message content, accounting for avatar width */
|
||||
width: 100%;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.tool-execution-container {
|
||||
background-color: var(--accented-background-color, rgba(245, 247, 250, 0.7));
|
||||
border: 1px solid var(--subtle-border-color);
|
||||
border-radius: 0.375rem;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
overflow: hidden;
|
||||
max-width: calc(100% - 20px);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.tool-execution-container.collapsed {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tool-execution-header {
|
||||
background-color: var(--main-background-color);
|
||||
border-bottom: 1px solid var(--subtle-border-color);
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--muted-text-color);
|
||||
font-weight: 500;
|
||||
padding: 0.6rem 0.8rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.tool-execution-header:hover {
|
||||
background-color: var(--hover-item-background-color, rgba(0, 0, 0, 0.03));
|
||||
}
|
||||
|
||||
.tool-execution-toggle {
|
||||
color: var(--muted-text-color) !important;
|
||||
background: transparent !important;
|
||||
padding: 0.2rem 0.4rem !important;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.tool-execution-toggle:hover {
|
||||
color: var(--main-text-color) !important;
|
||||
}
|
||||
|
||||
.tool-execution-toggle i.bx-chevron-down {
|
||||
transform: rotate(0deg);
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.tool-execution-toggle i.bx-chevron-right {
|
||||
transform: rotate(-90deg);
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.tool-execution-chat-steps {
|
||||
padding: 0.5rem;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Make error text more visible */
|
||||
.text-danger {
|
||||
color: #dc3545 !important;
|
||||
}
|
||||
|
||||
/* Sources Styling */
|
||||
.sources-container {
|
||||
background-color: var(--accented-background-color, var(--main-background-color));
|
||||
border-top: 1px solid var(--main-border-color);
|
||||
color: var(--main-text-color);
|
||||
}
|
||||
|
||||
.source-item {
|
||||
transition: all 0.2s ease;
|
||||
background-color: var(--main-background-color);
|
||||
border-color: var(--subtle-border-color, var(--main-border-color)) !important;
|
||||
}
|
||||
|
||||
.source-item:hover {
|
||||
background-color: var(--link-hover-background, var(--hover-item-background-color));
|
||||
}
|
||||
|
||||
.source-link {
|
||||
color: var(--link-color, var(--hover-item-text-color));
|
||||
text-decoration: none;
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.source-link:hover {
|
||||
color: var(--link-hover-color, var(--hover-item-text-color));
|
||||
}
|
||||
|
||||
/* Input Area Styling */
|
||||
.note-context-chat-form {
|
||||
background-color: var(--main-background-color);
|
||||
border-top: 1px solid var(--main-border-color);
|
||||
}
|
||||
|
||||
.context-option-container {
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid var(--subtle-border-color, var(--main-border-color));
|
||||
color: var(--main-text-color);
|
||||
}
|
||||
|
||||
.chat-input-container {
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
|
||||
.note-context-chat-input {
|
||||
border-color: var(--subtle-border-color, var(--main-border-color));
|
||||
background-color: var(--input-background-color) !important;
|
||||
color: var(--input-text-color) !important;
|
||||
resize: none;
|
||||
transition: all 0.2s ease;
|
||||
min-height: 50px;
|
||||
max-height: 150px;
|
||||
}
|
||||
|
||||
.note-context-chat-input:focus {
|
||||
border-color: var(--input-focus-outline-color, var(--main-border-color));
|
||||
box-shadow: 0 0 0 0.25rem var(--input-focus-outline-color, rgba(13, 110, 253, 0.25));
|
||||
}
|
||||
|
||||
.note-context-chat-send-button {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
align-self: flex-end;
|
||||
background-color: var(--cmd-button-background-color) !important;
|
||||
color: var(--cmd-button-text-color) !important;
|
||||
}
|
||||
|
||||
/* Loading Indicator */
|
||||
.loading-indicator {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
color: var(--muted-text-color);
|
||||
}
|
||||
|
||||
/* Thinking display styles */
|
||||
.llm-thinking-container {
|
||||
margin: 1rem 0;
|
||||
animation: fadeInUp 0.3s ease-out;
|
||||
}
|
||||
|
||||
.thinking-bubble {
|
||||
background-color: var(--accented-background-color, var(--main-background-color));
|
||||
border: 1px solid var(--subtle-border-color, var(--main-border-color));
|
||||
border-radius: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.thinking-bubble:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.thinking-bubble::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
inset-inline-start: -100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, var(--hover-item-background-color, rgba(0, 0, 0, 0.03)), transparent);
|
||||
animation: shimmer 2s infinite;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.thinking-header {
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.thinking-header:hover {
|
||||
background-color: var(--hover-item-background-color, rgba(0, 0, 0, 0.03));
|
||||
padding: 0.25rem;
|
||||
margin: -0.25rem;
|
||||
}
|
||||
|
||||
.thinking-dots {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.thinking-dots span {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background-color: var(--link-color, var(--hover-item-text-color));
|
||||
border-radius: 50%;
|
||||
animation: thinkingPulse 1.4s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.thinking-dots span:nth-child(1) {
|
||||
animation-delay: -0.32s;
|
||||
}
|
||||
|
||||
.thinking-dots span:nth-child(2) {
|
||||
animation-delay: -0.16s;
|
||||
}
|
||||
|
||||
.thinking-dots span:nth-child(3) {
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
.thinking-label {
|
||||
font-weight: 500;
|
||||
color: var(--link-color, var(--hover-item-text-color)) !important;
|
||||
}
|
||||
|
||||
.thinking-toggle {
|
||||
color: var(--muted-text-color) !important;
|
||||
transition: transform 0.2s ease;
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.thinking-toggle:hover {
|
||||
color: var(--main-text-color) !important;
|
||||
}
|
||||
|
||||
.thinking-toggle.expanded {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.thinking-content {
|
||||
margin-top: 0.75rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid var(--subtle-border-color, var(--main-border-color));
|
||||
animation: expandDown 0.3s ease-out;
|
||||
}
|
||||
|
||||
.thinking-text {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
color: var(--main-text-color);
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
background-color: var(--input-background-color);
|
||||
padding: 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid var(--subtle-border-color, var(--main-border-color));
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.thinking-text:hover {
|
||||
border-color: var(--main-border-color);
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes thinkingPulse {
|
||||
0%, 80%, 100% {
|
||||
transform: scale(0.8);
|
||||
opacity: 0.6;
|
||||
}
|
||||
40% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
inset-inline-start: -100%;
|
||||
}
|
||||
100% {
|
||||
inset-inline-start: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes expandDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
max-height: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
max-height: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.thinking-bubble {
|
||||
margin: 0.5rem 0;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.thinking-text {
|
||||
font-size: 0.8rem;
|
||||
padding: 0.5rem;
|
||||
max-height: 200px;
|
||||
}
|
||||
}
|
||||
@@ -153,11 +153,6 @@ textarea,
|
||||
background: var(--input-background-color);
|
||||
}
|
||||
|
||||
.form-control:disabled {
|
||||
background-color: var(--input-background-color);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.form-control:focus {
|
||||
color: var(--input-text-color);
|
||||
background: var(--input-background-color);
|
||||
@@ -947,7 +942,6 @@ table.promoted-attributes-in-tooltip th {
|
||||
color: var(--muted-text-color);
|
||||
opacity: 0.6;
|
||||
line-height: 1;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.aa-dropdown-menu .aa-suggestion p {
|
||||
@@ -1342,12 +1336,15 @@ body.desktop .dropdown-submenu > .dropdown-menu {
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.dropdown-submenu.dropstart > .dropdown-menu,
|
||||
body:not(.mobile) #launcher-pane.horizontal .dropdown-submenu > .dropdown-menu {
|
||||
.dropdown-submenu.dropstart > .dropdown-menu {
|
||||
inset-inline-start: auto;
|
||||
inset-inline-end: calc(100% - 2px);
|
||||
}
|
||||
|
||||
body:not(.mobile) #launcher-pane.horizontal .dropdown-submenu > .dropdown-menu {
|
||||
inset-inline-start: calc(-100% + 10px);
|
||||
}
|
||||
|
||||
.right-dropdown-widget {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -1587,7 +1584,7 @@ body:not(.mobile) #launcher-pane.horizontal .dropdown-submenu > .dropdown-menu {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
inset-inline-start: 0;
|
||||
height: 100dvh;
|
||||
bottom: 0;
|
||||
width: 85vw;
|
||||
padding-top: env(safe-area-inset-top);
|
||||
transition: transform 250ms ease-in-out;
|
||||
@@ -1612,7 +1609,11 @@ body:not(.mobile) #launcher-pane.horizontal .dropdown-submenu > .dropdown-menu {
|
||||
}
|
||||
|
||||
body.mobile #launcher-container {
|
||||
justify-content: space-evenly;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
body.mobile #launcher-container button {
|
||||
margin: 0 16px;
|
||||
}
|
||||
|
||||
body.mobile .modal.show {
|
||||
@@ -1647,27 +1648,13 @@ body:not(.mobile) #launcher-pane.horizontal .dropdown-submenu > .dropdown-menu {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
body.mobile .jump-to-note-dialog {
|
||||
.modal-header {
|
||||
padding-bottom: 0.75rem !important;
|
||||
}
|
||||
body.mobile .jump-to-note-dialog .modal-content {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
padding-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.aa-dropdown-menu {
|
||||
max-height: unset;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.aa-suggestion {
|
||||
padding-inline: 0;
|
||||
}
|
||||
body.mobile .jump-to-note-dialog .modal-dialog .aa-dropdown-menu {
|
||||
max-height: unset;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
body.mobile .modal-dialog .dropdown-menu {
|
||||
@@ -2626,7 +2613,7 @@ iframe.print-iframe {
|
||||
}
|
||||
}
|
||||
|
||||
body:not(.ios) #root-widget.virtual-keyboard-opened .note-split:not(.active) {
|
||||
#root-widget.virtual-keyboard-opened .note-split:not(.active) {
|
||||
max-height: 80px;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
@@ -210,7 +210,6 @@
|
||||
--badge-share-background-color: #4d4d4d;
|
||||
--badge-clipped-note-background-color: #295773;
|
||||
--badge-execute-background-color: #604180;
|
||||
--badge-active-content-background-color: rgb(12, 68, 70);
|
||||
|
||||
--note-icon-background-color: #444444;
|
||||
--note-icon-color: #d4d4d4;
|
||||
@@ -239,9 +238,9 @@
|
||||
|
||||
--bottom-panel-background-color: #11111180;
|
||||
--bottom-panel-title-bar-background-color: #3F3F3F80;
|
||||
|
||||
|
||||
--status-bar-border-color: var(--main-border-color);
|
||||
|
||||
|
||||
--scrollbar-thumb-color: #fdfdfd5c;
|
||||
--scrollbar-thumb-hover-color: #ffffff7d;
|
||||
--scrollbar-background-color: transparent;
|
||||
@@ -291,15 +290,6 @@
|
||||
--ck-editor-toolbar-button-on-shadow: 1px 1px 2px rgba(0, 0, 0, .75);
|
||||
--ck-editor-toolbar-dropdown-button-open-background: #ffffff14;
|
||||
|
||||
--note-list-view-icon-color: var(--left-pane-icon-color);
|
||||
--note-list-view-large-icon-background: var(--note-icon-background-color);
|
||||
--note-list-view-large-icon-color: var(--note-icon-color);
|
||||
--note-list-view-search-result-highlight-background: transparent;
|
||||
--note-list-view-search-result-highlight-color: var(--quick-search-result-highlight-color);
|
||||
--note-list-view-content-background: rgba(0, 0, 0, .2);
|
||||
--note-list-view-content-search-result-highlight-background: var(--quick-search-result-highlight-color);
|
||||
--note-list-view-content-search-result-highlight-color: black;
|
||||
|
||||
--calendar-coll-event-background-saturation: 25%;
|
||||
--calendar-coll-event-background-lightness: 20%;
|
||||
--calendar-coll-event-background-color: #3c3c3c;
|
||||
@@ -313,9 +303,7 @@
|
||||
* Dark color scheme tweaks
|
||||
*/
|
||||
|
||||
#left-pane .fancytree-node.tinted,
|
||||
.nested-note-list-item.use-note-color,
|
||||
.note-book-card .note-book-header.use-note-color {
|
||||
#left-pane .fancytree-node.tinted {
|
||||
--custom-color: var(--dark-theme-custom-color);
|
||||
|
||||
/* The background color of the active item in the note tree.
|
||||
@@ -349,25 +337,12 @@ body .todo-list input[type="checkbox"]:not(:checked):before {
|
||||
--promoted-attribute-card-background-color: hsl(var(--custom-color-hue), 13.2%, 20.8%);
|
||||
}
|
||||
|
||||
.modal.tab-bar-modal .tabs .tab-card.with-hue {
|
||||
background-color: hsl(var(--bg-hue), 8.8%, 11.2%);
|
||||
border-color: hsl(var(--bg-hue), 9.4%, 25.1%);
|
||||
}
|
||||
|
||||
.modal.tab-bar-modal .tabs .tab-card.active.with-hue {
|
||||
background-color: hsl(var(--bg-hue), 8.8%, 16.2%);
|
||||
border-color: hsl(var(--bg-hue), 9.4%, 25.1%);
|
||||
}
|
||||
|
||||
|
||||
.use-note-color {
|
||||
--custom-color: var(--dark-theme-custom-color);
|
||||
}
|
||||
|
||||
.note-split.with-hue,
|
||||
.quick-edit-dialog-wrapper.with-hue,
|
||||
.nested-note-list-item.with-hue,
|
||||
.note-book-card.with-hue .note-book-header {
|
||||
.quick-edit-dialog-wrapper.with-hue {
|
||||
--note-icon-custom-background-color: hsl(var(--custom-color-hue), 15.8%, 30.9%);
|
||||
--note-icon-custom-color: hsl(var(--custom-color-hue), 100%, 76.5%);
|
||||
--note-icon-hover-custom-background-color: hsl(var(--custom-color-hue), 28.3%, 36.7%);
|
||||
@@ -376,9 +351,4 @@ body .todo-list input[type="checkbox"]:not(:checked):before {
|
||||
.note-split.with-hue *::selection,
|
||||
.quick-edit-dialog-wrapper.with-hue *::selection {
|
||||
--selection-background-color: hsl(var(--custom-color-hue), 49.2%, 35%);
|
||||
}
|
||||
|
||||
.note-book-card.with-hue {
|
||||
--card-background-color: hsl(var(--custom-color-hue), 6%, 21%);
|
||||
--card-background-hover-color: hsl(var(--custom-color-hue), 8%, 25%);
|
||||
}
|
||||
@@ -202,7 +202,6 @@
|
||||
--badge-share-background-color: #6b6b6b;
|
||||
--badge-clipped-note-background-color: #2284c0;
|
||||
--badge-execute-background-color: #7b47af;
|
||||
--badge-active-content-background-color: rgb(27, 164, 168);
|
||||
|
||||
--note-icon-background-color: #4f4f4f;
|
||||
--note-icon-color: white;
|
||||
@@ -289,15 +288,6 @@
|
||||
--ck-editor-toolbar-button-on-shadow: none;
|
||||
--ck-editor-toolbar-dropdown-button-open-background: #0000000f;
|
||||
|
||||
--note-list-view-icon-color: var(--left-pane-icon-color);
|
||||
--note-list-view-large-icon-background: var(--note-icon-background-color);
|
||||
--note-list-view-large-icon-color: var(--note-icon-color);
|
||||
--note-list-view-search-result-highlight-background: transparent;
|
||||
--note-list-view-search-result-highlight-color: var(--quick-search-result-highlight-color);
|
||||
--note-list-view-content-background: #b1b1b133;
|
||||
--note-list-view-content-search-result-highlight-background: var(--quick-search-result-highlight-color);
|
||||
--note-list-view-content-search-result-highlight-color: white;
|
||||
|
||||
--calendar-coll-event-background-lightness: 95%;
|
||||
--calendar-coll-event-background-saturation: 80%;
|
||||
--calendar-coll-event-background-color: #eaeaea;
|
||||
@@ -307,9 +297,7 @@
|
||||
--calendar-coll-today-background-color: #00000006;
|
||||
}
|
||||
|
||||
#left-pane .fancytree-node.tinted,
|
||||
.nested-note-list-item.use-note-color,
|
||||
.note-book-card .note-book-header.use-note-color {
|
||||
#left-pane .fancytree-node.tinted {
|
||||
--custom-color: var(--light-theme-custom-color);
|
||||
|
||||
/* The background color of the active item in the note tree.
|
||||
@@ -324,20 +312,8 @@
|
||||
--promoted-attribute-card-background-color: hsl(var(--custom-color-hue), 40%, 88%);
|
||||
}
|
||||
|
||||
.modal.tab-bar-modal .tabs .tab-card.with-hue {
|
||||
background-color: hsl(var(--bg-hue), 56%, 96%);
|
||||
border-color: hsl(var(--bg-hue), 33%, 41%);
|
||||
}
|
||||
|
||||
.modal.tab-bar-modal .tabs .tab-card.active.with-hue {
|
||||
background-color: hsl(var(--bg-hue), 86%, 96%);
|
||||
border-color: hsl(var(--bg-hue), 33%, 41%);
|
||||
}
|
||||
|
||||
.note-split.with-hue,
|
||||
.quick-edit-dialog-wrapper.with-hue,
|
||||
.nested-note-list-item.with-hue,
|
||||
.note-book-card.with-hue .note-book-header {
|
||||
.quick-edit-dialog-wrapper.with-hue {
|
||||
--note-icon-custom-background-color: hsl(var(--custom-color-hue), 44.5%, 43.1%);
|
||||
--note-icon-custom-color: hsl(var(--custom-color-hue), 91.3%, 91%);
|
||||
--note-icon-hover-custom-background-color: hsl(var(--custom-color-hue), 55.1%, 50.2%);
|
||||
@@ -346,9 +322,4 @@
|
||||
.note-split.with-hue *::selection,
|
||||
.quick-edit-dialog-wrapper.with-hue *::selection {
|
||||
--selection-background-color: hsl(var(--custom-color-hue), 60%, 90%);
|
||||
}
|
||||
|
||||
.note-book-card.with-hue {
|
||||
--card-background-color: hsl(var(--custom-color-hue), 21%, 94%);
|
||||
--card-background-hover-color: hsl(var(--custom-color-hue), 21%, 87%);
|
||||
}
|
||||
@@ -643,6 +643,139 @@ li.dropdown-item a.dropdown-item-button:focus-visible {
|
||||
transform: translateY(4%);
|
||||
}
|
||||
|
||||
/*
|
||||
* NOTE LIST
|
||||
*/
|
||||
|
||||
.note-list .note-book-card {
|
||||
--note-list-horizontal-padding: 22px;
|
||||
--note-list-vertical-padding: 15px;
|
||||
background-color: var(--card-background-color);
|
||||
border: 1px solid var(--card-border-color) !important;
|
||||
border-radius: 12px;
|
||||
user-select: none;
|
||||
padding: 0;
|
||||
margin: 5px 10px 5px 0;
|
||||
}
|
||||
|
||||
:root .note-list .note-book-card:hover {
|
||||
background-color: var(--card-background-hover-color);
|
||||
transition: background-color 200ms ease-out;
|
||||
}
|
||||
|
||||
:root .note-list.grid-view .note-book-card:active {
|
||||
transform: scale(.98);
|
||||
}
|
||||
|
||||
.note-list.list-view .note-book-card {
|
||||
box-shadow: 0 0 3px var(--card-shadow-color);
|
||||
}
|
||||
|
||||
.note-list.list-view .note-book-card .note-book-header .note-icon {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.note-list-wrapper .note-book-card a {
|
||||
color: inherit !important;
|
||||
}
|
||||
|
||||
.note-list-wrapper .note-book-card .note-book-header {
|
||||
font-size: 1em;
|
||||
font-weight: bold;
|
||||
padding: 0.5em 1rem;
|
||||
border-bottom-color: var(--card-border-color);
|
||||
}
|
||||
|
||||
.note-list-wrapper .note-book-card .note-book-header .note-icon {
|
||||
font-size: 17px;
|
||||
vertical-align: text-bottom;
|
||||
}
|
||||
|
||||
.note-list-wrapper .note-book-card .note-book-header .note-book-title {
|
||||
font-size: 1em;
|
||||
color: var(--active-item-text-color);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.note-list-wrapper .note-book-card .note-book-header .rendered-note-attributes {
|
||||
font-size: 0.7em;
|
||||
font-weight: normal;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.note-list-wrapper .note-book-card .note-book-header:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.note-list-wrapper .note-book-card .note-book-content {
|
||||
padding: 0 !important;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.note-list-wrapper .note-book-card .note-book-content .rendered-content {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.note-list-wrapper .note-book-card .note-book-content.type-image .rendered-content,
|
||||
.note-list-wrapper .note-book-card .note-book-content.type-pdf .rendered-content {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.note-list-wrapper .note-book-card .note-book-content .rendered-content.text-with-ellipsis {
|
||||
padding: 1rem !important;
|
||||
}
|
||||
|
||||
.note-list-wrapper .note-book-card .note-book-content h1,
|
||||
.note-list-wrapper .note-book-card .note-book-content h2,
|
||||
.note-list-wrapper .note-book-card .note-book-content h3,
|
||||
.note-list-wrapper .note-book-card .note-book-content h4,
|
||||
.note-list-wrapper .note-book-card .note-book-content h5,
|
||||
.note-list-wrapper .note-book-card .note-book-content h6 {
|
||||
font-size: 1rem;
|
||||
color: var(--active-item-text-color);
|
||||
}
|
||||
|
||||
.note-list-wrapper .note-book-card .note-book-content p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.note-list-wrapper .note-book-card .note-book-content.type-canvas .rendered-content,
|
||||
.note-list-wrapper .note-book-card .note-book-content.type-mindMap .rendered-content,
|
||||
.note-list-wrapper .note-book-card .note-book-content.type-code .rendered-content,
|
||||
.note-list-wrapper .note-book-card .note-book-content.type-video .rendered-content {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.note-list-wrapper .note-book-card .note-book-content.type-code {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.note-list-wrapper .note-book-card .note-book-content.type-code pre {
|
||||
height: 100%;
|
||||
padding: 1em;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.note-list-wrapper .note-book-card .tn-icon {
|
||||
color: var(--left-pane-icon-color) !important;
|
||||
}
|
||||
|
||||
.note-list.grid-view .note-book-card:hover {
|
||||
filter: contrast(105%);
|
||||
}
|
||||
|
||||
.note-list.grid-view .ck-content {
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.note-list.grid-view .ck-content p {
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
.note-list.grid-view .ck-content figure.image {
|
||||
width: 25%;
|
||||
}
|
||||
|
||||
/*
|
||||
* NOTE SEARCH SUGGESTIONS
|
||||
*/
|
||||
@@ -667,19 +800,3 @@ li.dropdown-item a.dropdown-item-button:focus-visible {
|
||||
background: var(--hover-item-background-color);
|
||||
color: var(--hover-item-text-color);
|
||||
}
|
||||
|
||||
/*
|
||||
* Alert bars
|
||||
*/
|
||||
|
||||
div.alert {
|
||||
margin-bottom: 8px;
|
||||
background: var(--alert-bar-background) !important;
|
||||
color: var(--main-text-color);
|
||||
border-radius: 8px;
|
||||
font-size: .85em;
|
||||
}
|
||||
|
||||
div.alert p + p {
|
||||
margin-block: 1em 0;
|
||||
}
|
||||
|
||||
@@ -84,22 +84,6 @@ button.btn.btn-success kbd {
|
||||
letter-spacing: 0.5pt;
|
||||
}
|
||||
|
||||
/*
|
||||
* Low profile buttons
|
||||
*/
|
||||
|
||||
button.tn-low-profile {
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
button.tn-low-profile:hover {
|
||||
background-color: var(--icon-button-hover-background);
|
||||
}
|
||||
|
||||
/*
|
||||
* Icon buttons
|
||||
*/
|
||||
@@ -145,10 +129,6 @@ button.tn-low-profile:hover {
|
||||
font-size: calc(var(--icon-button-size) * var(--icon-button-icon-ratio));
|
||||
}
|
||||
|
||||
:root .icon-action.disabled::before {
|
||||
opacity: .5;
|
||||
}
|
||||
|
||||
:root .icon-action:not(.global-menu-button):hover,
|
||||
:root .icon-action:not(.global-menu-button).show,
|
||||
:root .tn-tool-button:hover,
|
||||
@@ -814,35 +794,3 @@ input[type="range"] {
|
||||
scrollbar-width: unset;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Centered forms
|
||||
*/
|
||||
|
||||
.tn-centered-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-bottom: 20vh;
|
||||
}
|
||||
|
||||
.tn-centered-form .form-group {
|
||||
text-align: center;
|
||||
color: var(--muted-text-color);
|
||||
}
|
||||
|
||||
.tn-centered-form .form-icon {
|
||||
font-size: 140px;
|
||||
color: var(--main-border-color);
|
||||
}
|
||||
|
||||
.tn-centered-form .protected-session-password {
|
||||
margin-inline: auto;
|
||||
max-width: 350px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.tn-centered-form .input-group,
|
||||
.tn-centered-form button {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
122
apps/client/src/stylesheets/theme-next/llm-chat.css
Normal file
122
apps/client/src/stylesheets/theme-next/llm-chat.css
Normal file
@@ -0,0 +1,122 @@
|
||||
/* LLM Chat Launcher Widget Styles */
|
||||
.note-context-chat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.note-context-chat-container {
|
||||
flex-grow: 1;
|
||||
overflow-y: auto;
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.chat-message {
|
||||
display: flex;
|
||||
margin-bottom: 15px;
|
||||
max-width: 85%;
|
||||
}
|
||||
|
||||
.chat-message.user-message {
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
|
||||
.chat-message.assistant-message {
|
||||
margin-inline-end: auto;
|
||||
}
|
||||
|
||||
.message-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-inline-end: 8px;
|
||||
}
|
||||
|
||||
.user-message .message-avatar {
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.assistant-message .message-avatar {
|
||||
background-color: var(--secondary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
background-color: var(--more-accented-background-color);
|
||||
border-radius: 12px;
|
||||
padding: 10px 15px;
|
||||
max-width: calc(100% - 40px);
|
||||
}
|
||||
|
||||
.user-message .message-content {
|
||||
background-color: var(--accented-background-color);
|
||||
}
|
||||
|
||||
.message-content pre {
|
||||
background-color: var(--code-background-color);
|
||||
border-radius: 5px;
|
||||
padding: 10px;
|
||||
overflow-x: auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.message-content code {
|
||||
background-color: var(--code-background-color);
|
||||
padding: 2px 4px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.loading-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 10px 0;
|
||||
color: var(--muted-text-color);
|
||||
}
|
||||
|
||||
.sources-container {
|
||||
background-color: var(--accented-background-color);
|
||||
border-top: 1px solid var(--main-border-color);
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.sources-list {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.source-item {
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.source-link {
|
||||
color: var(--link-color);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.source-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.note-context-chat-form {
|
||||
display: flex;
|
||||
background-color: var(--main-background-color);
|
||||
border-top: 1px solid var(--main-border-color);
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.note-context-chat-input {
|
||||
resize: vertical;
|
||||
min-height: 44px;
|
||||
max-height: 200px;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.chat-message {
|
||||
max-width: 95%;
|
||||
}
|
||||
}
|
||||
@@ -647,10 +647,10 @@ html .note-detail-editable-text :not(figure, .include-note, hr):first-child {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:root .ck-content hr {
|
||||
margin-block: 5px;
|
||||
height: 0;
|
||||
border: thin solid var(--main-border-color);
|
||||
.ck-content hr {
|
||||
margin: 5px 0;
|
||||
height: 1px;
|
||||
background-color: var(--main-border-color);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user