mirror of https://github.com/immich-app/immich.git
feat(server) user-defined storage structure (#1098)
[Breaking] newly uploaded file will conform to the default structure of `{uploadLocation}/{userId}/year/year-month-day/filename.ext`
pull/1110/head
parent
391d00bcb9
commit
c754c860fd
@ -0,0 +1,15 @@
|
||||
# openapi.model.SystemConfigStorageTemplateDto
|
||||
|
||||
## Load the model package
|
||||
```dart
|
||||
import 'package:openapi/api.dart';
|
||||
```
|
||||
|
||||
## Properties
|
||||
Name | Type | Description | Notes
|
||||
------------ | ------------- | ------------- | -------------
|
||||
**template** | **String** | |
|
||||
|
||||
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
|
||||
|
||||
|
||||
@ -0,0 +1,21 @@
|
||||
# openapi.model.SystemConfigTemplateStorageOptionDto
|
||||
|
||||
## Load the model package
|
||||
```dart
|
||||
import 'package:openapi/api.dart';
|
||||
```
|
||||
|
||||
## Properties
|
||||
Name | Type | Description | Notes
|
||||
------------ | ------------- | ------------- | -------------
|
||||
**yearOptions** | **List<String>** | | [default to const []]
|
||||
**monthOptions** | **List<String>** | | [default to const []]
|
||||
**dayOptions** | **List<String>** | | [default to const []]
|
||||
**hourOptions** | **List<String>** | | [default to const []]
|
||||
**minuteOptions** | **List<String>** | | [default to const []]
|
||||
**secondOptions** | **List<String>** | | [default to const []]
|
||||
**presetOptions** | **List<String>** | | [default to const []]
|
||||
|
||||
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
|
||||
|
||||
|
||||
@ -0,0 +1,111 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.12
|
||||
|
||||
// ignore_for_file: unused_element, unused_import
|
||||
// ignore_for_file: always_put_required_named_parameters_first
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
|
||||
part of openapi.api;
|
||||
|
||||
class SystemConfigStorageTemplateDto {
|
||||
/// Returns a new [SystemConfigStorageTemplateDto] instance.
|
||||
SystemConfigStorageTemplateDto({
|
||||
required this.template,
|
||||
});
|
||||
|
||||
String template;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is SystemConfigStorageTemplateDto &&
|
||||
other.template == template;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(template.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'SystemConfigStorageTemplateDto[template=$template]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final _json = <String, dynamic>{};
|
||||
_json[r'template'] = template;
|
||||
return _json;
|
||||
}
|
||||
|
||||
/// Returns a new [SystemConfigStorageTemplateDto] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static SystemConfigStorageTemplateDto? fromJson(dynamic value) {
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
// Ensure that the map contains the required keys.
|
||||
// Note 1: the values aren't checked for validity beyond being non-null.
|
||||
// Note 2: this code is stripped in release mode!
|
||||
assert(() {
|
||||
requiredKeys.forEach((key) {
|
||||
assert(json.containsKey(key), 'Required key "SystemConfigStorageTemplateDto[$key]" is missing from JSON.');
|
||||
assert(json[key] != null, 'Required key "SystemConfigStorageTemplateDto[$key]" has a null value in JSON.');
|
||||
});
|
||||
return true;
|
||||
}());
|
||||
|
||||
return SystemConfigStorageTemplateDto(
|
||||
template: mapValueOfType<String>(json, r'template')!,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<SystemConfigStorageTemplateDto>? listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <SystemConfigStorageTemplateDto>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = SystemConfigStorageTemplateDto.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, SystemConfigStorageTemplateDto> mapFromJson(dynamic json) {
|
||||
final map = <String, SystemConfigStorageTemplateDto>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = SystemConfigStorageTemplateDto.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of SystemConfigStorageTemplateDto-objects as value to a dart map
|
||||
static Map<String, List<SystemConfigStorageTemplateDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<SystemConfigStorageTemplateDto>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = SystemConfigStorageTemplateDto.listFromJson(entry.value, growable: growable,);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'template',
|
||||
};
|
||||
}
|
||||
|
||||
@ -0,0 +1,173 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.12
|
||||
|
||||
// ignore_for_file: unused_element, unused_import
|
||||
// ignore_for_file: always_put_required_named_parameters_first
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
|
||||
part of openapi.api;
|
||||
|
||||
class SystemConfigTemplateStorageOptionDto {
|
||||
/// Returns a new [SystemConfigTemplateStorageOptionDto] instance.
|
||||
SystemConfigTemplateStorageOptionDto({
|
||||
this.yearOptions = const [],
|
||||
this.monthOptions = const [],
|
||||
this.dayOptions = const [],
|
||||
this.hourOptions = const [],
|
||||
this.minuteOptions = const [],
|
||||
this.secondOptions = const [],
|
||||
this.presetOptions = const [],
|
||||
});
|
||||
|
||||
List<String> yearOptions;
|
||||
|
||||
List<String> monthOptions;
|
||||
|
||||
List<String> dayOptions;
|
||||
|
||||
List<String> hourOptions;
|
||||
|
||||
List<String> minuteOptions;
|
||||
|
||||
List<String> secondOptions;
|
||||
|
||||
List<String> presetOptions;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is SystemConfigTemplateStorageOptionDto &&
|
||||
other.yearOptions == yearOptions &&
|
||||
other.monthOptions == monthOptions &&
|
||||
other.dayOptions == dayOptions &&
|
||||
other.hourOptions == hourOptions &&
|
||||
other.minuteOptions == minuteOptions &&
|
||||
other.secondOptions == secondOptions &&
|
||||
other.presetOptions == presetOptions;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(yearOptions.hashCode) +
|
||||
(monthOptions.hashCode) +
|
||||
(dayOptions.hashCode) +
|
||||
(hourOptions.hashCode) +
|
||||
(minuteOptions.hashCode) +
|
||||
(secondOptions.hashCode) +
|
||||
(presetOptions.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'SystemConfigTemplateStorageOptionDto[yearOptions=$yearOptions, monthOptions=$monthOptions, dayOptions=$dayOptions, hourOptions=$hourOptions, minuteOptions=$minuteOptions, secondOptions=$secondOptions, presetOptions=$presetOptions]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final _json = <String, dynamic>{};
|
||||
_json[r'yearOptions'] = yearOptions;
|
||||
_json[r'monthOptions'] = monthOptions;
|
||||
_json[r'dayOptions'] = dayOptions;
|
||||
_json[r'hourOptions'] = hourOptions;
|
||||
_json[r'minuteOptions'] = minuteOptions;
|
||||
_json[r'secondOptions'] = secondOptions;
|
||||
_json[r'presetOptions'] = presetOptions;
|
||||
return _json;
|
||||
}
|
||||
|
||||
/// Returns a new [SystemConfigTemplateStorageOptionDto] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static SystemConfigTemplateStorageOptionDto? fromJson(dynamic value) {
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
// Ensure that the map contains the required keys.
|
||||
// Note 1: the values aren't checked for validity beyond being non-null.
|
||||
// Note 2: this code is stripped in release mode!
|
||||
assert(() {
|
||||
requiredKeys.forEach((key) {
|
||||
assert(json.containsKey(key), 'Required key "SystemConfigTemplateStorageOptionDto[$key]" is missing from JSON.');
|
||||
assert(json[key] != null, 'Required key "SystemConfigTemplateStorageOptionDto[$key]" has a null value in JSON.');
|
||||
});
|
||||
return true;
|
||||
}());
|
||||
|
||||
return SystemConfigTemplateStorageOptionDto(
|
||||
yearOptions: json[r'yearOptions'] is List
|
||||
? (json[r'yearOptions'] as List).cast<String>()
|
||||
: const [],
|
||||
monthOptions: json[r'monthOptions'] is List
|
||||
? (json[r'monthOptions'] as List).cast<String>()
|
||||
: const [],
|
||||
dayOptions: json[r'dayOptions'] is List
|
||||
? (json[r'dayOptions'] as List).cast<String>()
|
||||
: const [],
|
||||
hourOptions: json[r'hourOptions'] is List
|
||||
? (json[r'hourOptions'] as List).cast<String>()
|
||||
: const [],
|
||||
minuteOptions: json[r'minuteOptions'] is List
|
||||
? (json[r'minuteOptions'] as List).cast<String>()
|
||||
: const [],
|
||||
secondOptions: json[r'secondOptions'] is List
|
||||
? (json[r'secondOptions'] as List).cast<String>()
|
||||
: const [],
|
||||
presetOptions: json[r'presetOptions'] is List
|
||||
? (json[r'presetOptions'] as List).cast<String>()
|
||||
: const [],
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<SystemConfigTemplateStorageOptionDto>? listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <SystemConfigTemplateStorageOptionDto>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = SystemConfigTemplateStorageOptionDto.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, SystemConfigTemplateStorageOptionDto> mapFromJson(dynamic json) {
|
||||
final map = <String, SystemConfigTemplateStorageOptionDto>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = SystemConfigTemplateStorageOptionDto.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of SystemConfigTemplateStorageOptionDto-objects as value to a dart map
|
||||
static Map<String, List<SystemConfigTemplateStorageOptionDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<SystemConfigTemplateStorageOptionDto>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = SystemConfigTemplateStorageOptionDto.listFromJson(entry.value, growable: growable,);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'yearOptions',
|
||||
'monthOptions',
|
||||
'dayOptions',
|
||||
'hourOptions',
|
||||
'minuteOptions',
|
||||
'secondOptions',
|
||||
'presetOptions',
|
||||
};
|
||||
}
|
||||
|
||||
@ -0,0 +1,27 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.12
|
||||
|
||||
// ignore_for_file: unused_element, unused_import
|
||||
// ignore_for_file: always_put_required_named_parameters_first
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
|
||||
import 'package:openapi/api.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
// tests for SystemConfigStorageTemplateDto
|
||||
void main() {
|
||||
// final instance = SystemConfigStorageTemplateDto();
|
||||
|
||||
group('test SystemConfigStorageTemplateDto', () {
|
||||
// String template
|
||||
test('to test the property `template`', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
|
||||
}
|
||||
@ -0,0 +1,57 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.12
|
||||
|
||||
// ignore_for_file: unused_element, unused_import
|
||||
// ignore_for_file: always_put_required_named_parameters_first
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
|
||||
import 'package:openapi/api.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
// tests for SystemConfigTemplateStorageOptionDto
|
||||
void main() {
|
||||
// final instance = SystemConfigTemplateStorageOptionDto();
|
||||
|
||||
group('test SystemConfigTemplateStorageOptionDto', () {
|
||||
// List<String> yearOptions (default value: const [])
|
||||
test('to test the property `yearOptions`', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
// List<String> monthOptions (default value: const [])
|
||||
test('to test the property `monthOptions`', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
// List<String> dayOptions (default value: const [])
|
||||
test('to test the property `dayOptions`', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
// List<String> hourOptions (default value: const [])
|
||||
test('to test the property `hourOptions`', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
// List<String> minuteOptions (default value: const [])
|
||||
test('to test the property `minuteOptions`', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
// List<String> secondOptions (default value: const [])
|
||||
test('to test the property `secondOptions`', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
// List<String> presetOptions (default value: const [])
|
||||
test('to test the property `presetOptions`', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
# User defined storage structure
|
||||
|
||||
# Folder structure
|
||||
* Year is the top level
|
||||
* Different parsing sequence will be the second level
|
||||
|
||||
# Filename
|
||||
* Filename will always be appended by a unique ID. Maybe use https://github.com/ai/nanoid
|
||||
* Example: `notes.md` -> `notes-1234567890.md`
|
||||
* Filename will be unique in the same folder
|
||||
@ -0,0 +1,7 @@
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
export class SystemConfigStorageTemplateDto {
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
template!: string;
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
export class SystemConfigTemplateStorageOptionDto {
|
||||
yearOptions!: string[];
|
||||
monthOptions!: string[];
|
||||
dayOptions!: string[];
|
||||
hourOptions!: string[];
|
||||
minuteOptions!: string[];
|
||||
secondOptions!: string[];
|
||||
presetOptions!: string[];
|
||||
}
|
||||
@ -1,11 +1,24 @@
|
||||
import { SystemConfigEntity } from '@app/database/entities/system-config.entity';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { Module, Provider } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ImmichConfigService } from './immich-config.service';
|
||||
|
||||
export const INITIAL_SYSTEM_CONFIG = 'INITIAL_SYSTEM_CONFIG';
|
||||
|
||||
const providers: Provider[] = [
|
||||
ImmichConfigService,
|
||||
{
|
||||
provide: INITIAL_SYSTEM_CONFIG,
|
||||
inject: [ImmichConfigService],
|
||||
useFactory: async (configService: ImmichConfigService) => {
|
||||
return configService.getConfig();
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([SystemConfigEntity])],
|
||||
providers: [ImmichConfigService],
|
||||
exports: [ImmichConfigService],
|
||||
providers: [...providers],
|
||||
exports: [...providers],
|
||||
})
|
||||
export class ImmichConfigModule {}
|
||||
|
||||
@ -0,0 +1,20 @@
|
||||
export const supportedYearTokens = ['y', 'yy'];
|
||||
export const supportedMonthTokens = ['M', 'MM', 'MMM', 'MMMM'];
|
||||
export const supportedDayTokens = ['d', 'dd'];
|
||||
export const supportedHourTokens = ['h', 'hh', 'H', 'HH'];
|
||||
export const supportedMinuteTokens = ['m', 'mm'];
|
||||
export const supportedSecondTokens = ['s', 'ss'];
|
||||
export const supportedPresetTokens = [
|
||||
'{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}',
|
||||
'{{y}}/{{MM}}-{{dd}}/{{filename}}',
|
||||
'{{y}}/{{MMMM}}-{{dd}}/{{filename}}',
|
||||
'{{y}}/{{MM}}/{{filename}}',
|
||||
'{{y}}/{{MMM}}/{{filename}}',
|
||||
'{{y}}/{{MMMM}}/{{filename}}',
|
||||
'{{y}}/{{MM}}/{{dd}}/{{filename}}',
|
||||
'{{y}}/{{MMMM}}/{{dd}}/{{filename}}',
|
||||
'{{y}}/{{y}}-{{MM}}/{{y}}-{{MM}}-{{dd}}/{{filename}}',
|
||||
'{{y}}-{{MM}}-{{dd}}/{{filename}}',
|
||||
'{{y}}-{{MMM}}-{{dd}}/{{filename}}',
|
||||
'{{y}}-{{MMMM}}-{{dd}}/{{filename}}',
|
||||
];
|
||||
@ -0,0 +1,2 @@
|
||||
export * from './storage.module';
|
||||
export * from './storage.service';
|
||||
@ -0,0 +1,6 @@
|
||||
export interface IImmichStorage {
|
||||
write(): Promise<void>;
|
||||
read(): Promise<void>;
|
||||
}
|
||||
|
||||
export enum IStorageType {}
|
||||
@ -0,0 +1,13 @@
|
||||
import { AssetEntity } from '@app/database/entities/asset.entity';
|
||||
import { SystemConfigEntity } from '@app/database/entities/system-config.entity';
|
||||
import { ImmichConfigModule } from '@app/immich-config';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { StorageService } from './storage.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([AssetEntity, SystemConfigEntity]), ImmichConfigModule],
|
||||
providers: [StorageService],
|
||||
exports: [StorageService],
|
||||
})
|
||||
export class StorageModule {}
|
||||
@ -0,0 +1,153 @@
|
||||
import { APP_UPLOAD_LOCATION } from '@app/common';
|
||||
import { AssetEntity } from '@app/database/entities/asset.entity';
|
||||
import { SystemConfig } from '@app/database/entities/system-config.entity';
|
||||
import { ImmichConfigService, INITIAL_SYSTEM_CONFIG } from '@app/immich-config';
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import fsPromise from 'fs/promises';
|
||||
import handlebar from 'handlebars';
|
||||
import * as luxon from 'luxon';
|
||||
import mv from 'mv';
|
||||
import { constants } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
import sanitize from 'sanitize-filename';
|
||||
import { Repository } from 'typeorm';
|
||||
import {
|
||||
supportedDayTokens,
|
||||
supportedHourTokens,
|
||||
supportedMinuteTokens,
|
||||
supportedMonthTokens,
|
||||
supportedSecondTokens,
|
||||
supportedYearTokens,
|
||||
} from './constants/supported-datetime-template';
|
||||
|
||||
const moveFile = promisify<string, string, mv.Options>(mv);
|
||||
|
||||
@Injectable()
|
||||
export class StorageService {
|
||||
readonly log = new Logger(StorageService.name);
|
||||
|
||||
private storageTemplate: HandlebarsTemplateDelegate<any>;
|
||||
|
||||
constructor(
|
||||
@InjectRepository(AssetEntity)
|
||||
private assetRepository: Repository<AssetEntity>,
|
||||
private immichConfigService: ImmichConfigService,
|
||||
@Inject(INITIAL_SYSTEM_CONFIG) config: SystemConfig,
|
||||
) {
|
||||
this.storageTemplate = this.compile(config.storageTemplate.template);
|
||||
|
||||
this.immichConfigService.addValidator((config) => this.validateConfig(config));
|
||||
|
||||
this.immichConfigService.config$.subscribe((config) => {
|
||||
this.log.debug(`Received new config, recompiling storage template: ${config.storageTemplate.template}`);
|
||||
this.storageTemplate = this.compile(config.storageTemplate.template);
|
||||
});
|
||||
}
|
||||
|
||||
public async moveAsset(asset: AssetEntity, filename: string): Promise<AssetEntity> {
|
||||
try {
|
||||
const source = asset.originalPath;
|
||||
const ext = path.extname(source).split('.').pop() as string;
|
||||
const sanitized = sanitize(path.basename(filename, `.${ext}`));
|
||||
const rootPath = path.join(APP_UPLOAD_LOCATION, asset.userId);
|
||||
const storagePath = this.render(this.storageTemplate, asset, sanitized, ext);
|
||||
const fullPath = path.normalize(path.join(rootPath, storagePath));
|
||||
|
||||
if (!fullPath.startsWith(rootPath)) {
|
||||
this.log.warn(`Skipped attempt to access an invalid path: ${fullPath}. Path should start with ${rootPath}`);
|
||||
return asset;
|
||||
}
|
||||
|
||||
let duplicateCount = 0;
|
||||
let destination = `${fullPath}.${ext}`;
|
||||
|
||||
while (true) {
|
||||
const exists = await this.checkFileExist(destination);
|
||||
if (!exists) {
|
||||
break;
|
||||
}
|
||||
|
||||
duplicateCount++;
|
||||
destination = `${fullPath}_${duplicateCount}.${ext}`;
|
||||
}
|
||||
|
||||
await this.safeMove(source, destination);
|
||||
|
||||
asset.originalPath = destination;
|
||||
return await this.assetRepository.save(asset);
|
||||
} catch (error: any) {
|
||||
this.log.error(error, error.stack);
|
||||
return asset;
|
||||
}
|
||||
}
|
||||
|
||||
private safeMove(source: string, destination: string): Promise<void> {
|
||||
return moveFile(source, destination, { mkdirp: true, clobber: false });
|
||||
}
|
||||
|
||||
private async checkFileExist(path: string): Promise<boolean> {
|
||||
try {
|
||||
await fsPromise.access(path, constants.F_OK);
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private validateConfig(config: SystemConfig) {
|
||||
this.validateStorageTemplate(config.storageTemplate.template);
|
||||
}
|
||||
|
||||
private validateStorageTemplate(templateString: string) {
|
||||
try {
|
||||
const template = this.compile(templateString);
|
||||
|
||||
// test render an asset
|
||||
this.render(
|
||||
template,
|
||||
{
|
||||
createdAt: new Date().toISOString(),
|
||||
originalPath: '/upload/test/IMG_123.jpg',
|
||||
} as AssetEntity,
|
||||
'IMG_123',
|
||||
'jpg',
|
||||
);
|
||||
} catch (e) {
|
||||
this.log.warn(`Storage template validation failed: ${e}`);
|
||||
throw new Error(`Invalid storage template: ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
private compile(template: string) {
|
||||
return handlebar.compile(template, {
|
||||
knownHelpers: undefined,
|
||||
strict: true,
|
||||
});
|
||||
}
|
||||
|
||||
private render(template: HandlebarsTemplateDelegate<any>, asset: AssetEntity, filename: string, ext: string) {
|
||||
const substitutions: Record<string, string> = {
|
||||
filename,
|
||||
ext,
|
||||
};
|
||||
|
||||
const dt = luxon.DateTime.fromISO(new Date(asset.createdAt).toISOString());
|
||||
|
||||
const dateTokens = [
|
||||
...supportedYearTokens,
|
||||
...supportedMonthTokens,
|
||||
...supportedDayTokens,
|
||||
...supportedHourTokens,
|
||||
...supportedMinuteTokens,
|
||||
...supportedSecondTokens,
|
||||
];
|
||||
|
||||
for (const token of dateTokens) {
|
||||
substitutions[token] = dt.toFormat(token);
|
||||
}
|
||||
|
||||
return template(substitutions);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"declaration": true,
|
||||
"outDir": "../../dist/libs/storage"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "test", "**/*spec.ts"]
|
||||
}
|
||||
@ -0,0 +1,227 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
api,
|
||||
SystemConfigStorageTemplateDto,
|
||||
SystemConfigTemplateStorageOptionDto,
|
||||
UserResponseDto
|
||||
} from '@api';
|
||||
import * as luxon from 'luxon';
|
||||
import handlebar from 'handlebars';
|
||||
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
import SupportedDatetimePanel from './supported-datetime-panel.svelte';
|
||||
import SupportedVariablesPanel from './supported-variables-panel.svelte';
|
||||
import SettingButtonsRow from '../setting-buttons-row.svelte';
|
||||
import _ from 'lodash';
|
||||
import {
|
||||
notificationController,
|
||||
NotificationType
|
||||
} from '$lib/components/shared-components/notification/notification';
|
||||
import SettingInputField, { SettingInputFieldType } from '../setting-input-field.svelte';
|
||||
|
||||
export let storageConfig: SystemConfigStorageTemplateDto;
|
||||
export let user: UserResponseDto;
|
||||
|
||||
let savedConfig: SystemConfigStorageTemplateDto;
|
||||
let defaultConfig: SystemConfigStorageTemplateDto;
|
||||
let templateOptions: SystemConfigTemplateStorageOptionDto;
|
||||
let selectedPreset = '';
|
||||
|
||||
async function getConfigs() {
|
||||
[savedConfig, defaultConfig, templateOptions] = await Promise.all([
|
||||
api.systemConfigApi.getConfig().then((res) => res.data.storageTemplate),
|
||||
api.systemConfigApi.getDefaults().then((res) => res.data.storageTemplate),
|
||||
api.systemConfigApi.getStorageTemplateOptions().then((res) => res.data)
|
||||
]);
|
||||
|
||||
selectedPreset = templateOptions.presetOptions[0];
|
||||
}
|
||||
|
||||
const getSupportDateTimeFormat = async () => {
|
||||
const { data } = await api.systemConfigApi.getStorageTemplateOptions();
|
||||
return data;
|
||||
};
|
||||
|
||||
$: parsedTemplate = () => {
|
||||
try {
|
||||
return renderTemplate(storageConfig.template);
|
||||
} catch (error) {
|
||||
return 'error';
|
||||
}
|
||||
};
|
||||
|
||||
const renderTemplate = (templateString: string) => {
|
||||
const template = handlebar.compile(templateString, {
|
||||
knownHelpers: undefined
|
||||
});
|
||||
|
||||
const substitutions: Record<string, string> = {
|
||||
filename: 'IMG_10041123',
|
||||
ext: 'jpeg'
|
||||
};
|
||||
|
||||
const dt = luxon.DateTime.fromISO(new Date('2022-09-04T20:03:05.250').toISOString());
|
||||
|
||||
const dateTokens = [
|
||||
...templateOptions.yearOptions,
|
||||
...templateOptions.monthOptions,
|
||||
...templateOptions.dayOptions,
|
||||
...templateOptions.hourOptions,
|
||||
...templateOptions.minuteOptions,
|
||||
...templateOptions.secondOptions
|
||||
];
|
||||
|
||||
for (const token of dateTokens) {
|
||||
substitutions[token] = dt.toFormat(token);
|
||||
}
|
||||
|
||||
return template(substitutions);
|
||||
};
|
||||
|
||||
async function reset() {
|
||||
const { data: resetConfig } = await api.systemConfigApi.getConfig();
|
||||
|
||||
storageConfig.template = resetConfig.storageTemplate.template;
|
||||
savedConfig.template = resetConfig.storageTemplate.template;
|
||||
|
||||
notificationController.show({
|
||||
message: 'Reset storage template settings to the recent saved settings',
|
||||
type: NotificationType.Info
|
||||
});
|
||||
}
|
||||
|
||||
async function saveSetting() {
|
||||
try {
|
||||
const { data: currentConfig } = await api.systemConfigApi.getConfig();
|
||||
|
||||
const result = await api.systemConfigApi.updateConfig({
|
||||
...currentConfig,
|
||||
storageTemplate: storageConfig
|
||||
});
|
||||
|
||||
storageConfig.template = result.data.storageTemplate.template;
|
||||
savedConfig.template = result.data.storageTemplate.template;
|
||||
|
||||
notificationController.show({
|
||||
message: 'Storage template saved',
|
||||
type: NotificationType.Info
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Error [storage-template-settings] [saveSetting]', e);
|
||||
notificationController.show({
|
||||
message: 'Unable to save settings',
|
||||
type: NotificationType.Error
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function resetToDefault() {
|
||||
const { data: defaultConfig } = await api.systemConfigApi.getDefaults();
|
||||
|
||||
storageConfig.template = defaultConfig.storageTemplate.template;
|
||||
|
||||
notificationController.show({
|
||||
message: 'Reset storage template to default',
|
||||
type: NotificationType.Info
|
||||
});
|
||||
}
|
||||
|
||||
const handlePresetSelection = () => {
|
||||
storageConfig.template = selectedPreset;
|
||||
};
|
||||
</script>
|
||||
|
||||
<section class="dark:text-immich-dark-fg">
|
||||
{#await getConfigs() then}
|
||||
<div id="directory-path-builder" class="m-4">
|
||||
<h3 class="font-medium text-immich-primary dark:text-immich-dark-primary text-base">
|
||||
Variables
|
||||
</h3>
|
||||
|
||||
<section class="support-date">
|
||||
{#await getSupportDateTimeFormat()}
|
||||
<LoadingSpinner />
|
||||
{:then options}
|
||||
<div transition:fade={{ duration: 200 }}>
|
||||
<SupportedDatetimePanel {options} />
|
||||
</div>
|
||||
{/await}
|
||||
</section>
|
||||
|
||||
<section class="support-date">
|
||||
<SupportedVariablesPanel />
|
||||
</section>
|
||||
|
||||
<div class="mt-4 flex flex-col">
|
||||
<h3 class="font-medium text-immich-primary dark:text-immich-dark-primary text-base">
|
||||
Template
|
||||
</h3>
|
||||
|
||||
<div class="text-xs my-2">
|
||||
<h4>PREVIEW</h4>
|
||||
</div>
|
||||
|
||||
<p class="text-xs">
|
||||
Approximately path length limit : <span
|
||||
class="font-semibold text-immich-primary dark:text-immich-dark-primary"
|
||||
>{parsedTemplate().length + user.id.length + 'UPLOAD_LOCATION'.length}</span
|
||||
>/260
|
||||
</p>
|
||||
|
||||
<p class="text-xs">
|
||||
{user.id} is the user's ID
|
||||
</p>
|
||||
|
||||
<p
|
||||
class="text-xs p-4 bg-gray-200 dark:bg-gray-700 dark:text-immich-dark-fg py-2 rounded-lg mt-2"
|
||||
>
|
||||
<span class="text-immich-fg/25 dark:text-immich-dark-fg/50"
|
||||
>UPLOAD_LOCATION/{user.id}</span
|
||||
>/{parsedTemplate()}.jpeg
|
||||
</p>
|
||||
|
||||
<form autocomplete="off" class="flex flex-col" on:submit|preventDefault>
|
||||
<div class="flex flex-col my-2">
|
||||
<label class="text-xs" for="presets">PRESET</label>
|
||||
<select
|
||||
class="text-sm bg-slate-200 p-2 rounded-lg mt-2 dark:bg-gray-600 hover:cursor-pointer"
|
||||
name="presets"
|
||||
id="preset-select"
|
||||
bind:value={selectedPreset}
|
||||
on:change={handlePresetSelection}
|
||||
>
|
||||
{#each templateOptions.presetOptions as preset}
|
||||
<option value={preset}>{renderTemplate(preset)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex gap-2 align-bottom">
|
||||
<SettingInputField
|
||||
label="template"
|
||||
required
|
||||
inputType={SettingInputFieldType.TEXT}
|
||||
bind:value={storageConfig.template}
|
||||
isEdited={!(storageConfig.template === savedConfig.template)}
|
||||
/>
|
||||
|
||||
<div class="flex-0">
|
||||
<SettingInputField
|
||||
label="Extension"
|
||||
inputType={SettingInputFieldType.TEXT}
|
||||
value={'.jpeg'}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SettingButtonsRow
|
||||
on:reset={reset}
|
||||
on:save={saveSetting}
|
||||
on:reset-to-default={resetToDefault}
|
||||
showResetToDefault={!_.isEqual(savedConfig, defaultConfig)}
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/await}
|
||||
</section>
|
||||
@ -0,0 +1,78 @@
|
||||
<script lang="ts">
|
||||
import { SystemConfigTemplateStorageOptionDto } from '@api';
|
||||
import * as luxon from 'luxon';
|
||||
|
||||
export let options: SystemConfigTemplateStorageOptionDto;
|
||||
|
||||
const getLuxonExample = (format: string) => {
|
||||
return luxon.DateTime.fromISO(new Date('2022-09-04T20:03:05.250').toISOString()).toFormat(
|
||||
format
|
||||
);
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="text-xs mt-2">
|
||||
<h4>DATE & TIME</h4>
|
||||
</div>
|
||||
|
||||
<div class="text-xs bg-gray-200 dark:bg-gray-700 dark:text-immich-dark-fg p-4 mt-2 rounded-lg">
|
||||
<div class="mb-2 text-gray-600 dark:text-immich-dark-fg">
|
||||
<p>Asset's creation timestamp is used for the datetime information</p>
|
||||
<p>Sample time 2022-09-04T20:03:05.250</p>
|
||||
</div>
|
||||
<div class="flex gap-[50px]">
|
||||
<div>
|
||||
<p class="text-immich-primary font-medium dark:text-immich-dark-primary">YEAR</p>
|
||||
<ul>
|
||||
{#each options.yearOptions as yearFormat}
|
||||
<li>{'{{'}{yearFormat}{'}}'} - {getLuxonExample(yearFormat)}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-immich-primary font-medium dark:text-immich-dark-primary">MONTH</p>
|
||||
<ul>
|
||||
{#each options.monthOptions as monthFormat}
|
||||
<li>{'{{'}{monthFormat}{'}}'} - {getLuxonExample(monthFormat)}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-immich-primary font-medium dark:text-immich-dark-primary">DAY</p>
|
||||
<ul>
|
||||
{#each options.dayOptions as dayFormat}
|
||||
<li>{'{{'}{dayFormat}{'}}'} - {getLuxonExample(dayFormat)}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-immich-primary font-medium dark:text-immich-dark-primary">HOUR</p>
|
||||
<ul>
|
||||
{#each options.hourOptions as dayFormat}
|
||||
<li>{'{{'}{dayFormat}{'}}'} - {getLuxonExample(dayFormat)}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-immich-primary font-medium dark:text-immich-dark-primary">MINUTE</p>
|
||||
<ul>
|
||||
{#each options.minuteOptions as dayFormat}
|
||||
<li>{'{{'}{dayFormat}{'}}'} - {getLuxonExample(dayFormat)}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-immich-primary font-medium dark:text-immich-dark-primary">SECOND</p>
|
||||
<ul>
|
||||
{#each options.secondOptions as dayFormat}
|
||||
<li>{'{{'}{dayFormat}{'}}'} - {getLuxonExample(dayFormat)}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -0,0 +1,21 @@
|
||||
<div class="text-xs mt-4">
|
||||
<h4>OTHER VARIABLES</h4>
|
||||
</div>
|
||||
|
||||
<div class="text-xs bg-gray-200 dark:bg-gray-700 dark:text-immich-dark-fg p-4 mt-2 rounded-lg">
|
||||
<div class="flex gap-[50px]">
|
||||
<div>
|
||||
<p class="text-immich-primary font-medium dark:text-immich-dark-primary">FILE NAME</p>
|
||||
<ul>
|
||||
<li>{`{{filename}}`}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-immich-primary font-medium dark:text-immich-dark-primary">FILE EXTENSION</p>
|
||||
<ul>
|
||||
<li>{`{{ext}}`}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
Loading…
Reference in New Issue