Compare commits

..

1 Commits

Author SHA1 Message Date
perf3ct
ba1c6ba0e1 feat(editor): try to have ckeditor not crash when handling stranger tags 2025-09-01 20:39:02 -07:00
33 changed files with 931 additions and 1686 deletions

View File

@@ -1,453 +0,0 @@
#!/usr/bin/env tsx
import * as fs from 'fs/promises';
import * as path from 'path';
import { exec } from 'child_process';
import { promisify } from 'util';
import { Dirent } from 'fs';
const execAsync = promisify(exec);
// Configuration
const FILE_EXTENSIONS = ['.md', '.png', '.jpg', '.jpeg', '.gif', '.svg'] as const;
const README_PATTERN = /^README(?:[-.](.+))?\.md$/;
interface SyncConfig {
mainRepoPath: string;
wikiPath: string;
docsPath: string;
}
/**
* Convert markdown to GitHub Wiki format
* - Images: ![](image.png) → [[image.png]]
* - Links: [text](page.md) → [[text|page]]
*/
async function convertToWikiFormat(wikiDir: string): Promise<void> {
console.log('Converting to GitHub Wiki format...');
const mdFiles = await findFiles(wikiDir, ['.md']);
let convertedCount = 0;
for (const file of mdFiles) {
let content = await fs.readFile(file, 'utf-8');
const originalContent = content;
// Convert image references to wiki format
// ![alt](image.png) → [[image.png]]
content = content.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (match, alt, src) => {
// Skip external URLs
if (src.startsWith('http://') || src.startsWith('https://')) {
return match;
}
// Decode URL encoding
let imagePath = src;
if (src.includes('%')) {
try {
imagePath = decodeURIComponent(src);
} catch {
imagePath = src;
}
}
// Extract just the filename for wiki syntax
const filename = path.basename(imagePath);
// Use wiki syntax for images
// If alt text exists, add it after pipe
if (alt && alt.trim()) {
return `[[${filename}|alt=${alt}]]`;
} else {
return `[[${filename}]]`;
}
});
// Convert internal markdown links to wiki format
// [text](../path/to/Page.md) → [[text|Page]]
content = content.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, text, href) => {
// Skip external URLs, anchors, and images
if (href.startsWith('http://') ||
href.startsWith('https://') ||
href.startsWith('#') ||
href.match(/\.(png|jpg|jpeg|gif|svg)$/i)) {
return match;
}
// Check if it's a markdown file link
if (href.endsWith('.md') || href.includes('.md#')) {
// Decode URL encoding
let decodedHref = href;
if (href.includes('%')) {
try {
decodedHref = decodeURIComponent(href);
} catch {
decodedHref = href;
}
}
// Extract page name without extension and path
let pageName = decodedHref
.replace(/\.md(#.*)?$/, '') // Remove .md and anchor
.split('/') // Split by path
.pop() || ''; // Get last part (filename)
// Convert spaces to hyphens (GitHub wiki convention)
pageName = pageName.replace(/ /g, '-');
// Use wiki link syntax
if (text === pageName || text === pageName.replace(/-/g, ' ')) {
return `[[${pageName}]]`;
} else {
return `[[${text}|${pageName}]]`;
}
}
// For other internal links, just decode URL encoding
if (href.includes('%') && !href.startsWith('http')) {
try {
const decodedHref = decodeURIComponent(href);
return `[${text}](${decodedHref})`;
} catch {
return match;
}
}
return match;
});
// Save if modified
if (content !== originalContent) {
await fs.writeFile(file, content, 'utf-8');
const relativePath = path.relative(wikiDir, file);
console.log(` Converted: ${relativePath}`);
convertedCount++;
}
}
if (convertedCount > 0) {
console.log(`Converted ${convertedCount} files to wiki format`);
} else {
console.log('No files needed conversion');
}
}
/**
* Recursively find all files matching the given extensions
*/
async function findFiles(dir: string, extensions: readonly string[]): Promise<string[]> {
const files: string[] = [];
async function walk(currentDir: string): Promise<void> {
const entries: Dirent[] = await fs.readdir(currentDir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(currentDir, entry.name);
if (entry.isDirectory()) {
await walk(fullPath);
} else if (entry.isFile()) {
const ext = path.extname(entry.name).toLowerCase();
if (extensions.includes(ext)) {
files.push(fullPath);
}
}
}
}
await walk(dir);
return files;
}
/**
* Get all files in a directory recursively
*/
async function getAllFiles(dir: string): Promise<Set<string>> {
const files = new Set<string>();
async function walk(currentDir: string): Promise<void> {
try {
const entries = await fs.readdir(currentDir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(currentDir, entry.name);
const relativePath = path.relative(dir, fullPath);
// Skip .git directory
if (entry.name === '.git' || relativePath.startsWith('.git')) continue;
if (entry.isDirectory()) {
await walk(fullPath);
} else if (entry.isFile()) {
files.add(relativePath);
}
}
} catch (error) {
// Directory might not exist yet
if ((error as any).code !== 'ENOENT') {
throw error;
}
}
}
await walk(dir);
return files;
}
/**
* Flatten directory structure - move all files to root
* GitHub Wiki prefers flat structure
*/
async function flattenStructure(wikiDir: string): Promise<void> {
console.log('Flattening directory structure for wiki...');
const allFiles = await getAllFiles(wikiDir);
let movedCount = 0;
for (const file of allFiles) {
// Skip if already at root
if (!file.includes('/')) continue;
const oldPath = path.join(wikiDir, file);
const basename = path.basename(file);
// Create unique name if file already exists at root
let newName = basename;
let counter = 1;
while (await fileExists(path.join(wikiDir, newName))) {
const ext = path.extname(basename);
const nameWithoutExt = basename.slice(0, -ext.length);
newName = `${nameWithoutExt}-${counter}${ext}`;
counter++;
}
const newPath = path.join(wikiDir, newName);
// Move file to root
await fs.rename(oldPath, newPath);
console.log(` Moved: ${file}${newName}`);
movedCount++;
}
if (movedCount > 0) {
console.log(`Moved ${movedCount} files to root`);
// Clean up empty directories
await cleanEmptyDirectories(wikiDir);
}
}
async function fileExists(path: string): Promise<boolean> {
try {
await fs.access(path);
return true;
} catch {
return false;
}
}
/**
* Remove empty directories recursively
*/
async function cleanEmptyDirectories(dir: string): Promise<void> {
const allDirs = await getAllDirectories(dir);
for (const subDir of allDirs) {
try {
const entries = await fs.readdir(subDir);
if (entries.length === 0 || (entries.length === 1 && entries[0] === '.git')) {
await fs.rmdir(subDir);
}
} catch {
// Ignore errors
}
}
}
/**
* Get all directories recursively
*/
async function getAllDirectories(dir: string): Promise<string[]> {
const dirs: string[] = [];
async function walk(currentDir: string): Promise<void> {
try {
const entries = await fs.readdir(currentDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory() && entry.name !== '.git') {
const fullPath = path.join(currentDir, entry.name);
dirs.push(fullPath);
await walk(fullPath);
}
}
} catch {
// Ignore errors
}
}
await walk(dir);
return dirs.sort((a, b) => b.length - a.length); // Sort longest first for cleanup
}
/**
* Sync files from source to wiki
*/
async function syncFiles(sourceDir: string, wikiDir: string): Promise<void> {
console.log('Syncing files to wiki...');
// Get all valid source files
const sourceFiles = await findFiles(sourceDir, FILE_EXTENSIONS);
const sourceRelativePaths = new Set<string>();
// Copy all source files
console.log(`Found ${sourceFiles.length} files to sync`);
for (const file of sourceFiles) {
const relativePath = path.relative(sourceDir, file);
sourceRelativePaths.add(relativePath);
const targetPath = path.join(wikiDir, relativePath);
const targetDir = path.dirname(targetPath);
// Create directory structure
await fs.mkdir(targetDir, { recursive: true });
// Copy file
await fs.copyFile(file, targetPath);
}
// Remove orphaned files
const wikiFiles = await getAllFiles(wikiDir);
for (const wikiFile of wikiFiles) {
if (!sourceRelativePaths.has(wikiFile) && !wikiFile.startsWith('Home')) {
const fullPath = path.join(wikiDir, wikiFile);
await fs.unlink(fullPath);
}
}
}
/**
* Copy root README.md to wiki as Home.md if it exists
*/
async function copyRootReadme(mainRepoPath: string, wikiPath: string): Promise<void> {
const rootReadmePath = path.join(mainRepoPath, 'README.md');
const wikiHomePath = path.join(wikiPath, 'Home.md');
try {
await fs.access(rootReadmePath);
await fs.copyFile(rootReadmePath, wikiHomePath);
console.log(' Copied root README.md as Home.md');
} catch (error) {
console.log(' No root README.md found to use as Home page');
}
}
/**
* Rename README files to wiki-compatible names
*/
async function renameReadmeFiles(wikiDir: string): Promise<void> {
console.log('Converting README files for wiki compatibility...');
const files = await fs.readdir(wikiDir);
for (const file of files) {
const match = file.match(README_PATTERN);
if (match) {
const oldPath = path.join(wikiDir, file);
let newName: string;
if (match[1]) {
// Language-specific README
newName = `Home-${match[1]}.md`;
} else {
// Main README
newName = 'Home.md';
}
const newPath = path.join(wikiDir, newName);
await fs.rename(oldPath, newPath);
console.log(` Renamed: ${file}${newName}`);
}
}
}
/**
* Check if there are any changes in the wiki
*/
async function hasChanges(wikiDir: string): Promise<boolean> {
try {
const { stdout } = await execAsync('git status --porcelain', { cwd: wikiDir });
return stdout.trim().length > 0;
} catch (error) {
console.error('Error checking git status:', error);
return false;
}
}
/**
* Get configuration from environment variables
*/
function getConfig(): SyncConfig {
const mainRepoPath = process.env.MAIN_REPO_PATH || 'main-repo';
const wikiPath = process.env.WIKI_PATH || 'wiki';
const docsPath = path.join(mainRepoPath, 'docs');
return { mainRepoPath, wikiPath, docsPath };
}
/**
* Main sync function
*/
async function syncDocsToWiki(): Promise<void> {
const config = getConfig();
const flattenWiki = process.env.FLATTEN_WIKI === 'true';
console.log('Starting documentation sync to wiki...');
console.log(`Source: ${config.docsPath}`);
console.log(`Target: ${config.wikiPath}`);
console.log(`Flatten structure: ${flattenWiki}`);
try {
// Verify paths exist
await fs.access(config.docsPath);
await fs.access(config.wikiPath);
// Sync files
await syncFiles(config.docsPath, config.wikiPath);
// Copy root README.md as Home.md
await copyRootReadme(config.mainRepoPath, config.wikiPath);
// Convert to wiki format
await convertToWikiFormat(config.wikiPath);
// Optionally flatten directory structure
if (flattenWiki) {
await flattenStructure(config.wikiPath);
}
// Rename README files to wiki-compatible names
await renameReadmeFiles(config.wikiPath);
// Check for changes
const changed = await hasChanges(config.wikiPath);
if (changed) {
console.log('\nChanges detected in wiki');
process.stdout.write('::set-output name=changes::true\n');
} else {
console.log('\nNo changes detected in wiki');
process.stdout.write('::set-output name=changes::false\n');
}
console.log('Sync completed successfully!');
} catch (error) {
console.error('Error during sync:', error);
process.exit(1);
}
}
// Run if called directly
if (require.main === module) {
syncDocsToWiki();
}
export { syncDocsToWiki };

