1. Вступ
Коли ви чуєте «File Organizer», хочеться одразу писати copyTo() і радісно розкидати файли по директоріях — як кіт іграшки по квартирі. Але масові файлові операції небезпечні тим, що вони можуть бути незворотними (особливо MOVE). До того ж вони часто ламаються не там, де ви очікуєте: права доступу, дивні імена, надто довгі шляхи, файли без розширень, вкладені директорії тощо.
Тому нам потрібна дисципліна: спочатку ми обходимо вихідну директорію й збираємо список «кандидатів», потім фільтруємо його за правилами (наприклад, за розширеннями). І лише після цього будуємо план та виконуємо операції. Сьогодні — тільки перша частина конвеєра: «дерево → кандидати».
Невелика схема того, що ми будуємо (поки що без виконання операцій):
flowchart TD
A[Вихідна директорія sourceDir] --> B[walkTopDown: отримуємо всі вузли дерева]
B --> C[filter: лишаємо тільки файли isFile]
C --> D[extensionOrNull: дістаємо розширення]
D --> E[filter: ext in allowedExt]
E --> F[relativize: відносний шлях усередині sourceDir]
F --> G[Candidate: дані кандидата для подальшої обробки]
2. Обхід дерева: walkTopDown() і фільтр isFile
File і Path: чому нам потрібні обидва
У Kotlin/JVM робота з файлами часто виглядає так: «взяли java.io.File — і поїхали». І справді: File зручний для швидких перевірок exists(), isFile, isDirectory, для walkTopDown() і простих операцій. Такий стиль трапляється навіть в офіційних прикладах для консольних сценаріїв.
Але коли йдеться про шляхи як про структуру, а не як про рядки, зʼявляється другий герой: java.nio.file.Path. У Path є дуже важливі для нас операції:
- relativize(...) — побудувати відносний шлях «усередині базової директорії»;
- resolve(...) — поєднати частини шляху без ручної конкатенації рядків і без вгадування розділювачів (/ vs \).
Сьогодні нам критично потрібен relativize(...). Тому ми житимемо в «гібридному світі»: обхід робимо через File, а відносний шлях рахуємо через Path. Це нормально: це не «дві системи, які бʼються між собою», а два зручні інструменти для різних задач.
Базовий обхід: збираємо всі файли
Коли ми кажемо «обійти директорію», маємо на увазі таке: пройти всіма вкладеними директоріями й побачити кожен файл, який там лежить. У Kotlin це можна зробити різними способами, але для нашого проєкту найпрактичніший і найчитабельніший варіант — File.walkTopDown().
Важливий момент: walkTopDown() повертає «прогулянку по дереву», де траплятимуться і директорії, і файли. На цьому етапі директорії нам не потрібні як елементи списку кандидатів, тому перше, що ми робимо, — лишаємо тільки файли.
Ось мінімальна функція «зібрати всі файли взагалі»:
import java.io.File
fun collectAllFiles(sourceDir: File): List<File> {
return sourceDir
.walkTopDown()
.filter { it.isFile }
.toList()
}
Зверніть увагу на два нюанси.
Перший нюанс у тому, що walkTopDown() не зобовʼязаний «одразу все завантажити в памʼять». Обхід відбувається ліниво, а toList() змушує його реально пройти дерево й зібрати результат. Для початківців це добра новина: ви пишете «як конвеєр», а Kotlin виконує прохід тоді, коли ви просите підсумок.
Другий нюанс: isFile відсікає директорії, але не розвʼязує всіх питань на світі. Символічні посилання, спеціальні файли та проблеми доступу ми сьогодні не розбираємо — і це нормально. Наша мета — базовий стійкий прототип.
3. Розширення та фільтрація за allowedExt
Розширення файла: акуратно й передбачувано
Витягнути розширення здається елементарним: «беремо все після крапки». На практиці це одна з тих задач, де новачки стабільно наступають на граблі. І це не соромно: файлові імена не зобовʼязані бути «красивими».
Подумайте про такі імена:
- photo.JPG — розширення є, але регістр «кричить»;
- archive.tar.gz — крапок кілька, а яке розширення вважати «справжнім»?
- .bashrc — крапка є, але це радше «приховане імʼя», а не розширення;
- no_extension — крапки немає;
- weird. — крапка є, але розширення порожнє.
Для File Organizer нам потрібна стійка й передбачувана функція: якщо розширення нормальне — повертаємо рядок у нижньому регістрі, якщо ні — повертаємо null.
Ось акуратний варіант:
import java.io.File
fun extensionOrNull(file: File): String? {
val name = file.name
val dot = name.lastIndexOf('.')
if (dot <= 0) return null
if (dot == name.lastIndex) return null
return name.substring(dot + 1).lowercase()
}
Чому dot <= 0?
- dot == -1 означає «крапки немає» — отже, розширення немає.
- dot == 0 означає «крапка на самому початку» (наприклад, .bashrc). У більшості утиліт це не вважають розширенням — і ми теж не будемо. Інакше ви раптово отримаєте розширення "bashrc", а потім дивуватиметеся, чому ваш «конфіг термінала» полетів у категорію документів.
Чому dot == lastIndex?
- Тому що weird. не має давати розширення "" (порожній рядок). Порожнє розширення — поганий кандидат. Його простіше одразу вважати відсутнім.
А що з archive.tar.gz?
- Наш код поверне gz. Це не «істина», а правило. Для сьогоднішнього проєкту цього достатньо: ми робимо прототип і не заглиблюємося в розпізнавання складених розширень (це окрема тема й окрема політика).
Фільтрація за розширеннями: щоб .JPG не ламав вам життя
Тепер, коли ми вміємо діставати розширення, лишається просте: залишити тільки ті файли, чиє розширення входить до набору allowedExt з конфігурації.
Але тут є типова пастка: порівнювати «як є» не можна. Користувач може ввести JPG, .Jpg, jpg , а файл на диску може бути Photo.JPEG. Тому нам потрібна нормалізація в одному стилі.
Припустімо, що в минулій лекції ви вже зробили нормалізатор розширення:
fun normalizeExt(raw: String): String =
raw.trim().removePrefix(".").lowercase()
Тоді правило таке: у конфігурації ми зберігаємо вже нормалізовані розширення. А з файла теж дістаємо нормалізоване (ми вже робимо lowercase()). І порівняння стає «нудним і надійним».
Ось збір кандидатів із фільтром:
import java.io.File
fun collectCandidates(sourceDir: File, allowedExt: Set<String>): List<File> {
return sourceDir.walkTopDown()
.filter { it.isFile }
.filter { file ->
val ext = extensionOrNull(file) ?: return@filter false
ext in allowedExt
}
.toList()
}
Зверніть увагу: ми свідомо виключаємо файли без розширення (null). Це не «єдино правильно», це політика. Якщо згодом ви захочете підтримати файли без розширення, зробите це окремим правилом, а не «випадково, бо split так спрацював».
4. Відносні шляхи та модель кандидата
Відносний шлях: навіщо він потрібен
На цьому місці в новачків часто виникає чесне питання: «Навіщо нам відносний шлях, якщо у файла є file.absolutePath?».
Уявіть, що вихідна директорія така:
source/
cats/
cute.jpg
docs/
guide.pdf
Якщо ми зберігатимемо тільки абсолютний шлях, то втратимо важливу інформацію: де саме всередині source лежав файл. А інколи це корисно, щоб під час розкладання зберігати структуру піддиректорій.
Наприклад, ми хочемо (у майбутньому) отримати таке:
target/
images/
cats/
cute.jpg
docs/
docs/
guide.pdf
Щоб це стало можливим, нам потрібно вміти сказати: «cute.jpg лежить відносно source за шляхом cats/cute.jpg». Саме це й дає Path.relativize(...).
Мінімальна функція:
import java.io.File
import java.nio.file.Path
fun relativePath(sourceDir: File, file: File): Path {
return sourceDir.toPath().relativize(file.toPath())
}
Але тут є підступний нюанс: relativize працює коректно, коли обидва шляхи «одного типу» — або обидва абсолютні, або обидва відносні (і на одній файловій системі). У реальному житті краще нормалізувати обидва шляхи до абсолютних.
Трохи більш «залізобетонний» варіант:
import java.io.File
import java.nio.file.Path
fun relativePath(sourceDir: File, file: File): Path {
val src = sourceDir.toPath().toAbsolutePath().normalize()
val f = file.toPath().toAbsolutePath().normalize()
return src.relativize(f)
}
Це трохи довше, зате ви менше ризикуєте зловити дивні ефекти, якщо sourceDir передали як . або ./source, а файл виявився в абсолютному вигляді.
Модель кандидата: збираємо дані «достатньо, але не надто багато»
На цьому етапі ми вже вміємо:
- пройти по дереву;
- залишити тільки файли;
- дістати розширення;
- відфільтрувати за дозволеними розширеннями;
- обчислити відносний шлях.
Тепер важливо зробити ще один крок «у бік архітектури»: не тягати по всьому проєкту «голі File», а зібрати модель кандидата — невеликий data class, який містить потрібні поля.
Чому це важливо? Тому що далі в нас будуть різні етапи обробки. І якщо в кожному місці заново обчислювати розширення та відносний шлях, ви почнете дублювати код і отримувати неузгоджені правила (а це улюблена їжа для багів).
Зробімо модель:
import java.io.File
import java.nio.file.Path
data class CandidateFile(
val file: File,
val ext: String,
val relative: Path
)
Тут немає «всього на світі» (наприклад, розміру файла), тому що сьогодні ми збираємо мінімум, потрібний для подальших правил розкладання.
5. Збір кандидатів із конфігурації
Тепер зберемо все в одну функцію, яка приймає нашу конфігурацію (з минулої лекції) і повертає список кандидатів. Нагадаю ідею: конфігурація — це контракт утиліти, тож зручно, коли «вхід один».
Припустімо, у нас є:
data class OrganizerConfig(
val sourceDir: String,
val targetDir: String,
val mode: Mode,
val allowedExt: Set<String>,
val conflictPolicy: ConflictPolicy,
val dryRun: Boolean
)
Тоді функція збору кандидатів може виглядати так:
import java.io.File
fun collectCandidates(cfg: OrganizerConfig): List<CandidateFile> {
val sourceDir = File(cfg.sourceDir)
val srcPath = sourceDir.toPath().toAbsolutePath().normalize()
return sourceDir.walkTopDown()
.filter { it.isFile }
.mapNotNull { file ->
val ext = extensionOrNull(file) ?: return@mapNotNull null
if (ext !in cfg.allowedExt) return@mapNotNull null
val rel = srcPath.relativize(file.toPath().toAbsolutePath().normalize())
CandidateFile(file = file, ext = ext, relative = rel)
}
.toList()
}
Зверніть увагу на стиль: ми використовуємо mapNotNull, щоб «файли, які не підходять», перетворювалися на null і автоматично відкидалися. Це читається так: «перетвори файл на кандидата, а якщо не виходить — просто пропусти».
І так, це той самий момент, де багато хто вперше розуміє, що Kotlin-колекції — це не лише for і if, а ще й «конвеєр перетворень». Не хвилюйтеся: якщо ви поки читаєте mapNotNull повільно, це нормально. За кілька днів ви читатимете такі ланцюжки помітно легше.
Мінідіагностика: перевіряємо, що ми не зібрали «все підряд»
На практиці після написання обходу хочеться переконатися, що функція не тягне, наприклад, директорії, або що allowedExt справді працює. Найпростіший спосіб — тимчасово вивести перші кілька кандидатів у консоль.
Додамо маленький фрагмент у main (у навчальному проєкті це нормально):
fun main() {
val cfg = OrganizerConfig(
sourceDir = "input",
targetDir = "output",
mode = Mode.COPY,
allowedExt = setOf("jpg", "png", "pdf"),
conflictPolicy = ConflictPolicy.SKIP,
dryRun = true
)
val candidates = collectCandidates(cfg)
println("Кандидатів: ${candidates.size}") // Кандидатів: 3 (приклад)
for (c in candidates.take(3)) {
println("${c.ext}: ${c.relative}") // jpg: cats/cute.jpg (приклад)
}
}
Тут є два корисні ефекти.
По-перше, ви миттєво бачите, що relative виглядає як «шлях усередині source», а не як повний абсолютний шлях на вашій машині.
По-друге, ви можете швидко помітити помилки нормалізації розширень. Якщо раптом бачите JPG замість jpg, значить, ви забули lowercase().
6. Типові помилки
Помилка №1: обходять дерево, але забувають filter { it.isFile }, а потім «обробляють директорії як файли».
Це виглядає невинно, доки ви не спробуєте витягнути розширення з директорії або «скопіювати» директорію як файл. У найкращому разі ви отримаєте дивні результати, у найгіршому — винятки та напівзламаний запуск. Рішення просте: одразу після walkTopDown() відфільтруйте isFile, щоб далі ваш код працював тільки з тим, що він справді вміє обробляти.
Помилка №2: витягають розширення через split(".") і отримують несподівані значення.
split створює багато шматочків, погано працює з іменами на кшталт archive.tar.gz, а ще легко ламається на .bashrc або weird.. Для утиліти важлива передбачуваність: краще один раз написати акуратну функцію з lastIndexOf('.') і граничними перевірками, ніж потім лагодити «чому воно інколи null, інколи порожній рядок, а інколи взагалі падає».
Помилка №3: порівнюють розширення без нормалізації й дивуються, що photo.JPG не потрапляє в jpg.
Файлова система не зобовʼязана допомагати вам бути акуратними. Якщо в allowedExt ви зберігаєте jpg, а з файла витягаєте JPG, то порівняння чесно скаже «не збіглося». Виправлення концептуальне: і конфігурацію, і розширення файла приводимо до одного вигляду (зазвичай lowercase() і «без крапки»).
Помилка №4: будують відносний шлях через рядки (substring) і ловлять помилки на розділювачах та довжинах.
Ручна робота з рядками в шляхах — це як лагодити годинник молотком: інколи виходить, але страшно дивитися. У Windows і Linux різні розділювачі, у шляхів буває різний «префікс», і дуже легко помилитися на один символ. Для цього й існує Path.relativize(...): він робить те, що ви хочете, і робить це однаково на різних ОС.
Помилка №5: викликають relativize на «різних типах шляхів» (один відносний, інший абсолютний) і отримують винятки або дивні результати.
Це тонкий момент, який рідко вгадують із першого разу. Якщо sourceDir задано як ".", а файл виявився абсолютним, то relativize може не спрацювати так, як ви очікували. Звичка, яка рятує нерви: перед relativize приводити обидва шляхи до toAbsolutePath().normalize(). Тоді порівняння стає стабільним і нудним (а нудьга в I/O — це комплімент).
Помилка №6: змішують збір кандидатів із виконанням операцій.
Дуже хочеться в collectCandidates одразу зробити copyTo, «адже ми вже знайшли файл». Але це призводить до коду, який важко тестувати й налагоджувати, а ще — майже неможливо безпечно запускати в dry-run режимі. Сьогоднішній принцип простий: обхід і відбір кандидатів — це чистий крок підготовки даних. Він нічого не змінює на диску, а лише повертає список структурованих кандидатів.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