Compare commits

..

39 Commits

Author SHA1 Message Date
SiriusXT
91237918d8 fix(window): windowId is always main on mobile devices 2026-01-14 14:46:43 +08:00
SiriusXT
3f207663aa Merge branch 'main' into feat/extra-window 2026-01-14 14:28:28 +08:00
SiriusXT
21cb896849 feat(window): add the extra-window class to the body of extra windows 2026-01-12 09:58:43 +08:00
SiriusXT
b9bcb07b6d Merge branch 'main' into feat/extra-window 2026-01-12 09:20:14 +08:00
SiriusXT
787b180378 Merge branch 'main' into feat/extra-window 2026-01-09 16:45:20 +08:00
SiriusXT
80404b83b0 Merge branch 'main' into feat/extra-window 2026-01-05 11:28:47 +08:00
SiriusXT
c612bdbfc1 fix(window): normalize closedAt of OpenNoteContexts for abnormally closed windows 2026-01-05 11:21:40 +08:00
SiriusXT
3a9e686533 chore(window): simplify replacement logic for open windows 2026-01-04 18:49:08 +08:00
SiriusXT
9e8d89a170 chore(window): avoid missing windowId 2026-01-04 15:18:57 +08:00
SiriusXT
31c70938d6 Merge branch 'main' into feat/extra-window 2026-01-04 14:13:25 +08:00
SiriusXT
07f3c48d0b chore(window): import randomString only when needed 2026-01-04 14:13:18 +08:00
SiriusXT
2821b6da9d chore(window): add TS type WindowState 2026-01-03 20:04:17 +08:00
SiriusXT
daba7c398d Merge branch 'main' into feat/extra-window 2026-01-03 20:04:11 +08:00
SiriusXT
de1ef5b98b chore(test): fix errors caused by layout changes 2026-01-03 19:04:34 +08:00
SiriusXT
1bb206d978 chore(i18n): tabs total 2026-01-03 18:15:45 +08:00
SiriusXT
2fd5ddab86 chore(window): optimize the replacement logic for old window notes 2026-01-03 11:07:48 +08:00
SiriusXT
27dc662636 fix(window): a window with no open notes appears blank. 2026-01-02 18:17:05 +08:00
SiriusXT
52691b5c8c Merge branch 'main' into feat/extra-window 2026-01-02 17:44:39 +08:00
SiriusXT
8087ed5688 Merge branch 'main' into feat/extra-window 2026-01-02 14:58:10 +08:00
SiriusXT
79e2c97882 chore(window): initialize closed time of openNoteContents to 0 2026-01-01 16:09:03 +08:00
SiriusXT
1078107776 chore(window): initialize closed time of openNoteContents to 0 2026-01-01 14:30:01 +08:00
SiriusXT
9c9e123e3d Merge branch 'main' into feat/extra-window 2026-01-01 14:04:45 +08:00
SiriusXT
a8c2947062 Merge branch 'main' into feat/extra-window 2025-12-31 14:23:42 +08:00
SiriusXT
366166a561 fix(window): avoid invalid fallback value for openNoteContexts 2025-12-30 09:44:58 +08:00
SiriusXT
4d2b02eddb Merge branch 'main' into feat/extra-window 2025-12-30 09:03:26 +08:00
SiriusXT
07871853a5 fix(window): cannot save when switching between multiple windows 2025-12-29 19:24:53 +08:00
SiriusXT
254145f0e5 chore(window): handle potential JSON parsing failures 2025-12-29 16:26:50 +08:00
SiriusXT
c28f11336e chore(window_db): fix potential migration error 2025-12-29 16:11:49 +08:00
SiriusXT
2e30683b7b chore(window): avoid reduce error when no candidates 2025-12-29 16:08:29 +08:00
SiriusXT
0af7b8b145 chore(window): use MAX_SAVED_WINDOWS constant 2025-12-29 15:56:04 +08:00
SiriusXT
5d39b84886 fix(window): Fix incorrect noteContents error 2025-12-29 15:28:27 +08:00
SiriusXT
537c4051cc feat(window): add class to extra windows 2025-12-29 15:27:35 +08:00
SiriusXT
d0a22bc517 fix(window): Fix empty array issue during openNoteContents data migration 2025-12-29 15:27:11 +08:00
SiriusXT
19a75acf3f Merge branch 'main' into feat/extra-window 2025-12-29 14:44:25 +08:00
SiriusXT
3f0abce874 feat(window_db): migrate openNoteContexts to structured format with window metadata 2025-12-29 14:43:49 +08:00
SiriusXT
36dd29f919 feat(window): add class to extra windows 2025-12-29 14:37:40 +08:00
SiriusXT
d7838f0b67 feat(window): restore recently closed windows from tray 2025-12-29 14:37:35 +08:00
SiriusXT
3353d4f436 feat(window): record openNoteContents of recently closed windows 2025-12-29 14:33:34 +08:00
SiriusXT
7740154bdc feat(window): add windowId for extra windows 2025-12-29 14:32:53 +08:00
1292 changed files with 71345 additions and 69810 deletions

View File

@@ -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

View File

@@ -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 }}

View File

@@ -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:

View File

@@ -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 }}

View File

@@ -12,7 +12,7 @@ jobs:
steps:
- name: Check if PRs have conflicts
uses: eps1lon/actions-label-merge-conflict@v3
if: ${{ github.repository == vars.REPO_MAIN }}
if: github.repository == ${{ vars.REPO_MAIN }}
with:
dirtyLabel: "merge-conflicts"
repoToken: "${{ secrets.MERGE_CONFLICT_LABEL_PAT }}"

View File

@@ -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 }}

View File

@@ -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
@@ -67,7 +67,7 @@ jobs:
- name: Deploy
uses: ./.github/actions/deploy-to-cloudflare-pages
if: ${{ github.repository == vars.REPO_MAIN }}
if: github.repository == ${{ vars.REPO_MAIN }}
with:
project_name: "trilium-docs"
comment_body: "📚 Documentation preview is ready"

View File

@@ -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 }}

View File

@@ -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:

View File

@@ -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 }}

View File

@@ -26,7 +26,7 @@ permissions:
jobs:
nightly-electron:
if: ${{ github.repository == vars.REPO_MAIN }}
if: github.repository == ${{ vars.REPO_MAIN }}
name: Deploy nightly
strategy:
fail-fast: false
@@ -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,14 +102,14 @@ 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 }}
path: apps/desktop/upload
nightly-server:
if: ${{ github.repository == vars.REPO_MAIN }}
if: github.repository == ${{ vars.REPO_MAIN }}
name: Deploy server nightly
strategy:
fail-fast: false
@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -1,69 +0,0 @@
name: Deploy web clipper extension
on:
push:
branches:
- main
paths:
- "apps/web-clipper/**"
tags:
- "web-clipper-v*"
pull_request:
paths:
- "apps/web-clipper/**"
permissions:
contents: write
discussions: write
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
name: Build web clipper extension
permissions:
contents: read
deployments: write
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 web-clipper --frozen-lockfile --ignore-scripts
- name: Build the web clipper extension
run: |
pnpm --filter web-clipper zip
pnpm --filter web-clipper zip:firefox
- name: Upload build artifacts
uses: actions/upload-artifact@v7
if: ${{ !startsWith(github.ref, 'refs/tags/web-clipper-v') }}
with:
name: web-clipper-extension
path: apps/web-clipper/.output/*.zip
include-hidden-files: true
if-no-files-found: error
compression-level: 0
- name: Release web clipper extension
uses: softprops/action-gh-release@v2.6.1
if: ${{ startsWith(github.ref, 'refs/tags/web-clipper-v') }}
with:
draft: false
fail_on_unmatched_files: true
files: apps/web-clipper/.output/*.zip
discussion_category_name: Releases
make_latest: false
token: ${{ secrets.RELEASE_PAT }}

View File

@@ -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
View File

@@ -46,11 +46,9 @@ upload
/.direnv
/result
.svelte-kit
# docs
site/
apps/*/coverage
scripts/translation/.language*.json
# AI
.claude/settings.local.json

