Handle wiki style links

This commit is contained in:
2024-09-30 19:01:27 +02:00
parent 43d647c9ea
commit b64c13442b
8 changed files with 211 additions and 10 deletions

View File

@@ -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 { func GetFileContent(fs *filesystem.FileSystem) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
filePath := strings.TrimPrefix(r.URL.Path, "/api/v1/files/") filePath := strings.TrimPrefix(r.URL.Path, "/api/v1/files/")

View File

@@ -18,6 +18,7 @@ func SetupRoutes(r chi.Router, db *db.DB, fs *filesystem.FileSystem) {
r.Get("/*", GetFileContent(fs)) r.Get("/*", GetFileContent(fs))
r.Post("/*", SaveFile(fs)) r.Post("/*", SaveFile(fs))
r.Delete("/*", DeleteFile(fs)) r.Delete("/*", DeleteFile(fs))
r.Get("/lookup", LookupFileByName(fs))
}) })
r.Route("/git", func(r chi.Router) { r.Route("/git", func(r chi.Router) {
r.Post("/commit", StageCommitAndPush(fs)) r.Post("/commit", StageCommitAndPush(fs))

View File

@@ -103,6 +103,47 @@ func (fs *FileSystem) walkDirectory(dir string) ([]FileNode, error) {
return nodes, nil 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) { func (fs *FileSystem) GetFileContent(filePath string) ([]byte, error) {
fullPath, err := fs.validatePath(filePath) fullPath, err := fs.validatePath(filePath)
if err != nil { if err != nil {

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react'; 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 Header from './components/Header';
import MainContent from './components/MainContent'; import MainContent from './components/MainContent';
import useFileManagement from './hooks/useFileManagement'; import useFileManagement from './hooks/useFileManagement';
@@ -10,6 +10,7 @@ function App() {
const [themeType, setThemeType] = useState('light'); const [themeType, setThemeType] = useState('light');
const [userId, setUserId] = useState(1); const [userId, setUserId] = useState(1);
const [settings, setSettings] = useState({ gitEnabled: false }); const [settings, setSettings] = useState({ gitEnabled: false });
const { setToast } = useToasts();
useEffect(() => { useEffect(() => {
const loadUserSettings = async () => { const loadUserSettings = async () => {
@@ -36,12 +37,33 @@ function App() {
handleContentChange, handleContentChange,
handleSave, handleSave,
pullLatestChanges, pullLatestChanges,
lookupFileByName,
} = useFileManagement(settings.gitEnabled); } = useFileManagement(settings.gitEnabled);
const setTheme = (newTheme) => { const setTheme = (newTheme) => {
setThemeType(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 ( return (
<GeistProvider themeType={themeType}> <GeistProvider themeType={themeType}>
<CssBaseline /> <CssBaseline />
@@ -60,6 +82,7 @@ function App() {
onSave={handleSave} onSave={handleSave}
settings={settings} settings={settings}
pullLatestChanges={pullLatestChanges} pullLatestChanges={pullLatestChanges}
onLinkClick={handleLinkClick}
/> />
</Page.Content> </Page.Content>
</Page> </Page>

View File

@@ -19,6 +19,7 @@ import {
saveFileContent, saveFileContent,
deleteFile, deleteFile,
getFileUrl, getFileUrl,
lookupFileByName,
} from '../services/api'; } from '../services/api';
const isImageFile = (filePath) => { const isImageFile = (filePath) => {
@@ -37,6 +38,7 @@ const MainContent = ({
onSave, onSave,
settings, settings,
pullLatestChanges, pullLatestChanges,
onLinkClick,
}) => { }) => {
const [activeTab, setActiveTab] = useState('source'); const [activeTab, setActiveTab] = useState('source');
const { type: themeType } = useTheme(); const { type: themeType } = useTheme();
@@ -212,6 +214,8 @@ const MainContent = ({
<MarkdownPreview <MarkdownPreview
content={content} content={content}
baseUrl={window.API_BASE_URL} baseUrl={window.API_BASE_URL}
onLinkClick={onLinkClick}
lookupFileByName={lookupFileByName}
/> />
)} )}
</div> </div>

View File

@@ -1,4 +1,4 @@
import React from 'react'; import React, { useState, useEffect } from 'react';
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
import remarkMath from 'remark-math'; import remarkMath from 'remark-math';
import rehypeKatex from 'rehype-katex'; 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 { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
import 'katex/dist/katex.min.css'; 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 ( return (
<div className="markdown-preview"> <div className="markdown-preview">
<ReactMarkdown <ReactMarkdown
@@ -30,17 +92,48 @@ const MarkdownPreview = ({ content, baseUrl }) => {
</code> </code>
); );
}, },
img({ src, alt, ...props }) { img: ({ src, alt, ...props }) => (
// Check if the src is a relative path <img src={src} alt={alt} onError={handleImageError} {...props} />
if (src && !src.startsWith('http') && !src.startsWith('data:')) { ),
// Prepend the baseUrl to create an absolute path a: ({ href, children }) => {
src = `${baseUrl}/files/${src}`; if (href.startsWith(`${baseUrl}/internal/`)) {
const filePath = decodeURIComponent(
href.replace(`${baseUrl}/internal/`, '')
);
return (
<a
href="#"
onClick={(e) => {
e.preventDefault();
onLinkClick(filePath);
}}
>
{children}
</a>
);
} else if (href.startsWith(`${baseUrl}/notfound/`)) {
const fileName = decodeURIComponent(
href.replace(`${baseUrl}/notfound/`, '')
);
return (
<a
href="#"
style={{ color: 'red', textDecoration: 'underline' }}
onClick={(e) => {
e.preventDefault();
onLinkClick(fileName);
}}
>
{children}
</a>
);
} }
return <img src={src} alt={alt} {...props} />; // Regular markdown link
return <a href={href}>{children}</a>;
}, },
}} }}
> >
{content} {processedContent}
</ReactMarkdown> </ReactMarkdown>
</div> </div>
); );

View File

@@ -5,6 +5,7 @@ import {
fetchFileContent, fetchFileContent,
saveFileContent, saveFileContent,
pullChanges, pullChanges,
lookupFileByName,
} from '../services/api'; } from '../services/api';
const DEFAULT_FILE = { const DEFAULT_FILE = {
@@ -134,6 +135,7 @@ const useFileManagement = (gitEnabled = false) => {
handleContentChange, handleContentChange,
handleSave, handleSave,
pullLatestChanges, pullLatestChanges,
lookupFileByName,
}; };
}; };

View File

@@ -131,3 +131,19 @@ export const commitAndPush = async (message) => {
export const getFileUrl = (filePath) => { export const getFileUrl = (filePath) => {
return `${API_BASE_URL}/files/${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;
}
};