From b64c13442b1cacc775e2472f7c3cd19262528b4f Mon Sep 17 00:00:00 2001 From: LordMathis Date: Mon, 30 Sep 2024 19:01:27 +0200 Subject: [PATCH] Handle wiki style links --- backend/internal/api/handlers.go | 21 ++++ backend/internal/api/routes.go | 1 + backend/internal/filesystem/filesystem.go | 41 ++++++++ frontend/src/App.js | 25 ++++- frontend/src/components/MainContent.js | 4 + frontend/src/components/MarkdownPreview.js | 111 +++++++++++++++++++-- frontend/src/hooks/useFileManagement.js | 2 + frontend/src/services/api.js | 16 +++ 8 files changed, 211 insertions(+), 10 deletions(-) diff --git a/backend/internal/api/handlers.go b/backend/internal/api/handlers.go index 7161480..5c85920 100644 --- a/backend/internal/api/handlers.go +++ b/backend/internal/api/handlers.go @@ -28,6 +28,27 @@ func ListFiles(fs *filesystem.FileSystem) http.HandlerFunc { } } +func LookupFileByName(fs *filesystem.FileSystem) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + filenameOrPath := r.URL.Query().Get("filename") + if filenameOrPath == "" { + http.Error(w, "Filename or path is required", http.StatusBadRequest) + return + } + + filePaths, err := fs.FindFileByName(filenameOrPath) + if err != nil { + http.Error(w, "File not found", http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(map[string][]string{"paths": filePaths}); err != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + } + } +} + func GetFileContent(fs *filesystem.FileSystem) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { filePath := strings.TrimPrefix(r.URL.Path, "/api/v1/files/") diff --git a/backend/internal/api/routes.go b/backend/internal/api/routes.go index eaa81a9..7a9a6b8 100644 --- a/backend/internal/api/routes.go +++ b/backend/internal/api/routes.go @@ -18,6 +18,7 @@ func SetupRoutes(r chi.Router, db *db.DB, fs *filesystem.FileSystem) { r.Get("/*", GetFileContent(fs)) r.Post("/*", SaveFile(fs)) r.Delete("/*", DeleteFile(fs)) + r.Get("/lookup", LookupFileByName(fs)) }) r.Route("/git", func(r chi.Router) { r.Post("/commit", StageCommitAndPush(fs)) diff --git a/backend/internal/filesystem/filesystem.go b/backend/internal/filesystem/filesystem.go index 3e32482..17db080 100644 --- a/backend/internal/filesystem/filesystem.go +++ b/backend/internal/filesystem/filesystem.go @@ -103,6 +103,47 @@ func (fs *FileSystem) walkDirectory(dir string) ([]FileNode, error) { return nodes, nil } +func (fs *FileSystem) FindFileByName(filenameOrPath string) ([]string, error) { + var foundPaths []string + var searchPattern string + + // If no extension is provided, assume .md + if !strings.Contains(filenameOrPath, ".") { + searchPattern = filenameOrPath + ".md" + } else { + searchPattern = filenameOrPath + } + + err := filepath.Walk(fs.RootDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if !info.IsDir() { + relPath, err := filepath.Rel(fs.RootDir, path) + if err != nil { + return err + } + + // Check if the file matches the search pattern + if strings.HasSuffix(relPath, searchPattern) || + strings.EqualFold(info.Name(), searchPattern) { + foundPaths = append(foundPaths, relPath) + } + } + return nil + }) + + if err != nil { + return nil, err + } + + if len(foundPaths) == 0 { + return nil, errors.New("file not found") + } + + return foundPaths, nil +} + func (fs *FileSystem) GetFileContent(filePath string) ([]byte, error) { fullPath, err := fs.validatePath(filePath) if err != nil { diff --git a/frontend/src/App.js b/frontend/src/App.js index 075136d..8e4f5ff 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -1,5 +1,5 @@ import React, { useState, useEffect } from 'react'; -import { GeistProvider, CssBaseline, Page } from '@geist-ui/core'; +import { GeistProvider, CssBaseline, Page, useToasts } from '@geist-ui/core'; import Header from './components/Header'; import MainContent from './components/MainContent'; import useFileManagement from './hooks/useFileManagement'; @@ -10,6 +10,7 @@ function App() { const [themeType, setThemeType] = useState('light'); const [userId, setUserId] = useState(1); const [settings, setSettings] = useState({ gitEnabled: false }); + const { setToast } = useToasts(); useEffect(() => { const loadUserSettings = async () => { @@ -36,12 +37,33 @@ function App() { handleContentChange, handleSave, pullLatestChanges, + lookupFileByName, } = useFileManagement(settings.gitEnabled); const setTheme = (newTheme) => { setThemeType(newTheme); }; + const handleLinkClick = async (filename) => { + try { + const filePaths = await lookupFileByName(filename); + if (filePaths.length === 1) { + handleFileSelect(filePaths[0]); + } else if (filePaths.length > 1) { + setFileOptions(filePaths.map((path) => ({ label: path, value: path }))); + setFileSelectionModalVisible(true); + } else { + setToast({ text: `File "${filename}" not found`, type: 'error' }); + } + } catch (error) { + console.error('Error looking up file:', error); + setToast({ + text: 'Failed to lookup file. Please try again.', + type: 'error', + }); + } + }; + return ( @@ -60,6 +82,7 @@ function App() { onSave={handleSave} settings={settings} pullLatestChanges={pullLatestChanges} + onLinkClick={handleLinkClick} /> diff --git a/frontend/src/components/MainContent.js b/frontend/src/components/MainContent.js index 4ef8f21..53fe002 100644 --- a/frontend/src/components/MainContent.js +++ b/frontend/src/components/MainContent.js @@ -19,6 +19,7 @@ import { saveFileContent, deleteFile, getFileUrl, + lookupFileByName, } from '../services/api'; const isImageFile = (filePath) => { @@ -37,6 +38,7 @@ const MainContent = ({ onSave, settings, pullLatestChanges, + onLinkClick, }) => { const [activeTab, setActiveTab] = useState('source'); const { type: themeType } = useTheme(); @@ -212,6 +214,8 @@ const MainContent = ({ )} diff --git a/frontend/src/components/MarkdownPreview.js b/frontend/src/components/MarkdownPreview.js index 45fe5cc..be8581f 100644 --- a/frontend/src/components/MarkdownPreview.js +++ b/frontend/src/components/MarkdownPreview.js @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useState, useEffect } from 'react'; import ReactMarkdown from 'react-markdown'; import remarkMath from 'remark-math'; import rehypeKatex from 'rehype-katex'; @@ -6,7 +6,69 @@ import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism'; import 'katex/dist/katex.min.css'; -const MarkdownPreview = ({ content, baseUrl }) => { +const MarkdownPreview = ({ + content, + baseUrl, + onLinkClick, + lookupFileByName, +}) => { + const [processedContent, setProcessedContent] = useState(content); + + useEffect(() => { + const processContent = async (rawContent) => { + const regex = /(!?)\[\[(.*?)\]\]/g; + let result = rawContent; + const matches = [...rawContent.matchAll(regex)]; + + for (const match of matches) { + const [fullMatch, isImage, fileName] = match; + try { + const paths = await lookupFileByName(fileName); + if (paths && paths.length > 0) { + const filePath = paths[0]; + if (isImage) { + result = result.replace( + fullMatch, + `![${fileName}](${baseUrl}/files/${filePath})` + ); + } else { + // Use a valid URL format that React Markdown will recognize + result = result.replace( + fullMatch, + `[${fileName}](${baseUrl}/internal/${encodeURIComponent( + filePath + )})` + ); + } + } else { + // Use a valid URL format for not found links + result = result.replace( + fullMatch, + `[${fileName}](${baseUrl}/notfound/${encodeURIComponent( + fileName + )})` + ); + } + } catch (error) { + console.error('Error looking up file:', error); + result = result.replace( + fullMatch, + `[${fileName}](${baseUrl}/notfound/${encodeURIComponent(fileName)})` + ); + } + } + + return result; + }; + + processContent(content).then(setProcessedContent); + }, [content, baseUrl, lookupFileByName]); + + const handleImageError = (event) => { + console.error('Failed to load image:', event.target.src); + event.target.alt = 'Failed to load image'; + }; + return (
{ ); }, - img({ src, alt, ...props }) { - // Check if the src is a relative path - if (src && !src.startsWith('http') && !src.startsWith('data:')) { - // Prepend the baseUrl to create an absolute path - src = `${baseUrl}/files/${src}`; + img: ({ src, alt, ...props }) => ( + {alt} + ), + a: ({ href, children }) => { + if (href.startsWith(`${baseUrl}/internal/`)) { + const filePath = decodeURIComponent( + href.replace(`${baseUrl}/internal/`, '') + ); + return ( + { + e.preventDefault(); + onLinkClick(filePath); + }} + > + {children} + + ); + } else if (href.startsWith(`${baseUrl}/notfound/`)) { + const fileName = decodeURIComponent( + href.replace(`${baseUrl}/notfound/`, '') + ); + return ( + { + e.preventDefault(); + onLinkClick(fileName); + }} + > + {children} + + ); } - return {alt}; + // Regular markdown link + return {children}; }, }} > - {content} + {processedContent}
); diff --git a/frontend/src/hooks/useFileManagement.js b/frontend/src/hooks/useFileManagement.js index 9d837d5..8e78d6e 100644 --- a/frontend/src/hooks/useFileManagement.js +++ b/frontend/src/hooks/useFileManagement.js @@ -5,6 +5,7 @@ import { fetchFileContent, saveFileContent, pullChanges, + lookupFileByName, } from '../services/api'; const DEFAULT_FILE = { @@ -134,6 +135,7 @@ const useFileManagement = (gitEnabled = false) => { handleContentChange, handleSave, pullLatestChanges, + lookupFileByName, }; }; diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js index e780740..108e9e3 100644 --- a/frontend/src/services/api.js +++ b/frontend/src/services/api.js @@ -131,3 +131,19 @@ export const commitAndPush = async (message) => { export const getFileUrl = (filePath) => { return `${API_BASE_URL}/files/${filePath}`; }; + +export const lookupFileByName = async (filename) => { + try { + const response = await fetch( + `${API_BASE_URL}/files/lookup?filename=${encodeURIComponent(filename)}` + ); + if (!response.ok) { + throw new Error('File not found'); + } + const data = await response.json(); + return data.paths; + } catch (error) { + console.error('Error looking up file:', error); + throw error; + } +};