diff --git a/Cargo.toml b/Cargo.toml index 5adc3a8..b89a724 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,4 +32,5 @@ tokio = {version = "1", features = ["full"]} tower-http = {version = "0.7", features = ["cors"]} tracing = "0.1" tracing-subscriber = {version = "0.3", features = ["env-filter", "fmt"]} +globset = "0.4.20" diff --git a/README.md b/README.md index 7f0ea11..e9e24d9 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,15 @@ mq-serve docs/ -p 8080 # Bind to all interfaces (e.g. inside Docker) — requires an explicit opt-in, # since mq-serve has no authentication and anyone reaching the address can read your files -mq-serve docs/ --bind 0.0.0.0 --dangerously-allow-remote-access +mq-serve docs/ --bind 0.0.0.0 --dangerously-allow-remote + +# Glob patterns (quote them so the shell does not expand them first). +# New files that match the pattern are picked up automatically. +mq-serve "docs/**/*.md" +mq-serve "notes/*.md" -t "Notes" + +# --watch / -w does the same as passing the pattern as an argument; handy for scripts +mq-serve --watch "docs/**/*.md" # Run in the foreground (e.g. in a container or for debugging) mq-serve docs/ --foreground @@ -118,7 +126,8 @@ mq-serve --stop-all # stop every mq-serve server currently running mq-serve --restart # restart the server (session is preserved) mq-serve --clear # clear the saved session (restarts server if running) mq-serve --close old-draft.md # remove a file/directory from the running session -mq-serve --unwatch docs/ # alias for --close, for directories you no longer want watched +mq-serve --unwatch docs/ # alias for --close, for directories or patterns you no longer want watched +mq-serve --close "docs/**/*.md" # remove a glob pattern (pass it exactly as you added it) mq-serve --restart -p 7701 # restart/clear/close on a specific port ``` @@ -126,7 +135,7 @@ mq-serve --restart -p 7701 # restart/clear/close on a specific port ``` Arguments: - [FILES_OR_DIRS]... Markdown files or directories to serve. Defaults to the current directory + [FILES_OR_DIRS]... Markdown files, directories or glob patterns to serve. Defaults to the current directory Options: -t, --target @@ -135,7 +144,7 @@ Options: Port to listen on [default: 7700] -b, --bind Address to bind to [default: 127.0.0.1] - --dangerously-allow-remote-access + --dangerously-allow-remote Required together with a non-loopback --bind to confirm the server should be reachable from the network (it has no authentication) --no-open Do not automatically open the browser @@ -143,6 +152,8 @@ Options: Always open the browser, even when adding files to an already-running server --no-watch Disable file-change watching + -w, --watch ... + Add one or more files, directories or glob patterns to watch. Same as passing them as positional arguments, but unambiguous for quoted patterns such as --watch "docs/**/*.md" -f, --foreground Run in the foreground instead of the background (default is background) --stop @@ -167,17 +178,44 @@ Options: Print version ``` +## Querying from the CLI + +`-q/--query` runs an mq query without starting a server and prints the result to stdout. It accepts the same files, directories and glob patterns as the server, or piped stdin. + +```bash +mq-serve -q '.h' docs/ # headings of every file under docs/ +mq-serve -q '.code' "docs/**/*.md" --json # JSON: [{ "path", "name", "result" }, ...] +cat notes.md | mq-serve -q '.h1' +``` + +Files for which the query produces no output are skipped. The exit status is 1 if the query is invalid. + +The running server exposes the same thing over HTTP: + +| Endpoint | Body | Effect | +| -------- | ---- | ------ | +| `POST /api/query` | `{ "path": "...", "query": "..." }` or `{ "content": "...", "query": "..." }` | Query one served file or raw Markdown | +| `POST /api/query-all` | `{ "query": "..." }` | Query every served file | + ## mq Query Examples | Query | Effect | | ---------------- | ----------------------------------------- | | `.h` | Extract all headings | | `.code` | Extract all code blocks | -| `.p` | Extract all paragraphs | +| `.link` | Extract all links | +| `.list` | Extract all lists | | `.h \| upcase()` | Extract headings and convert to uppercase | -Enter a query in the bar at the top of the page and press Enter. -Click **Clear** to reset to the original content. +Type a query in the bar at the top of the page; it runs as you type. +Click **×** to reset to the original content. + +The buttons next to the query box: + +- **★ Save**: keep the current query in your saved list +- **History / presets menu**: pick from saved queries, recent queries, or built-in presets (headings, code blocks, links, unchecked tasks, ...) +- **Layers (all files)**: run the query against every served file and show the matches grouped by file, instead of only the open file +- **Link**: copy a URL that reopens the viewer with the same query, e.g. `http://localhost:7700/?q=.code&all=1`. The address bar is kept in sync, so reloading keeps your query too. ## Development diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8fe7043..62707d9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,10 +1,11 @@ import { useState, useEffect, useCallback, useRef } from 'react' -import type { FileGroup, SearchResult, Session } from './types' +import type { FileGroup, MultiQueryResponse, SearchResult, Session } from './types' import { loadSession, saveSession } from './store/session' import { useWebSocket } from './hooks/useWebSocket' import { useDropzone } from './hooks/useDropzone' import { renderMarkdown, type ParseResult } from './lib/markdown' import { preprocessMdx } from './lib/mdx' +import { isTrivial, pushHistory, readUrlQuery, syncUrl, toggleSaved } from './lib/queries' import { getTheme, applyTheme } from './lib/themes' import QueryBar from './components/QueryBar' import Toolbar from './components/Toolbar' @@ -13,6 +14,19 @@ import Preview from './components/Preview' interface InMemoryFile { name: string; content: string } +/** Joins per-file results into one document, titling each section with its path. */ +function combineResults(results: { path: string; result: string }[]): string { + if (results.length === 0) return '*No matches in any file.*' + const dirs = results.map((r) => r.path.slice(0, r.path.lastIndexOf('/') + 1)) + let prefix = dirs[0] + for (const d of dirs) { + while (!d.startsWith(prefix)) prefix = prefix.slice(0, prefix.lastIndexOf('/', prefix.length - 2) + 1) + } + return results + .map((r) => `## ${r.path.slice(prefix.length)}\n\n${r.result.trimEnd()}`) + .join('\n\n---\n\n') +} + function useDebounce(value: T, delay: number): T { const [debounced, setDebounced] = useState(value) useEffect(() => { @@ -23,7 +37,7 @@ function useDebounce(value: T, delay: number): T { } export default function App() { - const [session, setSession] = useState(loadSession) + const [session, setSession] = useState(() => ({ ...loadSession(), ...readUrlQuery() })) const [groups, setGroups] = useState([]) const [rawContent, setRawContent] = useState('') const [queryError, setQueryError] = useState('') @@ -31,6 +45,7 @@ export default function App() { const [searchResults, setSearchResults] = useState([]) const [inMemoryFiles, setInMemoryFiles] = useState([]) const [isLoading, setIsLoading] = useState(false) + const [changeTick, setChangeTick] = useState(0) const rawContentRef = useRef(rawContent) useEffect(() => { rawContentRef.current = rawContent }, [rawContent]) const [systemPrefersDark, setSystemPrefersDark] = useState( @@ -156,8 +171,59 @@ export default function App() { loadFile(path) }, [updateSession, loadFile]) + const recordHistory = useCallback((query: string) => { + setSession((prev) => { + const queryHistory = pushHistory(prev.queryHistory, query) + if (queryHistory === prev.queryHistory) return prev + const next = { ...prev, queryHistory } + saveSession(next) + return next + }) + }, []) + + // Keep the address bar in step with the query so the page can be shared. + useEffect(() => { + syncUrl(debouncedQuery, session.queryAll) + }, [debouncedQuery, session.queryAll]) + // Run mq query when content or query changes, then render markdown in one step useEffect(() => { + let cancelled = false + const trivial = isTrivial(debouncedQuery) || debouncedQuery.trim() === '.' + + const show = (markdown: string) => + renderMarkdown(markdown, themePreset.mode).then((parsed) => { + if (cancelled) return + setParseResult(parsed) + setIsLoading(false) + }) + + const fail = (message: string) => { + if (cancelled) return + setQueryError(message) + setIsLoading(false) + } + + // Query across every served file + if (session.queryAll && !trivial) { + setIsLoading(true) + fetch('/api/query-all', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ query: debouncedQuery }), + }) + .then((r) => r.json()) + .then((data: MultiQueryResponse) => { + if (cancelled) return + if (data.error) return fail(data.error) + setQueryError('') + recordHistory(debouncedQuery) + return show(combineResults(data.results)) + }) + .catch(() => fail('Network error')) + return () => { cancelled = true } + } + if (!rawContent) { setIsLoading(false) return @@ -165,13 +231,10 @@ export default function App() { const isMdx = session.currentPath?.endsWith('.mdx') ?? false const content = isMdx ? preprocessMdx(rawContent) : rawContent - if (debouncedQuery.trim() === '.' || debouncedQuery.trim() === '') { + if (trivial) { setQueryError('') - renderMarkdown(content, themePreset.mode).then((result) => { - setParseResult(result) - setIsLoading(false) - }).catch(console.error) - return + show(content).catch(console.error) + return () => { cancelled = true } } fetch('/api/query', { @@ -181,29 +244,27 @@ export default function App() { }) .then((r) => r.json()) .then((data: { result?: string; error?: string }) => { - if (data.error) { - setQueryError(data.error) - setIsLoading(false) - } else { - const result = data.result ?? '' - setQueryError('') - return renderMarkdown(result, themePreset.mode).then((parsed) => { - setParseResult(parsed) - setIsLoading(false) - }) - } + if (cancelled) return + if (data.error) return fail(data.error) + setQueryError('') + recordHistory(debouncedQuery) + return show(data.result ?? '') }) - .catch(() => { - setQueryError('Network error') - setIsLoading(false) - }) - }, [rawContent, debouncedQuery, session.currentPath, themePreset.mode]) + .catch(() => fail('Network error')) + return () => { cancelled = true } + }, [ + rawContent, debouncedQuery, session.currentPath, session.queryAll, + // Re-run the cross-file query whenever any file changes. + session.queryAll ? changeTick : 0, + themePreset.mode, recordHistory, + ]) // WebSocket live reload const wsStatus = useWebSocket( useCallback( (data: unknown) => { const msg = data as { type: string; path?: string } + setChangeTick((t) => t + 1) if (msg.type === 'change') { fetchGroups() if (msg.path && msg.path === session.currentPath) { @@ -273,6 +334,14 @@ export default function App() { onClear={() => updateSession({ query: '.' })} wsStatus={wsStatus} queryError={queryError} + queryAll={session.queryAll} + onQueryAllChange={(v) => updateSession({ queryAll: v })} + history={session.queryHistory} + saved={session.savedQueries} + onToggleSaved={() => + updateSession({ savedQueries: toggleSaved(session.savedQueries, session.query) }) + } + onClearHistory={() => updateSession({ queryHistory: [] })} /> void wsStatus: WsStatus queryError: string + queryAll: boolean + onQueryAllChange: (v: boolean) => void + history: string[] + saved: string[] + onToggleSaved: () => void + onClearHistory: () => void } -export default function QueryBar({ query, onQueryChange, onClear, wsStatus, queryError }: Props) { +export default function QueryBar({ + query, onQueryChange, onClear, wsStatus, queryError, + queryAll, onQueryAllChange, history, saved, onToggleSaved, onClearHistory, +}: Props) { const inputRef = useRef(null) + const menuRef = useRef(null) + const [menuOpen, setMenuOpen] = useState(false) + const [linkCopied, setLinkCopied] = useState(false) + + useEffect(() => { + if (!menuOpen) return + const handler = (e: MouseEvent) => { + if (!menuRef.current?.contains(e.target as Node)) setMenuOpen(false) + } + document.addEventListener('mousedown', handler) + return () => document.removeEventListener('mousedown', handler) + }, [menuOpen]) + + const pick = (q: string) => { + onQueryChange(q) + setMenuOpen(false) + inputRef.current?.focus() + } + + const copyLink = async () => { + try { + await navigator.clipboard.writeText(shareUrl(query, queryAll)) + setLinkCopied(true) + setTimeout(() => setLinkCopied(false), 1500) + } catch { + // Clipboard API unavailable (e.g. plain http on a non-localhost host) + } + } + + const isSaved = saved.includes(query.trim()) + const trivial = isTrivial(query) return (
@@ -35,6 +76,76 @@ export default function QueryBar({ query, onQueryChange, onClear, wsStatus, quer > + +
+ + {menuOpen && ( +
+ {saved.length > 0 && ( + <> +
Saved
+ {saved.map((q) => ( + + ))} + + )} + {history.length > 0 && ( + <> +
+ History + +
+ {history.map((q) => ( + + ))} + + )} +
Presets
+ {PRESETS.map((p) => ( + + ))} +
+ )} +
+ + {queryError && ( {queryError} )} diff --git a/frontend/src/components/Sidebar/FileGroup.tsx b/frontend/src/components/Sidebar/FileGroup.tsx index e8b58b0..172d8bd 100644 --- a/frontend/src/components/Sidebar/FileGroup.tsx +++ b/frontend/src/components/Sidebar/FileGroup.tsx @@ -60,7 +60,7 @@ export default function FileGroup({ group, currentPath, session, onSessionUpdate onSessionUpdate({ sidebarLabel: { ...session.sidebarLabel, [group.root]: next } }) } - const tree = viewMode === 'tree' ? buildTree(group.root, group.files) : null + const tree = viewMode === 'tree' ? buildTree(group.base ?? group.root, group.files) : null return (
diff --git a/frontend/src/index.css b/frontend/src/index.css index e723583..eaf2a9f 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -830,3 +830,72 @@ html, body { cursor: pointer; } .zoom-close:hover { background: rgba(255, 255, 255, 0.2); } + +/* ── Query menu (saved / history / presets) ── */ +.query-menu-wrap { position: relative; } + +.query-menu { + position: absolute; + top: 100%; + right: 0; + margin-top: 6px; + z-index: 100; + width: 340px; + max-height: 60vh; + overflow-y: auto; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 6px; + padding: 4px 0; + box-shadow: 0 4px 12px rgba(0,0,0,0.15); +} + +.query-menu-heading { + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px 14px 2px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--text-muted); +} + +.query-menu-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + width: 100%; + padding: 5px 14px; + text-align: left; + background: none; + border: none; + color: var(--text); + font-size: 13px; + cursor: pointer; +} + +.query-menu-item:hover { background: var(--surface); } + +.query-menu-item code { + font-family: var(--font-mono); + font-size: 12px; + color: var(--text-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.query-menu-clear { + display: inline-flex; + background: none; + border: none; + padding: 2px; + color: var(--text-muted); + cursor: pointer; +} +.query-menu-clear:hover { color: #f85149; } + +.bar-btn:disabled { opacity: 0.4; cursor: default; } diff --git a/frontend/src/lib/markdown.ts b/frontend/src/lib/markdown.ts index 7fcfb14..1e20239 100644 --- a/frontend/src/lib/markdown.ts +++ b/frontend/src/lib/markdown.ts @@ -176,33 +176,37 @@ async function ensureLang(lang: string): Promise { } } +type Highlighter = Awaited> + +function buildProcessor(highlighter: Highlighter) { + return unified() + .use(remarkParse) + .use(remarkGfm) + .use(remarkMath) + .use(remarkFrontmatter, ['yaml']) + .use(remarkExtractFrontmatter) + .use(remarkRehype, { allowDangerousHtml: true }) + .use(rehypeMermaid) + .use(rehypeGithubAlerts) + .use(rehypeShikiFromHighlighter, highlighter, { + themes: { light: 'github-light', dark: 'github-dark' }, + defaultColor: false, + }) + .use(rehypeKatex) + .use(rehypeSlug) + .use(rehypeExtractHeadings) + .use(rehypeStringify, { allowDangerousHtml: true }) +} + // Processor promise resolves only after the highlighter is ready so the // first real document render never has to wait for Shiki initialization. -let processorPromise: Promise> | null = null +let processorPromise: Promise> | null = null -function getProcessor(): Promise> { +function getProcessor(): Promise> { if (!processorPromise) { - processorPromise = getHighlighter().then((highlighter) => { - return unified() - .use(remarkParse) - .use(remarkGfm) - .use(remarkMath) - .use(remarkFrontmatter, ['yaml']) - .use(remarkExtractFrontmatter) - .use(remarkRehype, { allowDangerousHtml: true }) - .use(rehypeMermaid) - .use(rehypeGithubAlerts) - .use(rehypeShikiFromHighlighter, highlighter, { - themes: { light: 'github-light', dark: 'github-dark' }, - defaultColor: false, - }) - .use(rehypeKatex) - .use(rehypeSlug) - .use(rehypeExtractHeadings) - .use(rehypeStringify, { allowDangerousHtml: true }) - }) + processorPromise = getHighlighter().then(buildProcessor) } - return processorPromise! + return processorPromise } // Kick off highlighter + processor initialization immediately at module load time diff --git a/frontend/src/lib/queries.ts b/frontend/src/lib/queries.ts new file mode 100644 index 0000000..f39f3bb --- /dev/null +++ b/frontend/src/lib/queries.ts @@ -0,0 +1,65 @@ +export interface Preset { + label: string + query: string +} + +export const PRESETS: Preset[] = [ + { label: 'Headings', query: '.h' }, + { label: 'Top-level headings', query: '.h1' }, + { label: 'Heading text', query: '.h | to_text()' }, + { label: 'Code blocks', query: '.code' }, + { label: 'Links', query: '.link' }, + { label: 'Images', query: '.image' }, + { label: 'Lists', query: '.list' }, + { label: 'Unchecked tasks', query: '.list | select(.checked == false)' }, + { label: 'Tables', query: '.table' }, + { label: 'Blockquotes', query: '.blockquote' }, +] + +const MAX_HISTORY = 30 + +/** The identity query shows the document unchanged and is never worth recording. */ +export function isTrivial(query: string): boolean { + const q = query.trim() + return q === '' || q === '.' +} + +/** Moves `query` to the front of `history`, dropping duplicates and old entries. */ +export function pushHistory(history: string[], query: string): string[] { + const q = query.trim() + if (isTrivial(q)) return history + if (history[0] === q) return history + return [q, ...history.filter((h) => h !== q)].slice(0, MAX_HISTORY) +} + +export function toggleSaved(saved: string[], query: string): string[] { + const q = query.trim() + if (isTrivial(q)) return saved + return saved.includes(q) ? saved.filter((s) => s !== q) : [q, ...saved] +} + +/** URL that reopens the viewer with the given query (and scope) applied. */ +export function shareUrl(query: string, queryAll: boolean): string { + const url = new URL(window.location.href) + url.search = '' + url.hash = '' + if (!isTrivial(query)) { + url.searchParams.set('q', query) + if (queryAll) url.searchParams.set('all', '1') + } + return url.toString() +} + +export function readUrlQuery(): { query?: string; queryAll?: boolean } { + const params = new URLSearchParams(window.location.search) + const query = params.get('q') + if (query === null) return {} + return { query, queryAll: params.get('all') === '1' } +} + +/** Mirrors the current query into the address bar so the page can be shared or reloaded. */ +export function syncUrl(query: string, queryAll: boolean): void { + const next = new URL(shareUrl(query, queryAll)) + if (next.search === window.location.search) return + window.history.replaceState(null, '', next.pathname + next.search) +} diff --git a/frontend/src/store/session.ts b/frontend/src/store/session.ts index 2f21d5b..e29dc0e 100644 --- a/frontend/src/store/session.ts +++ b/frontend/src/store/session.ts @@ -16,6 +16,9 @@ const defaultSession: Session = { fileOrder: {}, sidebarLabel: {}, sidebarOpen: true, + queryAll: false, + queryHistory: [], + savedQueries: [], }; export function loadSession(): Session { diff --git a/frontend/src/types.ts b/frontend/src/types.ts index df78343..f9d15c7 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -7,6 +7,7 @@ export interface FileEntry { export interface FileGroup { root: string; + base?: string; name: string; files: FileEntry[]; } @@ -37,4 +38,19 @@ export interface Session { fileOrder: Record; sidebarLabel: Record; sidebarOpen: boolean; + /** Run the query against every served file instead of the open one. */ + queryAll: boolean; + queryHistory: string[]; + savedQueries: string[]; +} + +export interface FileQueryResult { + path: string; + name: string; + result: string; +} + +export interface MultiQueryResponse { + results: FileQueryResult[]; + error?: string; } diff --git a/package.json b/package.json index 2aba2b7..44ff5aa 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,9 @@ "typescript": "^5", "@types/react": "^19", "@types/react-dom": "^19", - "@types/katex": "^0.16" + "@types/katex": "^0.16", + "@types/mdast": "^4.0.4", + "@types/hast": "^3.0.5" }, "packageManager": "pnpm@11.8.0" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8ec1c3a..405a7aa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -72,9 +72,15 @@ importers: specifier: ^2 version: 2.8.3 devDependencies: + '@types/hast': + specifier: ^3.0.5 + version: 3.0.5 '@types/katex': specifier: ^0.16 version: 0.16.8 + '@types/mdast': + specifier: ^4.0.4 + version: 4.0.4 '@types/react': specifier: ^19 version: 19.2.14 @@ -405,8 +411,8 @@ packages: '@types/geojson@7946.0.16': resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} - '@types/hast@3.0.4': - resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + '@types/hast@3.0.5': + resolution: {integrity: sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==} '@types/katex@0.16.8': resolution: {integrity: sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==} @@ -433,6 +439,7 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + deprecated: Potential CWE-502 - Update to 1.3.1 or higher '@upsetjs/venn.js@2.0.0': resolution: {integrity: sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==} @@ -1459,7 +1466,7 @@ snapshots: '@shikijs/engine-oniguruma': 1.29.2 '@shikijs/types': 1.29.2 '@shikijs/vscode-textmate': 10.0.2 - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 hast-util-to-html: 9.0.5 '@shikijs/engine-javascript@1.29.2': @@ -1480,7 +1487,7 @@ snapshots: '@shikijs/rehype@1.29.2': dependencies: '@shikijs/types': 1.29.2 - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 hast-util-to-string: 3.0.1 shiki: 1.29.2 unified: 11.0.5 @@ -1493,7 +1500,7 @@ snapshots: '@shikijs/types@1.29.2': dependencies: '@shikijs/vscode-textmate': 10.0.2 - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@shikijs/vscode-textmate@10.0.2': {} @@ -1625,7 +1632,7 @@ snapshots: '@types/geojson@7946.0.16': {} - '@types/hast@3.0.4': + '@types/hast@3.0.5': dependencies: '@types/unist': 3.0.3 @@ -1949,20 +1956,20 @@ snapshots: hast-util-from-dom@5.0.1: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 hastscript: 9.0.1 web-namespaces: 2.0.1 hast-util-from-html-isomorphic@2.0.0: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 hast-util-from-dom: 5.0.1 hast-util-from-html: 2.0.3 unist-util-remove-position: 5.0.0 hast-util-from-html@2.0.3: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 devlop: 1.1.0 hast-util-from-parse5: 8.0.3 parse5: 7.3.0 @@ -1971,7 +1978,7 @@ snapshots: hast-util-from-parse5@8.0.3: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@types/unist': 3.0.3 devlop: 1.1.0 hastscript: 9.0.1 @@ -1982,19 +1989,19 @@ snapshots: hast-util-heading-rank@3.0.0: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 hast-util-is-element@3.0.0: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 hast-util-parse-selector@4.0.0: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 hast-util-to-html@9.0.5: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@types/unist': 3.0.3 ccount: 2.0.1 comma-separated-tokens: 2.0.3 @@ -2008,22 +2015,22 @@ snapshots: hast-util-to-string@3.0.1: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 hast-util-to-text@4.0.2: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@types/unist': 3.0.3 hast-util-is-element: 3.0.0 unist-util-find-after: 5.0.0 hast-util-whitespace@3.0.0: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 hastscript@9.0.1: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 comma-separated-tokens: 2.0.3 hast-util-parse-selector: 4.0.0 property-information: 7.1.0 @@ -2214,7 +2221,7 @@ snapshots: mdast-util-math@3.0.0: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@types/mdast': 4.0.4 devlop: 1.1.0 longest-streak: 3.1.0 @@ -2231,7 +2238,7 @@ snapshots: mdast-util-to-hast@13.2.1: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@types/mdast': 4.0.4 '@ungap/structured-clone': 1.3.0 devlop: 1.1.0 @@ -2561,7 +2568,7 @@ snapshots: rehype-katex@7.0.1: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@types/katex': 0.16.8 hast-util-from-html-isomorphic: 2.0.0 hast-util-to-text: 4.0.2 @@ -2571,7 +2578,7 @@ snapshots: rehype-slug@6.0.0: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 github-slugger: 2.0.0 hast-util-heading-rank: 3.0.0 hast-util-to-string: 3.0.1 @@ -2579,7 +2586,7 @@ snapshots: rehype-stringify@10.0.1: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 hast-util-to-html: 9.0.5 unified: 11.0.5 @@ -2623,7 +2630,7 @@ snapshots: remark-rehype@11.1.2: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@types/mdast': 4.0.4 mdast-util-to-hast: 13.2.1 unified: 11.0.5 @@ -2683,7 +2690,7 @@ snapshots: '@shikijs/themes': 1.29.2 '@shikijs/types': 1.29.2 '@shikijs/vscode-textmate': 10.0.2 - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 source-map-js@1.2.1: {} diff --git a/src/cli.rs b/src/cli.rs index 355b437..c171c0f 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -6,7 +6,7 @@ use clap::Parser; #[derive(Parser, Debug)] #[command(name = "mq-serve", version, about)] pub struct Cli { - /// Markdown files or directories to serve. + /// Markdown files, directories or glob patterns to serve. /// Defaults to the current directory. #[arg(value_name = "FILES_OR_DIRS")] pub paths: Vec, @@ -25,8 +25,8 @@ pub struct Cli { /// Required together with a non-loopback --bind to confirm the server /// should be reachable from the network (it has no authentication). - #[arg(long)] - pub dangerously_allow_remote_access: bool, + #[arg(long = "dangerously-allow-remote")] + pub allow_remote: bool, /// Do not automatically open the browser. #[arg(long)] @@ -61,7 +61,12 @@ pub struct Cli { #[arg(long)] pub status: bool, - /// Output --status as JSON instead of human-readable text. + /// Run an mq query against the given files/directories/glob patterns and + /// print the result to stdout instead of starting a server. + #[arg(long, short = 'q', value_name = "QUERY")] + pub query: Option, + + /// Output --status or --query results as JSON instead of human-readable text. #[arg(long)] pub json: bool, @@ -70,6 +75,12 @@ pub struct Cli { #[arg(long)] pub clear: bool, + /// Add one or more files, directories or glob patterns to watch. Same as + /// passing them as positional arguments, but unambiguous for quoted + /// patterns such as --watch "docs/**/*.md". + #[arg(long, short = 'w', value_name = "PATTERN", num_args = 1..)] + pub watch: Vec, + /// Remove one or more files/directories from the running session on the given port. #[arg(long, value_name = "PATH", num_args = 1..)] pub close: Vec, diff --git a/src/handlers.rs b/src/handlers.rs index 56f707c..05eba9e 100644 --- a/src/handlers.rs +++ b/src/handlers.rs @@ -20,13 +20,13 @@ use axum::{ http::{StatusCode, Uri, header}, response::{IntoResponse, Response}, }; -use miette::miette; use notify::{RecommendedWatcher, RecursiveMode, Watcher}; use rust_embed::RustEmbed; use serde::{Deserialize, Serialize}; use tokio::sync::broadcast; use tracing::debug; +use crate::query::{execute_query, query_files}; use crate::session::save_session; use crate::watcher::collect_markdown_files; @@ -120,10 +120,7 @@ pub async fn add_files( let new_paths: Vec = req .paths .iter() - .map(|s| { - let p = PathBuf::from(s); - p.canonicalize().unwrap_or(p) - }) + .map(|s| crate::paths::normalize(&PathBuf::from(s))) .collect(); { @@ -134,7 +131,7 @@ pub async fn add_files( paths.push(p.clone()); if let Some(watcher) = &state.watcher { let mut w = watcher.lock().unwrap(); - let _ = w.watch(p, RecursiveMode::Recursive); + let _ = w.watch(&crate::paths::watch_target(p), RecursiveMode::Recursive); } } } @@ -168,10 +165,7 @@ pub async fn remove_files( let remove_paths: HashSet = req .paths .iter() - .map(|s| { - let p = PathBuf::from(s); - p.canonicalize().unwrap_or(p) - }) + .map(|s| crate::paths::normalize(&PathBuf::from(s))) .collect(); { @@ -180,7 +174,7 @@ pub async fn remove_files( if let Some(watcher) = &state.watcher { let mut w = watcher.lock().unwrap(); for p in &remove_paths { - let _ = w.unwatch(p); + let _ = w.unwatch(&crate::paths::watch_target(p)); } } } @@ -213,6 +207,10 @@ pub struct FileEntry { #[derive(Serialize)] pub struct FileGroup { pub root: String, + /// Directory the sidebar tree is built relative to. Only set for glob + /// patterns, where `root` is the pattern itself. + #[serde(skip_serializing_if = "Option::is_none")] + pub base: Option, pub name: String, pub files: Vec, } @@ -255,7 +253,7 @@ pub async fn list_files(State(state): State>) -> Json = collect_markdown_files(std::slice::from_ref(root)) @@ -283,8 +281,12 @@ pub async fn list_files(State(state): State>) -> Json>, - Query(params): Query, +async fn read_allowed_file( + state: &AppState, + path: &str, ) -> Result { - let path = PathBuf::from(¶ms.path); + let path = PathBuf::from(path); let paths = state.paths.read().unwrap().clone(); let path_check = path.clone(); @@ -326,11 +328,23 @@ pub async fn get_file( .map_err(|e| (StatusCode::NOT_FOUND, e.to_string())) } +pub async fn get_file( + State(state): State>, + Query(params): Query, +) -> Result { + read_allowed_file(&state, ¶ms.path).await +} + // ── POST /api/query ─────────────────────────────────────────────────────────── #[derive(Deserialize)] pub struct QueryRequest { - pub content: String, + /// Markdown to query. Takes precedence over `path`. + #[serde(default)] + pub content: Option, + /// A served file to query, as listed by /api/files. + #[serde(default)] + pub path: Option, pub query: String, } @@ -342,8 +356,37 @@ pub struct QueryResponse { pub error: Option, } -pub async fn run_query(Json(req): Json) -> Response { - let result = tokio::task::spawn_blocking(move || execute_query(&req.content, &req.query)).await; +fn query_error(status: StatusCode, error: String) -> Response { + ( + status, + Json(QueryResponse { + result: None, + error: Some(error), + }), + ) + .into_response() +} + +pub async fn run_query( + State(state): State>, + Json(req): Json, +) -> Response { + let content = match (req.content, req.path) { + (Some(content), _) => content, + (None, Some(path)) => match read_allowed_file(&state, &path).await { + Ok(content) => content, + Err((status, msg)) => return query_error(status, msg), + }, + (None, None) => { + return query_error( + StatusCode::BAD_REQUEST, + "either `content` or `path` is required".into(), + ); + } + }; + + let query = req.query; + let result = tokio::task::spawn_blocking(move || execute_query(&content, &query)).await; match result { Ok(Ok(result)) => Json(QueryResponse { @@ -360,37 +403,21 @@ pub async fn run_query(Json(req): Json) -> Response { } } -fn execute_query(content: &str, query: &str) -> miette::Result { - let mut engine = mq_lang::DefaultEngine::default(); - engine.load_builtin_module(); - let input = mq_lang::parse_markdown_input(content)?; - let runtime_values = engine - .eval(query, input.into_iter()) - .map_err(|e| miette!("Query error: {}", e))?; - let nodes: Vec = runtime_values - .values() - .iter() - .flat_map(runtime_value_to_nodes) - .collect(); - Ok(mq_markdown::Markdown::new(nodes).to_string()) +// ── POST /api/query-all ─────────────────────────────────────────────────────── + +#[derive(Deserialize)] +pub struct QueryAllRequest { + pub query: String, } -fn runtime_value_to_nodes(value: &mq_lang::RuntimeValue) -> Vec { - match value { - mq_lang::RuntimeValue::Markdown(node, _) => vec![(**node).clone()], - mq_lang::RuntimeValue::Array(items) => { - let has_markdown = items - .iter() - .any(|v| matches!(v, mq_lang::RuntimeValue::Markdown(_, _))); - if has_markdown { - items.iter().flat_map(runtime_value_to_nodes).collect() - } else if items.is_empty() { - vec![] - } else { - vec![value.to_string().into()] - } - } - _ => vec![value.to_string().into()], +pub async fn run_query_all( + State(state): State>, + Json(req): Json, +) -> Response { + let paths = state.paths.read().unwrap().clone(); + match tokio::task::spawn_blocking(move || query_files(&paths, &req.query)).await { + Ok(result) => Json(result).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), } } diff --git a/src/main.rs b/src/main.rs index 2eca0ce..339ae92 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,8 @@ mod cli; mod handlers; +mod paths; mod proc; +mod query; mod registry; mod server; mod session; @@ -33,6 +35,11 @@ async fn main() { return; } + if let Some(query) = &cli.query { + run_cli_query(&cli.paths, query, cli.json); + return; + } + if cli.stop_all { stop_all_servers().await; return; @@ -65,13 +72,14 @@ async fn main() { let mut paths: Vec = cli .paths .iter() - .map(|p| p.canonicalize().unwrap_or_else(|_| p.clone())) + .chain(&cli.watch) + .map(|p| paths::normalize(p)) .collect(); - if let Some(p) = stdin_path { - if !paths.contains(&p) { - paths.push(p); - } + if let Some(p) = stdin_path + && !paths.contains(&p) + { + paths.push(p); } let url = format!("http://localhost:{}", cli.port); @@ -92,9 +100,9 @@ async fn main() { return; } - if !is_loopback(&cli.bind) && !cli.dangerously_allow_remote_access { + if !is_loopback(&cli.bind) && !cli.allow_remote { eprintln!( - "mq-serve: refusing to bind to non-loopback address {} without --dangerously-allow-remote-access\n\ + "mq-serve: refusing to bind to non-loopback address {} without --dangerously-allow-remote\n\ mq-serve has no authentication; anyone who can reach this address can read your files.", cli.bind ); @@ -111,7 +119,7 @@ async fn main() { cli.no_open, cli.no_watch, cli.target.clone(), - cli.dangerously_allow_remote_access, + cli.allow_remote, ) .await { @@ -125,7 +133,7 @@ async fn main() { cli.no_watch, &paths, cli.target.clone(), - cli.dangerously_allow_remote_access, + cli.allow_remote, ); write_pid_file(cli.port, pid); @@ -142,6 +150,47 @@ async fn main() { } } +/// Runs `query` against the given roots (the current directory when none are +/// given, or piped stdin) and prints the results. Exits with status 1 if the +/// query fails. +fn run_cli_query(paths: &[PathBuf], query: &str, json: bool) { + let mut roots: Vec = paths.iter().map(|p| paths::normalize(p)).collect(); + if let Some(stdin) = read_stdin_to_tempfile() { + roots.push(stdin); + } + if roots.is_empty() { + roots.push(std::env::current_dir().unwrap_or_default()); + } + + let multi = query::query_files(&roots, query); + if let Some(error) = &multi.error { + eprintln!("mq-serve: {}", error); + std::process::exit(1); + } + + if json { + println!( + "{}", + serde_json::to_string_pretty(&multi.results).unwrap_or_default() + ); + return; + } + + let show_names = multi.results.len() > 1; + for (i, file) in multi.results.iter().enumerate() { + if show_names { + if i > 0 { + println!(); + } + println!("==> {} <==", file.path); + } + print!("{}", file.result); + if !file.result.ends_with('\n') { + println!(); + } + } +} + fn is_loopback(bind: &str) -> bool { matches!(bind, "127.0.0.1" | "localhost" | "::1") } @@ -207,7 +256,7 @@ async fn add_paths_to_server( async fn remove_paths_from_server(url: &str, paths: &[PathBuf]) -> Result<(), String> { let path_strings: Vec = paths .iter() - .map(|p| p.canonicalize().unwrap_or_else(|_| p.clone())) + .map(|p| paths::normalize(p)) .map(|p| p.to_string_lossy().into_owned()) .collect(); diff --git a/src/paths.rs b/src/paths.rs new file mode 100644 index 0000000..712b403 --- /dev/null +++ b/src/paths.rs @@ -0,0 +1,153 @@ +//! Helpers for session roots, which are either plain files/directories or glob +//! patterns such as `/work/docs/**/*.md`. +//! +//! A pattern is stored as an absolute `PathBuf` whose non-glob prefix (the +//! "base") has been canonicalized, so it can live next to ordinary roots in the +//! session without any extra bookkeeping. + +use std::path::{Component, Path, PathBuf}; + +use globset::{GlobBuilder, GlobMatcher}; + +fn has_glob_chars(s: &str) -> bool { + s.contains(['*', '?', '[', '{']) +} + +/// True when `path` is a glob pattern rather than an existing file or directory. +pub fn is_pattern(path: &Path) -> bool { + has_glob_chars(&path.to_string_lossy()) && !path.exists() +} + +/// Splits a pattern into its leading non-glob components and the remainder. +fn split_pattern(pattern: &Path) -> (PathBuf, PathBuf) { + let mut base = PathBuf::new(); + let mut rest = PathBuf::new(); + for comp in pattern.components() { + if !rest.as_os_str().is_empty() || has_glob_chars(&comp.as_os_str().to_string_lossy()) { + rest.push(comp); + } else { + base.push(comp); + } + } + (base, rest) +} + +/// The directory a pattern is rooted at (its longest non-glob prefix). +pub fn pattern_base(pattern: &Path) -> PathBuf { + split_pattern(pattern).0 +} + +/// Makes a CLI/API path comparable with the paths held in the session: +/// existing paths are canonicalized, patterns are made absolute and their base +/// is canonicalized. Anything else is returned unchanged. +pub fn normalize(path: &Path) -> PathBuf { + if let Ok(canonical) = path.canonicalize() { + return canonical; + } + if !has_glob_chars(&path.to_string_lossy()) { + return path.to_path_buf(); + } + + let absolute = if path.is_absolute() { + path.to_path_buf() + } else { + std::env::current_dir().unwrap_or_default().join(path) + }; + let (base, rest) = split_pattern(&absolute); + let base = base.canonicalize().unwrap_or_else(|_| lexical_clean(&base)); + base.join(rest) +} + +/// Resolves `.` and `..` without touching the filesystem. +fn lexical_clean(path: &Path) -> PathBuf { + let mut out = PathBuf::new(); + for comp in path.components() { + match comp { + Component::CurDir => {} + Component::ParentDir => { + out.pop(); + } + other => out.push(other), + } + } + out +} + +/// A root can be served if it exists, or if it is a pattern whose base exists. +pub fn is_live(path: &Path) -> bool { + path.exists() || (is_pattern(path) && pattern_base(path).is_dir()) +} + +/// The directory to hand to the file watcher for a root. +pub fn watch_target(root: &Path) -> PathBuf { + if is_pattern(root) { + pattern_base(root) + } else { + root.to_path_buf() + } +} + +/// Compiles a pattern. `*` does not cross `/`; `**` does. +pub fn matcher(pattern: &Path) -> Option { + GlobBuilder::new(&pattern.to_string_lossy()) + .literal_separator(true) + .build() + .ok() + .map(|g| g.compile_matcher()) +} + +/// Label for a root in the sidebar when no `--target` name was given. +pub fn display_name(root: &Path) -> Option { + if is_pattern(root) { + let base = pattern_base(root); + let parent = base.parent().unwrap_or(&base); + return root + .strip_prefix(parent) + .ok() + .map(|p| p.to_string_lossy().into_owned()); + } + root.file_name().map(|n| n.to_string_lossy().into_owned()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn splits_pattern_at_first_glob_component() { + let (base, rest) = split_pattern(Path::new("/work/docs/**/*.md")); + assert_eq!(base, PathBuf::from("/work/docs")); + assert_eq!(rest, PathBuf::from("**/*.md")); + } + + #[test] + fn plain_path_has_no_pattern() { + assert!(!is_pattern(Path::new("/definitely/not/here.md"))); + } + + #[test] + fn matcher_respects_separators() { + let m = matcher(Path::new("/w/docs/*.md")).unwrap(); + assert!(m.is_match("/w/docs/a.md")); + assert!(!m.is_match("/w/docs/sub/a.md")); + let m = matcher(Path::new("/w/docs/**/*.md")).unwrap(); + assert!(m.is_match("/w/docs/sub/a.md")); + assert!(m.is_match("/w/docs/a.md")); + } + + #[test] + fn normalize_makes_relative_pattern_absolute() { + let n = normalize(Path::new("./src/**/*.rs")); + assert!(n.is_absolute()); + assert!(n.ends_with("**/*.rs")); + assert!(!n.to_string_lossy().contains("/./")); + } + + #[test] + fn display_name_uses_pattern_relative_to_base_parent() { + assert_eq!( + display_name(Path::new("/work/docs/**/*.md")).as_deref(), + Some("docs/**/*.md") + ); + } +} diff --git a/src/proc.rs b/src/proc.rs index fc6f3cc..b29cf95 100644 --- a/src/proc.rs +++ b/src/proc.rs @@ -36,7 +36,7 @@ pub fn spawn_background( args.push(target); } if allow_remote_access { - args.push("--dangerously-allow-remote-access".to_string()); + args.push("--dangerously-allow-remote".to_string()); } for p in paths { args.push(p.to_string_lossy().into_owned()); diff --git a/src/query.rs b/src/query.rs new file mode 100644 index 0000000..902bddc --- /dev/null +++ b/src/query.rs @@ -0,0 +1,124 @@ +//! mq query execution shared by the HTTP API and the `--query` CLI mode. + +use std::path::{Path, PathBuf}; + +use miette::miette; +use rayon::prelude::*; +use serde::Serialize; + +use crate::watcher::collect_markdown_files; + +pub fn execute_query(content: &str, query: &str) -> miette::Result { + let mut engine = mq_lang::DefaultEngine::default(); + engine.load_builtin_module(); + let input = mq_lang::parse_markdown_input(content)?; + let runtime_values = engine + .eval(query, input.into_iter()) + .map_err(|e| miette!("Query error: {}", e))?; + let nodes: Vec = runtime_values + .values() + .iter() + .flat_map(runtime_value_to_nodes) + .collect(); + Ok(mq_markdown::Markdown::new(nodes).to_string()) +} + +fn runtime_value_to_nodes(value: &mq_lang::RuntimeValue) -> Vec { + match value { + mq_lang::RuntimeValue::Markdown(node, _) => vec![(**node).clone()], + mq_lang::RuntimeValue::Array(items) => { + let has_markdown = items + .iter() + .any(|v| matches!(v, mq_lang::RuntimeValue::Markdown(_, _))); + if has_markdown { + items.iter().flat_map(runtime_value_to_nodes).collect() + } else if items.is_empty() { + vec![] + } else { + vec![value.to_string().into()] + } + } + _ => vec![value.to_string().into()], + } +} + +#[derive(Serialize)] +pub struct FileResult { + pub path: String, + pub name: String, + pub result: String, +} + +#[derive(Serialize)] +pub struct MultiResult { + pub results: Vec, + /// Set when the query failed for every file (typically a syntax error). + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +/// Runs `query` against every markdown file under `roots`, keeping only files +/// for which the query produced output. +pub fn query_files(roots: &[PathBuf], query: &str) -> MultiResult { + let files = collect_markdown_files(roots); + let outcomes: Vec<(PathBuf, miette::Result)> = files + .into_par_iter() + .filter_map(|p| { + let content = std::fs::read_to_string(&p).ok()?; + let res = execute_query(&content, query); + Some((p, res)) + }) + .collect(); + + let total = outcomes.len(); + let mut first_error = None; + let mut errors = 0; + let mut results = Vec::new(); + for (path, outcome) in outcomes { + match outcome { + Ok(result) if !result.trim().is_empty() => results.push(FileResult { + name: file_name(&path), + path: path.to_string_lossy().into_owned(), + result, + }), + Ok(_) => {} + Err(e) => { + errors += 1; + first_error.get_or_insert_with(|| e.to_string()); + } + } + } + results.sort_by(|a, b| a.path.cmp(&b.path)); + + MultiResult { + results, + error: if total > 0 && errors == total { + first_error + } else { + None + }, + } +} + +fn file_name(path: &Path) -> String { + path.file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extracts_headings() { + let out = execute_query("# A\n\ntext\n\n## B\n", ".h").unwrap(); + assert!(out.contains("# A") && out.contains("## B")); + assert!(!out.contains("text")); + } + + #[test] + fn invalid_query_is_an_error() { + assert!(execute_query("# A\n", "this is not (valid").is_err()); + } +} diff --git a/src/server.rs b/src/server.rs index f72089a..7e999b8 100644 --- a/src/server.rs +++ b/src/server.rs @@ -16,7 +16,7 @@ use tracing::info; use crate::{ handlers::{ AppState, add_files, get_file, get_status, list_files, remove_files, restart, run_query, - search_files, serve_asset, ws_handler, + run_query_all, search_files, serve_asset, ws_handler, }, registry, session::{load_session, save_session}, @@ -39,7 +39,7 @@ pub async fn start( ) -> Result<(), Box> { if !is_loopback(bind) && !allow_remote_access { return Err(format!( - "refusing to bind to non-loopback address {} without --dangerously-allow-remote-access\n\ + "refusing to bind to non-loopback address {} without --dangerously-allow-remote\n\ mq-serve has no authentication; anyone who can reach this address can read your files.", bind ) @@ -52,7 +52,7 @@ pub async fn start( let existing_set: HashSet = merged.iter().cloned().collect(); let mut new_canonical_paths = Vec::new(); for p in paths { - let canonical = p.canonicalize().unwrap_or(p); + let canonical = crate::paths::normalize(&p); if !existing_set.contains(&canonical) { merged.push(canonical.clone()); new_canonical_paths.push(canonical); @@ -102,6 +102,7 @@ pub async fn start( .route("/api/files", get(list_files)) .route("/api/file", get(get_file)) .route("/api/query", post(run_query)) + .route("/api/query-all", post(run_query_all)) .route("/api/search", post(search_files)) .route("/api/restart", post(restart)) .route("/ws", get(ws_handler)) diff --git a/src/session.rs b/src/session.rs index 3917129..01fbeeb 100644 --- a/src/session.rs +++ b/src/session.rs @@ -40,7 +40,7 @@ pub fn load_session(port: u16) -> (Vec, HashMap) { .paths .into_iter() .map(PathBuf::from) - .filter(|p| p.exists()) + .filter(|p| crate::paths::is_live(p)) .collect(); (paths, data.targets) } diff --git a/src/watcher.rs b/src/watcher.rs index c9eb661..933f138 100644 --- a/src/watcher.rs +++ b/src/watcher.rs @@ -33,7 +33,7 @@ pub fn spawn_watcher( })?; for path in paths { - watcher.watch(path, RecursiveMode::Recursive)?; + watcher.watch(&crate::paths::watch_target(path), RecursiveMode::Recursive)?; } Ok(watcher) @@ -58,13 +58,23 @@ pub fn collect_markdown_files(paths: &[PathBuf]) -> Vec { continue; } - if !path.is_dir() { + let pattern = if crate::paths::is_pattern(path) { + let Some(m) = crate::paths::matcher(path) else { + continue; + }; + Some(m) + } else if path.is_dir() { + None + } else { continue; - } + }; + let walk_root = crate::paths::watch_target(path); - for result in WalkBuilder::new(path).build() { + for result in WalkBuilder::new(walk_root).build() { let Ok(entry) = result else { continue }; - if entry.file_type().map(|t| t.is_file()).unwrap_or(false) && is_markdown(entry.path()) + if entry.file_type().map(|t| t.is_file()).unwrap_or(false) + && is_markdown(entry.path()) + && pattern.as_ref().is_none_or(|m| m.is_match(entry.path())) { files.push(entry.path().to_path_buf()); }