2
.nvmrc
View File

@@ -1 +1 @@
24.14.0
24.13.0

57
.vscode/launch.json vendored
View File

@@ -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"
}
]
}

View File

@@ -42,8 +42,5 @@
},
"eslint.rules.customizations": [
{ "rule": "*", "severity": "warn" }
],
"cSpell.words": [
"Trilium"
]
}
}

View File

@@ -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)

View File

@@ -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.0",
"devDependencies": {
"@redocly/cli": "2.24.1",
"@redocly/cli": "2.14.5",
"archiver": "7.0.1",
"fs-extra": "11.3.4",
"js-yaml": "4.1.1",
"react": "19.2.4",
"react-dom": "19.2.4",
"typedoc": "0.28.17",
"fs-extra": "11.3.3",
"react": "19.2.3",
"react-dom": "19.2.3",
"typedoc": "0.28.16",
"typedoc-plugin-missing-exports": "4.1.2"
}
}

View File

@@ -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();

View File

@@ -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, {});

View File

@@ -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`, {

View File

@@ -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();

View File

@@ -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();

View File

@@ -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, "../../../"),

View File

@@ -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

View File

@@ -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" });
}
}

View File

@@ -1,8 +1,6 @@
{
"extends": "../../tsconfig.base.json",
"include": [
"scripts/**/*.ts"
],
"include": [],
"references": [
{
"path": "../server"

View File

@@ -4,7 +4,6 @@
"entryPoints": [
"src/backend_script_entrypoint.ts"
],
"tsconfig": "tsconfig.app.json",
"plugin": [
"typedoc-plugin-missing-exports"
]

View File

@@ -4,7 +4,6 @@
"entryPoints": [
"src/frontend_script_entrypoint.ts"
],
"tsconfig": "tsconfig.app.json",
"plugin": [
"typedoc-plugin-missing-exports"
]

View File

@@ -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

View File

@@ -1 +0,0 @@
VITE_CKEDITOR_ENABLE_INSPECTOR=false

View File

@@ -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"
}
}

View File

@@ -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

View File

@@ -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"
}
]
}

View File

@@ -1,2 +0,0 @@
// Re-export desktop from client
export * from "../../client/src/desktop";

View File

@@ -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>

View File

@@ -1,255 +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;
}
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,
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 [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();
}

View File

@@ -1,192 +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 } from '@triliumnext/core';
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;
}
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; }
};
}
/**
* 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(() => {
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) => unknown, resultHandler?: ((req: any, res: any, result: unknown) => unknown) | null) => {
router.register(method, path, (req: BrowserRequest) => {
return getContext().init(() => {
const expressLikeReq = toExpressLikeReq(req);
const result = getSql().transactional(() => handler(expressLikeReq));
if (resultHandler) {
// Create a minimal response object that captures what apiResultHandler sets.
const res = createResultHandlerResponse();
resultHandler(expressLikeReq, res, result);
return res.result;
}
return result;
});
});
};
}
/**
* 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 auth middleware for standalone — there's no authentication.
*/
function checkApiAuth() {
// No authentication in standalone mode.
}
/**
* 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),
apiRoute,
asyncApiRoute: createApiRoute(router, false),
apiResultHandler,
checkApiAuth
});
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 }));
apiRoute("get", "/api/autocomplete", () => []);
}
function bootstrapRoute() {
const assetPath = ".";
return {
...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
} satisfies BootstrapDefinition;
}
/**
* Create and configure a router with all routes registered.
*/
export function createConfiguredRouter(): BrowserRouter {
const router = new BrowserRouter();
registerRoutes(router);
return router;
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

File diff suppressed because one or more lines are too long

View File

@@ -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 = [];
}
}

View File

@@ -1,94 +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
};
if (opts.cookieJar?.header) {
headers["Cookie"] = opts.cookieJar.header;
}
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
});
// Handle set-cookie from response (limited in browser, but keep for API compat)
if (opts.cookieJar) {
const setCookie = response.headers.get("set-cookie");
if (setCookie) {
opts.cookieJar.header = setCookie;
}
}
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();
}
}

View File

@@ -1,637 +0,0 @@
import { type BindableValue, default as sqlite3InitModule } from "@sqlite.org/sqlite-wasm";
import type { DatabaseProvider, RunResult, Statement, Transaction } from "@triliumnext/core";
import demoDbSql from "./db.sql?raw";
// 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");
// Initialize with demo data for in-memory databases
// (since they won't persist anyway)
this.initializeDemoDatabase();
const loadTime = performance.now() - startTime;
console.log(`[BrowserSqlProvider] In-memory database created in ${loadTime.toFixed(2)}ms`);
}
/**
* Initialize the database with demo/starter data.
* This should only be called once when creating a new database.
*
* For OPFS databases, this is called automatically only if the database
* doesn't already exist.
*/
initializeDemoDatabase(): void {
this.ensureDb();
console.log("[BrowserSqlProvider] Initializing database with demo data...");
const startTime = performance.now();
this.db!.exec(demoDbSql);
const loadTime = performance.now() - startTime;
console.log(`[BrowserSqlProvider] Demo data loaded 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.");
}
}
}

View File

@@ -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
});
}

View File

@@ -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
}
});
}
});
}

View File

@@ -1,259 +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");
// Check if database is initialized (schema exists)
if (!sqlProvider!.isDbInitialized()) {
console.log("[Worker] Database not initialized, loading demo data...");
sqlProvider!.initializeDemoDatabase();
console.log("[Worker] Demo data loaded");
} else {
console.log("[Worker] Existing initialized database loaded");
}
} 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");
console.log("[Worker] Initializing becca...");
await coreModule.becca_loader.beccaLoaded;
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)
});
}
};

View File

@@ -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();

View File

@@ -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));
});

View File

@@ -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;
}

View File

@@ -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"
]
}

View File

@@ -1,7 +0,0 @@
{
"extends": "../../tsconfig.base.json",
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.spec.json" }
]
}

View File

@@ -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"
]
}

View File

@@ -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"),
}
}));

View File

@@ -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.5.1",
"@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.1",
"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.0",
"globals": "17.0.0",
"i18next": "25.7.4",
"i18next-http-backend": "3.0.2",
"jquery": "4.0.0",
"jquery": "3.7.1",
"jquery.fancytree": "2.38.5",
"jsplumb": "2.15.6",
"katex": "0.16.40",
"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.5",
"mermaid": "11.13.0",
"mind-elixir": "5.9.3",
"marked": "17.0.1",
"mermaid": "11.12.2",
"mind-elixir": "5.5.0",
"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.2",
"react-i18next": "16.5.3",
"react-window": "2.2.5",
"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.1.0",
"lightningcss": "1.30.2",
"script-loader": "0.7.2",
"vite-plugin-static-copy": "3.3.0"
"vite-plugin-static-copy": "3.1.4"
}
}

View File

