diff --git a/.changeset/soft-attachments-travel.md b/.changeset/soft-attachments-travel.md new file mode 100644 index 000000000..294820f43 --- /dev/null +++ b/.changeset/soft-attachments-travel.md @@ -0,0 +1,5 @@ +--- +"@inkeep/open-knowledge": minor +--- + +Add a project setting for attachment upload placement, including Settings controls and upload routing for content-root and current-folder attachment directories. diff --git a/docs/content/get-started/meta.json b/docs/content/get-started/meta.json index 418985b8b..82076459a 100644 --- a/docs/content/get-started/meta.json +++ b/docs/content/get-started/meta.json @@ -1,5 +1,5 @@ { "title": "Get Started", "icon": "LuRocket", - "pages": ["overview", "quickstart"] + "pages": ["overview", "quickstart", "obsidian"] } diff --git a/docs/content/get-started/obsidian.mdx b/docs/content/get-started/obsidian.mdx new file mode 100644 index 000000000..8d2cf6471 --- /dev/null +++ b/docs/content/get-started/obsidian.mdx @@ -0,0 +1,70 @@ +--- +title: Using with Obsidian +description: How to open an existing Obsidian vault in OpenKnowledge, which markdown syntax carries over, and where the two differ. +--- + +OpenKnowledge and Obsidian are both editors over a folder of plain markdown — [the files are the database](/docs/reference/core-concepts#the-file-system-is-the-database). So there is nothing to import or convert: point OpenKnowledge at your existing vault and your notes, links, tags, and frontmatter come across as they are. + + + OpenKnowledge works **directly on your markdown files** — it never moves, renames, or rewrites them just for opening a vault. It keeps its own state in a `.ok/` directory and ignores Obsidian's `.obsidian/` folder, so your Obsidian setup stays exactly where it is. + + +## Open your vault + +### MacOS Desktop app + +Open OpenKnowledge, choose **Open folder on disk**, and select your existing vault folder, then proceed through the **Setup** screen. OpenKnowledge uses the folder in place — it doesn't copy or move your notes. + +### Linux, Windows, Intel Mac + +Install the [ok CLI](/docs/reference/cli), `cd` into your vault, and run `ok init && ok start --open`. + +## What carries over + +Most of what you write in Obsidian renders and round-trips in OpenKnowledge unchanged. Existing syntax is **preserved byte-for-byte** when a file is saved, so opening a vault is non-destructive. + +| Obsidian syntax | Example | In OpenKnowledge | +| --- | --- | --- | +| Wiki links | `[[Page]]`, `[[Page\|alias]]`, `[[Page#Heading]]` | Resolved to the target doc; feed backlinks and the graph. Preserved as written. | +| Markdown links | `[text](./page.md)` | Fully supported and the [recommended form](/docs/reference/core-concepts#links-and-backlinks) for new links. Wiki links keep working as a superset. | +| YAML frontmatter | `---` block at the top | Open-shape — any keys. `aliases`, `tags`, `cssclasses`, dates, and lists are preserved. Edit them in the [Properties panel](/docs/features/editor#properties). | +| Tags | `#tag`, or `tags:` in frontmatter | Both forms are indexed and searchable. | +| Callouts | `> [!note]`, `> [!warning]`, foldable `> [!tip]-` | Rendered with icons. 15 types including Obsidian's set (`success`, `question`, `failure`, `bug`, `example`, `quote`, …); aliases like `summary` / `tldr` fold to `abstract`. | +| Highlights | `==highlighted==` | Rendered as a highlight mark. | +| Comments | `%%hidden note%%` | Hidden in the editor, kept in the file. (`` works too.) | +| Math | `$inline$`, `$$block$$` | Rendered as LaTeX math. | +| Mermaid | ` ```mermaid ` fenced blocks | Rendered as diagrams. | +| Footnotes | `[^1]` with `[^1]: …` | Supported, including named footnotes. | +| Asset embeds | `![[image.png]]`, `![[file.pdf#page=3]]` | Images and PDFs render inline. See [Assets and embeds](/docs/features/assets-and-embeds). | + +## Where the two differ + +A handful of Obsidian features are not supported, or behave differently. None of them corrupt your files — the syntax is preserved on disk; it just isn't interpreted the same way. + +| Feature | What happens in OpenKnowledge | +| --- | --- | +| **Note transclusion** — `![[Some Note]]` to embed another note's *content* | Renders as a working link to the note rather than inlining its content — the target is one click away, just not pulled in. Only file assets (images, PDFs) embed inline; documents do not transclude. | +| **Block references** — `^block-id` and `[[Page#^block-id]]` | Not recognized. Heading links (`[[Page#Heading]]`) resolve, but a `#^block-id` fragment won't point at a block. | +| **The `.obsidian/` folder** | Ignored. OpenKnowledge does not read Obsidian's theme, hotkeys, plugin config, or vault preferences. Configure OpenKnowledge in [`.ok/config.yml`](/docs/reference/configuration) instead. | +| **Community plugins** | Don't run. OpenKnowledge is a separate app, not an Obsidian plugin host. Many features people reach for plugins for in Obsidian — callouts, tags, math, Mermaid, embeds — are built in here, but plugin-specific syntax (for example Dataview queries) is left as plain text. | +| **Canvas (`.canvas`) and Bases (`.base`)** | Open in a read-only text view rather than as interactive canvases or bases. They're still indexed and linkable from your notes. | + + + OpenKnowledge's defaults match Obsidian's defaults — wiki links and attachments stored alongside your notes — so a standard vault works as-is. There's no migration step today; if you have a vault with heavily non-default settings, [let us know](https://github.com/inkeep/open-knowledge/issues). + + +## Agent skills, built in + +Getting an AI agent to work *well* inside an Obsidian vault usually means adding an agent-skills layer on top — a set of `SKILL.md` instructions that teach the agent your conventions and how to read and write the vault. OpenKnowledge builds that layer in, so there's nothing to bolt on: + +- **A project skill is installed for you.** `ok init` (or the desktop setup) drops in a project-local skill that teaches your agent OpenKnowledge's tools and conventions — no hand-written `CLAUDE.md` to maintain. +- **Agents get first-class tools, not just file access.** The [MCP server](/docs/reference/mcp) gives any MCP-capable agent — Claude Code, Cursor, Codex, OpenCode — typed `read` / `write` / `search` / `links` tools over the same files, so edits are consistent, attributed, and keep your link graph healthy. + +## See also + +- [Quickstart](/docs/get-started/quickstart): the five-minute setup +- [Core concepts](/docs/reference/core-concepts): links, backlinks, and the file-system-as-database model +- [Editor](/docs/features/editor): WYSIWYG, source mode, and the graph, backlinks, and properties panels +- [Timeline and recovery](/docs/features/timeline-and-recovery): version history and point-in-time rollback +- [Ignore patterns](/docs/features/ignore-patterns): keep folders out of the editor, search, and agents +- [Assets and embeds](/docs/features/assets-and-embeds): how images and file references behave diff --git a/docs/content/reference/configuration.mdx b/docs/content/reference/configuration.mdx index ae82a52c1..72b3f68a3 100644 --- a/docs/content/reference/configuration.mdx +++ b/docs/content/reference/configuration.mdx @@ -26,6 +26,7 @@ The **Scope** column says which file a setting belongs in: **project** → `.ok/ | Field | Type | Default | Scope | Description | | --- | --- | --- | --- | --- | | `content.dir` | string | `"."` | project | Content directory, relative to project root. Defaults to `.` (the project root) even when `.ok/` is scaffolded at the git working-tree root from a sub-folder, so opened folder and content scope align by default. To narrow scope to a sub-folder, uncomment `content.dir` in `.ok/config.yml` post-init. Excluded paths live in `.okignore`; see [Ignore patterns](/docs/features/ignore-patterns). | +| `content.attachmentFolderPath` | string | `"./"` | project | Where pasted and dropped assets are stored. `"./"` colocates beside the current document; `"/"` targets the content root; `"./subdir"` targets a subfolder under the current folder; `"folder"` targets a fixed folder under the content root. | | `appearance.theme` | `"light" \| "dark" \| "system"` | (unset) | user | Editor theme. | | `appearance.sidebar.showHiddenFiles` | boolean | `false` | project-local | Show files whose path segments start with `.`. The sidebar otherwise lists every file on disk under the content directory; tooling internals (`.git/`, `.ok/`, `node_modules/`) stay hidden regardless. Toggled from the sidebar's right-click menu or the macOS **View → Show Hidden Files** item. | | `autoSync.enabled` | `boolean \| null` | `null` | project-local | Per-machine git auto-sync toggle. `null` means "unanswered"; the editor's onboarding modal triggers on first remote-detected open. | diff --git a/packages/app/src/components/FileTree.tsx b/packages/app/src/components/FileTree.tsx index 8c5f46ee5..7842bd291 100644 --- a/packages/app/src/components/FileTree.tsx +++ b/packages/app/src/components/FileTree.tsx @@ -66,6 +66,7 @@ import { MARKDOWN_FILE_ICON_VIEWBOX, } from '@/components/file-entry-icon'; import { + appendSidebarUploadFields, collectTreeFolderPathsFromDocuments, computeTreeAncestorPaths, computeTreeDropDestinationPath, @@ -88,7 +89,6 @@ import { treePathSignature, treePathToAppPath, uploadedPathForSidebarDrop, - uploadParentDocNameForFolderDrop, } from '@/components/file-tree-adapter'; import { createFileTreeStyle, @@ -2387,10 +2387,7 @@ export function FileTree({ for (const file of files) { const formData = new FormData(); formData.append('file', file); - formData.append( - 'parentDocName', - uploadParentDocNameForFolderDrop(parentDir, file.name || 'upload'), - ); + appendSidebarUploadFields(formData, parentDir, file.name || 'upload'); try { const res = await fetch('/api/upload', { method: 'POST', body: formData }); diff --git a/packages/app/src/components/file-tree-adapter.test.ts b/packages/app/src/components/file-tree-adapter.test.ts index 1423dac69..31ffd51ec 100644 --- a/packages/app/src/components/file-tree-adapter.test.ts +++ b/packages/app/src/components/file-tree-adapter.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from 'bun:test'; import type { ContextMenuItem, FileTreeDropTarget } from '@pierre/trees'; import { OK_SIDEBAR_DRAG_MIME } from '@/lib/sidebar-drag'; import { + appendSidebarUploadFields, collectTreeFolderPathsFromDocuments, computeTreeAncestorPaths, computeTreeDropDestinationPath, @@ -151,6 +152,11 @@ describe('file-tree-adapter', () => { expect(uploadParentDocNameForFolderDrop('docs/', 'clip.pdf')).toBe('docs/clip.pdf'); expect(uploadParentDocNameForFolderDrop('', 'clip.pdf')).toBe('clip.pdf'); + const formData = new FormData(); + appendSidebarUploadFields(formData, 'docs', 'clip.pdf'); + expect(formData.get('parentDocName')).toBe('docs/clip.pdf'); + expect(formData.get('placement')).toBe('parent-dir'); + expect(uploadedPathForSidebarDrop('docs', { src: 'clip.pdf' })).toBe('docs/clip.pdf'); expect( uploadedPathForSidebarDrop('docs', { src: 'ignored.pdf', path: '/assets/clip.pdf' }), diff --git a/packages/app/src/components/file-tree-adapter.ts b/packages/app/src/components/file-tree-adapter.ts index d1912d53e..c296663cb 100644 --- a/packages/app/src/components/file-tree-adapter.ts +++ b/packages/app/src/components/file-tree-adapter.ts @@ -258,6 +258,15 @@ export function uploadParentDocNameForFolderDrop( return parent ? `${parent}/${fileName}` : fileName; } +export function appendSidebarUploadFields( + formData: FormData, + parentFolderPath: string, + fileName: string, +): void { + formData.append('parentDocName', uploadParentDocNameForFolderDrop(parentFolderPath, fileName)); + formData.append('placement', 'parent-dir'); +} + export function uploadedPathForSidebarDrop( parentFolderPath: string, success: UploadAssetSuccess, diff --git a/packages/app/src/components/settings/SettingsDialogBody.dom.test.tsx b/packages/app/src/components/settings/SettingsDialogBody.dom.test.tsx index 450df8027..1d9482bd3 100644 --- a/packages/app/src/components/settings/SettingsDialogBody.dom.test.tsx +++ b/packages/app/src/components/settings/SettingsDialogBody.dom.test.tsx @@ -1,5 +1,6 @@ import { afterEach, describe, expect, test } from 'bun:test'; import { + CONFIG_DOC_NAME_PROJECT, CONFIG_DOC_NAME_USER, type Config, type ConfigBinding, @@ -9,7 +10,9 @@ import { import { act, cleanup, render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { ThemeProvider, useTheme } from 'next-themes'; +import type { ReactNode } from 'react'; import { TooltipProvider } from '@/components/ui/tooltip'; +import { ConfigContext, type ConfigContextValue } from '@/lib/config-context'; import { emitConfigValidationRejected } from '@/lib/config-validation-events'; import { expectVisualClassTokens } from '@/test-utils/visual-contract'; import { SettingsDialogBody } from './SettingsDialogBody'; @@ -40,14 +43,31 @@ function makeBinding(config: Config = ConfigSchema.parse({})): { return { binding, patches }; } -describe('SettingsDialogBody preferences runtime', () => { - afterEach(() => { - cleanup(); - }); +function makeConfigContextValue(projectBinding: ConfigBinding = makeBinding().binding) { + const config = ConfigSchema.parse({}); + return { + userBinding: null, + userSynced: true, + projectBinding, + projectLocalBinding: null, + okignoreBinding: null, + okignoreSynced: true, + userConfig: config, + projectConfig: config, + projectSynced: true, + projectLocalConfig: config, + projectLocalSynced: true, + merged: config, + } satisfies ConfigContextValue; +} - test('renders editor.wordWrap in the Preferences section', () => { - const { binding } = makeBinding(); - const { container } = render( +function SettingsContextProvider({ children }: { children: ReactNode }) { + return {children}; +} + +function renderPreferences(binding: ConfigBinding) { + return render( + { okignoreBinding={null} okignoreSynced={false} /> - , - ); + + , + ); +} + +describe('SettingsDialogBody preferences runtime', () => { + afterEach(() => { + cleanup(); + }); + + test('renders editor.wordWrap in the Preferences section', () => { + const { binding } = makeBinding(); + const { container } = renderPreferences(binding); expect(screen.getByRole('heading', { name: 'Preferences' })).toBeDefined(); expect(screen.getByText('Word wrap')).toBeDefined(); @@ -81,16 +112,7 @@ describe('SettingsDialogBody preferences runtime', () => { test('commits editor.wordWrap changes through binding.patch', async () => { const user = userEvent.setup(); const { binding, patches } = makeBinding(); - render( - - - , - ); + renderPreferences(binding); const wordWrapSwitch = screen.getByRole('switch', { name: 'Word wrap' }); await user.click(wordWrapSwitch); @@ -104,16 +126,7 @@ describe('SettingsDialogBody preferences runtime', () => { test('commits appearance.preview.autoOpen changes through binding.patch', async () => { const user = userEvent.setup(); const { binding, patches } = makeBinding(); - render( - - - , - ); + renderPreferences(binding); const autoOpenSwitch = screen.getByRole('switch', { name: 'Open preview when agent edits' }); expect(autoOpenSwitch.getAttribute('aria-checked')).toBe('true'); @@ -138,16 +151,7 @@ describe('SettingsDialogBody preferences runtime', () => { test('surfaces L3 config-validation rejections on the matching user field', async () => { const { binding } = makeBinding(); - const { container } = render( - - - , - ); + const { container } = renderPreferences(binding); const wordWrapField = container.querySelector('[data-field="editor.wordWrap"]'); expect(wordWrapField).toBeTruthy(); @@ -178,6 +182,40 @@ describe('SettingsDialogBody preferences runtime', () => { }); expectVisualClassTokens(wordWrapField?.className, ['animate-settings-flash']); }); + + test('surfaces L3 config-validation rejections on the project attachment field', async () => { + const { binding } = makeBinding(); + const { container } = renderPreferences(binding); + + const attachmentField = container.querySelector('[data-field="content.attachmentFolderPath"]'); + expect(attachmentField).toBeTruthy(); + + act(() => { + emitConfigValidationRejected({ + v: 1, + ch: 'config-validation-rejected', + seq: 2, + docName: CONFIG_DOC_NAME_PROJECT, + error: { + code: 'SCHEMA_INVALID', + issues: [ + { + path: ['content', 'attachmentFolderPath'], + message: 'Invalid attachment folder path', + issueCode: 'invalid_path', + }, + ], + }, + }); + }); + + await waitFor(() => { + expect( + within(screen.getByTestId('settings-attachments')).getByRole('alert').textContent, + ).toBe('Invalid attachment folder path'); + }); + expectVisualClassTokens(attachmentField?.className, ['animate-settings-flash']); + }); }); let themeStorageKeySeq = 0; @@ -196,15 +234,17 @@ function renderPreferencesWithTheme(binding: ConfigBinding) { enableSystem storageKey={`ok-theme-v1-test-${themeStorageKeySeq}`} > - - - - + + + + + + , ); } @@ -220,7 +260,7 @@ describe('SettingsDialogBody theme toggle — optimistic apply', () => { cleanup(); }); - test('clicking Dark flips next-themes immediately (no ConfigProvider) and still persists via binding.patch', async () => { + test('clicking Dark flips next-themes immediately and still persists via binding.patch', async () => { const user = userEvent.setup(); const { binding, patches } = makeBinding(); const { container } = renderPreferencesWithTheme(binding); diff --git a/packages/app/src/components/settings/SettingsDialogBody.sections.dom.test.tsx b/packages/app/src/components/settings/SettingsDialogBody.sections.dom.test.tsx index 6150c1721..fb2f43e72 100644 --- a/packages/app/src/components/settings/SettingsDialogBody.sections.dom.test.tsx +++ b/packages/app/src/components/settings/SettingsDialogBody.sections.dom.test.tsx @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, mock, test } from 'bun:test'; -import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { createContext, type ReactNode, use, useState } from 'react'; import { renderLinguiTemplate } from '@/test-utils/lingui-mock'; @@ -21,10 +21,18 @@ let projectLocalConfig: { autoSync?: { enabled?: boolean } } | null = { autoSync: { enabled: true }, }; let projectLocalSynced = true; -let projectConfig: { autoSync?: { default?: boolean | null } } | null = { +let projectConfig: { + autoSync?: { default?: boolean | null }; + content: { attachmentFolderPath: string }; +} | null = { autoSync: { default: null }, + content: { attachmentFolderPath: './' }, }; let projectSynced = true; +let projectBinding: { + patch: (patch: unknown) => { ok: true } | { ok: false; error: unknown }; +} | null = null; +let projectBindingPatchCalls: unknown[] = []; let syncWriterCalls: boolean[] = []; let syncDefaultWriterCalls: Array = []; let okignoreProps: Array<{ binding: unknown; synced: boolean }> = []; @@ -114,6 +122,48 @@ mock.module('@/components/ui/input', () => ({ Input: (props: React.InputHTMLAttributes) => , })); +const SelectHandlerCtx = createContext<((value: string) => void) | undefined>(undefined); +mock.module('@/components/ui/select', () => ({ + Select: ({ + children, + value, + onValueChange, + }: { + children?: ReactNode; + value?: string; + onValueChange?: (value: string) => void; + }) => ( + +
+ {children} +
+
+ ), + SelectContent: ({ children }: { children?: ReactNode }) =>
{children}
, + SelectItem: ({ + children, + value, + ...props + }: { + children?: ReactNode; + value: string; + [key: string]: unknown; + }) => { + const onValueChange = use(SelectHandlerCtx); + return ( + + ); + }, + SelectTrigger: ({ children, ...props }: { children?: ReactNode; [key: string]: unknown }) => ( + + ), + SelectValue: () => null, +})); + const ToggleGroupHandlerCtx = createContext<((value: string) => void) | undefined>(undefined); mock.module('@/components/ui/toggle-group', () => ({ ToggleGroup: ({ @@ -202,6 +252,7 @@ mock.module('@/hooks/use-git-sync-status', () => ({ mock.module('@/lib/config-provider', () => ({ useConfigContext: () => ({ + projectBinding, projectConfig, projectLocalConfig, projectLocalSynced, @@ -287,8 +338,15 @@ describe('SettingsDialogBody section runtime dispatch', () => { syncStatus = null; projectLocalConfig = { autoSync: { enabled: true } }; projectLocalSynced = true; - projectConfig = { autoSync: { default: null } }; + projectConfig = { autoSync: { default: null }, content: { attachmentFolderPath: './' } }; projectSynced = true; + projectBindingPatchCalls = []; + projectBinding = { + patch: (patch: unknown) => { + projectBindingPatchCalls.push(patch); + return { ok: true }; + }, + }; syncWriterCalls = []; syncDefaultWriterCalls = []; okignoreProps = []; @@ -325,6 +383,131 @@ describe('SettingsDialogBody section runtime dispatch', () => { expect(screen.getAllByText('Workspace').length).toBeGreaterThan(0); }); + test('preferences includes attachments controls mapped to content.attachmentFolderPath', async () => { + projectConfig = { + autoSync: { default: null }, + content: { attachmentFolderPath: './' }, + }; + + await renderBody({ activeId: 'preferences' }); + + expect(screen.getByTestId('settings-attachments')).not.toBeNull(); + expect(screen.getByTestId('select-root').getAttribute('data-value')).toBe('same-folder'); + + fireEvent.click(screen.getByText('Fixed folder in content root')); + expect(projectBindingPatchCalls.at(-1)).toEqual({ + content: { attachmentFolderPath: 'attachments' }, + }); + expect(screen.getByTestId('settings-attachments-folder')).not.toBeNull(); + + fireEvent.change(screen.getByTestId('settings-attachments-folder'), { + target: { value: 'assets/uploads' }, + }); + fireEvent.blur(screen.getByTestId('settings-attachments-folder')); + + expect(projectBindingPatchCalls.at(-1)).toEqual({ + content: { attachmentFolderPath: 'assets/uploads' }, + }); + + fireEvent.click(screen.getByText('Content root')); + expect(projectBindingPatchCalls.at(-1)).toEqual({ + content: { attachmentFolderPath: '/' }, + }); + }); + + test('preferences round trips current-folder attachment subfolders', async () => { + projectConfig = { + autoSync: { default: null }, + content: { attachmentFolderPath: './attachments' }, + }; + + await renderBody({ activeId: 'preferences' }); + + expect(screen.getByTestId('select-root').getAttribute('data-value')).toBe( + 'current-folder-subfolder', + ); + expect((screen.getByTestId('settings-attachments-folder') as HTMLInputElement).value).toBe( + 'attachments', + ); + + fireEvent.change(screen.getByTestId('settings-attachments-folder'), { + target: { value: 'media' }, + }); + fireEvent.blur(screen.getByTestId('settings-attachments-folder')); + + expect(projectBindingPatchCalls.at(-1)).toEqual({ + content: { attachmentFolderPath: './media' }, + }); + }); + + test('fixed content-root folder strips leading dot slash to avoid remounting as current-folder mode', async () => { + projectConfig = { + autoSync: { default: null }, + content: { attachmentFolderPath: 'attachments' }, + }; + + await renderBody({ activeId: 'preferences' }); + + expect(screen.getByTestId('select-root').getAttribute('data-value')).toBe( + 'content-root-folder', + ); + fireEvent.change(screen.getByTestId('settings-attachments-folder'), { + target: { value: './media' }, + }); + fireEvent.blur(screen.getByTestId('settings-attachments-folder')); + + expect(projectBindingPatchCalls.at(-1)).toEqual({ + content: { attachmentFolderPath: 'media' }, + }); + + projectConfig = { + autoSync: { default: null }, + content: { attachmentFolderPath: 'media' }, + }; + cleanup(); + await renderBody({ activeId: 'preferences' }); + + expect(screen.getByTestId('select-root').getAttribute('data-value')).toBe( + 'content-root-folder', + ); + }); + + test('preferences surfaces attachment patch failures inline', async () => { + projectConfig = { + autoSync: { default: null }, + content: { attachmentFolderPath: './' }, + }; + projectBinding = { + patch: (patch: unknown) => { + projectBindingPatchCalls.push(patch); + return { + ok: false, + error: { + code: 'SCHEMA_INVALID', + issues: [ + { + path: ['content', 'attachmentFolderPath'], + message: 'Folder must stay inside the content root', + issueCode: 'invalid_path', + }, + ], + }, + }; + }, + }; + + await renderBody({ activeId: 'preferences' }); + + fireEvent.click(screen.getByText('Fixed folder in content root')); + + expect(projectBindingPatchCalls.at(-1)).toEqual({ + content: { attachmentFolderPath: 'attachments' }, + }); + expect(within(screen.getByTestId('settings-attachments')).getByRole('alert').textContent).toBe( + 'Folder must stay inside the content root', + ); + }); + test('sync section reads checked state from project-local config and keeps the writer/confirm path', async () => { syncStatus = { state: 'enabled', diff --git a/packages/app/src/components/settings/SettingsDialogBody.tsx b/packages/app/src/components/settings/SettingsDialogBody.tsx index c74559e1f..99364b846 100644 --- a/packages/app/src/components/settings/SettingsDialogBody.tsx +++ b/packages/app/src/components/settings/SettingsDialogBody.tsx @@ -7,9 +7,11 @@ import { type ConfigBinding, ConfigSchema, type ConfigValidationError, + DEFAULT_ATTACHMENT_FOLDER_PATH, getFieldMeta, humanFormat, isKnownConfigError, + normalizeAttachmentFolderPath, type OkignoreBinding, SHOW_INSTALL_SKILL, } from '@inkeep/open-knowledge-core'; @@ -45,6 +47,13 @@ import { } from '@/components/ui/form'; import { Input } from '@/components/ui/input'; import { Kbd } from '@/components/ui/kbd'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; import { Skeleton } from '@/components/ui/skeleton'; import { Switch } from '@/components/ui/switch'; import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'; @@ -129,16 +138,21 @@ export function SettingsDialogBody({ }: SettingsDialogBodyProps) { const { t } = useLingui(); if (activeId === 'preferences') { - return userBinding ? ( - - ) : ( - + return ( +
+ {userBinding ? ( + + ) : ( + + )} + +
); } if (activeId === 'hotkeys') { @@ -415,6 +429,242 @@ function SchemaSection({ ); } +type AttachmentPlacementMode = + | 'same-folder' + | 'content-root' + | 'current-folder-subfolder' + | 'content-root-folder'; + +const ATTACHMENT_FIELD_NAME = 'content.attachmentFolderPath'; +const ATTACHMENT_FALLBACK_FOLDER = 'attachments'; + +function attachmentModeFromPath(path: string): AttachmentPlacementMode { + const normalized = normalizeAttachmentFolderPath(path); + if (normalized === DEFAULT_ATTACHMENT_FOLDER_PATH) return 'same-folder'; + if (normalized === '/') return 'content-root'; + if (normalized.startsWith('./')) return 'current-folder-subfolder'; + return 'content-root-folder'; +} + +function attachmentFolderTextFromPath(path: string): string { + const normalized = normalizeAttachmentFolderPath(path); + if (normalized === DEFAULT_ATTACHMENT_FOLDER_PATH || normalized === '/') { + return ATTACHMENT_FALLBACK_FOLDER; + } + if (normalized.startsWith('./')) { + return normalized.slice(2) || ATTACHMENT_FALLBACK_FOLDER; + } + return normalized; +} + +function normalizeAttachmentFolderInput(value: string): string { + return value + .trim() + .replace(/^(?:\.\/)+/, '') + .replace(/\/{2,}/g, '/') + .replace(/\/+$/g, ''); +} + +function attachmentPathFromMode(mode: AttachmentPlacementMode, folderText: string): string { + const folder = normalizeAttachmentFolderInput(folderText) || ATTACHMENT_FALLBACK_FOLDER; + if (mode === 'same-folder') return DEFAULT_ATTACHMENT_FOLDER_PATH; + if (mode === 'content-root') return '/'; + if (mode === 'current-folder-subfolder') return `./${folder}`; + return folder; +} + +function AttachmentsSection() { + const { projectBinding, projectConfig, projectSynced } = useConfigContext(); + if (!projectBinding || !projectSynced || !projectConfig) { + return ; + } + const attachmentFolderPath = + projectConfig.content.attachmentFolderPath ?? DEFAULT_ATTACHMENT_FOLDER_PATH; + return ( + + ); +} + +function AttachmentsSectionBody({ binding, value }: { binding: ConfigBinding; value: string }) { + const { t } = useLingui(); + const [mode, setMode] = useState(() => attachmentModeFromPath(value)); + const [folderText, setFolderText] = useState(() => attachmentFolderTextFromPath(value)); + const [error, setError] = useState(null); + const [savedTick, setSavedTick] = useState(false); + const [flashed, setFlashed] = useState(false); + const savedTickTimerRef = useRef | null>(null); + const flashTimerRef = useRef | null>(null); + + useEffect( + () => () => { + if (savedTickTimerRef.current) clearTimeout(savedTickTimerRef.current); + if (flashTimerRef.current) clearTimeout(flashTimerRef.current); + }, + [], + ); + + useEffect(() => { + const unsubscribe = subscribeToConfigValidationRejected((event) => { + if (event.docName !== CONFIG_DOC_NAME_PROJECT) return; + if (firstIssuePath(event.error) !== ATTACHMENT_FIELD_NAME) return; + const issue = pickFirstIssueForPath(event.error, ATTACHMENT_FIELD_NAME); + toast.error(humanFormat(event.error), { duration: 8000 }); + setError(issue); + setFlashed(true); + if (flashTimerRef.current) clearTimeout(flashTimerRef.current); + flashTimerRef.current = setTimeout(() => { + setFlashed(false); + setError(null); + }, 600); + }); + return unsubscribe; + }, []); + + const flashSavedTick = () => { + setSavedTick(true); + if (savedTickTimerRef.current) clearTimeout(savedTickTimerRef.current); + savedTickTimerRef.current = setTimeout(() => setSavedTick(false), 1200); + }; + + const commitPath = (nextPath: string) => { + const result = binding.patch({ content: { attachmentFolderPath: nextPath } }); + if (result.ok) { + setError(null); + flashSavedTick(); + return; + } + const detail = pickFirstIssueForPath(result.error, ATTACHMENT_FIELD_NAME); + setError(detail); + toast.error(t`Failed to update attachment location — ${detail}`); + }; + + const onModeChange = (next: string) => { + if ( + next !== 'same-folder' && + next !== 'content-root' && + next !== 'current-folder-subfolder' && + next !== 'content-root-folder' + ) { + return; + } + const nextMode = next as AttachmentPlacementMode; + const nextFolderText = + nextMode === 'current-folder-subfolder' || nextMode === 'content-root-folder' + ? folderText || ATTACHMENT_FALLBACK_FOLDER + : folderText; + setMode(nextMode); + setFolderText(nextFolderText); + commitPath(attachmentPathFromMode(nextMode, nextFolderText)); + }; + + const commitFolderText = () => { + const nextPath = attachmentPathFromMode(mode, folderText); + setFolderText(attachmentFolderTextFromPath(nextPath)); + commitPath(nextPath); + }; + + const showsFolderInput = mode === 'current-folder-subfolder' || mode === 'content-root-folder'; + const labelId = 'settings-attachments-location-label'; + const inputId = 'settings-attachments-folder-input'; + + return ( +
+
+

+ Attachments +

+

+ Set where pasted and dropped files are stored for this project. +

+
+ +
+
+
+
+ Default location for new attachments +
+

+ Where newly added attachments are placed. +

+
+ +
+ + {showsFolderInput ? ( +
+ + setFolderText(event.target.value)} + onBlur={commitFolderText} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + commitFolderText(); + } + }} + className="h-8 text-sm" + data-testid="settings-attachments-folder" + /> +
+ ) : null} + +
+ {error ? ( +

+ {error} +

+ ) : null} + +
+
+
+ ); +} + function SyncSection() { const { t } = useLingui(); const status = useGitSyncStatus(); diff --git a/packages/app/src/components/settings/SettingsDialogShell.dom.test.tsx b/packages/app/src/components/settings/SettingsDialogShell.dom.test.tsx index b4d100d41..cb8fcc784 100644 --- a/packages/app/src/components/settings/SettingsDialogShell.dom.test.tsx +++ b/packages/app/src/components/settings/SettingsDialogShell.dom.test.tsx @@ -176,6 +176,7 @@ describe('SettingsDialogShell userBinding gating (Tier-3 mount)', () => { expect(screen.getByRole('navigation', { name: 'Settings sections' })).toBeTruthy(); expect(screen.getByText('User')).toBeTruthy(); expect(screen.getByText('This project')).toBeTruthy(); + expect(screen.queryByTestId('settings-sidebar-item-attachments')).toBeNull(); expect(screen.queryByText('Integrations') === null).toBe(true); expect( screen.getByTestId('settings-sidebar-item-preferences').getAttribute('aria-current'), diff --git a/packages/app/src/locales/en/messages.json b/packages/app/src/locales/en/messages.json index 51758f920..b6d8ef281 100644 --- a/packages/app/src/locales/en/messages.json +++ b/packages/app/src/locales/en/messages.json @@ -43,6 +43,7 @@ "0LBDjY": ["\"", ["0"], "\" uninstalled — back to a draft"], "0NA4b3": ["Keep my version"], "0YEbfk": ["Connect to AI tools"], + "0YUS3Q": ["Subfolder under current folder"], "0_J12R": [ "New file from template \"", ["displayTitle"], @@ -424,6 +425,7 @@ "Aq-O0x": ["status"], "AqnqMJ": ["API key set"], "ArBWJf": ["Undo last edit"], + "AsqRE_": ["Default location for new attachments"], "AtBdnD": ["Couldn't delete skill: ", ["error"]], "B11p-O": ["Edit markdown link"], "B2DrPb": ["Auto-save"], @@ -663,6 +665,7 @@ "HfI1dR": ["I already have it locally →"], "Hl89Qd": ["Loading templates"], "HlmZBk": ["Documents response did not match expected shape."], + "Hnl9nT": ["Set where pasted and dropped files are stored for this project."], "HpK_8d": ["Reload"], "HptUxX": ["Number"], "HqiZvw": [ @@ -904,6 +907,7 @@ "OX2Neh": ["Inline code"], "OYnx7O": ["Path cannot start with \"/\""], "OdmsFg": ["Failed to delete item"], + "OeAl1e": ["e.g. assets/uploads"], "OgyJSr": ["Local only"], "OiHJdA": [ "Move through slash, wiki-link, tag, and path suggestions; accept or dismiss the active suggestion." @@ -949,6 +953,7 @@ "Pyp1NA": ["Restore to this point?"], "Q5fqMz": ["Connection error — try again"], "Q6hhn8": ["Preferences"], + "Q7W-mC": ["Same folder as current file"], "Q8A-U_": ["Connected to"], "QDZbvN": [["diffDays"], "d ago"], "QOINDn": ["empty"], @@ -1403,6 +1408,7 @@ "eNQGRb": ["Inline LaTeX math rendered with KaTeX."], "ePK91l": ["Edit"], "eQkgKV": ["Installed"], + "eQxm13": ["Failed to update attachment location — ", ["detail"]], "eSu2Xn": [ "Writes a project-MCP config for each selected tool; Claude also gets <0>.claude/launch.json." ], @@ -1798,6 +1804,7 @@ " · ", ["tabCount", "plural", { "one": ["#", " tab"], "other": ["#", " tabs"] }] ], + "qAx7xG": ["Fixed folder in content root"], "qEIFZu": ["Key ending in ", ["keyHint"]], "qEue6Q": [" or "], "qFWMl-": ["View as text"], @@ -1984,6 +1991,7 @@ "vfWA_g": ["You picked ~/Desktop. OpenKnowledge will index everything on your desktop."], "vhAnzJ": ["Turn on semantic search?"], "viwC-K": ["Failed to remove project."], + "vppAju": ["Where newly added attachments are placed."], "vv7kpg": ["Cancelled"], "vvWZnu": ["Diff unavailable"], "w3tVpc": ["No links yet. Add wiki links or markdown links to build a graph."], @@ -2000,6 +2008,7 @@ "wT4iQr": ["Open visual-editor find with replace controls expanded."], "wTcjov": ["No workspace"], "wXO4Tg": [["hrs"], "h ago"], + "w_Sphq": ["Attachments"], "wbv9xO": ["Create new project"], "wbwHYD": ["Enable sync"], "weP6me": ["Couldn't load docs — type @ again to retry"], @@ -2081,6 +2090,7 @@ "yQE2r9": ["Loading"], "yQyglE": ["No matching docs"], "yUxR-Q": ["Expand ", ["keyName"]], + "yWSvcf": ["Content root"], "yWWThY": ["Asset reference"], "yYR6za": ["Display text"], "yZmvQ4": ["This project's server (v", ["0"], ") is newer than this app (v", ["1"], ")."], diff --git a/packages/app/src/locales/en/messages.po b/packages/app/src/locales/en/messages.po index 1a2f81351..911c4280b 100644 --- a/packages/app/src/locales/en/messages.po +++ b/packages/app/src/locales/en/messages.po @@ -755,6 +755,10 @@ msgstr "Asset reference" msgid "Asset: {title}" msgstr "Asset: {title}" +#: src/components/settings/SettingsDialogBody.tsx +msgid "Attachments" +msgstr "Attachments" + #: src/editor/slash-command/component-items.tsx msgid "Authoritative content; mirrored verbatim everywhere it's referenced." msgstr "Authoritative content; mirrored verbatim everywhere it's referenced." @@ -1404,6 +1408,10 @@ msgstr "Content directory" msgid "Content directory must be inside the project" msgstr "Content directory must be inside the project" +#: src/components/settings/SettingsDialogBody.tsx +msgid "Content root" +msgstr "Content root" + #: src/editor/bubble-menu/FootnoteBubbleButton.tsx msgid "Convert selection to footnote" msgstr "Convert selection to footnote" @@ -1926,6 +1934,10 @@ msgstr "Customize how the editor looks and behaves." msgid "Date" msgstr "Date" +#: src/components/settings/SettingsDialogBody.tsx +msgid "Default location for new attachments" +msgstr "Default location for new attachments" + #: src/components/TemplateForm.tsx msgid "Default properties" msgstr "Default properties" @@ -2224,6 +2236,10 @@ msgstr "Duplicate the focused file-tree item when focus is in the Files sidebar. msgid "Duplicated to \"{0}\"" msgstr "Duplicated to \"{0}\"" +#: src/components/settings/SettingsDialogBody.tsx +msgid "e.g. assets/uploads" +msgstr "e.g. assets/uploads" + #: src/components/settings/OkignoreSection.tsx msgid "e.g. drafts/ or *.draft.md" msgstr "e.g. drafts/ or *.draft.md" @@ -2652,6 +2668,10 @@ msgstr "Failed to render PDF" msgid "Failed to reorder" msgstr "Failed to reorder" +#: src/components/settings/SettingsDialogBody.tsx +msgid "Failed to update attachment location — {detail}" +msgstr "Failed to update attachment location — {detail}" + #: src/components/ObjectWidget.tsx #: src/components/PropertyPanel.tsx msgid "Failed to update property" @@ -2779,6 +2799,10 @@ msgstr "Fit height" msgid "Fit width" msgstr "Fit width" +#: src/components/settings/SettingsDialogBody.tsx +msgid "Fixed folder in content root" +msgstr "Fixed folder in content root" + #: src/lib/keyboard-shortcuts.ts msgid "Fold or unfold source" msgstr "Fold or unfold source" @@ -2793,6 +2817,7 @@ msgstr "Fold, unfold, fold all, or unfold all source ranges." msgid "folder" msgstr "folder" +#: src/components/settings/SettingsDialogBody.tsx #: src/components/share-metadata-rows.tsx #: src/editor/composer-mention/ComposerMentionMenu.tsx #: src/editor/extensions/InternalLinkPropPanel.tsx @@ -5225,6 +5250,10 @@ msgstr "Run a real terminal docked inside OpenKnowledge, starting in this projec msgid "Run this in your terminal" msgstr "Run this in your terminal" +#: src/components/settings/SettingsDialogBody.tsx +msgid "Same folder as current file" +msgstr "Same folder as current file" + #: src/editor/slash-command/embed-starter-items.tsx msgid "Sandboxed HTML embed — write HTML, see the rendered preview live." msgstr "Sandboxed HTML embed — write HTML, see the rendered preview live." @@ -5488,6 +5517,10 @@ msgstr "Set the auto-sync default for users opening this project for the first t msgid "Set up syncing" msgstr "Set up syncing" +#: src/components/settings/SettingsDialogBody.tsx +msgid "Set where pasted and dropped files are stored for this project." +msgstr "Set where pasted and dropped files are stored for this project." + #: src/components/SeedDialog.tsx msgid "Setting up" msgstr "Setting up" @@ -5842,6 +5875,10 @@ msgstr "Stored on this machine." msgid "Strikethrough" msgstr "Strikethrough" +#: src/components/settings/SettingsDialogBody.tsx +msgid "Subfolder under current folder" +msgstr "Subfolder under current folder" + #: src/lib/keyboard-shortcuts.ts msgid "Suggestion menu navigation" msgstr "Suggestion menu navigation" @@ -6793,6 +6830,10 @@ msgstr "What would you like to create?" msgid "When enabled, the agent opens or refreshes the preview after each edit. Disable if you manage your own preview window (OK Desktop, a browser tab on another display, etc.)." msgstr "When enabled, the agent opens or refreshes the preview after each edit. Disable if you manage your own preview window (OK Desktop, a browser tab on another display, etc.)." +#: src/components/settings/SettingsDialogBody.tsx +msgid "Where newly added attachments are placed." +msgstr "Where newly added attachments are placed." + #: src/components/SeedDialog.tsx msgid "Where should it live?" msgstr "Where should it live?" diff --git a/packages/app/src/locales/pseudo/messages.json b/packages/app/src/locales/pseudo/messages.json index 9539593bb..9e796e527 100644 --- a/packages/app/src/locales/pseudo/messages.json +++ b/packages/app/src/locales/pseudo/messages.json @@ -43,6 +43,7 @@ "0LBDjY": ["\"", ["0"], "\" ũńĩńśţàĺĺēď — ƀàćķ ţō à ďŕàƒţ"], "0NA4b3": ["Ķēēƥ ḿŷ vēŕśĩōń"], "0YEbfk": ["Ćōńńēćţ ţō ÀĨ ţōōĺś"], + "0YUS3Q": ["Śũƀƒōĺďēŕ ũńďēŕ ćũŕŕēńţ ƒōĺďēŕ"], "0_J12R": [ "Ńēŵ ƒĩĺē ƒŕōḿ ţēḿƥĺàţē \"", ["displayTitle"], @@ -424,6 +425,7 @@ "Aq-O0x": ["śţàţũś"], "AqnqMJ": ["ÀƤĨ ķēŷ śēţ"], "ArBWJf": ["Ũńďō ĺàśţ ēďĩţ"], + "AsqRE_": ["Ďēƒàũĺţ ĺōćàţĩōń ƒōŕ ńēŵ àţţàćĥḿēńţś"], "AtBdnD": ["Ćōũĺďń'ţ ďēĺēţē śķĩĺĺ: ", ["error"]], "B11p-O": ["Ēďĩţ ḿàŕķďōŵń ĺĩńķ"], "B2DrPb": ["Àũţō-śàvē"], @@ -663,6 +665,7 @@ "HfI1dR": ["Ĩ àĺŕēàďŷ ĥàvē ĩţ ĺōćàĺĺŷ →"], "Hl89Qd": ["Ĺōàďĩńĝ ţēḿƥĺàţēś"], "HlmZBk": ["Ďōćũḿēńţś ŕēśƥōńśē ďĩď ńōţ ḿàţćĥ ēxƥēćţēď śĥàƥē."], + "Hnl9nT": ["Śēţ ŵĥēŕē ƥàśţēď àńď ďŕōƥƥēď ƒĩĺēś àŕē śţōŕēď ƒōŕ ţĥĩś ƥŕōĴēćţ."], "HpK_8d": ["Ŕēĺōàď"], "HptUxX": ["Ńũḿƀēŕ"], "HqiZvw": [ @@ -904,6 +907,7 @@ "OX2Neh": ["Ĩńĺĩńē ćōďē"], "OYnx7O": ["Ƥàţĥ ćàńńōţ śţàŕţ ŵĩţĥ \"/\""], "OdmsFg": ["Ƒàĩĺēď ţō ďēĺēţē ĩţēḿ"], + "OeAl1e": ["ē.ĝ. àśśēţś/ũƥĺōàďś"], "OgyJSr": ["Ĺōćàĺ ōńĺŷ"], "OiHJdA": [ "Ḿōvē ţĥŕōũĝĥ śĺàśĥ, ŵĩķĩ-ĺĩńķ, ţàĝ, àńď ƥàţĥ śũĝĝēśţĩōńś; àććēƥţ ōŕ ďĩśḿĩśś ţĥē àćţĩvē śũĝĝēśţĩōń." @@ -949,6 +953,7 @@ "Pyp1NA": ["Ŕēśţōŕē ţō ţĥĩś ƥōĩńţ?"], "Q5fqMz": ["Ćōńńēćţĩōń ēŕŕōŕ — ţŕŷ àĝàĩń"], "Q6hhn8": ["Ƥŕēƒēŕēńćēś"], + "Q7W-mC": ["Śàḿē ƒōĺďēŕ àś ćũŕŕēńţ ƒĩĺē"], "Q8A-U_": ["Ćōńńēćţēď ţō"], "QDZbvN": [["diffDays"], "ď àĝō"], "QOINDn": ["ēḿƥţŷ"], @@ -1403,6 +1408,7 @@ "eNQGRb": ["Ĩńĺĩńē ĹàŢēX ḿàţĥ ŕēńďēŕēď ŵĩţĥ ĶàŢēX."], "ePK91l": ["Ēďĩţ"], "eQkgKV": ["Ĩńśţàĺĺēď"], + "eQxm13": ["Ƒàĩĺēď ţō ũƥďàţē àţţàćĥḿēńţ ĺōćàţĩōń — ", ["detail"]], "eSu2Xn": [ "Ŵŕĩţēś à ƥŕōĴēćţ-ḾĆƤ ćōńƒĩĝ ƒōŕ ēàćĥ śēĺēćţēď ţōōĺ; Ćĺàũďē àĺśō ĝēţś <0>.ćĺàũďē/ĺàũńćĥ.Ĵśōń." ], @@ -1798,6 +1804,7 @@ " · ", ["tabCount", "plural", { "one": ["#", " ţàƀ"], "other": ["#", " ţàƀś"] }] ], + "qAx7xG": ["Ƒĩxēď ƒōĺďēŕ ĩń ćōńţēńţ ŕōōţ"], "qEIFZu": ["Ķēŷ ēńďĩńĝ ĩń ", ["keyHint"]], "qEue6Q": [" ōŕ "], "qFWMl-": ["Vĩēŵ àś ţēxţ"], @@ -1984,6 +1991,7 @@ "vfWA_g": ["Ŷōũ ƥĩćķēď ~/Ďēśķţōƥ. ŌƥēńĶńōŵĺēďĝē ŵĩĺĺ ĩńďēx ēvēŕŷţĥĩńĝ ōń ŷōũŕ ďēśķţōƥ."], "vhAnzJ": ["Ţũŕń ōń śēḿàńţĩć śēàŕćĥ?"], "viwC-K": ["Ƒàĩĺēď ţō ŕēḿōvē ƥŕōĴēćţ."], + "vppAju": ["Ŵĥēŕē ńēŵĺŷ àďďēď àţţàćĥḿēńţś àŕē ƥĺàćēď."], "vv7kpg": ["Ćàńćēĺĺēď"], "vvWZnu": ["Ďĩƒƒ ũńàvàĩĺàƀĺē"], "w3tVpc": ["Ńō ĺĩńķś ŷēţ. Àďď ŵĩķĩ ĺĩńķś ōŕ ḿàŕķďōŵń ĺĩńķś ţō ƀũĩĺď à ĝŕàƥĥ."], @@ -2000,6 +2008,7 @@ "wT4iQr": ["Ōƥēń vĩśũàĺ-ēďĩţōŕ ƒĩńď ŵĩţĥ ŕēƥĺàćē ćōńţŕōĺś ēxƥàńďēď."], "wTcjov": ["Ńō ŵōŕķśƥàćē"], "wXO4Tg": [["hrs"], "ĥ àĝō"], + "w_Sphq": ["Àţţàćĥḿēńţś"], "wbv9xO": ["Ćŕēàţē ńēŵ ƥŕōĴēćţ"], "wbwHYD": ["Ēńàƀĺē śŷńć"], "weP6me": ["Ćōũĺďń'ţ ĺōàď ďōćś — ţŷƥē @ àĝàĩń ţō ŕēţŕŷ"], @@ -2081,6 +2090,7 @@ "yQE2r9": ["Ĺōàďĩńĝ"], "yQyglE": ["Ńō ḿàţćĥĩńĝ ďōćś"], "yUxR-Q": ["Ēxƥàńď ", ["keyName"]], + "yWSvcf": ["Ćōńţēńţ ŕōōţ"], "yWWThY": ["Àśśēţ ŕēƒēŕēńćē"], "yYR6za": ["Ďĩśƥĺàŷ ţēxţ"], "yZmvQ4": ["Ţĥĩś ƥŕōĴēćţ'ś śēŕvēŕ (v", ["0"], ") ĩś ńēŵēŕ ţĥàń ţĥĩś àƥƥ (v", ["1"], ")."], diff --git a/packages/app/src/locales/pseudo/messages.po b/packages/app/src/locales/pseudo/messages.po index 16568788b..677443900 100644 --- a/packages/app/src/locales/pseudo/messages.po +++ b/packages/app/src/locales/pseudo/messages.po @@ -750,6 +750,10 @@ msgstr "" msgid "Asset: {title}" msgstr "" +#: src/components/settings/SettingsDialogBody.tsx +msgid "Attachments" +msgstr "" + #: src/editor/slash-command/component-items.tsx msgid "Authoritative content; mirrored verbatim everywhere it's referenced." msgstr "" @@ -1399,6 +1403,10 @@ msgstr "" msgid "Content directory must be inside the project" msgstr "" +#: src/components/settings/SettingsDialogBody.tsx +msgid "Content root" +msgstr "" + #: src/editor/bubble-menu/FootnoteBubbleButton.tsx msgid "Convert selection to footnote" msgstr "" @@ -1921,6 +1929,10 @@ msgstr "" msgid "Date" msgstr "" +#: src/components/settings/SettingsDialogBody.tsx +msgid "Default location for new attachments" +msgstr "" + #: src/components/TemplateForm.tsx msgid "Default properties" msgstr "" @@ -2219,6 +2231,10 @@ msgstr "" msgid "Duplicated to \"{0}\"" msgstr "" +#: src/components/settings/SettingsDialogBody.tsx +msgid "e.g. assets/uploads" +msgstr "" + #: src/components/settings/OkignoreSection.tsx msgid "e.g. drafts/ or *.draft.md" msgstr "" @@ -2647,6 +2663,10 @@ msgstr "" msgid "Failed to reorder" msgstr "" +#: src/components/settings/SettingsDialogBody.tsx +msgid "Failed to update attachment location — {detail}" +msgstr "" + #: src/components/ObjectWidget.tsx #: src/components/PropertyPanel.tsx msgid "Failed to update property" @@ -2774,6 +2794,10 @@ msgstr "" msgid "Fit width" msgstr "" +#: src/components/settings/SettingsDialogBody.tsx +msgid "Fixed folder in content root" +msgstr "" + #: src/lib/keyboard-shortcuts.ts msgid "Fold or unfold source" msgstr "" @@ -2788,6 +2812,7 @@ msgstr "" msgid "folder" msgstr "" +#: src/components/settings/SettingsDialogBody.tsx #: src/components/share-metadata-rows.tsx #: src/editor/composer-mention/ComposerMentionMenu.tsx #: src/editor/extensions/InternalLinkPropPanel.tsx @@ -5220,6 +5245,10 @@ msgstr "" msgid "Run this in your terminal" msgstr "" +#: src/components/settings/SettingsDialogBody.tsx +msgid "Same folder as current file" +msgstr "" + #: src/editor/slash-command/embed-starter-items.tsx msgid "Sandboxed HTML embed — write HTML, see the rendered preview live." msgstr "" @@ -5483,6 +5512,10 @@ msgstr "" msgid "Set up syncing" msgstr "" +#: src/components/settings/SettingsDialogBody.tsx +msgid "Set where pasted and dropped files are stored for this project." +msgstr "" + #: src/components/SeedDialog.tsx msgid "Setting up" msgstr "" @@ -5837,6 +5870,10 @@ msgstr "" msgid "Strikethrough" msgstr "" +#: src/components/settings/SettingsDialogBody.tsx +msgid "Subfolder under current folder" +msgstr "" + #: src/lib/keyboard-shortcuts.ts msgid "Suggestion menu navigation" msgstr "" @@ -6788,6 +6825,10 @@ msgstr "" msgid "When enabled, the agent opens or refreshes the preview after each edit. Disable if you manage your own preview window (OK Desktop, a browser tab on another display, etc.)." msgstr "" +#: src/components/settings/SettingsDialogBody.tsx +msgid "Where newly added attachments are placed." +msgstr "" + #: src/components/SeedDialog.tsx msgid "Where should it live?" msgstr "" diff --git a/packages/core/src/config/field-registry.test.ts b/packages/core/src/config/field-registry.test.ts index c469bbe91..6bfad967e 100644 --- a/packages/core/src/config/field-registry.test.ts +++ b/packages/core/src/config/field-registry.test.ts @@ -31,6 +31,13 @@ describe('getFieldMeta walker (descends innerType)', () => { expect(getFieldMeta(wrapped)).toEqual({ scope: 'user', agentSettable: false }); }); + test('descends through .refine()', () => { + const inner = z.string(); + fieldRegistry.add(inner, { scope: 'project', agentSettable: false }); + const wrapped = inner.refine(() => true).default('x'); + expect(getFieldMeta(wrapped)).toEqual({ scope: 'project', agentSettable: false }); + }); + test('descends through chained .optional().nullable().default()', () => { const inner = z.number(); fieldRegistry.add(inner, { scope: 'project', agentSettable: true }); @@ -136,7 +143,7 @@ describe('ConfigSchema coverage (NR3 — every leaf has fieldRegistry metadata)' ]); }); - test('project-strict fields cover autoSync.default + content.dir + telemetry.localSink.*', () => { + test('project-strict fields cover autoSync.default + content.* + telemetry.localSink.*', () => { const leaves: { path: string[]; schema: unknown }[] = []; walkLeaves(ConfigSchema, [], leaves); const projectStrict = leaves @@ -145,6 +152,7 @@ describe('ConfigSchema coverage (NR3 — every leaf has fieldRegistry metadata)' .sort(); expect(projectStrict).toEqual([ 'autoSync.default', + 'content.attachmentFolderPath', 'content.dir', 'telemetry.localSink.attributeDenylist', 'telemetry.localSink.enabled', diff --git a/packages/core/src/config/schema.test.ts b/packages/core/src/config/schema.test.ts new file mode 100644 index 000000000..15f2ec389 --- /dev/null +++ b/packages/core/src/config/schema.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, test } from 'bun:test'; +import { + ConfigSchema, + isValidAttachmentFolderPath, + normalizeAttachmentFolderPath, +} from './schema.ts'; + +describe('content.attachmentFolderPath', () => { + test('defaults to "./" when absent', () => { + expect(ConfigSchema.parse({}).content.attachmentFolderPath).toBe('./'); + }); + + test('defaults to "./" when key is absent inside content', () => { + expect(ConfigSchema.parse({ content: { dir: 'docs' } }).content.attachmentFolderPath).toBe( + './', + ); + }); + + test('accepts "./" (colocated with current document)', () => { + expect( + ConfigSchema.parse({ content: { attachmentFolderPath: './' } }).content.attachmentFolderPath, + ).toBe('./'); + }); + + test('accepts "/" (content-root sentinel)', () => { + expect( + ConfigSchema.parse({ content: { attachmentFolderPath: '/' } }).content.attachmentFolderPath, + ).toBe('/'); + }); + + test('accepts "./attachments" (subfolder under current document folder)', () => { + expect( + ConfigSchema.parse({ content: { attachmentFolderPath: './attachments' } }).content + .attachmentFolderPath, + ).toBe('./attachments'); + }); + + test('accepts "attachments" (fixed folder under content root)', () => { + expect( + ConfigSchema.parse({ content: { attachmentFolderPath: 'attachments' } }).content + .attachmentFolderPath, + ).toBe('attachments'); + }); + + test('accepts "assets/uploads" (nested path under content root)', () => { + expect( + ConfigSchema.parse({ content: { attachmentFolderPath: 'assets/uploads' } }).content + .attachmentFolderPath, + ).toBe('assets/uploads'); + }); + + test('normalizes empty string to "./"', () => { + expect(normalizeAttachmentFolderPath('')).toBe('./'); + expect(isValidAttachmentFolderPath('')).toBe(true); + }); + + test('normalizes whitespace-only to "./"', () => { + expect(normalizeAttachmentFolderPath(' ')).toBe('./'); + expect(isValidAttachmentFolderPath(' ')).toBe(true); + }); + + test('rejects ".." traversal segment', () => { + expect(() => ConfigSchema.parse({ content: { attachmentFolderPath: '..' } })).toThrow(); + }); + + test('rejects "../escape" traversal', () => { + expect(() => ConfigSchema.parse({ content: { attachmentFolderPath: '../escape' } })).toThrow(); + }); + + test('rejects nested traversal "good/../../../etc"', () => { + expect(() => + ConfigSchema.parse({ content: { attachmentFolderPath: 'good/../../../etc' } }), + ).toThrow(); + }); + + test('rejects NUL byte', () => { + expect(() => + ConfigSchema.parse({ content: { attachmentFolderPath: 'attach\0ments' } }), + ).toThrow(); + }); + + test('rejects backslash', () => { + expect(() => + ConfigSchema.parse({ content: { attachmentFolderPath: 'attach\\ments' } }), + ).toThrow(); + }); + + test('rejects absolute POSIX path "/etc/passwd"', () => { + expect(() => + ConfigSchema.parse({ content: { attachmentFolderPath: '/etc/passwd' } }), + ).toThrow(); + }); + + test('rejects absolute POSIX path "/attachments"', () => { + expect(() => + ConfigSchema.parse({ content: { attachmentFolderPath: '/attachments' } }), + ).toThrow(); + }); + + test('rejects Windows drive-letter path "C:/"', () => { + expect(() => ConfigSchema.parse({ content: { attachmentFolderPath: 'C:/' } })).toThrow(); + }); + + test('rejects Windows drive-letter path "D:attachments"', () => { + expect(() => + ConfigSchema.parse({ content: { attachmentFolderPath: 'D:attachments' } }), + ).toThrow(); + }); +}); + +describe('legacy upload.* keys remain non-authoritative', () => { + test('upload.* keys pass through looseObject without schema error', () => { + const result = ConfigSchema.safeParse({ + upload: { attachmentFolder: 'attachments', maxSize: 10485760 }, + }); + expect(result.success).toBe(true); + }); +}); diff --git a/packages/core/src/config/schema.ts b/packages/core/src/config/schema.ts index f8424de7e..f7edd72df 100644 --- a/packages/core/src/config/schema.ts +++ b/packages/core/src/config/schema.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { DEFAULT_ATTACHMENT_FOLDER_PATH } from '../constants/upload.ts'; import { fieldRegistry } from './field-registry.ts'; export const DEFAULT_TELEMETRY_ATTRIBUTE_DENYLIST: readonly string[] = Object.freeze([ @@ -18,6 +19,23 @@ export const DEFAULT_LOGS_MAX_BYTES = 26_214_400; export const DEFAULT_EMBEDDINGS_BASE_URL = 'https://api.openai.com/v1'; export const DEFAULT_EMBEDDINGS_MODEL = 'text-embedding-3-small'; +export function normalizeAttachmentFolderPath(value: string): string { + const trimmed = value.trim(); + return trimmed === '' ? DEFAULT_ATTACHMENT_FOLDER_PATH : trimmed; +} + +export function isValidAttachmentFolderPath(value: string): boolean { + const normalized = normalizeAttachmentFolderPath(value); + if (normalized.includes('\0')) return false; + if (normalized.includes('\\')) return false; + if (normalized === '/') return true; + if (normalized.startsWith('/')) return false; + if (/^[A-Za-z]:/.test(normalized)) return false; + const segments = normalized.split('/'); + if (segments.some((seg) => seg === '..')) return false; + return true; +} + export const ConfigSchema = z.looseObject({ content: z .looseObject({ @@ -31,9 +49,24 @@ export const ConfigSchema = z.looseObject({ 'Folder OpenKnowledge reads and writes documents under, relative to the project root (the folder that contains .ok/). Defaults to the project root. Exclude paths with .okignore.', }) .default('.'), + attachmentFolderPath: z + .string() + .register(fieldRegistry, { + scope: 'project', + agentSettable: false, + defaultScope: 'project', + description: + "Where pasted and dropped assets are stored, relative to the content root. './' colocates beside the current document (default); '/' targets the content root; './subdir' targets a subfolder under the current document folder; 'folder' targets a fixed folder under the content root. Whitespace-only values are treated as './'.", + }) + .refine(isValidAttachmentFolderPath, { + message: + "Invalid attachment folder path: must not contain '..' segments, NUL bytes, backslashes, or OS absolute paths (use '/' for the content root).", + }) + .default(DEFAULT_ATTACHMENT_FOLDER_PATH), }) .default({ dir: '.', + attachmentFolderPath: DEFAULT_ATTACHMENT_FOLDER_PATH, }), appearance: z .looseObject({ diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 4dba21a4f..6e8611aee 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -142,6 +142,8 @@ export { DEFAULT_LOGS_MAX_BYTES, DEFAULT_SPANS_MAX_BYTES, DEFAULT_TELEMETRY_ATTRIBUTE_DENYLIST, + isValidAttachmentFolderPath, + normalizeAttachmentFolderPath, } from './config/schema.ts'; export { getLeafFieldMeta, resolveLeafSchema } from './config/schema-leaf.ts'; export { CONFIG_SCHEMA_MAJOR, CONFIG_SCHEMA_MAJOR_PATH } from './config/schema-version.ts'; diff --git a/packages/core/src/schemas/api.type-tests.ts b/packages/core/src/schemas/api.type-tests.ts index a63b58092..8d19dc614 100644 --- a/packages/core/src/schemas/api.type-tests.ts +++ b/packages/core/src/schemas/api.type-tests.ts @@ -61,11 +61,15 @@ void _missingSrc; const _validHasSrc: UploadAssetSuccess = { src: 'foo.png' }; void _validHasSrc; -const _validRequest: UploadRequest = { parentDocName: 'notes/index' }; +const _validRequest: UploadRequest = { + parentDocName: 'notes/index', + placement: 'configured-attachments', +}; void _validRequest; const _withAgentIdentity: UploadRequest = { parentDocName: 'notes/index', + placement: 'parent-dir', agentId: 'claude-1', agentName: 'Claude', }; diff --git a/packages/core/src/schemas/api/_envelope.test.ts b/packages/core/src/schemas/api/_envelope.test.ts index 5e831ca3a..bdfdd85cf 100644 --- a/packages/core/src/schemas/api/_envelope.test.ts +++ b/packages/core/src/schemas/api/_envelope.test.ts @@ -332,6 +332,17 @@ describe('UploadRequestSchema', () => { expect(result.success).toBe(true); if (result.success) { expect(result.data.parentDocName).toBe('notes/index'); + expect(result.data.placement).toBe('configured-attachments'); + } + }); + + test('parses explicit upload placement modes', () => { + for (const placement of ['configured-attachments', 'parent-dir'] as const) { + const result = UploadRequestSchema.safeParse({ parentDocName: 'notes/index', placement }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.placement).toBe(placement); + } } }); @@ -357,6 +368,14 @@ describe('UploadRequestSchema', () => { const result = UploadRequestSchema.safeParse({ parentDocName: '' }); expect(result.success).toBe(false); }); + + test('fails when placement is unknown', () => { + const result = UploadRequestSchema.safeParse({ + parentDocName: 'notes/index', + placement: 'somewhere-else', + }); + expect(result.success).toBe(false); + }); }); describe('LocalOpCloneRequestSchema (US-005)', () => { diff --git a/packages/core/src/schemas/api/_envelope.ts b/packages/core/src/schemas/api/_envelope.ts index 3dc46a668..368247080 100644 --- a/packages/core/src/schemas/api/_envelope.ts +++ b/packages/core/src/schemas/api/_envelope.ts @@ -119,6 +119,7 @@ export type ProblemDetails = z.infer; export const UploadRequestSchema = z .object({ parentDocName: z.string().min(1), + placement: z.enum(['configured-attachments', 'parent-dir']).default('configured-attachments'), agentId: z.string().min(1).optional(), agentName: z.string().min(1).optional(), }) diff --git a/packages/server/src/api-extension.test.ts b/packages/server/src/api-extension.test.ts index c7432e5c2..3e2494783 100644 --- a/packages/server/src/api-extension.test.ts +++ b/packages/server/src/api-extension.test.ts @@ -156,10 +156,14 @@ describe('handleUploadAsset', () => { let contentDir: string; let server: import('node:http').Server; let port: number; + let attachmentFolderPath: string; + let getAttachmentFolderPath: () => string; beforeEach(async () => { tmpDir = await mkdtemp(join(tmpdir(), 'upload-test-')); contentDir = join(tmpDir, 'content'); + attachmentFolderPath = './'; + getAttachmentFolderPath = () => attachmentFolderPath; mkdirSync(contentDir, { recursive: true }); mkdirSync(join(contentDir, 'docs'), { recursive: true }); writeFileSync(join(contentDir, 'docs', 'guide.md'), '# Guide'); @@ -175,6 +179,7 @@ describe('handleUploadAsset', () => { sessionManager, contentDir, getFileIndex: () => new Map(), + getAttachmentFolderPath: () => getAttachmentFolderPath(), serverInstanceId: 'test-instance', }); @@ -216,9 +221,11 @@ describe('handleUploadAsset', () => { file: Buffer, filename: string, parentDocName: string, + placement?: 'configured-attachments' | 'parent-dir', ): Promise { const formData = new FormData(); formData.append('parentDocName', parentDocName); + if (placement !== undefined) formData.append('placement', placement); formData.append('file', new Blob([file]), filename); return fetch(`http://127.0.0.1:${port}/api/upload`, { method: 'POST', @@ -238,6 +245,70 @@ describe('handleUploadAsset', () => { expect((body as Record).ok).toBeUndefined(); }); + test('configured content-root folder stores upload under fixed attachments directory', async () => { + attachmentFolderPath = 'attachments'; + const res = await uploadImage(createPngBuffer(), 'diagram.png', 'docs/guide.md'); + const body = (await res.json()) as { src: string; path: string; deduped: boolean }; + expect(res.status).toBe(200); + expect(body.src).toBe('diagram.png'); + expect(body.path).toBe('attachments/diagram.png'); + expect(body.deduped).toBe(false); + expect(existsSync(join(contentDir, 'attachments', 'diagram.png'))).toBe(true); + expect(existsSync(join(contentDir, 'docs', 'diagram.png'))).toBe(false); + }); + + test('configured current-folder subfolder stores upload beside current document folder', async () => { + attachmentFolderPath = './attachments'; + const res = await uploadImage(createPngBuffer(), 'diagram.png', 'docs/guide.md'); + const body = (await res.json()) as { src: string; path: string }; + expect(res.status).toBe(200); + expect(body.path).toBe('docs/attachments/diagram.png'); + expect(existsSync(join(contentDir, 'docs', 'attachments', 'diagram.png'))).toBe(true); + }); + + test('configured content-root sentinel stores upload at the content root', async () => { + attachmentFolderPath = '/'; + const res = await uploadImage(createPngBuffer(), 'rooted.png', 'docs/guide.md'); + const body = (await res.json()) as { src: string; path: string }; + expect(res.status).toBe(200); + expect(body.path).toBe('rooted.png'); + expect(existsSync(join(contentDir, 'rooted.png'))).toBe(true); + }); + + test('invalid configured attachment folder is rejected before final upload write', async () => { + attachmentFolderPath = '../escape'; + const res = await uploadImage(createPngBuffer(), 'escape.png', 'docs/guide.md'); + const body = (await res.json()) as { type: string; status: number }; + expect(res.status).toBe(400); + expect(body.type).toBe('urn:ok:error:invalid-request'); + expect(body.status).toBe(400); + expect(existsSync(join(tmpDir, 'escape', 'escape.png'))).toBe(false); + expect(existsSync(join(contentDir, 'docs', 'escape.png'))).toBe(false); + }); + + test('throwing attachment config reader reports a server configuration error', async () => { + getAttachmentFolderPath = () => { + throw new Error('invalid project config'); + }; + const res = await uploadImage(createPngBuffer(), 'config-error.png', 'docs/guide.md'); + const body = (await res.json()) as { type: string; status: number; title: string }; + expect(res.status).toBe(500); + expect(body.type).toBe('urn:ok:error:internal-server-error'); + expect(body.status).toBe(500); + expect(body.title).toBe('Server configuration error: invalid attachment folder path.'); + expect(existsSync(join(contentDir, 'docs', 'config-error.png'))).toBe(false); + }); + + test('parent-dir placement keeps explicit sidebar folder drops in the target folder', async () => { + attachmentFolderPath = 'attachments'; + const res = await uploadImage(createPngBuffer(), 'clip.png', 'docs/guide.md', 'parent-dir'); + const body = (await res.json()) as { src: string; path: string }; + expect(res.status).toBe(200); + expect(body.path).toBe('docs/clip.png'); + expect(existsSync(join(contentDir, 'docs', 'clip.png'))).toBe(true); + expect(existsSync(join(contentDir, 'attachments', 'clip.png'))).toBe(false); + }); + test('rejects missing parentDocName', async () => { const formData = new FormData(); formData.append('file', new Blob([createPngBuffer()]), 'test.png'); diff --git a/packages/server/src/api-extension.ts b/packages/server/src/api-extension.ts index 48cc5a373..441935212 100644 --- a/packages/server/src/api-extension.ts +++ b/packages/server/src/api-extension.ts @@ -90,6 +90,7 @@ import { instantiateDoc, isHiddenDocName, isManagedArtifactDocName, + isValidAttachmentFolderPath, LINKABLE_ASSET_EXTENSIONS, type LifecycleStatus, LinkGraphSuccessSchema, @@ -110,6 +111,7 @@ import { MetricsParseHealthSuccessSchema, MetricsReconciliationSuccessSchema, mediaKindForSidebarAssetExtension, + normalizeAttachmentFolderPath, OK_DIR, OrphansSuccessSchema, PageHeadingsSuccessSchema, @@ -825,6 +827,7 @@ interface UploadResult { filename: string; mimeType: string; parentDocName: string; + placement: string; tempPath: string; sha: string; byteLength: number; @@ -847,6 +850,7 @@ function readUploadBody(req: IncomingMessage, projectDir: string): Promise { if (name === 'parentDocName') parentDocName = val; + if (name === 'placement') placement = val; }); bb.on('file', (_fieldname, file, info) => { @@ -894,6 +899,7 @@ function readUploadBody(req: IncomingMessage, projectDir: string): Promise ReadonlyMap; + getAttachmentFolderPath?: () => string; getAllFilesIndex?: () => ReadonlyMap; getFileIndexGeneration?: () => number; mutateFileIndex?: (event: DiskEvent) => void; @@ -1846,6 +1854,7 @@ export function createApiExtension(options: ApiExtensionOptions): Extension { contentDir, serverInstanceId, getFileIndex, + getAttachmentFolderPath, getAllFilesIndex = getFileIndex, mutateFileIndex = (event: DiskEvent) => applyDiskEventToLiveAllFilesIndex(event, getAllFilesIndex), @@ -8155,7 +8164,14 @@ export function createApiExtension(options: ApiExtensionOptions): Extension { return; } - const { filename, tempPath, sha, byteLength, parentDocName: rawParentDocName } = uploadResult; + const { + filename, + tempPath, + sha, + byteLength, + parentDocName: rawParentDocName, + placement: rawPlacement, + } = uploadResult; const cleanupTempfile = () => { if (existsSync(tempPath)) { @@ -8165,14 +8181,19 @@ export function createApiExtension(options: ApiExtensionOptions): Extension { } }; - const validated = validateBody(UploadRequestSchema, { parentDocName: rawParentDocName }, res, { - handler: 'upload-asset', - }); + const validated = validateBody( + UploadRequestSchema, + { parentDocName: rawParentDocName, placement: rawPlacement || undefined }, + res, + { + handler: 'upload-asset', + }, + ); if (!validated.ok) { cleanupTempfile(); return; } - const { parentDocName } = validated.value; + const { parentDocName, placement } = validated.value; const { agentId, agentName } = extractAgentIdentity( Object.fromEntries(new URL(req.url ?? '', 'http://localhost').searchParams.entries()), @@ -8199,11 +8220,36 @@ export function createApiExtension(options: ApiExtensionOptions): Extension { } const resolvedContentDir = resolve(contentDir); - const destDir = resolveUploadDestDir( - parentDocName, - DEFAULT_ATTACHMENT_FOLDER_PATH, - resolvedContentDir, - ); + let rawAttachmentFolderPath: string; + try { + rawAttachmentFolderPath = + placement === 'parent-dir' + ? DEFAULT_ATTACHMENT_FOLDER_PATH + : (getAttachmentFolderPath?.() ?? DEFAULT_ATTACHMENT_FOLDER_PATH); + } catch (err) { + cleanupTempfile(); + log.error({ err }, '[upload] project config has invalid content.attachmentFolderPath'); + errorResponse( + res, + 500, + 'urn:ok:error:internal-server-error', + 'Server configuration error: invalid attachment folder path.', + { + handler: 'upload-asset', + cause: err, + }, + ); + return; + } + if (!isValidAttachmentFolderPath(rawAttachmentFolderPath)) { + cleanupTempfile(); + errorResponse(res, 400, 'urn:ok:error:invalid-request', 'Invalid attachment folder path.', { + handler: 'upload-asset', + }); + return; + } + const attachmentFolderPath = normalizeAttachmentFolderPath(rawAttachmentFolderPath); + const destDir = resolveUploadDestDir(parentDocName, attachmentFolderPath, resolvedContentDir); if (!isWithinContentDir(destDir, resolvedContentDir)) { cleanupTempfile(); errorResponse(res, 400, 'urn:ok:error:path-escape', 'Path escape detected.', { diff --git a/packages/server/src/server-factory.ts b/packages/server/src/server-factory.ts index aeb78aa71..0454993ca 100644 --- a/packages/server/src/server-factory.ts +++ b/packages/server/src/server-factory.ts @@ -11,8 +11,11 @@ import { CONFIG_DOC_NAME_PROJECT_LOCAL, CONFIG_DOC_NAME_USER, CONFIG_DOC_NAMES, + type ConfigIssue, createBasenameIndex, + DEFAULT_ATTACHMENT_FOLDER_PATH, humanFormat, + isKnownConfigError, type MarkdownManager, type Principal, parseGlobalSkillBundleDoc, @@ -242,6 +245,31 @@ export function createServer(options: ServerOptions): ServerInstance { const log = getLogger('server'); + function readProjectAttachmentFolderPath(): string { + const project = readConfigSafely({ + absPath: resolveConfigPath('project', projectDir), + sideline: false, + warn: (message) => log.warn({ message }, '[config] could not read project config'), + }); + if (!project.valid) { + const attachmentIssues = + isKnownConfigError(project.error) && project.error.code === 'SCHEMA_INVALID' + ? (project.error.issues as ConfigIssue[]).filter( + (issue) => issue.path.map(String).join('.') === 'content.attachmentFolderPath', + ) + : []; + if (attachmentIssues.length > 0) { + const details = attachmentIssues.map((issue) => issue.message).join('; '); + throw new Error(`Invalid content.attachmentFolderPath in project config: ${details}`); + } + log.warn( + {}, + '[config] committed content.attachmentFolderPath unavailable (project config invalid) — using default attachment placement', + ); + } + return project.value.content.attachmentFolderPath ?? DEFAULT_ATTACHMENT_FOLDER_PATH; + } + function readProjectAutoSyncEnabled(): boolean { const local = readConfigSafely({ absPath: resolveConfigPath('project-local', projectDir), @@ -705,6 +733,7 @@ export function createServer(options: ServerOptions): ServerInstance { contentFilter, serverInstanceId, getFileIndex: () => (watcher ? watcher.getFileIndex() : new Map()), + getAttachmentFolderPath: readProjectAttachmentFolderPath, getAllFilesIndex: () => (watcher ? watcher.getAllFilesIndex() : new Map()), getFileIndexGeneration: () => watcher?.getFileIndexGeneration() ?? 0, mutateFileIndex: (event) => watcher?.mutateFileIndex(event),