mirror of
https://github.com/Molunerfinn/PicGo.git
synced 2026-10-19 08:13:42 +00:00
✨ Feature(gui): group album photos by uploader config (#1452)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -449,6 +449,7 @@ PRIVACY: "ご利用の前にプライバシーポリシー${privacyUrl}と利用
|
||||
PRIVACY_TIPS: アップロードするにはプライバシーポリシーに同意してください
|
||||
QUIT: 終了
|
||||
ALBUM_ALL_PHOTOS: すべての写真
|
||||
ALBUM_CONFIG_UNKNOWN: 未分類
|
||||
ALBUM_COLLECTIONS: コレクション
|
||||
ALBUM_TAGS: タグ
|
||||
ALBUM_MENU: メニュー
|
||||
|
||||
@@ -449,6 +449,7 @@ PRIVACY: "이용하기 전에 개인정보 처리방침 ${privacyUrl} 및 이용
|
||||
PRIVACY_TIPS: 업로드하려면 개인정보 처리방침에 동의해 주세요
|
||||
QUIT: 종료
|
||||
ALBUM_ALL_PHOTOS: 전체 사진
|
||||
ALBUM_CONFIG_UNKNOWN: 미분류
|
||||
ALBUM_COLLECTIONS: 컬렉션
|
||||
ALBUM_TAGS: 태그
|
||||
ALBUM_MENU: 메뉴
|
||||
|
||||
@@ -449,6 +449,7 @@ PRIVACY: "使用前请阅读并同意隐私政策 ${privacyUrl} 与服务条款
|
||||
PRIVACY_TIPS: 请同意隐私协议,否则无法上传。
|
||||
QUIT: 退出
|
||||
ALBUM_ALL_PHOTOS: 全部照片
|
||||
ALBUM_CONFIG_UNKNOWN: 未分类
|
||||
ALBUM_COLLECTIONS: 合集
|
||||
ALBUM_TAGS: 标签
|
||||
ALBUM_MENU: 菜单
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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,20 +265,86 @@ export function AlbumSidebar({
|
||||
onFilterChange({ type: NavType.All, value: allPhotosKey })
|
||||
}
|
||||
/>
|
||||
{displayProviders.map((provider) => (
|
||||
<AlbumNavButton
|
||||
key={provider.type}
|
||||
label={provider.name}
|
||||
count={provider.count}
|
||||
active={
|
||||
navContext.type === NavType.Provider &&
|
||||
{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
|
||||
}
|
||||
onClick={() =>
|
||||
onFilterChange({ type: NavType.Provider, 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,7 +731,19 @@ 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.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 (
|
||||
<main className="flex min-h-0 min-w-0 flex-1 gap-4">
|
||||
|
||||
@@ -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,9 +119,14 @@ export function filterAlbumImages (
|
||||
? true
|
||||
: navContext.type === NavType.Provider
|
||||
? image.type === navContext.value
|
||||
: navContext.type === NavType.Collection
|
||||
? image.collection === navContext.value
|
||||
: image.tags.includes(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)
|
||||
|
||||
const matchesSearch = query
|
||||
? image.name.toLowerCase().includes(query) ||
|
||||
@@ -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: [],
|
||||
|
||||
Vendored
+1
@@ -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
|
||||
|
||||
Vendored
+11
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user