View File

@@ -1,437 +0,0 @@
#!/usr/bin/env tsx
import * as fs from 'fs/promises';
import * as path from 'path';
import { exec } from 'child_process';
import { promisify } from 'util';
import { Dirent } from 'fs';
const execAsync = promisify(exec);
// Configuration
const FILE_EXTENSIONS = ['.md', '.png', '.jpg', '.jpeg', '.gif', '.svg'] as const;
const README_PATTERN = /^README(?:[-.](.+))?\.md$/;
interface SyncConfig {
mainRepoPath: string;
wikiPath: string;
docsPath: string;
}
/**
* Recursively find all files matching the given extensions
*/
async function findFiles(dir: string, extensions: readonly string[]): Promise<string[]> {
const files: string[] = [];
async function walk(currentDir: string): Promise<void> {
const entries: Dirent[] = await fs.readdir(currentDir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(currentDir, entry.name);
if (entry.isDirectory()) {
await walk(fullPath);
} else if (entry.isFile()) {
const ext = path.extname(entry.name).toLowerCase();
if (extensions.includes(ext)) {
files.push(fullPath);
}
}
}
}
await walk(dir);
return files;
}
/**
* Get all files in a directory recursively
*/
async function getAllFiles(dir: string): Promise<Set<string>> {
const files = new Set<string>();
async function walk(currentDir: string): Promise<void> {
try {
const entries = await fs.readdir(currentDir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(currentDir, entry.name);
const relativePath = path.relative(dir, fullPath);
// Skip .git directory
if (entry.name === '.git' || relativePath.startsWith('.git')) continue;
if (entry.isDirectory()) {
await walk(fullPath);
} else if (entry.isFile()) {
files.add(relativePath);
}
}
} catch (error) {
// Directory might not exist yet
if ((error as any).code !== 'ENOENT') {
throw error;
}
}
}
await walk(dir);
return files;
}
/**
* Sync files from source to wiki, preserving directory structure and removing orphaned files
*/
async function syncFiles(sourceDir: string, wikiDir: string): Promise<void> {
console.log('Analyzing files to sync...');
// Get all valid source files
const sourceFiles = await findFiles(sourceDir, FILE_EXTENSIONS);
const sourceRelativePaths = new Set<string>();
// Copy all source files and track their paths
console.log(`Found ${sourceFiles.length} files to sync`);
let copiedCount = 0;
let skippedCount = 0;
for (const file of sourceFiles) {
const relativePath = path.relative(sourceDir, file);
sourceRelativePaths.add(relativePath);
const targetPath = path.join(wikiDir, relativePath);
const targetDir = path.dirname(targetPath);
// Create directory structure
await fs.mkdir(targetDir, { recursive: true });
// Check if file needs updating (compare modification times)
let needsCopy = true;
try {
const sourceStat = await fs.stat(file);
const targetStat = await fs.stat(targetPath);
// Only copy if source is newer or sizes differ
needsCopy = sourceStat.mtime > targetStat.mtime || sourceStat.size !== targetStat.size;
} catch {
// Target doesn't exist, needs copy
needsCopy = true;
}
if (needsCopy) {
await fs.copyFile(file, targetPath);
console.log(` Updated: ${relativePath}`);
copiedCount++;
} else {
skippedCount++;
}
}
console.log(`Updated ${copiedCount} files, ${skippedCount} unchanged`);
// Find and remove files that don't exist in source
console.log('Checking for orphaned files in wiki...');
const wikiFiles = await getAllFiles(wikiDir);
let removedCount = 0;
for (const wikiFile of wikiFiles) {
// Check if this file should exist (either as-is or will be renamed)
let shouldExist = sourceRelativePaths.has(wikiFile);
// Special handling for Home files that will be created from READMEs
if (wikiFile.startsWith('Home')) {
const readmeVariant1 = wikiFile.replace(/^Home(-.*)?\.md$/, 'README$1.md');
const readmeVariant2 = wikiFile.replace(/^Home-(.+)\.md$/, 'README.$1.md');
shouldExist = sourceRelativePaths.has(readmeVariant1) || sourceRelativePaths.has(readmeVariant2) || sourceRelativePaths.has('README.md');
}
if (!shouldExist) {
const fullPath = path.join(wikiDir, wikiFile);
await fs.unlink(fullPath);
console.log(` Removed: ${wikiFile}`);
removedCount++;
}
}
if (removedCount > 0) {
console.log(`Removed ${removedCount} orphaned files`);
// Clean up empty directories
await cleanEmptyDirectories(wikiDir);
}
}
/**
* Remove empty directories recursively
*/
async function cleanEmptyDirectories(dir: string): Promise<void> {
async function removeEmptyDirs(currentDir: string): Promise<boolean> {
if (currentDir === dir) return false; // Don't remove root
try {
const entries = await fs.readdir(currentDir, { withFileTypes: true });
// Skip .git directory
const filteredEntries = entries.filter(e => e.name !== '.git');
if (filteredEntries.length === 0) {
await fs.rmdir(currentDir);
return true;
}
// Check subdirectories
for (const entry of filteredEntries) {
if (entry.isDirectory()) {
const subDir = path.join(currentDir, entry.name);
await removeEmptyDirs(subDir);
}
}
// Check again after cleaning subdirectories
const remainingEntries = await fs.readdir(currentDir);
const filteredRemaining = remainingEntries.filter(e => e !== '.git');
if (filteredRemaining.length === 0 && currentDir !== dir) {
await fs.rmdir(currentDir);
return true;
}
return false;
} catch (error) {
return false;
}
}
// Get all directories and process them
const allDirs = await getAllDirectories(dir);
for (const subDir of allDirs) {
await removeEmptyDirs(subDir);
}
}
/**
* Get all directories recursively
*/
async function getAllDirectories(dir: string): Promise<string[]> {
const dirs: string[] = [];
async function walk(currentDir: string): Promise<void> {
try {
const entries = await fs.readdir(currentDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory() && entry.name !== '.git') {
const fullPath = path.join(currentDir, entry.name);
dirs.push(fullPath);
await walk(fullPath);
}
}
} catch {
// Ignore errors
}
}
await walk(dir);
return dirs.sort((a, b) => b.length - a.length); // Sort longest first for cleanup
}
/**
* Fix references in markdown files for wiki compatibility
*
* Issues fixed:
* 1. URL-encoded image references (spaces as %20) need to match actual filenames
* 2. Internal markdown links need to be converted to wiki syntax
* 3. Images can optionally use wiki syntax [[image.png]] for better compatibility
*/
async function fixImageReferences(wikiDir: string): Promise<void> {
console.log('Fixing references for GitHub Wiki compatibility...');
const mdFiles = await findFiles(wikiDir, ['.md']);
let fixedCount = 0;
for (const file of mdFiles) {
let content = await fs.readFile(file, 'utf-8');
let modified = false;
const originalContent = content;
// Step 1: Fix URL-encoded image references
// Convert ![](Name%20With%20Spaces.png) to ![](Name With Spaces.png)
content = content.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (match, alt, src) => {
// Skip external URLs
if (src.startsWith('http://') || src.startsWith('https://')) {
return match;
}
// Decode URL encoding if present
if (src.includes('%')) {
try {
const decodedSrc = decodeURIComponent(src);
return `![${alt}](${decodedSrc})`;
} catch {
return match;
}
}
return match;
});
// Step 2: Fix internal links - decode URL encoding but keep standard markdown format
// GitHub Wiki actually supports standard markdown links with relative paths
content = content.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, text, href) => {
// Skip external URLs, anchors, and images
if (href.startsWith('http://') ||
href.startsWith('https://') ||
href.startsWith('#') ||
href.match(/\.(png|jpg|jpeg|gif|svg)$/i)) {
return match;
}
// Decode URL encoding for all internal links
if (href.includes('%')) {
try {
const decodedHref = decodeURIComponent(href);
return `[${text}](${decodedHref})`;
} catch {
return match;
}
}
return match;
});
// Check if content was modified
if (content !== originalContent) {
modified = true;
await fs.writeFile(file, content, 'utf-8');
const relativePath = path.relative(wikiDir, file);
console.log(` Fixed references in: ${relativePath}`);
fixedCount++;
}
}
if (fixedCount > 0) {
console.log(`Fixed references in ${fixedCount} files`);
} else {
console.log('No references needed fixing');
}
}
/**
* Rename README files to wiki-compatible names
*/
async function renameReadmeFiles(wikiDir: string): Promise<void> {
console.log('Converting README files for wiki compatibility...');
const files = await fs.readdir(wikiDir);
for (const file of files) {
const match = file.match(README_PATTERN);
if (match) {
const oldPath = path.join(wikiDir, file);
let newName: string;
if (match[1]) {
// Language-specific README (e.g., README-ZH_CN.md or README.es.md)
newName = `Home-${match[1]}.md`;
} else {
// Main README
newName = 'Home.md';
}
const newPath = path.join(wikiDir, newName);
await fs.rename(oldPath, newPath);
console.log(` Renamed: ${file}${newName}`);
}
}
}
/**
* Check if there are any changes in the wiki
*/
async function hasChanges(wikiDir: string): Promise<boolean> {
try {
const { stdout } = await execAsync('git status --porcelain', { cwd: wikiDir });
return stdout.trim().length > 0;
} catch (error) {
console.error('Error checking git status:', error);
return false;
}
}
/**
* Copy root README.md to wiki as Home.md if it exists
*/
async function copyRootReadme(mainRepoPath: string, wikiPath: string): Promise<void> {
const rootReadmePath = path.join(mainRepoPath, 'README.md');
const wikiHomePath = path.join(wikiPath, 'Home.md');
try {
await fs.access(rootReadmePath);
await fs.copyFile(rootReadmePath, wikiHomePath);
console.log(' Copied root README.md as Home.md');
} catch (error) {
// Root README doesn't exist or can't be accessed
console.log(' No root README.md found to use as Home page');
}
}
/**
* Get configuration from environment variables
*/
function getConfig(): SyncConfig {
const mainRepoPath = process.env.MAIN_REPO_PATH || 'main-repo';
const wikiPath = process.env.WIKI_PATH || 'wiki';
const docsPath = path.join(mainRepoPath, 'docs');
return { mainRepoPath, wikiPath, docsPath };
}
/**
* Main sync function
*/
async function syncDocsToWiki(): Promise<void> {
const config = getConfig();
console.log('Starting documentation sync to wiki...');
console.log(`Source: ${config.docsPath}`);
console.log(`Target: ${config.wikiPath}`);
try {
// Verify paths exist
await fs.access(config.docsPath);
await fs.access(config.wikiPath);
// Sync files (copy new/updated, remove orphaned)
await syncFiles(config.docsPath, config.wikiPath);
// Copy root README.md as Home.md
await copyRootReadme(config.mainRepoPath, config.wikiPath);
// Fix image and link references for wiki compatibility
await fixImageReferences(config.wikiPath);
// Rename README files to wiki-compatible names
await renameReadmeFiles(config.wikiPath);
// Check for changes
const changed = await hasChanges(config.wikiPath);
if (changed) {
console.log('\nChanges detected in wiki');
// GitHub Actions output format
process.stdout.write('::set-output name=changes::true\n');
} else {
console.log('\nNo changes detected in wiki');
process.stdout.write('::set-output name=changes::false\n');
}
console.log('Sync completed successfully!');
} catch (error) {
console.error('Error during sync:', error);
process.exit(1);
}
}
// Run if called directly
if (require.main === module) {
syncDocsToWiki();
}
export { syncDocsToWiki };

