# Attachments

How embedded images, PDFs, and other binary assets in vault pages land in Maxy.

## Syntax surface

The parser recognises two embed forms:

| Form | Example | Notes |
|------|---------|-------|
| Obsidian embed | `![[Diagram.png]]` | Default Obsidian image / PDF embed; routes to attachments only when the target has a supported extension. |
| Markdown image | `![alt text](path/to/image.jpg)` | Standard CommonMark; `alt` is preserved on the `:DigitalDocument` node. Remote URLs (`http://`, `https://`) are ignored — only vault-local paths are ingested. |

Embeds without a supported attachment extension (`![[Another Page]]`) are treated as wikilinks, not attachments — see [wikilinks.md](wikilinks.md).

## Supported extensions

The current parser pipeline copies files with these extensions and stamps the matching `encodingFormat` MIME on the `:DigitalDocument`:

| Extension | MIME |
|-----------|------|
| `.png` | `image/png` |
| `.jpg`, `.jpeg` | `image/jpeg` |
| `.gif` | `image/gif` |
| `.webp` | `image/webp` |
| `.svg` | `image/svg+xml` |
| `.pdf` | `application/pdf` |
| `.mp3` | `audio/mpeg` |
| `.mp4` | `video/mp4` |
| `.mov` | `video/quicktime` |
| `.wav` | `audio/wav` |
| `.m4a` | `audio/mp4` |
| `.webm` | `video/webm` |

Anything else: the embed reference is parsed but the attachment is not copied and no `:DigitalDocument` is written. Add a new extension by extending `SUPPORTED_ATTACHMENT_MIME` in `obsidian-vault-import.ts` and the matching attachment-regex in the parser library.

## Resolution

For each embed, the parser searches in three places:

1. **Vault-root-relative.** The path as written in the markdown, interpreted from the vault root.
2. **Page-relative.** The same path joined to the page's directory.
3. **Bare filename search.** A walk of the vault for an exact filename match. This covers Obsidian's default behaviour of placing attachments in a single attachments folder regardless of where the page lives.

The first hit wins. A missed embed emits `[obsidian-import] event=attachment-missing page=<path> path=<rawPath>` to `server.log` and the wikilink/page write still proceeds — the missing attachment does not abort the import.

## Archive layout

Successfully resolved attachments are copied (not moved) to:

```
{accountDir}/archive/obsidian/<importId>/<vault-relative-path>
```

`<importId>` is the run identifier the dry-run returned. Two imports of overlapping vaults each get their own directory; the same file copied twice produces two on-disk copies under different importId directories. This is deliberate — content-hash idempotency lives at the page level, not the attachment level. If an operator wants to deduplicate attachments across imports, that's a separate maintenance task.

The `:DigitalDocument.attachmentPath` property holds the absolute archive path. The MERGE key is `(accountId, attachmentPath)` so re-running the same import (same importId) updates the existing node rather than duplicating.

## Graph shape

```
(:KnowledgeDocument {obsidianPath: 'People/Adam.md'})
  <-[:EMBEDDED_IN]-
(:DigitalDocument {
  attachmentPath: '/data/accounts/<acct>/archive/obsidian/<importId>/People/adam-headshot.png',
  encodingFormat: 'image/png',
  alt: 'Adam at the conference',
  source: 'obsidian'
})
```

The `:EMBEDDED_IN` direction (`DigitalDocument -> KnowledgeDocument`) matches the convention used by `memory-ingest` for document attachments. Querying "which images live on this note" uses `MATCH (d:DigitalDocument)-[:EMBEDDED_IN]->(k:KnowledgeDocument {obsidianPath:$path}) RETURN d`.

## Out of scope

- Remote (HTTPS) images referenced in markdown — the parser intentionally skips them. Caching remote assets at import time would race with link rot and silently bloat the archive. If the operator wants a remote image preserved, they can save it into the vault locally first.
- OCR / image content extraction. The image bytes are archived; no derived content is written.
- Thumbnail generation. Operators view attachments via existing memory-list-attachments / memory-read-attachment surfaces.

Each of the above can be added as a separate task — never silently inflated into this import.
