Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 130 additions & 5 deletions cli/src/commands/asset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -451,26 +451,72 @@ const deleteFiles = async (uploaded: Asset[], duplicates: Asset[], options: Uplo
}
};

const resolveServerDuplicates = async (albumTuples: AlbumPathTuple[]): Promise<Map<string, string>> => {
const dirToAlbumName = new Map<string, string>();
const usedNames = new Set<string>();

// Sort by path depth (deepest first) to handle nesting properly
const sortedTuples = [...albumTuples].toSorted(([a], [b]) => b.split(path.sep).length - a.split(path.sep).length);

for (const [dir, albumName] of sortedTuples) {
const finalName = albumName;
let uniqueName = finalName;
let counter = 1;

// Only check against already used names in this batch
while (usedNames.has(uniqueName)) {
uniqueName = `${finalName} ${counter++}`;
}

usedNames.add(uniqueName);
dirToAlbumName.set(dir, uniqueName);
}

return dirToAlbumName;
};

const updateAlbums = async (assets: Asset[], options: UploadOptionsDto) => {
if (!options.album && !options.albumName) {
return;
}
const { dryRun, concurrency } = options;

// Get existing albums from server
const albums = await getAllAlbums({});
const existingAlbums = new Map(albums.map((album) => [album.albumName, album.id]));
const newAlbums: Set<string> = new Set();

// Generate album names for all directories
const albumTuples = generateAlbumNames(assets.map((a) => a.filepath));

// Resolve any duplicates within this batch
const dirToAlbumName = await resolveServerDuplicates(albumTuples);

// Create mapping from filepath to album name
const uploadAlbumNames = new Map<string, string>(); // filepath -> albumName

for (const { filepath } of assets) {
const albumName = getAlbumName(filepath, options);
if (albumName && !existingAlbums.has(albumName)) {
newAlbums.add(albumName);
const dir = path.dirname(filepath);
const albumName = dirToAlbumName.get(dir);
if (albumName) {
uploadAlbumNames.set(filepath, albumName);
if (!existingAlbums.has(albumName)) {
newAlbums.add(albumName);
}
}
}

if (dryRun) {
// TODO print asset counts for new albums
console.log(`Would have created ${newAlbums.size} new album${s(newAlbums.size)}`);
console.log(`Would have updated albums of ${assets.length} asset${s(assets.length)}`);

if (options.jsonOutput) {
const output = {
albums: [...newAlbums],
albumMappings: Object.fromEntries(uploadAlbumNames),
};
console.log(JSON.stringify(output, undefined, 2));
}
return;
}

Expand All @@ -497,9 +543,17 @@ const updateAlbums = async (assets: Asset[], options: UploadOptionsDto) => {
console.log(`Successfully created ${newAlbums.size} new album${s(newAlbums.size)}`);
console.log(`Successfully updated ${assets.length} asset${s(assets.length)}`);

if (options.jsonOutput) {
const output = {
albums: [...newAlbums],
albumMappings: Object.fromEntries(uploadAlbumNames),
};
console.log(JSON.stringify(output, undefined, 2));
}

const albumToAssets = new Map<string, string[]>();
for (const asset of assets) {
const albumName = getAlbumName(asset.filepath, options);
const albumName = uploadAlbumNames.get(asset.filepath);
if (!albumName) {
continue;
}
Expand Down Expand Up @@ -536,3 +590,74 @@ const updateAlbums = async (assets: Asset[], options: UploadOptionsDto) => {
export const getAlbumName = (filepath: string, options: UploadOptionsDto) => {
return options.albumName ?? path.basename(path.dirname(filepath));
};

type AlbumPathTuple = [string, string]; // [directoryPath, albumName]

/**
* Generate album names for all directories, resolving duplicates by adding parent directories
*/
const generateAlbumNames = (filepaths: string[]): AlbumPathTuple[] => {
// First pass: collect all unique directories
const directories = new Set<string>();
for (const filepath of filepaths) {
directories.add(path.dirname(filepath));
}

// Second pass: for each directory, collect all possible album name candidates
// with increasing levels of context
const dirCandidates = new Map<string, string[]>();

for (const dir of directories) {
const parts = dir.split(path.sep).filter(Boolean);
const candidates: string[] = [];

// Generate candidates with increasing context
for (let i = 1; i <= parts.length; i++) {
candidates.push(parts.slice(-i).join(' '));
}

dirCandidates.set(dir, candidates);
}

// Third pass: find the minimal unique name for each directory
const result: AlbumPathTuple[] = [];
const usedNames = new Set<string>();

// Sort directories by depth (deepest first) to handle nesting properly
const sortedDirs = [...directories].toSorted((a, b) => b.split(path.sep).length - a.split(path.sep).length);

for (const dir of sortedDirs) {
const candidates = dirCandidates.get(dir) || [];
let selectedName = candidates[0] || '';

// Find the shortest unique name
for (const candidate of candidates) {
selectedName = candidate;

// Check if this name is already used
let isUnique = true;
for (const [otherDir, otherCandidates] of dirCandidates) {
if (otherDir === dir) {
continue;
}

if (otherCandidates.includes(candidate)) {
isUnique = false;
break;
}
}

if (isUnique) {
break;
}
}

// Add to used names and result
if (selectedName) {
usedNames.add(selectedName);
result.push([dir, selectedName]);
}
}

return result;
};
Loading