View File

@@ -1,67 +0,0 @@
name: Sync Docs to Wiki
on:
push:
branches:
- main
paths:
- 'docs/**'
workflow_dispatch: # Allow manual triggering
permissions:
contents: read # Read access to repository contents
# Note: Writing to wiki requires a PAT or GITHUB_TOKEN with additional permissions
# The default GITHUB_TOKEN cannot write to wikis, so we need to:
# 1. Create a Personal Access Token (PAT) with 'repo' scope
# 2. Add it as a repository secret named WIKI_TOKEN
jobs:
sync-wiki:
runs-on: ubuntu-latest
steps:
- name: Checkout main repository
uses: actions/checkout@v4
with:
path: main-repo
- name: Checkout wiki repository
uses: actions/checkout@v4
with:
repository: TriliumNext/Trilium.wiki
path: wiki
token: ${{ secrets.WIKI_TOKEN }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install tsx for TypeScript execution
run: npm install -g tsx
- name: Setup Git
run: |
git config --global user.email "action@github.com"
git config --global user.name "GitHub Action"
- name: Sync documentation to wiki
id: sync
run: |
tsx main-repo/.github/scripts/sync-docs-to-wiki.ts
env:
MAIN_REPO_PATH: main-repo
WIKI_PATH: wiki
- name: Commit and push changes
if: contains(steps.sync.outputs.changes, 'true')
run: |
cd wiki
git add .
git commit -m "Sync documentation from main repository
Source commit: ${{ github.sha }}
Triggered by: ${{ github.event.head_commit.message }}"
git push
env:
GITHUB_TOKEN: ${{ secrets.WIKI_TOKEN }}

2
.nvmrc
View File

@@ -1 +1 @@
22.19.0
22.18.0

View File

@@ -36,7 +36,7 @@
},
"devDependencies": {
"@playwright/test": "1.55.0",
"@stylistic/eslint-plugin": "5.3.1",
"@stylistic/eslint-plugin": "5.2.3",
"@types/express": "5.0.3",
"@types/node": "22.18.0",
"@types/yargs": "17.0.33",
@@ -49,7 +49,7 @@
"rcedit": "4.0.1",
"rimraf": "6.0.1",
"tslib": "2.8.1",
"typedoc": "0.28.12",
"typedoc": "0.28.11",
"typedoc-plugin-missing-exports": "4.1.0"
},
"optionalDependencies": {

View File

@@ -30,7 +30,7 @@
"autocomplete.js": "0.38.1",
"bootstrap": "5.3.8",
"boxicons": "2.1.4",
"dayjs": "1.11.18",
"dayjs": "1.11.14",
"dayjs-plugin-utc": "0.1.2",
"debounce": "2.2.0",
"draggabilly": "3.0.0",
@@ -52,7 +52,7 @@
"normalize.css": "8.0.1",
"panzoom": "9.4.3",
"preact": "10.27.1",
"react-i18next": "15.7.3",
"react-i18next": "15.7.2",
"split.js": "1.6.5",
"svg-pan-zoom": "3.6.2",
"tabulator-tables": "6.3.1",

View File

@@ -1463,7 +1463,7 @@ body:not(.mobile) #launcher-pane.horizontal .dropdown-submenu > .dropdown-menu {
cursor: pointer;
border: none;
color: var(--launcher-pane-text-color);
background: transparent;
background-color: var(--launcher-pane-background-color);
flex-shrink: 0;
}
@@ -2366,12 +2366,3 @@ footer.webview-footer button {
content: "\ec24";
transform: rotate(180deg);
}
/* CK Edito */
/* Insert text snippet: limit the width of the listed items to avoid overly long names */
:root body.desktop div.ck-template-form li.ck-list__item .ck-template-form__text-part > span {
max-width: 25vw;
overflow: hidden;
text-overflow: ellipsis;
}

