mirror of
https://github.com/Afilmory/afilmory
synced 2026-04-24 23:05:05 +00:00
feat: add EagleStorageProvider (#122)
Co-authored-by: Innei <tukon479@gmail.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -202,7 +202,7 @@ export async function executePhotoProcessingPipeline(
|
||||
const photoInfo = extractPhotoInfo(photoKey, exifData)
|
||||
|
||||
// 7. 处理 Live Photo
|
||||
const livePhotoResult = processLivePhoto(photoKey, livePhotoMap)
|
||||
const livePhotoResult = await processLivePhoto(photoKey, livePhotoMap)
|
||||
|
||||
// 8. 构建照片清单项
|
||||
const aspectRatio = metadata.width / metadata.height
|
||||
@@ -213,7 +213,7 @@ export async function executePhotoProcessingPipeline(
|
||||
description: photoInfo.description,
|
||||
dateTaken: photoInfo.dateTaken,
|
||||
tags: photoInfo.tags,
|
||||
originalUrl: defaultBuilder
|
||||
originalUrl: await defaultBuilder
|
||||
.getStorageManager()
|
||||
.generatePublicUrl(photoKey),
|
||||
thumbnailUrl: thumbnailResult.thumbnailUrl,
|
||||
|
||||
@@ -16,10 +16,10 @@ export interface LivePhotoResult {
|
||||
* @param livePhotoMap Live Photo 映射表
|
||||
* @returns Live Photo 处理结果
|
||||
*/
|
||||
export function processLivePhoto(
|
||||
export async function processLivePhoto(
|
||||
photoKey: string,
|
||||
livePhotoMap: Map<string, _Object | StorageObject>,
|
||||
): LivePhotoResult {
|
||||
): Promise<LivePhotoResult> {
|
||||
const loggers = getGlobalLoggers()
|
||||
const livePhotoVideo = livePhotoMap.get(photoKey)
|
||||
const isLivePhoto = !!livePhotoVideo
|
||||
@@ -40,7 +40,7 @@ export function processLivePhoto(
|
||||
return { isLivePhoto: false }
|
||||
}
|
||||
|
||||
const livePhotoVideoUrl = defaultBuilder
|
||||
const livePhotoVideoUrl = await defaultBuilder
|
||||
.getStorageManager()
|
||||
.generatePublicUrl(videoKey)
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { StorageConfig, StorageProvider } from './interfaces'
|
||||
import { EagleStorageProvider } from './providers/eagle-provider.js'
|
||||
import { GitHubStorageProvider } from './providers/github-provider.js'
|
||||
import { LocalStorageProvider } from './providers/local-provider.js'
|
||||
import { S3StorageProvider } from './providers/s3-provider.js'
|
||||
@@ -17,6 +18,9 @@ export class StorageFactory {
|
||||
case 'github': {
|
||||
return new GitHubStorageProvider(config)
|
||||
}
|
||||
case 'eagle': {
|
||||
return new EagleStorageProvider(config)
|
||||
}
|
||||
case 'local': {
|
||||
return new LocalStorageProvider(config)
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ export { StorageFactory } from './factory.js'
|
||||
export { StorageManager } from './manager.js'
|
||||
|
||||
// 导出具体提供商(如果需要直接使用)
|
||||
export { EagleStorageProvider } from './providers/eagle-provider.js'
|
||||
export { GitHubStorageProvider } from './providers/github-provider.js'
|
||||
export { LocalStorageProvider } from './providers/local-provider.js'
|
||||
export { S3StorageProvider } from './providers/s3-provider.js'
|
||||
|
||||
@@ -48,7 +48,7 @@ export interface StorageProvider {
|
||||
* @param key 文件的键值/路径
|
||||
* @returns 公共访问 URL
|
||||
*/
|
||||
generatePublicUrl: (key: string) => string
|
||||
generatePublicUrl: (key: string) => string | Promise<string>
|
||||
|
||||
/**
|
||||
* 检测 Live Photos 配对
|
||||
@@ -101,4 +101,50 @@ export type LocalConfig = {
|
||||
maxFileLimit?: number
|
||||
}
|
||||
|
||||
export type StorageConfig = S3Config | GitHubConfig | LocalConfig
|
||||
export type EagleRule =
|
||||
| {
|
||||
type: 'tag'
|
||||
name: string
|
||||
}
|
||||
| {
|
||||
type: 'folder'
|
||||
/**
|
||||
* Only a folder name, not the full path.
|
||||
*/
|
||||
name: string
|
||||
/**
|
||||
* Defaults to `false`.
|
||||
*/
|
||||
includeSubfolder?: boolean
|
||||
}
|
||||
| {
|
||||
/**
|
||||
* Smart folders are not yet supported.
|
||||
*/
|
||||
type: 'smartFolder'
|
||||
}
|
||||
|
||||
export type EagleConfig = {
|
||||
provider: 'eagle'
|
||||
/**
|
||||
* The path to the Eagle library.
|
||||
*/
|
||||
libraryPath: string
|
||||
/**
|
||||
* The path where original files need to be stored.
|
||||
* The original files will be copied to this path during the build process.
|
||||
*
|
||||
* Defaults to `web/public/originals/`
|
||||
*/
|
||||
distPath?: string
|
||||
/**
|
||||
* The base URL to access the original files.
|
||||
*
|
||||
* Defaults to `/originals/`
|
||||
*/
|
||||
baseUrl?: string
|
||||
include?: EagleRule[]
|
||||
exclude?: EagleRule[]
|
||||
}
|
||||
|
||||
export type StorageConfig = S3Config | GitHubConfig | EagleConfig | LocalConfig
|
||||
|
||||
@@ -44,7 +44,7 @@ export class StorageManager {
|
||||
* @param key 文件的键值/路径
|
||||
* @returns 公共访问 URL
|
||||
*/
|
||||
generatePublicUrl(key: string): string {
|
||||
async generatePublicUrl(key: string): Promise<string> {
|
||||
return this.provider.generatePublicUrl(key)
|
||||
}
|
||||
|
||||
|
||||
@@ -270,4 +270,68 @@ photos/
|
||||
| 适用场景 | 生产环境 | 小型项目、演示 |
|
||||
| 设置复杂度 | 中等 | 简单 |
|
||||
|
||||
选择存储提供商时,请根据你的具体需求和预算进行选择。
|
||||
选择存储提供商时,请根据你的具体需求和预算进行选择。
|
||||
|
||||
## Eagle 存储提供商
|
||||
|
||||
直接读取 Eagle 桌面应用生成的素材库,并在构建时将所需图片复制到指定目录。
|
||||
|
||||
### 特点
|
||||
|
||||
- ✅ 方便管理:直接在已有 Eagle 素材库中管理图片
|
||||
- ✅ 多条件筛选:支持按文件夹(含子文件夹)或标签过滤
|
||||
- ✅ 自动复制:首次访问时自动复制原图到发布目录
|
||||
- ⚠️ 需自行托管筛选出的图片文件
|
||||
|
||||
### 配置示例
|
||||
|
||||
```typescript
|
||||
import type { EagleConfig } from '@afilmory/builder'
|
||||
|
||||
const eagleConfig: EagleConfig = {
|
||||
provider: 'eagle',
|
||||
libraryPath: '/Users/alice/Pictures/Eagle.library',
|
||||
distPath: '/Users/alice/workspaces/afilmory/apps/web/public/originals',
|
||||
baseUrl: '/originals/',
|
||||
include: [
|
||||
{ type: 'folder', name: '精选', includeSubfolder: true },
|
||||
{ type: 'tag', name: 'Published' },
|
||||
],
|
||||
exclude: [{ type: 'tag', name: 'Private' }],
|
||||
}
|
||||
```
|
||||
|
||||
### 设置步骤
|
||||
|
||||
1. **定位 Eagle 素材库**:找到 Eagle 「库位置」,复制完整路径。
|
||||
2. **准备 distPath**:指定一个绝对路径,默认为 `apps/web/public/originals` 或自定义静态资源目录。
|
||||
3. **调整访问 URL**:设置 `baseUrl` 以匹配前端访问路径(默认 `/originals/`)。
|
||||
4. **定义筛选规则(可选)**:通过 `include` 与 `exclude` 精确控制导出范围。
|
||||
|
||||
### 规则说明
|
||||
|
||||
- `include` 为空时表示包含库内所有支持格式的图片。
|
||||
- `exclude` 优先级高于 `include`,命中后即排除。
|
||||
- `folder` 规则的 `name` 仅填写文件夹名称;勾选 `includeSubfolder` 可递归包含子目录。
|
||||
- `tag` 规则将与 Eagle 标签匹配,大小写敏感。
|
||||
|
||||
### 使用示例
|
||||
|
||||
```typescript
|
||||
import { EagleStorageProvider } from '@/core/storage'
|
||||
|
||||
const provider = new EagleStorageProvider(eagleConfig)
|
||||
|
||||
const files = await provider.listImages()
|
||||
const buffer = await provider.getFile(files[0].key)
|
||||
const publicUrl = await provider.generatePublicUrl(files[0].key)
|
||||
```
|
||||
|
||||
首次生成公共 URL 时,提供商会将原图复制到 `distPath`,后续则复用已存在文件。
|
||||
|
||||
### 常见问题
|
||||
|
||||
- **路径错误**:`libraryPath` 或 `distPath` 不是绝对路径会导致实例化失败。
|
||||
- **版本兼容**:若检测到非 4.x 版本,会输出告警,建议升级 Eagle。
|
||||
- **不支持的格式**:仅处理 `SUPPORTED_FORMATS` 中列出的扩展名(与其它提供商一致)。
|
||||
- **权限限制**:确保进程具备对素材库与目标目录的读写权限。
|
||||
|
||||
410
packages/builder/src/storage/providers/eagle-provider.ts
Normal file
410
packages/builder/src/storage/providers/eagle-provider.ts
Normal file
@@ -0,0 +1,410 @@
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
|
||||
import { SUPPORTED_FORMATS } from '@afilmory/builder/constants/index.js'
|
||||
import { workdir } from '@afilmory/builder/path.js'
|
||||
|
||||
import type { Logger } from '../../logger/index.js'
|
||||
import { logger } from '../../logger/index.js'
|
||||
import type {
|
||||
EagleConfig,
|
||||
EagleRule,
|
||||
StorageObject,
|
||||
StorageProvider,
|
||||
} from '../interfaces.js'
|
||||
|
||||
const EAGLE_VERSION = '4.0.0'
|
||||
|
||||
interface EagleFolderNode {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
children?: EagleFolderNode[]
|
||||
modificationTime: number
|
||||
tags: string[]
|
||||
password: string
|
||||
passwordTips: string
|
||||
/**
|
||||
* For smart folders
|
||||
*/
|
||||
conditions?: unknown[]
|
||||
}
|
||||
|
||||
interface EagleLibraryMetadata {
|
||||
folders?: EagleFolderNode[]
|
||||
smartFolders?: unknown[]
|
||||
quickAccess: unknown[]
|
||||
tagsGroups: unknown[]
|
||||
modificationTime: number
|
||||
applicationVersion: '4.0.0'
|
||||
}
|
||||
|
||||
interface EagleImageMetadata {
|
||||
id: string
|
||||
name: string
|
||||
size: number
|
||||
btime: number
|
||||
mtime: number
|
||||
ext: string
|
||||
tags: string[]
|
||||
folders: string[]
|
||||
isDeleted: boolean
|
||||
url: string
|
||||
annotation: string
|
||||
modificationTime: number
|
||||
star?: number
|
||||
height: number
|
||||
width: number
|
||||
noThumbnail: boolean
|
||||
palettes: unknown[]
|
||||
lastModified: number
|
||||
}
|
||||
|
||||
const defaultEagleConfig = {
|
||||
provider: 'eagle',
|
||||
libraryPath: '',
|
||||
distPath: path.join(workdir, 'public', 'originals'),
|
||||
baseUrl: '/originals/',
|
||||
include: [],
|
||||
exclude: [],
|
||||
} satisfies Required<EagleConfig>
|
||||
|
||||
export class EagleStorageProvider implements StorageProvider {
|
||||
private readonly config: Required<EagleConfig>
|
||||
/**
|
||||
* Folder index map: folder ID -> folder path array
|
||||
*/
|
||||
private folderIndex = new Map<string, string[]>()
|
||||
|
||||
constructor(userConfig: EagleConfig) {
|
||||
if (!userConfig.libraryPath || userConfig.libraryPath.trim() === '') {
|
||||
throw new Error('EagleStorageProvider: libraryPath 不能为空')
|
||||
}
|
||||
if (!path.isAbsolute(userConfig.libraryPath)) {
|
||||
throw new Error(
|
||||
`EagleStorageProvider: libraryPath 必须是绝对路径. libraryPath: ${userConfig.libraryPath}`,
|
||||
)
|
||||
}
|
||||
if (userConfig.distPath && !path.isAbsolute(userConfig.distPath)) {
|
||||
throw new Error(
|
||||
`EagleStorageProvider: distPath 必须是绝对路径. distPath: ${userConfig.distPath}`,
|
||||
)
|
||||
}
|
||||
|
||||
this.config = {
|
||||
...defaultEagleConfig,
|
||||
...userConfig,
|
||||
libraryPath: path.resolve(userConfig.libraryPath),
|
||||
distPath: path.resolve(
|
||||
userConfig.distPath ?? defaultEagleConfig.distPath,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
initialized = false
|
||||
async initialize(): Promise<void> {
|
||||
if (this.initialized) return
|
||||
this.initialized = true
|
||||
try {
|
||||
await validateEagleLibrary(this.config.libraryPath)
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
logger.main.error(
|
||||
`EagleStorageProvider: libraryPath 不是有效的 Eagle 库:${error.message}`,
|
||||
)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
if (
|
||||
!(await fs
|
||||
.stat(this.config.distPath)
|
||||
.then((res) => res.isDirectory())
|
||||
.catch(() => false))
|
||||
) {
|
||||
await fs.mkdir(this.config.distPath, { recursive: true })
|
||||
logger.main.info(
|
||||
`EagleStorageProvider: 已创建 distPath 目录:${this.config.distPath}`,
|
||||
)
|
||||
}
|
||||
|
||||
const libraryMetadata = await readEagleLibraryMetadata(
|
||||
this.config.libraryPath,
|
||||
)
|
||||
logger.main.info(
|
||||
`EagleStorageProvider: 检测到 Eagle 版本:${libraryMetadata.applicationVersion}`,
|
||||
)
|
||||
if (
|
||||
Number(libraryMetadata.applicationVersion.at(0)) !==
|
||||
Number(EAGLE_VERSION.at(0))
|
||||
) {
|
||||
logger.main.warn(
|
||||
`EagleStorageProvider: 当前支持 Eagle ${EAGLE_VERSION} 版本的库,检测到的版本为:${libraryMetadata.applicationVersion},可能会导致兼容性问题。`,
|
||||
)
|
||||
}
|
||||
|
||||
this.folderIndex = buildFolderIndexes(libraryMetadata.folders ?? [])
|
||||
}
|
||||
|
||||
async getFile(key: string, logger?: Logger['s3']): Promise<Buffer | null> {
|
||||
await this.initialize()
|
||||
|
||||
const imageInfoPath = path.resolve(
|
||||
this.config.libraryPath,
|
||||
'images',
|
||||
`${key}.info`,
|
||||
)
|
||||
const infoStats = await fs.stat(imageInfoPath)
|
||||
if (!infoStats.isDirectory()) {
|
||||
logger?.error?.(
|
||||
`EagleStorageProvider: 请求的文件路径不安全。key: ${key}, 路径: ${imageInfoPath}`,
|
||||
)
|
||||
return null
|
||||
}
|
||||
const imageMetadata: EagleImageMetadata = await readImageMetadata(
|
||||
this.config.libraryPath,
|
||||
key,
|
||||
)
|
||||
if (!SUPPORTED_FORMATS.has(`.${imageMetadata.ext.toLowerCase()}`)) {
|
||||
logger?.error?.(
|
||||
`EagleStorageProvider: 不支持的图片格式。key: ${key}, 格式: .${imageMetadata.ext}`,
|
||||
)
|
||||
return null
|
||||
}
|
||||
const imageFileName = `${imageMetadata.name}.${imageMetadata.ext}`
|
||||
const imageFilePath = path.join(imageInfoPath, imageFileName)
|
||||
try {
|
||||
const buffer = await fs.readFile(imageFilePath)
|
||||
return buffer
|
||||
} catch (error) {
|
||||
logger?.error?.(
|
||||
`EagleStorageProvider: 读取图片文件失败。key: ${key}, 路径: ${imageFilePath}, 错误: ${error}`,
|
||||
)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async listImages() {
|
||||
return this.listAllFiles()
|
||||
}
|
||||
|
||||
async listAllFiles(): Promise<StorageObject[]> {
|
||||
await this.initialize()
|
||||
const imagesDir = path.join(this.config.libraryPath, 'images')
|
||||
const imageEntries = await fs.readdir(imagesDir, { withFileTypes: true })
|
||||
const keys = imageEntries
|
||||
// Filter out .DS_Store
|
||||
.filter((entry) => entry.isDirectory() && entry.name.endsWith('.info'))
|
||||
.map((entry) => {
|
||||
return entry.name.replace(/\.info$/, '')
|
||||
})
|
||||
const filtered: StorageObject[] = []
|
||||
|
||||
await Promise.all(
|
||||
keys.map(async (key) => {
|
||||
const meta = await readImageMetadata(this.config.libraryPath, key)
|
||||
const include =
|
||||
this.config.include.length === 0
|
||||
? true
|
||||
: this.runPredicate(meta, this.config.include)
|
||||
const exclude =
|
||||
this.config.exclude.length === 0
|
||||
? false
|
||||
: this.runPredicate(meta, this.config.exclude)
|
||||
const supportedFormat = SUPPORTED_FORMATS.has(
|
||||
`.${meta.ext.toLowerCase()}`,
|
||||
)
|
||||
|
||||
if (include && !exclude && supportedFormat) {
|
||||
filtered.push({
|
||||
key,
|
||||
size: meta.size,
|
||||
lastModified: new Date(meta.lastModified),
|
||||
})
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
return filtered
|
||||
}
|
||||
|
||||
async generatePublicUrl(key: string) {
|
||||
const imageName = await this.copyToDist(key)
|
||||
const publicPath = path.join(this.config.baseUrl, imageName)
|
||||
return publicPath
|
||||
}
|
||||
|
||||
detectLivePhotos(_allObjects: StorageObject[]): Map<string, StorageObject> {
|
||||
// TODO
|
||||
return new Map()
|
||||
}
|
||||
|
||||
private async copyToDist(key: string) {
|
||||
const imageMeta = await readImageMetadata(this.config.libraryPath, key)
|
||||
const imageName = `${imageMeta.name}.${imageMeta.ext}`
|
||||
const sourceImage = path.join(
|
||||
this.config.libraryPath,
|
||||
'images',
|
||||
`${key}.info`,
|
||||
imageName,
|
||||
)
|
||||
const distName = `${key}.${imageMeta.ext}`
|
||||
const distFile = path.join(this.config.distPath, distName)
|
||||
if (
|
||||
await fs
|
||||
.stat(distFile)
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
) {
|
||||
// 文件已存在,跳过复制
|
||||
logger.main.log(
|
||||
`EagleStorageProvider: 发布目录已存在文件,跳过复制: ${imageName} -> ${distFile}`,
|
||||
)
|
||||
return imageName
|
||||
}
|
||||
await fs.copyFile(sourceImage, distFile)
|
||||
logger.main.log(
|
||||
`EagleStorageProvider: 已复制文件到发布目录: ${imageName} -> ${distFile}`,
|
||||
)
|
||||
return distName
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns `true` if the image matches any of the given rules.
|
||||
*/
|
||||
private runPredicate(
|
||||
imageMeta: EagleImageMetadata,
|
||||
rules: EagleRule[],
|
||||
): boolean {
|
||||
if (rules.length === 0) {
|
||||
return true
|
||||
}
|
||||
return rules.some((condition) => {
|
||||
switch (condition.type) {
|
||||
case 'tag': {
|
||||
return imageMeta.tags.includes(condition.name)
|
||||
}
|
||||
case 'folder': {
|
||||
const includeSubfolder = !!condition.includeSubfolder
|
||||
for (const folderId of imageMeta.folders) {
|
||||
const folderPath = this.folderIndex.get(folderId)
|
||||
if (!folderPath) {
|
||||
logger.main.warn(
|
||||
`EagleStorageProvider: 无法找到文件夹索引,跳过该文件夹过滤条件。folderId: ${folderId}`,
|
||||
)
|
||||
continue
|
||||
}
|
||||
if (includeSubfolder) {
|
||||
if (folderPath.includes(condition.name)) {
|
||||
return true
|
||||
}
|
||||
continue
|
||||
} else {
|
||||
if (folderPath.at(-1) === condition.name) {
|
||||
return true
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
case 'smartFolder': {
|
||||
// Not supported yet
|
||||
return false
|
||||
}
|
||||
default: {
|
||||
return false
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function validateEagleLibrary(libraryPath: string): Promise<void> {
|
||||
// Check for directory existence
|
||||
try {
|
||||
const stats = await fs.stat(libraryPath)
|
||||
if (!stats.isDirectory()) {
|
||||
throw new Error(
|
||||
`EagleStorageProvider: 指定的 libraryPath 不是目录。libraryPath: ${libraryPath}`,
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`EagleStorageProvider: 无法访问指定的 libraryPath: ${libraryPath} - ${error}`,
|
||||
)
|
||||
}
|
||||
|
||||
// Check for metadata.json existence
|
||||
try {
|
||||
const metadataPath = path.join(libraryPath, 'metadata.json')
|
||||
await fs.access(metadataPath)
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`EagleStorageProvider: library metadata.json 不存在:${libraryPath} - ${error}`,
|
||||
)
|
||||
}
|
||||
|
||||
// Check for images directory existence
|
||||
try {
|
||||
const imagesDir = path.join(libraryPath, 'images')
|
||||
const stats = await fs.stat(imagesDir)
|
||||
if (!stats.isDirectory()) {
|
||||
throw new Error(
|
||||
`EagleStorageProvider: images 不是目录。libraryPath: ${imagesDir}`,
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`EagleStorageProvider: 无法访问指定的 images 目录: ${libraryPath} - ${error}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function readEagleLibraryMetadata(
|
||||
libraryPath: string,
|
||||
): Promise<EagleLibraryMetadata> {
|
||||
const metadataPath = path.join(libraryPath, 'metadata.json')
|
||||
|
||||
try {
|
||||
const content = await fs.readFile(metadataPath, 'utf-8')
|
||||
return JSON.parse(content) as EagleLibraryMetadata
|
||||
} catch (error) {
|
||||
const errorMsg =
|
||||
error instanceof Error ? `${error.name}: ${error.message}` : String(error)
|
||||
throw new Error(
|
||||
`EagleStorageProvider: 无法解析 library metadata:${errorMsg}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function readImageMetadata(
|
||||
libraryPath: string,
|
||||
key: string,
|
||||
): Promise<EagleImageMetadata> {
|
||||
const metadataPath = path.join(
|
||||
libraryPath,
|
||||
'images',
|
||||
`${key}.info`,
|
||||
'metadata.json',
|
||||
)
|
||||
const content = await fs.readFile(metadataPath, 'utf-8')
|
||||
return JSON.parse(content) as EagleImageMetadata
|
||||
}
|
||||
|
||||
function buildFolderIndexes(folders: EagleFolderNode[]) {
|
||||
const map = new Map<string, string[]>()
|
||||
const traverse = (node: EagleFolderNode, parent: string[]) => {
|
||||
const path = [...parent, node.name]
|
||||
map.set(node.id, path)
|
||||
const children = node.children ?? []
|
||||
for (const child of children) {
|
||||
traverse(child, path)
|
||||
}
|
||||
}
|
||||
|
||||
for (const folder of folders) {
|
||||
traverse(folder, [])
|
||||
}
|
||||
return map
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
---
|
||||
title: Storage providers
|
||||
description: Afilmory can work with multiple storage providers, including S3, Git and local file system
|
||||
description: Afilmory can work with multiple storage providers, including S3, Git, Eagle, and local file system
|
||||
createdAt: 2025-08-12T15:09:08+08:00
|
||||
lastModified: 2025-08-31T11:08:35+08:00
|
||||
lastModified: 2025-10-20T01:42:38+08:00
|
||||
---
|
||||
|
||||
# Storage Providers
|
||||
|
||||
Afilmory's flexible storage architecture allows you to store your photos across different platforms. The photo processing engine (`@afilmory/builder`) abstracts storage operations through a unified interface, making it easy to switch between providers.
|
||||
Afilmory's flexible storage architecture allows you to store your photos across different platforms. The photo processing engine (`@afilmory/builder`) abstracts storage operations through a unified interface, making it easy to switch between providers such as S3, GitHub, Eagle, and the local file system.
|
||||
|
||||
## Supported Providers
|
||||
|
||||
@@ -89,6 +89,50 @@ GIT_TOKEN=ghp_your_github_personal_access_token
|
||||
- `Contents: Read and write` - for uploading processed photos
|
||||
- `Metadata: Read` - for repository access
|
||||
|
||||
### Eagle Storage
|
||||
|
||||
Eagle storage integrates directly with an existing Eagle 4 desktop library, making it ideal for teams that already curate assets in Eagle and want to publish selected photos without additional uploads.
|
||||
|
||||
**Features:**
|
||||
- ✅ No upload step: reads directly from the Eagle library
|
||||
- ✅ Flexible filtering: include or exclude by folder (with optional subfolders) and tags
|
||||
- ✅ Automatic publishing: copies originals to a public directory on demand
|
||||
- ⚠️ Requires absolute paths for `libraryPath` and `distPath`
|
||||
- ⚠️ Only Eagle 4.x is validated; other versions may require testing
|
||||
|
||||
**Configuration in `builder.config.json`:**
|
||||
|
||||
```json
|
||||
{
|
||||
"storage": {
|
||||
"provider": "eagle",
|
||||
"libraryPath": "/Users/alice/Pictures/Eagle.library",
|
||||
"distPath": "/Users/alice/workspaces/afilmory/apps/web/public/originals",
|
||||
"baseUrl": "/originals/",
|
||||
"include": [
|
||||
{ "type": "folder", "name": "Published", "includeSubfolder": true },
|
||||
{ "type": "tag", "name": "Featured" }
|
||||
],
|
||||
"exclude": [
|
||||
{ "type": "tag", "name": "Private" }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Setup Steps:**
|
||||
|
||||
1. **Locate the Eagle library**: copy the absolute path shown in Eagle → Preferences → Library.
|
||||
2. **Choose a distribution path (optional)**: The original images will be copied to `distPath` during the build process (defaults to `<workdir>/public/originals`).
|
||||
3. **Align URLs**: set `baseUrl` to match how the frontend serves `distPath` (defaults to `/originals/`).
|
||||
4. **Refine filters (optional)**: use `include` and `exclude` rules to control which assets are exported.
|
||||
|
||||
**Rule Notes:**
|
||||
- Leaving `include` empty processes every supported image in the library.
|
||||
- `exclude` overrides inclusion rules when both match a single asset.
|
||||
- `folder` rules use folder names (not full paths); enabling `includeSubfolder` recursively matches children.
|
||||
- `tag` rules are case-sensitive and match Eagle tags.
|
||||
|
||||
### Local File System
|
||||
|
||||
Local storage is suitable for development, testing, or self-hosted deployments where photos are stored on the same server.
|
||||
@@ -256,7 +300,7 @@ Store sensitive credentials securely:
|
||||
S3_ACCESS_KEY_ID=prod_access_key
|
||||
S3_SECRET_ACCESS_KEY=prod_secret_key
|
||||
|
||||
# .env.development
|
||||
# .env.development
|
||||
S3_ACCESS_KEY_ID=dev_access_key
|
||||
S3_SECRET_ACCESS_KEY=dev_secret_key
|
||||
```
|
||||
Reference in New Issue
Block a user