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: ${candidates.size}") // Candidates: 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 режиме. Сегодняшний принцип простой: обход и отбор кандидатов — это чистый шаг подготовки данных. Он ничего не меняет на диске, а только возвращает список структурированных кандидатов.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