@@ -1,6 +1,6 @@
import type { CKTextEditor } from "@triliumnext/ckeditor5";
import type CodeMirror from "@triliumnext/codemirror";
import { SqlExecuteResponse } from "@triliumnext/commons";
import { SqlExecuteResults } from "@triliumnext/commons";
import type { NativeImage, TouchBar } from "electron";
import { ColumnComponent } from "tabulator-tables";
@@ -101,6 +101,8 @@ export type CommandMappings = {
showRevisions: CommandData & {
noteId?: string | null;
};
showLlmChat: CommandData;
createAiChat: CommandData;
showOptions: CommandData & {
section: string;
};
@@ -408,7 +410,7 @@ type EventMappings = {
addNewLabel: CommandData;
addNewRelation: CommandData;
sqlQueryResults: CommandData & {
response: SqlExecuteResponse;
results: SqlExecuteResults;
};
readOnlyTemporarilyDisabled: {
noteContext: NoteContext;
@@ -540,6 +542,7 @@ export type FilteredCommandNames<T extends CommandData> = keyof Pick<CommandMapp
export class AppContext extends Component {
isMainWindow: boolean;
windowId: string;
components: Component[];
beforeUnloadListeners: (WeakRef<BeforeUploadListener> | (() => boolean))[];
tabManager!: TabManager;
@@ -548,10 +551,11 @@ export class AppContext extends Component {
lastSearchString?: string;
constructor(isMainWindow: boolean) {
constructor(isMainWindow: boolean, windowId: string) {
super();
this.isMainWindow = isMainWindow;
this.windowId = windowId;
// non-widget/layout components needed for the application
this.components = [];
this.beforeUnloadListeners = [];
@@ -681,8 +685,7 @@ export class AppContext extends Component {
this.beforeUnloadListeners = this.beforeUnloadListeners.filter(l => l !== listener);
}
}
const appContext = new AppContext(window.glob.isMainWindow);
const appContext = new AppContext(window.glob.isMainWindow, window.glob.windowId);
// we should save all outstanding changes before the page/app is closed
$(window).on("beforeunload", () => {

View File

@@ -1,17 +1,16 @@
import { CreateChildrenResponse, SqlExecuteResponse } from "@triliumnext/commons";
import bundleService from "../services/bundle.js";
import utils from "../services/utils.js";
import dateNoteService from "../services/date_notes.js";
import froca from "../services/froca.js";
import { t } from "../services/i18n.js";
import linkService from "../services/link.js";
import protectedSessionHolder from "../services/protected_session_holder.js";
import server from "../services/server.js";
import toastService from "../services/toast.js";
import utils from "../services/utils.js";
import ws from "../services/ws.js";
import appContext, { type NoteCommandData } from "./app_context.js";
import Component from "./component.js";
import toastService from "../services/toast.js";
import ws from "../services/ws.js";
import bundleService from "../services/bundle.js";
import froca from "../services/froca.js";
import linkService from "../services/link.js";
import { t } from "../services/i18n.js";
import { CreateChildrenResponse, SqlExecuteResponse } from "@triliumnext/commons";
export default class Entrypoints extends Component {
constructor() {
@@ -143,14 +142,15 @@ export default class Entrypoints extends Component {
}
async openInWindowCommand({ notePath, hoistedNoteId, viewScope }: NoteCommandData) {
const extraWindowId = utils.randomString(4);
const extraWindowHash = linkService.calculateHash({ notePath, hoistedNoteId, viewScope });
if (utils.isElectron()) {
const { ipcRenderer } = utils.dynamicRequire("electron");
ipcRenderer.send("create-extra-window", { extraWindowHash });
ipcRenderer.send("create-extra-window", { extraWindowId, extraWindowHash });
} else {
const url = `${window.location.protocol}//${window.location.host}${window.location.pathname}?extraWindow=1${extraWindowHash}`;
const url = `${window.location.protocol}//${window.location.host}${window.location.pathname}?extraWindow=${extraWindowId}${extraWindowHash}`;
window.open(url, "", "width=1000,height=800");
}
@@ -188,8 +188,13 @@ export default class Entrypoints extends Component {
} else if (note.mime.endsWith("env=backend")) {
await server.post(`script/run/${note.noteId}`);
} else if (note.mime === "text/x-sqlite;schema=trilium") {
const response = await server.post<SqlExecuteResponse>(`sql/execute/${note.noteId}`);
await appContext.triggerEvent("sqlQueryResults", { ntxId, response });
const resp = await server.post<SqlExecuteResponse>(`sql/execute/${note.noteId}`);
if (!resp.success) {
toastService.showError(t("entrypoints.sql-error", { message: resp.error }));
}
await appContext.triggerEvent("sqlQueryResults", { ntxId: ntxId, results: resp.results });
}
toastService.showMessage(t("entrypoints.note-executed"));

View File

@@ -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;

View File

@@ -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}`);
}
}
}

View File

@@ -11,6 +11,8 @@ import linkService from "../services/link.js";
import type { EventData } from "./app_context.js";
import type FNote from "../entities/fnote.js";
const MAX_SAVED_WINDOWS = 10;
interface TabState {
contexts: NoteContext[];
position: number;
@@ -25,6 +27,13 @@ interface NoteContextState {
viewScope: Record<string, any>;
}
interface WindowState {
windowId: string;
createdAt: number;
closedAt: number;
contexts: NoteContextState[];
}
export default class TabManager extends Component {
public children: NoteContext[];
public mutex: Mutex;
@@ -41,9 +50,6 @@ export default class TabManager extends Component {
this.recentlyClosedTabs = [];
this.tabsUpdate = new SpacedUpdate(async () => {
if (!appContext.isMainWindow) {
return;
}
if (options.is("databaseReadonly")) {
return;
}
@@ -52,9 +58,21 @@ export default class TabManager extends Component {
.map((nc) => nc.getPojoState())
.filter((t) => !!t);
await server.put("options", {
openNoteContexts: JSON.stringify(openNoteContexts)
});
// Update the current windows openNoteContexts in options
const savedWindows = options.getJson("openNoteContexts") || [];
const win = savedWindows.find((w: WindowState) => w.windowId === appContext.windowId);
if (win) {
win.contexts = openNoteContexts;
} else {
savedWindows.push({
windowId: appContext.windowId,
createdAt: Date.now(),
closedAt: 0,
contexts: openNoteContexts
} as WindowState);
}
await options.save("openNoteContexts", JSON.stringify(savedWindows));
});
appContext.addBeforeUnloadListener(this);
@@ -69,8 +87,13 @@ export default class TabManager extends Component {
}
async loadTabs() {
// Get the current windows openNoteContexts
const savedWindows = options.getJson("openNoteContexts") || [];
const currentWin = savedWindows.find(w => w.windowId === appContext.windowId);
const openNoteContexts = currentWin ? currentWin.contexts : undefined;
try {
const noteContextsToOpen = (appContext.isMainWindow && options.getJson("openNoteContexts")) || [];
const noteContextsToOpen = openNoteContexts || [];
// preload all notes at once
await froca.getNotes([...noteContextsToOpen.flatMap((tab: NoteContextState) =>
@@ -119,6 +142,51 @@ export default class TabManager extends Component {
}
});
// Save window contents
if (currentWin as WindowState) {
currentWin.createdAt = Date.now();
currentWin.closedAt = 0;
currentWin.contexts = filteredNoteContexts;
} else {
if (savedWindows?.length >= MAX_SAVED_WINDOWS) {
// Filter out the oldest entry
// 1) Never remove the "main" window
// 2) Prefer removing the oldest closed window (closedAt !== 0)
// 3) If no closed window exists, remove the window with the oldest created window
let oldestClosedIndex = -1;
let oldestClosedTime = Infinity;
let oldestCreatedIndex = -1;
let oldestCreatedTime = Infinity;
savedWindows.forEach((w: WindowState, i: number) => {
if (w.windowId === "main") return;
if (w.closedAt !== 0) {
if (w.closedAt < oldestClosedTime) {
oldestClosedTime = w.closedAt;
oldestClosedIndex = i;
}
} else {
if (w.createdAt < oldestCreatedTime) {
oldestCreatedTime = w.createdAt;
oldestCreatedIndex = i;
}
}
});
const indexToRemove = oldestClosedIndex !== -1 ? oldestClosedIndex : oldestCreatedIndex;
if (indexToRemove !== -1) {
savedWindows.splice(indexToRemove, 1);
}
}
savedWindows.push({
windowId: appContext.windowId,
createdAt: Date.now(),
closedAt: 0,
contexts: filteredNoteContexts
} as WindowState);
}
await options.save("openNoteContexts", JSON.stringify(savedWindows));
// if there's a notePath in the URL, make sure it's open and active
// (useful, for e.g., opening clipped notes from clipper or opening link in an extra window)
if (parsedFromUrl.notePath) {

View File

@@ -46,6 +46,10 @@ if (utils.isElectron()) {
electronContextMenu.setupContextMenu();
}
if (utils.isPWA()) {
initPWATopbarColor();
}
function initOnElectron() {
const electron: typeof Electron = utils.dynamicRequire("electron");
electron.ipcRenderer.on("globalShortcut", async (event, actionName) => appContext.triggerCommand(actionName));
@@ -95,22 +99,15 @@ function initFullScreenDetection(currentWindow: Electron.BrowserWindow) {
}
function initTransparencyEffects(style: CSSStyleDeclaration, currentWindow: Electron.BrowserWindow) {
const material = style.getPropertyValue("--background-material").trim();
if (window.glob.platform === "win32") {
const material = style.getPropertyValue("--background-material");
// TriliumNextTODO: find a nicer way to make TypeScript happy unfortunately TS did not like Array.includes here
const bgMaterialOptions = ["auto", "none", "mica", "acrylic", "tabbed"] as const;
const foundBgMaterialOption = bgMaterialOptions.find((bgMaterialOption) => material === bgMaterialOption);
if (foundBgMaterialOption) {
currentWindow.setBackgroundMaterial(foundBgMaterialOption);
}
}
if (window.glob.platform === "darwin") {
const bgMaterialOptions = [ "popover", "tooltip", "titlebar", "selection", "menu", "sidebar", "header", "sheet", "window", "hud", "fullscreen-ui", "content", "under-window", "under-page" ] as const;
const foundBgMaterialOption = bgMaterialOptions.find((bgMaterialOption) => material === bgMaterialOption);
if (foundBgMaterialOption) {
currentWindow.setVibrancy(foundBgMaterialOption);
}
}
}
/**
@@ -130,3 +127,20 @@ function initDarkOrLightMode(style: CSSStyleDeclaration) {
const { nativeTheme } = utils.dynamicRequire("@electron/remote") as typeof ElectronRemote;
nativeTheme.themeSource = themeSource;
}
function initPWATopbarColor() {
const tracker = $("#background-color-tracker");
if (tracker.length) {
const applyThemeColor = () => {
let meta = $("meta[name='theme-color']");
if (!meta.length) {
meta = $(`<meta name="theme-color">`).appendTo($("head"));
}
meta.attr("content", tracker.css("color"));
};
tracker.on("transitionend", applyThemeColor);
applyThemeColor();
}
}

View File

@@ -1,4 +1,4 @@
import { getNoteIcon } from "@triliumnext/commons";
import { MIME_TYPES_DICT } from "@triliumnext/commons";
import cssClassManager from "../services/css_class_manager.js";
import type { Froca } from "../services/froca-interface.js";
@@ -13,12 +13,31 @@ import type { AttributeType, default as FAttribute } from "./fattribute.js";
const LABEL = "label";
const RELATION = "relation";
export const NOTE_TYPE_ICONS = {
file: "bx bx-file",
image: "bx bx-image",
code: "bx bx-code",
render: "bx bx-extension",
search: "bx bx-file-find",
relationMap: "bx bxs-network-chart",
book: "bx bx-book",
noteMap: "bx bxs-network-chart",
mermaid: "bx bx-selection",
canvas: "bx bx-pen",
webView: "bx bx-globe-alt",
launcher: "bx bx-link",
doc: "bx bxs-file-doc",
contentWidget: "bx bxs-widget",
mindMap: "bx bx-sitemap",
aiChat: "bx bx-bot"
};
/**
* There are many different Note types, some of which are entirely opaque to the
* 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;
@@ -563,18 +582,32 @@ export default class FNote {
}
getIcon() {
return `tn-icon ${this.#getIconInternal()}`;
}
#getIconInternal() {
const iconClassLabels = this.getLabels("iconClass");
const workspaceIconClass = this.getWorkspaceIconClass();
const icon = getNoteIcon({
noteId: this.noteId,
type: this.type,
mime: this.mime,
iconClass: iconClassLabels.length > 0 ? iconClassLabels[0].value : undefined,
workspaceIconClass,
isFolder: this.isFolder.bind(this)
});
return `tn-icon ${icon}`;
if (iconClassLabels && iconClassLabels.length > 0) {
return iconClassLabels[0].value;
} else if (workspaceIconClass) {
return workspaceIconClass;
} else if (this.noteId === "root") {
return "bx bx-home-alt-2";
}
if (this.noteId === "_share") {
return "bx bx-share-alt";
} else if (this.type === "text") {
if (this.isFolder()) {
return "bx bx-folder";
}
return "bx bx-note";
} else if (this.type === "code") {
const correspondingMimeType = MIME_TYPES_DICT.find(m => m.mime === this.mime);
return correspondingMimeType?.icon ?? NOTE_TYPE_ICONS.code;
}
return NOTE_TYPE_ICONS[this.type];
}
getColorClass() {
@@ -700,15 +733,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.

View File

@@ -13,14 +13,13 @@
<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 to 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>
<script src="./src/index.ts" type="module"></script>
<script src="./index.ts" type="module"></script>
<!-- Required for correct loading of scripts in Electron -->
<script>

View File

@@ -16,57 +16,19 @@ async function initJQuery() {
const $ = (await import("jquery")).default;
window.$ = $;
window.jQuery = $;
// Polyfill removed jQuery methods for autocomplete.js compatibility
($ as any).isArray = Array.isArray;
($ as any).isFunction = function(obj: any) { return typeof obj === 'function'; };
($ as any).isPlainObject = function(obj: any) {
if (obj == null || typeof obj !== 'object') { return false; }
const proto = Object.getPrototypeOf(obj);
if (proto === null) { return true; }
const Ctor = Object.prototype.hasOwnProperty.call(proto, 'constructor') && proto.constructor;
return typeof Ctor === 'function' && Ctor === Object;
};
}
async function setupGlob() {
const response = await fetch(`./bootstrap${window.location.search}`);
const response = await fetch(`/bootstrap${window.location.search}`);
const json = await response.json();
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) {
@@ -77,25 +39,22 @@ async function loadBootstrapCss() {
}
function loadStylesheets() {
const { device, assetPath, themeCssUrl, themeUseNextAsBase } = window.glob;
const { assetPath, themeCssUrl, themeUseNextAsBase } = window.glob;
const cssToLoad: string[] = [];
if (device !== "print") {
cssToLoad.push(`${assetPath}/stylesheets/ckeditor-theme.css`);
cssToLoad.push(`api/fonts`);
cssToLoad.push(`${assetPath}/stylesheets/theme-light.css`);
if (themeCssUrl) {
cssToLoad.push(themeCssUrl);
}
if (themeUseNextAsBase === "next") {
cssToLoad.push(`${assetPath}/stylesheets/theme-next.css`);
} else if (themeUseNextAsBase === "next-dark") {
cssToLoad.push(`${assetPath}/stylesheets/theme-next-dark.css`);
} else if (themeUseNextAsBase === "next-light") {
cssToLoad.push(`${assetPath}/stylesheets/theme-next-light.css`);
}
cssToLoad.push(`${assetPath}/stylesheets/style.css`);
cssToLoad.push(`${assetPath}/stylesheets/ckeditor-theme.css`);
cssToLoad.push(`api/fonts`);
cssToLoad.push(`${assetPath}/stylesheets/theme-light.css`);
if (themeCssUrl) {
cssToLoad.push(themeCssUrl);
}
if (themeUseNextAsBase === "next") {
cssToLoad.push(`${assetPath}/stylesheets/theme-next.css`);
} else if (themeUseNextAsBase === "next-dark") {
cssToLoad.push(`${assetPath}/stylesheets/theme-next-dark.css`);
} else if (themeUseNextAsBase === "next-light") {
cssToLoad.push(`${assetPath}/stylesheets/theme-next-light.css`);
}
cssToLoad.push(`${assetPath}/stylesheets/style.css`);
for (const href of cssToLoad) {
const linkEl = document.createElement("link");
@@ -112,7 +71,7 @@ function loadIcons() {
}
function setBodyAttributes() {
const { device, headingStyle, layoutOrientation, platform, isElectron, hasNativeTitleBar, hasBackgroundEffects, currentLocale } = window.glob;
const { device, headingStyle, layoutOrientation, platform, isElectron, hasNativeTitleBar, hasBackgroundEffects, currentLocale, isMainWindow } = window.glob;
const classesToSet = [
device,
`heading-style-${headingStyle}`,
@@ -120,7 +79,8 @@ function setBodyAttributes() {
`platform-${platform}`,
isElectron && "electron",
hasNativeTitleBar && "native-titlebar",
hasBackgroundEffects && "background-effects"
hasBackgroundEffects && "background-effects",
!isMainWindow && 'extra-window'
].filter(Boolean) as string[];
for (const classToSet of classesToSet) {
@@ -132,17 +92,10 @@ function setBodyAttributes() {
}
async function loadScripts() {
switch (glob.device) {
case "mobile":
await import("./mobile.js");
break;
case "print":
await import("./print.js");
break;
case "desktop":
default:
await import("./desktop.js");
break;
if (glob.device === "mobile") {
await import("./mobile.js");
} else {
await import("./desktop.js");
}
}

View File

@@ -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";
@@ -47,6 +46,8 @@ import ScrollPadding from "../widgets/scroll_padding.js";
import SearchResult from "../widgets/search_result.jsx";
import SharedInfo from "../widgets/shared_info.jsx";
import RightPanelContainer from "../widgets/sidebar/RightPanelContainer.jsx";
import SqlResults from "../widgets/sql_result.js";
import SqlTableSchemas from "../widgets/sql_table_schemas.js";
import TabRowWidget from "../widgets/tab_row.js";
import TabHistoryNavigationButtons from "../widgets/TabHistoryNavigationButtons.jsx";
import TitleBarButtons from "../widgets/title_bar_buttons.jsx";
@@ -162,9 +163,11 @@ export default class DesktopLayout {
.child(<SharedInfo />)
)
.optChild(!isNewLayout, <PromotedAttributes />)
.child(<SqlTableSchemas />)
.child(<NoteDetail />)
.child(<NoteList media="screen" />)
.child(<SearchResult />)
.child(<SqlResults />)
.child(<ScrollPadding />)
)
.child(<ApiLog />)
@@ -187,7 +190,6 @@ export default class DesktopLayout {
)
)
.optChild(launcherPaneIsHorizontal && isNewLayout, <StatusBar />)
.optChild(glob.isStandalone, <StandaloneWarningBar />)
.child(<CloseZenModeButton />)
// Desktop-specific dialogs.

View File

@@ -1,76 +0,0 @@
#background-color-tracker {
color: var(--main-background-color) !important;
}
span.keyboard-shortcut,
kbd {
display: none;
}
.dropdown-menu {
font-size: larger;
}
.action-button {
background: none;
border: none;
cursor: pointer;
font-size: 1.25em;
padding-inline-start: 0.5em;
padding-inline-end: 0.5em;
color: var(--main-text-color);
}
.quick-search {
margin: 0;
}
.quick-search .dropdown-menu {
max-width: 350px;
}
/* #region Tree */
.tree-wrapper {
max-height: 100%;
margin-top: 0px;
overflow-y: auto;
contain: content;
padding-inline-start: 10px;
}
.fancytree-title {
margin-inline-start: 0.6em !important;
}
.fancytree-node {
padding: 5px;
}
span.fancytree-expander {
width: 24px !important;
margin-inline-end: 5px;
}
.fancytree-loading span.fancytree-expander {
width: 24px;
height: 32px;
}
.fancytree-loading span.fancytree-expander:after {
width: 20px;
height: 20px;
margin-top: 4px;
border-width: 2px;
border-style: solid;
}
.tree-wrapper .collapse-tree-button,
.tree-wrapper .scroll-to-active-note-button,
.tree-wrapper .tree-settings-button {
position: fixed;
margin-inline-end: 16px;
display: none;
}
.tree-wrapper .unhoist-button {
font-size: 200%;
}
/* #endregion */

View File

@@ -1,39 +1,128 @@
import "./mobile_layout.css";
import type AppContext from "../components/app_context.js";
import GlobalMenuWidget from "../widgets/buttons/global_menu.js";
import CloseZenModeButton from "../widgets/close_zen_button.js";
import NoteList from "../widgets/collections/NoteList.jsx";
import ContentHeader from "../widgets/containers/content_header.js";
import FlexContainer from "../widgets/containers/flex_container.js";
import RootContainer from "../widgets/containers/root_container.js";
import ScrollingContainer from "../widgets/containers/scrolling_container.js";
import SplitNoteContainer from "../widgets/containers/split_note_container.js";
import FindWidget from "../widgets/find.js";
import FloatingButtons from "../widgets/FloatingButtons.jsx";
import { MOBILE_FLOATING_BUTTONS } from "../widgets/FloatingButtonsDefinitions.jsx";
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";
import ToggleSidebarButton from "../widgets/mobile_widgets/toggle_sidebar_button.jsx";
import NoteIconWidget from "../widgets/note_icon.jsx";
import NoteTitleWidget from "../widgets/note_title.js";
import NoteTreeWidget from "../widgets/note_tree.js";
import NoteWrapperWidget from "../widgets/note_wrapper.js";
import NoteDetail from "../widgets/NoteDetail.jsx";
import PromotedAttributes from "../widgets/PromotedAttributes.jsx";
import QuickSearchWidget from "../widgets/quick_search.js";
import ScrollPadding from "../widgets/scroll_padding";
import { useNoteContext } from "../widgets/react/hooks.jsx";
import ReadOnlyNoteInfoBar from "../widgets/ReadOnlyNoteInfoBar.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 SharedInfoWidget from "../widgets/shared_info.js";
import TabRowWidget from "../widgets/tab_row.js";
import MobileEditorToolbar from "../widgets/type_widgets/text/mobile_editor_toolbar.jsx";
import { applyModals } from "./layout_commons.js";
const MOBILE_CSS = `
<style>
span.keyboard-shortcut,
kbd {
display: none;
}
.dropdown-menu {
font-size: larger;
}
.action-button {
background: none;
border: none;
cursor: pointer;
font-size: 1.25em;
padding-inline-start: 0.5em;
padding-inline-end: 0.5em;
color: var(--main-text-color);
}
.quick-search {
margin: 0;
}
.quick-search .dropdown-menu {
max-width: 350px;
}
</style>`;
const FANCYTREE_CSS = `
<style>
.tree-wrapper {
max-height: 100%;
margin-top: 0px;
overflow-y: auto;
contain: content;
padding-inline-start: 10px;
}
.fancytree-custom-icon {
font-size: 2em;
}
.fancytree-title {
font-size: 1.5em;
margin-inline-start: 0.6em !important;
}
.fancytree-node {
padding: 5px;
}
.fancytree-node .fancytree-expander:before {
font-size: 2em !important;
}
span.fancytree-expander {
width: 24px !important;
margin-inline-end: 5px;
}
.fancytree-loading span.fancytree-expander {
width: 24px;
height: 32px;
}
.fancytree-loading span.fancytree-expander:after {
width: 20px;
height: 20px;
margin-top: 4px;
border-width: 2px;
border-style: solid;
}
.tree-wrapper .collapse-tree-button,
.tree-wrapper .scroll-to-active-note-button,
.tree-wrapper .tree-settings-button {
position: fixed;
margin-inline-end: 16px;
display: none;
}
.tree-wrapper .unhoist-button {
font-size: 200%;
}
</style>`;
export default class MobileLayout {
getRootWidget(appContext: typeof AppContext) {
const rootContainer = new RootContainer(true)
.setParent(appContext)
.class("horizontal-layout")
.cssBlock(MOBILE_CSS)
.child(new FlexContainer("column").id("mobile-sidebar-container"))
.child(
new FlexContainer("row")
@@ -47,7 +136,7 @@ export default class MobileLayout {
.css("padding-inline-start", "0")
.css("padding-inline-end", "0")
.css("contain", "content")
.child(new FlexContainer("column").filling().id("mobile-sidebar-wrapper").child(new QuickSearchWidget()).child(new NoteTreeWidget()))
.child(new FlexContainer("column").filling().id("mobile-sidebar-wrapper").child(new QuickSearchWidget()).child(new NoteTreeWidget().cssBlock(FANCYTREE_CSS)))
)
.child(
new ScreenContainer("detail", "row")
@@ -56,31 +145,32 @@ export default class MobileLayout {
.child(
new SplitNoteContainer(() =>
new NoteWrapperWidget()
.optChild(glob.isStandalone, <StandaloneWarningBar />)
.child(
new FlexContainer("row")
.class("title-row note-split-title")
.contentSized()
.css("font-size", "larger")
.css("align-items", "center")
.child(<ToggleSidebarButton />)
.child(<NoteIconWidget />)
.child(<NoteTitleWidget />)
.child(<NoteBadges />)
.child(<MobileDetailMenu />)
)
.child(<FloatingButtons items={MOBILE_FLOATING_BUTTONS} />)
.child(<PromotedAttributes />)
.child(
new ScrollingContainer()
.filling()
.contentSized()
.child(<InlineTitle />)
.child(<NoteTitleActions />)
.child(new ContentHeader()
.child(<ReadOnlyNoteInfoBar />)
.child(<SharedInfoWidget />)
)
.child(<NoteDetail />)
.child(<NoteList media="screen" />)
.child(<StandaloneRibbonAdapter component={SearchDefinitionTab} />)
.child(<SearchResult />)
.child(<ScrollPadding />)
.child(<FilePropertiesWrapper />)
)
.child(<MobileEditorToolbar />)
.child(new FindWidget())
)
)
)
@@ -89,6 +179,7 @@ export default class MobileLayout {
new FlexContainer("column")
.contentSized()
.id("mobile-bottom-bar")
.child(new TabRowWidget().css("height", "40px"))
.child(new FlexContainer("row")
.class("horizontal")
.css("height", "53px")
@@ -101,3 +192,13 @@ export default class MobileLayout {
return rootContainer;
}
}
function FilePropertiesWrapper() {
const { note, ntxId } = useNoteContext();
return (
<div>
{note?.type === "file" && <FilePropertiesTab note={note} ntxId={ntxId} />}
</div>
);
}

View File

@@ -1,9 +1,8 @@
import { KeyboardActionNames } from "@triliumnext/commons";
import { h, JSX, render } from "preact";
import keyboardActionService, { getActionSync } from "../services/keyboard_actions.js";
import note_tooltip from "../services/note_tooltip.js";
import utils from "../services/utils.js";
import { h, JSX, render } from "preact";
export interface ContextMenuOptions<T> {
x: number;
@@ -63,17 +62,17 @@ export type ContextMenuEvent = PointerEvent | MouseEvent | JQuery.ContextMenuEve
class ContextMenu {
private $widget: JQuery<HTMLElement>;
private $cover?: JQuery<HTMLElement>;
private $cover: JQuery<HTMLElement>;
private options?: ContextMenuOptions<any>;
private isMobile: boolean;
constructor() {
this.$widget = $("#context-menu-container");
this.$cover = $("#context-menu-cover");
this.$widget.addClass("dropend");
this.isMobile = utils.isMobile();
if (this.isMobile) {
this.$cover = $("#context-menu-cover");
this.$cover.on("click", () => this.hide());
} else {
$(document).on("click", (e) => this.hide());
@@ -92,7 +91,7 @@ class ContextMenu {
}
this.$widget.toggleClass("mobile-bottom-menu", !this.options.forcePositionOnMobile);
this.$cover?.addClass("show");
this.$cover.addClass("show");
$("body").addClass("context-menu-shown");
this.$widget.empty();
@@ -141,14 +140,16 @@ class ContextMenu {
} else {
left = this.options.x - contextMenuWidth + CONTEXT_MENU_OFFSET;
}
} else if (contextMenuWidth && this.options.x + contextMenuWidth - CONTEXT_MENU_OFFSET > clientWidth - CONTEXT_MENU_PADDING) {
// Overflow: right
left = clientWidth - contextMenuWidth - CONTEXT_MENU_PADDING;
} else if (this.options.x - CONTEXT_MENU_OFFSET < CONTEXT_MENU_PADDING) {
// Overflow: left
left = CONTEXT_MENU_PADDING;
} else {
left = this.options.x - CONTEXT_MENU_OFFSET;
if (contextMenuWidth && this.options.x + contextMenuWidth - CONTEXT_MENU_OFFSET > clientWidth - CONTEXT_MENU_PADDING) {
// Overflow: right
left = clientWidth - contextMenuWidth - CONTEXT_MENU_PADDING;
} else if (this.options.x - CONTEXT_MENU_OFFSET < CONTEXT_MENU_PADDING) {
// Overflow: left
left = CONTEXT_MENU_PADDING;
} else {
left = this.options.x - CONTEXT_MENU_OFFSET;
}
}
this.$widget
@@ -248,7 +249,7 @@ class ContextMenu {
if ("uiIcon" in item || "checked" in item) {
const icon = (item.checked ? "bx bx-check" : item.uiIcon);
if (icon) {
$icon.addClass([icon, "tn-icon"]);
$icon.addClass(icon);
} else {
$icon.append("&nbsp;");
}
@@ -260,7 +261,7 @@ class ContextMenu {
.append(item.title);
if ("badges" in item && item.badges) {
for (const badge of item.badges) {
for (let badge of item.badges) {
const badgeElement = $(`<span class="badge">`).text(badge.title);
if (badge.className) {
@@ -351,7 +352,7 @@ class ContextMenu {
async hide() {
this.options?.onHide?.();
this.$widget.removeClass("show");
this.$cover?.removeClass("show");
this.$cover.removeClass("show");
$("body").removeClass("context-menu-shown");
this.$widget.hide();
}

View File

@@ -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") {

View File

@@ -1,12 +1,12 @@
import type { ContextMenuCommandData,FilteredCommandNames } from "../components/app_context.js";
import type { SelectMenuItemEventListener } from "../components/events.js";
import dialogService from "../services/dialog.js";
import froca from "../services/froca.js";
import { t } from "../services/i18n.js";
import server from "../services/server.js";
import treeService from "../services/tree.js";
import type NoteTreeWidget from "../widgets/note_tree.js";
import froca from "../services/froca.js";
import contextMenu, { type MenuCommandItem, type MenuItem } from "./context_menu.js";
import dialogService from "../services/dialog.js";
import server from "../services/server.js";
import { t } from "../services/i18n.js";
import type { SelectMenuItemEventListener } from "../components/events.js";
import type NoteTreeWidget from "../widgets/note_tree.js";
import type { FilteredCommandNames, ContextMenuCommandData } from "../components/app_context.js";
type LauncherCommandNames = FilteredCommandNames<ContextMenuCommandData>;
@@ -32,8 +32,8 @@ export default class LauncherContextMenu implements SelectMenuItemEventListener<
const note = this.node.data.noteId ? await froca.getNote(this.node.data.noteId) : null;
const parentNoteId = this.node.getParent().data.noteId;
const isVisibleRoot = note?.noteId === "_lbVisibleLaunchers" || note?.noteId === "_lbMobileVisibleLaunchers";
const isAvailableRoot = note?.noteId === "_lbAvailableLaunchers" || note?.noteId === "_lbMobileAvailableLaunchers";
const isVisibleRoot = note?.noteId === "_lbVisibleLaunchers";
const isAvailableRoot = note?.noteId === "_lbAvailableLaunchers";
const isVisibleItem = parentNoteId === "_lbVisibleLaunchers" || parentNoteId === "_lbMobileVisibleLaunchers";
const isAvailableItem = parentNoteId === "_lbAvailableLaunchers" || parentNoteId === "_lbMobileAvailableLaunchers";
const isItem = isVisibleItem || isAvailableItem;

View File

@@ -18,10 +18,6 @@ export type PrintReport = {
} | {
type: "collection";
ignoredNoteIds: string[];
} | {
type: "error";
message: string;
stack?: string;
};
async function main() {
@@ -33,9 +29,7 @@ async function main() {
const froca = (await import("./services/froca")).default;
const note = await froca.getNote(noteId);
const bodyWrapper = document.createElement("div");
render(<App note={note} noteId={noteId} />, bodyWrapper);
document.body.appendChild(bodyWrapper);
render(<App note={note} noteId={noteId} />, document.body);
}
function App({ note, noteId }: { note: FNote | null | undefined, noteId: string }) {

View File

@@ -8,17 +8,6 @@ async function loadBootstrap() {
}
}
// Polyfill removed jQuery methods for autocomplete.js compatibility
($ as any).isArray = Array.isArray;
($ as any).isFunction = function(obj: any) { return typeof obj === 'function'; };
($ as any).isPlainObject = function(obj: any) {
if (obj == null || typeof obj !== 'object') { return false; }
const proto = Object.getPrototypeOf(obj);
if (proto === null) { return true; }
const Ctor = Object.prototype.hasOwnProperty.call(proto, 'constructor') && proto.constructor;
return typeof Ctor === 'function' && Ctor === Object;
};
(window as any).$ = $;
(window as any).jQuery = $;
await loadBootstrap();

View File

@@ -42,7 +42,7 @@ describe("Set boolean with inheritance", () => {
name: "foo",
value: "",
isInheritable: false
}, undefined);
});
});
it("removes boolean normally without inheritance", async () => {
@@ -91,7 +91,7 @@ describe("Set boolean with inheritance", () => {
name: "foo",
value: "false",
isInheritable: false
}, undefined);
});
});
it("overrides boolean with inherited false", async () => {
@@ -112,7 +112,7 @@ describe("Set boolean with inheritance", () => {
name: "foo",
value: "",
isInheritable: false
}, undefined);
});
});
it("deletes override boolean with inherited false with already existing value", async () => {
@@ -134,6 +134,6 @@ describe("Set boolean with inheritance", () => {
name: "foo",
value: "",
isInheritable: false
}, undefined);
});
});
});

View File

@@ -14,13 +14,13 @@ async function addLabel(noteId: string, name: string, value: string = "", isInhe
});
}
export async function setLabel(noteId: string, name: string, value: string = "", isInheritable = false, componentId?: string) {
export async function setLabel(noteId: string, name: string, value: string = "", isInheritable = false) {
await server.put(`notes/${noteId}/set-attribute`, {
type: "label",
name,
value,
isInheritable,
}, componentId);
isInheritable
});
}
export async function setRelation(noteId: string, name: string, value: string = "", isInheritable = false) {
@@ -117,15 +117,15 @@ function removeOwnedRelationByName(note: FNote, relationName: string) {
* @param name the name of the attribute to set.
* @param value the value of the attribute to set.
*/
export async function setAttribute(note: FNote, type: "label" | "relation", name: string, value: string | null | undefined, componentId?: string) {
export async function setAttribute(note: FNote, type: "label" | "relation", name: string, value: string | null | undefined) {
if (value !== null && value !== undefined) {
// Create or update the attribute.
await server.put(`notes/${note.noteId}/set-attribute`, { type, name, value }, componentId);
await server.put(`notes/${note.noteId}/set-attribute`, { type, name, value });
} else {
// Remove the attribute if it exists on the server but we don't define a value for it.
const attributeId = note.getAttribute(type, name)?.attributeId;
if (attributeId) {
await server.remove(`notes/${note.noteId}/attributes/${attributeId}`, componentId);
await server.remove(`notes/${note.noteId}/attributes/${attributeId}`);
}
}
}
@@ -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
};

View File

@@ -103,7 +103,7 @@ async function moveToParentNote(branchIdsToMove: string[], newParentBranchId: st
* @param moveToParent whether to automatically go to the parent note path after a succesful delete. Usually makes sense if deleting the active note(s).
* @returns promise that returns false if the operation was cancelled or there was nothing to delete, true if the operation succeeded.
*/
async function deleteNotes(branchIdsToDelete: string[], forceDeleteAllClones = false, moveToParent = true, componentId?: string) {
async function deleteNotes(branchIdsToDelete: string[], forceDeleteAllClones = false, moveToParent = true) {
branchIdsToDelete = filterRootNote(branchIdsToDelete);
if (branchIdsToDelete.length === 0) {
@@ -139,9 +139,9 @@ async function deleteNotes(branchIdsToDelete: string[], forceDeleteAllClones = f
const branch = froca.getBranch(branchIdToDelete);
if (deleteAllClones && branch) {
await server.remove(`notes/${branch.noteId}${query}`, componentId);
await server.remove(`notes/${branch.noteId}${query}`);
} else {
await server.remove(`branches/${branchIdToDelete}${query}`, componentId);
await server.remove(`branches/${branchIdToDelete}${query}`);
}
}

View File

@@ -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);
}
}

View File

@@ -1,9 +0,0 @@
.rendered-content.no-preview > div {
display: flex;
flex-direction: column;
justify-content: space-around;
align-items: center;
height: 100%;
font-size: 500%;
flex-grow: 1;
}

View File

@@ -1,7 +1,4 @@
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 +13,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 +51,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) {
@@ -77,9 +71,18 @@ export async function getRenderedContent(this: {} | { ctx: string }, entity: FNo
$renderedContent.append($("<div>").append("<div>This note is protected and to access it you need to enter password.</div>").append("<br/>").append($button));
} else if (entity instanceof FNote) {
$renderedContent.addClass("no-preview");
$renderedContent
.css("display", "flex")
.css("flex-direction", "column");
$renderedContent.append(
$("<div>").append($("<span>").addClass(entity.getIcon()))
$("<div>")
.css("display", "flex")
.css("justify-content", "space-around")
.css("align-items", "center")
.css("height", "100%")
.css("font-size", "500%")
.css("flex-grow", "1")
.append($("<span>").addClass(entity.getIcon()))
);
if (entity.type === "webView" && entity.hasLabel("webViewSrc")) {
@@ -180,7 +183,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 +196,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`))
@@ -293,11 +292,10 @@ function getRenderingType(entity: FNote | FAttachment) {
}
const mime = "mime" in entity && entity.mime;
const isIconPack = entity instanceof FNote && entity.hasLabel("iconPack");
if (type === "file" && mime === "application/pdf") {
type = "pdf";
} else if ((type === "file" || type === "viewConfig") && mime && CODE_MIME_TYPES.has(mime) && !isIconPack) {
} else if ((type === "file" || type === "viewConfig") && mime && CODE_MIME_TYPES.has(mime)) {
type = "code";
} else if (type === "file" && mime && mime.startsWith("audio/")) {
type = "audio";

View File

@@ -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

View File

@@ -49,7 +49,7 @@ function createClassForColor(colorString: string | null) {
return clsx("use-note-color", className, colorsWithHue.has(className) && "with-hue");
}
export function parseColor(color: string) {
function parseColor(color: string) {
try {
return Color(color.toLowerCase());
} catch (ex) {
@@ -77,7 +77,7 @@ function adjustColorLightness(color: ColorInstance, lightThemeMaxLightness: numb
}
/** Returns the hue of the specified color, or undefined if the color is grayscale. */
export function getHue(color: ColorInstance) {
function getHue(color: ColorInstance) {
const hslColor = color.hsl();
if (hslColor.saturationl() > 0) {
return hslColor.hue();

View File

@@ -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();

View File

@@ -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`;
}

View File

@@ -1,6 +1,5 @@
import { t } from "./i18n";
import options from "./options";
import { isMobile } from "./utils";
export interface ExperimentalFeature {
id: string;
@@ -22,7 +21,7 @@ let enabledFeatures: Set<ExperimentalFeatureId> | null = null;
export function isExperimentalFeatureEnabled(featureId: ExperimentalFeatureId): boolean {
if (featureId === "new-layout") {
return (isMobile() || options.is("newLayout"));
return options.is("newLayout");
}
return getEnabledFeatures().has(featureId);
@@ -30,7 +29,7 @@ export function isExperimentalFeatureEnabled(featureId: ExperimentalFeatureId):
export function getEnabledExperimentalFeatureIds() {
const values = [ ...getEnabledFeatures().values() ];
if (isMobile() || options.is("newLayout")) {
if (options.is("newLayout")) {
values.push("new-layout");
}
return values;

View File

@@ -27,10 +27,6 @@ async function processEntityChanges(entityChanges: EntityChange[]) {
loadResults.addRevision(ec.entityId, ec.noteId, ec.componentId);
} else if (ec.entityName === "options") {
const attributeEntity = ec.entity as FAttributeRow;
if (attributeEntity.name === "openNoteContexts") {
continue; // only noise
}
options.set(attributeEntity.name as OptionNames, attributeEntity.value);
loadResults.addOption(attributeEntity.name as OptionNames);
} else if (ec.entityName === "attachments") {
@@ -110,12 +106,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);
}
}

View File

@@ -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

View File

@@ -24,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);

View File

@@ -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 ?? ""]
}
}

View File

@@ -12,7 +12,7 @@ const SELECTED_NOTE_PATH_KEY = "data-note-path";
const SELECTED_EXTERNAL_LINK_KEY = "data-external-link";
// To prevent search lag when there are a large number of notes, set a delay based on the number of notes to avoid jitter.
const notesCount = 10000; // TODO: Replace with dynamic count from becca once available.
const notesCount = await server.get<number>(`autocomplete/notesCount`);
let debounceTimeoutId: ReturnType<typeof setTimeout>;
function getSearchDelay(notesCount: number): number {

View File

@@ -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) {

View File

@@ -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 || "";
}

View File

@@ -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 {

View File

@@ -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;

View 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
};

View File

@@ -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
};

View File

@@ -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,

View File

@@ -1,6 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import shortcuts, { isIMEComposing, keyMatches, matchesShortcut } from "./shortcuts.js";
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import shortcuts, { keyMatches, matchesShortcut, isIMEComposing } from "./shortcuts.js";
// Mock utils module
vi.mock("./utils.js", () => ({
@@ -62,10 +61,9 @@ describe("shortcuts", () => {
});
describe("keyMatches", () => {
const createKeyboardEvent = (key: string, code?: string, extraProps: Partial<KeyboardEvent> = {}) => ({
const createKeyboardEvent = (key: string, code?: string) => ({
key,
code: code || `Key${key.toUpperCase()}`,
...extraProps
code: code || `Key${key.toUpperCase()}`
} as KeyboardEvent);
it("should match regular letter keys using key code", () => {
@@ -103,23 +101,17 @@ describe("shortcuts", () => {
consoleSpy.mockRestore();
});
it("should match azerty keys", () => {
const event = createKeyboardEvent("A", "KeyQ");
expect(keyMatches(event, "a")).toBe(true);
expect(keyMatches(event, "q")).toBe(false);
});
it("should match letter keys using code when key is a special character (macOS Alt behavior)", () => {
// On macOS, pressing Option/Alt + A produces 'å' as the key, but code is still 'KeyA'
const macOSAltAEvent = createKeyboardEvent("å", "KeyA", { altKey: true });
const macOSAltAEvent = createKeyboardEvent("å", "KeyA");
expect(keyMatches(macOSAltAEvent, "a")).toBe(true);
// Option + H produces '˙'
const macOSAltHEvent = createKeyboardEvent("˙", "KeyH", { altKey: true });
const macOSAltHEvent = createKeyboardEvent("˙", "KeyH");
expect(keyMatches(macOSAltHEvent, "h")).toBe(true);
// Option + S produces 'ß'
const macOSAltSEvent = createKeyboardEvent("ß", "KeyS", { altKey: true });
const macOSAltSEvent = createKeyboardEvent("ß", "KeyS");
expect(keyMatches(macOSAltSEvent, "s")).toBe(true);
});
});
@@ -223,15 +215,6 @@ describe("shortcuts", () => {
consoleSpy.mockRestore();
});
it("matches azerty", () => {
const event = createKeyboardEvent({
key: "a",
code: "KeyQ",
ctrlKey: true
});
expect(matchesShortcut(event, "Ctrl+A")).toBe(true);
});
it("should match Alt+letter shortcuts on macOS where key is a special character", () => {
// On macOS, pressing Option/Alt + A produces 'å' but code remains 'KeyA'
const macOSAltAEvent = createKeyboardEvent({

Some files were not shown because too many files have changed in this diff Show More