mirror of
				https://github.com/zadam/trilium.git
				synced 2025-10-31 18:36:30 +01:00 
			
		
		
		
	Compare commits
	
		
			6 Commits
		
	
	
		
			v0.99.2
			...
			feat/push-
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|  | 53f31f9c78 | ||
|  | 22c5651eeb | ||
|  | 68a10a9813 | ||
|  | b204964e57 | ||
|  | b2c869d7ab | ||
|  | 307e17f9c8 | 
							
								
								
									
										453
									
								
								.github/scripts/sync-docs-to-wiki-with-wiki-syntax.ts
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										453
									
								
								.github/scripts/sync-docs-to-wiki-with-wiki-syntax.ts
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,453 @@ | |||||||
|  | #!/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]] | ||||||
|  |  * - 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 | ||||||
|  |     //  → [[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 }; | ||||||
							
								
								
									
										437
									
								
								.github/scripts/sync-docs-to-wiki.ts
									
									
									
									
										vendored
									
									
										Executable file
									
								
							
							
						
						
									
										437
									
								
								.github/scripts/sync-docs-to-wiki.ts
									
									
									
									
										vendored
									
									
										Executable file
									
								
							| @@ -0,0 +1,437 @@ | |||||||
|  | #!/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  to  | ||||||
|  |     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 ``; | ||||||
|  |         } 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 }; | ||||||
							
								
								
									
										67
									
								
								.github/workflows/sync-docs-to-wiki.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										67
									
								
								.github/workflows/sync-docs-to-wiki.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,67 @@ | |||||||
|  | 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 }} | ||||||
		Reference in New Issue
	
	Block a user