Skip to content

Commit b7dcc97

Browse files
authored
fix(cli): allow special characters in paths (immich-app#13282)
* fix(cli): commas in import paths * adding more test cases
1 parent 057510a commit b7dcc97

File tree

5 files changed

+149
-26
lines changed

5 files changed

+149
-26
lines changed

cli/package-lock.json

+1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

cli/src/utils.spec.ts

+1-11
Original file line numberDiff line numberDiff line change
@@ -115,17 +115,7 @@ const tests: Test[] = [
115115
'/albums/image3.jpg': true,
116116
},
117117
},
118-
{
119-
test: 'should support globbing paths',
120-
options: {
121-
pathsToCrawl: ['/photos*'],
122-
},
123-
files: {
124-
'/photos1/image1.jpg': true,
125-
'/photos2/image2.jpg': true,
126-
'/images/image3.jpg': false,
127-
},
128-
},
118+
129119
{
130120
test: 'should crawl a single path without trailing slash',
131121
options: {

cli/src/utils.ts

+9-13
Original file line numberDiff line numberDiff line change
@@ -141,25 +141,21 @@ export const crawl = async (options: CrawlOptions): Promise<string[]> => {
141141
}
142142
}
143143

144-
let searchPattern: string;
145-
if (patterns.length === 1) {
146-
searchPattern = patterns[0];
147-
} else if (patterns.length === 0) {
144+
if (patterns.length === 0) {
148145
return crawledFiles;
149-
} else {
150-
searchPattern = '{' + patterns.join(',') + '}';
151-
}
152-
153-
if (recursive) {
154-
searchPattern = searchPattern + '/**/';
155146
}
156147

157-
searchPattern = `${searchPattern}/*.{${extensions.join(',')}}`;
148+
const searchPatterns = patterns.map((pattern) => {
149+
let escapedPattern = pattern;
150+
if (recursive) {
151+
escapedPattern = escapedPattern + '/**';
152+
}
153+
return `${escapedPattern}/*.{${extensions.join(',')}}`;
154+
});
158155

159-
const globbedFiles = await glob(searchPattern, {
156+
const globbedFiles = await glob(searchPatterns, {
160157
absolute: true,
161158
caseSensitiveMatch: false,
162-
onlyFiles: true,
163159
dot: includeHidden,
164160
ignore: [`**/${exclusionPattern}`],
165161
});

e2e/src/cli/specs/upload.e2e-spec.ts

+137-2
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,90 @@
11
import { LoginResponseDto, getAllAlbums, getAssetStatistics } from '@immich/sdk';
2-
import { readFileSync } from 'node:fs';
2+
import { cpSync, readFileSync } from 'node:fs';
33
import { mkdir, readdir, rm, symlink } from 'node:fs/promises';
4-
import { asKeyAuth, immichCli, testAssetDir, utils } from 'src/utils';
4+
import { asKeyAuth, immichCli, specialCharStrings, testAssetDir, utils } from 'src/utils';
55
import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
66

7+
interface Test {
8+
test: string;
9+
paths: string[];
10+
files: Record<string, boolean>;
11+
}
12+
13+
const tests: Test[] = [
14+
{
15+
test: 'should support globbing with *',
16+
paths: [`/photos*`],
17+
files: {
18+
'/photos1/image1.jpg': true,
19+
'/photos2/image2.jpg': true,
20+
'/images/image3.jpg': false,
21+
},
22+
},
23+
{
24+
test: 'should support paths with an asterisk',
25+
paths: [`/photos\*/image1.jpg`],
26+
files: {
27+
'/photos*/image1.jpg': true,
28+
'/photos*/image2.jpg': false,
29+
'/images/image3.jpg': false,
30+
},
31+
},
32+
{
33+
test: 'should support paths with a space',
34+
paths: [`/my photos/image1.jpg`],
35+
files: {
36+
'/my photos/image1.jpg': true,
37+
'/my photos/image2.jpg': false,
38+
'/images/image3.jpg': false,
39+
},
40+
},
41+
{
42+
test: 'should support paths with a single quote',
43+
paths: [`/photos\'/image1.jpg`],
44+
files: {
45+
"/photos'/image1.jpg": true,
46+
"/photos'/image2.jpg": false,
47+
'/images/image3.jpg': false,
48+
},
49+
},
50+
{
51+
test: 'should support paths with a double quote',
52+
paths: [`/photos\"/image1.jpg`],
53+
files: {
54+
'/photos"/image1.jpg': true,
55+
'/photos"/image2.jpg': false,
56+
'/images/image3.jpg': false,
57+
},
58+
},
59+
{
60+
test: 'should support paths with a comma',
61+
paths: [`/photos, eh/image1.jpg`],
62+
files: {
63+
'/photos, eh/image1.jpg': true,
64+
'/photos, eh/image2.jpg': false,
65+
'/images/image3.jpg': false,
66+
},
67+
},
68+
{
69+
test: 'should support paths with an opening brace',
70+
paths: [`/photos\{/image1.jpg`],
71+
files: {
72+
'/photos{/image1.jpg': true,
73+
'/photos{/image2.jpg': false,
74+
'/images/image3.jpg': false,
75+
},
76+
},
77+
{
78+
test: 'should support paths with a closing brace',
79+
paths: [`/photos\}/image1.jpg`],
80+
files: {
81+
'/photos}/image1.jpg': true,
82+
'/photos}/image2.jpg': false,
83+
'/images/image3.jpg': false,
84+
},
85+
},
86+
];
87+
788
describe(`immich upload`, () => {
889
let admin: LoginResponseDto;
990
let key: string;
@@ -32,6 +113,60 @@ describe(`immich upload`, () => {
32113
expect(assets.total).toBe(1);
33114
});
34115

116+
describe(`should accept special cases`, () => {
117+
for (const { test, paths, files } of tests) {
118+
it(test, async () => {
119+
const baseDir = `/tmp/upload/`;
120+
121+
const testPaths = Object.keys(files).map((filePath) => `${baseDir}/${filePath}`);
122+
testPaths.map((filePath) => utils.createImageFile(filePath));
123+
124+
const commandLine = paths.map((argument) => `${baseDir}/${argument}`);
125+
126+
const expectedCount = Object.entries(files).filter((entry) => entry[1]).length;
127+
128+
const { stderr, stdout, exitCode } = await immichCli(['upload', ...commandLine]);
129+
expect(stderr).toBe('');
130+
expect(stdout.split('\n')).toEqual(
131+
expect.arrayContaining([expect.stringContaining(`Successfully uploaded ${expectedCount} new asset`)]),
132+
);
133+
expect(exitCode).toBe(0);
134+
135+
const assets = await getAssetStatistics({}, { headers: asKeyAuth(key) });
136+
expect(assets.total).toBe(expectedCount);
137+
138+
testPaths.map((filePath) => utils.removeImageFile(filePath));
139+
});
140+
}
141+
});
142+
143+
it.each(specialCharStrings)(`should upload a multiple files from paths containing %s`, async (testString) => {
144+
// https://github.com/immich-app/immich/issues/12078
145+
146+
// NOTE: this test must contain more than one path since a related bug is only triggered with multiple paths
147+
148+
const testPaths = [
149+
`${testAssetDir}/temp/dir1${testString}name/asset.jpg`,
150+
`${testAssetDir}/temp/dir2${testString}name/asset.jpg`,
151+
];
152+
153+
cpSync(`${testAssetDir}/albums/nature/tanners_ridge.jpg`, testPaths[0]);
154+
cpSync(`${testAssetDir}/albums/nature/silver_fir.jpg`, testPaths[1]);
155+
156+
const { stderr, stdout, exitCode } = await immichCli(['upload', ...testPaths]);
157+
expect(stderr).toBe('');
158+
expect(stdout.split('\n')).toEqual(
159+
expect.arrayContaining([expect.stringContaining('Successfully uploaded 2 new assets')]),
160+
);
161+
expect(exitCode).toBe(0);
162+
163+
utils.removeImageFile(testPaths[0]);
164+
utils.removeImageFile(testPaths[1]);
165+
166+
const assets = await getAssetStatistics({}, { headers: asKeyAuth(key) });
167+
expect(assets.total).toBe(2);
168+
});
169+
35170
it('should skip a duplicate file', async () => {
36171
const first = await immichCli(['upload', `${testAssetDir}/albums/nature/silver_fir.jpg`]);
37172
expect(first.stderr).toBe('');

e2e/src/utils.ts

+1
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ export const immichCli = (args: string[]) =>
6868
executeCommand('node', ['node_modules/.bin/immich', '-d', `/${tempDir}/immich/`, ...args]).promise;
6969
export const immichAdmin = (args: string[]) =>
7070
executeCommand('docker', ['exec', '-i', 'immich-e2e-server', '/bin/bash', '-c', `immich-admin ${args.join(' ')}`]);
71+
export const specialCharStrings = ["'", '"', ',', '{', '}', '*'];
7172

7273
const executeCommand = (command: string, args: string[]) => {
7374
let _resolve: (value: CommandResponse) => void;

0 commit comments

Comments
 (0)