Skip to content

achieffment/fs-normalizer

Repository files navigation

fs-normalizer

CLI-утилита для рекурсивной нормализации имён файлов и папок: транслитерация в ASCII, приведение дат к ISO-формату, единый стиль разделителей и регистра. Поведение идемпотентно — повторный запуск над уже нормализованным деревом ничего не переименовывает (единственное, что меняется при повторе, — служебный журнал .fs-log, который намеренно дополняется на каждом запуске; см. «Журнал»).

Возможности

  • Рекурсивный обход каталога с переименованием файлов и папок.
  • Транслитерация не-ASCII символов (кириллица, умляуты, emoji и т.п.) в ASCII. Мягкий и твёрдый знаки (ь/ъ) удаляются, а не превращаются в апостроф; запрещённые в именах файлов Windows символы (< > : " | ? *), которые может породить транслитерация типографики («»<</>>), вырезаются.
  • Обработка круглых и квадратных скобок: с числом (дубли файловых менеджеров Файл (1), Файл [1]) — скобки вырезаются, число дополняется ведущим нулём (→ fail-01); с текстом (инн (Нового договора нет)) — скобки сохраняются. Непарные/несовпадающие скобки (Файл (1, инн (текст]) вырезаются как невалидные.
  • Распознавание дат в разных форматах и приведение к ISO (YYYY-MM-DD) с плейсхолдерами 00 для недостающих компонентов.
  • Ведущий ноль для однозначных числовых токенов (1_file01_file).
  • Пробелы → дефис; цепочки дефисов вокруг пробелов схлопываются в один (Резюме - подготовкаreziume-podgotovka), но намеренные дефисы без пробелов сохраняются (file--improved). Обрезка «мусора» по краям имени (ведущий _ сохраняется и у файлов, и у папок, на краю сохраняется парная круглая скобка), единый регистр (папки — с заглавной, файлы — в нижнем; общепринятые имена вроде README сохраняют регистр; у папок ведущий _ сохраняется, а капитализируется первая буква после него — _private_Private).
  • Расширение файла не изменяется.
  • Скрытые файлы и папки (начинающиеся с .) пропускаются и не обходятся.
  • Фильтр путей .fs-ignore в стиле .gitignore (в корне нормализуемого каталога): обычная строка исключает объект из нормализации, строка с ! — возвращает (override). Полный синтаксис gitignore (*, **, ?, [abc], завершающий /, якоря), порядок строк значим. Без правила-! внутрь исключённых каталогов не заходим, такие объекты не учитываются в подсчёте (см. «Фильтр путей»).
  • Журнал .fs-log в нормализуемом каталоге: после прогона дописывается блок с датой-временем и списком выполненных переименований (только успешных — ошибки и конфликты в журнал не пишутся). Файл создаётся при отсутствии и дополняется при повторных запусках (см. «Журнал»).
  • Корневой каталог не переименовывается.
  • Безопасная обработка конфликтов: если целевое имя уже занято другим объектом, переименование пропускается (данные не перезаписываются).
  • Имя всегда остаётся одним компонентом пути: разделители (/, \) и управляющие символы, которые может ввести транслитерация, заменяются на - — объект нельзя случайно переместить в другой каталог или потерять.
  • Выбор каталога: нативный проводник Windows (на Windows и в WSL), стандартный диалог macOS либо ввод пути в терминале (на обычном Linux).

Структура проекта

fs-normalizer/
├── normalize_fs.py                   # точка входа
├── normalize.sh                      # обёртка для Linux/macOS (терминал)
├── normalize.command                 # обёртка для macOS (двойной клик в Finder)
├── normalize.bat                     # обёртка для Windows
├── normalizer/
│   ├── __init__.py                   # публичное API
│   ├── cli.py                        # разбор аргументов и сценарий запуска
│   ├── picker.py                     # выбор каталога (диалоги Windows/WSL/macOS, ввод в терминале)
│   ├── pick_folder.ps1               # нативный диалог выбора папки Windows (IFileOpenDialog)
│   ├── filesystem.py                 # обход ФС и применение переименований (deepest-first)
│   ├── ignore.py                     # фильтр путей .fs-ignore (gitignore-семантика, pathspec)
│   ├── log.py                        # журнал .fs-log: дата + список выполненных переименований
│   ├── name.py                       # сборка нового имени из конвейера правил
│   ├── rules/                        # пакет правил: по файлу на правило
│   │   ├── __init__.py               # ре-экспорт Rule и правил (__all__)
│   │   ├── base.py                   # Rule — базовый интерфейс
│   │   ├── transliteration.py        # транслитерация в ASCII + барьер безопасности
│   │   ├── brackets.py               # скобки с числом/датой убираются
│   │   ├── date.py                   # даты -> ISO YYYY-MM-DD
│   │   ├── leading_zero.py           # ведущий ноль для одиночной цифры
│   │   ├── space_to_dash.py          # пробелы -> дефис
│   │   ├── trim_edge.py              # обрезка мусора по краям имени
│   │   └── case.py                   # регистр: папки/файлы
│   └── safety.py                     # барьеры безопасности имени (один компонент пути)
├── tests/                            # тесты (pytest)
├── examples/                         # песочница-фикстуры для ручного прогона
│   └── reset.sh/.command/.bat        # откат прогона по examples/ (git reset + clean + checkout, ignorecase=false; + rm .fs-log)
└── requirements.txt

Требования

  • Python 3.10+ (используется синтаксис X | None).
  • Рантайм-зависимости (requirements.txt): Unidecode — транслитерация, pathspec — фильтр путей .fs-ignore.

Стандартной библиотеки достаточно для выбора папки: на Windows и в WSL вызывается powershell.exe (нативный диалог IFileOpenDialog), на macOS — osascript (AppleScript choose folder), на обычном Linux — ввод пути в терминале. Дополнительных GUI-пакетов не требуется.

Установка

Через обёртки установка не нужна: при первом запуске они сами создают .venv и ставят зависимости (требуется интернет один раз). Этот раздел — для прямого запуска python3 normalize_fs.py или разработки:

cd fs-normalizer
python3 -m venv .venv
source .venv/bin/activate    # Windows: .venv\Scripts\activate
pip install -r requirements.txt

Использование

Проще всего — через обёртку для своей ОС. При первом запуске она автоматически подготовит окружение (.venv + зависимости), затем запустит утилиту:

./normalize.bat    # Windows
./normalize.sh     # Linux/macOS (терминал)

На macOS для запуска двойным кликом в Finder используйте normalize.command (один раз сделайте его исполняемым: chmod +x normalize.command).

Альтернатива — прямой запуск (требует заранее подготовленного окружения, см. «Установка»):

python3 normalize_fs.py

Аргументы не нужны — каталог выбирается интерактивно. Поведение зависит от среды (в скобках — папка, предлагаемая по умолчанию):

  • Windows — открывается нативный диалог выбора папки (IFileOpenDialog, FOS_PICKFOLDERS). Он сразу открывается на папке по умолчанию, при этом навигация по всем путям остаётся свободной. По умолчанию это рабочий каталог: при запуске из командной строки — каталог вызова, а при запуске через ярлык/двойной клик по .bat — его рабочая папка (для двойного клика она автоматически равна каталогу проекта; для ярлыка .lnk задайте поле «Рабочая папка» = каталог проекта).
  • WSL — открывается тот же нативный диалог Windows, сразу на каталоге вызова (через UNC-путь \\wsl.localhost\...); выбранный путь автоматически переводится обратно в путь WSL. Навигация по всем путям (включая узел «Linux» и сеть) свободна.
  • macOS — открывается стандартный системный диалог choose folder (через osascript), возвращается POSIX-путь; по умолчанию предлагается каталог, из которого вызвана утилита (с откатом на каталог проекта).
  • Обычный Linux — запрашивается ввод пути к каталогу в терминале; по умолчанию (пустой ввод) используется каталог, из которого вызвана утилита.

О диалоге Windows/WSL. Используется системный IFileOpenDialog в режиме выбора папки: SetFolder вместе с уникальным SetClientGuid жёстко открывает диалог на нужной папке (игнорируя запомненную последнюю папку — MRU) и принимает UNC-путь WSL, сохраняя свободную навигацию. Стандартные OpenFileDialog/BrowseForFolder этого не дают: первый игнорирует стартовую папку для UNC (открывает «Документы»), второй не принимает UNC как корень. Флаг FOS_FORCEFILESYSTEM намеренно не ставится: корневой узел «Linux» (\\wsl.localhost) не считается элементом файловой системы, и с этим флагом он исчез бы из дерева — то есть из-под Windows нельзя было бы добраться до папок WSL. Из WSL путь к Windows-процессу прокидывается через переменную WSLENV (иначе пользовательские переменные окружения не доходят до powershell.exe). Небольшой блок интеропа на C# вынесен в отдельный файл normalizer/pick_folder.ps1 и исполняется через powershell.exe.

После завершения выводится сводка: сколько объектов переименовано и сколько пропущено.

Быстрый запуск через алиас

Чтобы не указывать полный путь к обёртке каждый раз, добавьте алиас в конфиг своей оболочки.

bash/zsh — в ~/.bashrc или ~/.zshrc:

alias fsnorm="$HOME/Projects/Work/fs-normalizer/normalize.sh"

Примените изменения (source ~/.bashrc) — и запускайте командой fsnorm.

PowerShell — в файле профиля ($PROFILE). Используется функция-обёртка, чтобы корректно пробрасывать аргументы:

function fsnorm { & "C:\path\to\fs-normalizer\normalize.bat" @args }

Перечитайте профиль (. $PROFILE) — и запускайте командой fsnorm.

Фильтр путей (.fs-ignore)

Чтобы исключить отдельные каталоги или файлы из нормализации, положите файл .fs-ignore в сам нормализуемый каталог (тот, что выбираете при запуске) и перечислите их в нём. Файл читается из этого каталога — ровно как .gitignore в корне репозитория. Формат — как у .gitignore (под капотом библиотека pathspec): обычная строка исключает, строка с ведущим ! — возвращает (override) ранее исключённое. Файл не создаётся автоматически: нет файла — фильтр выключен; если каталог под git, удобно добавить .fs-ignore в .gitignore, чтобы список оставался локальным.

Пути сопоставляются относительно нормализуемого каталога, и якорь / указывает на его верхушку (туда же, где лежит .fs-ignore). Пустые строки и комментарии (строка, начинающаяся с #) игнорируются. Пример: исключаем архивы и сборки, но возвращаем папки Data и не-скрытые файлы *.keep внутри них.

# Каталоги и файлы, которые не нормализуем
Archive
build/
*.bak
Projects/*/out

# Возвращаем обратно (override): порядок строк важен — выигрывает последняя совпавшая
!**/Data/
!*.keep

Как это работает:

  • Синтаксис gitignore. * — любые символы в пределах одного сегмента (не пересекает /); ** — ноль или более сегментов (Projects/**/Data); ? — ровно один символ; [abc]/[a-z] — класс символов; завершающий / (build/) исключает только каталог (и его содержимое), но не одноимённый файл. Разделитель — только /; \ — символ экранирования (литеральные скобки/? пишите как Файл \[1\]).
  • Без ./ и про #. Префикс ./ не поддерживается (. — обычный сегмент, паттерн молча ни с чем не совпадёт): пишите foo (basename) или /foo (якорь). Комментарий — только строка, начинающаяся с #; срединный # литерален, поэтому C#/Projects и C++/Projects валидны (а литеральный ведущий # экранируйте как \#).
  • Якоря. Паттерн без / (или только с завершающим /) — это basename: Archive совпадёт на любой глубине (.../Archive/...), но не с MyArchive/Archive2. Паттерн с / внутри (или ведущим /) привязан к корню нормализации: Home/Components совпадёт только с Home/Components от верхушки выбранного каталога, а Sub/notes.txt — только в этой цепочке.
  • Работает и для файлов. notes.txt исключит такой файл в любом месте дерева по границе сегмента (mynotes.txt не затрагивается).
  • Отрицание (!) и порядок. Строка с ! возвращает к нормализации то, что убрали предыдущие строки. Решение по каждому пути принимает последняя совпавшая строка (как в git). Это позволяет «исключить каталог, но вернуть в нём отдельные файлы».
  • Скрытые — раньше фильтра. Объекты с именем на . (например, .git, .keep) пропускаются до применения .fs-ignore и внутрь них обход не заходит, поэтому правило-! их вернуть не может. Паттерн *.keep действует только на не-скрытые файлы (marker.keep), но не на файл с именем .keep.
  • Исключённое не считается. Пропущенные объекты не попадают в итоговый счётчик (ни в «переименовано», ни в «пропущено»). Если в .fs-ignore нет ни одной !-строки, исключённый каталог не обходится (его содержимое не трогаем). Как только появляется хотя бы одно правило-!, обход заходит внутрь исключённых каталогов — там могут быть возвращённые потомки.
  • Регистронезависимость. Матчинг не учитывает регистр (как git с core.ignorecase=true — поведение по умолчанию на Windows и macOS): Archive совпадает с archive, *.txt — с Notes.TXT. Это нужно, чтобы капитализация вышележащих каталогов (file-globFile-glob, правило регистра) не рвала якоря паттернов и повторный запуск над уже нормализованным деревом ничего не менял. Замечание: не-ASCII имя родителя транслитерируется целиком (ДокументыDokumenty), и регистронезависимость такой случай не покрывает.
  • Кросс-платформенно. Пути относительные и не содержат буквы диска, поэтому один и тот же .fs-ignore работает одинаково на Windows, WSL, Linux и macOS.
  • Без файла — обычное поведение. Если .fs-ignore отсутствует, фильтр выключён. Пустой файл (или только из комментариев) ничего не исключает.

Рабочий пример с разными типами паттернов — в examples/.fs-ignore (разбор — в секции «10-ignore» файла examples/README.md): якоря с ведущим / и без, исключение каталога с возвратом потомка через !, и basename.

Журнал (.fs-log)

После каждого прогона в нормализуемый каталог дописывается журнал .fs-log: блок с датой-временем запуска (ISO, YYYY-MM-DD HH:MM:SS) и списком выполненных переименований (относительные пути от корня, old -> new). В журнал попадают только успешно выполненные переименования — ошибки и конфликты (занятое целевое имя) туда не пишутся, они выводятся в stderr. Если за прогон ничего не переименовано, фиксируется блок с пометкой (изменений нет). Файл создаётся при отсутствии, при повторных запусках — дополняется (новые блоки разделены пустой строкой).

Порядок записей — как выполнялись переименования (deepest-first: дети раньше родителей), поэтому в пути дочернего объекта родитель ещё со старым именем — это точно отражает фактические операции:

2026-06-11 13:39:00
  Отчёт за март/Файл (1).docx -> Отчёт за март/fail-01.docx
  Отчёт за март -> Otchiot-za-mart
  1_file.TXT -> 01_file.TXT

2026-06-11 14:02:11
  (изменений нет)

Сам .fs-log скрыт (имя на .), поэтому обходом пропускается: не нормализуется и не матчится .fs-ignore. Если каталог под git, файл удобно добавить в .gitignore (в этом репозитории он уже добавлен).

Журнал — намеренное исключение из инварианта идемпотентности: он дополняется на каждом запуске (блок с датой, при отсутствии переименований — (изменений нет)), тогда как сами переименования идемпотентны. Журнал пишет CLI после прохода, а не сам FilesystemNormalizer, поэтому проверки идемпотентности (которые гоняют FilesystemNormalizer.apply напрямую) на него не опираются. После демо-прогона по examples/ журнал убирают обёртки reset.* (явным rm, т.к. игнорируемый git-ом файл git clean -fd не удаляет).

Правила нормализации

Правила применяются к имени (без расширения для файлов) строго в указанном порядке:

# Правило Что делает
1 TransliterationRule Любой не-ASCII символ → ASCII (мягкий/твёрдый знаки ь/ъ удаляются, а не дают апостроф); разделители пути (/, \) и управляющие символы, которые может породить транслитерация (½1/2, \, U+2028→перевод строки), заменяются на -, чтобы имя осталось одним компонентом пути; запрещённые на Windows символы (`< > : "
2 BracketsRule Круглые и квадратные скобки с числом/датой (без букв) → убираются ((1)/[1]1, дальше ведущий ноль); скобки с текстом → сохраняются; непарные/несовпадающие → вырезаются
3 DateRule Даты → ISO YYYY-MM-DD; недостающие части → 00
4 SpaceToDashRule Пробелы → дефис; цепочки дефисов вокруг пробелов схлопываются в один, но намеренные дефисы без пробелов (file--improved) сохраняются
5 TrimEdgeRule Обрезка не буквенно-цифровых символов по краям (ведущий _ сохраняется и у файлов, и у папок; парная скобка на краю сохраняется; + и # — символы имени и сохраняются по краям: C#, C++, F#, notepad++)
6 LeadingZeroRule Однозначный числовой токен → с ведущим нулём
7 CaseRule Папки — с заглавной буквы, файлы — в нижнем регистре (кроме общепринятых имён вроде README, чей регистр сохраняется); у папок ведущий _ сохраняется, капитализируется первая буква после него (_private_Private)

Порядок важен: LeadingZeroRule идёт после TrimEdgeRule (ведущий ноль добавляется к уже очищенному от кромочного «мусора» токену — иначе том 5!tom-5 на первом проходе и tom-05 на втором, нарушая идемпотентность); CaseRule идёт последним (после схлопывания пробелов и обрезки кромок), что обеспечивает корректную капитализацию за один проход и идемпотентность.

Примеры

Файлы:

Исходное имя Результат
Отчёт.TXT otchiot.TXT
Письмо.doc pismo.doc
ООО «Печоралифтсервис».docx ooo-pechoraliftservis.docx
Файл (1).docx fail-01.docx
Файл [1].docx fail-01.docx
инн (Нового договора нет).txt inn-(novogo-dogovora-net).txt
инн [Нового договора нет].txt inn-[novogo-dogovora-net].txt
Файл (1.docx fail-01.docx
инн (Нового договора нет.txt inn-novogo-dogovora-net.txt
Резюме - подготовка.txt reziume-podgotovka.txt
file--improved.txt file--improved.txt
10½.dat 10-01-02.dat
1_file.TXT 01_file.TXT
v2 readme.MD v2-readme.MD
20.05.2020_dump 2020-05-20_dump
05.2020_report 2020-05-00_report
2020 2020-00-00
-file_01-.png file_01.png
_private.TXT _private.TXT
C++.txt c++.txt
notepad++ notepad++
README.md README.md

Папки:

Исходное имя Результат
отчёт за март Otchiot-za-mart
Отчёт 2020 Otchiot_2020-00-00
отчёт Otchiot
_private _Private
C# C#
C++ C++

Публичное API

Импортируйте из пакета normalizer, а не из подмодулей напрямую:

from pathlib import Path

from normalizer import build_normalizer, FilesystemNormalizer, load_fs_ignore, write_fs_log

normalizer = build_normalizer()
normalizer.normalize("Отчёт 2020", is_dir=True)    # 'Otchiot_2020-00-00'

# Фильтр читается из самого нормализуемого каталога (target/.fs-ignore);
# load_fs_ignore вернёт None, если файла нет (фильтр выключен).
target = Path("/path/to/dir")
ignorer = load_fs_ignore(target)                   # стиль .gitignore, якоря от target
fs = FilesystemNormalizer(build_normalizer(), ignorer)
renamed, skipped = fs.apply(target)
write_fs_log(target, fs.renames)                   # дописать .fs-log (только выполненное)

Экспортируется: main, FilesystemNormalizer, FsIgnore, load_fs_ignore, FS_LOG, write_fs_log, NameNormalizer, build_normalizer, Rule, TransliterationRule, BracketsRule, DateRule, LeadingZeroRule, CaseRule, SpaceToDashRule, TrimEdgeRule.

Разработка

pytest                                      # тесты
ruff check .                                # линтер
mypy --strict normalizer normalize_fs.py    # проверка типов

Код проходит ruff и mypy --strict без замечаний; покрытие правил и сценариев ФС — в tests/.

Windows: «ложные» изменения режима файлов

Скрипты-обёртки (normalize.sh, normalize.command) и normalize_fs.py хранятся в репозитории с битом исполняемости (100755), нужным на Linux/macOS. Файловая система Windows такой бит не хранит, поэтому git status/git diff на Windows постоянно показывают изменение режима (old mode 100755 / new mode 100644), которое не убирается даже через git checkout --.

Чтобы Git на Windows игнорировал режим файлов, выполните один раз в клоне:

git config core.fileMode false

Это локальная настройка конкретного клона; содержимое файлов Git по-прежнему отслеживает, а Unix-пользователей она не затрагивает.

About

CLI-утилита для рекурсивной нормализации имён файлов и папок: транслитерация в ASCII, приведение дат к ISO-формату, единый стиль разделителей и регистра.

Topics

Resources

Stars

Watchers

Forks

Contributors