View File

@@ -18,7 +18,7 @@
--main-text-color: #ccc;
--main-border-color: #454545;
--subtle-border-color: #313131;
--dropdown-border-color: #404040;
--dropdown-border-color: #292929;
--dropdown-shadow-opacity: 0.6;
--dropdown-item-icon-destructive-color: #de6e5b;
--disabled-tooltip-icon-color: #7fd2ef;

View File

@@ -115,7 +115,7 @@
--quick-search-focus-border: #00000029;
--quick-search-focus-background: #ffffff80;
--quick-search-focus-color: #000;
--quick-search-result-content-background: #0000000f;
--quick-search-result-content-background: #00000017;
--quick-search-result-highlight-color: #c65050;
--left-pane-collapsed-border-color: #0000000d;

View File

@@ -329,8 +329,6 @@ body.mobile .dropdown-menu .dropdown-item.submenu-open .dropdown-toggle::after {
#toast-container .toast .toast-body {
flex-grow: 1;
overflow: hidden;
text-overflow: ellipsis;
}
/*

View File

@@ -5,8 +5,7 @@
button.btn.btn-primary,
button.btn.btn-secondary,
button.btn.btn-sm:not(.select-button),
button.btn.btn-success,
button.ck.ck-button:is(.ck-button-action, .ck-button-save, .ck-button-cancel, .ck-button-replaceall, .ck-button-replace).ck-button_with-text {
button.btn.btn-success {
display: inline-flex;
align-items: center;
justify-content: center;
@@ -22,8 +21,7 @@ button.ck.ck-button:is(.ck-button-action, .ck-button-save, .ck-button-cancel, .c
button.btn.btn-primary:hover,
button.btn.btn-secondary:hover,
button.btn.btn-sm:not(.select-button):hover,
button.btn.btn-success:hover,
button.ck.ck-button:is(.ck-button-action, .ck-button-save, .ck-button-cancel, .ck-button-replaceall, .ck-button-replace).ck-button_with-text:not(.ck-disabled):hover {
button.btn.btn-success:hover {
background: var(--cmd-button-hover-background-color);
color: var(--cmd-button-hover-text-color);
}
@@ -31,8 +29,7 @@ button.ck.ck-button:is(.ck-button-action, .ck-button-save, .ck-button-cancel, .c
button.btn.btn-primary:active,
button.btn.btn-secondary:active,
button.btn.btn-sm:not(.select-button):active,
button.btn.btn-success:active,
button.ck.ck-button:is(.ck-button-action, .ck-button-save, .ck-button-cancel, .ck-button-replaceall, .ck-button-replace).ck-button_with-text:not(.ck-disabled):active {
button.btn.btn-success:active {
opacity: 0.85;
box-shadow: unset;
background: var(--cmd-button-background-color) !important;
@@ -43,16 +40,14 @@ button.ck.ck-button:is(.ck-button-action, .ck-button-save, .ck-button-cancel, .c
button.btn.btn-primary:disabled,
button.btn.btn-secondary:disabled,
button.btn.btn-sm:not(.select-button):disabled,
button.btn.btn-success:disabled,
button.ck.ck-button:is(.ck-button-action, .ck-button-save, .ck-button-cancel, .ck-button-replaceall, .ck-button-replace).ck-button_with-text.ck-disabled {
button.btn.btn-success:disabled {
opacity: var(--cmd-button-disabled-opacity);
}
button.btn.btn-primary:focus-visible,
button.btn.btn-secondary:focus-visible,
button.btn.btn-sm:not(.select-button):focus-visible,
button.btn.btn-success:focus-visible,
button.ck.ck-button:is(.ck-button-action, .ck-button-save, .ck-button-cancel, .ck-button-replaceall, .ck-button-replace).ck-button_with-text:not(.ck-disabled):focus-visible {
button.btn.btn-success:focus-visible {
outline: 2px solid var(--input-focus-outline-color);
}
@@ -154,11 +149,8 @@ input[type="password"],
input[type="date"],
input[type="time"],
input[type="datetime-local"],
:root input.ck.ck-input-text,
:root input.ck.ck-input-number,
textarea.form-control,
textarea,
:root textarea.ck.ck-textarea,
.tn-input-field {
outline: 3px solid transparent;
outline-offset: 6px;
@@ -175,11 +167,8 @@ input[type="password"]:hover,
input[type="date"]:hover,
input[type="time"]:hover,
input[type="datetime-local"]:hover,
:root input.ck.ck-input-text:not([readonly="true"]):hover,
:root input.ck.ck-input-number:not([readonly="true"]):hover,
textarea.form-control:hover,
textarea:hover,
:root textarea.ck.ck-textarea:hover,
.tn-input-field:hover {
background: var(--input-hover-background);
color: var(--input-hover-color);
@@ -192,11 +181,8 @@ input[type="password"]:focus,
input[type="date"]:focus,
input[type="time"]:focus,
input[type="datetime-local"]:focus,
:root input.ck.ck-input-text:focus,
:root input.ck.ck-input-number:focus,
textarea.form-control:focus,
textarea:focus,
:root textarea.ck.ck-textarea:focus,
.tn-input-field:focus,
.tn-input-field:focus-within {
box-shadow: unset;

View File

@@ -4,7 +4,6 @@
:root {
--ck-font-face: var(--main-font-family);
--ck-input-label-height: 1.5em;
}
/*
@@ -308,11 +307,6 @@
fill: black !important;
}
/* Hex color input box prefix */
:root .ck.ck-color-selector .ck-color-picker__hash-view {
margin-top: var(--ck-input-label-height);
}
/* Numbered list */
:root .ck.ck-list-properties_with-numbered-properties .ck.ck-list-styles-list {
@@ -369,86 +363,19 @@
color: var(--accent);
}
/* Text snippet dropdown */
/* Action buttons */
div.ck-template-form {
padding: 8px;
:root .ck-link-actions button.ck-button,
:root .ck-link-form button.ck-button {
--ck-border-radius: 6px;
background: transparent;
box-shadow: unset;
}
div.ck-template-form .ck-labeled-field-view {
margin-bottom: 8px;
}
/* Template item */
:root div.ck-template-form li.ck-list__item button.ck-template-button {
padding: 4px 8px;
}
/* Template icon */
:root .ck-template-form .ck-button__icon {
--ck-spacing-medium: 2px;
}
:root div.ck-template-form .note-icon {
color: var(--menu-item-icon-color);
}
/* Template name */
div.ck-template-form .ck-template-form__text-part {
color: var(--hover-item-text-color);
font-size: .9rem;
}
div.ck-template-form .ck-template-form__text-part mark {
background: unset;
color: var(--quick-search-result-highlight-color);
font-weight: bold;
}
/* Template description */
:root div.ck-template-form .ck-template-form__description {
opacity: .5;
font-size: .9em;
}
/* Messages */
div.ck-template-form .ck-search__info > span {
line-height: initial;
color: var(--muted-text-color);
}
div.ck-template-form .ck-search__info span:nth-child(2) {
display: block;
opacity: .5;
margin-top: 8px;
font-size: .9em;
}
/* Link dropdown */
:root .ck.ck-form.ck-link-form ul.ck-link-form__providers-list {
border-top: none;
}
/* Math popup */
.ck-math-form .ck-labeled-field-view {
--ck-input-label-height: 0;
margin-inline-end: 8px;
}
/* Emoji dropdown */
.ck-emoji-picker-form .ck-emoji__search .ck-button_with-text:not(.ck-list-item-button) {
margin-top: var(--ck-input-label-height);
}
/* Find and replace dialog */
.ck-find-and-replace-form .ck-find-and-replace-form__inputs button {
margin-top: var(--ck-input-label-height);
:root .ck-link-actions button.ck-button:hover,
:root .ck-link-form button.ck-button:hover {
background: var(--hover-item-background-color);
}
/* Mention list (the autocompletion list for emojis, labels and relations) */
@@ -465,58 +392,6 @@ div.ck-template-form .ck-search__info span:nth-child(2) {
background: transparent;
}
/*
* FORMS
*/
/*
* Buttons
*/
button.ck.ck-button:is(.ck-button-action, .ck-button-save, .ck-button-cancel).ck-button_with-text {
--ck-color-text: var(--cmd-button-text-color);
min-width: 60px;
font-weight: 500;
}
/*
* Text boxes
*/
.ck.ck-labeled-field-view {
padding-top: var(--ck-input-label-height) !important; /* Create space for the label */
}
.ck.ck-labeled-field-view > .ck.ck-labeled-field-view__input-wrapper > label.ck.ck-label {
/* Move the label above the text box regardless of the text box state */
transform: translate(0, calc(-.2em - var(--ck-input-label-height))) !important;
padding-left: 0 !important;
background: transparent;
font-size: .85em;
font-weight: 600;
}
:root input.ck.ck-input-text[readonly="true"] {
cursor: not-allowed;
background: var(--input-background-color);
}
/* Forms */
:root .ck.ck-form__row.ck-form__row_with-submit > :not(:first-child) {
margin-inline-start: 16px;
}
.ck.ck-form__row_with-submit button {
margin-top: var(--ck-input-label-height);
}
.ck.ck-form__header {
border-bottom: none;
}
/*
* EDITOR'S CONTENT
*/

View File

@@ -59,7 +59,8 @@ body.background-effects.platform-win32.layout-vertical {
}
body.background-effects.platform-win32,
body.background-effects.platform-win32 #root-widget {
body.background-effects.platform-win32 #root-widget,
body.background-effects.platform-win32 #launcher-pane .launcher-button {
background: transparent !important;
}
@@ -574,20 +575,31 @@ div.quick-search .search-button.show {
* Quick search results
*/
div.quick-search .dropdown-menu {
--quick-search-item-delimiter-color: transparent;
--menu-item-icon-vert-offset: -.065em;
}
/* Item */
.quick-search .dropdown-menu *.dropdown-item {
padding: 8px 12px !important;
}
/* Note icon */
.quick-search .dropdown-menu .dropdown-item > .bx {
position: relative;
top: 1px;
}
.quick-search .quick-search-item-icon {
vertical-align: text-bottom;
}
/* Note title */
.quick-search .dropdown-menu .dropdown-item > a {
color: var(--menu-text-color);
}
.quick-search .dropdown-menu .dropdown-item > a:hover {
--hover-item-background-color: transparent;
text-decoration: underline;
}
/* Note path */
.quick-search .dropdown-menu small {
display: block;
@@ -610,8 +622,9 @@ div.quick-search .dropdown-menu {
font-weight: 600;
}
.quick-search div.dropdown-divider {
margin: 8px 0;
/* Divider line */
.quick-search .dropdown-item::after {
display: none;
}
/*

View File

@@ -844,8 +844,7 @@
"note_type": "Тип нотатки",
"editable": "Редагув.",
"basic_properties": "Основні Властивості",
"language": "Мова",
"configure_code_notes": "Конфігурація нотатки з кодом..."
"language": "Мова"
},
"book_properties": {
"view_type": "Тип перегляду",
@@ -1587,8 +1586,7 @@
"hoist-this-note-workspace": "Закріпити цю нотатку (робочий простір)",
"refresh-saved-search-results": "Оновити збережені результати пошуку",
"create-child-note": "Створити дочірню нотатку",
"unhoist": "Відкріпити",
"toggle-sidebar": "Перемикання бічної панелі"
"unhoist": "Відкріпити"
},
"title_bar_buttons": {
"window-on-top": "Тримати вікно зверху"
@@ -1911,8 +1909,8 @@
"open-in-popup": "Швидке редагування"
},
"shared_info": {
"shared_publicly": "Ця нотатка опублікована на {{- link}}.",
"shared_locally": "Цю нотатку опубліковано локально на {{- link}}.",
"shared_publicly": "Ця нотатка опублікована на {{- link}}",
"shared_locally": "Цю нотатку опубліковано локально на {{- link}}",
"help_link": "Щоб отримати допомогу, відвідайте <a href=\"https://triliumnext.github.io/Docs/Wiki/sharing.html\">вікі</a>."
},
"note_types": {
@@ -2020,11 +2018,5 @@
},
"units": {
"percentage": "%"
},
"ui-performance": {
"title": "Продуктивність",
"enable-motion": "Увімкнути переходи та анімацію",
"enable-shadows": "Увімкнути тіні",
"enable-backdrop-effects": "Увімкнути фонові ефекти для меню, спливаючих вікон та панелей"
}
}

View File

@@ -15,15 +15,13 @@ const TPL = /*html*/`
padding: 10px 10px 10px 0px;
height: 50px;
}
.quick-search button, .quick-search input {
border: 0;
font-size: 100% !important;
}
.quick-search .dropdown-menu {
--quick-search-item-delimiter-color: var(--dropdown-border-color);
.quick-search .dropdown-menu {
max-height: 80vh;
min-width: 400px;
max-width: 720px;
@@ -40,14 +38,14 @@ const TPL = /*html*/`
position: relative;
}
.quick-search .dropdown-item + .dropdown-item::after {
.quick-search .dropdown-item:not(:last-child)::after {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 100%;
height: 1px;
border-bottom: 1px solid var(--quick-search-item-delimiter-color);
background: var(--dropdown-border-color);
}
.quick-search .dropdown-item:last-child::after {
@@ -94,8 +92,6 @@ const TPL = /*html*/`
background-color: var(--accented-background-color);
color: var(--main-text-color);
font-size: .85em;
overflow: hidden;
text-overflow: ellipsis;
}
/* Search result highlighting */
@@ -110,10 +106,6 @@ const TPL = /*html*/`
margin: 0;
}
.quick-search .bx-loader {
margin-inline-end: 4px;
}
</style>
<div class="input-group-prepend">
@@ -228,11 +220,7 @@ export default class QuickSearchWidget extends BasicWidget {
this.isLoadingMore = false;
this.$dropdownMenu.empty();
this.$dropdownMenu.append(`
<span class="dropdown-item disabled">
<span class="bx bx-loader bx-spin"></span>
${t("quick-search.searching")}
</span>`);
this.$dropdownMenu.append(`<span class="dropdown-item disabled"><span class="bx bx-loader bx-spin"></span>${t("quick-search.searching")}</span>`);
const { searchResultNoteIds, searchResults, error } = await server.get<QuickSearchResponse>(`quick-search/${encodeURIComponent(searchString)}`);

View File

@@ -1,6 +1,5 @@
import utils from "../../../services/utils.js";
import options from "../../../services/options.js";
import IconAlignCenter from "@ckeditor/ckeditor5-icons/theme/icons/align-center.svg?raw";
const TEXT_FORMATTING_GROUP = {
label: "Text formatting",
@@ -78,7 +77,7 @@ export function buildClassicToolbar(multilineToolbar: boolean) {
items: ["imageUpload", "|", "link", "bookmark", "internallink", "includeNote", "|", "specialCharacters", "emoji", "math", "mermaid", "horizontalLine", "pageBreak", "dateTime"]
},
"|",
buildAlignmentToolbar(),
"alignment",
"outdent",
"indent",
"|",
@@ -135,7 +134,7 @@ export function buildFloatingToolbar() {
items: ["link", "bookmark", "internallink", "includeNote", "|", "math", "mermaid", "horizontalLine", "pageBreak", "dateTime"]
},
"|",
buildAlignmentToolbar(),
"alignment",
"outdent",
"indent",
"|",
@@ -148,11 +147,3 @@ export function buildFloatingToolbar() {
]
};
}
function buildAlignmentToolbar() {
return {
label: "Alignment",
icon: IconAlignCenter,
items: ["alignment:left", "alignment:center", "alignment:right", "|", "alignment:justify"]
};
}

View File

@@ -0,0 +1,295 @@
import { describe, it, expect } from 'vitest';
// Mock the EditableTextTypeWidget class to test the escaping methods
class MockEditableTextTypeWidget {
private escapeGenericTypeSyntax(content: string): string {
if (!content) return content;
try {
// Count replacements for debugging
let replacementCount = 0;
// List of known HTML tags that should NOT be escaped
const htmlTags = new Set([
// Block elements
'div', 'p', 'section', 'article', 'nav', 'header', 'footer', 'aside', 'main',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'ul', 'ol', 'li', 'dl', 'dt', 'dd',
'table', 'thead', 'tbody', 'tfoot', 'tr', 'td', 'th', 'caption', 'colgroup', 'col',
'form', 'fieldset', 'legend',
'blockquote', 'pre', 'figure', 'figcaption',
'address', 'hr', 'br',
// Inline elements
'span', 'a', 'strong', 'em', 'b', 'i', 'u', 's', 'del', 'ins',
'small', 'mark', 'sub', 'sup',
'code', 'kbd', 'samp', 'var',
'q', 'cite', 'abbr', 'dfn', 'time',
'img', 'picture', 'source',
// Form elements
'input', 'textarea', 'button', 'select', 'option', 'optgroup',
'label', 'output', 'progress', 'meter',
// Media elements
'audio', 'video', 'track',
'canvas', 'svg',
// Metadata elements
'head', 'title', 'meta', 'link', 'style', 'script', 'noscript',
'base',
// Document structure
'html', 'body',
// Other common elements
'iframe', 'embed', 'object', 'param',
'details', 'summary', 'dialog',
'template', 'slot',
'area', 'map',
'ruby', 'rt', 'rp',
'bdi', 'bdo', 'wbr',
'data', 'datalist',
'keygen', 'output',
'math', 'mi', 'mo', 'mn', 'ms', 'mtext', 'mspace',
// Custom elements that Trilium uses
'includenote'
]);
// More comprehensive escaping strategy:
// We'll use a different approach - parse through the content and identify
// what looks like HTML vs what looks like generic type syntax
// First pass: Protect actual HTML tags by temporarily replacing them
const htmlProtectionMap = new Map<string, string>();
let protectionCounter = 0;
// Protect complete HTML tags (opening, closing, and self-closing)
content = content.replace(/<\/?([a-zA-Z][a-zA-Z0-9-]*)(?:\s+[^>]*)?\/?>|<!--[\s\S]*?-->/g, (match, tagName) => {
// Check if this is a comment
if (match.startsWith('<!--')) {
const placeholder = `__HTML_PROTECTED_${protectionCounter++}__`;
htmlProtectionMap.set(placeholder, match);
return placeholder;
}
// Extract just the tag name (first word after < or </)
const actualTagName = tagName?.toLowerCase();
// Only protect if it's a known HTML tag
if (actualTagName && htmlTags.has(actualTagName)) {
const placeholder = `__HTML_PROTECTED_${protectionCounter++}__`;
htmlProtectionMap.set(placeholder, match);
return placeholder;
}
// Not a known HTML tag, leave it for escaping
return match;
});
// Second pass: Now escape all remaining angle brackets that weren't protected
// These are likely generic type syntax or other non-HTML patterns
content = content.replace(/</g, () => {
replacementCount++;
return '&lt;';
});
content = content.replace(/>/g, () => {
replacementCount++;
return '&gt;';
});
// Third pass: Restore the protected HTML tags
htmlProtectionMap.forEach((originalHtml, placeholder) => {
content = content.replace(placeholder, originalHtml);
});
return content;
} catch (error) {
return content;
}
}
private unescapeGenericTypeSyntax(content: string): string {
if (!content) return content;
// Simply replace all &lt; with < and &gt; with >
// This is the correct behavior because CKEditor expects raw HTML
// Any entities that should display as literal text need to be double-escaped
content = content.replace(/&lt;/g, '<');
content = content.replace(/&gt;/g, '>');
return content;
}
testEscape(content: string): string {
return this.escapeGenericTypeSyntax(content);
}
testUnescape(content: string): string {
return this.unescapeGenericTypeSyntax(content);
}
}
describe('EditableTextTypeWidget - Generic Type Escaping', () => {
const widget = new MockEditableTextTypeWidget();
it('should escape generic type syntax with comma after tag name', () => {
const input = '<PhaseType,';
const expected = '&lt;PhaseType,';
expect(widget.testEscape(input)).toBe(expected);
});
it('should escape generic type syntax with two type parameters', () => {
const input = '<String, PromptTemplate>';
const expected = '&lt;String, PromptTemplate&gt;';
expect(widget.testEscape(input)).toBe(expected);
});
it('should escape nested generic types', () => {
const input = 'HashMap<String, List<Item>>';
const expected = 'HashMap&lt;String, List&lt;Item&gt;&gt;';
expect(widget.testEscape(input)).toBe(expected);
});
it('should not escape valid HTML tags', () => {
const input = '<div class="test">content</div>';
const expected = '<div class="test">content</div>';
expect(widget.testEscape(input)).toBe(expected);
});
it('should not escape HTML tags with attributes containing commas', () => {
const input = '<div data-values="1,2,3">content</div>';
const expected = '<div data-values="1,2,3">content</div>';
expect(widget.testEscape(input)).toBe(expected);
});
it('should handle mixed content with both generics and HTML', () => {
const input = 'Code: <String, Type> and HTML: <div>content</div>';
const expected = 'Code: &lt;String, Type&gt; and HTML: <div>content</div>';
expect(widget.testEscape(input)).toBe(expected);
});
it('should properly unescape escaped content', () => {
const testCases = [
'<String, PromptTemplate>',
'<PhaseType,',
'<RiskLevel, f32>',
'HashMap<String, List<Item>>',
'Mixed: <String, Type> and <div>HTML</div>'
];
testCases.forEach(original => {
const escaped = widget.testEscape(original);
const unescaped = widget.testUnescape(escaped);
expect(unescaped).toBe(original);
});
});
it('should handle empty or null content', () => {
expect(widget.testEscape('')).toBe('');
expect(widget.testUnescape('')).toBe('');
});
it('should handle content with multiple generic patterns', () => {
const input = `
pub struct LlmService {
anthropic_client: Option<AnthropicClient>,
openai_client: Option<OpenAIClient>,
templates: HashMap<String, PromptTemplate>,
}
`;
const escaped = widget.testEscape(input);
expect(escaped).toContain('Option&lt;AnthropicClient&gt;');
expect(escaped).toContain('Option&lt;OpenAIClient&gt;');
expect(escaped).toContain('HashMap&lt;String, PromptTemplate&gt;');
expect(escaped).not.toContain('Option<AnthropicClient>');
const unescaped = widget.testUnescape(escaped);
expect(unescaped).toBe(input);
});
// Additional test cases for problematic patterns
it('should escape Rust Box<dyn patterns', () => {
const input = 'Box<dyn';
const expected = 'Box&lt;dyn';
expect(widget.testEscape(input)).toBe(expected);
});
it('should escape Rust trait object syntax', () => {
const input = 'Box<dyn Error>';
const expected = 'Box&lt;dyn Error&gt;';
expect(widget.testEscape(input)).toBe(expected);
});
it('should escape complex Rust trait bounds', () => {
const input = 'Box<dyn Error + Send + Sync>';
const expected = 'Box&lt;dyn Error + Send + Sync&gt;';
expect(widget.testEscape(input)).toBe(expected);
});
it('should escape incomplete generic syntax with string', () => {
const input = '<string,';
const expected = '&lt;string,';
expect(widget.testEscape(input)).toBe(expected);
});
it('should escape C++ style templates', () => {
const testCases = [
{ input: 'std::vector<int>', expected: 'std::vector&lt;int&gt;' },
{ input: 'std::map<string, vector<int>>', expected: 'std::map&lt;string, vector&lt;int&gt;&gt;' },
{ input: 'unique_ptr<Widget>', expected: 'unique_ptr&lt;Widget&gt;' }
];
testCases.forEach(({ input, expected }) => {
expect(widget.testEscape(input)).toBe(expected);
});
});
it('should handle edge cases with standalone angle brackets', () => {
const testCases = [
{ input: '<', expected: '&lt;' },
{ input: '>', expected: '&gt;' },
{ input: '< >', expected: '&lt; &gt;' },
{ input: '<>', expected: '&lt;&gt;' },
{ input: '<<>>', expected: '&lt;&lt;&gt;&gt;' },
{ input: 'a < b && c > d', expected: 'a &lt; b && c &gt; d' }
];
testCases.forEach(({ input, expected }) => {
expect(widget.testEscape(input)).toBe(expected);
});
});
it('should preserve HTML comments', () => {
const input = '<!-- This is a comment with <generics> -->';
const expected = '<!-- This is a comment with <generics> -->';
expect(widget.testEscape(input)).toBe(expected);
});
it('should handle pre-escaped content correctly', () => {
// If content already has HTML entities, they get preserved during escaping
// (they don't match our angle bracket patterns)
const input = 'Already escaped: &lt;String&gt; and new: <Integer>';
const expected = 'Already escaped: &lt;String&gt; and new: &lt;Integer&gt;';
expect(widget.testEscape(input)).toBe(expected);
// When unescaping, ALL &lt; and &gt; entities get unescaped
// This is correct behavior because CKEditor expects raw HTML
const unescaped = widget.testUnescape(expected);
expect(unescaped).toBe('Already escaped: <String> and new: <Integer>');
});
it('should handle HTML with inline code containing generics', () => {
const input = '<p>Use <code>Vec<T></code> for dynamic arrays</p>';
const expected = '<p>Use <code>Vec&lt;T&gt;</code> for dynamic arrays</p>';
expect(widget.testEscape(input)).toBe(expected);
});
it('should handle self-closing HTML tags', () => {
const input = '<img src="test.jpg" /><br/><CustomType>';
const expected = '<img src="test.jpg" /><br/>&lt;CustomType&gt;';
expect(widget.testEscape(input)).toBe(expected);
});
});

View File

@@ -14,6 +14,7 @@ import type FNote from "../../entities/fnote.js";
import { PopupEditor, ClassicEditor, EditorWatchdog, type CKTextEditor, type MentionFeed, type WatchdogConfig, EditorConfig } from "@triliumnext/ckeditor5";
import "@triliumnext/ckeditor5/index.css";
import { updateTemplateCache } from "./ckeditor/snippets.js";
import { SANITIZER_DEFAULT_ALLOWED_TAGS } from "@triliumnext/commons";
const TPL = /*html*/`
<div class="note-detail-editable-text note-detail-printable">
@@ -239,12 +240,26 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget {
const blob = await note.getBlob();
await this.spacedUpdate.allowUpdateWithoutChange(async () => {
const data = blob?.content || "";
const newContentLanguage = this.note?.getLabelValue("language");
if (this.contentLanguage !== newContentLanguage) {
await this.reinitializeWithData(data);
} else {
this.watchdog.editor?.setData(data);
let data = blob?.content || "";
try {
// Escape generic type syntax that could be mistaken for HTML tags
data = this.escapeGenericTypeSyntax(data);
const newContentLanguage = this.note?.getLabelValue("language");
if (this.contentLanguage !== newContentLanguage) {
await this.reinitializeWithData(data);
} else {
this.watchdog.editor?.setData(data);
}
} catch (error) {
logError(`Failed to set editor data for note ${note.noteId}: ${error}`);
// Try to set the data without escaping as a fallback
try {
this.watchdog.editor?.setData(blob?.content || "");
} catch (fallbackError) {
logError(`Fallback also failed: ${fallbackError}`);
}
}
});
}
@@ -255,7 +270,10 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget {
return;
}
const content = this.watchdog.editor?.getData() ?? "";
let content = this.watchdog.editor?.getData() ?? "";
// Unescape any generic type syntax we escaped earlier
content = this.unescapeGenericTypeSyntax(content);
// if content is only tags/whitespace (typically <p>&nbsp;</p>), then just make it empty,
// this is important when setting a new note to code
@@ -488,9 +506,23 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget {
return;
}
this.watchdog.destroy();
await this.createEditor();
this.watchdog.editor?.setData(data);
try {
this.watchdog.destroy();
await this.createEditor();
// Data should already be escaped when this is called from doRefresh
// but we ensure it's escaped in case this is called from elsewhere
const escapedData = data.includes('&lt;') ? data : this.escapeGenericTypeSyntax(data);
this.watchdog.editor?.setData(escapedData);
} catch (error) {
logError(`Failed to reinitialize editor with data: ${error}`);
// Try to create editor without data and set it later
try {
await this.createEditor();
this.watchdog.editor?.setData("");
} catch (fallbackError) {
logError(`Failed to create empty editor: ${fallbackError}`);
}
}
}
async reinitialize() {
@@ -530,6 +562,109 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget {
}
}
/**
* Escapes generic type syntax (e.g., <String, Type>) that could be mistaken for HTML tags.
* This prevents CKEditor from trying to parse them as DOM elements.
*/
private escapeGenericTypeSyntax(content: string): string {
if (!content) return content;
try {
// Count replacements for debugging
let replacementCount = 0;
// Get the allowed HTML tags from user settings, with fallback to default list
let allowedTags;
try {
const allowedHtmlTagsOption = options.get("allowedHtmlTags");
allowedTags = allowedHtmlTagsOption ? JSON.parse(allowedHtmlTagsOption) : SANITIZER_DEFAULT_ALLOWED_TAGS;
} catch (e) {
// Fallback to default list if option doesn't exist or is invalid JSON
allowedTags = SANITIZER_DEFAULT_ALLOWED_TAGS;
}
// Convert to lowercase for case-insensitive comparison
const htmlTags = new Set(
allowedTags.map((tag: string) => tag.toLowerCase())
);
// Add custom Trilium element that must be preserved
htmlTags.add('includenote');
// More comprehensive escaping strategy:
// We'll use a different approach - parse through the content and identify
// what looks like HTML vs what looks like generic type syntax
// First pass: Protect actual HTML tags by temporarily replacing them
const htmlProtectionMap = new Map<string, string>();
let protectionCounter = 0;
// Protect complete HTML tags (opening, closing, and self-closing)
content = content.replace(/<\/?([a-zA-Z][a-zA-Z0-9-]*)(?:\s+[^>]*)?\/?>|<!--[\s\S]*?-->/g, (match, tagName) => {
// Check if this is a comment
if (match.startsWith('<!--')) {
const placeholder = `__HTML_PROTECTED_${protectionCounter++}__`;
htmlProtectionMap.set(placeholder, match);
return placeholder;
}
// Extract just the tag name (first word after < or </)
const actualTagName = tagName?.toLowerCase();
// Only protect if it's a known HTML tag
if (actualTagName && htmlTags.has(actualTagName)) {
const placeholder = `__HTML_PROTECTED_${protectionCounter++}__`;
htmlProtectionMap.set(placeholder, match);
return placeholder;
}
// Not a known HTML tag, leave it for escaping
return match;
});
// Second pass: Now escape all remaining angle brackets that weren't protected
// These are likely generic type syntax or other non-HTML patterns
content = content.replace(/</g, () => {
replacementCount++;
return '&lt;';
});
content = content.replace(/>/g, () => {
replacementCount++;
return '&gt;';
});
// Third pass: Restore the protected HTML tags
htmlProtectionMap.forEach((originalHtml, placeholder) => {
content = content.replace(placeholder, originalHtml);
});
if (replacementCount > 0) {
logInfo(`Escaped ${replacementCount} potential generic type patterns in note content`);
}
return content;
} catch (error) {
logError(`Failed to escape generic type syntax: ${error}`);
return content; // Return original content if escaping fails
}
}
/**
* Unescapes generic type syntax that was previously escaped.
* This restores the original content when saving.
*/
private unescapeGenericTypeSyntax(content: string): string {
if (!content) return content;
// Simply replace all &lt; with < and &gt; with >
// This is the correct behavior because CKEditor expects raw HTML
// Any entities that should display as literal text need to be double-escaped
content = content.replace(/&lt;/g, '<');
content = content.replace(/&gt;/g, '>');
return content;
}
buildTouchBarCommand(data: CommandListenerData<"buildTouchBar">) {
const { TouchBar, buildIcon } = data;
const { TouchBarSegmentedControl, TouchBarGroup, TouchBarButton } = TouchBar;

View File

@@ -18,7 +18,7 @@
}
},
"devDependencies": {
"dotenv": "17.2.2",
"dotenv": "17.2.1",
"electron": "37.4.0"
}
}

View File

@@ -17,6 +17,6 @@
}
},
"devDependencies": {
"dotenv": "17.2.2"
"dotenv": "17.2.1"
}
}

View File

@@ -1,4 +1,4 @@
FROM node:22.19.0-bullseye-slim AS builder
FROM node:22.18.0-bullseye-slim AS builder
RUN corepack enable
# Install native dependencies since we might be building cross-platform.
@@ -7,7 +7,7 @@ COPY ./docker/package.json ./docker/pnpm-workspace.yaml /usr/src/app/
# We have to use --no-frozen-lockfile due to CKEditor patches
RUN pnpm install --no-frozen-lockfile --prod && pnpm rebuild
FROM node:22.19.0-bullseye-slim
FROM node:22.18.0-bullseye-slim
# Install only runtime dependencies
RUN apt-get update && \
apt-get install -y --no-install-recommends \

View File

@@ -1,4 +1,4 @@
FROM node:22.19.0-alpine AS builder
FROM node:22.18.0-alpine AS builder
RUN corepack enable
# Install native dependencies since we might be building cross-platform.
@@ -7,7 +7,7 @@ COPY ./docker/package.json ./docker/pnpm-workspace.yaml /usr/src/app/
# We have to use --no-frozen-lockfile due to CKEditor patches
RUN pnpm install --no-frozen-lockfile --prod && pnpm rebuild
FROM node:22.19.0-alpine
FROM node:22.18.0-alpine
# Install runtime dependencies
RUN apk add --no-cache su-exec shadow

View File

@@ -1,4 +1,4 @@
FROM node:22.19.0-alpine AS builder
FROM node:22.18.0-alpine AS builder
RUN corepack enable
# Install native dependencies since we might be building cross-platform.
@@ -7,7 +7,7 @@ COPY ./docker/package.json ./docker/pnpm-workspace.yaml /usr/src/app/
# We have to use --no-frozen-lockfile due to CKEditor patches
RUN pnpm install --no-frozen-lockfile --prod && pnpm rebuild
FROM node:22.19.0-alpine
FROM node:22.18.0-alpine
# Create a non-root user with configurable UID/GID
ARG USER=trilium
ARG UID=1001

View File

@@ -1,4 +1,4 @@
FROM node:22.19.0-bullseye-slim AS builder
FROM node:22.18.0-bullseye-slim AS builder
RUN corepack enable
# Install native dependencies since we might be building cross-platform.
@@ -7,7 +7,7 @@ COPY ./docker/package.json ./docker/pnpm-workspace.yaml /usr/src/app/
# We have to use --no-frozen-lockfile due to CKEditor patches
RUN pnpm install --no-frozen-lockfile --prod && pnpm rebuild
FROM node:22.19.0-bullseye-slim
FROM node:22.18.0-bullseye-slim
# Create a non-root user with configurable UID/GID
ARG USER=trilium
ARG UID=1001

View File

@@ -39,7 +39,7 @@
"@types/ws": "8.18.1",
"@types/xml2js": "0.4.14",
"express-http-proxy": "2.1.1",
"@anthropic-ai/sdk": "0.61.0",
"@anthropic-ai/sdk": "0.60.0",
"@braintree/sanitize-url": "7.1.1",
"@triliumnext/commons": "workspace:*",
"@triliumnext/express-partial-content": "workspace:*",
@@ -55,7 +55,7 @@
"compression": "1.8.1",
"cookie-parser": "1.4.7",
"csrf-csrf": "3.2.2",
"dayjs": "1.11.18",
"dayjs": "1.11.14",
"debounce": "2.2.0",
"debug": "4.4.1",
"ejs": "3.1.10",

View File

@@ -81,7 +81,7 @@
"url": "https://github.com/TriliumNext/Notes/issues"
},
"homepage": "https://github.com/TriliumNext/Notes#readme",
"packageManager": "pnpm@10.15.1",
"packageManager": "pnpm@10.15.0",
"pnpm": {
"patchedDependencies": {
"@ckeditor/ckeditor5-mention": "patches/@ckeditor__ckeditor5-mention.patch",

View File

@@ -35,7 +35,7 @@
"@ckeditor/ckeditor5-dev-build-tools": "43.1.0",
"@ckeditor/ckeditor5-inspector": ">=4.1.0",
"@ckeditor/ckeditor5-package-tools": "^4.0.0",
"@typescript-eslint/eslint-plugin": "~8.42.0",
"@typescript-eslint/eslint-plugin": "~8.41.0",
"@typescript-eslint/parser": "^8.0.0",
"@vitest/browser": "^3.0.5",
"@vitest/coverage-istanbul": "^3.0.5",

View File

@@ -36,7 +36,7 @@
"@ckeditor/ckeditor5-dev-build-tools": "43.1.0",
"@ckeditor/ckeditor5-inspector": ">=4.1.0",
"@ckeditor/ckeditor5-package-tools": "^4.0.0",
"@typescript-eslint/eslint-plugin": "~8.42.0",
"@typescript-eslint/eslint-plugin": "~8.41.0",
"@typescript-eslint/parser": "^8.0.0",
"@vitest/browser": "^3.0.5",
"@vitest/coverage-istanbul": "^3.0.5",

View File

@@ -38,7 +38,7 @@
"@ckeditor/ckeditor5-dev-build-tools": "43.1.0",
"@ckeditor/ckeditor5-inspector": ">=4.1.0",
"@ckeditor/ckeditor5-package-tools": "^4.0.0",
"@typescript-eslint/eslint-plugin": "~8.42.0",
"@typescript-eslint/eslint-plugin": "~8.41.0",
"@typescript-eslint/parser": "^8.0.0",
"@vitest/browser": "^3.0.5",
"@vitest/coverage-istanbul": "^3.0.5",

View File

@@ -39,7 +39,7 @@
"@ckeditor/ckeditor5-dev-utils": "43.1.0",
"@ckeditor/ckeditor5-inspector": ">=4.1.0",
"@ckeditor/ckeditor5-package-tools": "^4.0.0",
"@typescript-eslint/eslint-plugin": "~8.42.0",
"@typescript-eslint/eslint-plugin": "~8.41.0",
"@typescript-eslint/parser": "^8.0.0",
"@vitest/browser": "^3.0.5",
"@vitest/coverage-istanbul": "^3.0.5",

View File

@@ -38,7 +38,7 @@
"@ckeditor/ckeditor5-dev-build-tools": "43.1.0",
"@ckeditor/ckeditor5-inspector": ">=4.1.0",
"@ckeditor/ckeditor5-package-tools": "^4.0.0",
"@typescript-eslint/eslint-plugin": "~8.42.0",
"@typescript-eslint/eslint-plugin": "~8.41.0",
"@typescript-eslint/parser": "^8.0.0",
"@vitest/browser": "^3.0.5",
"@vitest/coverage-istanbul": "^3.0.5",

View File

@@ -30,7 +30,7 @@
"@codemirror/lang-xml": "6.1.0",
"@codemirror/legacy-modes": "6.5.1",
"@codemirror/search": "6.5.11",
"@codemirror/view": "6.38.2",
"@codemirror/view": "6.38.1",
"@fsegurai/codemirror-theme-abcdef": "6.2.2",
"@fsegurai/codemirror-theme-abyss": "6.2.2",
"@fsegurai/codemirror-theme-android-studio": "6.2.2",

894
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff