✨ Feature(gui): group album photos by uploader config (#1452)

This commit is contained in:
相柳
2026-10-17 10:09:37 +08:00
committed by GitHub
parent 2e3e30fcbc
commit 78283f4908
15 changed files with 1358 additions and 36 deletions
+1
View File
@@ -449,6 +449,7 @@ PRIVACY: "Please read and agree to the Privacy Policy ${privacyUrl} and Terms of
PRIVACY_TIPS: Please agree the privacy policy to upload
QUIT: Quit
ALBUM_ALL_PHOTOS: All Photos
ALBUM_CONFIG_UNKNOWN: Unsorted
ALBUM_COLLECTIONS: Collections
ALBUM_TAGS: Tags
ALBUM_MENU: Menu
+1
View File
@@ -449,6 +449,7 @@ PRIVACY: "ご利用の前にプライバシーポリシー${privacyUrl}と利用
PRIVACY_TIPS: アップロードするにはプライバシーポリシーに同意してください
QUIT: 終了
ALBUM_ALL_PHOTOS: すべての写真
ALBUM_CONFIG_UNKNOWN: 未分類
ALBUM_COLLECTIONS: コレクション
ALBUM_TAGS: タグ
ALBUM_MENU: メニュー
+1
View File
@@ -449,6 +449,7 @@ PRIVACY: "이용하기 전에 개인정보 처리방침 ${privacyUrl} 및 이용
PRIVACY_TIPS: 업로드하려면 개인정보 처리방침에 동의해 주세요
QUIT: 종료
ALBUM_ALL_PHOTOS: 전체 사진
ALBUM_CONFIG_UNKNOWN: 미분류
ALBUM_COLLECTIONS: 컬렉션
ALBUM_TAGS: 태그
ALBUM_MENU: 메뉴
+1
View File
@@ -449,6 +449,7 @@ PRIVACY: "使用前请阅读并同意隐私政策 ${privacyUrl} 与服务条款
PRIVACY_TIPS: 请同意隐私协议,否则无法上传。
QUIT: 退出
ALBUM_ALL_PHOTOS: 全部照片
ALBUM_CONFIG_UNKNOWN: 未分类
ALBUM_COLLECTIONS: 合集
ALBUM_TAGS: 标签
ALBUM_MENU: 菜单
+1
View File
@@ -449,6 +449,7 @@ PRIVACY: "使用前請閱讀並同意隱私政策 ${privacyUrl} 與服務條款
PRIVACY_TIPS: 請同意隱私協議,否則無法上傳。
QUIT: 退出
ALBUM_ALL_PHOTOS: 全部相片
ALBUM_CONFIG_UNKNOWN: 未分類
ALBUM_COLLECTIONS: 合輯
ALBUM_TAGS: 標籤
ALBUM_MENU: 選單
@@ -1,5 +1,7 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { createServer } from 'node:net'
import childProcess, { ChildProcess } from 'node:child_process'
import { PassThrough } from 'node:stream'
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
@@ -321,7 +323,7 @@ describe('GUI server upload options integration', () => {
expect(body.result).toEqual([expectedUrl])
expect(body.items[0]).toMatchObject({ imgUrl: expectedUrl, type: uploaderA })
expect(uploadCaptures).toEqual([{ uploader: uploaderA, endpoint: namedA.endpoint, origins: [jsonImagePath] }])
expect(mocks.albumInsertMock).toHaveBeenCalledWith(expect.objectContaining({ imgUrl: expectedUrl, type: uploaderA }))
expect(mocks.albumInsertMock).toHaveBeenCalledWith(expect.objectContaining({ imgUrl: expectedUrl, type: uploaderA, _configId: namedA._id, _configName: namedA._configName }))
await expectConfigUnchanged()
})
@@ -339,7 +341,7 @@ describe('GUI server upload options integration', () => {
expect(body.result).toEqual([expectedUrl])
expect(body.items[0]).toMatchObject({ imgUrl: expectedUrl, fileName: 'multipart image.png', type: uploaderB })
expect(uploadCaptures[0]).toMatchObject({ uploader: uploaderB, endpoint: selectedB.endpoint })
expect(mocks.albumInsertMock).toHaveBeenCalledWith(expect.objectContaining({ imgUrl: expectedUrl, type: uploaderB }))
expect(mocks.albumInsertMock).toHaveBeenCalledWith(expect.objectContaining({ imgUrl: expectedUrl, type: uploaderB, _configId: selectedB._id, _configName: selectedB._configName }))
await expectConfigUnchanged()
})
@@ -357,7 +359,101 @@ describe('GUI server upload options integration', () => {
expect(body.result[0]).toMatch(new RegExp(`^https://uploads\\.test/${defaultA.endpoint}/\\d+\\.png$`))
expect(uploadCaptures[0]).toMatchObject({ uploader: uploaderA, endpoint: defaultA.endpoint })
expect(mocks.clipboardReadImageMock).toHaveBeenCalled()
expect(mocks.albumInsertMock).toHaveBeenCalledWith(expect.objectContaining({ imgUrl: body.result[0], type: uploaderA }))
expect(mocks.albumInsertMock).toHaveBeenCalledWith(expect.objectContaining({ imgUrl: body.result[0], type: uploaderA, _configId: defaultA._id, _configName: defaultA._configName }))
await expectConfigUnchanged()
})
it('attributes a different uploader without a profile selector to its own active config', async () => {
const response = await fetch(`${baseUrl}/upload?uploader=${uploaderB}`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ list: [jsonImagePath] })
})
const body = await parseUploadResponse(response)
expect(response.status).toBe(200)
expect(body.items[0]).toMatchObject({ type: uploaderB })
expect(mocks.albumInsertMock).toHaveBeenCalledWith(expect.objectContaining({ type: uploaderB, _configId: defaultB._id }))
await expectConfigUnchanged()
})
// 本用例依赖 Core 3.1.0 的行为:PicGo.upload 在 input 为空时先校验一次 options、再 await
// 读取系统剪贴板,之后才在 lifecycle.start 中真正解析 options。因此在剪贴板读取挂起期间
// 切换活动 profile,未做固定的实现会解析到切换后的 profile——这正是本用例的判别力来源。
// 只 mock 掉"启动系统剪贴板助手"这一层外部依赖,其余(HTTP 路由、快照、resolveUploadOptions、
// lifecycle.start、上传器插件、相册归属)都走真实链路。
it.each([false, true])('pins an uploader-only clipboard request across an active-profile switch (mirror differs: %s)', async (mirrorDiffers) => {
// 手工构造 ChildProcess 并挂上 PassThrough,以便精确控制剪贴板助手何时返回路径。
const stdout = new PassThrough()
const clipboardProcess = new ChildProcess()
clipboardProcess.stdout = stdout
let reportClipboardStart: () => void = () => undefined
const clipboardStarted = new Promise<void>(resolve => { reportClipboardStart = resolve })
// 配置必须在安装 spy 之前写入:若 saveConfig 抛错,try/finally 尚未进入,spy 会泄漏到后续用例。
picgo.saveConfig({
'settings.useBuiltinClipboard': false,
[`picBed.${uploaderB}`]: mirrorDiffers ? { ...defaultB, endpoint: 'b-mirror' } : defaultB
})
const spawnSpy = vi.spyOn(childProcess, 'spawn').mockImplementation(() => {
reportClipboardStart()
return clipboardProcess
})
try {
const request = fetch(`${baseUrl}/upload?uploader=${uploaderB}`, { method: 'POST' })
await clipboardStarted
expect(uploadCaptures).toEqual([])
picgo.saveConfig({
[`picBed.${uploaderB}`]: selectedB,
[`uploader.${uploaderB}.defaultId`]: selectedB._id
})
stdout.write(jsonImagePath)
const response = await request
const body = await parseUploadResponse(response)
const expectedUrl = 'https://uploads.test/b-default/json image.png'
expect(response.status).toBe(200)
expect(body.success).toBe(true)
expect(body.result).toEqual([expectedUrl])
expect(uploadCaptures).toEqual([{ uploader: uploaderB, endpoint: 'b-default', origins: [jsonImagePath] }])
expect(mocks.albumInsertMock).toHaveBeenCalledWith(expect.objectContaining({
imgUrl: expectedUrl,
type: uploaderB,
_configId: 'b-default-id',
_configName: 'Default B'
}))
} finally {
stdout.end()
spawnSpy.mockRestore()
picgo.saveConfig({
'settings.useBuiltinClipboard': true,
[`picBed.${uploaderB}`]: defaultB,
[`uploader.${uploaderB}.defaultId`]: defaultB._id
})
}
await expectConfigUnchanged()
})
it('keeps each profile attribution isolated across concurrent requests', async () => {
const [responseA, responseB] = await Promise.all([
fetch(`${baseUrl}/upload?configId=${namedA._id}`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ list: [jsonImagePath] })
}),
fetch(`${baseUrl}/upload?configId=${selectedB._id}`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ list: [jsonImagePath] })
})
])
const [bodyA, bodyB] = await Promise.all([parseUploadResponse(responseA), parseUploadResponse(responseB)])
expect(responseA.status).toBe(200)
expect(responseB.status).toBe(200)
expect(bodyA.items[0]).toMatchObject({ type: uploaderA })
expect(bodyB.items[0]).toMatchObject({ type: uploaderB })
expect(mocks.albumInsertMock).toHaveBeenCalledWith(expect.objectContaining({ imgUrl: bodyA.items[0].imgUrl, _configId: namedA._id }))
expect(mocks.albumInsertMock).toHaveBeenCalledWith(expect.objectContaining({ imgUrl: bodyB.items[0].imgUrl, _configId: selectedB._id }))
await expectConfigUnchanged()
})
@@ -377,6 +473,284 @@ describe('GUI server upload options integration', () => {
await expectConfigUnchanged()
})
it('attributes an upload without options to picBed.uploader when it differs from picBed.current', async () => {
picgo.saveConfig({ 'picBed.uploader': uploaderB })
try {
const response = await fetch(`${baseUrl}/upload`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ list: [jsonImagePath] })
})
const body = await parseUploadResponse(response)
const expectedUrl = `https://uploads.test/${defaultB.endpoint}/json image.png`
expect(response.status).toBe(200)
expect(body.result).toEqual([expectedUrl])
expect(uploadCaptures).toEqual([{ uploader: uploaderB, endpoint: defaultB.endpoint, origins: [jsonImagePath] }])
expect(mocks.albumInsertMock).toHaveBeenCalledWith(expect.objectContaining({
imgUrl: expectedUrl,
type: uploaderB,
_configId: defaultB._id,
_configName: defaultB._configName
}))
} finally {
picgo.saveConfig({ 'picBed.uploader': uploaderA })
}
await expectConfigUnchanged()
})
it('does not persist a stale config when Core falls back from an unregistered uploader', async () => {
const cloudUploader = picgo.helper.uploader.get('picgo-cloud')
expect(cloudUploader).toBeDefined()
if (!cloudUploader) return
const cloudHandle = vi.spyOn(cloudUploader, 'handle').mockImplementation((ctx: IPicGo) => {
ctx.output = ctx.output.map(item => ({ ...item, imgUrl: `https://uploads.test/cloud/${item.fileName}` }))
})
picgo.saveConfig({
'picBed.uploader': 'missing-plugin',
'picBed.missing-plugin': { _id: 'stale-id', _configName: 'Stale' }
})
try {
const response = await fetch(`${baseUrl}/upload`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ list: [jsonImagePath] })
})
const body = await parseUploadResponse(response)
const expectedUrl = 'https://uploads.test/cloud/json image.png'
expect(response.status).toBe(200)
expect(body.result).toEqual([expectedUrl])
expect(body.items[0]).toMatchObject({ imgUrl: expectedUrl, type: 'picgo-cloud' })
expect(mocks.albumInsertMock).toHaveBeenCalledOnce()
const stored = mocks.albumInsertMock.mock.calls[0][0] as IImgInfo
expect(stored.type).toBe('picgo-cloud')
expect(stored).not.toHaveProperty('_configId')
expect(stored).not.toHaveProperty('_configName')
} finally {
cloudHandle.mockRestore()
picgo.saveConfig({ 'picBed.uploader': uploaderA })
picgo.removeConfig('picBed', 'missing-plugin')
}
await expectConfigUnchanged()
})
it('keeps the saved profile aligned with a default upload when the global profile switches during transform', async () => {
let releaseTransform: () => void = () => undefined
let reportTransformStart: () => void = () => undefined
const holdTransform = new Promise<void>(resolve => { releaseTransform = resolve })
const transformStarted = new Promise<void>(resolve => { reportTransformStart = resolve })
const originalHandle = integrationTransformer.handle
const transformSpy = vi.spyOn(integrationTransformer, 'handle').mockImplementation(async (ctx: IPicGo) => {
reportTransformStart()
await holdTransform
await originalHandle(ctx)
})
try {
const request = fetch(`${baseUrl}/upload`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ list: [jsonImagePath] })
})
await transformStarted
picgo.saveConfig({
[`picBed.${uploaderA}`]: namedA,
[`uploader.${uploaderA}.defaultId`]: namedA._id
})
releaseTransform()
const response = await request
const body = await parseUploadResponse(response)
const expectedUrl = `https://uploads.test/${defaultA.endpoint}/json image.png`
expect(response.status).toBe(200)
expect(body.result).toEqual([expectedUrl])
expect(mocks.albumInsertMock).toHaveBeenCalledWith(expect.objectContaining({
imgUrl: expectedUrl,
type: uploaderA,
_configId: defaultA._id,
_configName: defaultA._configName
}))
} finally {
releaseTransform()
transformSpy.mockRestore()
picgo.saveConfig({
[`picBed.${uploaderA}`]: defaultA,
[`uploader.${uploaderA}.defaultId`]: defaultA._id
})
}
await expectConfigUnchanged()
})
it('preserves the active mirror upload target when a saved profile with the same ID differs', async () => {
const mirror = { ...defaultA, endpoint: 'a-mirror-new' }
picgo.saveConfig({ [`picBed.${uploaderA}`]: mirror })
try {
const response = await fetch(`${baseUrl}/upload`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ list: [jsonImagePath] })
})
const body = await parseUploadResponse(response)
const expectedUrl = 'https://uploads.test/a-mirror-new/json image.png'
expect(response.status).toBe(200)
expect(body.result).toEqual([expectedUrl])
expect(uploadCaptures).toEqual([{ uploader: uploaderA, endpoint: mirror.endpoint, origins: [jsonImagePath] }])
expect(mocks.albumInsertMock).toHaveBeenCalledOnce()
const stored = mocks.albumInsertMock.mock.calls[0][0] as IImgInfo
expect(stored.imgUrl).toBe(expectedUrl)
expect(stored.type).toBe(uploaderA)
expect(stored).not.toHaveProperty('_configId')
expect(stored).not.toHaveProperty('_configName')
} finally {
picgo.saveConfig({ [`picBed.${uploaderA}`]: defaultA })
}
await expectConfigUnchanged()
})
it('does not attribute a legacy mirror to the wrong profile when it changes during upload', async () => {
let releaseTransform: () => void = () => undefined
let reportTransformStart: () => void = () => undefined
const holdTransform = new Promise<void>(resolve => { releaseTransform = resolve })
const transformStarted = new Promise<void>(resolve => { reportTransformStart = resolve })
const originalHandle = integrationTransformer.handle
const transformSpy = vi.spyOn(integrationTransformer, 'handle').mockImplementation(async (ctx: IPicGo) => {
reportTransformStart()
await holdTransform
await originalHandle(ctx)
})
const configFileBeforeLegacyUpload = await readFile(testState.configPath, 'utf8')
picgo.removeConfig('uploader', uploaderA)
try {
const request = fetch(`${baseUrl}/upload`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ list: [jsonImagePath] })
})
await transformStarted
picgo.saveConfig({ [`picBed.${uploaderA}`]: namedA })
releaseTransform()
const response = await request
const body = await parseUploadResponse(response)
const expectedUrl = `https://uploads.test/${namedA.endpoint}/json image.png`
expect(response.status).toBe(200)
expect(body.result).toEqual([expectedUrl])
expect(mocks.albumInsertMock).toHaveBeenCalledOnce()
const stored = mocks.albumInsertMock.mock.calls[0][0] as IImgInfo
expect(stored.imgUrl).toBe(expectedUrl)
expect(stored.type).toBe(uploaderA)
expect(stored._configId).not.toBe(defaultA._id)
} finally {
releaseTransform()
transformSpy.mockRestore()
picgo.saveConfig(rootConfigBeforeUploads as IStringKeyMap)
}
expect(structuredClone(picgo.getConfig())).toEqual(rootConfigBeforeUploads)
await expect(readFile(testState.configPath, 'utf8')).resolves.toBe(configFileBeforeLegacyUpload)
})
it.each([
['switches to B and then back to A', true],
['switches from A to B after the uploader reads A', false]
])('leaves an unpinned upload unattributed when the config %s', async (_description, switchBeforeRead) => {
const mirror = { ...defaultA, endpoint: 'a-mirror-new' }
let releaseTransform: () => void = () => undefined
let reportTransformStart: () => void = () => undefined
let releaseUpload: () => void = () => undefined
let reportUploadRead: () => void = () => undefined
const holdTransform = new Promise<void>(resolve => { releaseTransform = resolve })
const transformStarted = new Promise<void>(resolve => { reportTransformStart = resolve })
const holdUpload = new Promise<void>(resolve => { releaseUpload = resolve })
const uploadRead = new Promise<void>(resolve => { reportUploadRead = resolve })
const originalTransform = integrationTransformer.handle
const transformSpy = vi.spyOn(integrationTransformer, 'handle').mockImplementation(async (ctx: IPicGo) => {
reportTransformStart()
await holdTransform
await originalTransform(ctx)
})
const originalUpload = integrationUploader.handle
const uploaderSpy = vi.spyOn(integrationUploader, 'handle').mockImplementation(async (ctx: IPicGo) => {
await originalUpload(ctx)
reportUploadRead()
await holdUpload
})
picgo.saveConfig({ [`picBed.${uploaderA}`]: mirror })
try {
const request = fetch(`${baseUrl}/upload`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ list: [jsonImagePath] })
})
await transformStarted
if (switchBeforeRead) picgo.saveConfig({ [`picBed.${uploaderA}`]: namedA })
releaseTransform()
await uploadRead
picgo.saveConfig({ [`picBed.${uploaderA}`]: switchBeforeRead ? mirror : namedA })
releaseUpload()
const response = await request
const body = await parseUploadResponse(response)
const expectedEndpoint = switchBeforeRead ? namedA.endpoint : mirror.endpoint
const expectedUrl = `https://uploads.test/${expectedEndpoint}/json image.png`
expect(response.status).toBe(200)
expect(body.result).toEqual([expectedUrl])
expect(mocks.albumInsertMock).toHaveBeenCalledOnce()
const stored = mocks.albumInsertMock.mock.calls[0][0] as IImgInfo
expect(stored.imgUrl).toBe(expectedUrl)
expect(stored.type).toBe(uploaderA)
expect(stored).not.toHaveProperty('_configId')
expect(stored).not.toHaveProperty('_configName')
} finally {
releaseTransform()
releaseUpload()
transformSpy.mockRestore()
uploaderSpy.mockRestore()
picgo.saveConfig({ [`picBed.${uploaderA}`]: defaultA })
}
await expectConfigUnchanged()
})
it('does not persist attribution copied from an input image after fallback to cloud', async () => {
const cloudUploader = picgo.helper.uploader.get('picgo-cloud')
expect(cloudUploader).toBeDefined()
if (!cloudUploader) return
const cloudHandle = vi.spyOn(cloudUploader, 'handle').mockImplementation((ctx: IPicGo) => {
ctx.output = ctx.output.map(item => ({ ...item, imgUrl: `https://uploads.test/cloud/${item.fileName}` }))
})
const originalHandle = integrationTransformer.handle
const transformerSpy = vi.spyOn(integrationTransformer, 'handle').mockImplementation(async (ctx: IPicGo) => {
await originalHandle(ctx)
ctx.output.forEach(item => {
item._configId = 'stale-id'
item._configName = 'Old upload'
})
})
picgo.saveConfig({ 'picBed.uploader': 'missing-plugin' })
try {
const response = await fetch(`${baseUrl}/upload`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ list: [jsonImagePath] })
})
const body = await parseUploadResponse(response)
expect(response.status).toBe(200)
expect(body.items[0].type).toBe('picgo-cloud')
expect(mocks.albumInsertMock).toHaveBeenCalledOnce()
const stored = mocks.albumInsertMock.mock.calls[0][0] as IImgInfo
expect(stored).not.toHaveProperty('_configId')
expect(stored).not.toHaveProperty('_configName')
} finally {
transformerSpy.mockRestore()
cloudHandle.mockRestore()
picgo.saveConfig({ 'picBed.uploader': uploaderA })
}
await expectConfigUnchanged()
})
it('rejects invalid options before invoking the GUI upload adapter', async () => {
const response = await fetch(`${baseUrl}/upload?uploader=missing-uploader`, {
method: 'POST',
+32 -1
View File
@@ -40,6 +40,7 @@ const mocks = vi.hoisted(() => {
}
const uploadMock = vi.fn<(input?: IUploadOption, options?: UploadOptions) => Promise<ImgInfo[] | Error>>()
const resolveUploadOptionsMock = vi.fn<(_picgo: unknown, options?: UploadOptions) => { uploader: string, config?: { _id: string, _configName: string } } | undefined>()
const reportUploadDataMock = vi.fn<(webContents: WebContents, options: ReportOptions) => Promise<void>>()
const privacyCheckMock = vi.fn<() => Promise<boolean>>()
const getClipboardFilePathListMock = vi.fn<() => string[]>()
@@ -51,6 +52,7 @@ const mocks = vi.hoisted(() => {
return {
uploadMock,
resolveUploadOptionsMock,
reportUploadDataMock,
privacyCheckMock,
getClipboardFilePathListMock,
@@ -68,7 +70,13 @@ const mocks = vi.hoisted(() => {
on: vi.fn((event: string, listener: EventListener) => {
addListener(picgoListeners, event, listener)
}),
uploaderConfig: {
getConfigList: vi.fn(() => [])
},
helper: {
uploader: {
get: vi.fn(() => undefined)
},
beforeUploadPlugins: {
register: vi.fn((_name: string, plugin: RenamePlugin) => {
renameHandler = plugin.handle
@@ -113,6 +121,10 @@ vi.mock('@core/picgo', () => ({
default: mocks.picgo
}))
vi.mock('picgo', () => ({
resolveUploadOptions: mocks.resolveUploadOptionsMock
}))
vi.mock('@core/picgo/logger', () => ({
default: {
error: mocks.loggerErrorMock,
@@ -270,6 +282,7 @@ describe('Uploader task context', () => {
vi.clearAllMocks()
mocks.resetIpcListeners()
mocks.uploadMock.mockReset()
mocks.resolveUploadOptionsMock.mockReset()
mocks.reportUploadDataMock.mockReset()
mocks.reportUploadDataMock.mockResolvedValue(undefined)
mocks.privacyCheckMock.mockReset()
@@ -462,6 +475,23 @@ describe('Uploader task context', () => {
expect(mocks.loggerErrorMock).toHaveBeenCalledWith(reportError)
})
it('does not borrow the globally active config when a selected uploader has no saved profile', async () => {
const image = createImage('cloud.png')
mocks.picgo.getConfig.mockImplementation((key: string) => {
if (key === 'picBed.current') return 's3'
if (key === 'picBed.s3') return { _id: 'global-id', _configName: 'Global' }
return undefined
})
mocks.resolveUploadOptionsMock.mockReturnValue({ uploader: 'picgo-cloud' })
mocks.uploadMock.mockResolvedValue([image])
const result = await uploader.upload({ options: { uploader: 'picgo-cloud' } })
expect(result).toEqual([image])
expect(image).not.toHaveProperty('_configId')
expect(image).not.toHaveProperty('_configName')
})
it('forwards options for the Core clipboard path and preserves default uploads', async () => {
const window = createWebContents(5)
const clipboardImage = createImage('core-clipboard.png')
@@ -535,7 +565,8 @@ describe('Uploader task context', () => {
await unlinkStarted.promise
expect(mocks.writeFileMock).toHaveBeenCalledWith(
expect.stringMatching(/^\/picgo-base\/.*\/\d{17}\.png$/),
// Separator-agnostic: path.join yields backslashes on Windows.
expect.stringMatching(/^[/\\]picgo-base[/\\].*[/\\]\d{17}\.png$/),
Buffer.from('png')
)
const generatedPath = mocks.writeFileMock.mock.calls[0][0]
@@ -0,0 +1,432 @@
import { describe, expect, it } from 'vitest'
import {
buildAlbumConfigMap,
buildAlbumPhotos,
buildAlbumProviderFilters,
filterAlbumImages,
inferConfigIdFromUrl,
unknownConfigKey,
NavType,
type AlbumConfigMap
} from '@/components/main/album/utils'
const S3_TYPE = 'aws-s3'
const MY_IMG_ID = 'config-my-img'
const COS_ID = 'config-cos'
const picBeds: IPicBedType[] = [
{ type: S3_TYPE, name: 'Amazon S3', visible: true },
{ type: 'smms', name: 'SM.MS', visible: true }
]
const configMap: AlbumConfigMap = {
[S3_TYPE]: [
{ id: MY_IMG_ID, name: 'my-img', urlPrefix: 'https://myimg.example.org' },
{ id: COS_ID, name: 'cos', urlPrefix: 'https://cos.example.org' }
]
}
function s3Item (overrides: Partial<ImgInfo> = {}): ImgInfo {
return {
id: `db-${Math.random().toString(36).slice(2)}`,
type: S3_TYPE,
fileName: 'photo.png',
imgUrl: 'https://myimg.example.org/img/2025/12/photo.png',
...overrides
}
}
describe('inferConfigIdFromUrl', () => {
it('matches a legacy item by its url prefix', () => {
const id = inferConfigIdFromUrl(s3Item(), configMap[S3_TYPE])
expect(id).toBe(MY_IMG_ID)
})
it('distinguishes configs that differ only by host', () => {
const id = inferConfigIdFromUrl(
s3Item({ imgUrl: 'https://cos.example.org/img/2025/12/photo.png' }),
configMap[S3_TYPE]
)
expect(id).toBe(COS_ID)
})
it('prefers the longest matching prefix when configs share a host', () => {
const configs = [
{ id: 'root', name: 'root', urlPrefix: 'https://cdn.example.org' },
{ id: 'nested', name: 'nested', urlPrefix: 'https://cdn.example.org/album' }
]
const id = inferConfigIdFromUrl(
s3Item({ imgUrl: 'https://cdn.example.org/album/photo.png' }),
configs
)
expect(id).toBe('nested')
})
it('requires a path boundary so a sibling segment is not claimed', () => {
const configs = [{ id: 'foo', name: 'foo', urlPrefix: 'https://cdn.example.org/foo' }]
expect(inferConfigIdFromUrl(
s3Item({ imgUrl: 'https://cdn.example.org/foobar/photo.png' }),
configs
)).toBe('')
expect(inferConfigIdFromUrl(
s3Item({ imgUrl: 'https://cdn.example.org/foo/photo.png' }),
configs
)).toBe('foo')
expect(inferConfigIdFromUrl(
s3Item({ imgUrl: 'https://cdn.example.org/foo' }),
configs
)).toBe('foo')
})
it('requires the host to match exactly', () => {
const configs = [{ id: 'cdn', name: 'cdn', urlPrefix: 'https://cdn.example.org' }]
expect(inferConfigIdFromUrl(
s3Item({ imgUrl: 'https://cdn.example.org.other.net/photo.png' }),
configs
)).toBe('')
expect(inferConfigIdFromUrl(
s3Item({ imgUrl: 'https://cdn.example.org/photo.png' }),
configs
)).toBe('cdn')
})
it('ignores host case but keeps paths case-sensitive', () => {
const configs = [
{ id: 'upper', name: 'upper', urlPrefix: 'https://cdn.example.org/Photos' },
{ id: 'lower', name: 'lower', urlPrefix: 'https://cdn.example.org/photos' }
]
expect(inferConfigIdFromUrl(
s3Item({ imgUrl: 'https://CDN.Example.ORG/Photos/a.png' }),
configs
)).toBe('upper')
expect(inferConfigIdFromUrl(
s3Item({ imgUrl: 'https://cdn.example.org/photos/a.png' }),
configs
)).toBe('lower')
})
it('ignores a rewritten imgUrl in favour of originImgUrl', () => {
const id = inferConfigIdFromUrl(
s3Item({
imgUrl: 'https://unrelated-cdn.example.net/photo.png',
originImgUrl: 'https://cos.example.org/img/photo.png'
}),
configMap[S3_TYPE]
)
expect(id).toBe(COS_ID)
})
it('tolerates a trailing slash and mixed case in the configured prefix', () => {
const id = inferConfigIdFromUrl(
s3Item({ imgUrl: 'https://myimg.example.org/img/photo.png' }),
[{ id: MY_IMG_ID, name: 'my-img', urlPrefix: 'https://MyImg.Example.org/' }]
)
expect(id).toBe(MY_IMG_ID)
})
it('returns empty string when no prefix matches', () => {
const id = inferConfigIdFromUrl(
s3Item({ imgUrl: 'https://somewhere-else.example.net/photo.png' }),
configMap[S3_TYPE]
)
expect(id).toBe('')
})
it('returns empty string when the config has no url prefix configured', () => {
const id = inferConfigIdFromUrl(s3Item(), [
{ id: 'no-prefix', name: 'no-prefix', urlPrefix: '' }
])
expect(id).toBe('')
})
it('returns empty string when the item has no url at all', () => {
const id = inferConfigIdFromUrl(
{ type: S3_TYPE, imgUrl: '', originImgUrl: '' },
configMap[S3_TYPE]
)
expect(id).toBe('')
})
})
describe('buildAlbumConfigMap', () => {
it('derives options from the uploader config, resolving url prefix fields', () => {
const map = buildAlbumConfigMap({
[S3_TYPE]: {
configList: [
{ _id: MY_IMG_ID, _configName: 'my-img', urlPrefix: 'https://myimg.example.org' },
{ _id: COS_ID, _configName: 'cos', customUrl: 'https://cos.example.org' }
]
}
})
expect(map[S3_TYPE]).toEqual([
{ id: MY_IMG_ID, name: 'my-img', urlPrefix: 'https://myimg.example.org' },
{ id: COS_ID, name: 'cos', urlPrefix: 'https://cos.example.org' }
])
})
it('skips configs without an _id and picbeds with no usable configs', () => {
const map = buildAlbumConfigMap({
[S3_TYPE]: { configList: [{ _configName: 'legacy' }] },
smms: { configList: [] }
})
expect(map).toEqual({})
})
it('falls back to the id when the config has no name', () => {
const map = buildAlbumConfigMap({
[S3_TYPE]: { configList: [{ _id: MY_IMG_ID }] }
})
expect(map[S3_TYPE][0].name).toBe(MY_IMG_ID)
expect(map[S3_TYPE][0].urlPrefix).toBe('')
})
it('returns an empty map when there is no uploader config', () => {
expect(buildAlbumConfigMap(undefined)).toEqual({})
})
})
describe('buildAlbumPhotos config attribution', () => {
it('uses the stored _configId when present, ignoring the url', () => {
const photos = buildAlbumPhotos(
[s3Item({
_configId: COS_ID,
imgUrl: 'https://myimg.example.org/img/photo.png'
})],
picBeds,
configMap
)
expect(photos[0].configId).toBe(COS_ID)
expect(photos[0].configName).toBe('cos')
})
it('infers the config for legacy items with no _configId', () => {
const photos = buildAlbumPhotos([s3Item()], picBeds, configMap)
expect(photos[0].configId).toBe(MY_IMG_ID)
expect(photos[0].configName).toBe('my-img')
})
it('resolves the display name from live config, not the upload-time snapshot', () => {
const photos = buildAlbumPhotos(
[s3Item({ _configId: MY_IMG_ID, _configName: 'old-name' })],
picBeds,
configMap
)
expect(photos[0].configName).toBe('my-img')
})
it('keeps the snapshotted name when the config no longer exists', () => {
const photos = buildAlbumPhotos(
[s3Item({ _configId: 'deleted-config', _configName: 'deleted' })],
picBeds,
configMap
)
expect(photos[0].configId).toBe('deleted-config')
expect(photos[0].configName).toBe('deleted')
})
it('leaves configId empty when nothing can be resolved', () => {
const photos = buildAlbumPhotos(
[s3Item({ imgUrl: 'https://somewhere-else.example.net/photo.png' })],
picBeds,
configMap
)
expect(photos[0].configId).toBe('')
})
it('works without a config map at all (cloud album path)', () => {
const photos = buildAlbumPhotos([s3Item()], picBeds)
expect(photos[0].configId).toBe('')
expect(photos[0].configName).toBe('')
})
})
describe('buildAlbumProviderFilters', () => {
const images = buildAlbumPhotos(
[
s3Item({ imgUrl: 'https://myimg.example.org/a.png' }),
s3Item({ imgUrl: 'https://myimg.example.org/b.png' }),
s3Item({ imgUrl: 'https://cos.example.org/c.png' }),
s3Item({ imgUrl: 'https://orphan.example.net/d.png' })
],
picBeds,
configMap
)
it('reports per-config counts and an unknown bucket', () => {
const filters = buildAlbumProviderFilters(images, picBeds, configMap)
const s3 = filters.find((item) => item.type === S3_TYPE)!
expect(s3.count).toBe(4)
expect(s3.configs).toEqual([
{ id: MY_IMG_ID, name: 'my-img', count: 2 },
{ id: COS_ID, name: 'cos', count: 1 }
])
expect(s3.unknownCount).toBe(1)
})
it('keeps photos from a deleted config reachable through a child node', () => {
const withDeletedConfig = buildAlbumPhotos(
[
s3Item({ _configId: MY_IMG_ID, _configName: 'my-img' }),
s3Item({ _configId: 'deleted-config', _configName: 'deleted' })
],
picBeds,
configMap
)
const s3 = buildAlbumProviderFilters(withDeletedConfig, picBeds, configMap)
.find((item) => item.type === S3_TYPE)!
expect(s3.count).toBe(2)
expect(s3.configs).toEqual([
{ id: MY_IMG_ID, name: 'my-img', count: 1 },
{ id: COS_ID, name: 'cos', count: 0 },
{ id: 'deleted-config', name: 'deleted', count: 1 }
])
expect(s3.unknownCount).toBe(0)
})
it('falls back to the config id when a deleted config has no snapshotted name', () => {
const withDeletedConfig = buildAlbumPhotos(
[s3Item({ _configId: 'deleted-config' })],
picBeds,
configMap
)
const s3 = buildAlbumProviderFilters(withDeletedConfig, picBeds, configMap)
.find((item) => item.type === S3_TYPE)!
expect(s3.configs).toEqual([
{ id: MY_IMG_ID, name: 'my-img', count: 0 },
{ id: COS_ID, name: 'cos', count: 0 },
{ id: 'deleted-config', name: 'deleted-config', count: 1 }
])
})
it('counts every photo of a provider exactly once across its children', () => {
const mixed = buildAlbumPhotos(
[
s3Item({ _configId: MY_IMG_ID }),
s3Item({ _configId: 'deleted-config', _configName: 'deleted' }),
s3Item({ imgUrl: 'https://orphan.example.net/d.png' })
],
picBeds,
configMap
)
const s3 = buildAlbumProviderFilters(mixed, picBeds, configMap)
.find((item) => item.type === S3_TYPE)!
const reachable = s3.configs.reduce((total, config) => total + config.count, 0) + s3.unknownCount
expect(reachable).toBe(s3.count)
})
it('reports zero counts for picbeds with no photos', () => {
const filters = buildAlbumProviderFilters(images, picBeds, configMap)
const smms = filters.find((item) => item.type === 'smms')!
expect(smms.count).toBe(0)
expect(smms.configs).toEqual([])
expect(smms.unknownCount).toBe(0)
})
it('excludes hidden picbeds', () => {
const filters = buildAlbumProviderFilters(
images,
[{ type: S3_TYPE, name: 'Amazon S3', visible: false }],
configMap
)
expect(filters).toEqual([])
})
})
describe('filterAlbumImages with config nav', () => {
const images = buildAlbumPhotos(
[
s3Item({ fileName: 'a.png', imgUrl: 'https://myimg.example.org/a.png' }),
s3Item({ fileName: 'b.png', imgUrl: 'https://cos.example.org/b.png' }),
s3Item({ fileName: 'c.png', imgUrl: 'https://orphan.example.net/c.png' })
],
picBeds,
configMap
)
it('filters to a single config', () => {
const result = filterAlbumImages(
images,
{ type: NavType.Config, value: COS_ID, providerType: S3_TYPE },
''
)
expect(result.map((image) => image.name)).toEqual(['b.png'])
})
it('filters to a config that no longer exists', () => {
const withDeletedConfig = buildAlbumPhotos(
[
s3Item({ fileName: 'a.png', _configId: MY_IMG_ID, _configName: 'my-img' }),
s3Item({ fileName: 'd.png', _configId: 'deleted-config', _configName: 'deleted' })
],
picBeds,
configMap
)
const result = filterAlbumImages(
withDeletedConfig,
{ type: NavType.Config, value: 'deleted-config', providerType: S3_TYPE },
''
)
expect(result.map((image) => image.name)).toEqual(['d.png'])
})
it('filters to the unknown bucket', () => {
const result = filterAlbumImages(
images,
{ type: NavType.Config, value: unknownConfigKey, providerType: S3_TYPE },
''
)
expect(result.map((image) => image.name)).toEqual(['c.png'])
})
it('returns nothing when providerType does not match the photo type', () => {
const result = filterAlbumImages(
images,
{ type: NavType.Config, value: MY_IMG_ID, providerType: 'smms' },
''
)
expect(result).toEqual([])
})
it('combines a config filter with the search query', () => {
const result = filterAlbumImages(
images,
{ type: NavType.Config, value: MY_IMG_ID, providerType: S3_TYPE },
'b'
)
expect(result).toEqual([])
})
it('still filters by provider across all its configs', () => {
const result = filterAlbumImages(
images,
{ type: NavType.Provider, value: S3_TYPE },
''
)
expect(result).toHaveLength(3)
})
})
+66 -2
View File
@@ -6,11 +6,13 @@ import {
import type { IpcMainEvent, WebContents } from 'electron'
import { AsyncLocalStorage } from 'node:async_hooks'
import dayjs from 'dayjs'
import isEqual from 'lodash/isEqual'
import picgo from '@core/picgo'
import windowManager from 'apis/app/window/windowManager'
import { IWindowList } from '#/types/enum'
import util from 'util'
import type { IPicGo } from 'picgo'
import { resolveUploadOptions } from 'picgo'
import type { IPicGo, UploadOptions } from 'picgo'
import { showNotification, getClipboardFilePathList } from '~/main/utils/common'
import { GET_RENAME_FILE_NAME, RENAME_FILE_NAME } from '~/universal/events/constants'
import logger from '@core/picgo/logger'
@@ -94,6 +96,50 @@ const waitForRename = (
})
}
interface IUploaderConfigSnapshot {
uploader: string
configId: string
configName: string
}
/**
* Resolve the config selected for this request before uploading. Only uploads
* without a profile override use the globally active config as a fallback.
*/
const getActiveUploaderConfigSnapshot = (options?: UploadOptions): IUploaderConfigSnapshot | null => {
const resolved = resolveUploadOptions(picgo, options)
if (resolved) {
const config = resolved.config
return config?._id ? { uploader: resolved.uploader, configId: config._id, configName: config._configName ?? '' } : null
}
const type = picgo.getConfig<string>('picBed.uploader') || picgo.getConfig<string>('picBed.current')
if (!type) {
return null
}
const config = picgo.getConfig<IStringKeyMap>(`picBed.${type}`)
if (!config?._id || typeof config._id !== 'string') {
return null
}
return {
uploader: type,
configId: config._id,
configName: typeof config._configName === 'string' ? config._configName : ''
}
}
const applyUploaderConfigSnapshot = (
output: ImgInfo[],
snapshot: IUploaderConfigSnapshot | null
) => {
output.forEach((item) => {
delete item._configId
delete item._configName
if (!snapshot || item.type !== snapshot.uploader) return
item._configId = snapshot.configId
item._configName = snapshot.configName
})
}
class Uploader {
private readonly uploadTaskStorage = new AsyncLocalStorage<UploadTaskContext>()
constructor () {
@@ -211,8 +257,26 @@ class Uploader {
throw Error(T('PRIVACY_TIPS'))
}
const startTime = Date.now()
const output = await picgo.upload(capturedTask.input, capturedTask.options)
// Snapshot this request's uploader config before uploading, so concurrent
// uploads and config switches cannot mis-attribute album items.
const configSnapshot = getActiveUploaderConfigSnapshot(capturedTask.options)
const hasUploadSelector = Boolean(
capturedTask.options?.uploader || capturedTask.options?.configId || capturedTask.options?.configName
)
// 显式 selector 解析到已保存的配置时,读取剪贴板前就固定其 ID:Core 会在读取
// 剪贴板期间重新解析 options,不固定就会落到切换后的 profile 上。
// 无 selector 时仍需确认活动 mirror 与已保存配置一致,保留原上传目标。
const canPinConfig = configSnapshot !== null &&
Boolean(picgo.helper.uploader.get(configSnapshot.uploader)) &&
picgo.uploaderConfig.getConfigList(configSnapshot.uploader).some(config =>
config._id === configSnapshot.configId && (hasUploadSelector || isEqual(config, picgo.getConfig(`picBed.${configSnapshot.uploader}`)))
)
const uploadOptions = canPinConfig
? { ...capturedTask.options, uploader: configSnapshot.uploader, configId: configSnapshot.configId }
: capturedTask.options
const output = await picgo.upload(capturedTask.input, uploadOptions)
if (Array.isArray(output) && output.some((item: ImgInfo) => item.imgUrl)) {
applyUploaderConfigSnapshot(output, canPinConfig || hasUploadSelector ? configSnapshot : null)
if (context.active && isAvailableWebContents(capturedTask.webContents)) {
try {
await dataReportManager.reportUploadData(capturedTask.webContents, {
@@ -1,3 +1,5 @@
import { useState } from "react"
import { ChevronDownIcon, ChevronRightIcon } from "lucide-react"
import { useTranslation } from "react-i18next"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
@@ -12,6 +14,7 @@ import { useAppStore } from "@/store"
import { CloudSidebarSkeleton } from "./cloud-loading"
import {
NavType,
unknownConfigKey,
type AlbumProviderFilter,
type AlbumPhoto,
type NavContext,
@@ -33,6 +36,19 @@ type AlbumNavButtonProps = {
onClick: () => void
}
type AlbumConfigNavButtonProps = {
label: string
count: number
active: boolean
onClick: () => void
}
type AlbumConfigNode = {
key: string
label: string
count: number
}
type AlbumSidebarProps = {
images: AlbumPhoto[]
providers: AlbumProviderFilter[]
@@ -111,6 +127,50 @@ function AlbumNavButton({
)
}
function AlbumConfigNavButton({
label,
count,
active,
onClick,
}: AlbumConfigNavButtonProps) {
return (
<Button
variant="ghost"
className={cn(
"h-8 w-full cursor-pointer justify-between rounded-md px-2 transition-all duration-300",
"focus-visible:ring-0 focus-visible:border-transparent",
"focus-visible:bg-(--app-sidebar-item-hover-bg)",
active
? "bg-(--app-sidebar-item-active-bg) text-(--app-sidebar-item-active-color) hover:bg-(--app-sidebar-item-active-bg) hover:text-(--app-sidebar-item-active-color)"
: "text-muted-foreground hover:bg-(--app-sidebar-item-hover-bg) hover:text-(--app-sidebar-item-active-color)"
)}
onClick={onClick}
aria-current={active ? "page" : undefined}
>
<span className="flex min-w-0 items-center gap-2">
<span
className={cn(
"size-1.5 shrink-0 rounded-full",
active ? "bg-primary" : "bg-muted-foreground/40"
)}
/>
<span className="truncate text-xs">{label}</span>
</span>
<Badge
variant="secondary"
className={cn(
"h-4 rounded-full px-1.5 text-[10px] font-semibold",
active
? "bg-(--app-sidebar-item-active-bg) text-(--app-sidebar-item-active-color)"
: "bg-muted text-muted-foreground"
)}
>
{count}
</Badge>
</Button>
)
}
export function AlbumSidebar({
images,
providers,
@@ -123,11 +183,20 @@ export function AlbumSidebar({
onCloudRefresh,
}: AlbumSidebarProps) {
const { t } = useTranslation()
const [expandedProviders, setExpandedProviders] = useState<string[]>([])
const isCloud = albumSource === AlbumSource.CLOUD
const showCloudNav = isCloud && isCloudAvailable
const picBeds = useAppStore.use.picBeds()
const cloudStatsQuery = useCloudAlbumStatsQuery({ enabled: showCloudNav })
const toggleProviderExpanded = (type: string) => {
setExpandedProviders((prev) =>
prev.includes(type)
? prev.filter((value) => value !== type)
: [...prev, type]
)
}
const cloudStats = cloudStatsQuery.data
const cloudStatsError = showCloudNav && cloudStatsQuery.isError
const cloudProviderFilters: AlbumProviderFilter[] = (cloudStats?.types ?? []).map((stat) => {
@@ -136,6 +205,9 @@ export function AlbumSidebar({
type: stat.type,
name: bed?.name ?? stat.type,
count: stat.count,
// Cloud album items carry no uploader config id, so no config breakdown.
configs: [],
unknownCount: 0,
}
})
@@ -193,21 +265,87 @@ export function AlbumSidebar({
onFilterChange({ type: NavType.All, value: allPhotosKey })
}
/>
{displayProviders.map((provider) => (
<AlbumNavButton
key={provider.type}
label={provider.name}
count={provider.count}
active={
{displayProviders.map((provider) => {
const configNodes: AlbumConfigNode[] = [
...provider.configs.map((config) => ({
key: config.id,
label: config.name,
count: config.count,
})),
...(provider.unknownCount > 0
? [{
key: unknownConfigKey,
label: t("ALBUM_CONFIG_UNKNOWN"),
count: provider.unknownCount,
}]
: []),
]
const hasConfigNodes = configNodes.length > 0
const isExpanded = expandedProviders.includes(provider.type)
const isProviderActive =
navContext.type === NavType.Provider &&
navContext.value === provider.type
}
return (
<div key={provider.type}>
<div className="flex items-center gap-1">
<div className="min-w-0 flex-1">
<AlbumNavButton
label={provider.name}
count={provider.count}
active={isProviderActive}
onClick={() =>
onFilterChange({ type: NavType.Provider, value: provider.type })
}
/>
</div>
{hasConfigNodes ? (
<Button
type="button"
variant="ghost"
size="icon-xs"
className="text-muted-foreground shrink-0 hover:bg-(--app-sidebar-item-hover-bg) hover:text-(--app-sidebar-item-active-color)"
onClick={() => toggleProviderExpanded(provider.type)}
title={isExpanded ? t("PROVIDER_SIDEBAR_COLLAPSE") : t("PROVIDER_SIDEBAR_EXPAND")}
aria-label={isExpanded ? t("PROVIDER_SIDEBAR_COLLAPSE") : t("PROVIDER_SIDEBAR_EXPAND")}
aria-expanded={isExpanded}
>
{isExpanded ? (
<ChevronDownIcon className="size-4" />
) : (
<ChevronRightIcon className="size-4" />
)}
</Button>
) : null}
</div>
{hasConfigNodes && isExpanded ? (
<div className="border-sidebar-border/60 mt-1 ml-4 space-y-1 border-l pb-1 pl-2">
{configNodes.map((node) => (
<AlbumConfigNavButton
key={node.key}
label={node.label}
count={node.count}
active={
navContext.type === NavType.Config &&
navContext.providerType === provider.type &&
navContext.value === node.key
}
onClick={() =>
onFilterChange({
type: NavType.Config,
value: node.key,
providerType: provider.type,
})
}
/>
))}
</div>
) : null}
</div>
)
})}
</div>
) : (
<CloudFeatureHighlights />
)}
@@ -1,6 +1,7 @@
import {
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react"
@@ -33,9 +34,12 @@ import {
useAlbumSelectionBox,
} from "./hooks/use-album-selection-box"
import {
buildAlbumConfigMap,
buildAlbumPhotos,
buildAlbumProviderFilters,
filterAlbumImages,
AlbumViewMode,
unknownConfigKey,
type AlbumProviderFilter,
type AlbumPhoto,
type NavContext,
@@ -82,6 +86,11 @@ export function PicGoAlbum() {
const masonryColumnCount =
useAlbumStore.use.masonryColumnCount() || GALLERY_MASONRY_COLUMN_COUNT_DEFAULT
const picBeds = useAppStore.use.picBeds()
const appConfig = useAppStore.use.appConfig()
const configMap = useMemo(
() => buildAlbumConfigMap(appConfig?.uploader),
[appConfig?.uploader]
)
const {
userInfo: cloudUserInfo,
isPaid: isCloudPaidUser,
@@ -115,19 +124,17 @@ export function PicGoAlbum() {
const displayImages = isCloud ? cloudItems : filteredImages
const masonryLayoutScopeKey = isCloud
? `cloud:${cloudTypeFilter || allPhotosKey}:${cloudSearch}`
: `local:${navContext.type}:${navContext.value}:${searchValue}`
: `local:${navContext.type}:${navContext.providerType ?? ""}:${navContext.value}:${searchValue}`
const imageMap = new Map(displayImages.map((image) => [image.id, image]))
const selectedSet = new Set(selectedIds)
const selectedImages = selectedIds
.map((id) => imageMap.get(id))
.filter((image): image is AlbumPhoto => Boolean(image))
const visibleProviders: AlbumProviderFilter[] = picBeds
.filter((item) => item.visible !== false)
.map((item) => ({
type: item.type,
name: item.name,
count: images.filter((image) => image.type === item.type).length,
}))
const visibleProviders: AlbumProviderFilter[] = buildAlbumProviderFilters(
images,
picBeds,
configMap
)
// TODO(v3-post-launch): Restore selected tag derivation when Tags inspector actions return.
// const selectedTags = getSelectedTags(selectedImages)
@@ -159,7 +166,7 @@ export function PicGoAlbum() {
}
const result = await cloudAlbumAdapter.list(query)
if (result.success && cloudFirstPageRequestIdRef.current === requestId) {
const photos = buildAlbumPhotos(result.data.items, picBeds)
const photos = buildAlbumPhotos(result.data.items, picBeds, configMap)
albumStoreActions.setCloudItems(photos)
albumStoreActions.setCloudTotal(result.data.total)
albumStoreActions.setCloudOffset(result.data.items.length)
@@ -232,7 +239,7 @@ export function PicGoAlbum() {
}
const result = await cloudAlbumAdapter.list(query)
if (result.success) {
const photos = buildAlbumPhotos(result.data.items, picBeds)
const photos = buildAlbumPhotos(result.data.items, picBeds, configMap)
albumStoreActions.appendCloudItems(photos)
albumStoreActions.setCloudOffset(state.cloudOffset + result.data.items.length)
albumStoreActions.setCloudHasMore(state.cloudOffset + result.data.items.length < result.data.total)
@@ -252,14 +259,14 @@ export function PicGoAlbum() {
try {
await appActions.ensureHydrated()
const albumItems = await albumAdapter.getAlbumItems()
setImages(buildAlbumPhotos(albumItems, picBeds))
setImages(buildAlbumPhotos(albumItems, picBeds, configMap))
} catch (error) {
console.error(error)
}
}
refreshAlbumPage()
}, [picBeds, refreshNonce, isCloud])
}, [picBeds, configMap, refreshNonce, isCloud])
// Cloud gallery data fetch — 免费用户切到 Cloud tab 时只看升级提示,不发列表请求
useEffect(() => {
@@ -724,6 +731,18 @@ export function PicGoAlbum() {
? (isCloud
? picBeds.find((bed) => bed.type === navContext.value)?.name ?? navContext.value
: visibleProviders.find((provider) => provider.type === navContext.value)?.name ?? navContext.value)
: navContext.type === NavType.Config
? (() => {
const provider = visibleProviders.find(
(item) => item.type === navContext.providerType
)
const providerName = provider?.name ?? navContext.providerType ?? ""
const configName = navContext.value === unknownConfigKey
? t("ALBUM_CONFIG_UNKNOWN")
: provider?.configs.find((config) => config.id === navContext.value)?.name ??
navContext.value
return providerName ? `${providerName} / ${configName}` : configName
})()
: navContext.value
return (
+244 -1
View File
@@ -18,12 +18,31 @@ export type AlbumPhoto = {
date: string
type: string
typeName?: string
/**
* `_id` of the uploader config this photo belongs to. Empty string when the
* config could not be determined (legacy item with no matching url prefix).
*/
configId: string
configName: string
raw: ImgInfo
collection: string
tags: string[]
isVideo: boolean
}
/** Sentinel nav value for photos whose uploader config could not be resolved. */
export const unknownConfigKey = '__unknown__'
export type AlbumConfigOption = {
id: string
name: string
/** Public url prefix, used to infer the config of legacy items. */
urlPrefix: string
}
/** Uploader config options grouped by picbed type. */
export type AlbumConfigMap = Record<string, AlbumConfigOption[]>
function resolveExtname (item: ImgInfo): string {
if (item.extname) {
return item.extname.toLowerCase()
@@ -52,6 +71,15 @@ export type AlbumProviderFilter = {
type: string
name: string
count: number
configs: AlbumConfigFilter[]
/** Photos of this provider whose config could not be resolved. */
unknownCount: number
}
export type AlbumConfigFilter = {
id: string
name: string
count: number
}
export const AlbumViewMode = {
@@ -65,6 +93,7 @@ export type AlbumViewMode =
export const NavType = {
All: 'all',
Provider: 'provider',
Config: 'config',
Collection: 'collection',
Tag: 'tag'
} as const
@@ -74,6 +103,8 @@ export type NavType = ValueOf<typeof NavType>
export type NavContext = {
type: NavType
value: string
/** Owning picbed type. Only set when `type` is `Config`. */
providerType?: string
}
export function filterAlbumImages (
@@ -88,6 +119,11 @@ export function filterAlbumImages (
? true
: navContext.type === NavType.Provider
? image.type === navContext.value
: navContext.type === NavType.Config
? image.type === navContext.providerType &&
(navContext.value === unknownConfigKey
? !image.configId
: image.configId === navContext.value)
: navContext.type === NavType.Collection
? image.collection === navContext.value
: image.tags.includes(navContext.value)
@@ -139,9 +175,146 @@ function formatAlbumSize (size?: number) {
return size / 1024 / 1024
}
/**
* Field names picbeds use for their public url prefix. Each builtin uploader
* names it differently (`urlPrefix` for S3, `customUrl` for COS/OSS/GitHub,
* `url` for Qiniu/Upyun), and plugins are free to pick their own — an unknown
* name just means legacy items of that picbed stay unattributed.
*/
const URL_PREFIX_FIELDS = ['urlPrefix', 'customUrl', 'customDomain', 'url', 'host', 'domain']
function resolveConfigUrlPrefix (config: Record<string, unknown>): string {
for (const field of URL_PREFIX_FIELDS) {
const value = config[field]
if (typeof value === 'string' && value.trim()) {
return value
}
}
return ''
}
/** Derive per-picbed uploader config options from the app config. */
export function buildAlbumConfigMap (
uploaderConfig: Record<string, { configList?: Array<Record<string, unknown>> }> | undefined
): AlbumConfigMap {
if (!uploaderConfig) {
return {}
}
const result: AlbumConfigMap = {}
Object.entries(uploaderConfig).forEach(([type, item]) => {
const configList = item?.configList ?? []
const options = configList
.filter((config) => typeof config._id === 'string' && config._id)
.map((config) => ({
id: config._id as string,
name: typeof config._configName === 'string' && config._configName
? config._configName
: (config._id as string),
urlPrefix: resolveConfigUrlPrefix(config)
}))
if (options.length > 0) {
result[type] = options
}
})
return result
}
type UrlParts = {
/** `scheme://host[:port]`, lowercased — scheme and host are case-insensitive. */
origin: string
/** Path without a trailing slash, case preserved — object keys can be case-sensitive. */
path: string
}
/**
* Split a value into an origin and a path so they can be compared separately.
* Returns null for values without an absolute scheme+host, which cannot be
* matched against a photo url — a bare `host`/`domain` config field therefore
* claims nothing, exactly as it did before.
*/
function parseUrlParts (value: string): UrlParts | null {
const trimmed = value.trim()
if (!trimmed) {
return null
}
let parsed: URL
try {
parsed = new URL(trimmed)
} catch {
return null
}
if (!parsed.hostname) {
return null
}
return {
origin: `${parsed.protocol}//${parsed.host}`.toLowerCase(),
path: parsed.pathname.replace(/\/+$/, '')
}
}
/**
* Whether `target` falls under `prefix`. The origin must match exactly, so a
* host-only prefix cannot claim a lookalike host (`cdn.example.org` must not
* claim `cdn.example.org.other.net`), and a path prefix must end on a segment
* boundary, so `/foo` cannot claim `/foobar/photo.png`.
*/
function matchesUrlPrefix (target: UrlParts, prefix: UrlParts): boolean {
if (target.origin !== prefix.origin) {
return false
}
if (!prefix.path) {
return true
}
if (!target.path.startsWith(prefix.path)) {
return false
}
const next = target.path.charAt(prefix.path.length)
return next === '' || next === '/'
}
/**
* Infer which uploader config produced a legacy album item by matching its url
* against each config's public url prefix. Longest prefix wins, so configs that
* share a host but differ by path still resolve correctly. Inference is never
* persisted: it is recomputed on every load, so renaming a config or changing
* its domain immediately re-buckets old photos instead of leaving stale data.
*/
export function inferConfigIdFromUrl (
item: ImgInfo,
configs: AlbumConfigOption[]
): string {
// Prefer the original url: a rewritten `imgUrl` may point at a CDN that no
// longer resembles any config's prefix.
const target = parseUrlParts(item.originImgUrl || item.imgUrl || '')
if (!target) {
return ''
}
let matchedId = ''
let matchedLength = 0
configs.forEach((config) => {
const prefix = parseUrlParts(config.urlPrefix)
if (!prefix || !matchesUrlPrefix(target, prefix)) {
return
}
const length = prefix.origin.length + prefix.path.length
if (length > matchedLength) {
matchedId = config.id
matchedLength = length
}
})
return matchedId
}
export function buildAlbumPhotos (
items: ImgInfo[],
picBeds: IPicBedType[]
picBeds: IPicBedType[],
configMap: AlbumConfigMap = {}
) {
const picBedMap = new Map(picBeds.map((bed) => [bed.type, bed.name]))
@@ -150,6 +323,14 @@ export function buildAlbumPhotos (
const itemType = typeof item.type === 'string' ? item.type : ''
const timestamp = resolveTimestampValue(item.createdAt) || resolveTimestampValue(item.updatedAt) || undefined
const size = typeof item.size === 'number' ? item.size : undefined
const configs = configMap[itemType] ?? []
const configId = typeof item._configId === 'string' && item._configId
? item._configId
: inferConfigIdFromUrl(item, configs)
// Always resolve the display name from live config, so a renamed config is
// reflected in the album; fall back to the name snapshotted at upload time.
const configName = configs.find((config) => config.id === configId)?.name ??
(typeof item._configName === 'string' ? item._configName : '')
return {
id: index,
@@ -164,6 +345,8 @@ export function buildAlbumPhotos (
date: formatAlbumDate(timestamp),
type: itemType,
typeName: picBedMap.get(itemType) || itemType,
configId,
configName,
raw: item,
collection: '',
tags: [],
@@ -171,3 +354,63 @@ export function buildAlbumPhotos (
} satisfies AlbumPhoto
})
}
/**
* Build the sidebar's provider tree: one node per visible picbed, each with a
* child per uploader config plus an "unknown" bucket for photos that could not
* be attributed.
*
* A config can be deleted after its photos were uploaded. Those photos keep a
* non-empty `configId` that no longer appears in the config list, so they would
* otherwise be counted in the provider total but reachable through no child at
* all. They get a child of their own, labelled with the name snapshotted at
* upload time, which keeps `configs` + `unknownCount` a partition of `count`.
*/
export function buildAlbumProviderFilters (
images: AlbumPhoto[],
picBeds: IPicBedType[],
configMap: AlbumConfigMap = {}
): AlbumProviderFilter[] {
return picBeds
.filter((item) => item.visible !== false)
.map((item) => {
const typeImages = images.filter((image) => image.type === item.type)
const configs = configMap[item.type] ?? []
const knownIds = new Set(configs.map((config) => config.id))
const orphanedIds = new Map<string, { name: string, count: number }>()
typeImages.forEach((image) => {
if (!image.configId || knownIds.has(image.configId)) {
return
}
const entry = orphanedIds.get(image.configId)
if (entry) {
entry.count += 1
} else {
orphanedIds.set(image.configId, {
name: image.configName || image.configId,
count: 1
})
}
})
return {
type: item.type,
name: item.name,
count: typeImages.length,
configs: [
...configs.map((config) => ({
id: config.id,
name: config.name,
count: typeImages.filter((image) => image.configId === config.id).length
})),
...Array.from(orphanedIds, ([id, entry]) => ({
id,
name: entry.name,
count: entry.count
}))
],
unknownCount: typeImages.filter((image) => !image.configId).length
}
})
}
@@ -239,6 +239,10 @@ export function HistoryPanel ({
sizeMb: 0,
date: item.time,
type: item.raw.type || '',
// Preview-only: the history panel has no config sidebar, so carry
// whatever the record stored without inferring anything.
configId: typeof item.raw._configId === 'string' ? item.raw._configId : '',
configName: typeof item.raw._configName === 'string' ? item.raw._configName : '',
raw: item.raw,
collection: '',
tags: [],
+1
View File
@@ -414,6 +414,7 @@ interface ILocales {
PRIVACY_TIPS: string
QUIT: string
ALBUM_ALL_PHOTOS: string
ALBUM_CONFIG_UNKNOWN: string
ALBUM_COLLECTIONS: string
ALBUM_TAGS: string
ALBUM_MENU: string
+11
View File
@@ -87,6 +87,17 @@ interface ImgInfo {
* updated time, Date.now(). if item is updated, such as url rewrite, this will be updated. otherwise, it will be the same as createdAt.
*/
updatedAt?: number | string | Date
/**
* `_id` of the uploader config that produced this item. Absent on items
* uploaded before this field was introduced; the album falls back to
* inferring the config from the URL in that case.
*/
_configId?: string
/**
* `_configName` of the uploader config at upload time. Display-only snapshot:
* renaming a config does not rewrite historical items.
*/
_configName?: string
[propName: string]: any
}