.
This commit is contained in:
parent
6a8c7635f1
commit
d715d94b6d
26 changed files with 1007 additions and 453 deletions
66
frontend/sync-client/src/utils/conflict-path.ts
Normal file
66
frontend/sync-client/src/utils/conflict-path.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import type { RelativePath } from "../sync-operations/types";
|
||||
|
||||
// Local-only files displaced by `FileOperations.ensureClearPath` are named
|
||||
// `conflict-<uuid>-<originalName>`. The UUID is a full RFC-4122 v4 value so
|
||||
// a user-authored filename that happens to start with `conflict-` doesn't
|
||||
// get misclassified.
|
||||
const CONFLICT_UUID_REGEX =
|
||||
/^conflict-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}-/u;
|
||||
|
||||
// Safe segment length for common filesystems (ext4 / NTFS / APFS all cap
|
||||
// at 255 bytes). `conflict-<36-char-uuid>-` adds 46 bytes; reserve a few
|
||||
// extra bytes for a future prefix bump and leave room for multi-byte UTF-8
|
||||
// characters in the original name.
|
||||
const CONFLICT_PREFIX_LEN = "conflict-".length + 36 + 1;
|
||||
const MAX_SEGMENT_BYTES = 255;
|
||||
const MAX_ORIGINAL_BYTES = MAX_SEGMENT_BYTES - CONFLICT_PREFIX_LEN - 4;
|
||||
|
||||
export function buildConflictFileName(fileName: string): string {
|
||||
// Truncate the original name if keeping it whole would bust the
|
||||
// filesystem's segment-length cap. Preserve the trailing extension
|
||||
// so the file is still recognizable / openable.
|
||||
const safeName = truncateFileNameToByteLimit(fileName, MAX_ORIGINAL_BYTES);
|
||||
return `conflict-${crypto.randomUUID()}-${safeName}`;
|
||||
}
|
||||
|
||||
function truncateFileNameToByteLimit(
|
||||
fileName: string,
|
||||
maxBytes: number
|
||||
): string {
|
||||
const encoder = new TextEncoder();
|
||||
if (encoder.encode(fileName).byteLength <= maxBytes) return fileName;
|
||||
|
||||
const dotIndex = fileName.lastIndexOf(".");
|
||||
// Dotfile (starts with "." and nothing else) → no extension to preserve.
|
||||
const hasExtension = dotIndex > 0;
|
||||
const extension = hasExtension ? fileName.slice(dotIndex) : "";
|
||||
const stem = hasExtension ? fileName.slice(0, dotIndex) : fileName;
|
||||
|
||||
const extensionBytes = encoder.encode(extension).byteLength;
|
||||
const stemBudget = Math.max(0, maxBytes - extensionBytes);
|
||||
|
||||
// Walk the stem by grapheme cluster so we never split an emoji sequence
|
||||
// (e.g. ZWJ families, skin-tone modifiers) or a base+combining-mark pair.
|
||||
const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
||||
let truncatedStem = "";
|
||||
let usedBytes = 0;
|
||||
for (const { segment } of segmenter.segment(stem)) {
|
||||
const segmentBytes = encoder.encode(segment).byteLength;
|
||||
if (usedBytes + segmentBytes > stemBudget) break;
|
||||
truncatedStem += segment;
|
||||
usedBytes += segmentBytes;
|
||||
}
|
||||
return truncatedStem + extension;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is `path`'s final segment a conflict-displaced filename?
|
||||
*
|
||||
* Any sync code that would otherwise create/update/delete/sync the path
|
||||
* should short-circuit when this returns true: conflict-displaced files are
|
||||
* strictly local and must stay invisible to the server.
|
||||
*/
|
||||
export function isConflictPath(path: RelativePath): boolean {
|
||||
const fileName = path.substring(path.lastIndexOf("/") + 1);
|
||||
return CONFLICT_UUID_REGEX.test(fileName);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue