mirror of
				https://github.com/zadam/trilium.git
				synced 2025-11-03 20:06:08 +01:00 
			
		
		
		
	Compare commits
	
		
			329 Commits
		
	
	
		
			react/type
			...
			copilot/ch
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						 | 
					50a69248a7 | ||
| 
						 | 
					82e5de2261 | ||
| 
						 | 
					5b8bb8587d | ||
| 
						 | 
					7cdd8ffbe2 | ||
| 
						 | 
					4fc434a52e | ||
| 
						 | 
					4c5b2a7c75 | ||
| 
						 | 
					a5a90b582a | ||
| 
						 | 
					510601037d | ||
| 
						 | 
					b312b6f3bc | ||
| 
						 | 
					0c1efd3402 | ||
| 
						 | 
					69182a1a42 | ||
| 
						 | 
					8a63f2028c | ||
| 
						 | 
					947330ed73 | ||
| 
						 | 
					4526455486 | ||
| 
						 | 
					1d259aab9d | ||
| 
						 | 
					5171675dee | ||
| 
						 | 
					6bc54892a3 | ||
| 
						 | 
					0a6670ce5e | ||
| 
						 | 
					beb7c66ff5 | ||
| 
						 | 
					966e5a2ef3 | ||
| 
						 | 
					4f5be54030 | ||
| 
						 | 
					68c6260e45 | ||
| 
						 | 
					85bfd49d1c | ||
| 
						 | 
					23f2e1eb45 | ||
| 
						 | 
					9bbd6d146b | ||
| 
						 | 
					37aa8ec176 | ||
| 
						 | 
					05f3f9627d | ||
| 
						 | 
					a9fa99cadf | ||
| 
						 | 
					d5c1604a58 | ||
| 
						 | 
					e99f821e88 | ||
| 
						 | 
					6571ff9d84 | ||
| 
						 | 
					9d63ef20fb | ||
| 
						 | 
					71a3cf0cfe | ||
| 
						 | 
					e5e55e1cf1 | ||
| 
						 | 
					83b13cae92 | ||
| 
						 | 
					6663c3abc1 | ||
| 
						 | 
					738b28c2b3 | ||
| 
						 | 
					4eec6021c3 | ||
| 
						 | 
					39bda30853 | ||
| 
						 | 
					6adcaca5e0 | ||
| 
						 | 
					5a5f71fc71 | ||
| 
						 | 
					f3765f95b5 | ||
| 
						 | 
					6ebb0eb03e | ||
| 
						 | 
					b42aa32b72 | ||
| 
						 | 
					11e1ea7ea5 | ||
| 
						 | 
					5f6fac994f | ||
| 
						 | 
					d7460e9fe5 | ||
| 
						 | 
					89585e38ce | ||
| 
						 | 
					938c6e356b | ||
| 
						 | 
					b7dd806d07 | ||
| 
						 | 
					a1d86cef58 | ||
| 
						 | 
					1fec5bb564 | ||
| 
						 | 
					35e98addc8 | ||
| 
						 | 
					79290633b1 | ||
| 
						 | 
					ffc9e715ef | ||
| 
						 | 
					8f8302c4a3 | ||
| 
						 | 
					136b449f60 | ||
| 
						 | 
					61319c3a14 | ||
| 
						 | 
					104a1f0c3a | ||
| 
						 | 
					16200312ce | ||
| 
						 | 
					664de68d53 | ||
| 
						 | 
					6190949dcc | ||
| 
						 | 
					3ac248169f | ||
| 
						 | 
					15e240ac33 | ||
| 
						 | 
					d30fc09e73 | ||
| 
						 | 
					6a2b9b748f | ||
| 
						 | 
					b5e2187c0d | ||
| 
						 | 
					d9a349a531 | ||
| 
						 | 
					2c1cebfbc3 | ||
| 
						 | 
					c6738ac52f | ||
| 
						 | 
					604f2abf5a | ||
| 
						 | 
					617703899f | ||
| 
						 | 
					6322ca11c9 | ||
| 
						 | 
					d62aecc551 | ||
| 
						 | 
					953b376ce3 | ||
| 
						 | 
					80f1707d8b | ||
| 
						 | 
					4f9f8652e2 | ||
| 
						 | 
					6e06d7169f | ||
| 
						 | 
					ecf12a4063 | ||
| 
						 | 
					64428ae761 | ||
| 
						 | 
					3524c34ff9 | ||
| 
						 | 
					3f99c8b337 | ||
| 
						 | 
					27d9ae885f | ||
| 
						 | 
					35efd2a680 | ||
| 
						 | 
					19c6ae6fe5 | ||
| 
						 | 
					bf0761a303 | ||
| 
						 | 
					8391fd7534 | ||
| 
						 | 
					6d4b87888a | ||
| 
						 | 
					3f0b0f9b62 | ||
| 
						 | 
					859d9dcd04 | ||
| 
						 | 
					76dd9baea8 | ||
| 
						 | 
					82ff5f6660 | ||
| 
						 | 
					b52d30c55a | ||
| 
						 | 
					99fd088ff5 | ||
| 
						 | 
					945f29c759 | ||
| 
						 | 
					98f42887d8 | ||
| 
						 | 
					a1b589148b | ||
| 
						 | 
					66bb639a15 | ||
| 
						 | 
					1784b50990 | ||
| 
						 | 
					5e9c271bfd | ||
| 
						 | 
					70837fdc69 | ||
| 
						 | 
					67d80512f6 | ||
| 
						 | 
					dd8a1e8aca | ||
| 
						 | 
					99f43e2280 | ||
| 
						 | 
					bcffa77c90 | ||
| 
						 | 
					8ab2069411 | ||
| 
						 | 
					21fc61d132 | ||
| 
						 | 
					552df50fe4 | ||
| 
						 | 
					c058b663ee | ||
| 
						 | 
					6c19370235 | ||
| 
						 | 
					d332bb57ba | ||
| 
						 | 
					3ef38e7f4e | ||
| 
						 | 
					1abc3b5534 | ||
| 
						 | 
					ddcd27ddf6 | ||
| 
						 | 
					ff385c8c88 | ||
| 
						 | 
					a641e452ce | ||
| 
						 | 
					5f4fa25da5 | ||
| 
						 | 
					ea177e972e | ||
| 
						 | 
					7e3013bfdc | ||
| 
						 | 
					5115baeb21 | ||
| 
						 | 
					35a924a05a | ||
| 
						 | 
					78f067965f | ||
| 
						 | 
					413b16b51c | ||
| 
						 | 
					59586c53b2 | ||
| 
						 | 
					70ed1d7abb | ||
| 
						 | 
					67de6c614c | ||
| 
						 | 
					faf030ab3a | ||
| 
						 | 
					6e20d4b5dd | ||
| 
						 | 
					10e809af75 | ||
| 
						 | 
					f1478f8149 | ||
| 
						 | 
					9087adf254 | ||
| 
						 | 
					f944c6d8e2 | ||
| 
						 | 
					444e103047 | ||
| 
						 | 
					1d6ab64ae5 | ||
| 
						 | 
					0bc86d7c75 | ||
| 
						 | 
					cfd55147df | ||
| 
						 | 
					754bb61a52 | ||
| 
						 | 
					4f103375b5 | ||
| 
						 | 
					496091677b | ||
| 
						 | 
					618b67f551 | ||
| 
						 | 
					9c791df0ed | ||
| 
						 | 
					ce4f46c226 | ||
| 
						 | 
					44cfbcf7f4 | ||
| 
						 | 
					a317331551 | ||
| 
						 | 
					eeec3e440d | ||
| 
						 | 
					b06aa29ea3 | ||
| 
						 | 
					9c3f9a524e | ||
| 
						 | 
					1c832182d6 | ||
| 
						 | 
					b58e1f146c | ||
| 
						 | 
					bc86fb95b5 | ||
| 
						 | 
					6c43db692e | ||
| 
						 | 
					6ffce824d1 | ||
| 
						 | 
					f1f8f34ef8 | ||
| 
						 | 
					a0b19ce526 | ||
| 
						 | 
					4cc9ba824d | ||
| 
						 | 
					08e66c18e7 | ||
| 
						 | 
					cf8089b07f | ||
| 
						 | 
					f8c61ecde9 | ||
| 
						 | 
					6d51da9b88 | ||
| 
						 | 
					0ad95d00dc | ||
| 
						 | 
					9819a92b48 | ||
| 
						 | 
					55a7017e92 | ||
| 
						 | 
					1581568741 | ||
| 
						 | 
					d7982c65dd | ||
| 
						 | 
					39608a2815 | ||
| 
						 | 
					f656c2caaa | ||
| 
						 | 
					bd3e92f091 | ||
| 
						 | 
					7ce7c66463 | ||
| 
						 | 
					61d26fec60 | ||
| 
						 | 
					1822eea77c | ||
| 
						 | 
					28c0e4e802 | ||
| 
						 | 
					5b7e9d4c12 | ||
| 
						 | 
					bee2fdb22f | ||
| 
						 | 
					5c46a0dfa8 | ||
| 
						 | 
					88d90fdedd | ||
| 
						 | 
					2a1ecdbdca | ||
| 
						 | 
					5772046674 | ||
| 
						 | 
					1e2c8b2ac4 | ||
| 
						 | 
					955b202b8a | ||
| 
						 | 
					be98a27439 | ||
| 
						 | 
					54200fa0cb | ||
| 
						 | 
					5d82a26c87 | ||
| 
						 | 
					e51070e389 | ||
| 
						 | 
					e0dc4fee20 | ||
| 
						 | 
					e683dc1d66 | ||
| 
						 | 
					14a3438a20 | ||
| 
						 | 
					dd483fccbc | ||
| 
						 | 
					5620e7f4a7 | ||
| 
						 | 
					187e9b57de | ||
| 
						 | 
					d6d67e7957 | ||
| 
						 | 
					bde03e8378 | ||
| 
						 | 
					4c3fcdba4a | ||
| 
						 | 
					7a5c1277f1 | ||
| 
						 | 
					69b262040a | ||
| 
						 | 
					8731fa6c31 | ||
| 
						 | 
					f4e8fc4d83 | ||
| 
						 | 
					dd5b3a3c1c | ||
| 
						 | 
					17319d25e8 | ||
| 
						 | 
					2f189b6961 | ||
| 
						 | 
					b1f8d44576 | ||
| 
						 | 
					7f22532a0a | ||
| 
						 | 
					c7beb87980 | ||
| 
						 | 
					5cd1fd53d4 | ||
| 
						 | 
					2eadbe3f01 | ||
| 
						 | 
					4e7493f648 | ||
| 
						 | 
					b9d54a44f6 | ||
| 
						 | 
					a1ad8be02b | ||
| 
						 | 
					b02514f395 | ||
| 
						 | 
					dcef3f2be5 | ||
| 
						 | 
					585fdabd27 | ||
| 
						 | 
					71fcb77a22 | ||
| 
						 | 
					33ecf6aa6d | ||
| 
						 | 
					1f75de83c6 | ||
| 
						 | 
					31b52f72d2 | ||
| 
						 | 
					01aaf81196 | ||
| 
						 | 
					3ecfdd62e8 | ||
| 
						 | 
					3c74d0714a | ||
| 
						 | 
					f58d9adff2 | ||
| 
						 | 
					0eecf5b132 | ||
| 
						 | 
					9e3cca333a | ||
| 
						 | 
					81c233463e | ||
| 
						 | 
					87946e7e85 | ||
| 
						 | 
					c3768a051d | ||
| 
						 | 
					c579cd3ce7 | ||
| 
						 | 
					945e2625d3 | ||
| 
						 | 
					ff36414a55 | ||
| 
						 | 
					8f184c5b10 | ||
| 
						 | 
					c027a2bbfa | ||
| 
						 | 
					91adc2258d | ||
| 
						 | 
					6701e83927 | ||
| 
						 | 
					3f54e589d8 | ||
| 
						 | 
					f65be73f71 | ||
| 
						 | 
					346e9282bd | ||
| 
						 | 
					8f8ea7adc3 | ||
| 
						 | 
					4affd3a955 | ||
| 
						 | 
					bcce05cc4d | ||
| 
						 | 
					ac16c42e23 | ||
| 
						 | 
					5025329e92 | ||
| 
						 | 
					507910b0ce | ||
| 
						 | 
					b59fab9dba | ||
| 
						 | 
					ac7e4580f6 | ||
| 
						 | 
					27d1044ba8 | ||
| 
						 | 
					96c949b2fc | ||
| 
						 | 
					927cd0255e | ||
| 
						 | 
					c2c8417c42 | ||
| 
						 | 
					3bb224e682 | ||
| 
						 | 
					6f126ea17b | ||
| 
						 | 
					61a5cf1452 | ||
| 
						 | 
					14b8d0a47e | ||
| 
						 | 
					12df6a0d6e | ||
| 
						 | 
					21d243eec1 | ||
| 
						 | 
					161238ca11 | ||
| 
						 | 
					4d5267e18b | ||
| 
						 | 
					0fa52907b3 | ||
| 
						 | 
					c4f57f3d15 | ||
| 
						 | 
					6bde264156 | ||
| 
						 | 
					4f72f81a95 | ||
| 
						 | 
					c212c5d6ff | ||
| 
						 | 
					f24880d42c | ||
| 
						 | 
					ee9bf1d47b | ||
| 
						 | 
					b069fab82f | ||
| 
						 | 
					d5ce01a65b | ||
| 
						 | 
					dbfa94a9ee | ||
| 
						 | 
					86aaa97809 | ||
| 
						 | 
					c4c8fe23a9 | ||
| 
						 | 
					715fe77db3 | ||
| 
						 | 
					40f5abd6e3 | ||
| 
						 | 
					f3f7e5900b | ||
| 
						 | 
					f4402a6d81 | ||
| 
						 | 
					6966efd374 | ||
| 
						 | 
					cd3e025fdc | ||
| 
						 | 
					a224b774d3 | ||
| 
						 | 
					f20078f3b0 | ||
| 
						 | 
					56019e5449 | ||
| 
						 | 
					7dd517d8f7 | ||
| 
						 | 
					b2f1b3c910 | ||
| 
						 | 
					2197fae700 | ||
| 
						 | 
					3661733f07 | ||
| 
						 | 
					52a6f2597e | ||
| 
						 | 
					ba26c478d6 | ||
| 
						 | 
					055fcb7b2a | ||
| 
						 | 
					f4468706ef | ||
| 
						 | 
					212956201a | ||
| 
						 | 
					1182592fc5 | ||
| 
						 | 
					1a68bdfe02 | ||
| 
						 | 
					0c399a676a | ||
| 
						 | 
					395f33cd5b | ||
| 
						 | 
					21b20cf575 | ||
| 
						 | 
					e3dd25b591 | ||
| 
						 | 
					b9a4e7ab11 | ||
| 
						 | 
					6ae67c410c | ||
| 
						 | 
					4ef7667484 | ||
| 
						 | 
					3660e2f127 | ||
| 
						 | 
					357d294f2d | ||
| 
						 | 
					bb636128b0 | ||
| 
						 | 
					aa102ab393 | ||
| 
						 | 
					ea53665e64 | ||
| 
						 | 
					9cf7fa1997 | ||
| 
						 | 
					fded714f18 | ||
| 
						 | 
					06de06b501 | ||
| 
						 | 
					9abdbbbc5b | ||
| 
						 | 
					3ebfee8bd2 | ||
| 
						 | 
					6d446c5b27 | ||
| 
						 | 
					3a55490bbf | ||
| 
						 | 
					bc4643fed2 | ||
| 
						 | 
					a2110ca631 | ||
| 
						 | 
					413137ac64 | ||
| 
						 | 
					9bc966491d | ||
| 
						 | 
					61dbc15fc6 | ||
| 
						 | 
					b475037127 | ||
| 
						 | 
					35622a2122 | ||
| 
						 | 
					77e4c3d0ec | ||
| 
						 | 
					8523050ab2 | ||
| 
						 | 
					0efdf65202 | ||
| 
						 | 
					acb0991d05 | ||
| 
						 | 
					a9f68f5487 | ||
| 
						 | 
					55bb2fdb9b | ||
| 
						 | 
					e529633b8b | ||
| 
						 | 
					dfd575b6eb | ||
| 
						 | 
					c5196721d4 | ||
| 
						 | 
					968c75b618 | ||
| 
						 | 
					01beebf660 | ||
| 
						 | 
					d3115e834a | ||
| 
						 | 
					01a552ceb5 | ||
| 
						 | 
					d8958adea5 | ||
| 
						 | 
					4d5e866db6 | ||
| 
						 | 
					f189deb415 | ||
| 
						 | 
					9c460dbc87 | ||
| 
						 | 
					2c6ba9ba2c | 
							
								
								
									
										2
									
								
								.github/actions/build-server/action.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/actions/build-server/action.yml
									
									
									
									
										vendored
									
									
								
							@@ -12,7 +12,7 @@ runs:
 | 
			
		||||
  - name: Set up node & dependencies
 | 
			
		||||
    uses: actions/setup-node@v6
 | 
			
		||||
    with:
 | 
			
		||||
      node-version: 22
 | 
			
		||||
      node-version: 24
 | 
			
		||||
      cache: "pnpm"
 | 
			
		||||
  - name: Install dependencies
 | 
			
		||||
    shell: bash
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										75
									
								
								.github/workflows/deploy-docs.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										75
									
								
								.github/workflows/deploy-docs.yml
									
									
									
									
										vendored
									
									
								
							@@ -1,6 +1,4 @@
 | 
			
		||||
# GitHub Actions workflow for deploying MkDocs documentation to Cloudflare Pages
 | 
			
		||||
# This workflow builds and deploys your MkDocs site when changes are pushed to main
 | 
			
		||||
name: Deploy MkDocs Documentation
 | 
			
		||||
name: Deploy Documentation
 | 
			
		||||
 | 
			
		||||
on:
 | 
			
		||||
  # Trigger on push to main branch
 | 
			
		||||
@@ -11,11 +9,8 @@ on:
 | 
			
		||||
    # Only run when docs files change
 | 
			
		||||
    paths:
 | 
			
		||||
      - 'docs/**'
 | 
			
		||||
      - 'README.md'  # README is synced to docs/index.md
 | 
			
		||||
      - 'mkdocs.yml'
 | 
			
		||||
      - 'requirements-docs.txt'
 | 
			
		||||
      - '.github/workflows/deploy-docs.yml'
 | 
			
		||||
      - 'scripts/fix-mkdocs-structure.ts'
 | 
			
		||||
      - 'apps/edit-docs/**'
 | 
			
		||||
      - 'packages/share-theme/**'
 | 
			
		||||
 | 
			
		||||
  # Allow manual triggering from Actions tab
 | 
			
		||||
  workflow_dispatch:
 | 
			
		||||
@@ -27,15 +22,12 @@ on:
 | 
			
		||||
      - master
 | 
			
		||||
    paths:
 | 
			
		||||
      - 'docs/**'
 | 
			
		||||
      - 'README.md'  # README is synced to docs/index.md
 | 
			
		||||
      - 'mkdocs.yml'
 | 
			
		||||
      - 'requirements-docs.txt'
 | 
			
		||||
      - '.github/workflows/deploy-docs.yml'
 | 
			
		||||
      - 'scripts/fix-mkdocs-structure.ts'
 | 
			
		||||
      - 'apps/edit-docs/**'
 | 
			
		||||
      - 'packages/share-theme/**'
 | 
			
		||||
 | 
			
		||||
jobs:
 | 
			
		||||
  build-and-deploy:
 | 
			
		||||
    name: Build and Deploy MkDocs
 | 
			
		||||
    name: Build and Deploy Documentation
 | 
			
		||||
    runs-on: ubuntu-latest
 | 
			
		||||
    timeout-minutes: 10
 | 
			
		||||
 | 
			
		||||
