1. Розкладка як чиста логіка
Коли ви пишете утиліту, що масово працює з файлами, дуже легко непомітно змішати все в одну кашу: тут ми знайшли файл, тут же визначили категорію, тут же скопіювали, тут же зловили помилку, тут же вивели звіт. У маленькому прикладі це виглядає «нормально», але на практиці такий підхід швидко перетворюється на монстра.
Ідея цієї лекції — відокремити чисте обчислення від дій із диском. Розкладка — це і є чисте обчислення: ми беремо вхідні дані (файл, його розширення, відносний шлях, правила) й отримуємо результат (категорію та цільовий шлях). Це можна перевіряти «на око», логувати, показувати користувачу як «план», і, головне, це можна робити без копіювання або переміщення. А «рушій операцій» ми залишимо для наступної лекції.
Зафіксуймо коротку думку-формулу:
Файл-кандидат + Правила розкладки => План (куди покласти і що робити за конфлікту)
Схематично конвеєр виглядає так:
flowchart LR
A[Файли-кандидати] --> B[Категорія за розширенням]
B --> C[Цільовий шлях: target/category/relative]
C --> D[Перевірка конфлікту імені]
D --> E[PlanItem: дані для виконання та звіту]
2. Категорії за розширенням
Коли ми кажемо «розкладати файли», зазвичай маємо на увазі, що хочемо отримати більш-менш зрозумілу структуру: зображення — окремо, документи — окремо, аудіо — окремо. Категорії можна зберігати рядками, але рядки мають суперсилу «непомітно помилятися в написанні» (а потім дивуватися, звідки взявся каталог documets).
Тому найспокійніший варіант — enum class Category. Він обмежує набір варіантів і допомагає компілятору ловити помилки замість вас (а компілятор — ще той прискіпливець, зате в побуті корисний).
enum class Category {
IMAGES,
DOCS,
AUDIO,
VIDEO,
ARCHIVES,
OTHER
}
Тепер нам потрібне правило: extension -> Category. У нас уже є нормалізація розширень (lowercase, без крапки). Отже, правило виглядатиме як таблиця відповідностей.
Невеличка «шпаргалка» (ви можете розширювати її під себе):
| Розширення (приклад) | Категорія | Каталог (приклад) |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| усе інше | |
|
Код — короткий і читається як «словничок»:
fun categoryOf(ext: String): Category = when (ext) {
"jpg", "jpeg", "png", "gif" -> Category.IMAGES
"txt", "pdf", "doc", "docx" -> Category.DOCS
"mp3", "wav" -> Category.AUDIO
"mp4", "mkv" -> Category.VIDEO
"zip", "7z", "rar" -> Category.ARCHIVES
else -> Category.OTHER
}
Тут ми використовуємо when як таблицю відповідностей — це для нього ідеальний сценарій.
Чому каталог категорії краще не робити через category.name.lowercase()
Здається зручним: «візьму Category.IMAGES, зроблю images — і готово». Це справді працює, але є нюанс: імʼя enum — це частина вашого коду, а імʼя каталогу — частина користувацького інтерфейсу. Іноді хочеться каталог Photos, іноді Images, іноді 01_images (так, люди так роблять, особливо якщо люблять сортувати «як у бухгалтерії»). Тому краще винести правило «як назвати каталог категорії» в окрему функцію.
fun categoryDirName(category: Category): String = when (category) {
Category.IMAGES -> "images"
Category.DOCS -> "docs"
Category.AUDIO -> "audio"
Category.VIDEO -> "video"
Category.ARCHIVES -> "archives"
Category.OTHER -> "other"
}
Так ви не привʼязані до .name, і структура стає «налаштовуваною» навіть без складних конфігурацій.
3. Побудова цільового шляху
Коли мова заходить про шляхи, новачки майже завжди пишуть щось на кшталт:
val target = cfg.targetDir + "/" + category + "/" + relative
І десь тихенько плаче Windows, бо в неї роздільник \. А ще плаче будь-хто, хто випадково отримав // або забув слеш. Загалом, рядкова конкатенація шляхів — це як лагодити велосипед ізоляційною стрічкою: іноді працює, але краще не звикати.
У світі JVM у нас є Path, який уміє коректно збирати шляхи. Ми використовуватимемо два головні інструменти:
- Path.resolve(...) — «приклеїти частину шляху» безпечно, без ручних слешів.
- File.toPath() і Path.toFile() — переходи між File і Path.
Зберемо цільовий шлях за формулою:
targetRoot / categoryDir / relativePath
Наприклад:
- source: C:\in\music\rock\song.mp3
- relative: music\rock\song.mp3 (усередині source)
- category: audio
- target: D:\out\audio\music\rock\song.mp3
Код:
import java.nio.file.Path
fun buildTargetPath(
targetRoot: Path,
category: Category,
relative: Path
): Path {
val catDir = categoryDirName(category)
return targetRoot
.resolve(catDir)
.resolve(relative)
}
Зверніть увагу на стиль: сигнатура в кілька рядків читається легше. Це також відповідає рекомендаціям з оформлення коду (у Kotlin так справді прийнято).
Збереження структури підкаталогів
Тут є дві крайнощі.
Перша крайність — «усе в один каталог за категоріями». Тоді у вас вийде images/ із десятками тисяч файлів, і пошук «де мій IMG_0001.jpg» перетворюється на археологічну експедицію.
Друга крайність — «повністю копіювати абсолютний шлях». Тоді ви випадково створите каталоги на кшталт target/C:/Users/... (або щось настільки ж дивне), і це теж виглядає не дуже.
Золота середина — зберігати відносний шлях усередині source. Ми вже вміємо отримувати його через relativize, а зараз просто вбудуємо це в побудову плану.
Нагадаємо функцію (вона коротка, але важлива):
import java.io.File
import java.nio.file.Path
fun relativePath(sourceDir: File, file: File): Path =
sourceDir.toPath().relativize(file.toPath())
І тепер ми можемо гарантувати: що б не було всередині sourceDir (підкаталоги, рівні вкладеності), усе акуратно збережеться в targetDir, але вже всередині каталогу категорії.
4. План розкладки та конфлікти
Тепер ми підійшли до важливого дизайнерського моменту. Нам потрібен обʼєкт, який описує «що ми хочемо зробити», але ще цього не робить. Це зручно з трьох причин: можна надрукувати план, можна перевірити його «на око», і можна виконати його пізніше — хоч копіюванням, хоч переміщенням.
Зробимо data class PlanItem. У попередні дні ви вже звикли, що data class — це зручна «коробка для даних», яка автоматично додає toString, порівняння та інше. (І так, це той самий момент, коли Kotlin бере на себе частину рутини.)
import java.io.File
import java.nio.file.Path
data class PlanItem(
val source: File,
val relative: Path,
val category: Category,
val target: File,
val conflict: PlannedConflict
)
Тепер питання: що таке conflict? Це результат нашого рішення «що робити, якщо файл у target уже існує». Ми поки не виконуємо копіювання або резервне копіювання — ми лише фіксуємо рішення.
Опишемо варіанти конфлікту окремим enumʼом (простим і зрозумілим):
enum class PlannedConflict {
NONE, // target не існує
SKIP, // існує і політика каже "пропустити"
BACKUP // існує і політика каже "зробити backup, потім продовжити"
}
Чому не Boolean hasConflict? Тому що Boolean миттєво перетворюється на питання «а що означає true?». Enum робить зміст читабельним прямо в коді.
Стратегія конфліктів
Конфлікти імен — це не «рідкісний випадок», а цілком буденна ситуація. У цільовому каталозі вже може бути файл із таким імʼям, особливо якщо ви запускаєте утиліту повторно або розкладаєте файли з різних джерел.
На цьому кроці ми маємо зробити дві речі:
По-перше, явно вибрати політику, а не діяти «як вийде». У контракті в нас уже є ConflictPolicy (наприклад, SKIP або BACKUP).
По-друге, вирішити: конфлікт — це помилка чи варіант норми? У нашому навчальному проєкті ми вважаємо, що це варіант норми, але він має бути відображений у плані.
Нам потрібна функція, яка за політикою та фактом існування target поверне PlannedConflict.
fun plannedConflict(
targetExists: Boolean,
policy: ConflictPolicy
): PlannedConflict {
if (!targetExists) return PlannedConflict.NONE
return when (policy) {
ConflictPolicy.SKIP -> PlannedConflict.SKIP
ConflictPolicy.BACKUP -> PlannedConflict.BACKUP
}
}
Зауважте, як це читається: «якщо target не існує — немає конфлікту; інакше — вирішуємо за політикою». Це максимально прямолінійно, а прямолінійність у файлових утилітах — чеснота.
Чому BACKUP поки що лише в плані
Тому що резервна копія — це операція з диском: перейменування, перевірка помилок, можливо, видалення старого .bak, можливо, неможливість перейменувати через права. Це вже «рушій операцій» і обробка часткових помилок — тема наступної лекції.
Зараз наше завдання — чесно сказати: «ось тут буде резервна копія, якщо дійдемо до виконання».
5. Збирання і показ плану
Тепер зберемо центральну функцію: «створити один пункт плану для одного файла». Це місце, де сходяться всі шматочки: розширення → категорія → відносний шлях → цільовий шлях → конфлікт.
Функція буде невеликою, але корисною.
import java.io.File
fun planOne(
sourceDir: File,
targetDir: File,
file: File,
cfg: OrganizerConfig
): PlanItem? {
val ext = extensionOrNull(file) ?: return null
if (ext !in cfg.allowedExt) return null
val rel = relativePath(sourceDir, file)
val category = categoryOf(ext)
val targetPath = buildTargetPath(targetDir.toPath(), category, rel)
val targetFile = targetPath.toFile()
val conflict = plannedConflict(targetFile.exists(), cfg.conflictPolicy)
return PlanItem(
source = file,
relative = rel,
category = category,
target = targetFile,
conflict = conflict
)
}
Тут важливо одразу кілька речей.
Ми повертаємо PlanItem?, тому що файл може не відповідати правилам (немає розширення, розширення не дозволене). Такий стиль добре поєднується з обробкою колекцій через mapNotNull, де null просто відкидається з результату. Це один зі стандартних патернів роботи з перетвореннями колекцій у Kotlin.
Перевірка targetFile.exists() — це вже читання стану файлової системи, але воно безпечне: ми нічого не змінюємо, лише «дивимося». А нам це потрібно, щоб план відображав реальний стан речей.
Збирання плану за списком кандидатів
Припустімо, що кандидатів ми вже отримали на попередньому кроці (наприклад, collectCandidates(...)). Тепер можна зібрати план як перетворення списку.
import java.io.File
fun buildPlan(
sourceDir: File,
targetDir: File,
candidates: List<File>,
cfg: OrganizerConfig
): List<PlanItem> {
return candidates.mapNotNull { file ->
planOne(sourceDir, targetDir, file, cfg)
}
}
Це виглядає майже «як українською»: беремо кандидатів, перетворюємо їх на елементи плану й відкидаємо ті, що не підходять.
Як показати план користувачу
Навіть якщо ви не пишете повноцінний звіт (це буде окрема тема), користувачу вкрай корисно побачити хоча б «чернетку»: що куди поїде і де будуть конфлікти. Особливо якщо режим MOVE, тому що переміщення психологічно відчувається як «я торкаюся оригіналу». (Навіть якщо ви все зробили правильно, мозок усе одно хвилюється.)
Зробімо просту функцію форматування — без складних таблиць і вирівнювань, просто читабельний рядок.
fun describe(item: PlanItem): String {
val base = "${item.category}: ${item.source.path} -> ${item.target.path}"
return when (item.conflict) {
PlannedConflict.NONE -> base
PlannedConflict.SKIP -> "$base | conflict: SKIP (target exists)"
PlannedConflict.BACKUP -> "$base | conflict: BACKUP (target exists)"
}
}
І приклад друку:
fun printPlan(plan: List<PlanItem>) {
for (item in plan) {
println(describe(item))
// Приклад виводу:
// IMAGES: /in/cat.png -> /out/images/cat.png
}
}
Так, це не «красивий звіт», але вже чудовий рівень прозорості: ви бачите, що саме станеться.
6. Корисні нюанси
OTHER — це страхувальна сітка, а не смітник
Коли ви вводите категорію OTHER, легко відчути себе людиною, яка змітає під килим усе, що не влізло. Але OTHER корисна саме як страховка: якщо розширення не розпізнали, файл не має «зникнути» або «зламати програму». Він просто потрапить у каталог other/, і користувач зможе вручну вирішити, що з ним робити.
Практично це означає, що categoryOf(ext) майже ніколи не має повертати null. Він має повертати OTHER, якщо не знає розширення. Тоді план вийде повним, а поведінка — передбачуваною.
Перевірка здорового глузду: цільовий шлях має бути всередині target
Іноді студенти (і не лише студенти) випадково будують шлях так, що файл їде «кудись не туди». Типова причина — переплутали source/target або зробили resolve у неправильному порядку.
Нам не потрібні складні перевірки, але одна проста думка корисна: цільовий шлях має починатися з targetDir. Для Path є метод startsWith(...). Ми не перетворюватимемо це на величезну систему валідації, але можемо додати невеликий запобіжник у planOne.
import java.nio.file.Path
fun isInside(root: Path, child: Path): Boolean =
child.normalize().startsWith(root.normalize())
І в planOne після targetPath:
val root = targetDir.toPath()
if (!isInside(root, targetPath)) return null
Це радше «пасок безпеки», ніж основна логіка. Якщо він раптом спрацював, значить, десь є помилка в побудові шляху — і тоді краще взагалі не виконувати операції.
7. Типові помилки
Помилка № 1: склеювання шляхів рядками замість Path.resolve(...).
Спочатку здається, що "$target/$category/$rel" — це швидко й зрозуміло. Потім зʼясовується, що десь подвійний слеш, десь зник роздільник, а на Windows усе взагалі виглядає як набір випадкових символів. Використання Path.resolve(...) робить побудову шляху коректною на будь-якій ОС і позбавляє «магії слешів».
Помилка № 2: категорію зберігають рядком, а не enumʼом.
Рядки дають свободу… і описки. У результаті зʼявляються каталоги doc, docs, Documents, dcos, і проєкт сам собі влаштовує конкуренцію. enum class Category обмежує варіанти й змушує код бути чесним: або категорія існує, або компілятор не дасть зібрати проєкт.
Помилка № 3: функція категоризації повертає null, і файли «випадають» із плану без пояснення.
Якщо ви робите categoryOf(ext): Category? і потім десь пишете ?: return null, ви дуже легко втрачаєте файли мовчки. Набагато спокійніше повернути Category.OTHER і зберегти файл у плані, щоб користувач бачив: його обробили за запасним правилом.
Помилка № 4: конфлікт імен вирішується на етапі виконання, а план про це мовчить.
Якщо план каже «все ок», а на етапі виконання раптом зʼясовується, що половину файлів пропущено через наявні target, користувач підозрюватиме баг — і підозри будуть не на порожньому місці. Краще одразу в PlanItem фіксувати PlannedConflict, щоб план був чесним і завчасно показував проблемні місця.
Помилка № 5: спроба робити резервну копію прямо в логіці розкладки.
Розкладка має бути обчисленням: визначити категорію та цільовий шлях. Резервна копія — це файлова операція з помилками, правами та несподіванками. Якщо змішати їх, код стане гірше тестованим і гірше читабельним: незрозуміло, де ми «думаємо», а де вже «торкаємося диска». Тримайте backup як частину виконання, а в плані залишайте лише рішення BACKUP.
Помилка № 6: відносний шлях обчислюють через substring замість relativize.
Поки шляхи прості, здається, що «відріжу початок рядка — і готово». Але щойно зʼявляються різні роздільники, різні варіанти абсолютних шляхів, символічні посилання та інші «радощі», рядкова логіка починає ламатися. Path.relativize(...) розвʼязує задачу саме як задачу шляхів, а не як задачу рядків — і це набагато надійніше.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