Files
readest/apps/readest-app/src/services/imageService.ts
T
Huang Xin de6529523f feat(sync): cross-device background texture sync (#4079)
Plug the texture replica adapter into the kind-agnostic primitives
shipped in #4077. Textures imported on one device download and
become available on every signed-in device, with the same shape as
the font sync stack (single-file binary, contentId from
partialMD5+size+filename, bundleDir layout, replica-publish on
import, full activation on auto-download).

Includes legacy flat-path migration so pre-existing textures sync
without re-import, ColorPanel import flow now publishes the row
and queues the binary upload, and createCustomTexture preserves
contentId/bundleDir/byteSize through addTexture (mirrors the
font-import fix). Server allowlist gains 'texture' with a
single-image Zod schema; useBackgroundTexture passes replica
metadata through addTexture so the boot-time "ensure selected
texture is in store" path doesn't silently un-publish a remote
record.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 09:55:32 +02:00

77 lines
2.5 KiB
TypeScript

import { FileSystem } from '@/types/system';
import { getFilename } from '@/utils/path';
import { md5, partialMD5 } from '@/utils/md5';
import { uniqueId } from '@/utils/misc';
import { CustomTextureInfo, getTextureName } from '@/styles/textures';
/**
* Build the cross-device content id for a texture:
* `md5(partialMD5 ‖ byteSize ‖ filename)`. Same recipe shape as
* fontService.computeFontContentId — keeps the kinds aligned.
*/
export const computeTextureContentId = (
partialMd5: string,
byteSize: number,
filename: string,
): string => md5(`${partialMd5}|${byteSize}|${filename}`);
/**
* Import an image into the user's `Images` base under a per-texture
* bundle dir (`<bundleDir>/<filename>`). The bundle dir is a fresh
* `uniqueId()` — matches the font / dictionary import pattern. Returns
* a CustomTextureInfo with the relative path, contentId, bundleDir,
* and byteSize populated so the store can publish the replica row
* immediately.
*/
export async function importImage(
fs: FileSystem,
file?: string | File,
): Promise<CustomTextureInfo | null> {
const bundleDir = uniqueId();
let filename: string;
let bytes: ArrayBuffer;
if (typeof file === 'string') {
const filePath = file;
const fileobj = await fs.openFile(filePath, 'None');
filename = fileobj.name || getFilename(filePath);
bytes = await fileobj.arrayBuffer();
} else if (file) {
filename = getFilename(file.name);
bytes = await file.arrayBuffer();
} else {
return null;
}
const texturePath = `${bundleDir}/${filename}`;
await fs.createDir(bundleDir, 'Images', true);
await fs.writeFile(texturePath, 'Images', bytes);
const textureFile = await fs.openFile(texturePath, 'Images');
const partialMd5 = await partialMD5(textureFile);
const byteSize = bytes.byteLength;
const contentId = computeTextureContentId(partialMd5, byteSize, filename);
return {
name: getTextureName(filename),
path: texturePath,
bundleDir,
contentId,
byteSize,
};
}
export async function deleteImage(fs: FileSystem, texture: CustomTextureInfo): Promise<void> {
await fs.removeFile(texture.path, 'Images');
// Also remove the per-texture bundle dir if it's now empty. Legacy
// textures without bundleDir live at the flat `Images/<filename>`
// path; nothing extra to clean up there.
if (texture.bundleDir) {
try {
await fs.removeDir(texture.bundleDir, 'Images', true);
} catch (err) {
console.warn('Failed to remove texture bundleDir', texture.bundleDir, err);
}
}
}