From 307e17f9c8607865aa0bb8576a79aa666b949825 Mon Sep 17 00:00:00 2001 From: perf3ct Date: Wed, 3 Sep 2025 17:29:50 +0000 Subject: [PATCH] feat(docs): push to wiki when docs change asdf asdf --- .github/scripts/sync-docs-to-wiki.ts | 334 ++++++++++++++++++++++++ .github/workflows/sync-docs-to-wiki.yml | 60 +++++ 2 files changed, 394 insertions(+) create mode 100755 .github/scripts/sync-docs-to-wiki.ts create mode 100644 .github/workflows/sync-docs-to-wiki.yml diff --git a/.github/scripts/sync-docs-to-wiki.ts b/.github/scripts/sync-docs-to-wiki.ts new file mode 100755 index 000000000..e54d0317d --- /dev/null +++ b/.github/scripts/sync-docs-to-wiki.ts @@ -0,0 +1,334 @@ +#!/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 { + const files: string[] = []; + + async function walk(currentDir: string): Promise { + 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> { + const files = new Set(); + + async function walk(currentDir: string): Promise { + 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 { + console.log('Analyzing files to sync...'); + + // Get all valid source files + const sourceFiles = await findFiles(sourceDir, FILE_EXTENSIONS); + const sourceRelativePaths = new Set(); + + // 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 { + async function removeEmptyDirs(currentDir: string): Promise { + 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 { + const dirs: string[] = []; + + async function walk(currentDir: string): Promise { + 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 +} + +/** + * Rename README files to wiki-compatible names + */ +async function renameReadmeFiles(wikiDir: string): Promise { + 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 { + 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 { + 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); + + // 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 }; \ No newline at end of file diff --git a/.github/workflows/sync-docs-to-wiki.yml b/.github/workflows/sync-docs-to-wiki.yml new file mode 100644 index 000000000..081ec3c1b --- /dev/null +++ b/.github/workflows/sync-docs-to-wiki.yml @@ -0,0 +1,60 @@ +name: Sync Docs to Wiki + +on: + push: + branches: + - main + paths: + - 'docs/**' + workflow_dispatch: # Allow manual triggering + +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.GITHUB_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.GITHUB_TOKEN }} \ No newline at end of file