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 уже существует». Мы пока не выполняем копирование/backup — мы только фиксируем решение.
Опишем варианты конфликта отдельным 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 пока только в плане
Потому что backup — это операция с диском: переименование, проверка ошибок, возможно, удаление старого .bak, возможно, невозможность переименовать из-за прав. Это уже «двигатель операций» и обработка частичных ошибок — следующая лекция.
Сейчас наша задача — честно сказать: «вот здесь будет backup, если дойдём до выполнения».
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 — это файловая операция с ошибками, правами и неожиданностями. Если смешать их, код станет плохо тестируемым и плохо читаемым: непонятно, где мы «думаем», а где уже «трогаем диск». Держите backup как часть исполнения, а в плане оставляйте только решение BACKUP.
Ошибка №6: относительный путь вычисляют через substring вместо relativize.
Пока пути простые — кажется, что «отрежу начало строки и готово». Но как только появляются разные разделители, разные варианты абсолютных путей, символические ссылки и прочие «радости», строковая логика начинает ломаться. Path.relativize(...) решает задачу именно как задачу путей, а не как задачу строк — и это намного надёжнее.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