@@ -49,72 +41,25 @@ jobs:
 | 
			
		||||
    steps:
 | 
			
		||||
      - name: Checkout Repository
 | 
			
		||||
        uses: actions/checkout@v5
 | 
			
		||||
        with:
 | 
			
		||||
          fetch-depth: 0 # Fetch all history for git info and mkdocs-git-revision-date plugin
 | 
			
		||||
 | 
			
		||||
      - name: Setup Python
 | 
			
		||||
        uses: actions/setup-python@v6
 | 
			
		||||
        with:
 | 
			
		||||
          python-version: '3.14'
 | 
			
		||||
          cache: 'pip'
 | 
			
		||||
          cache-dependency-path: 'requirements-docs.txt'
 | 
			
		||||
 | 
			
		||||
      - name: Install MkDocs and Dependencies
 | 
			
		||||
        run: |
 | 
			
		||||
          pip install --upgrade pip
 | 
			
		||||
          pip install -r requirements-docs.txt
 | 
			
		||||
        env:
 | 
			
		||||
          PIP_DISABLE_PIP_VERSION_CHECK: 1
 | 
			
		||||
 | 
			
		||||
      # Setup pnpm before fixing docs structure
 | 
			
		||||
      - name: Setup pnpm
 | 
			
		||||
        uses: pnpm/action-setup@v4
 | 
			
		||||
 | 
			
		||||
      # Setup Node.js with pnpm
 | 
			
		||||
      - name: Setup Node.js
 | 
			
		||||
        uses: actions/setup-node@v6
 | 
			
		||||
        with:
 | 
			
		||||
          node-version: '22'
 | 
			
		||||
          node-version: '24'
 | 
			
		||||
          cache: 'pnpm'
 | 
			
		||||
 | 
			
		||||
      # Install Node.js dependencies for the TypeScript script
 | 
			
		||||
      - name: Install Dependencies
 | 
			
		||||
        run: |
 | 
			
		||||
          pnpm install --frozen-lockfile
 | 
			
		||||
        run: pnpm install --frozen-lockfile
 | 
			
		||||
 | 
			
		||||
      - name: Fix Documentation Structure
 | 
			
		||||
        run: |
 | 
			
		||||
          # Fix duplicate navigation entries by moving overview pages to index.md
 | 
			
		||||
          pnpm run chore:fix-mkdocs-structure
 | 
			
		||||
 | 
			
		||||
      - name: Build MkDocs Site
 | 
			
		||||
        run: |
 | 
			
		||||
          # Build with strict mode but allow expected warnings
 | 
			
		||||
          mkdocs build --verbose || {
 | 
			
		||||
            EXIT_CODE=$?
 | 
			
		||||
            # Check if the only issue is expected warnings
 | 
			
		||||
            if mkdocs build 2>&1 | grep -E "WARNING.*(README|not found)" && \
 | 
			
		||||
               [ $(mkdocs build 2>&1 | grep -c "ERROR") -eq 0 ]; then
 | 
			
		||||
              echo "✅ Build succeeded with expected warnings"
 | 
			
		||||
              mkdocs build --verbose
 | 
			
		||||
            else
 | 
			
		||||
              echo "❌ Build failed with unexpected errors"
 | 
			
		||||
              exit $EXIT_CODE
 | 
			
		||||
            fi
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
      - name: Fix HTML Links
 | 
			
		||||
        run: |
 | 
			
		||||
          # Remove .md extensions from links in generated HTML
 | 
			
		||||
          pnpm tsx ./scripts/fix-html-links.ts site
 | 
			
		||||
      - name: Trigger build of documentation
 | 
			
		||||
        run: pnpm docs:build
 | 
			
		||||
 | 
			
		||||
      - name: Validate Built Site
 | 
			
		||||
        run: |
 | 
			
		||||
          # Basic validation that important files exist
 | 
			
		||||
          test -f site/index.html || (echo "ERROR: site/index.html not found" && exit 1)
 | 
			
		||||
          test -f site/sitemap.xml || (echo "ERROR: site/sitemap.xml not found" && exit 1)
 | 
			
		||||
          test -d site/assets || (echo "ERROR: site/assets directory not found" && exit 1)
 | 
			
		||||
          echo "✅ Site validation passed"
 | 
			
		||||
 | 
			
		||||
      - name: Deploy
 | 
			
		||||
        uses: ./.github/actions/deploy-to-cloudflare-pages
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										2
									
								
								.github/workflows/dev.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/dev.yml
									
									
									
									
										vendored
									
									
								
							@@ -30,7 +30,7 @@ jobs:
 | 
			
		||||
      - name: Set up node & dependencies
 | 
			
		||||
        uses: actions/setup-node@v6
 | 
			
		||||
        with:
 | 
			
		||||
          node-version: 22
 | 
			
		||||
          node-version: 24
 | 
			
		||||
          cache: "pnpm"
 | 
			
		||||
      - run: pnpm install --frozen-lockfile
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										8
									
								
								.github/workflows/main-docker.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										8
									
								
								.github/workflows/main-docker.yml
									
									
									
									
										vendored
									
									
								
							@@ -46,7 +46,7 @@ jobs:
 | 
			
		||||
      - name: Set up node & dependencies
 | 
			
		||||
        uses: actions/setup-node@v6
 | 
			
		||||
        with:
 | 
			
		||||
          node-version: 22
 | 
			
		||||
          node-version: 24
 | 
			
		||||
          cache: "pnpm"
 | 
			
		||||
 | 
			
		||||
      - name: Install npm dependencies
 | 
			
		||||
@@ -116,10 +116,10 @@ jobs:
 | 
			
		||||
          - dockerfile: Dockerfile
 | 
			
		||||
            platform: linux/arm64
 | 
			
		||||
            image: ubuntu-24.04-arm
 | 
			
		||||
          - dockerfile: Dockerfile
 | 
			
		||||
          - dockerfile: Dockerfile.legacy
 | 
			
		||||
            platform: linux/arm/v7
 | 
			
		||||
            image: ubuntu-24.04-arm
 | 
			
		||||
          - dockerfile: Dockerfile
 | 
			
		||||
          - dockerfile: Dockerfile.legacy
 | 
			
		||||
            platform: linux/arm/v8
 | 
			
		||||
            image: ubuntu-24.04-arm
 | 
			
		||||
    runs-on: ${{ matrix.image }}
 | 
			
		||||
@@ -146,7 +146,7 @@ jobs:
 | 
			
		||||
      - name: Set up node & dependencies
 | 
			
		||||
        uses: actions/setup-node@v6
 | 
			
		||||
        with:
 | 
			
		||||
          node-version: 22
 | 
			
		||||
          node-version: 24
 | 
			
		||||
          cache: 'pnpm'
 | 
			
		||||
 | 
			
		||||
      - name: Install dependencies
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										2
									
								
								.github/workflows/nightly.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/nightly.yml
									
									
									
									
										vendored
									
									
								
							@@ -52,7 +52,7 @@ jobs:
 | 
			
		||||
      - name: Set up node & dependencies
 | 
			
		||||
        uses: actions/setup-node@v6
 | 
			
		||||
        with:
 | 
			
		||||
          node-version: 22
 | 
			
		||||
          node-version: 24
 | 
			
		||||
          cache: 'pnpm'
 | 
			
		||||
      - name: Install dependencies
 | 
			
		||||
        run: pnpm install --frozen-lockfile
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										2
									
								
								.github/workflows/playwright.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/playwright.yml
									
									
									
									
										vendored
									
									
								
							@@ -24,7 +24,7 @@ jobs:
 | 
			
		||||
      - uses: pnpm/action-setup@v4
 | 
			
		||||
      - uses: actions/setup-node@v6
 | 
			
		||||
        with:
 | 
			
		||||
          node-version: 22
 | 
			
		||||
          node-version: 24
 | 
			
		||||
          cache: 'pnpm'
 | 
			
		||||
 | 
			
		||||
      - name: Install dependencies
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										2
									
								
								.github/workflows/release.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/release.yml
									
									
									
									
										vendored
									
									
								
							@@ -50,7 +50,7 @@ jobs:
 | 
			
		||||
      - name: Set up node & dependencies
 | 
			
		||||
        uses: actions/setup-node@v6
 | 
			
		||||
        with:
 | 
			
		||||
          node-version: 22
 | 
			
		||||
          node-version: 24
 | 
			
		||||
          cache: 'pnpm'
 | 
			
		||||
      - name: Install dependencies
 | 
			
		||||
        run: pnpm install --frozen-lockfile
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										2
									
								
								.github/workflows/website.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/website.yml
									
									
									
									
										vendored
									
									
								
							@@ -30,7 +30,7 @@ jobs:
 | 
			
		||||
      - name: Set up node & dependencies
 | 
			
		||||
        uses: actions/setup-node@v6
 | 
			
		||||
        with:
 | 
			
		||||
          node-version: 22
 | 
			
		||||
          node-version: 24
 | 
			
		||||
          cache: "pnpm"
 | 
			
		||||
 | 
			
		||||
      - name: Install dependencies
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										4
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							@@ -49,3 +49,7 @@ upload
 | 
			
		||||
 | 
			
		||||
# docs
 | 
			
		||||
site/
 | 
			
		||||
 | 
			
		||||
# TypeScript and JavaScript maps
 | 
			
		||||
*.js.map
 | 
			
		||||
*.d.ts.map
 | 
			
		||||
 
 | 
			
		||||
@@ -37,20 +37,18 @@
 | 
			
		||||
  "devDependencies": {    
 | 
			
		||||
    "@playwright/test": "1.56.1",
 | 
			
		||||
    "@stylistic/eslint-plugin": "5.5.0",        
 | 
			
		||||
    "@types/express": "5.0.4",    
 | 
			
		||||
    "@types/node": "22.18.12",    
 | 
			
		||||
    "@types/express": "5.0.5",    
 | 
			
		||||
    "@types/node": "24.9.2",    
 | 
			
		||||
    "@types/yargs": "17.0.34",
 | 
			
		||||
    "@vitest/coverage-v8": "3.2.4",
 | 
			
		||||
    "eslint": "9.38.0",
 | 
			
		||||
    "eslint": "9.39.0",
 | 
			
		||||
    "eslint-plugin-simple-import-sort": "12.1.1",
 | 
			
		||||
    "esm": "3.2.25",
 | 
			
		||||
    "jsdoc": "4.0.5",
 | 
			
		||||
    "lorem-ipsum": "2.0.8",    
 | 
			
		||||
    "rcedit": "4.0.1",
 | 
			
		||||
    "rimraf": "6.0.1",    
 | 
			
		||||
    "tslib": "2.8.1",    
 | 
			
		||||
    "typedoc": "0.28.14",
 | 
			
		||||
    "typedoc-plugin-missing-exports": "4.1.2"
 | 
			
		||||
    "rimraf": "6.1.0",    
 | 
			
		||||
    "tslib": "2.8.1" 
 | 
			
		||||
  },
 | 
			
		||||
  "optionalDependencies": {
 | 
			
		||||
    "appdmg": "0.6.6"
 | 
			
		||||
 
 | 
			
		||||
@@ -1,15 +0,0 @@
 | 
			
		||||
{
 | 
			
		||||
  "entryPoints": [
 | 
			
		||||
    "src/services/backend_script_entrypoint.ts",
 | 
			
		||||
    "src/public/app/services/frontend_script_entrypoint.ts"
 | 
			
		||||
  ],
 | 
			
		||||
  "plugin": [
 | 
			
		||||
    "typedoc-plugin-missing-exports"
 | 
			
		||||
  ],
 | 
			
		||||
  "outputs": [
 | 
			
		||||
    {
 | 
			
		||||
      "name": "html",
 | 
			
		||||
      "path": "./docs/Script API"
 | 
			
		||||
    }
 | 
			
		||||
  ]
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										22
									
								
								apps/build-docs/package.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								apps/build-docs/package.json
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,22 @@
 | 
			
		||||
{
 | 
			
		||||
  "name": "build-docs",
 | 
			
		||||
  "version": "1.0.0",
 | 
			
		||||
  "description": "",
 | 
			
		||||
  "main": "src/main.ts",
 | 
			
		||||
  "scripts": {
 | 
			
		||||
    "start": "tsx ."
 | 
			
		||||
  },
 | 
			
		||||
  "keywords": [],
 | 
			
		||||
  "author": "Elian Doran <contact@eliandoran.me>",
 | 
			
		||||
  "license": "AGPL-3.0-only",
 | 
			
		||||
  "packageManager": "pnpm@10.19.0",
 | 
			
		||||
  "devDependencies": {
 | 
			
		||||
    "@redocly/cli": "2.10.0",
 | 
			
		||||
    "archiver": "7.0.1",
 | 
			
		||||
    "fs-extra": "11.3.2",
 | 
			
		||||
    "react": "19.2.0",
 | 
			
		||||
    "react-dom": "19.2.0",
 | 
			
		||||
    "typedoc": "0.28.14",
 | 
			
		||||
    "typedoc-plugin-missing-exports": "4.1.2"
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										36
									
								
								apps/build-docs/src/backend_script_entrypoint.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										36
									
								
								apps/build-docs/src/backend_script_entrypoint.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,36 @@
 | 
			
		||||
/**
 | 
			
		||||
 * The backend script API is accessible to code notes with the "JS (backend)" language.
 | 
			
		||||
 *
 | 
			
		||||
 * The entire API is exposed as a single global: {@link api}
 | 
			
		||||
 *
 | 
			
		||||
 * @module Backend Script API
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * This file creates the entrypoint for TypeDoc that simulates the context from within a
 | 
			
		||||
 * script note on the server side.
 | 
			
		||||
 *
 | 
			
		||||
 * 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 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";
 | 
			
		||||
export type { BNote };
 | 
			
		||||
export type { default as BOption } from "../../server/src/becca/entities/boption.js";
 | 
			
		||||
export type { default as BRecentNote } from "../../server/src/becca/entities/brecent_note.js";
 | 
			
		||||
export type { default as BRevision } from "../../server/src/becca/entities/brevision.js";
 | 
			
		||||
 | 
			
		||||
import BNote from "../../server/src/becca/entities/bnote.js";
 | 
			
		||||
import BackendScriptApi, { type Api } from "../../server/src/services/backend_script_api.js";
 | 
			
		||||
 | 
			
		||||
export type { Api };
 | 
			
		||||
 | 
			
		||||
const fakeNote = new BNote();
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * The `api` global variable allows access to the backend script API, which is documented in {@link Api}.
 | 
			
		||||
 */
 | 
			
		||||
export const api: Api = new BackendScriptApi(fakeNote, {});
 | 
			
		||||
							
								
								
									
										127
									
								
								apps/build-docs/src/build-docs.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										127
									
								
								apps/build-docs/src/build-docs.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,127 @@
 | 
			
		||||
process.env.TRILIUM_INTEGRATION_TEST = "memory-no-store";
 | 
			
		||||
process.env.TRILIUM_RESOURCE_DIR = "../server/src";
 | 
			
		||||
process.env.NODE_ENV = "development";
 | 
			
		||||
 | 
			
		||||
import cls from "@triliumnext/server/src/services/cls.js";
 | 
			
		||||
import { dirname, join, resolve } from "path";
 | 
			
		||||
import * as fs from "fs/promises";
 | 
			
		||||
import * as fsExtra from "fs-extra";
 | 
			
		||||
import archiver from "archiver";
 | 
			
		||||
import { WriteStream } from "fs";
 | 
			
		||||
import { execSync } from "child_process";
 | 
			
		||||
import BuildContext from "./context.js";
 | 
			
		||||
 | 
			
		||||
const DOCS_ROOT = "../../../docs";
 | 
			
		||||
const OUTPUT_DIR = "../../site";
 | 
			
		||||
 | 
			
		||||
async function buildDocsInner() {
 | 
			
		||||
    const i18n = await import("@triliumnext/server/src/services/i18n.js");
 | 
			
		||||
    await i18n.initializeTranslations();
 | 
			
		||||
 | 
			
		||||
    const sqlInit = (await import("../../server/src/services/sql_init.js")).default;
 | 
			
		||||
    await sqlInit.createInitialDatabase(true);
 | 
			
		||||
 | 
			
		||||
    const note = await importData(join(__dirname, DOCS_ROOT, "User Guide"));
 | 
			
		||||
 | 
			
		||||
    // Export
 | 
			
		||||
    const zipFilePath = "output.zip";
 | 
			
		||||
    try {
 | 
			
		||||
        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 fileOutputStream = fsExtra.createWriteStream(zipFilePath);
 | 
			
		||||
        await exportToZip(taskContext, branch, "share", fileOutputStream);
 | 
			
		||||
        await waitForStreamToFinish(fileOutputStream);
 | 
			
		||||
        await extractZip(zipFilePath, OUTPUT_DIR);
 | 
			
		||||
    } finally {
 | 
			
		||||
        if (await fsExtra.exists(zipFilePath)) {
 | 
			
		||||
            await fsExtra.rm(zipFilePath);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Copy favicon.
 | 
			
		||||
    await fs.copyFile("../../apps/website/src/assets/favicon.ico", join(OUTPUT_DIR, "favicon.ico"));
 | 
			
		||||
 | 
			
		||||
    console.log("Documentation built successfully!");
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export async function importData(path: string) {
 | 
			
		||||
    const buffer = await createImportZip(path);
 | 
			
		||||
    const importService = (await import("@triliumnext/server/src/services/import/zip.js")).default;
 | 
			
		||||
    const TaskContext = (await import("@triliumnext/server/src/services/task_context.js")).default;
 | 
			
		||||
    const context = new TaskContext("no-progress-reporting", "importNotes", null);
 | 
			
		||||
    const becca = (await import("@triliumnext/server/src/becca/becca.js")).default;
 | 
			
		||||
 | 
			
		||||
    const rootNote = becca.getRoot();
 | 
			
		||||
    if (!rootNote) {
 | 
			
		||||
        throw new Error("Missing root note for import.");
 | 
			
		||||
    }
 | 
			
		||||
    return await importService.importZip(context, buffer, rootNote, {
 | 
			
		||||
        preserveIds: true
 | 
			
		||||
    });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function createImportZip(path: string) {
 | 
			
		||||
    const inputFile = "input.zip";
 | 
			
		||||
    const archive = archiver("zip", {
 | 
			
		||||
        zlib: { level: 0 }
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    console.log("Archive path is ", resolve(path))
 | 
			
		||||
    archive.directory(path, "/");
 | 
			
		||||
 | 
			
		||||
    const outputStream = fsExtra.createWriteStream(inputFile);
 | 
			
		||||
    archive.pipe(outputStream);
 | 
			
		||||
    archive.finalize();
 | 
			
		||||
    await waitForStreamToFinish(outputStream);
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
        return await fsExtra.readFile(inputFile);
 | 
			
		||||
    } finally {
 | 
			
		||||
        await fsExtra.rm(inputFile);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
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"));
 | 
			
		||||
    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)) {
 | 
			
		||||
            const destPath = join(outputPath, entry.fileName);
 | 
			
		||||
            const fileContent = await readContent(zip, entry);
 | 
			
		||||
 | 
			
		||||
            await fsExtra.mkdirs(dirname(destPath));
 | 
			
		||||
            await fs.writeFile(destPath, fileContent);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        zip.readEntry();
 | 
			
		||||
    });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default async function buildDocs({ gitRootDir }: BuildContext) {
 | 
			
		||||
    // Build the share theme.
 | 
			
		||||
    execSync(`pnpm run --filter share-theme build`, {
 | 
			
		||||
        stdio: "inherit",
 | 
			
		||||
        cwd: gitRootDir
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    // Trigger the actual build.
 | 
			
		||||
    await new Promise((res, rej) => {
 | 
			
		||||
        cls.init(() => {
 | 
			
		||||
            buildDocsInner()
 | 
			
		||||
                .catch(rej)
 | 
			
		||||
                .then(res);
 | 
			
		||||
        });
 | 
			
		||||
    });
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										4
									
								
								apps/build-docs/src/context.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								apps/build-docs/src/context.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,4 @@
 | 
			
		||||
export default interface BuildContext {
 | 
			
		||||
    gitRootDir: string;
 | 
			
		||||
    baseDir: string;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										28
									
								
								apps/build-docs/src/frontend_script_entrypoint.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								apps/build-docs/src/frontend_script_entrypoint.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,28 @@
 | 
			
		||||
/**
 | 
			
		||||
 * The front script API is accessible to code notes with the "JS (frontend)" language.
 | 
			
		||||
 *
 | 
			
		||||
 * The entire API is exposed as a single global: {@link api}
 | 
			
		||||
 *
 | 
			
		||||
 * @module Frontend Script API
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * This file creates the entrypoint for TypeDoc that simulates the context from within a
 | 
			
		||||
 * script note.
 | 
			
		||||
 *
 | 
			
		||||
 * 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 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
 | 
			
		||||
export const api: Api = new FrontendScriptApi();
 | 
			
		||||
							
								
								
									
										26
									
								
								apps/build-docs/src/main.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								apps/build-docs/src/main.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,26 @@
 | 
			
		||||
import { join } from "path";
 | 
			
		||||
import BuildContext from "./context";
 | 
			
		||||
import buildSwagger from "./swagger";
 | 
			
		||||
import { existsSync, mkdirSync, rmSync } from "fs";
 | 
			
		||||
import buildDocs from "./build-docs";
 | 
			
		||||
import buildScriptApi from "./script-api";
 | 
			
		||||
 | 
			
		||||
const context: BuildContext = {
 | 
			
		||||
    gitRootDir: join(__dirname, "../../../"),
 | 
			
		||||
    baseDir: join(__dirname, "../../../site")
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
async function main() {
 | 
			
		||||
    // Clean input dir.
 | 
			
		||||
    if (existsSync(context.baseDir)) {
 | 
			
		||||
        rmSync(context.baseDir, { recursive: true });
 | 
			
		||||
    }
 | 
			
		||||
    mkdirSync(context.baseDir);
 | 
			
		||||
 | 
			
		||||
    // Start building.
 | 
			
		||||
    await buildDocs(context);
 | 
			
		||||
    buildSwagger(context);
 | 
			
		||||
    buildScriptApi(context);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
main();
 | 
			
		||||
							
								
								
									
										15
									
								
								apps/build-docs/src/script-api.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								apps/build-docs/src/script-api.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,15 @@
 | 
			
		||||
import { execSync } from "child_process";
 | 
			
		||||
import BuildContext from "./context";
 | 
			
		||||
import { join } from "path";
 | 
			
		||||
 | 
			
		||||
export default function buildScriptApi({ baseDir, gitRootDir }: BuildContext) {
 | 
			
		||||
    // Generate types
 | 
			
		||||
    execSync(`pnpm typecheck`, { stdio: "inherit", cwd: gitRootDir });
 | 
			
		||||
 | 
			
		||||
    for (const config of [ "backend", "frontend" ]) {
 | 
			
		||||
        const outDir = join(baseDir, "script-api", config);
 | 
			
		||||
        execSync(`pnpm typedoc --options typedoc.${config}.json --html "${outDir}"`, {
 | 
			
		||||
            stdio: "inherit"
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										32
									
								
								apps/build-docs/src/swagger.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								apps/build-docs/src/swagger.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,32 @@
 | 
			
		||||
import BuildContext from "./context";
 | 
			
		||||
import { join } from "path";
 | 
			
		||||
import { execSync } from "child_process";
 | 
			
		||||
import { mkdirSync } from "fs";
 | 
			
		||||
 | 
			
		||||
interface BuildInfo {
 | 
			
		||||
    specPath: string;
 | 
			
		||||
    outDir: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const DIR_PREFIX = "rest-api";
 | 
			
		||||
 | 
			
		||||
const buildInfos: BuildInfo[] = [
 | 
			
		||||
    {
 | 
			
		||||
        // Paths are relative to Git root.
 | 
			
		||||
        specPath: "apps/server/internal.openapi.yaml",
 | 
			
		||||
        outDir: `${DIR_PREFIX}/internal`
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
        specPath: "apps/server/etapi.openapi.yaml",
 | 
			
		||||
        outDir: `${DIR_PREFIX}/etapi`
 | 
			
		||||
    }
 | 
			
		||||
];
 | 
			
		||||
 | 
			
		||||
export default function buildSwagger({ baseDir, gitRootDir }: BuildContext) {
 | 
			
		||||
    for (const { specPath, outDir } of buildInfos) {
 | 
			
		||||
        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" });
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										36
									
								
								apps/build-docs/tsconfig.app.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										36
									
								
								apps/build-docs/tsconfig.app.json
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,36 @@
 | 
			
		||||
{
 | 
			
		||||
  "extends": "../../tsconfig.base.json",
 | 
			
		||||
  "compilerOptions": {
 | 
			
		||||
    "module": "ESNext",
 | 
			
		||||
    "moduleResolution": "bundler",
 | 
			
		||||
    "target": "ES2020",
 | 
			
		||||
    "outDir": "dist",
 | 
			
		||||
    "strict": false,
 | 
			
		||||
    "types": [
 | 
			
		||||
      "node",
 | 
			
		||||
      "express"
 | 
			
		||||
    ],
 | 
			
		||||
    "rootDir": "src",
 | 
			
		||||
    "tsBuildInfoFile": "dist/tsconfig.app.tsbuildinfo"
 | 
			
		||||
  },
 | 
			
		||||
  "include": [
 | 
			
		||||
    "src/**/*.ts",
 | 
			
		||||
    "../server/src/*.d.ts"
 | 
			
		||||
  ],
 | 
			
		||||
  "exclude": [
 | 
			
		||||
    "eslint.config.js",
 | 
			
		||||
    "eslint.config.cjs",
 | 
			
		||||
    "eslint.config.mjs"
 | 
			
		||||
  ],
 | 
			
		||||
  "references": [
 | 
			
		||||
    {
 | 
			
		||||
      "path": "../server/tsconfig.app.json"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "path": "../desktop/tsconfig.app.json"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "path": "../client/tsconfig.app.json"
 | 
			
		||||
    }
 | 
			
		||||
  ]
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										15
									
								
								apps/build-docs/tsconfig.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								apps/build-docs/tsconfig.json
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,15 @@
 | 
			
		||||
{
 | 
			
		||||
  "extends": "../../tsconfig.base.json",
 | 
			
		||||
  "include": [],
 | 
			
		||||
  "references": [
 | 
			
		||||
    {
 | 
			
		||||
      "path": "../server"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "path": "../client"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "path": "./tsconfig.app.json"
 | 
			
		||||
    }
 | 
			
		||||
  ]
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										10
									
								
								apps/build-docs/typedoc.backend.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								apps/build-docs/typedoc.backend.json
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,10 @@
 | 
			
		||||
{
 | 
			
		||||
  "$schema": "https://typedoc.org/schema.json",
 | 
			
		||||
  "name": "Trilium Backend API",
 | 
			
		||||
  "entryPoints": [
 | 
			
		||||
    "src/backend_script_entrypoint.ts"
 | 
			
		||||
  ],
 | 
			
		||||
  "plugin": [
 | 
			
		||||
    "typedoc-plugin-missing-exports"
 | 
			
		||||
  ]
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										10
									
								
								apps/build-docs/typedoc.frontend.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								apps/build-docs/typedoc.frontend.json
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,10 @@
 | 
			
		||||
{
 | 
			
		||||
  "$schema": "https://typedoc.org/schema.json",
 | 
			
		||||
  "name": "Trilium Frontend API",
 | 
			
		||||
  "entryPoints": [
 | 
			
		||||
    "src/frontend_script_entrypoint.ts"
 | 
			
		||||
  ],
 | 
			
		||||
  "plugin": [
 | 
			
		||||
    "typedoc-plugin-missing-exports"
 | 
			
		||||
  ]
 | 
			
		||||
}
 | 
			
		||||
@@ -15,7 +15,7 @@
 | 
			
		||||
    "circular-deps": "dpdm -T src/**/*.ts --tree=false --warning=false --skip-dynamic-imports=circular"
 | 
			
		||||
  },
 | 
			
		||||
  "dependencies": {
 | 
			
		||||
    "@eslint/js": "9.38.0",
 | 
			
		||||
    "@eslint/js": "9.39.0",
 | 
			
		||||
    "@excalidraw/excalidraw": "0.18.0",
 | 
			
		||||
    "@fullcalendar/core": "6.1.19",
 | 
			
		||||
    "@fullcalendar/daygrid": "6.1.19",
 | 
			
		||||
@@ -37,12 +37,12 @@
 | 
			
		||||
    "bootstrap": "5.3.8",
 | 
			
		||||
    "boxicons": "2.1.4",
 | 
			
		||||
    "color": "5.0.2",
 | 
			
		||||
    "dayjs": "1.11.18",
 | 
			
		||||
    "dayjs": "1.11.19",
 | 
			
		||||
    "dayjs-plugin-utc": "0.1.2",
 | 
			
		||||
    "debounce": "2.2.0",
 | 
			
		||||
    "draggabilly": "3.0.0",
 | 
			
		||||
    "force-graph": "1.51.0",
 | 
			
		||||
    "globals": "16.4.0",
 | 
			
		||||
    "globals": "16.5.0",
 | 
			
		||||
    "i18next": "25.6.0",
 | 
			
		||||
    "i18next-http-backend": "3.0.2",
 | 
			
		||||
    "jquery": "3.7.1",
 | 
			
		||||
@@ -54,12 +54,12 @@
 | 
			
		||||
    "leaflet-gpx": "2.2.0",
 | 
			
		||||
    "mark.js": "8.11.1",
 | 
			
		||||
    "marked": "16.4.1",
 | 
			
		||||
    "mermaid": "11.12.0",
 | 
			
		||||
    "mermaid": "11.12.1",
 | 
			
		||||
    "mind-elixir": "5.3.4",
 | 
			
		||||
    "normalize.css": "8.0.1",
 | 
			
		||||
    "panzoom": "9.4.3",
 | 
			
		||||
    "preact": "10.27.2",
 | 
			
		||||
    "react-i18next": "16.2.0",
 | 
			
		||||
    "react-i18next": "16.2.3",
 | 
			
		||||
    "reveal.js": "5.2.1",
 | 
			
		||||
    "svg-pan-zoom": "3.6.2",
 | 
			
		||||
    "tabulator-tables": "6.3.1",
 | 
			
		||||
@@ -76,7 +76,7 @@
 | 
			
		||||
    "@types/reveal.js": "5.2.1",
 | 
			
		||||
    "@types/tabulator-tables": "6.3.0",
 | 
			
		||||
    "copy-webpack-plugin": "13.0.1",
 | 
			
		||||
    "happy-dom": "20.0.8",
 | 
			
		||||
    "happy-dom": "20.0.10",
 | 
			
		||||
    "script-loader": "0.7.2",
 | 
			
		||||
    "vite-plugin-static-copy": "3.1.4"
 | 
			
		||||
  }
 | 
			
		||||
 
 | 
			
		||||
@@ -13,6 +13,7 @@ import MainTreeExecutors from "./main_tree_executors.js";
 | 
			
		||||
import toast from "../services/toast.js";
 | 
			
		||||
import ShortcutComponent from "./shortcut_component.js";
 | 
			
		||||
import { t, initLocale } from "../services/i18n.js";
 | 
			
		||||
import type NoteDetailWidget from "../widgets/note_detail.js";
 | 
			
		||||
import type { ResolveOptions } from "../widgets/dialogs/delete_notes.js";
 | 
			
		||||
import type { PromptDialogOptions } from "../widgets/dialogs/prompt.js";
 | 
			
		||||
import type { ConfirmWithMessageOptions, ConfirmWithTitleOptions } from "../widgets/dialogs/confirm.js";
 | 
			
		||||
@@ -20,6 +21,8 @@ import type LoadResults from "../services/load_results.js";
 | 
			
		||||
import type { Attribute } from "../services/attribute_parser.js";
 | 
			
		||||
import type NoteTreeWidget from "../widgets/note_tree.js";
 | 
			
		||||
import type { default as NoteContext, GetTextEditorCallback } from "./note_context.js";
 | 
			
		||||
import type TypeWidget from "../widgets/type_widgets/type_widget.js";
 | 
			
		||||
import type EditableTextTypeWidget from "../widgets/type_widgets/editable_text.js";
 | 
			
		||||
import type { NativeImage, TouchBar } from "electron";
 | 
			
		||||
import TouchBarComponent from "./touch_bar.js";
 | 
			
		||||
import type { CKTextEditor } from "@triliumnext/ckeditor5";
 | 
			
		||||
@@ -30,10 +33,6 @@ import { ColumnComponent } from "tabulator-tables";
 | 
			
		||||
import { ChooseNoteTypeCallback } from "../widgets/dialogs/note_type_chooser.jsx";
 | 
			
		||||
import type RootContainer from "../widgets/containers/root_container.js";
 | 
			
		||||
import { SqlExecuteResults } from "@triliumnext/commons";
 | 
			
		||||
import { AddLinkOpts } from "../widgets/dialogs/add_link.jsx";
 | 
			
		||||
import { IncludeNoteOpts } from "../widgets/dialogs/include_note.jsx";
 | 
			
		||||
import { ReactWrappedWidget } from "../widgets/basic_widget.js";
 | 
			
		||||
import { TypeWidget } from "../widgets/note_types.jsx";
 | 
			
		||||
 | 
			
		||||
interface Layout {
 | 
			
		||||
    getRootWidget: (appContext: AppContext) => RootContainer;
 | 
			
		||||
@@ -200,7 +199,7 @@ export type CommandMappings = {
 | 
			
		||||
    resetLauncher: ContextMenuCommandData;
 | 
			
		||||
 | 
			
		||||
    executeInActiveNoteDetailWidget: CommandData & {
 | 
			
		||||
        callback: (value: ReactWrappedWidget) => void;
 | 
			
		||||
        callback: (value: NoteDetailWidget | PromiseLike<NoteDetailWidget>) => void;
 | 
			
		||||
    };
 | 
			
		||||
    executeWithTextEditor: CommandData &
 | 
			
		||||
    ExecuteCommandData<CKTextEditor> & {
 | 
			
		||||
@@ -212,19 +211,19 @@ export type CommandMappings = {
 | 
			
		||||
     * Generally should not be invoked manually, as it is used by {@link NoteContext.getContentElement}.
 | 
			
		||||
     */
 | 
			
		||||
    executeWithContentElement: CommandData & ExecuteCommandData<JQuery<HTMLElement>>;
 | 
			
		||||
    executeWithTypeWidget: CommandData & ExecuteCommandData<ReactWrappedWidget | null>;
 | 
			
		||||
    executeWithTypeWidget: CommandData & ExecuteCommandData<TypeWidget | null>;
 | 
			
		||||
    addTextToActiveEditor: CommandData & {
 | 
			
		||||
        text: string;
 | 
			
		||||
    };
 | 
			
		||||
    /** Works only in the electron context menu. */
 | 
			
		||||
    replaceMisspelling: CommandData;
 | 
			
		||||
 | 
			
		||||
    importMarkdownInline: CommandData;
 | 
			
		||||
    showPasswordNotSet: CommandData;
 | 
			
		||||
    showProtectedSessionPasswordDialog: CommandData;
 | 
			
		||||
    showUploadAttachmentsDialog: CommandData & { noteId: string };
 | 
			
		||||
    showIncludeNoteDialog: CommandData & IncludeNoteOpts;
 | 
			
		||||
    showAddLinkDialog: CommandData & AddLinkOpts;
 | 
			
		||||
    showIncludeNoteDialog: CommandData & { textTypeWidget: EditableTextTypeWidget };
 | 
			
		||||
    showAddLinkDialog: CommandData & { textTypeWidget: EditableTextTypeWidget, text: string };
 | 
			
		||||
    showPasteMarkdownDialog: CommandData & { textTypeWidget: EditableTextTypeWidget };
 | 
			
		||||
    closeProtectedSessionPasswordDialog: CommandData;
 | 
			
		||||
    copyImageReferenceToClipboard: CommandData;
 | 
			
		||||
    copyImageToClipboard: CommandData;
 | 
			
		||||
@@ -271,6 +270,7 @@ export type CommandMappings = {
 | 
			
		||||
    closeThisNoteSplit: CommandData;
 | 
			
		||||
    moveThisNoteSplit: CommandData & { isMovingLeft: boolean };
 | 
			
		||||
    jumpToNote: CommandData;
 | 
			
		||||
    openTodayNote: CommandData;
 | 
			
		||||
    commandPalette: CommandData;
 | 
			
		||||
 | 
			
		||||
    // Keyboard shortcuts
 | 
			
		||||
@@ -485,8 +485,13 @@ type EventMappings = {
 | 
			
		||||
    relationMapResetZoomIn: { ntxId: string | null | undefined };
 | 
			
		||||
    relationMapResetZoomOut: { ntxId: string | null | undefined };
 | 
			
		||||
    activeNoteChanged: {};
 | 
			
		||||
    showAddLinkDialog: AddLinkOpts;
 | 
			
		||||
    showIncludeDialog: IncludeNoteOpts;
 | 
			
		||||
    showAddLinkDialog: {
 | 
			
		||||
        textTypeWidget: EditableTextTypeWidget;
 | 
			
		||||
        text: string;
 | 
			
		||||
    };
 | 
			
		||||
    showIncludeDialog: {
 | 
			
		||||
        textTypeWidget: EditableTextTypeWidget;
 | 
			
		||||
    };
 | 
			
		||||
    openBulkActionsDialog: {
 | 
			
		||||
        selectedOrActiveNoteIds: string[];
 | 
			
		||||
    };
 | 
			
		||||
@@ -661,10 +666,6 @@ export class AppContext extends Component {
 | 
			
		||||
            this.beforeUnloadListeners.push(obj);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    removeBeforeUnloadListener(listener: (() => boolean)) {
 | 
			
		||||
        this.beforeUnloadListeners = this.beforeUnloadListeners.filter(l => l === listener);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const appContext = new AppContext(window.glob.isMainWindow);
 | 
			
		||||
 
 | 
			
		||||
@@ -159,13 +159,22 @@ export default class Entrypoints extends Component {
 | 
			
		||||
        this.openInWindowCommand({ notePath: "", hoistedNoteId: "root" });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async openTodayNoteCommand() {
 | 
			
		||||
        const todayNote = await dateNoteService.getTodayNote();
 | 
			
		||||
        if (!todayNote) {
 | 
			
		||||
            console.warn("Missing today note.");
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        await appContext.tabManager.openInSameTab(todayNote.noteId);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async runActiveNoteCommand() {
 | 
			
		||||
        const noteContext = appContext.tabManager.getActiveContext();
 | 
			
		||||
        if (!noteContext) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
        const { ntxId, note } = noteContext;
 | 
			
		||||
        console.log("Run active note");
 | 
			
		||||
 | 
			
		||||
        // ctrl+enter is also used elsewhere, so make sure we're running only when appropriate
 | 
			
		||||
        if (!note || note.type !== "code") {
 | 
			
		||||
 
 | 
			
		||||
@@ -9,11 +9,10 @@ import hoistedNoteService from "../services/hoisted_note.js";
 | 
			
		||||
import options from "../services/options.js";
 | 
			
		||||
import type { ViewScope } from "../services/link.js";
 | 
			
		||||
import type FNote from "../entities/fnote.js";
 | 
			
		||||
import type TypeWidget from "../widgets/type_widgets/type_widget.js";
 | 
			
		||||
import type { CKTextEditor } from "@triliumnext/ckeditor5";
 | 
			
		||||
import type CodeMirror from "@triliumnext/codemirror";
 | 
			
		||||
import { closeActiveDialog } from "../services/dialog.js";
 | 
			
		||||
import { TypeWidget } from "../widgets/note_types.jsx";
 | 
			
		||||
import { ReactWrappedWidget } from "../widgets/basic_widget.js";
 | 
			
		||||
 | 
			
		||||
export interface SetNoteOpts {
 | 
			
		||||
    triggerSwitchEvent?: unknown;
 | 
			
		||||
@@ -398,7 +397,7 @@ class NoteContext extends Component implements EventListener<"entitiesReloaded">
 | 
			
		||||
 | 
			
		||||
    async getTypeWidget() {
 | 
			
		||||
        return this.timeout(
 | 
			
		||||
            new Promise<ReactWrappedWidget | null>((resolve) =>
 | 
			
		||||
            new Promise<TypeWidget | null>((resolve) =>
 | 
			
		||||
                appContext.triggerCommand("executeWithTypeWidget", {
 | 
			
		||||
                    resolve,
 | 
			
		||||
                    ntxId: this.ntxId
 | 
			
		||||
 
 | 
			
		||||
@@ -417,7 +417,7 @@ export default class FNote {
 | 
			
		||||
        return notePaths;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    getSortedNotePathRecords(hoistedNoteId = "root"): NotePathRecord[] {
 | 
			
		||||
    getSortedNotePathRecords(hoistedNoteId = "root", activeNotePath: string | null = null): NotePathRecord[] {
 | 
			
		||||
        const isHoistedRoot = hoistedNoteId === "root";
 | 
			
		||||
 | 
			
		||||
        const notePaths: NotePathRecord[] = this.getAllNotePaths().map((path) => ({
 | 
			
		||||
@@ -428,7 +428,23 @@ export default class FNote {
 | 
			
		||||
            isHidden: path.includes("_hidden")
 | 
			
		||||
        }));
 | 
			
		||||
 | 
			
		||||
        // Calculate the length of the prefix match between two arrays
 | 
			
		||||
        const prefixMatchLength = (path: string[], target: string[]) => {
 | 
			
		||||
            const diffIndex = path.findIndex((seg, i) => seg !== target[i]);
 | 
			
		||||
            return diffIndex === -1 ? Math.min(path.length, target.length) : diffIndex;
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        notePaths.sort((a, b) => {
 | 
			
		||||
            if (activeNotePath) {
 | 
			
		||||
                const activeSegments = activeNotePath.split('/');
 | 
			
		||||
                const aOverlap = prefixMatchLength(a.notePath, activeSegments);
 | 
			
		||||
                const bOverlap = prefixMatchLength(b.notePath, activeSegments);
 | 
			
		||||
                // Paths with more matching prefix segments are prioritized
 | 
			
		||||
                // when the match count is equal, other criteria are used for sorting
 | 
			
		||||
                if (bOverlap !== aOverlap) {
 | 
			
		||||
                    return bOverlap - aOverlap;
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
            if (a.isInHoistedSubTree !== b.isInHoistedSubTree) {
 | 
			
		||||
                return a.isInHoistedSubTree ? -1 : 1;
 | 
			
		||||
            } else if (a.isArchived !== b.isArchived) {
 | 
			
		||||
@@ -449,10 +465,11 @@ export default class FNote {
 | 
			
		||||
     * Returns the note path considered to be the "best"
 | 
			
		||||
     *
 | 
			
		||||
     * @param {string} [hoistedNoteId='root']
 | 
			
		||||
     * @param {string|null} [activeNotePath=null]
 | 
			
		||||
     * @return {string[]} array of noteIds constituting the particular note path
 | 
			
		||||
     */
 | 
			
		||||
    getBestNotePath(hoistedNoteId = "root") {
 | 
			
		||||
        return this.getSortedNotePathRecords(hoistedNoteId)[0]?.notePath;
 | 
			
		||||
    getBestNotePath(hoistedNoteId = "root", activeNotePath: string | null = null) {
 | 
			
		||||
        return this.getSortedNotePathRecords(hoistedNoteId, activeNotePath)[0]?.notePath;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
 
 | 
			
		||||
@@ -3,6 +3,7 @@ import TabRowWidget from "../widgets/tab_row.js";
 | 
			
		||||
import LeftPaneContainer from "../widgets/containers/left_pane_container.js";
 | 
			
		||||
import NoteTreeWidget from "../widgets/note_tree.js";
 | 
			
		||||
import NoteTitleWidget from "../widgets/note_title.jsx";
 | 
			
		||||
import NoteDetailWidget from "../widgets/note_detail.js";
 | 
			
		||||
import PromotedAttributesWidget from "../widgets/promoted_attributes.js";
 | 
			
		||||
import NoteIconWidget from "../widgets/note_icon.jsx";
 | 
			
		||||
import ScrollingContainer from "../widgets/containers/scrolling_container.js";
 | 
			
		||||
@@ -41,7 +42,6 @@ import ApiLog from "../widgets/api_log.jsx";
 | 
			
		||||
import CloseZenModeButton from "../widgets/close_zen_button.jsx";
 | 
			
		||||
import SharedInfo from "../widgets/shared_info.jsx";
 | 
			
		||||
import NoteList from "../widgets/collections/NoteList.jsx";
 | 
			
		||||
import NoteDetail from "../widgets/NoteDetail.jsx";
 | 
			
		||||
 | 
			
		||||
export default class DesktopLayout {
 | 
			
		||||
 | 
			
		||||
@@ -137,7 +137,7 @@ export default class DesktopLayout {
 | 
			
		||||
                                                                .filling()
 | 
			
		||||
                                                                .child(new PromotedAttributesWidget())
 | 
			
		||||
                                                                .child(<SqlTableSchemas />)
 | 
			
		||||
                                                                .child(<NoteDetail />)
 | 
			
		||||
                                                                .child(new NoteDetailWidget())
 | 
			
		||||
                                                                .child(<NoteList media="screen" />)
 | 
			
		||||
                                                                .child(<SearchResult />)
 | 
			
		||||
                                                                .child(<SqlResults />)
 | 
			
		||||
 
 | 
			
		||||
@@ -26,11 +26,11 @@ import PopupEditorDialog from "../widgets/dialogs/popup_editor.js";
 | 
			
		||||
import FlexContainer from "../widgets/containers/flex_container.js";
 | 
			
		||||
import NoteIconWidget from "../widgets/note_icon";
 | 
			
		||||
import PromotedAttributesWidget from "../widgets/promoted_attributes.js";
 | 
			
		||||
import NoteDetailWidget from "../widgets/note_detail.js";
 | 
			
		||||
import CallToActionDialog from "../widgets/dialogs/call_to_action.jsx";
 | 
			
		||||
import NoteTitleWidget from "../widgets/note_title.jsx";
 | 
			
		||||
import FormattingToolbar from "../widgets/ribbon/FormattingToolbar.js";
 | 
			
		||||
import NoteList from "../widgets/collections/NoteList.jsx";
 | 
			
		||||
import NoteDetail from "../widgets/NoteDetail.jsx";
 | 
			
		||||
import StandaloneRibbonAdapter from "../widgets/ribbon/components/StandaloneRibbonAdapter.jsx";
 | 
			
		||||
 | 
			
		||||
export function applyModals(rootContainer: RootContainer) {
 | 
			
		||||
@@ -66,7 +66,7 @@ export function applyModals(rootContainer: RootContainer) {
 | 
			
		||||
                    .child(<NoteTitleWidget />))
 | 
			
		||||
                .child(<StandaloneRibbonAdapter component={FormattingToolbar} />)
 | 
			
		||||
                .child(new PromotedAttributesWidget())
 | 
			
		||||
                .child(<NoteDetail />)
 | 
			
		||||
                .child(new NoteDetailWidget())
 | 
			
		||||
                .child(<NoteList media="screen" displayOnlyCollections />))
 | 
			
		||||
        .child(<CallToActionDialog />);
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,6 @@
 | 
			
		||||
import FlexContainer from "../widgets/containers/flex_container.js";
 | 
			
		||||
import NoteTitleWidget from "../widgets/note_title.js";
 | 
			
		||||
import NoteDetailWidget from "../widgets/note_detail.js";
 | 
			
		||||
import QuickSearchWidget from "../widgets/quick_search.js";
 | 
			
		||||
import NoteTreeWidget from "../widgets/note_tree.js";
 | 
			
		||||
import ScreenContainer from "../widgets/mobile_widgets/screen_container.js";
 | 
			
		||||
@@ -12,7 +13,7 @@ import PromotedAttributesWidget from "../widgets/promoted_attributes.js";
 | 
			
		||||
import SidebarContainer from "../widgets/mobile_widgets/sidebar_container.js";
 | 
			
		||||
import type AppContext from "../components/app_context.js";
 | 
			
		||||
import TabRowWidget from "../widgets/tab_row.js";
 | 
			
		||||
import MobileEditorToolbar from "../widgets/type_widgets/text/mobile_editor_toolbar.js";
 | 
			
		||||
import MobileEditorToolbar from "../widgets/type_widgets/ckeditor/mobile_editor_toolbar.js";
 | 
			
		||||
import { applyModals } from "./layout_commons.js";
 | 
			
		||||
import FilePropertiesTab from "../widgets/ribbon/FilePropertiesTab.jsx";
 | 
			
		||||
import { useNoteContext } from "../widgets/react/hooks.jsx";
 | 
			
		||||
@@ -23,7 +24,6 @@ import CloseZenModeButton from "../widgets/close_zen_button.js";
 | 
			
		||||
import NoteWrapperWidget from "../widgets/note_wrapper.js";
 | 
			
		||||
import MobileDetailMenu from "../widgets/mobile_widgets/mobile_detail_menu.js";
 | 
			
		||||
import NoteList from "../widgets/collections/NoteList.jsx";
 | 
			
		||||
import NoteDetail from "../widgets/NoteDetail.jsx";
 | 
			
		||||
import StandaloneRibbonAdapter from "../widgets/ribbon/components/StandaloneRibbonAdapter.jsx";
 | 
			
		||||
import SearchDefinitionTab from "../widgets/ribbon/SearchDefinitionTab.jsx";
 | 
			
		||||
import SearchResult from "../widgets/search_result.jsx";
 | 
			
		||||
@@ -156,7 +156,7 @@ export default class MobileLayout {
 | 
			
		||||
                                        new ScrollingContainer()
 | 
			
		||||
                                            .filling()
 | 
			
		||||
                                            .contentSized()
 | 
			
		||||
                                            .child(<NoteDetail />)
 | 
			
		||||
                                            .child(new NoteDetailWidget())
 | 
			
		||||
                                            .child(<NoteList media="screen" />)
 | 
			
		||||
                                            .child(<StandaloneRibbonAdapter component={SearchDefinitionTab} />)
 | 
			
		||||
                                            .child(<SearchResult />)
 | 
			
		||||
 
 | 
			
		||||
@@ -137,7 +137,7 @@ export default class TreeContextMenu implements SelectMenuItemEventListener<Tree
 | 
			
		||||
                        command: "editBranchPrefix",
 | 
			
		||||
                        keyboardShortcut: "editBranchPrefix",
 | 
			
		||||
                        uiIcon: "bx bx-rename",
 | 
			
		||||
                        enabled: isNotRoot && parentNotSearch && noSelectedNotes && notOptionsOrHelp
 | 
			
		||||
                        enabled: isNotRoot && parentNotSearch && notOptionsOrHelp
 | 
			
		||||
                    },
 | 
			
		||||
                    { title: t("tree-context-menu.convert-to-attachment"), command: "convertNoteToAttachment", uiIcon: "bx bx-paperclip", enabled: isNotRoot && !isHoisted && notOptionsOrHelp },
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -56,7 +56,20 @@ function SingleNoteRenderer({ note, onReady }: RendererProps) {
 | 
			
		||||
                await import("@triliumnext/ckeditor5/src/theme/ck-content.css");
 | 
			
		||||
            }
 | 
			
		||||
            const { $renderedContent } = await content_renderer.getRenderedContent(note, { noChildrenList: true });
 | 
			
		||||
            containerRef.current?.replaceChildren(...$renderedContent);
 | 
			
		||||
            const container = containerRef.current!;
 | 
			
		||||
            container.replaceChildren(...$renderedContent);
 | 
			
		||||
 | 
			
		||||
            // Wait for all images to load.
 | 
			
		||||
            const images = Array.from(container.querySelectorAll("img"));
 | 
			
		||||
            await Promise.all(
 | 
			
		||||
                images.map(img => {
 | 
			
		||||
                    if (img.complete) return Promise.resolve();
 | 
			
		||||
                    return new Promise<void>(resolve => {
 | 
			
		||||
                        img.addEventListener("load", () => resolve(), { once: true });
 | 
			
		||||
                        img.addEventListener("error", () => resolve(), { once: true });
 | 
			
		||||
                    });
 | 
			
		||||
                })
 | 
			
		||||
            );
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        load().then(() => requestAnimationFrame(onReady))
 | 
			
		||||
 
 | 
			
		||||
@@ -11,7 +11,7 @@ import RightPanelWidget from "../widgets/right_panel_widget.js";
 | 
			
		||||
import ws from "./ws.js";
 | 
			
		||||
import appContext from "../components/app_context.js";
 | 
			
		||||
import NoteContextAwareWidget from "../widgets/note_context_aware_widget.js";
 | 
			
		||||
import BasicWidget, { ReactWrappedWidget } from "../widgets/basic_widget.js";
 | 
			
		||||
import BasicWidget from "../widgets/basic_widget.js";
 | 
			
		||||
import SpacedUpdate from "./spaced_update.js";
 | 
			
		||||
import shortcutService from "./shortcuts.js";
 | 
			
		||||
import dialogService from "./dialog.js";
 | 
			
		||||
@@ -19,6 +19,7 @@ import type FNote from "../entities/fnote.js";
 | 
			
		||||
import { t } from "./i18n.js";
 | 
			
		||||
import dayjs from "dayjs";
 | 
			
		||||
import type NoteContext from "../components/note_context.js";
 | 
			
		||||
import type NoteDetailWidget from "../widgets/note_detail.js";
 | 
			
		||||
import type Component from "../components/component.js";
 | 
			
		||||
import { formatLogMessage } from "@triliumnext/commons";
 | 
			
		||||
 | 
			
		||||
@@ -316,7 +317,7 @@ export interface Api {
 | 
			
		||||
     * Get access to the widget handling note detail. Methods like `getWidgetType()` and `getTypeWidget()` to get to the
 | 
			
		||||
     * implementation of actual widget type.
 | 
			
		||||
     */
 | 
			
		||||
    getActiveNoteDetailWidget(): Promise<ReactWrappedWidget>;
 | 
			
		||||
    getActiveNoteDetailWidget(): Promise<NoteDetailWidget>;
 | 
			
		||||
    /**
 | 
			
		||||
     * @returns returns a note path of active note or null if there isn't active note
 | 
			
		||||
     */
 | 
			
		||||
 
 | 
			
		||||
@@ -1,28 +0,0 @@
 | 
			
		||||
/**
 | 
			
		||||
 * The front script API is accessible to code notes with the "JS (frontend)" language.
 | 
			
		||||
 *
 | 
			
		||||
 * The entire API is exposed as a single global: {@link api}
 | 
			
		||||
 *
 | 
			
		||||
 * @module Frontend Script API
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * This file creates the entrypoint for TypeDoc that simulates the context from within a
 | 
			
		||||
 * script note.
 | 
			
		||||
 *
 | 
			
		||||
 * Make sure to keep in line with frontend's `script_context.ts`.
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
export type { default as BasicWidget } from "../widgets/basic_widget.js";
 | 
			
		||||
export type { default as FAttachment } from "../entities/fattachment.js";
 | 
			
		||||
export type { default as FAttribute } from "../entities/fattribute.js";
 | 
			
		||||
export type { default as FBranch } from "../entities/fbranch.js";
 | 
			
		||||
export type { default as FNote } from "../entities/fnote.js";
 | 
			
		||||
export type { Api } from "./frontend_script_api.js";
 | 
			
		||||
export type { default as NoteContextAwareWidget } from "../widgets/note_context_aware_widget.js";
 | 
			
		||||
export type { default as RightPanelWidget } from "../widgets/right_panel_widget.js";
 | 
			
		||||
 | 
			
		||||
import FrontendScriptApi, { type Api } from "./frontend_script_api.js";
 | 
			
		||||
 | 
			
		||||
//@ts-expect-error
 | 
			
		||||
export const api: Api = new FrontendScriptApi();
 | 
			
		||||
@@ -20,9 +20,6 @@ function setupGlobs() {
 | 
			
		||||
    window.glob.froca = froca;
 | 
			
		||||
    window.glob.treeCache = froca; // compatibility for CKEditor builds for a while
 | 
			
		||||
 | 
			
		||||
    // for CKEditor integration (button on block toolbar)
 | 
			
		||||
    window.glob.importMarkdownInline = async () => appContext.triggerCommand("importMarkdownInline");
 | 
			
		||||
 | 
			
		||||
    window.onerror = function (msg, url, lineNo, columnNo, error) {
 | 
			
		||||
        const string = String(msg).toLowerCase();
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -10,7 +10,7 @@ export const byNoteType: Record<Exclude<NoteType, "book">, string | null> = {
 | 
			
		||||
    file: null,
 | 
			
		||||
    image: null,
 | 
			
		||||
    launcher: null,
 | 
			
		||||
    mermaid: null,
 | 
			
		||||
    mermaid: "s1aBHPd79XYj",
 | 
			
		||||
    mindMap: null,
 | 
			
		||||
    noteMap: null,
 | 
			
		||||
    relationMap: null,
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,6 @@
 | 
			
		||||
import server from "./server.js";
 | 
			
		||||
import appContext from "../components/app_context.js";
 | 
			
		||||
import shortcutService, { ShortcutBinding } from "./shortcuts.js";
 | 
			
		||||
import appContext, { type CommandNames } from "../components/app_context.js";
 | 
			
		||||
import shortcutService from "./shortcuts.js";
 | 
			
		||||
import type Component from "../components/component.js";
 | 
			
		||||
import type { ActionKeyboardShortcut } from "@triliumnext/commons";
 | 
			
		||||
 | 
			
		||||
@@ -30,18 +30,12 @@ async function getActionsForScope(scope: string) {
 | 
			
		||||
 | 
			
		||||
async function setupActionsForElement(scope: string, $el: JQuery<HTMLElement>, component: Component) {
 | 
			
		||||
    const actions = await getActionsForScope(scope);
 | 
			
		||||
    const bindings: ShortcutBinding[] = [];
 | 
			
		||||
 | 
			
		||||
    for (const action of actions) {
 | 
			
		||||
        for (const shortcut of action.effectiveShortcuts ?? []) {
 | 
			
		||||
            const binding = shortcutService.bindElShortcut($el, shortcut, () => component.triggerCommand(action.actionName, { ntxId: appContext.tabManager.activeNtxId }));
 | 
			
		||||
            if (binding) {
 | 
			
		||||
                bindings.push(binding);
 | 
			
		||||
            }
 | 
			
		||||
            shortcutService.bindElShortcut($el, shortcut, () => component.triggerCommand(action.actionName, { ntxId: appContext.tabManager.activeNtxId }));
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return bindings;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
getActionsForScope("window").then((actions) => {
 | 
			
		||||
 
 | 
			
		||||
@@ -280,7 +280,7 @@ function goToLink(evt: MouseEvent | JQuery.ClickEvent | JQuery.MouseDownEvent) {
 | 
			
		||||
 * @param $link the jQuery element of the link that was clicked, used to determine if the link is an anchor link (e.g., `#fn1` or `#fnref1`) and to handle it accordingly.
 | 
			
		||||
 * @returns `true` if the link was handled (i.e., the element was found and scrolled to), or a falsy value otherwise.
 | 
			
		||||
 */
 | 
			
		||||
export function goToLinkExt(evt: MouseEvent | JQuery.ClickEvent | JQuery.MouseDownEvent | React.PointerEvent<HTMLCanvasElement> | null, hrefLink: string | undefined, $link?: JQuery<HTMLElement> | null) {
 | 
			
		||||
function goToLinkExt(evt: MouseEvent | JQuery.ClickEvent | JQuery.MouseDownEvent | React.PointerEvent<HTMLCanvasElement> | null, hrefLink: string | undefined, $link?: JQuery<HTMLElement> | null) {
 | 
			
		||||
    if (hrefLink?.startsWith("data:")) {
 | 
			
		||||
        return true;
 | 
			
		||||
    }
 | 
			
		||||
 
 | 
			
		||||
@@ -126,7 +126,7 @@ function downloadRevision(noteId: string, revisionId: string) {
 | 
			
		||||
/**
 | 
			
		||||
 * @param url - should be without initial slash!!!
 | 
			
		||||
 */
 | 
			
		||||
export function getUrlForDownload(url: string) {
 | 
			
		||||
function getUrlForDownload(url: string) {
 | 
			
		||||
    if (utils.isElectron()) {
 | 
			
		||||
        // electron needs absolute URL, so we extract current host, port, protocol
 | 
			
		||||
        return `${getHost()}/${url}`;
 | 
			
		||||
 
 | 
			
		||||
@@ -3,7 +3,7 @@ import utils from "./utils.js";
 | 
			
		||||
type ElementType = HTMLElement | Document;
 | 
			
		||||
type Handler = (e: KeyboardEvent) => void;
 | 
			
		||||
 | 
			
		||||
export interface ShortcutBinding {
 | 
			
		||||
interface ShortcutBinding {
 | 
			
		||||
    element: HTMLElement | Document;
 | 
			
		||||
    shortcut: string;
 | 
			
		||||
    handler: Handler;
 | 
			
		||||
@@ -126,20 +126,10 @@ function bindElShortcut($el: JQuery<ElementType | Element>, keyboardShortcut: st
 | 
			
		||||
                activeBindings.set(key, []);
 | 
			
		||||
            }
 | 
			
		||||
            activeBindings.get(key)!.push(binding);
 | 
			
		||||
            return binding;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function removeIndividualBinding(binding: ShortcutBinding) {
 | 
			
		||||
    const key = binding.namespace ?? "global";
 | 
			
		||||
    const activeBindingsInNamespace = activeBindings.get(key);
 | 
			
		||||
    if (activeBindingsInNamespace) {
 | 
			
		||||
        activeBindings.set(key, activeBindingsInNamespace.filter(aBinding => aBinding.handler === binding.handler));
 | 
			
		||||
    }
 | 
			
		||||
    binding.element.removeEventListener("keydown", binding.listener);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function removeNamespaceBindings(namespace: string) {
 | 
			
		||||
    const bindings = activeBindings.get(namespace);
 | 
			
		||||
    if (bindings) {
 | 
			
		||||
 
 | 
			
		||||
@@ -26,21 +26,12 @@ async function resolveNotePathToSegments(notePath: string, hoistedNoteId = "root
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const path = notePath.split("/").reverse();
 | 
			
		||||
 | 
			
		||||
    if (!path.includes("root")) {
 | 
			
		||||
        path.push("root");
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const effectivePathSegments: string[] = [];
 | 
			
		||||
    let childNoteId: string | null = null;
 | 
			
		||||
    let i = 0;
 | 
			
		||||
 | 
			
		||||
    while (true) {
 | 
			
		||||
        if (i >= path.length) {
 | 
			
		||||
            break;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const parentNoteId = path[i++];
 | 
			
		||||
    for (let i = 0; i < path.length; i++) {
 | 
			
		||||
        const parentNoteId = path[i];
 | 
			
		||||
 | 
			
		||||
        if (childNoteId !== null) {
 | 
			
		||||
            const child = await froca.getNote(childNoteId, !logErrors);
 | 
			
		||||
@@ -65,7 +56,7 @@ async function resolveNotePathToSegments(notePath: string, hoistedNoteId = "root
 | 
			
		||||
                return null;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if (!parents.some((p) => p.noteId === parentNoteId)) {
 | 
			
		||||
            if (!parents.some(p => p.noteId === parentNoteId) || (i === path.length - 1 && parentNoteId !== 'root')) {
 | 
			
		||||
                if (logErrors) {
 | 
			
		||||
                    const parent = froca.getNoteFromCache(parentNoteId);
 | 
			
		||||
 | 
			
		||||
@@ -77,7 +68,8 @@ async function resolveNotePathToSegments(notePath: string, hoistedNoteId = "root
 | 
			
		||||
                    );
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                const bestNotePath = child.getBestNotePath(hoistedNoteId);
 | 
			
		||||
                const activeNotePath = appContext.tabManager.getActiveContextNotePath();
 | 
			
		||||
                const bestNotePath = child.getBestNotePath(hoistedNoteId, activeNotePath);
 | 
			
		||||
 | 
			
		||||
                if (bestNotePath) {
 | 
			
		||||
                    const pathToRoot = bestNotePath.reverse().slice(1);
 | 
			
		||||
@@ -108,7 +100,9 @@ async function resolveNotePathToSegments(notePath: string, hoistedNoteId = "root
 | 
			
		||||
        if (!note) {
 | 
			
		||||
            throw new Error(`Unable to find note: ${notePath}.`);
 | 
			
		||||
        }
 | 
			
		||||
        const bestNotePath = note.getBestNotePath(hoistedNoteId);
 | 
			
		||||
 | 
			
		||||
        const activeNotePath = appContext.tabManager.getActiveContextNotePath();
 | 
			
		||||
        const bestNotePath = note.getBestNotePath(hoistedNoteId, activeNotePath);
 | 
			
		||||
 | 
			
		||||
        if (!bestNotePath) {
 | 
			
		||||
            throw new Error(`Did not find any path segments for '${note.toString()}', hoisted note '${hoistedNoteId}'`);
 | 
			
		||||
 
 | 
			
		||||
@@ -11,7 +11,11 @@ export function reloadFrontendApp(reason?: string) {
 | 
			
		||||
        logInfo(`Frontend app reload: ${reason}`);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    window.location.reload();
 | 
			
		||||
    if (isElectron()) {
 | 
			
		||||
        dynamicRequire("@electron/remote").BrowserWindow.getFocusedWindow()?.reload();
 | 
			
		||||
    } else {
 | 
			
		||||
        window.location.reload();
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function restartDesktopApp() {
 | 
			
		||||
@@ -169,7 +173,7 @@ const entityMap: Record<string, string> = {
 | 
			
		||||
    "=": "="
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export function escapeHtml(str: string) {
 | 
			
		||||
function escapeHtml(str: string) {
 | 
			
		||||
    return str.replace(/[&<>"'`=\/]/g, (s) => entityMap[s]);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@@ -869,29 +873,6 @@ export function getErrorMessage(e: unknown) {
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// TODO: Deduplicate with server
 | 
			
		||||
export interface DeferredPromise<T> extends Promise<T> {
 | 
			
		||||
    resolve: (value: T | PromiseLike<T>) => void;
 | 
			
		||||
    reject: (reason?: any) => void;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// TODO: Deduplicate with server
 | 
			
		||||
export function deferred<T>(): DeferredPromise<T> {
 | 
			
		||||
    return (() => {
 | 
			
		||||
        let resolve!: (value: T | PromiseLike<T>) => void;
 | 
			
		||||
        let reject!: (reason?: any) => void;
 | 
			
		||||
 | 
			
		||||
        let promise = new Promise<T>((res, rej) => {
 | 
			
		||||
            resolve = res;
 | 
			
		||||
            reject = rej;
 | 
			
		||||
        }) as DeferredPromise<T>;
 | 
			
		||||
 | 
			
		||||
        promise.resolve = resolve;
 | 
			
		||||
        promise.reject = reject;
 | 
			
		||||
        return promise as DeferredPromise<T>;
 | 
			
		||||
    })();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Handles left or right placement of e.g. tooltips in case of right-to-left languages. If the current language is a RTL one, then left and right are swapped. Other directions are unaffected.
 | 
			
		||||
 * @param placement a string optionally containing a "left" or "right" value.
 | 
			
		||||
 
 | 
			
		||||
@@ -1,84 +0,0 @@
 | 
			
		||||
import "normalize.css";
 | 
			
		||||
import "boxicons/css/boxicons.min.css";
 | 
			
		||||
import "@triliumnext/ckeditor5/src/theme/ck-content.css";
 | 
			
		||||
import "@triliumnext/share-theme/styles/index.css";
 | 
			
		||||
import "@triliumnext/share-theme/scripts/index.js";
 | 
			
		||||
 | 
			
		||||
async function ensureJQuery() {
 | 
			
		||||
    const $ = (await import("jquery")).default;
 | 
			
		||||
    (window as any).$ = $;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function applyMath() {
 | 
			
		||||
    const anyMathBlock = document.querySelector("#content .math-tex");
 | 
			
		||||
    if (!anyMathBlock) {
 | 
			
		||||
        return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const renderMathInElement = (await import("./services/math.js")).renderMathInElement;
 | 
			
		||||
    renderMathInElement(document.getElementById("content"));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function formatCodeBlocks() {
 | 
			
		||||
    const anyCodeBlock = document.querySelector("#content pre");
 | 
			
		||||
    if (!anyCodeBlock) {
 | 
			
		||||
        return;
 | 
			
		||||
    }
 | 
			
		||||
    await ensureJQuery();
 | 
			
		||||
    const { formatCodeBlocks } = await import("./services/syntax_highlight.js");
 | 
			
		||||
    await formatCodeBlocks($("#content"));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function setupTextNote() {
 | 
			
		||||
    formatCodeBlocks();
 | 
			
		||||
    applyMath();
 | 
			
		||||
 | 
			
		||||
    const setupMermaid = (await import("./share/mermaid.js")).default;
 | 
			
		||||
    setupMermaid();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Fetch note with given ID from backend
 | 
			
		||||
 *
 | 
			
		||||
 * @param noteId of the given note to be fetched. If false, fetches current note.
 | 
			
		||||
 */
 | 
			
		||||
async function fetchNote(noteId: string | null = null) {
 | 
			
		||||
    if (!noteId) {
 | 
			
		||||
        noteId = document.body.getAttribute("data-note-id");
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const resp = await fetch(`api/notes/${noteId}`);
 | 
			
		||||
 | 
			
		||||
    return await resp.json();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
document.addEventListener(
 | 
			
		||||
    "DOMContentLoaded",
 | 
			
		||||
    () => {
 | 
			
		||||
        const noteType = determineNoteType();
 | 
			
		||||
 | 
			
		||||
        if (noteType === "text") {
 | 
			
		||||
            setupTextNote();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const toggleMenuButton = document.getElementById("toggleMenuButton");
 | 
			
		||||
        const layout = document.getElementById("layout");
 | 
			
		||||
 | 
			
		||||
        if (toggleMenuButton && layout) {
 | 
			
		||||
            toggleMenuButton.addEventListener("click", () => layout.classList.toggle("showMenu"));
 | 
			
		||||
        }
 | 
			
		||||
    },
 | 
			
		||||
    false
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
function determineNoteType() {
 | 
			
		||||
    const bodyClass = document.body.className;
 | 
			
		||||
    const match = bodyClass.match(/type-([^\s]+)/);
 | 
			
		||||
    return match ? match[1] : null;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// workaround to prevent webpack from removing "fetchNote" as dead code:
 | 
			
		||||
// add fetchNote as property to the window object
 | 
			
		||||
Object.defineProperty(window, "fetchNote", {
 | 
			
		||||
    value: fetchNote
 | 
			
		||||
});
 | 
			
		||||
@@ -407,7 +407,7 @@ body.desktop .tabulator-popup-container,
 | 
			
		||||
.dropdown-menu .disabled .disabled-tooltip {
 | 
			
		||||
    pointer-events: all;
 | 
			
		||||
    margin-inline-start: 8px;
 | 
			
		||||
    font-size: 0.75rem;
 | 
			
		||||
    font-size: 0.5em;
 | 
			
		||||
    color: var(--disabled-tooltip-icon-color);
 | 
			
		||||
    cursor: help;
 | 
			
		||||
    opacity: 0.75;
 | 
			
		||||
@@ -2034,9 +2034,9 @@ body.zen #right-pane,
 | 
			
		||||
body.zen #mobile-sidebar-wrapper,
 | 
			
		||||
body.zen .tab-row-container,
 | 
			
		||||
body.zen .tab-row-widget,
 | 
			
		||||
body.zen .ribbon-container:not(:has(.classic-toolbar-widget.visible)),
 | 
			
		||||
body.zen .ribbon-container:has(.classic-toolbar-widget.visible) .ribbon-top-row,
 | 
			
		||||
body.zen .ribbon-container .ribbon-body:not(:has(.classic-toolbar-widget.visible)),
 | 
			
		||||
body.zen .ribbon-container:not(:has(.classic-toolbar-widget)),
 | 
			
		||||
body.zen .ribbon-container:has(.classic-toolbar-widget) .ribbon-top-row,
 | 
			
		||||
body.zen .ribbon-container .ribbon-body:not(:has(.classic-toolbar-widget)),
 | 
			
		||||
body.zen .note-icon-widget,
 | 
			
		||||
body.zen .title-row .icon-action,
 | 
			
		||||
body.zen .floating-buttons-children > *:not(.bx-edit-alt),
 | 
			
		||||
 
 | 
			
		||||
@@ -575,14 +575,9 @@ li.dropdown-item a.dropdown-item-button:focus-visible {
 | 
			
		||||
    padding: 0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.note-list-wrapper .note-book-card .note-book-content.type-code {
 | 
			
		||||
    height: 100%;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.note-list-wrapper .note-book-card .note-book-content.type-code pre {
 | 
			
		||||
    height: 100%;
 | 
			
		||||
    padding: 1em;
 | 
			
		||||
    margin-bottom: 0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.note-list-wrapper .note-book-card .bx {
 | 
			
		||||
 
 | 
			
		||||
@@ -716,7 +716,6 @@
 | 
			
		||||
    "backup_database_now": "نسخ اختياطي لقاعدة البيانات الان"
 | 
			
		||||
  },
 | 
			
		||||
  "etapi": {
 | 
			
		||||
    "wiki": "ويكي",
 | 
			
		||||
    "created": "تم الأنشاء",
 | 
			
		||||
    "actions": "أجراءات",
 | 
			
		||||
    "title": "ETAPI",
 | 
			
		||||
 
 | 
			
		||||
@@ -51,7 +51,7 @@
 | 
			
		||||
    "bulk_actions_executed": "批量操作已成功执行。",
 | 
			
		||||
    "none_yet": "暂无操作 ... 通过点击上方的可用操作添加一个操作。",
 | 
			
		||||
    "labels": "标签",
 | 
			
		||||
    "relations": "关联关系",
 | 
			
		||||
    "relations": "关系",
 | 
			
		||||
    "notes": "笔记",
 | 
			
		||||
    "other": "其它"
 | 
			
		||||
  },
 | 
			
		||||
@@ -104,7 +104,8 @@
 | 
			
		||||
    "export_status": "导出状态",
 | 
			
		||||
    "export_in_progress": "导出进行中:{{progressCount}}",
 | 
			
		||||
    "export_finished_successfully": "导出成功完成。",
 | 
			
		||||
    "format_pdf": "PDF - 用于打印或共享目的。"
 | 
			
		||||
    "format_pdf": "PDF - 用于打印或共享目的。",
 | 
			
		||||
    "share-format": "HTML 网页发布——采用与共享笔记相同的主题,但可发布为静态网站。"
 | 
			
		||||
  },
 | 
			
		||||
  "help": {
 | 
			
		||||
    "noteNavigation": "笔记导航",
 | 
			
		||||
@@ -184,7 +185,8 @@
 | 
			
		||||
    },
 | 
			
		||||
    "import-status": "导入状态",
 | 
			
		||||
    "in-progress": "导入进行中:{{progress}}",
 | 
			
		||||
    "successful": "导入成功完成。"
 | 
			
		||||
    "successful": "导入成功完成。",
 | 
			
		||||
    "importZipRecommendation": "导入 ZIP 文件时,笔记层级将反映压缩文件内的子目录结构。"
 | 
			
		||||
  },
 | 
			
		||||
  "include_note": {
 | 
			
		||||
    "dialog_title": "包含笔记",
 | 
			
		||||
@@ -259,7 +261,6 @@
 | 
			
		||||
    "delete_all_revisions": "删除此笔记的所有修订版本",
 | 
			
		||||
    "delete_all_button": "删除所有修订版本",
 | 
			
		||||
    "help_title": "关于笔记修订版本的帮助",
 | 
			
		||||
    "revision_last_edited": "此修订版本上次编辑于 {{date}}",
 | 
			
		||||
    "confirm_delete_all": "您是否要删除此笔记的所有修订版本?",
 | 
			
		||||
    "no_revisions": "此笔记暂无修订版本...",
 | 
			
		||||
    "restore_button": "恢复",
 | 
			
		||||
@@ -991,7 +992,7 @@
 | 
			
		||||
  },
 | 
			
		||||
  "protected_session": {
 | 
			
		||||
    "enter_password_instruction": "显示受保护的笔记需要输入您的密码:",
 | 
			
		||||
    "start_session_button": "开始受保护的会话",
 | 
			
		||||
    "start_session_button": "开始受保护的会话 <kbd>Enter</kbd>",
 | 
			
		||||
    "started": "受保护的会话已启动。",
 | 
			
		||||
    "wrong_password": "密码错误。",
 | 
			
		||||
    "protecting-finished-successfully": "保护操作已成功完成。",
 | 
			
		||||
@@ -1288,10 +1289,6 @@
 | 
			
		||||
  "etapi": {
 | 
			
		||||
    "title": "ETAPI",
 | 
			
		||||
    "description": "ETAPI 是一个 REST API,用于以编程方式访问 Trilium 实例,而无需 UI。",
 | 
			
		||||
    "see_more": "有关更多详细信息,请参见 {{- link_to_wiki}} 和 {{- link_to_openapi_spec}} 或 {{- link_to_swagger_ui}}。",
 | 
			
		||||
    "wiki": "维基",
 | 
			
		||||
    "openapi_spec": "ETAPI OpenAPI 规范",
 | 
			
		||||
    "swagger_ui": "ETAPI Swagger UI",
 | 
			
		||||
    "create_token": "创建新的 ETAPI 令牌",
 | 
			
		||||
    "existing_tokens": "现有令牌",
 | 
			
		||||
    "no_tokens_yet": "目前还没有令牌。点击上面的按钮创建一个。",
 | 
			
		||||
@@ -1558,7 +1555,9 @@
 | 
			
		||||
    "window-on-top": "保持此窗口置顶"
 | 
			
		||||
  },
 | 
			
		||||
  "note_detail": {
 | 
			
		||||
    "could_not_find_typewidget": "找不到类型为 '{{type}}' 的 typeWidget"
 | 
			
		||||
    "could_not_find_typewidget": "找不到类型为 '{{type}}' 的 typeWidget",
 | 
			
		||||
    "printing": "正在打印…",
 | 
			
		||||
    "printing_pdf": "正在导出为PDF…"
 | 
			
		||||
  },
 | 
			
		||||
  "note_title": {
 | 
			
		||||
    "placeholder": "请输入笔记标题..."
 | 
			
		||||
 
 | 
			
		||||
@@ -4,7 +4,7 @@
 | 
			
		||||
    "homepage": "Startseite:",
 | 
			
		||||
    "app_version": "App-Version:",
 | 
			
		||||
    "db_version": "DB-Version:",
 | 
			
		||||
    "sync_version": "Synch-version:",
 | 
			
		||||
    "sync_version": "Sync-Version:",
 | 
			
		||||
    "build_date": "Build-Datum:",
 | 
			
		||||
    "build_revision": "Build-Revision:",
 | 
			
		||||
    "data_directory": "Datenverzeichnis:"
 | 
			
		||||
@@ -104,7 +104,8 @@
 | 
			
		||||
    "export_status": "Exportstatus",
 | 
			
		||||
    "export_in_progress": "Export läuft: {{progressCount}}",
 | 
			
		||||
    "export_finished_successfully": "Der Export wurde erfolgreich abgeschlossen.",
 | 
			
		||||
    "format_pdf": "PDF - für Ausdrucke oder Teilen."
 | 
			
		||||
    "format_pdf": "PDF - für Ausdrucke oder Teilen.",
 | 
			
		||||
    "share-format": "HTML für die Web-Veröffentlichung – verwendet dasselbe Theme wie bei freigegebenen Notizen, kann jedoch als statische Website veröffentlicht werden."
 | 
			
		||||
  },
 | 
			
		||||
  "help": {
 | 
			
		||||
    "noteNavigation": "Notiz Navigation",
 | 
			
		||||
@@ -260,7 +261,6 @@
 | 
			
		||||
    "delete_all_revisions": "Lösche alle Revisionen dieser Notiz",
 | 
			
		||||
    "delete_all_button": "Alle Revisionen löschen",
 | 
			
		||||
    "help_title": "Hilfe zu Notizrevisionen",
 | 
			
		||||
    "revision_last_edited": "Diese Revision wurde zuletzt am {{date}} bearbeitet",
 | 
			
		||||
    "confirm_delete_all": "Möchtest du alle Revisionen dieser Notiz löschen?",
 | 
			
		||||
    "no_revisions": "Für diese Notiz gibt es noch keine Revisionen...",
 | 
			
		||||
    "confirm_restore": "Möchtest du diese Revision wiederherstellen? Dadurch werden der aktuelle Titel und Inhalt der Notiz mit dieser Revision überschrieben.",
 | 
			
		||||
@@ -989,9 +989,9 @@
 | 
			
		||||
  },
 | 
			
		||||
  "protected_session": {
 | 
			
		||||
    "enter_password_instruction": "Um die geschützte Notiz anzuzeigen, musst du dein Passwort eingeben:",
 | 
			
		||||
    "start_session_button": "Starte eine geschützte Sitzung",
 | 
			
		||||
    "start_session_button": "Starte eine geschützte Sitzung <kbd>Eingabetaste</kbd>",
 | 
			
		||||
    "started": "Geschützte Sitzung gestartet.",
 | 
			
		||||
    "wrong_password": "Passwort flasch.",
 | 
			
		||||
    "wrong_password": "Passwort falsch.",
 | 
			
		||||
    "protecting-finished-successfully": "Geschützt erfolgreich beendet.",
 | 
			
		||||
    "unprotecting-finished-successfully": "Ungeschützt erfolgreich beendet.",
 | 
			
		||||
    "protecting-in-progress": "Schützen läuft: {{count}}",
 | 
			
		||||
@@ -1286,10 +1286,6 @@
 | 
			
		||||
  "etapi": {
 | 
			
		||||
    "title": "ETAPI",
 | 
			
		||||
    "description": "ETAPI ist eine REST-API, die für den programmgesteuerten Zugriff auf die Trilium-Instanz ohne Benutzeroberfläche verwendet wird.",
 | 
			
		||||
    "see_more": "Weitere Details können im {{- link_to_wiki}} und in der {{- link_to_openapi_spec}} oder der {{- link_to_swagger_ui }} gefunden werden.",
 | 
			
		||||
    "wiki": "Wiki",
 | 
			
		||||
    "openapi_spec": "ETAPI OpenAPI-Spezifikation",
 | 
			
		||||
    "swagger_ui": "ETAPI Swagger UI",
 | 
			
		||||
    "create_token": "Erstelle ein neues ETAPI-Token",
 | 
			
		||||
    "existing_tokens": "Vorhandene Token",
 | 
			
		||||
    "no_tokens_yet": "Es sind noch keine Token vorhanden. Klicke auf die Schaltfläche oben, um eine zu erstellen.",
 | 
			
		||||
@@ -1658,7 +1654,7 @@
 | 
			
		||||
    "add-term-to-dictionary": "Begriff \"{{term}}\" zum Wörterbuch hinzufügen",
 | 
			
		||||
    "cut": "Ausschneiden",
 | 
			
		||||
    "copy": "Kopieren",
 | 
			
		||||
    "copy-link": "Link opieren",
 | 
			
		||||
    "copy-link": "Link kopieren",
 | 
			
		||||
    "paste": "Einfügen",
 | 
			
		||||
    "paste-as-plain-text": "Als unformatierten Text einfügen",
 | 
			
		||||
    "search_online": "Suche nach \"{{term}}\" mit {{searchEngine}} starten"
 | 
			
		||||
 
 | 
			
		||||
@@ -36,10 +36,13 @@
 | 
			
		||||
  },
 | 
			
		||||
  "branch_prefix": {
 | 
			
		||||
    "edit_branch_prefix": "Edit branch prefix",
 | 
			
		||||
    "edit_branch_prefix_multiple": "Edit branch prefix for {{count}} branches",
 | 
			
		||||
    "help_on_tree_prefix": "Help on Tree prefix",
 | 
			
		||||
    "prefix": "Prefix: ",
 | 
			
		||||
    "save": "Save",
 | 
			
		||||
    "branch_prefix_saved": "Branch prefix has been saved."
 | 
			
		||||
    "branch_prefix_saved": "Branch prefix has been saved.",
 | 
			
		||||
    "branch_prefix_saved_multiple": "Branch prefix has been saved for {{count}} branches.",
 | 
			
		||||
    "affected_branches": "Affected branches ({{count}}):"
 | 
			
		||||
  },
 | 
			
		||||
  "bulk_actions": {
 | 
			
		||||
    "bulk_actions": "Bulk actions",
 | 
			
		||||
@@ -104,7 +107,8 @@
 | 
			
		||||
    "export_status": "Export status",
 | 
			
		||||
    "export_in_progress": "Export in progress: {{progressCount}}",
 | 
			
		||||
    "export_finished_successfully": "Export finished successfully.",
 | 
			
		||||
    "format_pdf": "PDF - for printing or sharing purposes."
 | 
			
		||||
    "format_pdf": "PDF - for printing or sharing purposes.",
 | 
			
		||||
    "share-format": "HTML for web publishing - uses the same theme that is used shared notes, but can be published as a static website."
 | 
			
		||||
  },
 | 
			
		||||
  "help": {
 | 
			
		||||
    "title": "Cheatsheet",
 | 
			
		||||
@@ -260,7 +264,6 @@
 | 
			
		||||
    "delete_all_revisions": "Delete all revisions of this note",
 | 
			
		||||
    "delete_all_button": "Delete all revisions",
 | 
			
		||||
    "help_title": "Help on Note Revisions",
 | 
			
		||||
    "revision_last_edited": "This revision was last edited on {{date}}",
 | 
			
		||||
    "confirm_delete_all": "Do you want to delete all revisions of this note?",
 | 
			
		||||
    "no_revisions": "No revisions for this note yet...",
 | 
			
		||||
    "restore_button": "Restore",
 | 
			
		||||
@@ -992,7 +995,7 @@
 | 
			
		||||
  },
 | 
			
		||||
  "protected_session": {
 | 
			
		||||
    "enter_password_instruction": "Showing protected note requires entering your password:",
 | 
			
		||||
    "start_session_button": "Start protected session",
 | 
			
		||||
    "start_session_button": "Start protected session <kbd>enter</kbd>",
 | 
			
		||||
    "started": "Protected session has been started.",
 | 
			
		||||
    "wrong_password": "Wrong password.",
 | 
			
		||||
    "protecting-finished-successfully": "Protecting finished successfully.",
 | 
			
		||||
@@ -1453,10 +1456,6 @@
 | 
			
		||||
  "etapi": {
 | 
			
		||||
    "title": "ETAPI",
 | 
			
		||||
    "description": "ETAPI is a REST API used to access Trilium instance programmatically, without UI.",
 | 
			
		||||
    "see_more": "See more details in the {{- link_to_wiki}} and the {{- link_to_openapi_spec}} or the {{- link_to_swagger_ui }}.",
 | 
			
		||||
    "wiki": "wiki",
 | 
			
		||||
    "openapi_spec": "ETAPI OpenAPI spec",
 | 
			
		||||
    "swagger_ui": "ETAPI Swagger UI",
 | 
			
		||||
    "create_token": "Create new ETAPI token",
 | 
			
		||||
    "existing_tokens": "Existing tokens",
 | 
			
		||||
    "no_tokens_yet": "There are no tokens yet. Click on the button above to create one.",
 | 
			
		||||
 
 | 
			
		||||
@@ -104,7 +104,8 @@
 | 
			
		||||
    "export_status": "Estado de exportación",
 | 
			
		||||
    "export_in_progress": "Exportación en curso: {{progressCount}}",
 | 
			
		||||
    "export_finished_successfully": "La exportación finalizó exitosamente.",
 | 
			
		||||
    "format_pdf": "PDF - para propósitos de impresión o compartición."
 | 
			
		||||
    "format_pdf": "PDF - para propósitos de impresión o compartición.",
 | 
			
		||||
    "share-format": "HTML para publicación web: utiliza el mismo tema que se utiliza en las notas compartidas, pero se puede publicar como un sitio web estático."
 | 
			
		||||
  },
 | 
			
		||||
  "help": {
 | 
			
		||||
    "noteNavigation": "Navegación de notas",
 | 
			
		||||
@@ -184,7 +185,8 @@
 | 
			
		||||
    },
 | 
			
		||||
    "import-status": "Estado de importación",
 | 
			
		||||
    "in-progress": "Importación en progreso: {{progress}}",
 | 
			
		||||
    "successful": "Importación finalizada exitosamente."
 | 
			
		||||
    "successful": "Importación finalizada exitosamente.",
 | 
			
		||||
    "importZipRecommendation": "Al importar un archivo ZIP, la jerarquía de notas reflejará la estructura de subdirectorios dentro del archivo comprimido."
 | 
			
		||||
  },
 | 
			
		||||
  "include_note": {
 | 
			
		||||
    "dialog_title": "Incluir nota",
 | 
			
		||||
@@ -259,7 +261,6 @@
 | 
			
		||||
    "delete_all_revisions": "Eliminar todas las revisiones de esta nota",
 | 
			
		||||
    "delete_all_button": "Eliminar todas las revisiones",
 | 
			
		||||
    "help_title": "Ayuda sobre revisiones de notas",
 | 
			
		||||
    "revision_last_edited": "Esta revisión se editó por última vez en {{date}}",
 | 
			
		||||
    "confirm_delete_all": "¿Quiere eliminar todas las revisiones de esta nota?",
 | 
			
		||||
    "no_revisions": "Aún no hay revisiones para esta nota...",
 | 
			
		||||
    "restore_button": "Restaurar",
 | 
			
		||||
@@ -991,7 +992,7 @@
 | 
			
		||||
  },
 | 
			
		||||
  "protected_session": {
 | 
			
		||||
    "enter_password_instruction": "Para mostrar una nota protegida es necesario ingresar su contraseña:",
 | 
			
		||||
    "start_session_button": "Iniciar sesión protegida",
 | 
			
		||||
    "start_session_button": "Iniciar sesión protegida <kbd>Enter</kbd>",
 | 
			
		||||
    "started": "La sesión protegida ha iniciado.",
 | 
			
		||||
    "wrong_password": "Contraseña incorrecta.",
 | 
			
		||||
    "protecting-finished-successfully": "La protección finalizó exitosamente.",
 | 
			
		||||
@@ -1445,10 +1446,6 @@
 | 
			
		||||
  "etapi": {
 | 
			
		||||
    "title": "ETAPI",
 | 
			
		||||
    "description": "ETAPI es una REST API que se utiliza para acceder a la instancia de Trilium mediante programación, sin interfaz de usuario.",
 | 
			
		||||
    "see_more": "Véa más detalles en el {{- link_to_wiki}} y el {{- link_to_openapi_spec}} o el {{- link_to_swagger_ui }}.",
 | 
			
		||||
    "wiki": "wiki",
 | 
			
		||||
    "openapi_spec": "Especificación ETAPI OpenAPI",
 | 
			
		||||
    "swagger_ui": "ETAPI Swagger UI",
 | 
			
		||||
    "create_token": "Crear nuevo token ETAPI",
 | 
			
		||||
    "existing_tokens": "Tokens existentes",
 | 
			
		||||
    "no_tokens_yet": "Aún no hay tokens. Dé clic en el botón de arriba para crear uno.",
 | 
			
		||||
@@ -1715,7 +1712,9 @@
 | 
			
		||||
    "window-on-top": "Mantener esta ventana en la parte superior"
 | 
			
		||||
  },
 | 
			
		||||
  "note_detail": {
 | 
			
		||||
    "could_not_find_typewidget": "No se pudo encontrar typeWidget para el tipo '{{type}}'"
 | 
			
		||||
    "could_not_find_typewidget": "No se pudo encontrar typeWidget para el tipo '{{type}}'",
 | 
			
		||||
    "printing": "Impresión en curso...",
 | 
			
		||||
    "printing_pdf": "Exportando a PDF en curso.."
 | 
			
		||||
  },
 | 
			
		||||
  "note_title": {
 | 
			
		||||
    "placeholder": "escriba el título de la nota aquí..."
 | 
			
		||||
 
 | 
			
		||||
@@ -260,7 +260,6 @@
 | 
			
		||||
    "delete_all_revisions": "Supprimer toutes les versions de cette note",
 | 
			
		||||
    "delete_all_button": "Supprimer toutes les versions",
 | 
			
		||||
    "help_title": "Aide sur les versions de notes",
 | 
			
		||||
    "revision_last_edited": "Cette version a été modifiée pour la dernière fois le {{date}}",
 | 
			
		||||
    "confirm_delete_all": "Voulez-vous supprimer toutes les versions de cette note ?",
 | 
			
		||||
    "no_revisions": "Aucune version pour cette note pour l'instant...",
 | 
			
		||||
    "confirm_restore": "Voulez-vous restaurer cette version ? Le titre et le contenu actuels de la note seront écrasés par cette version.",
 | 
			
		||||
@@ -992,7 +991,7 @@
 | 
			
		||||
  },
 | 
			
		||||
  "protected_session": {
 | 
			
		||||
    "enter_password_instruction": "L'affichage de la note protégée nécessite la saisie de votre mot de passe :",
 | 
			
		||||
    "start_session_button": "Démarrer une session protégée",
 | 
			
		||||
    "start_session_button": "Démarrer une session protégée <kbd>Entrée</kbd>",
 | 
			
		||||
    "started": "La session protégée a démarré.",
 | 
			
		||||
    "wrong_password": "Mot de passe incorrect.",
 | 
			
		||||
    "protecting-finished-successfully": "La protection de la note s'est terminée avec succès.",
 | 
			
		||||
@@ -1289,8 +1288,6 @@
 | 
			
		||||
  "etapi": {
 | 
			
		||||
    "title": "ETAPI",
 | 
			
		||||
    "description": "ETAPI est une API REST utilisée pour accéder à l'instance Trilium par programme, sans interface utilisateur.",
 | 
			
		||||
    "wiki": "wiki",
 | 
			
		||||
    "openapi_spec": "Spec ETAPI OpenAPI",
 | 
			
		||||
    "create_token": "Créer un nouveau jeton ETAPI",
 | 
			
		||||
    "existing_tokens": "Jetons existants",
 | 
			
		||||
    "no_tokens_yet": "Il n'y a pas encore de jetons. Cliquez sur le bouton ci-dessus pour en créer un.",
 | 
			
		||||
@@ -1307,9 +1304,7 @@
 | 
			
		||||
    "delete_token": "Supprimer/désactiver ce token",
 | 
			
		||||
    "rename_token_title": "Renommer le jeton",
 | 
			
		||||
    "rename_token_message": "Veuillez saisir le nom du nouveau jeton",
 | 
			
		||||
    "delete_token_confirmation": "Êtes-vous sûr de vouloir supprimer le jeton ETAPI « {{name}} » ?",
 | 
			
		||||
    "see_more": "Voir plus de détails dans le {{- link_to_wiki}} et le {{- link_to_openapi_spec}} ou le {{- link_to_swagger_ui }}.",
 | 
			
		||||
    "swagger_ui": "Interface utilisateur ETAPI Swagger"
 | 
			
		||||
    "delete_token_confirmation": "Êtes-vous sûr de vouloir supprimer le jeton ETAPI « {{name}} » ?"
 | 
			
		||||
  },
 | 
			
		||||
  "options_widget": {
 | 
			
		||||
    "options_status": "Statut des options",
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										5
									
								
								apps/client/src/translations/hi/translation.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								apps/client/src/translations/hi/translation.json
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,5 @@
 | 
			
		||||
{
 | 
			
		||||
  "about": {
 | 
			
		||||
    "title": "ट्रिलियम नोट्स के बारें में"
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -132,10 +132,6 @@
 | 
			
		||||
    "new_token_message": "Inserisci il nome del nuovo token",
 | 
			
		||||
    "title": "ETAPI",
 | 
			
		||||
    "description": "ETAPI è un'API REST utilizzata per accedere alle istanze di Trilium in modo programmatico, senza interfaccia utente.",
 | 
			
		||||
    "see_more": "Per maggiori dettagli consulta {{- link_to_wiki}} e {{- link_to_openapi_spec}} o {{- link_to_swagger_ui}}.",
 | 
			
		||||
    "wiki": "wiki",
 | 
			
		||||
    "openapi_spec": "Specifiche ETAPI OpenAPI",
 | 
			
		||||
    "swagger_ui": "Interfaccia utente ETAPI Swagger",
 | 
			
		||||
    "create_token": "Crea un nuovo token ETAPI",
 | 
			
		||||
    "existing_tokens": "Token esistenti",
 | 
			
		||||
    "no_tokens_yet": "Non ci sono ancora token. Clicca sul pulsante qui sopra per crearne uno.",
 | 
			
		||||
@@ -867,7 +863,6 @@
 | 
			
		||||
    "delete_all_revisions": "Elimina tutte le revisioni di questa nota",
 | 
			
		||||
    "delete_all_button": "Elimina tutte le revisioni",
 | 
			
		||||
    "help_title": "Aiuto sulle revisioni delle note",
 | 
			
		||||
    "revision_last_edited": "Questa revisione è stata modificata l'ultima volta il {{date}}",
 | 
			
		||||
    "confirm_delete_all": "Vuoi eliminare tutte le revisioni di questa nota?",
 | 
			
		||||
    "no_revisions": "Ancora nessuna revisione per questa nota...",
 | 
			
		||||
    "restore_button": "Ripristina",
 | 
			
		||||
 
 | 
			
		||||
@@ -254,7 +254,8 @@
 | 
			
		||||
    "export_status": "エクスポート状況",
 | 
			
		||||
    "export_in_progress": "エクスポート処理中: {{progressCount}}",
 | 
			
		||||
    "export_finished_successfully": "エクスポートが正常に完了しました。",
 | 
			
		||||
    "format_pdf": "PDF - 印刷または共有目的に。"
 | 
			
		||||
    "format_pdf": "PDF - 印刷または共有目的に。",
 | 
			
		||||
    "share-format": "Web 公開用の HTML - 共有ノートで使用されるのと同じテーマを使用しますが、静的 Web サイトとして公開できます。"
 | 
			
		||||
  },
 | 
			
		||||
  "help": {
 | 
			
		||||
    "title": "チートシート",
 | 
			
		||||
@@ -610,7 +611,6 @@
 | 
			
		||||
    "delete_all_revisions": "このノートの変更履歴をすべて削除",
 | 
			
		||||
    "delete_all_button": "変更履歴をすべて削除",
 | 
			
		||||
    "help_title": "変更履歴のヘルプ",
 | 
			
		||||
    "revision_last_edited": "この変更は{{date}}に行われました",
 | 
			
		||||
    "confirm_delete_all": "このノートのすべての変更履歴を削除しますか?",
 | 
			
		||||
    "no_revisions": "このノートに変更履歴はまだありません...",
 | 
			
		||||
    "restore_button": "復元",
 | 
			
		||||
@@ -657,10 +657,6 @@
 | 
			
		||||
    "created": "作成日時",
 | 
			
		||||
    "title": "ETAPI",
 | 
			
		||||
    "description": "ETAPI は、Trilium インスタンスに UI なしでプログラム的にアクセスするための REST API です。",
 | 
			
		||||
    "see_more": "詳細は{{- link_to_wiki}}と{{- link_to_openapi_spec}}または{{- link_to_swagger_ui }}を参照してください。",
 | 
			
		||||
    "wiki": "wiki",
 | 
			
		||||
    "openapi_spec": "ETAPI OpenAPIの仕様",
 | 
			
		||||
    "swagger_ui": "ETAPI Swagger UI",
 | 
			
		||||
    "create_token": "新しくETAPIトークンを作成",
 | 
			
		||||
    "existing_tokens": "既存のトークン",
 | 
			
		||||
    "no_tokens_yet": "トークンはまだありません。上のボタンをクリックして作成してください。",
 | 
			
		||||
 
 | 
			
		||||
@@ -13,6 +13,13 @@
 | 
			
		||||
    "critical-error": {
 | 
			
		||||
      "title": "Kritische Error",
 | 
			
		||||
      "message": "Een kritieke fout heeft plaatsgevonden waardoor de cliënt zich aanmeldt vanaf het begin:\n\n84X\n\nDit is waarschijnlijk veroorzaakt door een script dat op een onverwachte manier faalt. Probeer de sollicitatie in veilige modus te starten en de kwestie aan te spreken."
 | 
			
		||||
    },
 | 
			
		||||
    "widget-error": {
 | 
			
		||||
      "title": "Starten widget mislukt",
 | 
			
		||||
      "message-unknown": "Onbekende widget kan niet gestart worden omdat:\n\n{{message}}"
 | 
			
		||||
    },
 | 
			
		||||
    "bundle-error": {
 | 
			
		||||
      "title": "Custom script laden mislukt"
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  "add_link": {
 | 
			
		||||
 
 | 
			
		||||
@@ -912,7 +912,6 @@
 | 
			
		||||
    "delete_all_revisions": "Usuń wszystkie wersje tej notatki",
 | 
			
		||||
    "delete_all_button": "Usuń wszystkie wersje",
 | 
			
		||||
    "help_title": "Pomoc dotycząca wersji notatki",
 | 
			
		||||
    "revision_last_edited": "Ta wersja była ostatnio edytowana {{date}}",
 | 
			
		||||
    "confirm_delete_all": "Czy chcesz usunąć wszystkie wersje tej notatki?",
 | 
			
		||||
    "no_revisions": "Brak wersji dla tej notatki...",
 | 
			
		||||
    "restore_button": "Przywróć",
 | 
			
		||||
@@ -1664,10 +1663,6 @@
 | 
			
		||||
  "etapi": {
 | 
			
		||||
    "title": "ETAPI",
 | 
			
		||||
    "description": "ETAPI to interfejs API REST używany do programowego dostępu do instancji Trilium, bez interfejsu użytkownika.",
 | 
			
		||||
    "see_more": "Zobacz więcej szczegółów w {{- link_to_wiki}} oraz w {{- link_to_openapi_spec}} lub {{- link_to_swagger_ui }}.",
 | 
			
		||||
    "wiki": "wiki",
 | 
			
		||||
    "openapi_spec": "specyfikacja ETAPI OpenAPI",
 | 
			
		||||
    "swagger_ui": "ETAPI Swagger UI",
 | 
			
		||||
    "create_token": "Utwórz nowy token ETAPI",
 | 
			
		||||
    "existing_tokens": "Istniejące tokeny",
 | 
			
		||||
    "no_tokens_yet": "Nie ma jeszcze żadnych tokenów. Kliknij przycisk powyżej, aby utworzyć jeden.",
 | 
			
		||||
 
 | 
			
		||||
@@ -259,7 +259,6 @@
 | 
			
		||||
    "delete_all_revisions": "Apagar todas as versões desta nota",
 | 
			
		||||
    "delete_all_button": "Apagar todas as versões",
 | 
			
		||||
    "help_title": "Ajuda sobre as versões da nota",
 | 
			
		||||
    "revision_last_edited": "Esta versão foi editada pela última vez em {{date}}",
 | 
			
		||||
    "confirm_delete_all": "Quer apagar todas as versões desta nota?",
 | 
			
		||||
    "no_revisions": "Ainda não há versões para esta nota...",
 | 
			
		||||
    "restore_button": "Recuperar",
 | 
			
		||||
@@ -968,7 +967,7 @@
 | 
			
		||||
  },
 | 
			
		||||
  "protected_session": {
 | 
			
		||||
    "enter_password_instruction": "É necessário digitar a sua palavra-passe para mostar notas protegidas:",
 | 
			
		||||
    "start_session_button": "Iniciar sessão protegida",
 | 
			
		||||
    "start_session_button": "Iniciar sessão protegida <kbd>enter</kbd>",
 | 
			
		||||
    "started": "A sessão protegida foi iniciada.",
 | 
			
		||||
    "wrong_password": "Palavra-passe incorreta.",
 | 
			
		||||
    "protecting-finished-successfully": "A proteção foi finalizada com sucesso.",
 | 
			
		||||
@@ -1423,10 +1422,6 @@
 | 
			
		||||
  "etapi": {
 | 
			
		||||
    "title": "ETAPI",
 | 
			
		||||
    "description": "ETAPI é uma API REST usada para aceder a instância do Trilium programaticamente, sem interface gráfica.",
 | 
			
		||||
    "see_more": "Veja mais pormenores no {{- link_to_wiki}}, na {{- link_to_openapi_spec}} ou na {{- link_to_swagger_ui}}.",
 | 
			
		||||
    "wiki": "wiki",
 | 
			
		||||
    "openapi_spec": "Especificação OpenAPI do ETAPI",
 | 
			
		||||
    "swagger_ui": "ETAPI Swagger UI",
 | 
			
		||||
    "create_token": "Criar token ETAPI",
 | 
			
		||||
    "existing_tokens": "Tokens existentes",
 | 
			
		||||
    "no_tokens_yet": "Ainda não existem tokens. Clique no botão acima para criar um.",
 | 
			
		||||
 
 | 
			
		||||
@@ -415,7 +415,6 @@
 | 
			
		||||
    "delete_all_revisions": "Excluir todas as versões desta nota",
 | 
			
		||||
    "delete_all_button": "Excluir todas as versões",
 | 
			
		||||
    "help_title": "Ajuda sobre as versões da nota",
 | 
			
		||||
    "revision_last_edited": "Esta versão foi editada pela última vez em {{date}}",
 | 
			
		||||
    "confirm_delete_all": "Você quer excluir todas as versões desta nota?",
 | 
			
		||||
    "no_revisions": "Ainda não há versões para esta nota...",
 | 
			
		||||
    "restore_button": "Recuperar",
 | 
			
		||||
@@ -1219,7 +1218,7 @@
 | 
			
		||||
    "unprotecting-in-progress-count": "Remoções de proteção em andamento: {{count}}",
 | 
			
		||||
    "protecting-title": "Estado da proteção",
 | 
			
		||||
    "unprotecting-title": "Estado da remoção de proteção",
 | 
			
		||||
    "start_session_button": "Iniciar sessão protegida"
 | 
			
		||||
    "start_session_button": "Iniciar sessão protegida <kbd>enter</kbd>"
 | 
			
		||||
  },
 | 
			
		||||
  "relation_map": {
 | 
			
		||||
    "open_in_new_tab": "Abrir em nova aba",
 | 
			
		||||
@@ -1933,10 +1932,6 @@
 | 
			
		||||
  "etapi": {
 | 
			
		||||
    "title": "ETAPI",
 | 
			
		||||
    "description": "ETAPI é uma API REST usada para acessar a instância do Trilium programaticamente, sem interface gráfica.",
 | 
			
		||||
    "see_more": "Veja mais detalhes no {{- link_to_wiki}}, na {{- link_to_openapi_spec}} ou na {{- link_to_swagger_ui}}.",
 | 
			
		||||
    "wiki": "wiki",
 | 
			
		||||
    "openapi_spec": "Especificação OpenAPI do ETAPI",
 | 
			
		||||
    "swagger_ui": "ETAPI Swagger UI",
 | 
			
		||||
    "create_token": "Criar novo token ETAPI",
 | 
			
		||||
    "existing_tokens": "Tokens existentes",
 | 
			
		||||
    "no_tokens_yet": "Ainda não existem tokens. Clique no botão acima para criar um.",
 | 
			
		||||
 
 | 
			
		||||
@@ -507,17 +507,13 @@
 | 
			
		||||
    "new_token_message": "Introduceți denumirea noului token",
 | 
			
		||||
    "new_token_title": "Token ETAPI nou",
 | 
			
		||||
    "no_tokens_yet": "Nu există încă token-uri. Clic pe butonul de deasupra pentru a crea una.",
 | 
			
		||||
    "openapi_spec": "Specificația OpenAPI pentru ETAPI",
 | 
			
		||||
    "swagger_ui": "UI-ul Swagger pentru ETAPI",
 | 
			
		||||
    "rename_token": "Redenumește token-ul",
 | 
			
		||||
    "rename_token_message": "Introduceți denumirea noului token",
 | 
			
		||||
    "rename_token_title": "Redenumire token",
 | 
			
		||||
    "see_more": "Vedeți mai multe detalii în {{- link_to_wiki}} și în {{- link_to_openapi_spec}} sau în {{- link_to_swagger_ui }}.",
 | 
			
		||||
    "title": "ETAPI",
 | 
			
		||||
    "token_created_message": "Copiați token-ul creat în clipboard. Trilium stochează token-ul ca hash așadar această valoare poate fi văzută doar acum.",
 | 
			
		||||
    "token_created_title": "Token ETAPI creat",
 | 
			
		||||
    "token_name": "Denumire token",
 | 
			
		||||
    "wiki": "wiki"
 | 
			
		||||
    "token_name": "Denumire token"
 | 
			
		||||
  },
 | 
			
		||||
  "execute_script": {
 | 
			
		||||
    "example_1": "De exemplu, pentru a adăuga un șir de caractere la titlul unei notițe, se poate folosi acest mic script:",
 | 
			
		||||
@@ -989,7 +985,7 @@
 | 
			
		||||
  },
 | 
			
		||||
  "protected_session": {
 | 
			
		||||
    "enter_password_instruction": "Afișarea notițelor protejate necesită introducerea parolei:",
 | 
			
		||||
    "start_session_button": "Deschide sesiunea protejată",
 | 
			
		||||
    "start_session_button": "Deschide sesiunea protejată <kbd>enter</kbd>",
 | 
			
		||||
    "started": "Sesiunea protejată este activă.",
 | 
			
		||||
    "wrong_password": "Parolă greșită.",
 | 
			
		||||
    "protecting-finished-successfully": "Protejarea a avut succes.",
 | 
			
		||||
@@ -1090,7 +1086,6 @@
 | 
			
		||||
    "preview_not_available": "Nu este disponibilă o previzualizare pentru acest tip de notiță.",
 | 
			
		||||
    "restore_button": "Restaurează",
 | 
			
		||||
    "revision_deleted": "Revizia notiței a fost ștearsă.",
 | 
			
		||||
    "revision_last_edited": "Revizia a fost ultima oară modificată pe {{date}}",
 | 
			
		||||
    "revision_restored": "Revizia notiței a fost restaurată.",
 | 
			
		||||
    "revisions_deleted": "Notița reviziei a fost ștearsă.",
 | 
			
		||||
    "maximum_revisions": "Numărul maxim de revizii pentru notița curentă: {{number}}.",
 | 
			
		||||
 
 | 
			
		||||
@@ -366,7 +366,6 @@
 | 
			
		||||
    "delete_all_button": "Удалить все версии",
 | 
			
		||||
    "help_title": "Помощь по версиям заметок",
 | 
			
		||||
    "confirm_delete_all": "Вы хотите удалить все версии этой заметки?",
 | 
			
		||||
    "revision_last_edited": "Эта версия последний раз редактировалась {{date}}",
 | 
			
		||||
    "confirm_restore": "Хотите восстановить эту версию? Текущее название и содержание заметки будут перезаписаны этой версией.",
 | 
			
		||||
    "confirm_delete": "Вы хотите удалить эту версию?",
 | 
			
		||||
    "revisions_deleted": "Версии заметки были удалены.",
 | 
			
		||||
@@ -1441,7 +1440,6 @@
 | 
			
		||||
  },
 | 
			
		||||
  "etapi": {
 | 
			
		||||
    "title": "ETAPI",
 | 
			
		||||
    "wiki": "вики",
 | 
			
		||||
    "created": "Создано",
 | 
			
		||||
    "actions": "Действия",
 | 
			
		||||
    "existing_tokens": "Существующие токены",
 | 
			
		||||
@@ -1449,10 +1447,7 @@
 | 
			
		||||
    "default_token_name": "новый токен",
 | 
			
		||||
    "rename_token_title": "Переименовать токен",
 | 
			
		||||
    "description": "ETAPI — это REST API, используемый для программного доступа к экземпляру Trilium без пользовательского интерфейса.",
 | 
			
		||||
    "see_more": "Более подробную информацию смотрите в {{- link_to_wiki}} и {{- link_to_openapi_spec}} или {{- link_to_swagger_ui }}.",
 | 
			
		||||
    "create_token": "Создать новый токен ETAPI",
 | 
			
		||||
    "openapi_spec": "Спецификация ETAPI OpenAPI",
 | 
			
		||||
    "swagger_ui": "Пользовательский интерфейс ETAPI Swagger",
 | 
			
		||||
    "new_token_title": "Новый токен ETAPI",
 | 
			
		||||
    "token_created_title": "Создан токен ETAPI",
 | 
			
		||||
    "rename_token": "Переименовать этот токен",
 | 
			
		||||
@@ -1693,7 +1688,7 @@
 | 
			
		||||
    "unprotecting-title": "Статус снятия защиты",
 | 
			
		||||
    "protecting-finished-successfully": "Защита успешно завершена.",
 | 
			
		||||
    "unprotecting-finished-successfully": "Снятие защиты успешно завершено.",
 | 
			
		||||
    "start_session_button": "Начать защищенный сеанс",
 | 
			
		||||
    "start_session_button": "Начать защищенный сеанс <kbd>enter</kbd>",
 | 
			
		||||
    "protecting-in-progress": "Защита в процессе: {{count}}",
 | 
			
		||||
    "unprotecting-in-progress-count": "Снятие защиты в процессе: {{count}}",
 | 
			
		||||
    "started": "Защищенный сеанс запущен.",
 | 
			
		||||
 
 | 
			
		||||
@@ -256,7 +256,6 @@
 | 
			
		||||
        "delete_all_revisions": "Obriši sve revizije ove beleške",
 | 
			
		||||
        "delete_all_button": "Obriši sve revizije",
 | 
			
		||||
        "help_title": "Pomoć za Revizije beleški",
 | 
			
		||||
        "revision_last_edited": "Ova revizija je poslednji put izmenjena {{date}}",
 | 
			
		||||
        "confirm_delete_all": "Da li želite da obrišete sve revizije ove beleške?",
 | 
			
		||||
        "no_revisions": "Još uvek nema revizija za ovu belešku...",
 | 
			
		||||
        "restore_button": "Vrati",
 | 
			
		||||
 
 | 
			
		||||
@@ -104,7 +104,8 @@
 | 
			
		||||
    "export_in_progress": "正在匯出:{{progressCount}}",
 | 
			
		||||
    "export_finished_successfully": "成功匯出。",
 | 
			
		||||
    "format_html": "HTML - 推薦,因為它保留了所有格式",
 | 
			
		||||
    "format_pdf": "PDF - 用於列印或與他人分享。"
 | 
			
		||||
    "format_pdf": "PDF - 用於列印或與他人分享。",
 | 
			
		||||
    "share-format": "HTML 網頁發佈——使用與共享筆記相同的佈景主題,但可發佈為靜態網站。"
 | 
			
		||||
  },
 | 
			
		||||
  "help": {
 | 
			
		||||
    "noteNavigation": "筆記導航",
 | 
			
		||||
@@ -260,7 +261,6 @@
 | 
			
		||||
    "delete_all_revisions": "刪除此筆記的所有歷史版本",
 | 
			
		||||
    "delete_all_button": "刪除所有歷史版本",
 | 
			
		||||
    "help_title": "關於筆記歷史版本的說明",
 | 
			
		||||
    "revision_last_edited": "此歷史版本上次於 {{date}} 編輯",
 | 
			
		||||
    "confirm_delete_all": "您是否要刪除此筆記的所有歷史版本?",
 | 
			
		||||
    "no_revisions": "此筆記暫無歷史版本…",
 | 
			
		||||
    "confirm_restore": "您是否要還原此歷史版本?這將使用此歷史版本覆寫筆記的目前標題和內容。",
 | 
			
		||||
@@ -989,7 +989,7 @@
 | 
			
		||||
  },
 | 
			
		||||
  "protected_session": {
 | 
			
		||||
    "enter_password_instruction": "顯示受保護的筆記需要輸入您的密碼:",
 | 
			
		||||
    "start_session_button": "開始受保護的作業階段",
 | 
			
		||||
    "start_session_button": "開始受保護的作業階段 <kbd>Enter</kbd>",
 | 
			
		||||
    "started": "已啟動受保護的作業階段。",
 | 
			
		||||
    "wrong_password": "密碼錯誤。",
 | 
			
		||||
    "protecting-finished-successfully": "已成功完成保護操作。",
 | 
			
		||||
@@ -1281,8 +1281,6 @@
 | 
			
		||||
  "etapi": {
 | 
			
		||||
    "title": "ETAPI",
 | 
			
		||||
    "description": "ETAPI 是一個 REST API,用於以編程方式訪問 Trilium 實例,而無需 UI。",
 | 
			
		||||
    "wiki": "維基",
 | 
			
		||||
    "openapi_spec": "ETAPI OpenAPI 規範",
 | 
			
		||||
    "create_token": "新增 ETAPI 令牌",
 | 
			
		||||
    "existing_tokens": "現有令牌",
 | 
			
		||||
    "no_tokens_yet": "目前還沒有令牌。點擊上面的按鈕新增一個。",
 | 
			
		||||
@@ -1299,9 +1297,7 @@
 | 
			
		||||
    "delete_token": "刪除 / 停用此令牌",
 | 
			
		||||
    "rename_token_title": "重新命名令牌",
 | 
			
		||||
    "rename_token_message": "請輸入新的令牌名稱",
 | 
			
		||||
    "delete_token_confirmation": "您確定要刪除 ETAPI 令牌 \"{{name}}\" 嗎?",
 | 
			
		||||
    "see_more": "有關更多詳細資訊,請參閱 {{- link_to_wiki}} 和 {{- link_to_openapi_spec}} 或 {{- link_to_swagger_ui}}。",
 | 
			
		||||
    "swagger_ui": "ETAPI Swagger UI"
 | 
			
		||||
    "delete_token_confirmation": "您確定要刪除 ETAPI 令牌 \"{{name}}\" 嗎?"
 | 
			
		||||
  },
 | 
			
		||||
  "options_widget": {
 | 
			
		||||
    "options_status": "選項狀態",
 | 
			
		||||
 
 | 
			
		||||
@@ -309,7 +309,6 @@
 | 
			
		||||
    "delete_all_revisions": "Видалити всі версії цієї нотатки",
 | 
			
		||||
    "delete_all_button": "Видалити всі версії",
 | 
			
		||||
    "help_title": "Довідка щодо Версій нотаток",
 | 
			
		||||
    "revision_last_edited": "Цю версію востаннє редагували {{date}}",
 | 
			
		||||
    "confirm_delete_all": "Ви хочете видалити всі версії цієї нотатки?",
 | 
			
		||||
    "no_revisions": "Поки що немає версій цієї нотатки...",
 | 
			
		||||
    "restore_button": "Відновити",
 | 
			
		||||
@@ -1090,7 +1089,7 @@
 | 
			
		||||
  },
 | 
			
		||||
  "protected_session": {
 | 
			
		||||
    "enter_password_instruction": "Для відображення захищеної нотатки потрібно ввести пароль:",
 | 
			
		||||
    "start_session_button": "Розпочати захищений сеанс",
 | 
			
		||||
    "start_session_button": "Розпочати захищений сеанс <kbd>enter</kbd>",
 | 
			
		||||
    "started": "Захищений сеанс розпочато.",
 | 
			
		||||
    "wrong_password": "Неправильний пароль.",
 | 
			
		||||
    "protecting-finished-successfully": "Захист успішно завершено.",
 | 
			
		||||
@@ -1403,10 +1402,6 @@
 | 
			
		||||
  "etapi": {
 | 
			
		||||
    "title": "ETAPI",
 | 
			
		||||
    "description": "ETAPI — це REST API, який використовується для програмного доступу до екземпляра Trilium без інтерфейсу користувача.",
 | 
			
		||||
    "see_more": "Див. докладнішу інформацію у {{- link_to_wiki}} та {{- link_to_openapi_spec}} або {{- link_to_swagger_ui }}.",
 | 
			
		||||
    "wiki": "вікі",
 | 
			
		||||
    "openapi_spec": "ETAPI OpenAPI spec",
 | 
			
		||||
    "swagger_ui": "ETAPI Swagger UI",
 | 
			
		||||
    "create_token": "Створити новий токен ETAPI",
 | 
			
		||||
    "existing_tokens": "Існуючі токени",
 | 
			
		||||
    "no_tokens_yet": "Токенів поки що немає. Натисніть кнопку вище, щоб створити його.",
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										11
									
								
								apps/client/src/types-lib.d.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										11
									
								
								apps/client/src/types-lib.d.ts
									
									
									
									
										vendored
									
									
								
							@@ -60,14 +60,3 @@ declare global {
 | 
			
		||||
        windowControlsOverlay?: unknown;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
declare module "preact" {
 | 
			
		||||
    namespace JSX {
 | 
			
		||||
        interface IntrinsicElements {
 | 
			
		||||
            webview: {
 | 
			
		||||
                src: string;
 | 
			
		||||
                class: string;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										9
									
								
								apps/client/src/types.d.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										9
									
								
								apps/client/src/types.d.ts
									
									
									
									
										vendored
									
									
								
							@@ -26,7 +26,6 @@ interface CustomGlobals {
 | 
			
		||||
    appContext: AppContext;
 | 
			
		||||
    froca: Froca;
 | 
			
		||||
    treeCache: Froca;
 | 
			
		||||
    importMarkdownInline: () => Promise<unknown>;
 | 
			
		||||
    SEARCH_HELP_TEXT: string;
 | 
			
		||||
    activeDialog: JQuery<HTMLElement> | null;
 | 
			
		||||
    componentId: string;
 | 
			
		||||
@@ -119,17 +118,11 @@ declare global {
 | 
			
		||||
        filterKey: (e: { altKey: boolean }, dx: number, dy: number, dz: number) => void;
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    interface PanZoomTransform {
 | 
			
		||||
        x: number;
 | 
			
		||||
        y: number;
 | 
			
		||||
        scale: number;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    interface PanZoom {
 | 
			
		||||
        zoomTo(x: number, y: number, scale: number);
 | 
			
		||||
        moveTo(x: number, y: number);
 | 
			
		||||
        on(event: string, callback: () => void);
 | 
			
		||||
        getTransform(): PanZoomTransform;
 | 
			
		||||
        getTransform(): unknown;
 | 
			
		||||
        dispose(): void;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -23,7 +23,7 @@ import { ViewTypeOptions } from "./collections/interface";
 | 
			
		||||
 | 
			
		||||
export interface FloatingButtonContext {
 | 
			
		||||
    parentComponent: Component;
 | 
			
		||||
    note: FNote;
 | 
			
		||||
    note: FNote;    
 | 
			
		||||
    noteContext: NoteContext;
 | 
			
		||||
    isDefaultViewMode: boolean;
 | 
			
		||||
    isReadOnly: boolean;
 | 
			
		||||
@@ -65,11 +65,11 @@ export const MOBILE_FLOATING_BUTTONS: FloatingButtonsList = [
 | 
			
		||||
    EditButton,
 | 
			
		||||
    RelationMapButtons,
 | 
			
		||||
    ExportImageButtons,
 | 
			
		||||
    Backlinks
 | 
			
		||||
    Backlinks    
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
function RefreshBackendLogButton({ note, parentComponent, noteContext, isDefaultViewMode }: FloatingButtonContext) {
 | 
			
		||||
    const isEnabled = (note.noteId === "_backendLog" || note.type === "render") && isDefaultViewMode;
 | 
			
		||||
    const isEnabled = note.noteId === "_backendLog" && isDefaultViewMode;
 | 
			
		||||
    return isEnabled && <FloatingButton
 | 
			
		||||
        text={t("backend_log.refresh")}
 | 
			
		||||
        icon="bx bx-refresh"
 | 
			
		||||
@@ -84,14 +84,14 @@ function SwitchSplitOrientationButton({ note, isReadOnly, isDefaultViewMode }: F
 | 
			
		||||
 | 
			
		||||
    return isEnabled && <FloatingButton
 | 
			
		||||
        text={upcomingOrientation === "vertical" ? t("switch_layout_button.title_vertical") : t("switch_layout_button.title_horizontal")}
 | 
			
		||||
        icon={upcomingOrientation === "vertical" ? "bx bxs-dock-bottom" : "bx bxs-dock-left"}
 | 
			
		||||
        icon={upcomingOrientation === "vertical" ? "bx bxs-dock-bottom" : "bx bxs-dock-left"}        
 | 
			
		||||
        onClick={() => setSplitEditorOrientation(upcomingOrientation)}
 | 
			
		||||
    />
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function ToggleReadOnlyButton({ note, viewType, isDefaultViewMode }: FloatingButtonContext) {
 | 
			
		||||
    const [ isReadOnly, setReadOnly ] = useNoteLabelBoolean(note, "readOnly");
 | 
			
		||||
    const isEnabled = ([ "mermaid", "mindMap", "canvas" ].includes(note.type) || viewType === "geoMap")
 | 
			
		||||
    const [ isReadOnly, setReadOnly ] = useNoteLabelBoolean(note, "readOnly");    
 | 
			
		||||
    const isEnabled = (note.type === "mermaid" || viewType === "geoMap")
 | 
			
		||||
            && note.isContentAvailable() && isDefaultViewMode;
 | 
			
		||||
 | 
			
		||||
    return isEnabled && <FloatingButton
 | 
			
		||||
@@ -264,7 +264,7 @@ function GeoMapButtons({ triggerEvent, viewType, isReadOnly }: FloatingButtonCon
 | 
			
		||||
 | 
			
		||||
function CopyImageReferenceButton({ note, isDefaultViewMode }: FloatingButtonContext) {
 | 
			
		||||
    const hiddenImageCopyRef = useRef<HTMLDivElement>(null);
 | 
			
		||||
    const isEnabled = ["mermaid", "canvas", "mindMap", "image"].includes(note?.type ?? "")
 | 
			
		||||
    const isEnabled = ["mermaid", "canvas", "mindMap"].includes(note?.type ?? "")
 | 
			
		||||
            && note?.isContentAvailable() && isDefaultViewMode;
 | 
			
		||||
 | 
			
		||||
    return isEnabled && (
 | 
			
		||||
@@ -325,7 +325,7 @@ function Backlinks({ note, isDefaultViewMode }: FloatingButtonContext) {
 | 
			
		||||
    let [ backlinkCount, setBacklinkCount ] = useState(0);
 | 
			
		||||
    let [ popupOpen, setPopupOpen ] = useState(false);
 | 
			
		||||
    const backlinksContainerRef = useRef<HTMLDivElement>(null);
 | 
			
		||||
 | 
			
		||||
    
 | 
			
		||||
    useEffect(() => {
 | 
			
		||||
        if (!isDefaultViewMode) return;
 | 
			
		||||
 | 
			
		||||
@@ -338,7 +338,7 @@ function Backlinks({ note, isDefaultViewMode }: FloatingButtonContext) {
 | 
			
		||||
    const { windowHeight } = useWindowSize();
 | 
			
		||||
    useLayoutEffect(() => {
 | 
			
		||||
        const el = backlinksContainerRef.current;
 | 
			
		||||
        if (popupOpen && el) {
 | 
			
		||||
        if (popupOpen && el) {            
 | 
			
		||||
            const box = el.getBoundingClientRect();
 | 
			
		||||
            const maxHeight = windowHeight - box.top - 10;
 | 
			
		||||
            el.style.maxHeight = `${maxHeight}px`;
 | 
			
		||||
@@ -374,7 +374,7 @@ function BacklinksList({ noteId }: { noteId: string }) {
 | 
			
		||||
                    .filter(bl => "noteId" in bl)
 | 
			
		||||
                    .map((bl) => bl.noteId);
 | 
			
		||||
            await froca.getNotes(noteIds);
 | 
			
		||||
            setBacklinks(backlinks);
 | 
			
		||||
            setBacklinks(backlinks);       
 | 
			
		||||
        });
 | 
			
		||||
    }, [ noteId ]);
 | 
			
		||||
 | 
			
		||||
@@ -395,4 +395,4 @@ function BacklinksList({ noteId }: { noteId: string }) {
 | 
			
		||||
            )}
 | 
			
		||||
        </div>
 | 
			
		||||
    ));
 | 
			
		||||
}
 | 
			
		||||
}
 | 
			
		||||
@@ -1,13 +0,0 @@
 | 
			
		||||
.component.note-detail {
 | 
			
		||||
    font-family: var(--detail-font-family);
 | 
			
		||||
    font-size: var(--detail-font-size);
 | 
			
		||||
    contain: none;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.note-detail.full-height {
 | 
			
		||||
    height: 100%;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.note-detail > * {
 | 
			
		||||
    contain: none;
 | 
			
		||||
}
 | 
			
		||||
@@ -1,324 +0,0 @@
 | 
			
		||||
import { useNoteContext, useTriliumEvent, useTriliumEvents } from "./react/hooks"
 | 
			
		||||
import FNote from "../entities/fnote";
 | 
			
		||||
import protected_session_holder from "../services/protected_session_holder";
 | 
			
		||||
import { useEffect, useRef, useState } from "preact/hooks";
 | 
			
		||||
import NoteContext from "../components/note_context";
 | 
			
		||||
import { isValidElement, VNode } from "preact";
 | 
			
		||||
import { TypeWidgetProps } from "./type_widgets/type_widget";
 | 
			
		||||
import "./NoteDetail.css";
 | 
			
		||||
import attributes from "../services/attributes";
 | 
			
		||||
import { ExtendedNoteType, TYPE_MAPPINGS, TypeWidget } from "./note_types";
 | 
			
		||||
import { dynamicRequire, isElectron, isMobile } from "../services/utils";
 | 
			
		||||
import toast from "../services/toast.js";
 | 
			
		||||
import { t } from "../services/i18n";
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * The note detail is in charge of rendering the content of a note, by determining its type (e.g. text, code) and using the appropriate view widget.
 | 
			
		||||
 *
 | 
			
		||||
 * Apart from that, it:
 | 
			
		||||
 * - Applies a full-height style depending on the content type (e.g. canvas notes).
 | 
			
		||||
 * - Focuses the content when switching tabs.
 | 
			
		||||
 * - Caches the note type elements based on what the user has accessed, in order to quickly load it again.
 | 
			
		||||
 * - Fixes the tree for launch bar configurations on mobile.
 | 
			
		||||
 * - Provides scripting events such as obtaining the active note detail widget, or note type widget.
 | 
			
		||||
 * - Printing and exporting to PDF.
 | 
			
		||||
 */
 | 
			
		||||
export default function NoteDetail() {
 | 
			
		||||
    const containerRef = useRef<HTMLDivElement>(null);
 | 
			
		||||
    const { note, type, mime, noteContext, parentComponent } = useNoteInfo();
 | 
			
		||||
    const { ntxId, viewScope } = noteContext ?? {};
 | 
			
		||||
    const isFullHeight = checkFullHeight(noteContext, type);
 | 
			
		||||
    const noteTypesToRender = useRef<{ [ key in ExtendedNoteType ]?: (props: TypeWidgetProps) => VNode }>({});
 | 
			
		||||
    const [ activeNoteType, setActiveNoteType ] = useState<ExtendedNoteType>();
 | 
			
		||||
 | 
			
		||||
    const props: TypeWidgetProps = {
 | 
			
		||||
        note: note!,
 | 
			
		||||
        viewScope,
 | 
			
		||||
        ntxId,
 | 
			
		||||
        parentComponent,
 | 
			
		||||
        noteContext
 | 
			
		||||
    };
 | 
			
		||||
    useEffect(() => {
 | 
			
		||||
        if (!type) return;
 | 
			
		||||
 | 
			
		||||
        if (!noteTypesToRender.current[type]) {
 | 
			
		||||
            getCorrespondingWidget(type).then((el) => {
 | 
			
		||||
                if (!el) return;
 | 
			
		||||
                noteTypesToRender.current[type] = el;
 | 
			
		||||
                setActiveNoteType(type);
 | 
			
		||||
            });
 | 
			
		||||
        } else {
 | 
			
		||||
            setActiveNoteType(type);
 | 
			
		||||
        }
 | 
			
		||||
    }, [ note, viewScope, type ]);
 | 
			
		||||
 | 
			
		||||
    // Detect note type changes.
 | 
			
		||||
    useTriliumEvent("entitiesReloaded", async ({ loadResults }) => {
 | 
			
		||||
        if (!note) return;
 | 
			
		||||
 | 
			
		||||
        // we're detecting note type change on the note_detail level, but triggering the noteTypeMimeChanged
 | 
			
		||||
        // globally, so it gets also to e.g. ribbon components. But this means that the event can be generated multiple
 | 
			
		||||
        // times if the same note is open in several tabs.
 | 
			
		||||
 | 
			
		||||
        if (note.noteId && loadResults.isNoteContentReloaded(note.noteId, parentComponent.componentId)) {
 | 
			
		||||
            // probably incorrect event
 | 
			
		||||
            // calling this.refresh() is not enough since the event needs to be propagated to children as well
 | 
			
		||||
            // FIXME: create a separate event to force hierarchical refresh
 | 
			
		||||
 | 
			
		||||
            // this uses handleEvent to make sure that the ordinary content updates are propagated only in the subtree
 | 
			
		||||
            // to avoid the problem in #3365
 | 
			
		||||
            parentComponent.handleEvent("noteTypeMimeChanged", { noteId: note.noteId });
 | 
			
		||||
        } else if (note.noteId
 | 
			
		||||
            && loadResults.isNoteReloaded(note.noteId, parentComponent.componentId)
 | 
			
		||||
            && (type !== (await getWidgetType(note, noteContext)) || mime !== note?.mime)) {
 | 
			
		||||
            // this needs to have a triggerEvent so that e.g., note type (not in the component subtree) is updated
 | 
			
		||||
            parentComponent.triggerEvent("noteTypeMimeChanged", { noteId: note.noteId });
 | 
			
		||||
        } else {
 | 
			
		||||
            const attrs = loadResults.getAttributeRows();
 | 
			
		||||
 | 
			
		||||
            const label = attrs.find(
 | 
			
		||||
                (attr) =>
 | 
			
		||||
                    attr.type === "label" &&
 | 
			
		||||
                    ["readOnly", "autoReadOnlyDisabled", "cssClass", "displayRelations", "hideRelations"].includes(attr.name ?? "") &&
 | 
			
		||||
                    attributes.isAffecting(attr, note)
 | 
			
		||||
            );
 | 
			
		||||
 | 
			
		||||
            const relation = attrs.find((attr) => attr.type === "relation" && ["template", "inherit", "renderNote"]
 | 
			
		||||
                .includes(attr.name ?? "") && attributes.isAffecting(attr, note));
 | 
			
		||||
 | 
			
		||||
            if (note.noteId && (label || relation)) {
 | 
			
		||||
                // probably incorrect event
 | 
			
		||||
                // calling this.refresh() is not enough since the event needs to be propagated to children as well
 | 
			
		||||
                parentComponent.triggerEvent("noteTypeMimeChanged", { noteId: note.noteId });
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    // Automatically focus the editor.
 | 
			
		||||
    useTriliumEvent("activeNoteChanged", () => {
 | 
			
		||||
        // Restore focus to the editor when switching tabs, but only if the note tree is not already focused.
 | 
			
		||||
        if (!document.activeElement?.classList.contains("fancytree-title")) {
 | 
			
		||||
            parentComponent.triggerCommand("focusOnDetail", { ntxId });
 | 
			
		||||
        }
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    // Fixed tree for launch bar config on mobile.
 | 
			
		||||
    useEffect(() => {
 | 
			
		||||
        if (!isMobile) return;
 | 
			
		||||
        const hasFixedTree = noteContext?.hoistedNoteId === "_lbMobileRoot";
 | 
			
		||||
        document.body.classList.toggle("force-fixed-tree", hasFixedTree);
 | 
			
		||||
    }, [ note ]);
 | 
			
		||||
 | 
			
		||||
    // Handle toast notifications.
 | 
			
		||||
    useEffect(() => {
 | 
			
		||||
        if (!isElectron()) return;
 | 
			
		||||
        const { ipcRenderer } = dynamicRequire("electron");
 | 
			
		||||
        const listener = () => {
 | 
			
		||||
            toast.closePersistent("printing");
 | 
			
		||||
        };
 | 
			
		||||
        ipcRenderer.on("print-done", listener);
 | 
			
		||||
        return () => ipcRenderer.off("print-done", listener);
 | 
			
		||||
    }, []);
 | 
			
		||||
 | 
			
		||||
    useTriliumEvent("executeInActiveNoteDetailWidget", ({ callback }) => {
 | 
			
		||||
        if (!noteContext?.isActive()) return;
 | 
			
		||||
        callback(parentComponent);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    useTriliumEvent("executeWithTypeWidget", ({ resolve, ntxId: eventNtxId }) => {
 | 
			
		||||
        if (eventNtxId !== ntxId || !activeNoteType || !containerRef.current) return;
 | 
			
		||||
 | 
			
		||||
        const classNameToSearch = TYPE_MAPPINGS[activeNoteType].className;
 | 
			
		||||
        const componentEl = containerRef.current.querySelector<HTMLElement>(`.${classNameToSearch}`);
 | 
			
		||||
        if (!componentEl) return;
 | 
			
		||||
 | 
			
		||||
        const component = glob.getComponentByEl(componentEl);
 | 
			
		||||
        resolve(component);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    useTriliumEvent("printActiveNote", () => {
 | 
			
		||||
        if (!noteContext?.isActive() || !note) return;
 | 
			
		||||
 | 
			
		||||
        toast.showPersistent({
 | 
			
		||||
            icon: "bx bx-loader-circle bx-spin",
 | 
			
		||||
            message: t("note_detail.printing"),
 | 
			
		||||
            id: "printing"
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        if (isElectron()) {
 | 
			
		||||
            const { ipcRenderer } = dynamicRequire("electron");
 | 
			
		||||
            ipcRenderer.send("print-note", {
 | 
			
		||||
                notePath: noteContext.notePath
 | 
			
		||||
            });
 | 
			
		||||
        } else {
 | 
			
		||||
            const iframe = document.createElement('iframe');
 | 
			
		||||
            iframe.src = `?print#${noteContext.notePath}`;
 | 
			
		||||
            iframe.className = "print-iframe";
 | 
			
		||||
            document.body.appendChild(iframe);
 | 
			
		||||
            iframe.onload = () => {
 | 
			
		||||
                if (!iframe.contentWindow) {
 | 
			
		||||
                    toast.closePersistent("printing");
 | 
			
		||||
                    document.body.removeChild(iframe);
 | 
			
		||||
                    return;
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                iframe.contentWindow.addEventListener("note-ready", () => {
 | 
			
		||||
                    toast.closePersistent("printing");
 | 
			
		||||
                    iframe.contentWindow?.print();
 | 
			
		||||
                    document.body.removeChild(iframe);
 | 
			
		||||
                });
 | 
			
		||||
            };
 | 
			
		||||
        }
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    useTriliumEvent("exportAsPdf", () => {
 | 
			
		||||
        if (!noteContext?.isActive() || !note) return;
 | 
			
		||||
        toast.showPersistent({
 | 
			
		||||
            icon: "bx bx-loader-circle bx-spin",
 | 
			
		||||
            message: t("note_detail.printing_pdf"),
 | 
			
		||||
            id: "printing"
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        const { ipcRenderer } = dynamicRequire("electron");
 | 
			
		||||
        ipcRenderer.send("export-as-pdf", {
 | 
			
		||||
            title: note.title,
 | 
			
		||||
            notePath: noteContext.notePath,
 | 
			
		||||
            pageSize: note.getAttributeValue("label", "printPageSize") ?? "Letter",
 | 
			
		||||
            landscape: note.hasAttribute("label", "printLandscape")
 | 
			
		||||
        });
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
        <div
 | 
			
		||||
            ref={containerRef}
 | 
			
		||||
            class={`note-detail ${isFullHeight ? "full-height" : ""}`}
 | 
			
		||||
        >
 | 
			
		||||
            {Object.entries(noteTypesToRender.current).map(([ type, Element ]) => {
 | 
			
		||||
                return <NoteDetailWrapper
 | 
			
		||||
                    Element={Element}
 | 
			
		||||
                    key={type}
 | 
			
		||||
                    type={type as ExtendedNoteType}
 | 
			
		||||
                    isVisible={activeNoteType === type}
 | 
			
		||||
                    isFullHeight={isFullHeight}
 | 
			
		||||
                    props={props}
 | 
			
		||||
                />
 | 
			
		||||
            })}
 | 
			
		||||
        </div>
 | 
			
		||||
    );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Wraps a single note type widget, in order to keep it in the DOM even after the user has switched away to another note type. This allows faster loading of the same note type again. The properties are cached, so that they are updated only
 | 
			
		||||
 * while the widget is visible, to avoid rendering in the background. When not visible, the DOM element is simply hidden.
 | 
			
		||||
 */
 | 
			
		||||
function NoteDetailWrapper({ Element, type, isVisible, isFullHeight, props }: { Element: (props: TypeWidgetProps) => VNode, type: ExtendedNoteType, isVisible: boolean, isFullHeight: boolean, props: TypeWidgetProps }) {
 | 
			
		||||
    const [ cachedProps, setCachedProps ] = useState(props);
 | 
			
		||||
 | 
			
		||||
    useEffect(() => {
 | 
			
		||||
        if (isVisible) {
 | 
			
		||||
            setCachedProps(props);
 | 
			
		||||
        } else {
 | 
			
		||||
            // Do nothing, keep the old props.
 | 
			
		||||
        }
 | 
			
		||||
    }, [ isVisible ]);
 | 
			
		||||
 | 
			
		||||
    const typeMapping = TYPE_MAPPINGS[type];
 | 
			
		||||
    return (
 | 
			
		||||
        <div
 | 
			
		||||
            className={`${typeMapping.className} ${typeMapping.printable ? "note-detail-printable" : ""}`}
 | 
			
		||||
            style={{
 | 
			
		||||
                display: !isVisible ? "none" : "",
 | 
			
		||||
                height: isFullHeight ? "100%" : ""
 | 
			
		||||
            }}
 | 
			
		||||
        >
 | 
			
		||||
            { <Element {...cachedProps} /> }
 | 
			
		||||
        </div>
 | 
			
		||||
    );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/** Manages both note changes and changes to the widget type, which are asynchronous. */
 | 
			
		||||
function useNoteInfo() {
 | 
			
		||||
    const { note: actualNote, noteContext, parentComponent } = useNoteContext();
 | 
			
		||||
    const [ note, setNote ] = useState<FNote | null | undefined>();
 | 
			
		||||
    const [ type, setType ] = useState<ExtendedNoteType>();
 | 
			
		||||
    const [ mime, setMime ] = useState<string>();
 | 
			
		||||
 | 
			
		||||
    function refresh() {
 | 
			
		||||
        getWidgetType(actualNote, noteContext).then(type => {
 | 
			
		||||
            setNote(actualNote);
 | 
			
		||||
            setType(type);
 | 
			
		||||
            setMime(actualNote?.mime);
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    useEffect(refresh, [ actualNote, noteContext, noteContext?.viewScope ]);
 | 
			
		||||
    useTriliumEvent("readOnlyTemporarilyDisabled", ({ noteContext: eventNoteContext }) => {
 | 
			
		||||
        if (eventNoteContext?.ntxId !== noteContext?.ntxId) return;
 | 
			
		||||
        refresh();
 | 
			
		||||
    });
 | 
			
		||||
    useTriliumEvent("noteTypeMimeChanged", refresh);
 | 
			
		||||
 | 
			
		||||
    return { note, type, mime, noteContext, parentComponent };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function getCorrespondingWidget(type: ExtendedNoteType): Promise<null | TypeWidget> {
 | 
			
		||||
    const correspondingType = TYPE_MAPPINGS[type].view;
 | 
			
		||||
    if (!correspondingType) return null;
 | 
			
		||||
 | 
			
		||||
    const result = await correspondingType();
 | 
			
		||||
 | 
			
		||||
    if ("default" in result) {
 | 
			
		||||
        return result.default;
 | 
			
		||||
    } else if (isValidElement(result)) {
 | 
			
		||||
        // Direct VNode provided.
 | 
			
		||||
        return result;
 | 
			
		||||
    } else {
 | 
			
		||||
        return result;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function getWidgetType(note: FNote | null | undefined, noteContext: NoteContext | undefined): Promise<ExtendedNoteType> {
 | 
			
		||||
    if (!note) {
 | 
			
		||||
        console.log("Returning empty because no note.");
 | 
			
		||||
        return "empty";
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const type = note.type;
 | 
			
		||||
    let resultingType: ExtendedNoteType;
 | 
			
		||||
 | 
			
		||||
    if (noteContext?.viewScope?.viewMode === "source") {
 | 
			
		||||
        resultingType = "readOnlyCode";
 | 
			
		||||
    } else if (noteContext?.viewScope && noteContext.viewScope.viewMode === "attachments") {
 | 
			
		||||
        resultingType = noteContext.viewScope.attachmentId ? "attachmentDetail" : "attachmentList";
 | 
			
		||||
    } else if (type === "text" && (await noteContext?.isReadOnly())) {
 | 
			
		||||
        resultingType = "readOnlyText";
 | 
			
		||||
    } else if ((type === "code" || type === "mermaid") && (await noteContext?.isReadOnly())) {
 | 
			
		||||
        resultingType = "readOnlyCode";
 | 
			
		||||
    } else if (type === "text") {
 | 
			
		||||
        resultingType = "editableText";
 | 
			
		||||
    } else if (type === "code") {
 | 
			
		||||
        resultingType = "editableCode";
 | 
			
		||||
    } else if (type === "launcher") {
 | 
			
		||||
        resultingType = "doc";
 | 
			
		||||
    } else {
 | 
			
		||||
        resultingType = type;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (note.isProtected && !protected_session_holder.isProtectedSessionAvailable()) {
 | 
			
		||||
        resultingType = "protectedSession";
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return resultingType;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function checkFullHeight(noteContext: NoteContext | undefined, type: ExtendedNoteType | undefined) {
 | 
			
		||||
    if (!noteContext) return false;
 | 
			
		||||
 | 
			
		||||
    // https://github.com/zadam/trilium/issues/2522
 | 
			
		||||
    const isBackendNote = noteContext?.noteId === "_backendLog";
 | 
			
		||||
    const isSqlNote = noteContext.note?.mime === "text/x-sqlite;schema=trilium";
 | 
			
		||||
    const isFullHeightNoteType = type && TYPE_MAPPINGS[type].isFullHeight;
 | 
			
		||||
    return (!noteContext?.hasNoteList() && isFullHeightNoteType && !isSqlNote)
 | 
			
		||||
        || noteContext?.viewScope?.viewMode === "attachments"
 | 
			
		||||
        || isBackendNote;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										207
									
								
								apps/client/src/widgets/attachment_detail.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										207
									
								
								apps/client/src/widgets/attachment_detail.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,207 @@
 | 
			
		||||
import { t } from "../services/i18n.js";
 | 
			
		||||
import utils from "../services/utils.js";
 | 
			
		||||
import AttachmentActionsWidget from "./buttons/attachments_actions.js";
 | 
			
		||||
import BasicWidget from "./basic_widget.js";
 | 
			
		||||
import options from "../services/options.js";
 | 
			
		||||
import imageService from "../services/image.js";
 | 
			
		||||
import linkService from "../services/link.js";
 | 
			
		||||
import contentRenderer from "../services/content_renderer.js";
 | 
			
		||||
import toastService from "../services/toast.js";
 | 
			
		||||
import type FAttachment from "../entities/fattachment.js";
 | 
			
		||||
import type { EventData } from "../components/app_context.js";
 | 
			
		||||
 | 
			
		||||
const TPL = /*html*/`
 | 
			
		||||
<div class="attachment-detail-widget">
 | 
			
		||||
    <style>
 | 
			
		||||
        .attachment-detail-widget {
 | 
			
		||||
            height: 100%;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .attachment-detail-wrapper {
 | 
			
		||||
            margin-bottom: 20px;
 | 
			
		||||
            display: flex;
 | 
			
		||||
            flex-direction: column;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .attachment-title-line {
 | 
			
		||||
            display: flex;
 | 
			
		||||
            align-items: baseline;
 | 
			
		||||
            gap: 1em;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .attachment-details {
 | 
			
		||||
            margin-inline-start: 10px;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .attachment-content-wrapper {
 | 
			
		||||
            flex-grow: 1;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .attachment-content-wrapper .rendered-content {
 | 
			
		||||
            height: 100%;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .attachment-content-wrapper pre {
 | 
			
		||||
            padding: 10px;
 | 
			
		||||
            margin-top: 10px;
 | 
			
		||||
            margin-bottom: 10px;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .attachment-detail-wrapper.list-view .attachment-content-wrapper {
 | 
			
		||||
            max-height: 300px;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .attachment-detail-wrapper.full-detail {
 | 
			
		||||
            height: 100%;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .attachment-detail-wrapper.full-detail .attachment-content-wrapper {
 | 
			
		||||
            height: 100%;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .attachment-detail-wrapper.list-view .attachment-content-wrapper pre {
 | 
			
		||||
            max-height: 400px;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .attachment-content-wrapper img {
 | 
			
		||||
            margin: 10px;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .attachment-detail-wrapper.list-view .attachment-content-wrapper img, .attachment-detail-wrapper.list-view .attachment-content-wrapper video {
 | 
			
		||||
            max-height: 300px;
 | 
			
		||||
            max-width: 90%;
 | 
			
		||||
            object-fit: contain;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .attachment-detail-wrapper.full-detail .attachment-content-wrapper img {
 | 
			
		||||
            max-width: 90%;
 | 
			
		||||
            object-fit: contain;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .attachment-detail-wrapper.scheduled-for-deletion .attachment-content-wrapper img {
 | 
			
		||||
            filter: contrast(10%);
 | 
			
		||||
        }
 | 
			
		||||
    </style>
 | 
			
		||||
 | 
			
		||||
    <div class="attachment-detail-wrapper">
 | 
			
		||||
        <div class="attachment-title-line">
 | 
			
		||||
            <div class="attachment-actions-container"></div>
 | 
			
		||||
            <h4 class="attachment-title"></h4>
 | 
			
		||||
            <div class="attachment-details"></div>
 | 
			
		||||
            <div style="flex: 1 1;"></div>
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
        <div class="attachment-deletion-warning alert alert-info" style="margin-top: 15px;"></div>
 | 
			
		||||
 | 
			
		||||
        <div class="attachment-content-wrapper"></div>
 | 
			
		||||
    </div>
 | 
			
		||||
</div>`;
 | 
			
		||||
 | 
			
		||||
export default class AttachmentDetailWidget extends BasicWidget {
 | 
			
		||||
    attachment: FAttachment;
 | 
			
		||||
    attachmentActionsWidget: AttachmentActionsWidget;
 | 
			
		||||
    isFullDetail: boolean;
 | 
			
		||||
    $wrapper!: JQuery<HTMLElement>;
 | 
			
		||||
 | 
			
		||||
    constructor(attachment: FAttachment, isFullDetail: boolean) {
 | 
			
		||||
        super();
 | 
			
		||||
 | 
			
		||||
        this.contentSized();
 | 
			
		||||
        this.attachment = attachment;
 | 
			
		||||
        this.attachmentActionsWidget = new AttachmentActionsWidget(attachment, isFullDetail);
 | 
			
		||||
        this.isFullDetail = isFullDetail;
 | 
			
		||||
        this.child(this.attachmentActionsWidget);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    doRender() {
 | 
			
		||||
        this.$widget = $(TPL);
 | 
			
		||||
        this.refresh();
 | 
			
		||||
 | 
			
		||||
        super.doRender();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async refresh() {
 | 
			
		||||
        this.$widget.find(".attachment-detail-wrapper").empty().append($(TPL).find(".attachment-detail-wrapper").html());
 | 
			
		||||
        this.$wrapper = this.$widget.find(".attachment-detail-wrapper");
 | 
			
		||||
        this.$wrapper.addClass(this.isFullDetail ? "full-detail" : "list-view");
 | 
			
		||||
 | 
			
		||||
        if (!this.isFullDetail) {
 | 
			
		||||
            const $link = await linkService.createLink(this.attachment.ownerId, {
 | 
			
		||||
                title: this.attachment.title,
 | 
			
		||||
                viewScope: {
 | 
			
		||||
                    viewMode: "attachments",
 | 
			
		||||
                    attachmentId: this.attachment.attachmentId
 | 
			
		||||
                }
 | 
			
		||||
            });
 | 
			
		||||
            $link.addClass("use-tn-links");
 | 
			
		||||
 | 
			
		||||
            this.$wrapper.find(".attachment-title").append($link);
 | 
			
		||||
        } else {
 | 
			
		||||
            this.$wrapper.find(".attachment-title").text(this.attachment.title);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const $deletionWarning = this.$wrapper.find(".attachment-deletion-warning");
 | 
			
		||||
        const { utcDateScheduledForErasureSince } = this.attachment;
 | 
			
		||||
 | 
			
		||||
        if (utcDateScheduledForErasureSince) {
 | 
			
		||||
            this.$wrapper.addClass("scheduled-for-deletion");
 | 
			
		||||
 | 
			
		||||
            const scheduledSinceTimestamp = utils.parseDate(utcDateScheduledForErasureSince)?.getTime();
 | 
			
		||||
            // use default value (30 days in seconds) from options_init as fallback, in case getInt returns null
 | 
			
		||||
            const intervalMs = options.getInt("eraseUnusedAttachmentsAfterSeconds") || 2592000 * 1000;
 | 
			
		||||
            const deletionTimestamp = scheduledSinceTimestamp + intervalMs;
 | 
			
		||||
            const willBeDeletedInMs = deletionTimestamp - Date.now();
 | 
			
		||||
 | 
			
		||||
            $deletionWarning.show();
 | 
			
		||||
 | 
			
		||||
            if (willBeDeletedInMs >= 60000) {
 | 
			
		||||
                $deletionWarning.text(t("attachment_detail_2.will_be_deleted_in", { time: utils.formatTimeInterval(willBeDeletedInMs) }));
 | 
			
		||||
            } else {
 | 
			
		||||
                $deletionWarning.text(t("attachment_detail_2.will_be_deleted_soon"));
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            $deletionWarning.append(t("attachment_detail_2.deletion_reason"));
 | 
			
		||||
        } else {
 | 
			
		||||
            this.$wrapper.removeClass("scheduled-for-deletion");
 | 
			
		||||
            $deletionWarning.hide();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        this.$wrapper.find(".attachment-details").text(t("attachment_detail_2.role_and_size", { role: this.attachment.role, size: utils.formatSize(this.attachment.contentLength) }));
 | 
			
		||||
        this.$wrapper.find(".attachment-actions-container").append(this.attachmentActionsWidget.render());
 | 
			
		||||
 | 
			
		||||
        const { $renderedContent } = await contentRenderer.getRenderedContent(this.attachment, { imageHasZoom: this.isFullDetail });
 | 
			
		||||
        this.$wrapper.find(".attachment-content-wrapper").append($renderedContent);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async copyAttachmentLinkToClipboard() {
 | 
			
		||||
        if (this.attachment.role === "image") {
 | 
			
		||||
            imageService.copyImageReferenceToClipboard(this.$wrapper.find(".attachment-content-wrapper"));
 | 
			
		||||
        } else if (this.attachment.role === "file") {
 | 
			
		||||
            const $link = await linkService.createLink(this.attachment.ownerId, {
 | 
			
		||||
                referenceLink: true,
 | 
			
		||||
                viewScope: {
 | 
			
		||||
                    viewMode: "attachments",
 | 
			
		||||
                    attachmentId: this.attachment.attachmentId
 | 
			
		||||
                }
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
            utils.copyHtmlToClipboard($link[0].outerHTML);
 | 
			
		||||
 | 
			
		||||
            toastService.showMessage(t("attachment_detail_2.link_copied"));
 | 
			
		||||
        } else {
 | 
			
		||||
            throw new Error(t("attachment_detail_2.unrecognized_role", { role: this.attachment.role }));
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
 | 
			
		||||
        const attachmentRow = loadResults.getAttachmentRows().find((att) => att.attachmentId === this.attachment.attachmentId);
 | 
			
		||||
 | 
			
		||||
        if (attachmentRow) {
 | 
			
		||||
            if (attachmentRow.isDeleted) {
 | 
			
		||||
                this.toggleInt(false);
 | 
			
		||||
            } else {
 | 
			
		||||
                this.refresh();
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -4,6 +4,8 @@ import froca from "../services/froca.js";
 | 
			
		||||
import { t } from "../services/i18n.js";
 | 
			
		||||
import toastService from "../services/toast.js";
 | 
			
		||||
import { renderReactWidget } from "./react/react_utils.jsx";
 | 
			
		||||
import { EventNames, EventData } from "../components/app_context.js";
 | 
			
		||||
import { Handler } from "leaflet";
 | 
			
		||||
 | 
			
		||||
export class TypedBasicWidget<T extends TypedComponent<any>> extends TypedComponent<T> {
 | 
			
		||||
    protected attrs: Record<string, string>;
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										195
									
								
								apps/client/src/widgets/buttons/attachments_actions.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										195
									
								
								apps/client/src/widgets/buttons/attachments_actions.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,195 @@
 | 
			
		||||
import { t } from "../../services/i18n.js";
 | 
			
		||||
import BasicWidget from "../basic_widget.js";
 | 
			
		||||
import server from "../../services/server.js";
 | 
			
		||||
import dialogService from "../../services/dialog.js";
 | 
			
		||||
import toastService from "../../services/toast.js";
 | 
			
		||||
import ws from "../../services/ws.js";
 | 
			
		||||
import appContext from "../../components/app_context.js";
 | 
			
		||||
import openService from "../../services/open.js";
 | 
			
		||||
import utils from "../../services/utils.js";
 | 
			
		||||
import { Dropdown } from "bootstrap";
 | 
			
		||||
import type FAttachment from "../../entities/fattachment.js";
 | 
			
		||||
import type AttachmentDetailWidget from "../attachment_detail.js";
 | 
			
		||||
import type { NoteRow } from "@triliumnext/commons";
 | 
			
		||||
 | 
			
		||||
const TPL = /*html*/`
 | 
			
		||||
<div class="dropdown attachment-actions">
 | 
			
		||||
    <style>
 | 
			
		||||
    .attachment-actions {
 | 
			
		||||
        width: 35px;
 | 
			
		||||
        height: 35px;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    .attachment-actions .dropdown-menu {
 | 
			
		||||
        width: 20em;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    .attachment-actions .dropdown-item .bx {
 | 
			
		||||
        position: relative;
 | 
			
		||||
        top: 3px;
 | 
			
		||||
        font-size: 120%;
 | 
			
		||||
        margin-inline-end: 5px;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    .attachment-actions .dropdown-item[disabled], .attachment-actions .dropdown-item[disabled]:hover {
 | 
			
		||||
        color: var(--muted-text-color) !important;
 | 
			
		||||
        background-color: transparent !important;
 | 
			
		||||
        pointer-events: none; /* makes it unclickable */
 | 
			
		||||
    }
 | 
			
		||||
    </style>
 | 
			
		||||
 | 
			
		||||
    <button type="button" data-bs-toggle="dropdown" aria-haspopup="true"
 | 
			
		||||
        aria-expanded="false" class="icon-action icon-action-always-border bx bx-dots-vertical-rounded"
 | 
			
		||||
        style="position: relative; top: 3px;"></button>
 | 
			
		||||
 | 
			
		||||
    <div class="dropdown-menu dropdown-menu-right">
 | 
			
		||||
 | 
			
		||||
        <li data-trigger-command="openAttachment" class="dropdown-item"
 | 
			
		||||
            title="${t("attachments_actions.open_externally_title")}"><span class="bx bx-file-find"></span> ${t("attachments_actions.open_externally")}</li>
 | 
			
		||||
 | 
			
		||||
        <li data-trigger-command="openAttachmentCustom" class="dropdown-item"
 | 
			
		||||
            title="${t("attachments_actions.open_custom_title")}"><span class="bx bx-customize"></span> ${t("attachments_actions.open_custom")}</li>
 | 
			
		||||
 | 
			
		||||
        <li data-trigger-command="downloadAttachment" class="dropdown-item">
 | 
			
		||||
            <span class="bx bx-download"></span> ${t("attachments_actions.download")}</li>
 | 
			
		||||
 | 
			
		||||
        <li data-trigger-command="copyAttachmentLinkToClipboard" class="dropdown-item"><span class="bx bx-link">
 | 
			
		||||
            </span> ${t("attachments_actions.copy_link_to_clipboard")}</li>
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
        <div class="dropdown-divider"></div>
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
        <li data-trigger-command="uploadNewAttachmentRevision" class="dropdown-item"><span class="bx bx-upload">
 | 
			
		||||
            </span> ${t("attachments_actions.upload_new_revision")}</li>
 | 
			
		||||
 | 
			
		||||
        <li data-trigger-command="renameAttachment" class="dropdown-item">
 | 
			
		||||
            <span class="bx bx-rename"></span> ${t("attachments_actions.rename_attachment")}</li>
 | 
			
		||||
 | 
			
		||||
        <li data-trigger-command="deleteAttachment" class="dropdown-item">
 | 
			
		||||
            <span class="bx bx-trash destructive-action-icon"></span> ${t("attachments_actions.delete_attachment")}</li>
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
        <div class="dropdown-divider"></div>
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
        <li data-trigger-command="convertAttachmentIntoNote" class="dropdown-item"><span class="bx bx-note">
 | 
			
		||||
            </span> ${t("attachments_actions.convert_attachment_into_note")}</li>
 | 
			
		||||
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <input type="file" class="attachment-upload-new-revision-input" style="display: none">
 | 
			
		||||
</div>`;
 | 
			
		||||
 | 
			
		||||
// TODO: Deduplicate
 | 
			
		||||
interface AttachmentResponse {
 | 
			
		||||
    note: NoteRow;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default class AttachmentActionsWidget extends BasicWidget {
 | 
			
		||||
    $uploadNewRevisionInput!: JQuery<HTMLInputElement>;
 | 
			
		||||
    attachment: FAttachment;
 | 
			
		||||
    isFullDetail: boolean;
 | 
			
		||||
    dropdown!: Dropdown;
 | 
			
		||||
 | 
			
		||||
    constructor(attachment: FAttachment, isFullDetail: boolean) {
 | 
			
		||||
        super();
 | 
			
		||||
 | 
			
		||||
        this.attachment = attachment;
 | 
			
		||||
        this.isFullDetail = isFullDetail;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    get attachmentId() {
 | 
			
		||||
        return this.attachment.attachmentId;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    doRender() {
 | 
			
		||||
        this.$widget = $(TPL);
 | 
			
		||||
        this.dropdown = Dropdown.getOrCreateInstance(this.$widget.find("[data-bs-toggle='dropdown']")[0]);
 | 
			
		||||
        this.$widget.on("click", ".dropdown-item", () => this.dropdown.toggle());
 | 
			
		||||
 | 
			
		||||
        this.$uploadNewRevisionInput = this.$widget.find(".attachment-upload-new-revision-input");
 | 
			
		||||
        this.$uploadNewRevisionInput.on("change", async () => {
 | 
			
		||||
            const fileToUpload = this.$uploadNewRevisionInput[0].files?.item(0); // copy to allow reset below
 | 
			
		||||
            this.$uploadNewRevisionInput.val("");
 | 
			
		||||
            if (fileToUpload) {
 | 
			
		||||
                const result = await server.upload(`attachments/${this.attachmentId}/file`, fileToUpload);
 | 
			
		||||
                if (result.uploaded) {
 | 
			
		||||
                    toastService.showMessage(t("attachments_actions.upload_success"));
 | 
			
		||||
                } else {
 | 
			
		||||
                    toastService.showError(t("attachments_actions.upload_failed"));
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        const isElectron = utils.isElectron();
 | 
			
		||||
        if (!this.isFullDetail) {
 | 
			
		||||
            const $openAttachmentButton = this.$widget.find("[data-trigger-command='openAttachment']");
 | 
			
		||||
            $openAttachmentButton.addClass("disabled").append($('<span class="bx bx-info-circle disabled-tooltip" />').attr("title", t("attachments_actions.open_externally_detail_page")));
 | 
			
		||||
            if (isElectron) {
 | 
			
		||||
                const $openAttachmentCustomButton = this.$widget.find("[data-trigger-command='openAttachmentCustom']");
 | 
			
		||||
                $openAttachmentCustomButton.addClass("disabled").append($('<span class="bx bx-info-circle disabled-tooltip" />').attr("title", t("attachments_actions.open_externally_detail_page")));
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        if (!isElectron) {
 | 
			
		||||
            const $openAttachmentCustomButton = this.$widget.find("[data-trigger-command='openAttachmentCustom']");
 | 
			
		||||
            $openAttachmentCustomButton.addClass("disabled").append($('<span class="bx bx-info-circle disabled-tooltip" />').attr("title", t("attachments_actions.open_custom_client_only")));
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async openAttachmentCommand() {
 | 
			
		||||
        await openService.openAttachmentExternally(this.attachmentId, this.attachment.mime);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async openAttachmentCustomCommand() {
 | 
			
		||||
        await openService.openAttachmentCustom(this.attachmentId, this.attachment.mime);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async downloadAttachmentCommand() {
 | 
			
		||||
        await openService.downloadAttachment(this.attachmentId);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async uploadNewAttachmentRevisionCommand() {
 | 
			
		||||
        this.$uploadNewRevisionInput.trigger("click");
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async copyAttachmentLinkToClipboardCommand() {
 | 
			
		||||
        if (this.parent && "copyAttachmentLinkToClipboard" in this.parent) {
 | 
			
		||||
            (this.parent as AttachmentDetailWidget).copyAttachmentLinkToClipboard();
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async deleteAttachmentCommand() {
 | 
			
		||||
        if (!(await dialogService.confirm(t("attachments_actions.delete_confirm", { title: this.attachment.title })))) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        await server.remove(`attachments/${this.attachmentId}`);
 | 
			
		||||
        toastService.showMessage(t("attachments_actions.delete_success", { title: this.attachment.title }));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async convertAttachmentIntoNoteCommand() {
 | 
			
		||||
        if (!(await dialogService.confirm(t("attachments_actions.convert_confirm", { title: this.attachment.title })))) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const { note: newNote } = await server.post<AttachmentResponse>(`attachments/${this.attachmentId}/convert-to-note`);
 | 
			
		||||
        toastService.showMessage(t("attachments_actions.convert_success", { title: this.attachment.title }));
 | 
			
		||||
        await ws.waitForMaxKnownEntityChangeId();
 | 
			
		||||
        await appContext.tabManager.getActiveContext()?.setNote(newNote.noteId);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async renameAttachmentCommand() {
 | 
			
		||||
        const attachmentTitle = await dialogService.prompt({
 | 
			
		||||
            title: t("attachments_actions.rename_attachment"),
 | 
			
		||||
            message: t("attachments_actions.enter_new_name"),
 | 
			
		||||
            defaultValue: this.attachment.title
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        if (!attachmentTitle?.trim()) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        await server.put(`attachments/${this.attachmentId}/rename`, { title: attachmentTitle });
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -4,7 +4,7 @@ import Calendar from "./calendar";
 | 
			
		||||
import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks";
 | 
			
		||||
import "./index.css";
 | 
			
		||||
import { useNoteLabel, useNoteLabelBoolean, useResizeObserver, useSpacedUpdate, useTriliumEvent, useTriliumOption, useTriliumOptionInt } from "../../react/hooks";
 | 
			
		||||
import { DISPLAYABLE_LOCALE_IDS } from "@triliumnext/commons";
 | 
			
		||||
import { DISPLAYABLE_LOCALE_IDS, LOCALE_IDS } from "@triliumnext/commons";
 | 
			
		||||
import { Calendar as FullCalendar } from "@fullcalendar/core";
 | 
			
		||||
import { parseStartEndDateFromEvent, parseStartEndTimeFromEvent } from "./utils";
 | 
			
		||||
import dialog from "../../../services/dialog";
 | 
			
		||||
 
 | 
			
		||||
@@ -1,7 +1,7 @@
 | 
			
		||||
import Map from "./map";
 | 
			
		||||
import "./index.css";
 | 
			
		||||
import { ViewModeProps } from "../interface";
 | 
			
		||||
import { useNoteBlob, useNoteLabel, useNoteLabelBoolean, useNoteProperty, useNoteTreeDrag, useSpacedUpdate, useTriliumEvent } from "../../react/hooks";
 | 
			
		||||
import { useNoteBlob, useNoteLabel, useNoteLabelBoolean, useNoteProperty, useNoteTreeDrag, useSpacedUpdate, useTouchBar, useTriliumEvent } from "../../react/hooks";
 | 
			
		||||
import { DEFAULT_MAP_LAYER_NAME } from "./map_layer";
 | 
			
		||||
import { divIcon, GPXOptions, LatLng, LeafletMouseEvent } from "leaflet";
 | 
			
		||||
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "preact/hooks";
 | 
			
		||||
 
 | 
			
		||||
@@ -6,6 +6,7 @@ import NoteAutocomplete from "../react/NoteAutocomplete";
 | 
			
		||||
import { useRef, useState, useEffect } from "preact/hooks";
 | 
			
		||||
import tree from "../../services/tree";
 | 
			
		||||
import note_autocomplete, { Suggestion } from "../../services/note_autocomplete";
 | 
			
		||||
import { default as TextTypeWidget } from "../type_widgets/editable_text.js";
 | 
			
		||||
import { logError } from "../../services/ws";
 | 
			
		||||
import FormGroup from "../react/FormGroup.js";
 | 
			
		||||
import { refToJQuerySelector } from "../react/react_utils";
 | 
			
		||||
@@ -13,32 +14,29 @@ import { useTriliumEvent } from "../react/hooks";
 | 
			
		||||
 | 
			
		||||
type LinkType = "reference-link" | "external-link" | "hyper-link";
 | 
			
		||||
 | 
			
		||||
export interface AddLinkOpts {
 | 
			
		||||
    text: string;
 | 
			
		||||
    hasSelection: boolean;
 | 
			
		||||
    addLink(notePath: string, linkTitle: string | null, externalLink?: boolean): Promise<void>;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default function AddLinkDialog() {
 | 
			
		||||
    const [ opts, setOpts ] = useState<AddLinkOpts>();
 | 
			
		||||
    const [ textTypeWidget, setTextTypeWidget ] = useState<TextTypeWidget>();
 | 
			
		||||
    const initialText = useRef<string>();
 | 
			
		||||
    const [ linkTitle, setLinkTitle ] = useState("");
 | 
			
		||||
    const [ linkType, setLinkType ] = useState<LinkType>();
 | 
			
		||||
    const hasSelection = textTypeWidget?.hasSelection();
 | 
			
		||||
    const [ linkType, setLinkType ] = useState<LinkType>(hasSelection ? "hyper-link" : "reference-link");
 | 
			
		||||
    const [ suggestion, setSuggestion ] = useState<Suggestion | null>(null);
 | 
			
		||||
    const [ shown, setShown ] = useState(false);
 | 
			
		||||
    const hasSubmittedRef = useRef(false);
 | 
			
		||||
 | 
			
		||||
    useTriliumEvent("showAddLinkDialog", opts => {
 | 
			
		||||
        setOpts(opts);
 | 
			
		||||
    useTriliumEvent("showAddLinkDialog", ( { textTypeWidget, text }) => {
 | 
			
		||||
        setTextTypeWidget(textTypeWidget);
 | 
			
		||||
        initialText.current = text;
 | 
			
		||||
        setShown(true);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    useEffect(() => {
 | 
			
		||||
        if (opts?.hasSelection) {
 | 
			
		||||
        if (hasSelection) {
 | 
			
		||||
            setLinkType("hyper-link");
 | 
			
		||||
        } else {
 | 
			
		||||
            setLinkType("reference-link");
 | 
			
		||||
        }
 | 
			
		||||
    }, [ opts ])
 | 
			
		||||
    }, [ hasSelection ])
 | 
			
		||||
 | 
			
		||||
    async function setDefaultLinkTitle(noteId: string) {
 | 
			
		||||
        const noteTitle = await tree.getNoteTitle(noteId);
 | 
			
		||||
@@ -73,10 +71,10 @@ export default function AddLinkDialog() {
 | 
			
		||||
 | 
			
		||||
    function onShown() {
 | 
			
		||||
        const $autocompleteEl = refToJQuerySelector(autocompleteRef);
 | 
			
		||||
        if (!opts?.text) {
 | 
			
		||||
        if (!initialText.current) {
 | 
			
		||||
            note_autocomplete.showRecentNotes($autocompleteEl);
 | 
			
		||||
        } else {
 | 
			
		||||
            note_autocomplete.setText($autocompleteEl, opts.text);
 | 
			
		||||
            note_autocomplete.setText($autocompleteEl, initialText.current);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // to be able to quickly remove entered text
 | 
			
		||||
@@ -110,15 +108,15 @@ export default function AddLinkDialog() {
 | 
			
		||||
            onShown={onShown}
 | 
			
		||||
            onHidden={() => {
 | 
			
		||||
                // Insert the link.
 | 
			
		||||
                if (hasSubmittedRef.current && suggestion && opts) {
 | 
			
		||||
                if (hasSubmittedRef.current && suggestion && textTypeWidget) {
 | 
			
		||||
                    hasSubmittedRef.current = false;
 | 
			
		||||
 | 
			
		||||
                    if (suggestion.notePath) {
 | 
			
		||||
                        // Handle note link
 | 
			
		||||
                        opts.addLink(suggestion.notePath, linkType === "reference-link" ? null : linkTitle);
 | 
			
		||||
                        textTypeWidget.addLink(suggestion.notePath, linkType === "reference-link" ? null : linkTitle);
 | 
			
		||||
                    } else if (suggestion.externalLink) {
 | 
			
		||||
                        // Handle external link
 | 
			
		||||
                        opts.addLink(suggestion.externalLink, linkTitle, true);
 | 
			
		||||
                        textTypeWidget.addLink(suggestion.externalLink, linkTitle, true);
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
@@ -138,7 +136,7 @@ export default function AddLinkDialog() {
 | 
			
		||||
                />
 | 
			
		||||
            </FormGroup>
 | 
			
		||||
 | 
			
		||||
            {!opts?.hasSelection && (
 | 
			
		||||
            {!hasSelection && (
 | 
			
		||||
                <div className="add-link-title-settings">
 | 
			
		||||
                    {(linkType !== "external-link") && (
 | 
			
		||||
                        <>
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										13
									
								
								apps/client/src/widgets/dialogs/branch_prefix.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								apps/client/src/widgets/dialogs/branch_prefix.css
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,13 @@
 | 
			
		||||
.branch-prefix-dialog .branch-prefix-notes-list {
 | 
			
		||||
    margin-top: 10px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.branch-prefix-dialog .branch-prefix-notes-list ul {
 | 
			
		||||
    max-height: 200px;
 | 
			
		||||
    overflow: auto;
 | 
			
		||||
    margin-top: 5px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.branch-prefix-dialog .branch-prefix-current {
 | 
			
		||||
    opacity: 0.6;
 | 
			
		||||
}
 | 
			
		||||
@@ -10,53 +10,86 @@ import Button from "../react/Button.jsx";
 | 
			
		||||
import FormGroup from "../react/FormGroup.js";
 | 
			
		||||
import { useTriliumEvent } from "../react/hooks.jsx";
 | 
			
		||||
import FBranch from "../../entities/fbranch.js";
 | 
			
		||||
import type { ContextMenuCommandData } from "../../components/app_context.js";
 | 
			
		||||
import "./branch_prefix.css";
 | 
			
		||||
 | 
			
		||||
// Virtual branches (e.g., from search results) start with this prefix
 | 
			
		||||
const VIRTUAL_BRANCH_PREFIX = "virt-";
 | 
			
		||||
 | 
			
		||||
export default function BranchPrefixDialog() {
 | 
			
		||||
    const [ shown, setShown ] = useState(false);
 | 
			
		||||
    const [ branch, setBranch ] = useState<FBranch>();
 | 
			
		||||
    const [ branches, setBranches ] = useState<FBranch[]>([]);
 | 
			
		||||
    const [ prefix, setPrefix ] = useState("");
 | 
			
		||||
    const branchInput = useRef<HTMLInputElement>(null);
 | 
			
		||||
 | 
			
		||||
    useTriliumEvent("editBranchPrefix", async () => {
 | 
			
		||||
        const notePath = appContext.tabManager.getActiveContextNotePath();
 | 
			
		||||
        if (!notePath) {
 | 
			
		||||
    useTriliumEvent("editBranchPrefix", async (data?: ContextMenuCommandData) => {
 | 
			
		||||
        let branchIds: string[] = [];
 | 
			
		||||
 | 
			
		||||
        if (data?.selectedOrActiveBranchIds && data.selectedOrActiveBranchIds.length > 0) {
 | 
			
		||||
            // Multi-select mode from tree context menu
 | 
			
		||||
            branchIds = data.selectedOrActiveBranchIds.filter((branchId) => !branchId.startsWith(VIRTUAL_BRANCH_PREFIX));
 | 
			
		||||
        } else {
 | 
			
		||||
            // Single branch mode from keyboard shortcut or when no selection
 | 
			
		||||
            const notePath = appContext.tabManager.getActiveContextNotePath();
 | 
			
		||||
            if (!notePath) {
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            const { noteId, parentNoteId } = tree.getNoteIdAndParentIdFromUrl(notePath);
 | 
			
		||||
 | 
			
		||||
            if (!noteId || !parentNoteId) {
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            const branchId = await froca.getBranchId(parentNoteId, noteId);
 | 
			
		||||
            if (!branchId) {
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
            const parentNote = await froca.getNote(parentNoteId);
 | 
			
		||||
            if (!parentNote || parentNote.type === "search") {
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            branchIds = [branchId];
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (branchIds.length === 0) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const { noteId, parentNoteId } = tree.getNoteIdAndParentIdFromUrl(notePath);
 | 
			
		||||
        const newBranches = branchIds
 | 
			
		||||
            .map(id => froca.getBranch(id))
 | 
			
		||||
            .filter((branch): branch is FBranch => branch !== null);
 | 
			
		||||
 | 
			
		||||
        if (!noteId || !parentNoteId) {
 | 
			
		||||
        if (newBranches.length === 0) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const newBranchId = await froca.getBranchId(parentNoteId, noteId);
 | 
			
		||||
        if (!newBranchId) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
        const parentNote = await froca.getNote(parentNoteId);
 | 
			
		||||
        if (!parentNote || parentNote.type === "search") {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const newBranch = froca.getBranch(newBranchId);
 | 
			
		||||
        setBranch(newBranch);
 | 
			
		||||
        setPrefix(newBranch?.prefix ?? "");
 | 
			
		||||
        setBranches(newBranches);
 | 
			
		||||
        // Use the prefix of the first branch as the initial value
 | 
			
		||||
        setPrefix(newBranches[0]?.prefix ?? "");
 | 
			
		||||
        setShown(true);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    async function onSubmit() {
 | 
			
		||||
        if (!branch) {
 | 
			
		||||
        if (branches.length === 0) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        savePrefix(branch.branchId, prefix);
 | 
			
		||||
        if (branches.length === 1) {
 | 
			
		||||
            await savePrefix(branches[0].branchId, prefix);
 | 
			
		||||
        } else {
 | 
			
		||||
            await savePrefixBatch(branches.map(b => b.branchId), prefix);
 | 
			
		||||
        }
 | 
			
		||||
        setShown(false);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const isSingleBranch = branches.length === 1;
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
        <Modal
 | 
			
		||||
            className="branch-prefix-dialog"
 | 
			
		||||
            title={t("branch_prefix.edit_branch_prefix")}
 | 
			
		||||
            title={isSingleBranch ? t("branch_prefix.edit_branch_prefix") : t("branch_prefix.edit_branch_prefix_multiple", { count: branches.length })}
 | 
			
		||||
            size="lg"
 | 
			
		||||
            onShown={() => branchInput.current?.focus()}
 | 
			
		||||
            onHidden={() => setShown(false)}
 | 
			
		||||
@@ -69,9 +102,27 @@ export default function BranchPrefixDialog() {
 | 
			
		||||
                <div class="input-group">
 | 
			
		||||
                    <input class="branch-prefix-input form-control" value={prefix} ref={branchInput}
 | 
			
		||||
                        onChange={(e) => setPrefix((e.target as HTMLInputElement).value)} />
 | 
			
		||||
                    <div class="branch-prefix-note-title input-group-text"> - {branch && branch.getNoteFromCache().title}</div>
 | 
			
		||||
                    {isSingleBranch && branches[0] && (
 | 
			
		||||
                        <div class="branch-prefix-note-title input-group-text"> - {branches[0].getNoteFromCache().title}</div>
 | 
			
		||||
                    )}
 | 
			
		||||
                </div>
 | 
			
		||||
            </FormGroup>
 | 
			
		||||
            {!isSingleBranch && (
 | 
			
		||||
                <div className="branch-prefix-notes-list">
 | 
			
		||||
                    <strong>{t("branch_prefix.affected_branches", { count: branches.length })}</strong>
 | 
			
		||||
                    <ul>
 | 
			
		||||
                        {branches.map((branch) => {
 | 
			
		||||
                            const note = branch.getNoteFromCache();
 | 
			
		||||
                            return (
 | 
			
		||||
                                <li key={branch.branchId}>
 | 
			
		||||
                                    {branch.prefix && <span className="branch-prefix-current">{branch.prefix} - </span>}
 | 
			
		||||
                                    {note.title}
 | 
			
		||||
                                </li>
 | 
			
		||||
                            );
 | 
			
		||||
                        })}
 | 
			
		||||
                    </ul>
 | 
			
		||||
                </div>
 | 
			
		||||
            )}
 | 
			
		||||
        </Modal>
 | 
			
		||||
    );
 | 
			
		||||
}
 | 
			
		||||
@@ -80,3 +131,8 @@ async function savePrefix(branchId: string, prefix: string) {
 | 
			
		||||
    await server.put(`branches/${branchId}/set-prefix`, { prefix: prefix });
 | 
			
		||||
    toast.showMessage(t("branch_prefix.branch_prefix_saved"));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function savePrefixBatch(branchIds: string[], prefix: string) {
 | 
			
		||||
    await server.put("branches/set-prefix-batch", { branchIds, prefix });
 | 
			
		||||
    toast.showMessage(t("branch_prefix.branch_prefix_saved_multiple", { count: branchIds.length }));
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -79,6 +79,7 @@ export default function ExportDialog() {
 | 
			
		||||
                        values={[
 | 
			
		||||
                            { value: "html", label: t("export.format_html_zip") },
 | 
			
		||||
                            { value: "markdown", label: t("export.format_markdown") },
 | 
			
		||||
                            { value: "share", label: t("export.share-format") },
 | 
			
		||||
                            { value: "opml", label: t("export.format_opml") }
 | 
			
		||||
                        ]}
 | 
			
		||||
                    />
 | 
			
		||||
 
 | 
			
		||||
@@ -8,21 +8,17 @@ import Button from "../react/Button";
 | 
			
		||||
import { Suggestion, triggerRecentNotes } from "../../services/note_autocomplete";
 | 
			
		||||
import tree from "../../services/tree";
 | 
			
		||||
import froca from "../../services/froca";
 | 
			
		||||
import EditableTextTypeWidget, { type BoxSize } from "../type_widgets/editable_text";
 | 
			
		||||
import { useTriliumEvent } from "../react/hooks";
 | 
			
		||||
import { type BoxSize, CKEditorApi } from "../type_widgets/text/CKEditorWithWatchdog";
 | 
			
		||||
 | 
			
		||||
export interface IncludeNoteOpts {
 | 
			
		||||
    editorApi: CKEditorApi;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default function IncludeNoteDialog() {
 | 
			
		||||
    const editorApiRef = useRef<CKEditorApi>(null);
 | 
			
		||||
    const [textTypeWidget, setTextTypeWidget] = useState<EditableTextTypeWidget>();
 | 
			
		||||
    const [suggestion, setSuggestion] = useState<Suggestion | null>(null);
 | 
			
		||||
    const [boxSize, setBoxSize] = useState<string>("medium");
 | 
			
		||||
    const [boxSize, setBoxSize] = useState("medium");
 | 
			
		||||
    const [shown, setShown] = useState(false);
 | 
			
		||||
 | 
			
		||||
    useTriliumEvent("showIncludeNoteDialog", ({ editorApi }) => {
 | 
			
		||||
        editorApiRef.current = editorApi;
 | 
			
		||||
    useTriliumEvent("showIncludeNoteDialog", ({ textTypeWidget }) => {
 | 
			
		||||
        setTextTypeWidget(textTypeWidget);
 | 
			
		||||
        setShown(true);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
@@ -36,9 +32,12 @@ export default function IncludeNoteDialog() {
 | 
			
		||||
            onShown={() => triggerRecentNotes(autoCompleteRef.current)}
 | 
			
		||||
            onHidden={() => setShown(false)}
 | 
			
		||||
            onSubmit={() => {
 | 
			
		||||
                if (!suggestion?.notePath || !editorApiRef.current) return;
 | 
			
		||||
                if (!suggestion?.notePath || !textTypeWidget) {
 | 
			
		||||
                    return;
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                setShown(false);
 | 
			
		||||
                includeNote(suggestion.notePath, editorApiRef.current, boxSize as BoxSize);
 | 
			
		||||
                includeNote(suggestion.notePath, textTypeWidget, boxSize as BoxSize);
 | 
			
		||||
            }}
 | 
			
		||||
            footer={<Button text={t("include_note.button_include")} keyboardShortcut="Enter" />}
 | 
			
		||||
            show={shown}
 | 
			
		||||
@@ -70,7 +69,7 @@ export default function IncludeNoteDialog() {
 | 
			
		||||
    )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function includeNote(notePath: string, editorApi: CKEditorApi, boxSize: BoxSize) {
 | 
			
		||||
async function includeNote(notePath: string, textTypeWidget: EditableTextTypeWidget, boxSize: BoxSize) {
 | 
			
		||||
    const noteId = tree.getNoteIdFromUrl(notePath);
 | 
			
		||||
    if (!noteId) {
 | 
			
		||||
        return;
 | 
			
		||||
@@ -80,8 +79,8 @@ async function includeNote(notePath: string, editorApi: CKEditorApi, boxSize: Bo
 | 
			
		||||
    if (["image", "canvas", "mermaid"].includes(note?.type ?? "")) {
 | 
			
		||||
        // there's no benefit to use insert note functionlity for images,
 | 
			
		||||
        // so we'll just add an IMG tag
 | 
			
		||||
        editorApi.addImage(noteId);
 | 
			
		||||
        textTypeWidget.addImage(noteId);
 | 
			
		||||
    } else {
 | 
			
		||||
        editorApi.addIncludeNote(noteId, boxSize);
 | 
			
		||||
        textTypeWidget.addIncludeNote(noteId, boxSize);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
}
 | 
			
		||||
@@ -7,6 +7,7 @@ import utils from "../../services/utils";
 | 
			
		||||
import Modal from "../react/Modal";
 | 
			
		||||
import Button from "../react/Button";
 | 
			
		||||
import { useTriliumEvent } from "../react/hooks";
 | 
			
		||||
import EditableTextTypeWidget from "../type_widgets/editable_text";
 | 
			
		||||
 | 
			
		||||
interface RenderMarkdownResponse {
 | 
			
		||||
    htmlContent: string;
 | 
			
		||||
@@ -14,39 +15,34 @@ interface RenderMarkdownResponse {
 | 
			
		||||
 | 
			
		||||
export default function MarkdownImportDialog() {
 | 
			
		||||
    const markdownImportTextArea = useRef<HTMLTextAreaElement>(null);
 | 
			
		||||
    const [textTypeWidget, setTextTypeWidget] = useState<EditableTextTypeWidget>();
 | 
			
		||||
    const [ text, setText ] = useState("");
 | 
			
		||||
    const [ shown, setShown ] = useState(false);
 | 
			
		||||
 | 
			
		||||
    const triggerImport = useCallback(() => {
 | 
			
		||||
        if (appContext.tabManager.getActiveContextNoteType() !== "text") {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
    
 | 
			
		||||
    useTriliumEvent("showPasteMarkdownDialog", ({ textTypeWidget }) => {
 | 
			
		||||
        setTextTypeWidget(textTypeWidget);
 | 
			
		||||
        if (utils.isElectron()) {
 | 
			
		||||
            const { clipboard } = utils.dynamicRequire("electron");
 | 
			
		||||
            const text = clipboard.readText();
 | 
			
		||||
    
 | 
			
		||||
            convertMarkdownToHtml(text);
 | 
			
		||||
            convertMarkdownToHtml(text, textTypeWidget);
 | 
			
		||||
        } else {
 | 
			
		||||
            setShown(true);
 | 
			
		||||
        }
 | 
			
		||||
    }, []);
 | 
			
		||||
 | 
			
		||||
    useTriliumEvent("importMarkdownInline", triggerImport);
 | 
			
		||||
    useTriliumEvent("pasteMarkdownIntoText", triggerImport);
 | 
			
		||||
 | 
			
		||||
    async function sendForm() {
 | 
			
		||||
        await convertMarkdownToHtml(text);
 | 
			
		||||
        setText("");
 | 
			
		||||
        setShown(false);
 | 
			
		||||
    }
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
        <Modal
 | 
			
		||||
            className="markdown-import-dialog" title={t("markdown_import.dialog_title")} size="lg"
 | 
			
		||||
            footer={<Button className="markdown-import-button" text={t("markdown_import.import_button")} onClick={sendForm} keyboardShortcut="Ctrl+Space" />}
 | 
			
		||||
            footer={<Button className="markdown-import-button" text={t("markdown_import.import_button")} onClick={() => setShown(false)} keyboardShortcut="Ctrl+Enter" />}
 | 
			
		||||
            onShown={() => markdownImportTextArea.current?.focus()}
 | 
			
		||||
            onHidden={() => setShown(false) }
 | 
			
		||||
            onHidden={async () => {
 | 
			
		||||
                if (textTypeWidget) {
 | 
			
		||||
                    await convertMarkdownToHtml(text, textTypeWidget);
 | 
			
		||||
                }
 | 
			
		||||
                setShown(false);
 | 
			
		||||
                setText("");
 | 
			
		||||
            }}
 | 
			
		||||
            show={shown}
 | 
			
		||||
        >
 | 
			
		||||
            <p>{t("markdown_import.modal_body_text")}</p>
 | 
			
		||||
@@ -56,26 +52,17 @@ export default function MarkdownImportDialog() {
 | 
			
		||||
                onKeyDown={(e) => {
 | 
			
		||||
                    if (e.key === "Enter" && e.ctrlKey) {
 | 
			
		||||
                        e.preventDefault();
 | 
			
		||||
                        sendForm();
 | 
			
		||||
                        setShown(false);
 | 
			
		||||
                    }
 | 
			
		||||
                }}></textarea>
 | 
			
		||||
        </Modal>
 | 
			
		||||
    )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function convertMarkdownToHtml(markdownContent: string) {
 | 
			
		||||
async function convertMarkdownToHtml(markdownContent: string, textTypeWidget: EditableTextTypeWidget) {
 | 
			
		||||
    const { htmlContent } = await server.post<RenderMarkdownResponse>("other/render-markdown", { markdownContent });
 | 
			
		||||
 | 
			
		||||
    const textEditor = await appContext.tabManager.getActiveContext()?.getTextEditor();
 | 
			
		||||
    if (!textEditor) {
 | 
			
		||||
        return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const viewFragment = textEditor.data.processor.toView(htmlContent);
 | 
			
		||||
    const modelFragment = textEditor.data.toModel(viewFragment);
 | 
			
		||||
 | 
			
		||||
    textEditor.model.insertContent(modelFragment, textEditor.model.document.selection);
 | 
			
		||||
    textEditor.editing.view.focus();
 | 
			
		||||
 | 
			
		||||
    await textTypeWidget.addHtmlToEditor(htmlContent);
 | 
			
		||||
    
 | 
			
		||||
    toast.showMessage(t("markdown_import.import_success"));
 | 
			
		||||
}
 | 
			
		||||
@@ -1,8 +1,9 @@
 | 
			
		||||
import type { EventNames, EventData } from "../../components/app_context.js";
 | 
			
		||||
import NoteContext from "../../components/note_context.js";
 | 
			
		||||
import { openDialog } from "../../services/dialog.js";
 | 
			
		||||
import BasicWidget, { ReactWrappedWidget } from "../basic_widget.js";
 | 
			
		||||
import BasicWidget from "../basic_widget.js";
 | 
			
		||||
import Container from "../containers/container.js";
 | 
			
		||||
import TypeWidget from "../type_widgets/type_widget.js";
 | 
			
		||||
 | 
			
		||||
const TPL = /*html*/`\
 | 
			
		||||
<div class="popup-editor-dialog modal fade mx-auto" tabindex="-1" role="dialog">
 | 
			
		||||
@@ -129,7 +130,7 @@ export default class PopupEditorDialog extends Container<BasicWidget> {
 | 
			
		||||
        $dialog.on("hidden.bs.modal", () => {
 | 
			
		||||
            const $typeWidgetEl = $dialog.find(".note-detail-printable");
 | 
			
		||||
            if ($typeWidgetEl.length) {
 | 
			
		||||
                const typeWidget = glob.getComponentByEl($typeWidgetEl[0]) as ReactWrappedWidget;
 | 
			
		||||
                const typeWidget = glob.getComponentByEl($typeWidgetEl[0]) as TypeWidget;
 | 
			
		||||
                typeWidget.cleanup();
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
@@ -154,6 +155,11 @@ export default class PopupEditorDialog extends Container<BasicWidget> {
 | 
			
		||||
            return Promise.resolve();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Avoid not showing recent notes when creating a new empty tab.
 | 
			
		||||
        if ("noteContext" in data && data.noteContext.ntxId !== "_popup-editor") {
 | 
			
		||||
            return Promise.resolve();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return super.handleEventInChildren(name, data);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -140,11 +140,10 @@ function RevisionsList({ revisions, onSelect, currentRevision }: { revisions: Re
 | 
			
		||||
        <FormList onSelect={onSelect} fullHeight>
 | 
			
		||||
            {revisions.map((item) =>
 | 
			
		||||
                <FormListItem
 | 
			
		||||
                    title={t("revisions.revision_last_edited", { date: item.dateLastEdited })}
 | 
			
		||||
                    value={item.revisionId}
 | 
			
		||||
                    active={currentRevision && item.revisionId === currentRevision.revisionId}
 | 
			
		||||
                >
 | 
			
		||||
                    {item.dateLastEdited && item.dateLastEdited.substr(0, 16)} ({item.contentLength && utils.formatSize(item.contentLength)})
 | 
			
		||||
                    {item.dateCreated && item.dateCreated.substr(0, 16)} ({item.contentLength && utils.formatSize(item.contentLength)})
 | 
			
		||||
                </FormListItem>
 | 
			
		||||
            )}
 | 
			
		||||
        </FormList>);
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										462
									
								
								apps/client/src/widgets/note_detail.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										462
									
								
								apps/client/src/widgets/note_detail.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,462 @@
 | 
			
		||||
import { t } from "../services/i18n.js";
 | 
			
		||||
import NoteContextAwareWidget from "./note_context_aware_widget.js";
 | 
			
		||||
import protectedSessionHolder from "../services/protected_session_holder.js";
 | 
			
		||||
import SpacedUpdate from "../services/spaced_update.js";
 | 
			
		||||
import server from "../services/server.js";
 | 
			
		||||
import appContext, { type CommandListenerData, type EventData } from "../components/app_context.js";
 | 
			
		||||
import keyboardActionsService from "../services/keyboard_actions.js";
 | 
			
		||||
import noteCreateService from "../services/note_create.js";
 | 
			
		||||
import attributeService from "../services/attributes.js";
 | 
			
		||||
 | 
			
		||||
import EmptyTypeWidget from "./type_widgets/empty.js";
 | 
			
		||||
import EditableTextTypeWidget from "./type_widgets/editable_text.js";
 | 
			
		||||
import EditableCodeTypeWidget from "./type_widgets/editable_code.js";
 | 
			
		||||
import FileTypeWidget from "./type_widgets/file.js";
 | 
			
		||||
import ImageTypeWidget from "./type_widgets/image.js";
 | 
			
		||||
import RenderTypeWidget from "./type_widgets/render.js";
 | 
			
		||||
import RelationMapTypeWidget from "./type_widgets/relation_map.js";
 | 
			
		||||
import CanvasTypeWidget from "./type_widgets/canvas.js";
 | 
			
		||||
import ProtectedSessionTypeWidget from "./type_widgets/protected_session.js";
 | 
			
		||||
import BookTypeWidget from "./type_widgets/book.js";
 | 
			
		||||
import ReadOnlyTextTypeWidget from "./type_widgets/read_only_text.js";
 | 
			
		||||
import ReadOnlyCodeTypeWidget from "./type_widgets/read_only_code.js";
 | 
			
		||||
import NoneTypeWidget from "./type_widgets/none.js";
 | 
			
		||||
import NoteMapTypeWidget from "./type_widgets/note_map.js";
 | 
			
		||||
import WebViewTypeWidget from "./type_widgets/web_view.js";
 | 
			
		||||
import DocTypeWidget from "./type_widgets/doc.js";
 | 
			
		||||
import ContentWidgetTypeWidget from "./type_widgets/content_widget.js";
 | 
			
		||||
import AttachmentListTypeWidget from "./type_widgets/attachment_list.js";
 | 
			
		||||
import AttachmentDetailTypeWidget from "./type_widgets/attachment_detail.js";
 | 
			
		||||
import MindMapWidget from "./type_widgets/mind_map.js";
 | 
			
		||||
import utils, { isElectron } from "../services/utils.js";
 | 
			
		||||
import type { NoteType } from "../entities/fnote.js";
 | 
			
		||||
import type TypeWidget from "./type_widgets/type_widget.js";
 | 
			
		||||
import { MermaidTypeWidget } from "./type_widgets/mermaid.js";
 | 
			
		||||
import AiChatTypeWidget from "./type_widgets/ai_chat.js";
 | 
			
		||||
import toast from "../services/toast.js";
 | 
			
		||||
 | 
			
		||||
const TPL = /*html*/`
 | 
			
		||||
<div class="note-detail">
 | 
			
		||||
    <style>
 | 
			
		||||
    .note-detail {
 | 
			
		||||
        font-family: var(--detail-font-family);
 | 
			
		||||
        font-size: var(--detail-font-size);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    .note-detail.full-height {
 | 
			
		||||
        height: 100%;
 | 
			
		||||
    }
 | 
			
		||||
    </style>
 | 
			
		||||
</div>
 | 
			
		||||
`;
 | 
			
		||||
 | 
			
		||||
const typeWidgetClasses = {
 | 
			
		||||
    empty: EmptyTypeWidget,
 | 
			
		||||
    editableText: EditableTextTypeWidget,
 | 
			
		||||
    readOnlyText: ReadOnlyTextTypeWidget,
 | 
			
		||||
    editableCode: EditableCodeTypeWidget,
 | 
			
		||||
    readOnlyCode: ReadOnlyCodeTypeWidget,
 | 
			
		||||
    file: FileTypeWidget,
 | 
			
		||||
    image: ImageTypeWidget,
 | 
			
		||||
    search: NoneTypeWidget,
 | 
			
		||||
    render: RenderTypeWidget,
 | 
			
		||||
    relationMap: RelationMapTypeWidget,
 | 
			
		||||
    canvas: CanvasTypeWidget,
 | 
			
		||||
    protectedSession: ProtectedSessionTypeWidget,
 | 
			
		||||
    book: BookTypeWidget,
 | 
			
		||||
    noteMap: NoteMapTypeWidget,
 | 
			
		||||
    webView: WebViewTypeWidget,
 | 
			
		||||
    doc: DocTypeWidget,
 | 
			
		||||
    contentWidget: ContentWidgetTypeWidget,
 | 
			
		||||
    attachmentDetail: AttachmentDetailTypeWidget,
 | 
			
		||||
    attachmentList: AttachmentListTypeWidget,
 | 
			
		||||
    mindMap: MindMapWidget,
 | 
			
		||||
    aiChat: AiChatTypeWidget,
 | 
			
		||||
 | 
			
		||||
    // Split type editors
 | 
			
		||||
    mermaid: MermaidTypeWidget
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * A `NoteType` altered by the note detail widget, taking into consideration whether the note is editable or not and adding special note types such as an empty one,
 | 
			
		||||
 * for protected session or attachment information.
 | 
			
		||||
 */
 | 
			
		||||
type ExtendedNoteType =
 | 
			
		||||
    | Exclude<NoteType, "launcher" | "text" | "code">
 | 
			
		||||
    | "empty"
 | 
			
		||||
    | "readOnlyCode"
 | 
			
		||||
    | "readOnlyText"
 | 
			
		||||
    | "editableText"
 | 
			
		||||
    | "editableCode"
 | 
			
		||||
    | "attachmentDetail"
 | 
			
		||||
    | "attachmentList"
 | 
			
		||||
    | "protectedSession"
 | 
			
		||||
    | "aiChat";
 | 
			
		||||
 | 
			
		||||
export default class NoteDetailWidget extends NoteContextAwareWidget {
 | 
			
		||||
 | 
			
		||||
    private typeWidgets: Record<string, TypeWidget>;
 | 
			
		||||
    private spacedUpdate: SpacedUpdate;
 | 
			
		||||
    private type?: ExtendedNoteType;
 | 
			
		||||
    private mime?: string;
 | 
			
		||||
 | 
			
		||||
    constructor() {
 | 
			
		||||
        super();
 | 
			
		||||
 | 
			
		||||
        this.typeWidgets = {};
 | 
			
		||||
 | 
			
		||||
        this.spacedUpdate = new SpacedUpdate(async () => {
 | 
			
		||||
            if (!this.noteContext) {
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            const { note } = this.noteContext;
 | 
			
		||||
            if (!note) {
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            const { noteId } = note;
 | 
			
		||||
 | 
			
		||||
            const data = await this.getTypeWidget().getData();
 | 
			
		||||
 | 
			
		||||
            // for read only notes
 | 
			
		||||
            if (data === undefined) {
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            protectedSessionHolder.touchProtectedSessionIfNecessary(note);
 | 
			
		||||
 | 
			
		||||
            await server.put(`notes/${noteId}/data`, data, this.componentId);
 | 
			
		||||
 | 
			
		||||
            this.getTypeWidget().dataSaved();
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        appContext.addBeforeUnloadListener(this);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    isEnabled() {
 | 
			
		||||
        return true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    doRender() {
 | 
			
		||||
        this.$widget = $(TPL);
 | 
			
		||||
        this.contentSized();
 | 
			
		||||
 | 
			
		||||
        if (utils.isElectron()) {
 | 
			
		||||
            const { ipcRenderer } = utils.dynamicRequire("electron");
 | 
			
		||||
            ipcRenderer.on("print-done", () => {
 | 
			
		||||
                toast.closePersistent("printing");
 | 
			
		||||
            });
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async refresh() {
 | 
			
		||||
        this.type = await this.getWidgetType();
 | 
			
		||||
        this.mime = this.note?.mime;
 | 
			
		||||
 | 
			
		||||
        if (!(this.type in this.typeWidgets)) {
 | 
			
		||||
            const clazz = typeWidgetClasses[this.type];
 | 
			
		||||
 | 
			
		||||
            if (!clazz) {
 | 
			
		||||
                throw new Error(`Cannot find type widget for type '${this.type}'`);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            const typeWidget = (this.typeWidgets[this.type] = new clazz());
 | 
			
		||||
            typeWidget.spacedUpdate = this.spacedUpdate;
 | 
			
		||||
            typeWidget.setParent(this);
 | 
			
		||||
 | 
			
		||||
            if (this.noteContext) {
 | 
			
		||||
                typeWidget.setNoteContextEvent({ noteContext: this.noteContext });
 | 
			
		||||
            }
 | 
			
		||||
            const $renderedWidget = typeWidget.render();
 | 
			
		||||
            keyboardActionsService.updateDisplayedShortcuts($renderedWidget);
 | 
			
		||||
 | 
			
		||||
            this.$widget.append($renderedWidget);
 | 
			
		||||
 | 
			
		||||
            if (this.noteContext) {
 | 
			
		||||
                await typeWidget.handleEvent("setNoteContext", { noteContext: this.noteContext });
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // this is happening in update(), so note has been already set, and we need to reflect this
 | 
			
		||||
            if (this.noteContext) {
 | 
			
		||||
                await typeWidget.handleEvent("noteSwitched", {
 | 
			
		||||
                    noteContext: this.noteContext,
 | 
			
		||||
                    notePath: this.noteContext.notePath
 | 
			
		||||
                });
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            this.child(typeWidget);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        this.checkFullHeight();
 | 
			
		||||
 | 
			
		||||
        if (utils.isMobile()) {
 | 
			
		||||
            const hasFixedTree = this.noteContext?.hoistedNoteId === "_lbMobileRoot";
 | 
			
		||||
            $("body").toggleClass("force-fixed-tree", hasFixedTree);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * sets full height of container that contains note content for a subset of note-types
 | 
			
		||||
     */
 | 
			
		||||
    checkFullHeight() {
 | 
			
		||||
        // https://github.com/zadam/trilium/issues/2522
 | 
			
		||||
        const isBackendNote = this.noteContext?.noteId === "_backendLog";
 | 
			
		||||
        const isSqlNote = this.mime === "text/x-sqlite;schema=trilium";
 | 
			
		||||
        const isFullHeightNoteType = ["canvas", "webView", "noteMap", "mindMap", "mermaid", "file", "aiChat"].includes(this.type ?? "");
 | 
			
		||||
        const isFullHeight = (!this.noteContext?.hasNoteList() && isFullHeightNoteType && !isSqlNote)
 | 
			
		||||
            || this.noteContext?.viewScope?.viewMode === "attachments"
 | 
			
		||||
            || isBackendNote;
 | 
			
		||||
 | 
			
		||||
        this.$widget.toggleClass("full-height", isFullHeight);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    getTypeWidget() {
 | 
			
		||||
        if (!this.type || !this.typeWidgets[this.type]) {
 | 
			
		||||
            throw new Error(t(`note_detail.could_not_find_typewidget`, { type: this.type }));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return this.typeWidgets[this.type];
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async getWidgetType(): Promise<ExtendedNoteType> {
 | 
			
		||||
        const note = this.note;
 | 
			
		||||
        if (!note) {
 | 
			
		||||
            return "empty";
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const type = note.type;
 | 
			
		||||
        let resultingType: ExtendedNoteType;
 | 
			
		||||
        const viewScope = this.noteContext?.viewScope;
 | 
			
		||||
 | 
			
		||||
        if (viewScope?.viewMode === "source") {
 | 
			
		||||
            resultingType = "readOnlyCode";
 | 
			
		||||
        } else if (viewScope && viewScope.viewMode === "attachments") {
 | 
			
		||||
            resultingType = viewScope.attachmentId ? "attachmentDetail" : "attachmentList";
 | 
			
		||||
        } else if (type === "text" && (await this.noteContext?.isReadOnly())) {
 | 
			
		||||
            resultingType = "readOnlyText";
 | 
			
		||||
        } else if ((type === "code" || type === "mermaid") && (await this.noteContext?.isReadOnly())) {
 | 
			
		||||
            resultingType = "readOnlyCode";
 | 
			
		||||
        } else if (type === "text") {
 | 
			
		||||
            resultingType = "editableText";
 | 
			
		||||
        } else if (type === "code") {
 | 
			
		||||
            resultingType = "editableCode";
 | 
			
		||||
        } else if (type === "launcher") {
 | 
			
		||||
            resultingType = "doc";
 | 
			
		||||
        } else {
 | 
			
		||||
            resultingType = type;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (note.isProtected && !protectedSessionHolder.isProtectedSessionAvailable()) {
 | 
			
		||||
            resultingType = "protectedSession";
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return resultingType;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async focusOnDetailEvent({ ntxId }: EventData<"focusOnDetail">) {
 | 
			
		||||
        if (this.noteContext?.ntxId !== ntxId) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        await this.refresh();
 | 
			
		||||
        const widget = this.getTypeWidget();
 | 
			
		||||
        await widget.initialized;
 | 
			
		||||
        widget.focus();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async scrollToEndEvent({ ntxId }: EventData<"scrollToEnd">) {
 | 
			
		||||
        if (this.noteContext?.ntxId !== ntxId) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        await this.refresh();
 | 
			
		||||
        const widget = this.getTypeWidget();
 | 
			
		||||
        await widget.initialized;
 | 
			
		||||
 | 
			
		||||
        if (widget.scrollToEnd) {
 | 
			
		||||
            widget.scrollToEnd();
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async beforeNoteSwitchEvent({ noteContext }: EventData<"beforeNoteSwitch">) {
 | 
			
		||||
        if (this.isNoteContext(noteContext.ntxId)) {
 | 
			
		||||
            await this.spacedUpdate.updateNowIfNecessary();
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async beforeNoteContextRemoveEvent({ ntxIds }: EventData<"beforeNoteContextRemove">) {
 | 
			
		||||
        if (this.isNoteContext(ntxIds)) {
 | 
			
		||||
            await this.spacedUpdate.updateNowIfNecessary();
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async runActiveNoteCommand(params: CommandListenerData<"runActiveNote">) {
 | 
			
		||||
        if (this.isNoteContext(params.ntxId)) {
 | 
			
		||||
            // make sure that script is saved before running it #4028
 | 
			
		||||
            await this.spacedUpdate.updateNowIfNecessary();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return await this.parent?.triggerCommand("runActiveNote", params);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async printActiveNoteEvent() {
 | 
			
		||||
        if (!this.noteContext?.isActive()) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        toast.showPersistent({
 | 
			
		||||
            icon: "bx bx-loader-circle bx-spin",
 | 
			
		||||
            message: t("note_detail.printing"),
 | 
			
		||||
            id: "printing"
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        if (isElectron()) {
 | 
			
		||||
            const { ipcRenderer } = utils.dynamicRequire("electron");
 | 
			
		||||
            ipcRenderer.send("print-note", {
 | 
			
		||||
                notePath: this.notePath
 | 
			
		||||
            });
 | 
			
		||||
        } else {
 | 
			
		||||
            const iframe = document.createElement('iframe');
 | 
			
		||||
            iframe.src = `?print#${this.notePath}`;
 | 
			
		||||
            iframe.className = "print-iframe";
 | 
			
		||||
            document.body.appendChild(iframe);
 | 
			
		||||
            iframe.onload = () => {
 | 
			
		||||
                if (!iframe.contentWindow) {
 | 
			
		||||
                    toast.closePersistent("printing");
 | 
			
		||||
                    document.body.removeChild(iframe);
 | 
			
		||||
                    return;
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                iframe.contentWindow.addEventListener("note-ready", () => {
 | 
			
		||||
                    toast.closePersistent("printing");
 | 
			
		||||
                    iframe.contentWindow?.print();
 | 
			
		||||
                    document.body.removeChild(iframe);
 | 
			
		||||
                });
 | 
			
		||||
            };
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async exportAsPdfEvent() {
 | 
			
		||||
        if (!this.noteContext?.isActive() || !this.note || !this.notePath) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        toast.showPersistent({
 | 
			
		||||
            icon: "bx bx-loader-circle bx-spin",
 | 
			
		||||
            message: t("note_detail.printing_pdf"),
 | 
			
		||||
            id: "printing"
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        const { ipcRenderer } = utils.dynamicRequire("electron");
 | 
			
		||||
        ipcRenderer.send("export-as-pdf", {
 | 
			
		||||
            title: this.note.title,
 | 
			
		||||
            notePath: this.notePath,
 | 
			
		||||
            pageSize: this.note.getAttributeValue("label", "printPageSize") ?? "Letter",
 | 
			
		||||
            landscape: this.note.hasAttribute("label", "printLandscape")
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    hoistedNoteChangedEvent({ ntxId }: EventData<"hoistedNoteChanged">) {
 | 
			
		||||
        if (this.isNoteContext(ntxId)) {
 | 
			
		||||
            this.refresh();
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
 | 
			
		||||
        // we're detecting note type change on the note_detail level, but triggering the noteTypeMimeChanged
 | 
			
		||||
        // globally, so it gets also to e.g. ribbon components. But this means that the event can be generated multiple
 | 
			
		||||
        // times if the same note is open in several tabs.
 | 
			
		||||
 | 
			
		||||
        if (this.noteId && loadResults.isNoteContentReloaded(this.noteId, this.componentId)) {
 | 
			
		||||
            // probably incorrect event
 | 
			
		||||
            // calling this.refresh() is not enough since the event needs to be propagated to children as well
 | 
			
		||||
            // FIXME: create a separate event to force hierarchical refresh
 | 
			
		||||
 | 
			
		||||
            // this uses handleEvent to make sure that the ordinary content updates are propagated only in the subtree
 | 
			
		||||
            // to avoid the problem in #3365
 | 
			
		||||
            this.handleEvent("noteTypeMimeChanged", { noteId: this.noteId });
 | 
			
		||||
        } else if (this.noteId && loadResults.isNoteReloaded(this.noteId, this.componentId) && (this.type !== (await this.getWidgetType()) || this.mime !== this.note?.mime)) {
 | 
			
		||||
            // this needs to have a triggerEvent so that e.g., note type (not in the component subtree) is updated
 | 
			
		||||
            this.triggerEvent("noteTypeMimeChanged", { noteId: this.noteId });
 | 
			
		||||
        } else {
 | 
			
		||||
            const attrs = loadResults.getAttributeRows();
 | 
			
		||||
 | 
			
		||||
            const label = attrs.find(
 | 
			
		||||
                (attr) =>
 | 
			
		||||
                    attr.type === "label" &&
 | 
			
		||||
                    ["readOnly", "autoReadOnlyDisabled", "cssClass", "displayRelations", "hideRelations"].includes(attr.name ?? "") &&
 | 
			
		||||
                    attributeService.isAffecting(attr, this.note)
 | 
			
		||||
            );
 | 
			
		||||
 | 
			
		||||
            const relation = attrs.find((attr) => attr.type === "relation" && ["template", "inherit", "renderNote"].includes(attr.name ?? "") && attributeService.isAffecting(attr, this.note));
 | 
			
		||||
 | 
			
		||||
            if (this.noteId && (label || relation)) {
 | 
			
		||||
                // probably incorrect event
 | 
			
		||||
                // calling this.refresh() is not enough since the event needs to be propagated to children as well
 | 
			
		||||
                this.triggerEvent("noteTypeMimeChanged", { noteId: this.noteId });
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    beforeUnloadEvent() {
 | 
			
		||||
        return this.spacedUpdate.isAllSavedAndTriggerUpdate();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    readOnlyTemporarilyDisabledEvent({ noteContext }: EventData<"readOnlyTemporarilyDisabled">) {
 | 
			
		||||
        if (this.isNoteContext(noteContext.ntxId)) {
 | 
			
		||||
            this.refresh();
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async executeInActiveNoteDetailWidgetEvent({ callback }: EventData<"executeInActiveNoteDetailWidget">) {
 | 
			
		||||
        if (!this.isActiveNoteContext()) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        await this.initialized;
 | 
			
		||||
 | 
			
		||||
        callback(this);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async cutIntoNoteCommand() {
 | 
			
		||||
        const note = appContext.tabManager.getActiveContextNote();
 | 
			
		||||
 | 
			
		||||
        if (!note) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // without await as this otherwise causes deadlock through component mutex
 | 
			
		||||
        const parentNotePath = appContext.tabManager.getActiveContextNotePath();
 | 
			
		||||
        if (this.noteContext && parentNotePath) {
 | 
			
		||||
            noteCreateService.createNote(parentNotePath, {
 | 
			
		||||
                isProtected: note.isProtected,
 | 
			
		||||
                saveSelection: true,
 | 
			
		||||
                textEditor: await this.noteContext.getTextEditor()
 | 
			
		||||
            });
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // used by cutToNote in CKEditor build
 | 
			
		||||
    async saveNoteDetailNowCommand() {
 | 
			
		||||
        await this.spacedUpdate.updateNowIfNecessary();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    renderActiveNoteEvent() {
 | 
			
		||||
        if (this.noteContext?.isActive()) {
 | 
			
		||||
            this.refresh();
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async executeWithTypeWidgetEvent({ resolve, ntxId }: EventData<"executeWithTypeWidget">) {
 | 
			
		||||
        if (!this.isNoteContext(ntxId)) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        await this.initialized;
 | 
			
		||||
 | 
			
		||||
        await this.getWidgetType();
 | 
			
		||||
 | 
			
		||||
        resolve(this.getTypeWidget());
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										671
									
								
								apps/client/src/widgets/note_map.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										671
									
								
								apps/client/src/widgets/note_map.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,671 @@
 | 
			
		||||
import server from "../services/server.js";
 | 
			
		||||
import attributeService from "../services/attributes.js";
 | 
			
		||||
import hoistedNoteService from "../services/hoisted_note.js";
 | 
			
		||||
import appContext, { type EventData } from "../components/app_context.js";
 | 
			
		||||
import NoteContextAwareWidget from "./note_context_aware_widget.js";
 | 
			
		||||
import linkContextMenuService from "../menus/link_context_menu.js";
 | 
			
		||||
import utils from "../services/utils.js";
 | 
			
		||||
import { t } from "../services/i18n.js";
 | 
			
		||||
import type ForceGraph from "force-graph";
 | 
			
		||||
import type { GraphData, LinkObject, NodeObject } from "force-graph";
 | 
			
		||||
import type FNote from "../entities/fnote.js";
 | 
			
		||||
 | 
			
		||||
const esc = utils.escapeHtml;
 | 
			
		||||
 | 
			
		||||
const TPL = /*html*/`<div class="note-map-widget">
 | 
			
		||||
    <style>
 | 
			
		||||
        .note-detail-note-map {
 | 
			
		||||
            height: 100%;
 | 
			
		||||
            overflow: hidden;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        /* Style Ui Element to Drag Nodes */
 | 
			
		||||
        .fixnodes-type-switcher {
 | 
			
		||||
            display: flex;
 | 
			
		||||
            align-items: center;
 | 
			
		||||
            z-index: 10; /* should be below dropdown (note actions) */
 | 
			
		||||
            border-radius: .2rem;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .fixnodes-type-switcher button.toggled {
 | 
			
		||||
            background: var(--active-item-background-color);
 | 
			
		||||
            color: var(--active-item-text-color);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        /* Start of styling the slider */
 | 
			
		||||
        .fixnodes-type-switcher input[type="range"] {
 | 
			
		||||
 | 
			
		||||
            /* removing default appearance */
 | 
			
		||||
            -webkit-appearance: none;
 | 
			
		||||
            appearance: none;
 | 
			
		||||
            margin-inline-start: 15px;
 | 
			
		||||
            width: 150px;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        /* Changing slider tracker */
 | 
			
		||||
        .fixnodes-type-switcher input[type="range"]::-webkit-slider-runnable-track {
 | 
			
		||||
            height: 4px;
 | 
			
		||||
            background-color: var(--main-border-color);
 | 
			
		||||
            border-radius: 4px;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        /* Changing Slider Thumb */
 | 
			
		||||
        .fixnodes-type-switcher input[type="range"]::-webkit-slider-thumb {
 | 
			
		||||
            /* removing default appearance */
 | 
			
		||||
            -webkit-appearance: none;
 | 
			
		||||
            appearance: none;
 | 
			
		||||
            /* creating a custom design */
 | 
			
		||||
            height: 15px;
 | 
			
		||||
            width: 15px;
 | 
			
		||||
            margin-top:-5px;
 | 
			
		||||
            background-color: var(--accented-background-color);
 | 
			
		||||
            border: 1px solid var(--main-text-color);
 | 
			
		||||
            border-radius: 50%;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .fixnodes-type-switcher input[type="range"]::-moz-range-track {
 | 
			
		||||
            background-color: var(--main-border-color);
 | 
			
		||||
            border-radius: 4px;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .fixnodes-type-switcher input[type="range"]::-moz-range-thumb {
 | 
			
		||||
            background-color: var(--accented-background-color);
 | 
			
		||||
            border-color: var(--main-text-color);
 | 
			
		||||
            height: 10px;
 | 
			
		||||
            width: 10px;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        /* End of styling the slider */
 | 
			
		||||
 | 
			
		||||
    </style>
 | 
			
		||||
 | 
			
		||||
    <div class="btn-group btn-group-sm map-type-switcher content-floating-buttons top-left" role="group">
 | 
			
		||||
      <button type="button" class="btn bx bx-network-chart tn-tool-button" title="${t("note-map.button-link-map")}" data-type="link"></button>
 | 
			
		||||
      <button type="button" class="btn bx bx-sitemap tn-tool-button" title="${t("note-map.button-tree-map")}" data-type="tree"></button>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <! UI for dragging Notes and link force >
 | 
			
		||||
 | 
			
		||||
    <div class="btn-group-sm fixnodes-type-switcher content-floating-buttons bottom-left" role="group">
 | 
			
		||||
      <button type="button" data-toggle="button" class="btn bx bx-lock-alt tn-tool-button" title="${t("note_map.fix-nodes")}" data-type="moveable"></button>
 | 
			
		||||
      <input type="range" class="slider" min="1" title="${t("note_map.link-distance")}" max="100" value="40" >
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <div class="style-resolver"></div>
 | 
			
		||||
 | 
			
		||||
    <div class="note-map-container"></div>
 | 
			
		||||
</div>`;
 | 
			
		||||
 | 
			
		||||
type WidgetMode = "type" | "ribbon";
 | 
			
		||||
type MapType = "tree" | "link";
 | 
			
		||||
type Data = GraphData<NodeObject, LinkObject<NodeObject>>;
 | 
			
		||||
 | 
			
		||||
interface Node extends NodeObject {
 | 
			
		||||
    id: string;
 | 
			
		||||
    name: string;
 | 
			
		||||
    type: string;
 | 
			
		||||
    color: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
interface Link extends LinkObject<NodeObject> {
 | 
			
		||||
    id: string;
 | 
			
		||||
    name: string;
 | 
			
		||||
 | 
			
		||||
    x: number;
 | 
			
		||||
    y: number;
 | 
			
		||||
    source: Node;
 | 
			
		||||
    target: Node;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
interface NotesAndRelationsData {
 | 
			
		||||
    nodes: Node[];
 | 
			
		||||
    links: {
 | 
			
		||||
        id: string;
 | 
			
		||||
        source: string;
 | 
			
		||||
        target: string;
 | 
			
		||||
        name: string;
 | 
			
		||||
    }[];
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Replace
 | 
			
		||||
interface ResponseLink {
 | 
			
		||||
    key: string;
 | 
			
		||||
    sourceNoteId: string;
 | 
			
		||||
    targetNoteId: string;
 | 
			
		||||
    name: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
interface PostNotesMapResponse {
 | 
			
		||||
    notes: string[];
 | 
			
		||||
    links: ResponseLink[];
 | 
			
		||||
    noteIdToDescendantCountMap: Record<string, number>;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
interface GroupedLink {
 | 
			
		||||
    id: string;
 | 
			
		||||
    sourceNoteId: string;
 | 
			
		||||
    targetNoteId: string;
 | 
			
		||||
    names: string[];
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
interface CssData {
 | 
			
		||||
    fontFamily: string;
 | 
			
		||||
    textColor: string;
 | 
			
		||||
    mutedTextColor: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default class NoteMapWidget extends NoteContextAwareWidget {
 | 
			
		||||
 | 
			
		||||
    private fixNodes: boolean;
 | 
			
		||||
    private widgetMode: WidgetMode;
 | 
			
		||||
    private mapType?: MapType;
 | 
			
		||||
    private cssData!: CssData;
 | 
			
		||||
 | 
			
		||||
    private themeStyle!: string;
 | 
			
		||||
    private $container!: JQuery<HTMLElement>;
 | 
			
		||||
    private $styleResolver!: JQuery<HTMLElement>;
 | 
			
		||||
    private $fixNodesButton!: JQuery<HTMLElement>;
 | 
			
		||||
    graph!: ForceGraph;
 | 
			
		||||
    private noteIdToSizeMap!: Record<string, number>;
 | 
			
		||||
    private zoomLevel!: number;
 | 
			
		||||
    private nodes!: Node[];
 | 
			
		||||
 | 
			
		||||
    constructor(widgetMode: WidgetMode) {
 | 
			
		||||
        super();
 | 
			
		||||
        this.fixNodes = false; // needed to save the status of the UI element. Is set later in the code
 | 
			
		||||
        this.widgetMode = widgetMode; // 'type' or 'ribbon'
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    doRender() {
 | 
			
		||||
        this.$widget = $(TPL);
 | 
			
		||||
 | 
			
		||||
        const documentStyle = window.getComputedStyle(document.documentElement);
 | 
			
		||||
        this.themeStyle = documentStyle.getPropertyValue("--theme-style")?.trim();
 | 
			
		||||
 | 
			
		||||
        this.$container = this.$widget.find(".note-map-container");
 | 
			
		||||
        this.$styleResolver = this.$widget.find(".style-resolver");
 | 
			
		||||
        this.$fixNodesButton = this.$widget.find(".fixnodes-type-switcher > button");
 | 
			
		||||
 | 
			
		||||
        new ResizeObserver(() => this.setDimensions()).observe(this.$container[0]);
 | 
			
		||||
 | 
			
		||||
        this.$widget.find(".map-type-switcher button").on("click", async (e) => {
 | 
			
		||||
            const type = $(e.target).closest("button").attr("data-type");
 | 
			
		||||
 | 
			
		||||
            await attributeService.setLabel(this.noteId ?? "", "mapType", type);
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        // Reading the status of the Drag nodes Ui element. Changing it´s color when activated.
 | 
			
		||||
        // Reading Force value of the link distance.
 | 
			
		||||
        this.$fixNodesButton.on("click", async (event) => {
 | 
			
		||||
            this.fixNodes = !this.fixNodes;
 | 
			
		||||
            this.$fixNodesButton.toggleClass("toggled", this.fixNodes);
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        super.doRender();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    setDimensions() {
 | 
			
		||||
        if (!this.graph) {
 | 
			
		||||
            // no graph has been even rendered
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const $parent = this.$widget.parent();
 | 
			
		||||
 | 
			
		||||
        this.graph
 | 
			
		||||
            .height($parent.height() || 0)
 | 
			
		||||
            .width($parent.width() || 0);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async refreshWithNote(note: FNote) {
 | 
			
		||||
        this.$widget.show();
 | 
			
		||||
 | 
			
		||||
        this.cssData = {
 | 
			
		||||
            fontFamily: this.$container.css("font-family"),
 | 
			
		||||
            textColor: this.rgb2hex(this.$container.css("color")),
 | 
			
		||||
            mutedTextColor: this.rgb2hex(this.$styleResolver.css("color"))
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        this.mapType = note.getLabelValue("mapType") === "tree" ? "tree" : "link";
 | 
			
		||||
 | 
			
		||||
        //variables for the hover effekt. We have to save the neighbours of a hovered node in a set. Also we need to save the links as well as the hovered node itself
 | 
			
		||||
 | 
			
		||||
        let hoverNode: NodeObject | null = null;
 | 
			
		||||
        const highlightLinks = new Set();
 | 
			
		||||
        const neighbours = new Set();
 | 
			
		||||
 | 
			
		||||
        const ForceGraph = (await import("force-graph")).default;
 | 
			
		||||
        this.graph = new ForceGraph(this.$container[0])
 | 
			
		||||
            .width(this.$container.width() || 0)
 | 
			
		||||
            .height(this.$container.height() || 0)
 | 
			
		||||
            .onZoom((zoom) => this.setZoomLevel(zoom.k))
 | 
			
		||||
            .d3AlphaDecay(0.01)
 | 
			
		||||
            .d3VelocityDecay(0.08)
 | 
			
		||||
 | 
			
		||||
            //Code to fixate nodes when dragged
 | 
			
		||||
            .onNodeDragEnd((node) => {
 | 
			
		||||
                if (this.fixNodes) {
 | 
			
		||||
                    node.fx = node.x;
 | 
			
		||||
                    node.fy = node.y;
 | 
			
		||||
                } else {
 | 
			
		||||
                    node.fx = undefined;
 | 
			
		||||
                    node.fy = undefined;
 | 
			
		||||
                }
 | 
			
		||||
            })
 | 
			
		||||
            //check if hovered and set the hovernode variable, saving the hovered node object into it. Clear links variable everytime you hover. Without clearing links will stay highlighted
 | 
			
		||||
            .onNodeHover((node) => {
 | 
			
		||||
                hoverNode = node || null;
 | 
			
		||||
                highlightLinks.clear();
 | 
			
		||||
            })
 | 
			
		||||
 | 
			
		||||
            // set link width to immitate a highlight effekt. Checking the condition if any links are saved in the previous defined set highlightlinks
 | 
			
		||||
            .linkWidth((link) => (highlightLinks.has(link) ? 3 : 0.4))
 | 
			
		||||
            .linkColor((link) => (highlightLinks.has(link) ? "white" : this.cssData.mutedTextColor))
 | 
			
		||||
            .linkDirectionalArrowLength(4)
 | 
			
		||||
            .linkDirectionalArrowRelPos(0.95)
 | 
			
		||||
 | 
			
		||||
            // main code for highlighting hovered nodes and neighbours. here we "style" the nodes. the nodes are rendered several hundred times per second.
 | 
			
		||||
            .nodeCanvasObject((_node, ctx) => {
 | 
			
		||||
                const node = _node as Node;
 | 
			
		||||
                if (hoverNode == node) {
 | 
			
		||||
                    //paint only hovered node
 | 
			
		||||
                    this.paintNode(node, "#661822", ctx);
 | 
			
		||||
                    neighbours.clear(); //clearing neighbours or the effect would be maintained after hovering is over
 | 
			
		||||
                    for (const _link of data.links) {
 | 
			
		||||
                        const link = _link as unknown as Link;
 | 
			
		||||
                        //check if node is part of a link in the canvas, if so add it´s neighbours and related links to the previous defined variables to paint the nodes
 | 
			
		||||
                        if (link.source.id == node.id || link.target.id == node.id) {
 | 
			
		||||
                            neighbours.add(link.source);
 | 
			
		||||
                            neighbours.add(link.target);
 | 
			
		||||
                            highlightLinks.add(link);
 | 
			
		||||
                            neighbours.delete(node);
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
                } else if (neighbours.has(node) && hoverNode != null) {
 | 
			
		||||
                    //paint neighbours
 | 
			
		||||
                    this.paintNode(node, "#9d6363", ctx);
 | 
			
		||||
                } else {
 | 
			
		||||
                    this.paintNode(node, this.getColorForNode(node), ctx); //paint rest of nodes in canvas
 | 
			
		||||
                }
 | 
			
		||||
            })
 | 
			
		||||
 | 
			
		||||
            .nodePointerAreaPaint((node, _, ctx) => this.paintNode(node as Node, this.getColorForNode(node as Node), ctx))
 | 
			
		||||
            .nodePointerAreaPaint((node, color, ctx) => {
 | 
			
		||||
                if (!node.id) {
 | 
			
		||||
                    return;
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                ctx.fillStyle = color;
 | 
			
		||||
                ctx.beginPath();
 | 
			
		||||
                if (node.x && node.y) {
 | 
			
		||||
                    ctx.arc(node.x, node.y, this.noteIdToSizeMap[node.id], 0, 2 * Math.PI, false);
 | 
			
		||||
                }
 | 
			
		||||
                ctx.fill();
 | 
			
		||||
            })
 | 
			
		||||
            .nodeLabel((node) => esc((node as Node).name))
 | 
			
		||||
            .maxZoom(7)
 | 
			
		||||
            .warmupTicks(30)
 | 
			
		||||
            .onNodeClick((node) => {
 | 
			
		||||
                if (node.id) {
 | 
			
		||||
                    appContext.tabManager.getActiveContext()?.setNote((node as Node).id);
 | 
			
		||||
                }
 | 
			
		||||
            })
 | 
			
		||||
            .onNodeRightClick((node, e) => {
 | 
			
		||||
                if (node.id) {
 | 
			
		||||
                    linkContextMenuService.openContextMenu((node as Node).id, e);
 | 
			
		||||
                }
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
        if (this.mapType === "link") {
 | 
			
		||||
            this.graph
 | 
			
		||||
                .linkLabel((l) => `${esc((l as Link).source.name)} - <strong>${esc((l as Link).name)}</strong> - ${esc((l as Link).target.name)}`)
 | 
			
		||||
                .linkCanvasObject((link, ctx) => this.paintLink(link as Link, ctx))
 | 
			
		||||
                .linkCanvasObjectMode(() => "after");
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const mapRootNoteId = this.getMapRootNoteId();
 | 
			
		||||
 | 
			
		||||
        const labelValues = (name: string) => this.note?.getLabels(name).map(l => l.value) ?? [];
 | 
			
		||||
 | 
			
		||||
        const excludeRelations = labelValues("mapExcludeRelation");
 | 
			
		||||
        const includeRelations = labelValues("mapIncludeRelation");
 | 
			
		||||
 | 
			
		||||
        const data = await this.loadNotesAndRelations(mapRootNoteId, excludeRelations, includeRelations);
 | 
			
		||||
 | 
			
		||||
        const nodeLinkRatio = data.nodes.length / data.links.length;
 | 
			
		||||
        const magnifiedRatio = Math.pow(nodeLinkRatio, 1.5);
 | 
			
		||||
        const charge = -20 / magnifiedRatio;
 | 
			
		||||
        const boundedCharge = Math.min(-3, charge);
 | 
			
		||||
        let distancevalue = 40; // default value for the link force of the nodes
 | 
			
		||||
 | 
			
		||||
        this.$widget.find(".fixnodes-type-switcher input").on("change", async (e) => {
 | 
			
		||||
            distancevalue = parseInt(e.target.closest("input")?.value ?? "0");
 | 
			
		||||
            this.graph.d3Force("link")?.distance(distancevalue);
 | 
			
		||||
 | 
			
		||||
            this.renderData(data);
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        this.graph.d3Force("center")?.strength(0.2);
 | 
			
		||||
        this.graph.d3Force("charge")?.strength(boundedCharge);
 | 
			
		||||
        this.graph.d3Force("charge")?.distanceMax(1000);
 | 
			
		||||
 | 
			
		||||
        this.renderData(data);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    getMapRootNoteId(): string {
 | 
			
		||||
        if (this.noteId && this.widgetMode === "ribbon") {
 | 
			
		||||
            return this.noteId;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        let mapRootNoteId = this.note?.getLabelValue("mapRootNoteId");
 | 
			
		||||
 | 
			
		||||
        if (mapRootNoteId === "hoisted") {
 | 
			
		||||
            mapRootNoteId = hoistedNoteService.getHoistedNoteId();
 | 
			
		||||
        } else if (!mapRootNoteId) {
 | 
			
		||||
            mapRootNoteId = appContext.tabManager.getActiveContext()?.parentNoteId;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return mapRootNoteId ?? "";
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    getColorForNode(node: Node) {
 | 
			
		||||
        if (node.color) {
 | 
			
		||||
            return node.color;
 | 
			
		||||
        } else if (this.widgetMode === "ribbon" && node.id === this.noteId) {
 | 
			
		||||
            return "red"; // subtree root mark as red
 | 
			
		||||
        } else {
 | 
			
		||||
            return this.generateColorFromString(node.type);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    generateColorFromString(str: string) {
 | 
			
		||||
        if (this.themeStyle === "dark") {
 | 
			
		||||
            str = `0${str}`; // magic lightning modifier
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        let hash = 0;
 | 
			
		||||
        for (let i = 0; i < str.length; i++) {
 | 
			
		||||
            hash = str.charCodeAt(i) + ((hash << 5) - hash);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        let color = "#";
 | 
			
		||||
        for (let i = 0; i < 3; i++) {
 | 
			
		||||
            const value = (hash >> (i * 8)) & 0xff;
 | 
			
		||||
 | 
			
		||||
            color += `00${value.toString(16)}`.substr(-2);
 | 
			
		||||
        }
 | 
			
		||||
        return color;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    rgb2hex(rgb: string) {
 | 
			
		||||
        return `#${(rgb.match(/^rgb\((\d+),\s*(\d+),\s*(\d+)\)$/) || [])
 | 
			
		||||
            .slice(1)
 | 
			
		||||
            .map((n) => parseInt(n, 10).toString(16).padStart(2, "0"))
 | 
			
		||||
            .join("")}`;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    setZoomLevel(level: number) {
 | 
			
		||||
        this.zoomLevel = level;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    paintNode(node: Node, color: string, ctx: CanvasRenderingContext2D) {
 | 
			
		||||
        const { x, y } = node;
 | 
			
		||||
        if (!x || !y) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
        const size = this.noteIdToSizeMap[node.id];
 | 
			
		||||
 | 
			
		||||
        ctx.fillStyle = color;
 | 
			
		||||
        ctx.beginPath();
 | 
			
		||||
        ctx.arc(x, y, size * 0.8, 0, 2 * Math.PI, false);
 | 
			
		||||
        ctx.fill();
 | 
			
		||||
 | 
			
		||||
        const toRender = this.zoomLevel > 2 || (this.zoomLevel > 1 && size > 6) || (this.zoomLevel > 0.3 && size > 10);
 | 
			
		||||
 | 
			
		||||
        if (!toRender) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        ctx.fillStyle = this.cssData.textColor;
 | 
			
		||||
        ctx.font = `${size}px ${this.cssData.fontFamily}`;
 | 
			
		||||
        ctx.textAlign = "center";
 | 
			
		||||
        ctx.textBaseline = "middle";
 | 
			
		||||
 | 
			
		||||
        let title = node.name;
 | 
			
		||||
 | 
			
		||||
        if (title.length > 15) {
 | 
			
		||||
            title = `${title.substr(0, 15)}...`;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        ctx.fillText(title, x, y + Math.round(size * 1.5));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    paintLink(link: Link, ctx: CanvasRenderingContext2D) {
 | 
			
		||||
        if (this.zoomLevel < 5) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        ctx.font = `3px ${this.cssData.fontFamily}`;
 | 
			
		||||
        ctx.textAlign = "center";
 | 
			
		||||
        ctx.textBaseline = "middle";
 | 
			
		||||
        ctx.fillStyle = this.cssData.mutedTextColor;
 | 
			
		||||
 | 
			
		||||
        const { source, target } = link;
 | 
			
		||||
        if (typeof source !== "object" || typeof target !== "object") {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (source.x && source.y && target.x && target.y) {
 | 
			
		||||
            const x = (source.x + target.x) / 2;
 | 
			
		||||
            const y = (source.y + target.y) / 2;
 | 
			
		||||
            ctx.save();
 | 
			
		||||
            ctx.translate(x, y);
 | 
			
		||||
 | 
			
		||||
            const deltaY = source.y - target.y;
 | 
			
		||||
            const deltaX = source.x - target.x;
 | 
			
		||||
 | 
			
		||||
            let angle = Math.atan2(deltaY, deltaX);
 | 
			
		||||
            let moveY = 2;
 | 
			
		||||
 | 
			
		||||
            if (angle < -Math.PI / 2 || angle > Math.PI / 2) {
 | 
			
		||||
                angle += Math.PI;
 | 
			
		||||
                moveY = -2;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            ctx.rotate(angle);
 | 
			
		||||
            ctx.fillText(link.name, 0, moveY);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        ctx.restore();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async loadNotesAndRelations(mapRootNoteId: string, excludeRelations: string[], includeRelations: string[]): Promise<NotesAndRelationsData> {
 | 
			
		||||
        const resp = await server.post<PostNotesMapResponse>(`note-map/${mapRootNoteId}/${this.mapType}`, {
 | 
			
		||||
            excludeRelations, includeRelations
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        this.calculateNodeSizes(resp);
 | 
			
		||||
 | 
			
		||||
        const links = this.getGroupedLinks(resp.links);
 | 
			
		||||
 | 
			
		||||
        this.nodes = resp.notes.map(([noteId, title, type, color]) => ({
 | 
			
		||||
            id: noteId,
 | 
			
		||||
            name: title,
 | 
			
		||||
            type: type,
 | 
			
		||||
            color: color
 | 
			
		||||
        }));
 | 
			
		||||
 | 
			
		||||
        return {
 | 
			
		||||
            nodes: this.nodes,
 | 
			
		||||
            links: links.map((link) => ({
 | 
			
		||||
                id: `${link.sourceNoteId}-${link.targetNoteId}`,
 | 
			
		||||
                source: link.sourceNoteId,
 | 
			
		||||
                target: link.targetNoteId,
 | 
			
		||||
                name: link.names.join(", ")
 | 
			
		||||
            }))
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    getGroupedLinks(links: ResponseLink[]): GroupedLink[] {
 | 
			
		||||
        const linksGroupedBySourceTarget: Record<string, GroupedLink> = {};
 | 
			
		||||
 | 
			
		||||
        for (const link of links) {
 | 
			
		||||
            const key = `${link.sourceNoteId}-${link.targetNoteId}`;
 | 
			
		||||
 | 
			
		||||
            if (key in linksGroupedBySourceTarget) {
 | 
			
		||||
                if (!linksGroupedBySourceTarget[key].names.includes(link.name)) {
 | 
			
		||||
                    linksGroupedBySourceTarget[key].names.push(link.name);
 | 
			
		||||
                }
 | 
			
		||||
            } else {
 | 
			
		||||
                linksGroupedBySourceTarget[key] = {
 | 
			
		||||
                    id: key,
 | 
			
		||||
                    sourceNoteId: link.sourceNoteId,
 | 
			
		||||
                    targetNoteId: link.targetNoteId,
 | 
			
		||||
                    names: [link.name]
 | 
			
		||||
                };
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return Object.values(linksGroupedBySourceTarget);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    calculateNodeSizes(resp: PostNotesMapResponse) {
 | 
			
		||||
        this.noteIdToSizeMap = {};
 | 
			
		||||
 | 
			
		||||
        if (this.mapType === "tree") {
 | 
			
		||||
            const { noteIdToDescendantCountMap } = resp;
 | 
			
		||||
 | 
			
		||||
            for (const noteId in noteIdToDescendantCountMap) {
 | 
			
		||||
                this.noteIdToSizeMap[noteId] = 4;
 | 
			
		||||
 | 
			
		||||
                const count = noteIdToDescendantCountMap[noteId];
 | 
			
		||||
 | 
			
		||||
                if (count > 0) {
 | 
			
		||||
                    this.noteIdToSizeMap[noteId] += 1 + Math.round(Math.log(count) / Math.log(1.5));
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        } else if (this.mapType === "link") {
 | 
			
		||||
            const noteIdToLinkCount: Record<string, number> = {};
 | 
			
		||||
 | 
			
		||||
            for (const link of resp.links) {
 | 
			
		||||
                noteIdToLinkCount[link.targetNoteId] = 1 + (noteIdToLinkCount[link.targetNoteId] || 0);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            for (const [noteId] of resp.notes) {
 | 
			
		||||
                this.noteIdToSizeMap[noteId] = 4;
 | 
			
		||||
 | 
			
		||||
                if (noteId in noteIdToLinkCount) {
 | 
			
		||||
                    this.noteIdToSizeMap[noteId] += Math.min(Math.pow(noteIdToLinkCount[noteId], 0.5), 15);
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    renderData(data: Data) {
 | 
			
		||||
        this.graph.graphData(data);
 | 
			
		||||
 | 
			
		||||
        if (this.widgetMode === "ribbon" && this.note?.type !== "search") {
 | 
			
		||||
            setTimeout(() => {
 | 
			
		||||
                this.setDimensions();
 | 
			
		||||
 | 
			
		||||
                const subGraphNoteIds = this.getSubGraphConnectedToCurrentNote(data);
 | 
			
		||||
 | 
			
		||||
                this.graph.zoomToFit(400, 50, (node) => subGraphNoteIds.has(node.id));
 | 
			
		||||
 | 
			
		||||
                if (subGraphNoteIds.size < 30) {
 | 
			
		||||
                    this.graph.d3VelocityDecay(0.4);
 | 
			
		||||
                }
 | 
			
		||||
            }, 1000);
 | 
			
		||||
        } else {
 | 
			
		||||
            if (data.nodes.length > 1) {
 | 
			
		||||
                setTimeout(() => {
 | 
			
		||||
                    this.setDimensions();
 | 
			
		||||
 | 
			
		||||
                    const noteIdsWithLinks = this.getNoteIdsWithLinks(data);
 | 
			
		||||
 | 
			
		||||
                    if (noteIdsWithLinks.size > 0) {
 | 
			
		||||
                        this.graph.zoomToFit(400, 30, (node) => noteIdsWithLinks.has(node.id ?? ""));
 | 
			
		||||
                    }
 | 
			
		||||
 | 
			
		||||
                    if (noteIdsWithLinks.size < 30) {
 | 
			
		||||
                        this.graph.d3VelocityDecay(0.4);
 | 
			
		||||
                    }
 | 
			
		||||
                }, 1000);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    getNoteIdsWithLinks(data: Data) {
 | 
			
		||||
        const noteIds = new Set<string | number>();
 | 
			
		||||
 | 
			
		||||
        for (const link of data.links) {
 | 
			
		||||
            if (typeof link.source === "object" && link.source.id) {
 | 
			
		||||
                noteIds.add(link.source.id);
 | 
			
		||||
            }
 | 
			
		||||
            if (typeof link.target === "object" && link.target.id) {
 | 
			
		||||
                noteIds.add(link.target.id);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return noteIds;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    getSubGraphConnectedToCurrentNote(data: Data) {
 | 
			
		||||
        function getGroupedLinks(links: LinkObject<NodeObject>[], type: "source" | "target") {
 | 
			
		||||
            const map: Record<string | number, LinkObject<NodeObject>[]> = {};
 | 
			
		||||
 | 
			
		||||
            for (const link of links) {
 | 
			
		||||
                if (typeof link[type] !== "object") {
 | 
			
		||||
                    continue;
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                const key = link[type].id;
 | 
			
		||||
                if (key) {
 | 
			
		||||
                    map[key] = map[key] || [];
 | 
			
		||||
                    map[key].push(link);
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            return map;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const linksBySource = getGroupedLinks(data.links, "source");
 | 
			
		||||
        const linksByTarget = getGroupedLinks(data.links, "target");
 | 
			
		||||
 | 
			
		||||
        const subGraphNoteIds = new Set();
 | 
			
		||||
 | 
			
		||||
        function traverseGraph(noteId?: string | number) {
 | 
			
		||||
            if (!noteId || subGraphNoteIds.has(noteId)) {
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            subGraphNoteIds.add(noteId);
 | 
			
		||||
 | 
			
		||||
            for (const link of linksBySource[noteId] || []) {
 | 
			
		||||
                if (typeof link.target === "object") {
 | 
			
		||||
                    traverseGraph(link.target?.id);
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            for (const link of linksByTarget[noteId] || []) {
 | 
			
		||||
                if (typeof link.source === "object") {
 | 
			
		||||
                    traverseGraph(link.source?.id);
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        traverseGraph(this.noteId);
 | 
			
		||||
        return subGraphNoteIds;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    cleanup() {
 | 
			
		||||
        this.$container.html("");
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
 | 
			
		||||
        if (loadResults.getAttributeRows(this.componentId)
 | 
			
		||||
                .find((attr) => attr.type === "label" && ["mapType", "mapRootNoteId"].includes(attr.name || "") && attributeService.isAffecting(attr, this.note))) {
 | 
			
		||||
            this.refresh();
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -1,57 +0,0 @@
 | 
			
		||||
.note-detail-note-map {
 | 
			
		||||
            height: 100%;
 | 
			
		||||
    overflow: hidden;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Style Ui Element to Drag Nodes */
 | 
			
		||||
.fixnodes-type-switcher {
 | 
			
		||||
    display: flex;
 | 
			
		||||
    align-items: center;
 | 
			
		||||
    z-index: 10; /* should be below dropdown (note actions) */
 | 
			
		||||
    border-radius: .2rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Start of styling the slider */
 | 
			
		||||
.fixnodes-type-switcher input[type="range"] {
 | 
			
		||||
 | 
			
		||||
    /* removing default appearance */
 | 
			
		||||
    -webkit-appearance: none;
 | 
			
		||||
    appearance: none;
 | 
			
		||||
    margin-inline-start: 15px;
 | 
			
		||||
    width: 150px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Changing slider tracker */
 | 
			
		||||
.fixnodes-type-switcher input[type="range"]::-webkit-slider-runnable-track {
 | 
			
		||||
    height: 4px;
 | 
			
		||||
    background-color: var(--main-border-color);
 | 
			
		||||
    border-radius: 4px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Changing Slider Thumb */
 | 
			
		||||
.fixnodes-type-switcher input[type="range"]::-webkit-slider-thumb {
 | 
			
		||||
    /* removing default appearance */
 | 
			
		||||
    -webkit-appearance: none;
 | 
			
		||||
    appearance: none;
 | 
			
		||||
    /* creating a custom design */
 | 
			
		||||
    height: 15px;
 | 
			
		||||
    width: 15px;
 | 
			
		||||
    margin-top:-5px;
 | 
			
		||||
    background-color: var(--accented-background-color);
 | 
			
		||||
    border: 1px solid var(--main-text-color);
 | 
			
		||||
    border-radius: 50%;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.fixnodes-type-switcher input[type="range"]::-moz-range-track {
 | 
			
		||||
    background-color: var(--main-border-color);
 | 
			
		||||
    border-radius: 4px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.fixnodes-type-switcher input[type="range"]::-moz-range-thumb {
 | 
			
		||||
    background-color: var(--accented-background-color);
 | 
			
		||||
    border-color: var(--main-text-color);
 | 
			
		||||
    height: 10px;
 | 
			
		||||
    width: 10px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* End of styling the slider */
 | 
			
		||||
@@ -1,174 +0,0 @@
 | 
			
		||||
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
 | 
			
		||||
import "./NoteMap.css";
 | 
			
		||||
import { getThemeStyle, MapType, NoteMapWidgetMode, rgb2hex } from "./utils";
 | 
			
		||||
import { RefObject } from "preact";
 | 
			
		||||
import FNote from "../../entities/fnote";
 | 
			
		||||
import { useElementSize, useNoteLabel } from "../react/hooks";
 | 
			
		||||
import ForceGraph from "force-graph";
 | 
			
		||||
import { loadNotesAndRelations, NoteMapLinkObject, NoteMapNodeObject, NotesAndRelationsData } from "./data";
 | 
			
		||||
import { CssData, setupRendering } from "./rendering";
 | 
			
		||||
import ActionButton from "../react/ActionButton";
 | 
			
		||||
import { t } from "../../services/i18n";
 | 
			
		||||
import link_context_menu from "../../menus/link_context_menu";
 | 
			
		||||
import appContext from "../../components/app_context";
 | 
			
		||||
import Slider from "../react/Slider";
 | 
			
		||||
import hoisted_note from "../../services/hoisted_note";
 | 
			
		||||
 | 
			
		||||
interface NoteMapProps {
 | 
			
		||||
    note: FNote;
 | 
			
		||||
    widgetMode: NoteMapWidgetMode;
 | 
			
		||||
    parentRef: RefObject<HTMLElement>;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default function NoteMap({ note, widgetMode, parentRef }: NoteMapProps) {
 | 
			
		||||
    const containerRef = useRef<HTMLDivElement>(null);
 | 
			
		||||
    const styleResolverRef = useRef<HTMLDivElement>(null);
 | 
			
		||||
    const [ mapTypeRaw, setMapType ] = useNoteLabel(note, "mapType");
 | 
			
		||||
    const [ mapRootIdLabel ] = useNoteLabel(note, "mapRootNoteId");
 | 
			
		||||
    const mapType: MapType = mapTypeRaw === "tree" ? "tree" : "link";
 | 
			
		||||
 | 
			
		||||
    const graphRef = useRef<ForceGraph<NoteMapNodeObject, NoteMapLinkObject>>();
 | 
			
		||||
    const containerSize = useElementSize(parentRef);
 | 
			
		||||
    const [ fixNodes, setFixNodes ] = useState(false);
 | 
			
		||||
    const [ linkDistance, setLinkDistance ] = useState(40);
 | 
			
		||||
    const notesAndRelationsRef = useRef<NotesAndRelationsData>();
 | 
			
		||||
 | 
			
		||||
    const mapRootId = useMemo(() => {
 | 
			
		||||
        if (note.noteId && widgetMode === "ribbon") {
 | 
			
		||||
            return note.noteId;
 | 
			
		||||
        } else if (mapRootIdLabel === "hoisted") {
 | 
			
		||||
            return hoisted_note.getHoistedNoteId();
 | 
			
		||||
        } else if (mapRootIdLabel) {
 | 
			
		||||
            return mapRootIdLabel;
 | 
			
		||||
        } else {
 | 
			
		||||
            return appContext.tabManager.getActiveContext()?.parentNoteId ?? null;
 | 
			
		||||
        }
 | 
			
		||||
    }, [ note ]);
 | 
			
		||||
 | 
			
		||||
    // Build the note graph instance.
 | 
			
		||||
    useEffect(() => {
 | 
			
		||||
        const container = containerRef.current;
 | 
			
		||||
        if (!container || !mapRootId) return;
 | 
			
		||||
        const graph = new ForceGraph<NoteMapNodeObject, NoteMapLinkObject>(container);
 | 
			
		||||
 | 
			
		||||
        graphRef.current = graph;
 | 
			
		||||
 | 
			
		||||
        const labelValues = (name: string) => note.getLabels(name).map(l => l.value) ?? [];
 | 
			
		||||
        const excludeRelations = labelValues("mapExcludeRelation");
 | 
			
		||||
        const includeRelations = labelValues("mapIncludeRelation");
 | 
			
		||||
        loadNotesAndRelations(mapRootId, excludeRelations, includeRelations, mapType).then((notesAndRelations) => {
 | 
			
		||||
            if (!containerRef.current || !styleResolverRef.current) return;
 | 
			
		||||
            const cssData = getCssData(containerRef.current, styleResolverRef.current);
 | 
			
		||||
 | 
			
		||||
            // Configure rendering properties.
 | 
			
		||||
            setupRendering(graph, {
 | 
			
		||||
                note,
 | 
			
		||||
                noteId: note.noteId,
 | 
			
		||||
                noteIdToSizeMap: notesAndRelations.noteIdToSizeMap,
 | 
			
		||||
                cssData,
 | 
			
		||||
                notesAndRelations,
 | 
			
		||||
                themeStyle: getThemeStyle(),
 | 
			
		||||
                widgetMode,
 | 
			
		||||
                mapType
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
            // Interaction
 | 
			
		||||
            graph
 | 
			
		||||
                .onNodeClick((node) => {
 | 
			
		||||
                    if (!node.id) return;
 | 
			
		||||
                    appContext.tabManager.getActiveContext()?.setNote(node.id);
 | 
			
		||||
                })
 | 
			
		||||
                .onNodeRightClick((node, e) => {
 | 
			
		||||
                    if (!node.id) return;
 | 
			
		||||
                    link_context_menu.openContextMenu(node.id, e);
 | 
			
		||||
                });
 | 
			
		||||
 | 
			
		||||
            // Set data
 | 
			
		||||
            graph.graphData(notesAndRelations);
 | 
			
		||||
            notesAndRelationsRef.current = notesAndRelations;
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        return () => container.replaceChildren();
 | 
			
		||||
    }, [ note, mapType ]);
 | 
			
		||||
 | 
			
		||||
    useEffect(() => {
 | 
			
		||||
        if (!graphRef.current || !notesAndRelationsRef.current) return;
 | 
			
		||||
        graphRef.current.d3Force("link")?.distance(linkDistance);
 | 
			
		||||
        graphRef.current.graphData(notesAndRelationsRef.current);
 | 
			
		||||
    }, [ linkDistance ]);
 | 
			
		||||
 | 
			
		||||
    // React to container size
 | 
			
		||||
    useEffect(() => {
 | 
			
		||||
        if (!containerSize || !graphRef.current) return;
 | 
			
		||||
        graphRef.current.width(containerSize.width).height(containerSize.height);
 | 
			
		||||
    }, [ containerSize?.width, containerSize?.height ]);
 | 
			
		||||
 | 
			
		||||
    // Fixing nodes when dragged.
 | 
			
		||||
    useEffect(() => {
 | 
			
		||||
        graphRef.current?.onNodeDragEnd((node) => {
 | 
			
		||||
            if (fixNodes) {
 | 
			
		||||
                node.fx = node.x;
 | 
			
		||||
                node.fy = node.y;
 | 
			
		||||
            } else {
 | 
			
		||||
                node.fx = undefined;
 | 
			
		||||
                node.fy = undefined;
 | 
			
		||||
            }
 | 
			
		||||
        })
 | 
			
		||||
    }, [ fixNodes ]);
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
        <div className="note-map-widget">
 | 
			
		||||
            <div className="btn-group btn-group-sm map-type-switcher content-floating-buttons top-left" role="group">
 | 
			
		||||
                <MapTypeSwitcher type="link" icon="bx bx-network-chart" text={t("note-map.button-link-map")} currentMapType={mapType} setMapType={setMapType} />
 | 
			
		||||
                <MapTypeSwitcher type="tree" icon="bx bx-sitemap" text={t("note-map.button-tree-map")} currentMapType={mapType} setMapType={setMapType} />
 | 
			
		||||
            </div>
 | 
			
		||||
 | 
			
		||||
            <div class="btn-group-sm fixnodes-type-switcher content-floating-buttons bottom-left" role="group">
 | 
			
		||||
                <ActionButton
 | 
			
		||||
                    icon="bx bx-lock-alt"
 | 
			
		||||
                    text={t("note_map.fix-nodes")}
 | 
			
		||||
                    className={fixNodes ? "active" : ""}
 | 
			
		||||
                    onClick={() => setFixNodes(!fixNodes)}
 | 
			
		||||
                    frame
 | 
			
		||||
                />
 | 
			
		||||
 | 
			
		||||
                <Slider
 | 
			
		||||
                    min={1} max={100}
 | 
			
		||||
                    value={linkDistance} onChange={setLinkDistance}
 | 
			
		||||
                    title={t("note_map.link-distance")}
 | 
			
		||||
                />
 | 
			
		||||
            </div>
 | 
			
		||||
 | 
			
		||||
            <div ref={styleResolverRef} class="style-resolver" />
 | 
			
		||||
            <div ref={containerRef} className="note-map-container" />
 | 
			
		||||
        </div>
 | 
			
		||||
    )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function MapTypeSwitcher({ icon, text, type, currentMapType, setMapType }: {
 | 
			
		||||
    icon: string;
 | 
			
		||||
    text: string;
 | 
			
		||||
    type: MapType;
 | 
			
		||||
    currentMapType: MapType;
 | 
			
		||||
    setMapType: (type: MapType) => void;
 | 
			
		||||
}) {
 | 
			
		||||
    return (
 | 
			
		||||
        <ActionButton
 | 
			
		||||
            icon={icon} text={text}
 | 
			
		||||
            active={currentMapType === type}
 | 
			
		||||
            onClick={() => setMapType(type)}
 | 
			
		||||
            frame
 | 
			
		||||
        />
 | 
			
		||||
    )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function getCssData(container: HTMLElement, styleResolver: HTMLElement): CssData {
 | 
			
		||||
    const containerStyle = window.getComputedStyle(container);
 | 
			
		||||
    const styleResolverStyle = window.getComputedStyle(styleResolver);
 | 
			
		||||
 | 
			
		||||
    return {
 | 
			
		||||
        fontFamily: containerStyle.fontFamily,
 | 
			
		||||
        textColor: rgb2hex(containerStyle.color),
 | 
			
		||||
        mutedTextColor: rgb2hex(styleResolverStyle.color)
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -1,120 +0,0 @@
 | 
			
		||||
import { NoteMapLink, NoteMapPostResponse } from "@triliumnext/commons";
 | 
			
		||||
import server from "../../services/server";
 | 
			
		||||
import { LinkObject, NodeObject } from "force-graph";
 | 
			
		||||
 | 
			
		||||
type MapType = "tree" | "link";
 | 
			
		||||
 | 
			
		||||
interface GroupedLink {
 | 
			
		||||
    id: string;
 | 
			
		||||
    sourceNoteId: string;
 | 
			
		||||
    targetNoteId: string;
 | 
			
		||||
    names: string[];
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface NoteMapNodeObject extends NodeObject {
 | 
			
		||||
    id: string;
 | 
			
		||||
    name: string;
 | 
			
		||||
    type: string;
 | 
			
		||||
    color: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface NoteMapLinkObject extends LinkObject<NoteMapNodeObject> {
 | 
			
		||||
    id: string;
 | 
			
		||||
    name: string;
 | 
			
		||||
    x?: number;
 | 
			
		||||
    y?: number;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface NotesAndRelationsData {
 | 
			
		||||
    nodes: NoteMapNodeObject[];
 | 
			
		||||
    links: {
 | 
			
		||||
        id: string;
 | 
			
		||||
        source: string | NoteMapNodeObject;
 | 
			
		||||
        target: string | NoteMapNodeObject;
 | 
			
		||||
        name: string;
 | 
			
		||||
    }[];
 | 
			
		||||
    noteIdToSizeMap: Record<string, number>;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export async function loadNotesAndRelations(mapRootNoteId: string, excludeRelations: string[], includeRelations: string[], mapType: MapType): Promise<NotesAndRelationsData> {
 | 
			
		||||
    const resp = await server.post<NoteMapPostResponse>(`note-map/${mapRootNoteId}/${mapType}`, {
 | 
			
		||||
        excludeRelations, includeRelations
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    const noteIdToSizeMap = calculateNodeSizes(resp, mapType);
 | 
			
		||||
    const links = getGroupedLinks(resp.links);
 | 
			
		||||
    const nodes = resp.notes.map(([noteId, title, type, color]) => ({
 | 
			
		||||
        id: noteId,
 | 
			
		||||
        name: title,
 | 
			
		||||
        type: type,
 | 
			
		||||
        color: color
 | 
			
		||||
    }));
 | 
			
		||||
 | 
			
		||||
    return {
 | 
			
		||||
        noteIdToSizeMap,
 | 
			
		||||
        nodes,
 | 
			
		||||
        links: links.map((link) => ({
 | 
			
		||||
            id: `${link.sourceNoteId}-${link.targetNoteId}`,
 | 
			
		||||
            source: link.sourceNoteId,
 | 
			
		||||
            target: link.targetNoteId,
 | 
			
		||||
            name: link.names.join(", ")
 | 
			
		||||
        }))
 | 
			
		||||
    };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function calculateNodeSizes(resp: NoteMapPostResponse, mapType: MapType) {
 | 
			
		||||
    const noteIdToSizeMap: Record<string, number> = {};
 | 
			
		||||
 | 
			
		||||
    if (mapType === "tree") {
 | 
			
		||||
        const { noteIdToDescendantCountMap } = resp;
 | 
			
		||||
 | 
			
		||||
        for (const noteId in noteIdToDescendantCountMap) {
 | 
			
		||||
            noteIdToSizeMap[noteId] = 4;
 | 
			
		||||
 | 
			
		||||
            const count = noteIdToDescendantCountMap[noteId];
 | 
			
		||||
 | 
			
		||||
            if (count > 0) {
 | 
			
		||||
                noteIdToSizeMap[noteId] += 1 + Math.round(Math.log(count) / Math.log(1.5));
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    } else if (mapType === "link") {
 | 
			
		||||
        const noteIdToLinkCount: Record<string, number> = {};
 | 
			
		||||
 | 
			
		||||
        for (const link of resp.links) {
 | 
			
		||||
            noteIdToLinkCount[link.targetNoteId] = 1 + (noteIdToLinkCount[link.targetNoteId] || 0);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        for (const [noteId] of resp.notes) {
 | 
			
		||||
            noteIdToSizeMap[noteId] = 4;
 | 
			
		||||
 | 
			
		||||
            if (noteId in noteIdToLinkCount) {
 | 
			
		||||
                noteIdToSizeMap[noteId] += Math.min(Math.pow(noteIdToLinkCount[noteId], 0.5), 15);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return noteIdToSizeMap;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function getGroupedLinks(links: NoteMapLink[]): GroupedLink[] {
 | 
			
		||||
    const linksGroupedBySourceTarget: Record<string, GroupedLink> = {};
 | 
			
		||||
 | 
			
		||||
    for (const link of links) {
 | 
			
		||||
        const key = `${link.sourceNoteId}-${link.targetNoteId}`;
 | 
			
		||||
 | 
			
		||||
        if (key in linksGroupedBySourceTarget) {
 | 
			
		||||
            if (!linksGroupedBySourceTarget[key].names.includes(link.name)) {
 | 
			
		||||
                linksGroupedBySourceTarget[key].names.push(link.name);
 | 
			
		||||
            }
 | 
			
		||||
        } else {
 | 
			
		||||
            linksGroupedBySourceTarget[key] = {
 | 
			
		||||
                id: key,
 | 
			
		||||
                sourceNoteId: link.sourceNoteId,
 | 
			
		||||
                targetNoteId: link.targetNoteId,
 | 
			
		||||
                names: [link.name]
 | 
			
		||||
            };
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return Object.values(linksGroupedBySourceTarget);
 | 
			
		||||
}
 | 
			
		||||
@@ -1,282 +0,0 @@
 | 
			
		||||
import type ForceGraph from "force-graph";
 | 
			
		||||
import { NoteMapLinkObject, NoteMapNodeObject, NotesAndRelationsData } from "./data";
 | 
			
		||||
import { LinkObject, NodeObject } from "force-graph";
 | 
			
		||||
import { generateColorFromString, MapType, NoteMapWidgetMode } from "./utils";
 | 
			
		||||
import { escapeHtml } from "../../services/utils";
 | 
			
		||||
import FNote from "../../entities/fnote";
 | 
			
		||||
 | 
			
		||||
export interface CssData {
 | 
			
		||||
    fontFamily: string;
 | 
			
		||||
    textColor: string;
 | 
			
		||||
    mutedTextColor: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
interface RenderData {
 | 
			
		||||
    note: FNote;
 | 
			
		||||
    noteIdToSizeMap: Record<string, number>;
 | 
			
		||||
    cssData: CssData;
 | 
			
		||||
    noteId: string;
 | 
			
		||||
    themeStyle: "light" | "dark";
 | 
			
		||||
    widgetMode: NoteMapWidgetMode;
 | 
			
		||||
    notesAndRelations: NotesAndRelationsData;
 | 
			
		||||
    mapType: MapType;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function setupRendering(graph: ForceGraph<NoteMapNodeObject, NoteMapLinkObject>, { note, noteId, themeStyle, widgetMode, noteIdToSizeMap, notesAndRelations, cssData, mapType }: RenderData) {
 | 
			
		||||
    // variables for the hover effect. We have to save the neighbours of a hovered node in a set. Also we need to save the links as well as the hovered node itself
 | 
			
		||||
    const neighbours = new Set();
 | 
			
		||||
    const highlightLinks = new Set();
 | 
			
		||||
    let hoverNode: NodeObject | null = null;
 | 
			
		||||
    let zoomLevel: number;
 | 
			
		||||
 | 
			
		||||
    function getColorForNode(node: NoteMapNodeObject) {
 | 
			
		||||
        if (node.color) {
 | 
			
		||||
            return node.color;
 | 
			
		||||
        } else if (widgetMode === "ribbon" && node.id === noteId) {
 | 
			
		||||
            return "red"; // subtree root mark as red
 | 
			
		||||
        } else {
 | 
			
		||||
            return generateColorFromString(node.type, themeStyle);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    function paintNode(node: NoteMapNodeObject, color: string, ctx: CanvasRenderingContext2D) {
 | 
			
		||||
        const { x, y } = node;
 | 
			
		||||
        if (!x || !y) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
        const size = noteIdToSizeMap[node.id];
 | 
			
		||||
 | 
			
		||||
        ctx.fillStyle = color;
 | 
			
		||||
        ctx.beginPath();
 | 
			
		||||
        ctx.arc(x, y, size * 0.8, 0, 2 * Math.PI, false);
 | 
			
		||||
        ctx.fill();
 | 
			
		||||
 | 
			
		||||
        const toRender = zoomLevel > 2 || (zoomLevel > 1 && size > 6) || (zoomLevel > 0.3 && size > 10);
 | 
			
		||||
 | 
			
		||||
        if (!toRender) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        ctx.fillStyle = cssData.textColor;
 | 
			
		||||
        ctx.font = `${size}px ${cssData.fontFamily}`;
 | 
			
		||||
        ctx.textAlign = "center";
 | 
			
		||||
        ctx.textBaseline = "middle";
 | 
			
		||||
 | 
			
		||||
        let title = node.name;
 | 
			
		||||
 | 
			
		||||
        if (title.length > 15) {
 | 
			
		||||
            title = `${title.substr(0, 15)}...`;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        ctx.fillText(title, x, y + Math.round(size * 1.5));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    function paintLink(link: NoteMapLinkObject, ctx: CanvasRenderingContext2D) {
 | 
			
		||||
        if (zoomLevel < 5) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        ctx.font = `3px ${cssData.fontFamily}`;
 | 
			
		||||
        ctx.textAlign = "center";
 | 
			
		||||
        ctx.textBaseline = "middle";
 | 
			
		||||
        ctx.fillStyle = cssData.mutedTextColor;
 | 
			
		||||
 | 
			
		||||
        const { source, target } = link;
 | 
			
		||||
        if (typeof source !== "object" || typeof target !== "object") {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (source.x && source.y && target.x && target.y) {
 | 
			
		||||
            const x = (source.x + target.x) / 2;
 | 
			
		||||
            const y = (source.y + target.y) / 2;
 | 
			
		||||
            ctx.save();
 | 
			
		||||
            ctx.translate(x, y);
 | 
			
		||||
 | 
			
		||||
            const deltaY = source.y - target.y;
 | 
			
		||||
            const deltaX = source.x - target.x;
 | 
			
		||||
 | 
			
		||||
            let angle = Math.atan2(deltaY, deltaX);
 | 
			
		||||
            let moveY = 2;
 | 
			
		||||
 | 
			
		||||
            if (angle < -Math.PI / 2 || angle > Math.PI / 2) {
 | 
			
		||||
                angle += Math.PI;
 | 
			
		||||
                moveY = -2;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            ctx.rotate(angle);
 | 
			
		||||
            ctx.fillText(link.name, 0, moveY);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        ctx.restore();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // main code for highlighting hovered nodes and neighbours. here we "style" the nodes. the nodes are rendered several hundred times per second.
 | 
			
		||||
    graph
 | 
			
		||||
        .d3AlphaDecay(0.01)
 | 
			
		||||
        .d3VelocityDecay(0.08)
 | 
			
		||||
        .maxZoom(7)
 | 
			
		||||
        .warmupTicks(30)
 | 
			
		||||
        .nodeCanvasObject((node, ctx) => {
 | 
			
		||||
            if (hoverNode == node) {
 | 
			
		||||
                //paint only hovered node
 | 
			
		||||
                paintNode(node, "#661822", ctx);
 | 
			
		||||
                neighbours.clear(); //clearing neighbours or the effect would be maintained after hovering is over
 | 
			
		||||
                for (const link of notesAndRelations.links) {
 | 
			
		||||
                    const { source, target } = link;
 | 
			
		||||
                    if (typeof source !== "object" || typeof target !== "object") continue;
 | 
			
		||||
 | 
			
		||||
                    //check if node is part of a link in the canvas, if so add it´s neighbours and related links to the previous defined variables to paint the nodes
 | 
			
		||||
                    if (source.id == node.id || target.id == node.id) {
 | 
			
		||||
                        neighbours.add(link.source);
 | 
			
		||||
                        neighbours.add(link.target);
 | 
			
		||||
                        highlightLinks.add(link);
 | 
			
		||||
                        neighbours.delete(node);
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            } else if (neighbours.has(node) && hoverNode != null) {
 | 
			
		||||
                //paint neighbours
 | 
			
		||||
                paintNode(node, "#9d6363", ctx);
 | 
			
		||||
            } else {
 | 
			
		||||
                paintNode(node, getColorForNode(node), ctx); //paint rest of nodes in canvas
 | 
			
		||||
            }
 | 
			
		||||
        })
 | 
			
		||||
        //check if hovered and set the hovernode variable, saving the hovered node object into it. Clear links variable everytime you hover. Without clearing links will stay highlighted
 | 
			
		||||
        .onNodeHover((node) => {
 | 
			
		||||
            hoverNode = node || null;
 | 
			
		||||
            highlightLinks.clear();
 | 
			
		||||
        })
 | 
			
		||||
        .nodePointerAreaPaint((node, _, ctx) => paintNode(node, getColorForNode(node), ctx))
 | 
			
		||||
        .nodePointerAreaPaint((node, color, ctx) => {
 | 
			
		||||
            if (!node.id) {
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            ctx.fillStyle = color;
 | 
			
		||||
            ctx.beginPath();
 | 
			
		||||
            if (node.x && node.y) {
 | 
			
		||||
                ctx.arc(node.x, node.y, noteIdToSizeMap[node.id], 0, 2 * Math.PI, false);
 | 
			
		||||
            }
 | 
			
		||||
            ctx.fill();
 | 
			
		||||
        })
 | 
			
		||||
        .nodeLabel((node) => escapeHtml(node.name))
 | 
			
		||||
        .onZoom((zoom) => zoomLevel = zoom.k);
 | 
			
		||||
 | 
			
		||||
    // set link width to immitate a highlight effect. Checking the condition if any links are saved in the previous defined set highlightlinks
 | 
			
		||||
    graph
 | 
			
		||||
        .linkWidth((link) => (highlightLinks.has(link) ? 3 : 0.4))
 | 
			
		||||
        .linkColor((link) => (highlightLinks.has(link) ? cssData.textColor : cssData.mutedTextColor))
 | 
			
		||||
        .linkDirectionalArrowLength(4)
 | 
			
		||||
        .linkDirectionalArrowRelPos(0.95)
 | 
			
		||||
 | 
			
		||||
    // Link-specific config
 | 
			
		||||
    if (mapType) {
 | 
			
		||||
        graph
 | 
			
		||||
            .linkLabel((link) => {
 | 
			
		||||
                const { source, target } = link;
 | 
			
		||||
                if (typeof source !== "object" || typeof target !== "object") return escapeHtml(link.name);
 | 
			
		||||
                return `${escapeHtml(source.name)} - <strong>${escapeHtml(link.name)}</strong> - ${escapeHtml(target.name)}`;
 | 
			
		||||
            })
 | 
			
		||||
            .linkCanvasObject((link, ctx) => paintLink(link, ctx))
 | 
			
		||||
            .linkCanvasObjectMode(() => "after");
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Forces
 | 
			
		||||
    const nodeLinkRatio = notesAndRelations.nodes.length / notesAndRelations.links.length;
 | 
			
		||||
    const magnifiedRatio = Math.pow(nodeLinkRatio, 1.5);
 | 
			
		||||
    const charge = -20 / magnifiedRatio;
 | 
			
		||||
    const boundedCharge = Math.min(-3, charge);
 | 
			
		||||
    graph.d3Force("center")?.strength(0.2);
 | 
			
		||||
    graph.d3Force("charge")?.strength(boundedCharge);
 | 
			
		||||
    graph.d3Force("charge")?.distanceMax(1000);
 | 
			
		||||
 | 
			
		||||
    // Zoom to notes
 | 
			
		||||
    if (widgetMode === "ribbon" && note?.type !== "search") {
 | 
			
		||||
        setTimeout(() => {
 | 
			
		||||
            const subGraphNoteIds = getSubGraphConnectedToCurrentNote(noteId, notesAndRelations);
 | 
			
		||||
 | 
			
		||||
            graph.zoomToFit(400, 50, (node) => subGraphNoteIds.has(node.id));
 | 
			
		||||
 | 
			
		||||
            if (subGraphNoteIds.size < 30) {
 | 
			
		||||
                graph.d3VelocityDecay(0.4);
 | 
			
		||||
            }
 | 
			
		||||
        }, 1000);
 | 
			
		||||
    } else {
 | 
			
		||||
        if (notesAndRelations.nodes.length > 1) {
 | 
			
		||||
            setTimeout(() => {
 | 
			
		||||
                const noteIdsWithLinks = getNoteIdsWithLinks(notesAndRelations);
 | 
			
		||||
 | 
			
		||||
                if (noteIdsWithLinks.size > 0) {
 | 
			
		||||
                    graph.zoomToFit(400, 30, (node) => noteIdsWithLinks.has(node.id ?? ""));
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                if (noteIdsWithLinks.size < 30) {
 | 
			
		||||
                    graph.d3VelocityDecay(0.4);
 | 
			
		||||
                }
 | 
			
		||||
            }, 1000);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function getNoteIdsWithLinks(data: NotesAndRelationsData) {
 | 
			
		||||
    const noteIds = new Set<string | number>();
 | 
			
		||||
 | 
			
		||||
    for (const link of data.links) {
 | 
			
		||||
        if (typeof link.source === "object" && link.source.id) {
 | 
			
		||||
            noteIds.add(link.source.id);
 | 
			
		||||
        }
 | 
			
		||||
        if (typeof link.target === "object" && link.target.id) {
 | 
			
		||||
            noteIds.add(link.target.id);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return noteIds;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function getSubGraphConnectedToCurrentNote(noteId: string, data: NotesAndRelationsData) {
 | 
			
		||||
    function getGroupedLinks(links: LinkObject<NodeObject>[], type: "source" | "target") {
 | 
			
		||||
        const map: Record<string | number, LinkObject<NodeObject>[]> = {};
 | 
			
		||||
 | 
			
		||||
        for (const link of links) {
 | 
			
		||||
            if (typeof link[type] !== "object") {
 | 
			
		||||
                continue;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            const key = link[type].id;
 | 
			
		||||
            if (key) {
 | 
			
		||||
                map[key] = map[key] || [];
 | 
			
		||||
                map[key].push(link);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return map;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const linksBySource = getGroupedLinks(data.links, "source");
 | 
			
		||||
    const linksByTarget = getGroupedLinks(data.links, "target");
 | 
			
		||||
 | 
			
		||||
    const subGraphNoteIds = new Set();
 | 
			
		||||
 | 
			
		||||
    function traverseGraph(noteId?: string | number) {
 | 
			
		||||
        if (!noteId || subGraphNoteIds.has(noteId)) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        subGraphNoteIds.add(noteId);
 | 
			
		||||
 | 
			
		||||
        for (const link of linksBySource[noteId] || []) {
 | 
			
		||||
            if (typeof link.target === "object") {
 | 
			
		||||
                traverseGraph(link.target?.id);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        for (const link of linksByTarget[noteId] || []) {
 | 
			
		||||
            if (typeof link.source === "object") {
 | 
			
		||||
                traverseGraph(link.source?.id);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    traverseGraph(noteId);
 | 
			
		||||
    return subGraphNoteIds;
 | 
			
		||||
}
 | 
			
		||||
@@ -1,33 +0,0 @@
 | 
			
		||||
export type NoteMapWidgetMode = "ribbon" | "hoisted" | "type";
 | 
			
		||||
export type MapType = "tree" | "link";
 | 
			
		||||
 | 
			
		||||
export function rgb2hex(rgb: string) {
 | 
			
		||||
    return `#${(rgb.match(/^rgb\((\d+),\s*(\d+),\s*(\d+)\)$/) || [])
 | 
			
		||||
        .slice(1)
 | 
			
		||||
        .map((n) => parseInt(n, 10).toString(16).padStart(2, "0"))
 | 
			
		||||
        .join("")}`;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function generateColorFromString(str: string, themeStyle: "light" | "dark") {
 | 
			
		||||
    if (themeStyle === "dark") {
 | 
			
		||||
        str = `0${str}`; // magic lightning modifier
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    let hash = 0;
 | 
			
		||||
    for (let i = 0; i < str.length; i++) {
 | 
			
		||||
        hash = str.charCodeAt(i) + ((hash << 5) - hash);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    let color = "#";
 | 
			
		||||
    for (let i = 0; i < 3; i++) {
 | 
			
		||||
        const value = (hash >> (i * 8)) & 0xff;
 | 
			
		||||
 | 
			
		||||
        color += `00${value.toString(16)}`.substr(-2);
 | 
			
		||||
    }
 | 
			
		||||
    return color;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function getThemeStyle() {
 | 
			
		||||
    const documentStyle = window.getComputedStyle(document.documentElement);
 | 
			
		||||
    return documentStyle.getPropertyValue("--theme-style")?.trim() as "light" | "dark";
 | 
			
		||||
}
 | 
			
		||||
@@ -47,9 +47,7 @@ export default function NoteTitleWidget() {
 | 
			
		||||
 | 
			
		||||
    // Prevent user from navigating away if the spaced update is not done.
 | 
			
		||||
    useEffect(() => {
 | 
			
		||||
        const listener = () => spacedUpdate.isAllSavedAndTriggerUpdate();
 | 
			
		||||
        appContext.addBeforeUnloadListener(listener);
 | 
			
		||||
        return () => appContext.removeBeforeUnloadListener(listener);
 | 
			
		||||
        appContext.addBeforeUnloadListener(() => spacedUpdate.isAllSavedAndTriggerUpdate());
 | 
			
		||||
    }, []);
 | 
			
		||||
    useTriliumEvents([ "beforeNoteSwitch", "beforeNoteContextRemove" ], () => spacedUpdate.updateNowIfNecessary());
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1591,6 +1591,20 @@ export default class NoteTreeWidget extends NoteContextAwareWidget {
 | 
			
		||||
        this.clearSelectedNodes();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async editBranchPrefixCommand({ node }: CommandListenerData<"editBranchPrefix">) {
 | 
			
		||||
        const branchIds = this.getSelectedOrActiveBranchIds(node).filter((branchId) => !branchId.startsWith("virt-"));
 | 
			
		||||
 | 
			
		||||
        if (!branchIds.length) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Trigger the event with the selected branch IDs
 | 
			
		||||
        appContext.triggerEvent("editBranchPrefix", {
 | 
			
		||||
            selectedOrActiveBranchIds: branchIds,
 | 
			
		||||
            node: node
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    canBeMovedUpOrDown(node: Fancytree.FancytreeNode) {
 | 
			
		||||
        if (node.data.noteId === "root") {
 | 
			
		||||
            return false;
 | 
			
		||||
 
 | 
			
		||||
@@ -1,142 +0,0 @@
 | 
			
		||||
/**
 | 
			
		||||
 * @module
 | 
			
		||||
 * Contains the definitions for all the note types supported by the application.
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
import { NoteType } from "@triliumnext/commons";
 | 
			
		||||
import { VNode, type JSX } from "preact";
 | 
			
		||||
import { TypeWidgetProps } from "./type_widgets/type_widget";
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * A `NoteType` altered by the note detail widget, taking into consideration whether the note is editable or not and adding special note types such as an empty one,
 | 
			
		||||
 * for protected session or attachment information.
 | 
			
		||||
 */
 | 
			
		||||
export type ExtendedNoteType = Exclude<NoteType, "launcher" | "text" | "code"> | "empty" | "readOnlyCode" | "readOnlyText" | "editableText" | "editableCode" | "attachmentDetail" | "attachmentList" |  "protectedSession" | "aiChat";
 | 
			
		||||
 | 
			
		||||
export type TypeWidget = ((props: TypeWidgetProps) => VNode | JSX.Element);
 | 
			
		||||
type NoteTypeView = () => (Promise<{ default: TypeWidget } | TypeWidget> | TypeWidget);
 | 
			
		||||
 | 
			
		||||
interface NoteTypeMapping {
 | 
			
		||||
    view: NoteTypeView;
 | 
			
		||||
    printable?: boolean;
 | 
			
		||||
    /** The class name to assign to the note type wrapper */
 | 
			
		||||
    className: string;
 | 
			
		||||
    isFullHeight?: boolean;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const TYPE_MAPPINGS: Record<ExtendedNoteType, NoteTypeMapping> = {
 | 
			
		||||
    empty: {
 | 
			
		||||
        view: () => import("./type_widgets/Empty"),
 | 
			
		||||
        className: "note-detail-empty",
 | 
			
		||||
        printable: true
 | 
			
		||||
    },
 | 
			
		||||
    doc: {
 | 
			
		||||
        view: () => import("./type_widgets/Doc"),
 | 
			
		||||
        className: "note-detail-doc",
 | 
			
		||||
        printable: true
 | 
			
		||||
    },
 | 
			
		||||
    search: {
 | 
			
		||||
        view: () => (props: TypeWidgetProps) => <></>,
 | 
			
		||||
        className: "note-detail-none",
 | 
			
		||||
        printable: true
 | 
			
		||||
    },
 | 
			
		||||
    protectedSession: {
 | 
			
		||||
        view: () => import("./type_widgets/ProtectedSession"),
 | 
			
		||||
        className: "protected-session-password-component"
 | 
			
		||||
    },
 | 
			
		||||
    book: {
 | 
			
		||||
        view: () => import("./type_widgets/Book"),
 | 
			
		||||
        className: "note-detail-book",
 | 
			
		||||
        printable: true,
 | 
			
		||||
    },
 | 
			
		||||
    contentWidget: {
 | 
			
		||||
        view: () => import("./type_widgets/ContentWidget"),
 | 
			
		||||
        className: "note-detail-content-widget",
 | 
			
		||||
        printable: true
 | 
			
		||||
    },
 | 
			
		||||
    webView: {
 | 
			
		||||
        view: () => import("./type_widgets/WebView"),
 | 
			
		||||
        className: "note-detail-web-view",
 | 
			
		||||
        printable: true,
 | 
			
		||||
        isFullHeight: true
 | 
			
		||||
    },
 | 
			
		||||
    file: {
 | 
			
		||||
        view: () => import("./type_widgets/File"),
 | 
			
		||||
        className: "note-detail-file",
 | 
			
		||||
        printable: true,
 | 
			
		||||
        isFullHeight: true
 | 
			
		||||
    },
 | 
			
		||||
    image: {
 | 
			
		||||
        view: () => import("./type_widgets/Image"),
 | 
			
		||||
        className: "note-detail-image",
 | 
			
		||||
        printable: true
 | 
			
		||||
    },
 | 
			
		||||
    readOnlyCode: {
 | 
			
		||||
        view: async () => (await import("./type_widgets/code/Code")).ReadOnlyCode,
 | 
			
		||||
        className: "note-detail-readonly-code",
 | 
			
		||||
        printable: true
 | 
			
		||||
    },
 | 
			
		||||
    editableCode: {
 | 
			
		||||
        view: async () => (await import("./type_widgets/code/Code")).EditableCode,
 | 
			
		||||
        className: "note-detail-code",
 | 
			
		||||
        printable: true
 | 
			
		||||
    },
 | 
			
		||||
    mermaid: {
 | 
			
		||||
        view: () => import("./type_widgets/Mermaid"),
 | 
			
		||||
        className: "note-detail-mermaid",
 | 
			
		||||
        printable: true,
 | 
			
		||||
        isFullHeight: true
 | 
			
		||||
    },
 | 
			
		||||
    mindMap: {
 | 
			
		||||
        view: () => import("./type_widgets/MindMap"),
 | 
			
		||||
        className: "note-detail-mind-map",
 | 
			
		||||
        printable: true,
 | 
			
		||||
        isFullHeight: true
 | 
			
		||||
    },
 | 
			
		||||
    attachmentList: {
 | 
			
		||||
        view: async () => (await import("./type_widgets/Attachment")).AttachmentList,
 | 
			
		||||
        className: "attachment-list",
 | 
			
		||||
        printable: true
 | 
			
		||||
    },
 | 
			
		||||
    attachmentDetail: {
 | 
			
		||||
        view: async () => (await import("./type_widgets/Attachment")).AttachmentDetail,
 | 
			
		||||
        className: "attachment-detail",
 | 
			
		||||
        printable: true
 | 
			
		||||
    },
 | 
			
		||||
    readOnlyText: {
 | 
			
		||||
        view: () => import("./type_widgets/text/ReadOnlyText"),
 | 
			
		||||
        className: "note-detail-readonly-text"
 | 
			
		||||
    },
 | 
			
		||||
    editableText: {
 | 
			
		||||
        view: () => import("./type_widgets/text/EditableText"),
 | 
			
		||||
        className: "note-detail-editable-text",
 | 
			
		||||
        printable: true
 | 
			
		||||
    },
 | 
			
		||||
    render: {
 | 
			
		||||
        view: () => import("./type_widgets/Render"),
 | 
			
		||||
        className: "note-detail-render",
 | 
			
		||||
        printable: true
 | 
			
		||||
    },
 | 
			
		||||
    canvas: {
 | 
			
		||||
        view: () => import("./type_widgets/Canvas"),
 | 
			
		||||
        className: "note-detail-canvas",
 | 
			
		||||
        printable: true,
 | 
			
		||||
        isFullHeight: true
 | 
			
		||||
    },
 | 
			
		||||
    relationMap: {
 | 
			
		||||
        view: () => import("./type_widgets/relation_map/RelationMap"),
 | 
			
		||||
        className: "note-detail-relation-map",
 | 
			
		||||
        printable: true
 | 
			
		||||
    },
 | 
			
		||||
    noteMap: {
 | 
			
		||||
        view: () => import("./type_widgets/NoteMap"),
 | 
			
		||||
        className: "note-detail-note-map",
 | 
			
		||||
        printable: true,
 | 
			
		||||
        isFullHeight: true
 | 
			
		||||
    },
 | 
			
		||||
    aiChat: {
 | 
			
		||||
        view: () => import("./type_widgets/AiChat"),
 | 
			
		||||
        className: "ai-chat-widget-container",
 | 
			
		||||
        isFullHeight: true
 | 
			
		||||
    }
 | 
			
		||||
};
 | 
			
		||||
@@ -3,13 +3,12 @@ import { ComponentChildren } from "preact";
 | 
			
		||||
interface AdmonitionProps {
 | 
			
		||||
    type: "warning" | "note" | "caution";
 | 
			
		||||
    children: ComponentChildren;
 | 
			
		||||
    className?: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default function Admonition({ type, children, className }: AdmonitionProps) {
 | 
			
		||||
export default function Admonition({ type, children }: AdmonitionProps) {
 | 
			
		||||
    return (
 | 
			
		||||
        <div className={`admonition ${type} ${className}`} role="alert">
 | 
			
		||||
        <div className={`admonition ${type}`} role="alert">
 | 
			
		||||
            {children}
 | 
			
		||||
        </div>
 | 
			
		||||
    )
 | 
			
		||||
}
 | 
			
		||||
}
 | 
			
		||||
@@ -82,8 +82,6 @@ interface FormListItemOpts {
 | 
			
		||||
    active?: boolean;
 | 
			
		||||
    badges?: FormListBadge[];
 | 
			
		||||
    disabled?: boolean;
 | 
			
		||||
    /** Will indicate the reason why the item is disabled via an icon, when hovered over it. */
 | 
			
		||||
    disabledTooltip?: string;
 | 
			
		||||
    checked?: boolean | null;
 | 
			
		||||
    selected?: boolean;
 | 
			
		||||
    container?: boolean;
 | 
			
		||||
@@ -121,24 +119,21 @@ export function FormListItem({ className, icon, value, title, active, disabled,
 | 
			
		||||
            <Icon icon={icon} /> 
 | 
			
		||||
            {description ? (
 | 
			
		||||
                <div>
 | 
			
		||||
                    <FormListContent description={description} disabled={disabled} {...contentProps} />
 | 
			
		||||
                    <FormListContent description={description} {...contentProps} />
 | 
			
		||||
                </div>
 | 
			
		||||
            ) : (
 | 
			
		||||
                <FormListContent description={description} disabled={disabled} {...contentProps} />
 | 
			
		||||
                <FormListContent description={description} {...contentProps} />
 | 
			
		||||
            )}
 | 
			
		||||
        </li>
 | 
			
		||||
    );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function FormListContent({ children, badges, description, disabled, disabledTooltip }: Pick<FormListItemOpts, "children" | "badges" | "description" | "disabled" | "disabledTooltip">) {
 | 
			
		||||
function FormListContent({ children, badges, description }: Pick<FormListItemOpts, "children" | "badges" | "description">) {
 | 
			
		||||
    return <>
 | 
			
		||||
        {children}
 | 
			
		||||
        {badges && badges.map(({ className, text }) => (
 | 
			
		||||
            <span className={`badge ${className ?? ""}`}>{text}</span>
 | 
			
		||||
        ))}
 | 
			
		||||
        {disabled && disabledTooltip && (
 | 
			
		||||
            <span class="bx bx-info-circle disabled-tooltip" title={disabledTooltip} />
 | 
			
		||||
        )}
 | 
			
		||||
        {description && <div className="description">{description}</div>}
 | 
			
		||||
    </>;
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -5,18 +5,17 @@ import { openInAppHelpFromUrl } from "../../services/utils";
 | 
			
		||||
interface HelpButtonProps {
 | 
			
		||||
    className?: string;
 | 
			
		||||
    helpPage: string;
 | 
			
		||||
    title?: string;
 | 
			
		||||
    style?: CSSProperties;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default function HelpButton({ className, helpPage, title, style }: HelpButtonProps) {
 | 
			
		||||
export default function HelpButton({ className, helpPage, style }: HelpButtonProps) {
 | 
			
		||||
    return (
 | 
			
		||||
        <button
 | 
			
		||||
            class={`${className ?? ""} icon-action bx bx-help-circle`}
 | 
			
		||||
            type="button"
 | 
			
		||||
            onClick={() => openInAppHelpFromUrl(helpPage)}
 | 
			
		||||
            title={title ?? t("open-help-page")}
 | 
			
		||||
            title={t("open-help-page")}
 | 
			
		||||
            style={style}
 | 
			
		||||
        />
 | 
			
		||||
    );
 | 
			
		||||
}
 | 
			
		||||
}
 | 
			
		||||
@@ -1,5 +1,5 @@
 | 
			
		||||
import { useEffect, useRef, useState } from "preact/hooks";
 | 
			
		||||
import link, { ViewScope } from "../../services/link";
 | 
			
		||||
import link from "../../services/link";
 | 
			
		||||
import { useImperativeSearchHighlighlighting } from "./hooks";
 | 
			
		||||
 | 
			
		||||
interface NoteLinkOpts {
 | 
			
		||||
@@ -11,26 +11,18 @@ interface NoteLinkOpts {
 | 
			
		||||
    noPreview?: boolean;
 | 
			
		||||
    noTnLink?: boolean;
 | 
			
		||||
    highlightedTokens?: string[] | null | undefined;
 | 
			
		||||
    // Override the text of the link, otherwise the note title is used.
 | 
			
		||||
    title?: string;
 | 
			
		||||
    viewScope?: ViewScope;
 | 
			
		||||
    noContextMenu?: boolean;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default function NoteLink({ className, notePath, showNotePath, showNoteIcon, style, noPreview, noTnLink, highlightedTokens, title, viewScope, noContextMenu }: NoteLinkOpts) {
 | 
			
		||||
export default function NoteLink({ className, notePath, showNotePath, showNoteIcon, style, noPreview, noTnLink, highlightedTokens }: NoteLinkOpts) {
 | 
			
		||||
    const stringifiedNotePath = Array.isArray(notePath) ? notePath.join("/") : notePath;
 | 
			
		||||
    const ref = useRef<HTMLSpanElement>(null);
 | 
			
		||||
    const [ jqueryEl, setJqueryEl ] = useState<JQuery<HTMLElement>>();
 | 
			
		||||
    const highlightSearch = useImperativeSearchHighlighlighting(highlightedTokens);
 | 
			
		||||
 | 
			
		||||
    useEffect(() => {
 | 
			
		||||
        link.createLink(stringifiedNotePath, {
 | 
			
		||||
            title,
 | 
			
		||||
            showNotePath,
 | 
			
		||||
            showNoteIcon,
 | 
			
		||||
            viewScope
 | 
			
		||||
        }).then(setJqueryEl);
 | 
			
		||||
    }, [ stringifiedNotePath, showNotePath, title, viewScope ]);
 | 
			
		||||
        link.createLink(stringifiedNotePath, { showNotePath, showNoteIcon })
 | 
			
		||||
            .then(setJqueryEl);
 | 
			
		||||
    }, [ stringifiedNotePath, showNotePath ]);
 | 
			
		||||
 | 
			
		||||
    useEffect(() => {
 | 
			
		||||
        if (!ref.current || !jqueryEl) return;
 | 
			
		||||
@@ -51,10 +43,6 @@ export default function NoteLink({ className, notePath, showNotePath, showNoteIc
 | 
			
		||||
        $linkEl?.addClass("tn-link");
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (noContextMenu) {
 | 
			
		||||
        $linkEl?.attr("data-no-context-menu", "true");
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (className) {
 | 
			
		||||
        $linkEl?.addClass(className);
 | 
			
		||||
    }
 | 
			
		||||
 
 | 
			
		||||
@@ -1,20 +0,0 @@
 | 
			
		||||
interface SliderProps {
 | 
			
		||||
    value: number;
 | 
			
		||||
    onChange(newValue: number);
 | 
			
		||||
    min?: number;
 | 
			
		||||
    max?: number;
 | 
			
		||||
    title?: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default function Slider({ onChange, ...restProps }: SliderProps) {
 | 
			
		||||
    return (
 | 
			
		||||
        <input
 | 
			
		||||
            type="range"
 | 
			
		||||
            className="slider"
 | 
			
		||||
            onChange={(e) => {
 | 
			
		||||
                onChange(e.currentTarget.valueAsNumber);
 | 
			
		||||
            }}
 | 
			
		||||
            {...restProps}
 | 
			
		||||
        />
 | 
			
		||||
    );
 | 
			
		||||
}
 | 
			
		||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user