reen 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.claude/CLAUDE.md +44 -0
- data/.markdownlint.json +3 -0
- data/.rubocop.yml +24 -0
- data/CHANGELOG.md +18 -0
- data/CLAUDE.feature-numbered-lists.md +145 -0
- data/Gemfile +15 -0
- data/LICENSE.txt +21 -0
- data/README.md +131 -0
- data/Rakefile +38 -0
- data/bin/reen +92 -0
- data/lib/reen/actions/delete.rb +20 -0
- data/lib/reen/actions/force_delete.rb +20 -0
- data/lib/reen/actions/nothing.rb +17 -0
- data/lib/reen/actions/rename.rb +20 -0
- data/lib/reen/change.rb +148 -0
- data/lib/reen/changes.rb +79 -0
- data/lib/reen/changes_file.rb +46 -0
- data/lib/reen/path_entry.rb +20 -0
- data/lib/reen/path_entry_list.rb +39 -0
- data/lib/reen/reen.rb +46 -0
- data/lib/reen/version.rb +5 -0
- data/lib/reen.rb +13 -0
- data/sig/reenrb.rbs +4 -0
- metadata +67 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: c32624b91f88178dd9a68dc09229916804beffff846e04074e4f76a5eb299739
|
|
4
|
+
data.tar.gz: ef4cde36ed7202532276d761da152004dcb2f322814d7ed8d82c6de78ba4c920
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: e139aa0f8f927448c3e566a37868b5c29697e91b46f9fa3043d12dce53d015e64b1791474e36178d40b1b937b76032ab1fcce8caebfa5c45081a79251cee1c56
|
|
7
|
+
data.tar.gz: f9e20a1defcf449536eb2e8ca160ef7ee6eb26831527015df85cb2839b88cc97fb3ca2300ab27d00e388ea22af95c978c0d972932a80e2881638390f2c4b3c81
|
data/.claude/CLAUDE.md
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# CLAUDE.md
|
|
2
|
+
|
|
3
|
+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
4
|
+
|
|
5
|
+
## Project Overview
|
|
6
|
+
|
|
7
|
+
Reen is a Ruby gem for mass renaming/deleting files through an interactive editor. It provides both a CLI (`reen`) and a programmatic API. Version 1.0.0, requires Ruby >= 3.0.
|
|
8
|
+
|
|
9
|
+
## Common Commands
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
bundle exec rake # Run tests + rubocop (default task)
|
|
13
|
+
bundle exec rake spec # Run all tests (minitest)
|
|
14
|
+
bundle exec rake rubocop # Lint only
|
|
15
|
+
bundle exec rake respec # Auto-rerun tests on file changes
|
|
16
|
+
|
|
17
|
+
# Run a single test file
|
|
18
|
+
bundle exec ruby -Ispec:lib spec/spec_changes.rb
|
|
19
|
+
|
|
20
|
+
# Test fixtures
|
|
21
|
+
bundle exec rake example:recreate # Extract example.zip for tests
|
|
22
|
+
bundle exec rake example:remove # Clean up extracted fixtures
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Architecture
|
|
26
|
+
|
|
27
|
+
**Flow:** `ReenCLI (bin/reen)` → `Reen::Reen` → `ChangesFile` (temp file + editor) → `Changes` → `Change` → `Action`
|
|
28
|
+
|
|
29
|
+
- **`Reen::Reen`** (`lib/reen/reen.rb`) — Main orchestrator. Two-phase workflow: `request()` analyzes changes, `execute()` applies them. Constructor takes `editor:` (defaults to $VISUAL/$EDITOR) and `options:`.
|
|
30
|
+
- **`Reen::Changes`** (`lib/reen/changes.rb`) — Collection of `Change` objects with filtering (`rename_requested`, `delete_requested`, `accepted`, `rejected`, `executed`, `failed`) and `execute_all`.
|
|
31
|
+
- **`Reen::Change`** (`lib/reen/change.rb`) — Parses a single original→requested pair. Prefix `-` = delete, `--` = force delete. Maps change type to action handler via `ACTION_HANDLER` constant. Auto-rejects non-empty directory deletion (unless force).
|
|
32
|
+
- **`Reen::ChangesFile`** (`lib/reen/changes_file.rb`) — Manages Tempfile for editor interaction, writes instructions header, parses edited result.
|
|
33
|
+
- **Actions** (`lib/reen/actions/`) — Strategy pattern: `Rename`, `Delete`, `ForceDelete`, `DoNothing`. Each has `new(old, new)` and `call()` returning nil (success) or error string.
|
|
34
|
+
|
|
35
|
+
## Test Setup
|
|
36
|
+
|
|
37
|
+
- **Framework:** Minitest with describe/it blocks, minitest-rg for formatting, SimpleCov for coverage
|
|
38
|
+
- **Fixtures:** `spec/fixtures/example.zip` extracted to `spec/fixtures/example/` via `FixtureHelper` before tests
|
|
39
|
+
- **Test files:** `spec/spec_*.rb` pattern (spec_reenrb, spec_changes, spec_requests)
|
|
40
|
+
|
|
41
|
+
## Style
|
|
42
|
+
|
|
43
|
+
- RuboCop enforced: double quotes, 120 char line length, NewCops enabled
|
|
44
|
+
- Target Ruby version: 3.0
|
data/.markdownlint.json
ADDED
data/.rubocop.yml
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
AllCops:
|
|
2
|
+
TargetRubyVersion: 3.0
|
|
3
|
+
NewCops: enable
|
|
4
|
+
|
|
5
|
+
Metrics/BlockLength:
|
|
6
|
+
Enabled: true
|
|
7
|
+
Exclude:
|
|
8
|
+
- reen.gemspec
|
|
9
|
+
|
|
10
|
+
Style/HashSyntax:
|
|
11
|
+
Enabled: true
|
|
12
|
+
Exclude:
|
|
13
|
+
- Rakefile
|
|
14
|
+
|
|
15
|
+
Style/StringLiterals:
|
|
16
|
+
Enabled: true
|
|
17
|
+
EnforcedStyle: double_quotes
|
|
18
|
+
|
|
19
|
+
Style/StringLiteralsInInterpolation:
|
|
20
|
+
Enabled: true
|
|
21
|
+
EnforcedStyle: double_quotes
|
|
22
|
+
|
|
23
|
+
Layout/LineLength:
|
|
24
|
+
Max: 120
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.2.1] - 2022-11-05
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- Simplified API for passing block to request/execute
|
|
15
|
+
|
|
16
|
+
## [0.1.0] - 2022-11-05
|
|
17
|
+
|
|
18
|
+
- Initial release: CLI and API working
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# Numbered List Prefixes for Editor
|
|
2
|
+
|
|
3
|
+
> **IMPORTANT**: This plan must be kept up-to-date at all times. Assume context can be cleared at any time — this file is the single source of truth for the current state of this work. Update this plan before and after task and subtask implementations.
|
|
4
|
+
|
|
5
|
+
## Branch
|
|
6
|
+
|
|
7
|
+
`feature-numbered-lists`
|
|
8
|
+
|
|
9
|
+
## Goal
|
|
10
|
+
|
|
11
|
+
Add numbered prefixes (e.g., `[01]`, `[002]`) to the file list shown in the editor so users can freely reorder entries without losing track of which original file each line corresponds to. Matching changes back to originals uses the number, not line position. Also fix the dash-operator ambiguity by requiring a space after `-`/`--` to distinguish delete operations from literal filenames starting with dashes. Introduce `PathEntry` and `PathEntryList` domain objects to replace raw path strings — `PathEntry` wraps a file path and answers filesystem queries, `PathEntryList` is a collection that owns numbered serialization for the editor.
|
|
12
|
+
|
|
13
|
+
## Strategy: Vertical Slice
|
|
14
|
+
|
|
15
|
+
1. **Refactor** — Introduce `PathEntry`/`PathEntryList` domain objects, migrate existing code (all tests pass)
|
|
16
|
+
2. **Fix** — Require space after `-`/`--` for delete operators (dash ambiguity fix)
|
|
17
|
+
3. **Feature** — Add numbered prefixes and number-based matching
|
|
18
|
+
4. **Verify** — Manual test with real editor confirms behavior
|
|
19
|
+
|
|
20
|
+
## Current State
|
|
21
|
+
|
|
22
|
+
- [x] Plan created
|
|
23
|
+
- [x] Slice 0: PathEntry/PathEntryList refactor
|
|
24
|
+
- [x] Slice 1: Dash-operator ambiguity fix
|
|
25
|
+
- [x] Slice 2: Numbered prefixes
|
|
26
|
+
- [x] Slice 3: Number-based matching
|
|
27
|
+
- [x] Slice 4: Documentation
|
|
28
|
+
- [ ] Slice 5: Verification
|
|
29
|
+
|
|
30
|
+
## Key Findings
|
|
31
|
+
|
|
32
|
+
- `Change` currently owns filesystem queries (`request_dir?`, `request_file?`, `request_empty_dir?`, `request_full_dir?`) by checking `@original` path directly. These should move to `PathEntry`.
|
|
33
|
+
- `Change` will hold an `PathEntry` instead of a raw `@original` string. `Change#original` delegates to `PathEntry#path`.
|
|
34
|
+
- `PathEntryList` wraps an array of `PathEntry` objects. Constructed from `Dir.glob` results (array of strings). Owns numbered serialization (writing `[NN] path` lines) and parsing (stripping prefixes, returning number-keyed entries).
|
|
35
|
+
- `ChangesFile#initialize` currently writes `requested_list.join("\n")` — will instead take an `PathEntryList` and call its serialization.
|
|
36
|
+
- `ChangesFile#allow_changes` reads back lines, strips comments/blanks — will delegate prefix parsing to `PathEntryList`.
|
|
37
|
+
- `Reen#request` does positional matching via `original_list.zip(changed_list)` — changes to number-based matching via `PathEntryList`.
|
|
38
|
+
- `Reen#request` raises if list sizes differ (line deletion guard) — still valid with numbered approach.
|
|
39
|
+
- `Change#extract_request` parses operator prefixes (`-`, `--`) greedily, which means renaming to `-` or `--` is impossible. Fix: require a space after `-`/`--` to trigger delete.
|
|
40
|
+
- Tests manipulate `file.list` directly (mock editor) — the list exposed to the block will include number prefixes so test blocks simulate what a real user sees/edits.
|
|
41
|
+
- Number width is auto-sized: `format("%0#{width}d")` where `width = list.size.to_s.length`.
|
|
42
|
+
|
|
43
|
+
## Questions
|
|
44
|
+
|
|
45
|
+
- ~~Should `Change` know about number prefixes?~~ No — `ChangesFile` handles writing and stripping prefixes. `Change` and `Reen` see clean filenames. `Reen#compare_lists` changes from positional zip to number-keyed matching.
|
|
46
|
+
- ~~Should dashes go before or after number prefixes?~~ After. Dashes are operators on the filename, numbers are stable anchors: `[01] - file` = delete, `[01] -file` = rename to `-file`. Require space after `-`/`--` to distinguish.
|
|
47
|
+
- [ ] Should reordering be reflected in the output summary? (Probably not needed for v1.)
|
|
48
|
+
|
|
49
|
+
## Scope
|
|
50
|
+
|
|
51
|
+
**In scope**:
|
|
52
|
+
|
|
53
|
+
- `PathEntry` (`lib/reenrb/path_entry.rb`): wraps a file path, provides `file?`, `dir?`, `empty_dir?`, `full_dir?`, `path`
|
|
54
|
+
- `PathEntryList` (`lib/reenrb/path_entry_list.rb`): collection of `PathEntry`, constructed from string array, owns numbered serialization/parsing
|
|
55
|
+
- `Change`: refactor to hold `PathEntry` instead of raw string, delegate filesystem queries to `PathEntry`
|
|
56
|
+
- `Change#extract_request`: require space after `-`/`--` for delete operators (fixes dash-name ambiguity)
|
|
57
|
+
- `ChangesFile`: use `PathEntryList` for writing/reading, numbered prefixes, updated instructions
|
|
58
|
+
- `Reen`: accept `PathEntryList` (or build one from string array), number-based matching
|
|
59
|
+
- Tests for: `PathEntry`, `PathEntryList`, dash-space fix, prefix formatting/stripping, reordered matching
|
|
60
|
+
|
|
61
|
+
**Out of scope**:
|
|
62
|
+
|
|
63
|
+
- Reporting reorder information to the user
|
|
64
|
+
- Any CLI flag to disable numbering
|
|
65
|
+
- `Changes` refactor (stays as-is, wraps array of `Change`)
|
|
66
|
+
- File/folder creation via unnumbered lines (touch/mkdir) — future feature, separate branch
|
|
67
|
+
|
|
68
|
+
## Tasks
|
|
69
|
+
|
|
70
|
+
> **Strict TDD cycle**: For each slice — (1) run full suite to confirm green baseline, (2) write failing tests (RED), (3) run tests to confirm they fail, (4) implement (GREEN), (5) run full suite to confirm all pass. **Check off each step immediately after completing it.**
|
|
71
|
+
|
|
72
|
+
### Slice 0: Introduce PathEntry and PathEntryList domain objects (refactor)
|
|
73
|
+
|
|
74
|
+
- [x] 0.0 Run full suite — confirm green baseline (11 tests, 0 failures)
|
|
75
|
+
- [x] 0.1 RED — Write failing tests in `spec/spec_path_entry.rb`:
|
|
76
|
+
- [x] 0.1a `PathEntry` wraps a path and delegates `file?`, `dir?`, `empty_dir?`, `full_dir?`
|
|
77
|
+
- [x] 0.1b `PathEntryList` constructs from array of strings, iterates as `PathEntry` objects
|
|
78
|
+
- [x] 0.1c `PathEntryList#paths` returns array of path strings
|
|
79
|
+
- [x] 0.2 Run new tests — confirm RED (6 errors, uninitialized constant)
|
|
80
|
+
- [x] 0.3 GREEN — Implement `PathEntry` (`lib/reenrb/path_entry.rb`)
|
|
81
|
+
- [x] 0.4 GREEN — Implement `PathEntryList` (`lib/reenrb/path_entry_list.rb`)
|
|
82
|
+
- [x] 0.5 Run new tests — confirm GREEN (6 runs, 27 assertions, 0 failures)
|
|
83
|
+
- [x] 0.6 Refactor `Change` to hold `PathEntry`, delegate filesystem queries
|
|
84
|
+
- [x] 0.7 Refactor `Reen` and `ChangesFile` to accept/use `PathEntryList`
|
|
85
|
+
- [x] 0.8 Run full suite — confirm all tests pass (17 runs, 58 assertions, 0 failures)
|
|
86
|
+
|
|
87
|
+
### Slice 1: Fix dash-operator ambiguity in Change
|
|
88
|
+
|
|
89
|
+
- [x] 1.0 Run full suite — confirm green baseline (17 runs, 0 failures)
|
|
90
|
+
- [x] 1.1 RED — Write failing tests in `spec/spec_change.rb`:
|
|
91
|
+
- [x] 1.1a `- filename` (with space) triggers delete
|
|
92
|
+
- [x] 1.1b `-filename` (no space) triggers rename to `-filename`
|
|
93
|
+
- [x] 1.1c `-- filename` (with space) triggers force delete
|
|
94
|
+
- [x] 1.1d `--filename` (no space) triggers rename to `--filename`
|
|
95
|
+
- [x] 1.2 Run new tests — confirm RED (1.1b and 1.1d fail as expected)
|
|
96
|
+
- [x] 1.3 GREEN — Updated `Change#extract_request` regex: `^(--|-)·(?<name>.*)` with fallback `^()(?<name>.*)`
|
|
97
|
+
- [x] 1.4 Run full suite — confirm GREEN (21 runs, 64 assertions, 0 failures)
|
|
98
|
+
|
|
99
|
+
### Slice 2: Numbered prefixes in PathEntryList and ChangesFile
|
|
100
|
+
|
|
101
|
+
- [x] 2.0 Run full suite — confirm green baseline (21 runs, 0 failures)
|
|
102
|
+
- [x] 2.1 RED — Write failing tests:
|
|
103
|
+
- [x] 2.1a `PathEntryList#to_numbered` serializes as `[NN] path` lines (auto-sized width)
|
|
104
|
+
- [x] 2.1b `PathEntryList.from_numbered` parses `[NN] path` lines back to number-keyed entries
|
|
105
|
+
- [x] 2.1c (deferred to Slice 4 — documentation)
|
|
106
|
+
- [x] 2.2 Run new tests — confirm RED (3 errors, undefined method `to_numbered`)
|
|
107
|
+
- [x] 2.3 GREEN — Implement `PathEntryList#to_numbered` and `PathEntryList.from_numbered`
|
|
108
|
+
- [x] 2.4 GREEN — Update `ChangesFile` to use numbered serialization
|
|
109
|
+
- [x] 2.5 GREEN — Update `INSTRUCTIONS` constant with reorder/number/dash-space guidance
|
|
110
|
+
- [x] 2.6 Run full suite — deferred to Slice 3 (existing tests broke as expected, fixed there)
|
|
111
|
+
|
|
112
|
+
### Slice 3: Number-based matching in Reen
|
|
113
|
+
|
|
114
|
+
- [x] 3.0 (combined with Slice 2 — baseline was 21 runs before numbered output broke existing tests)
|
|
115
|
+
- [x] 3.1 RED — Write failing tests in `spec/spec_reorder.rb`:
|
|
116
|
+
- [x] 3.1a `Reen#request` correctly matches reordered entries to originals by number (reverse, rename+move)
|
|
117
|
+
- [x] 3.1b `Reen#request` still raises error when lines are removed
|
|
118
|
+
- [x] 3.2 Run new tests — confirm RED (3.1a failed, 3.1b passed)
|
|
119
|
+
- [x] 3.3 GREEN — Changed `Reen#compare_lists` to use `PathEntryList.from_numbered` for number-keyed matching
|
|
120
|
+
- [x] 3.4 GREEN — Updated existing tests: delete/force-delete use `.sub("] ", "] - ")` pattern
|
|
121
|
+
- [x] 3.5 Run full suite — confirm all pass (27 runs, 110 assertions, 0 failures)
|
|
122
|
+
|
|
123
|
+
### Slice 4: Documentation
|
|
124
|
+
|
|
125
|
+
- [x] 4.1 Update README.md: documented numbered prefixes, dash-space syntax, reordering, programmatic examples
|
|
126
|
+
- [x] 4.2 CLI help in `bin/reen` — no changes needed (uses banner/options only, editing syntax is in INSTRUCTIONS)
|
|
127
|
+
- [x] 4.3 `ChangesFile::INSTRUCTIONS` — updated in Slice 2.5, verified
|
|
128
|
+
|
|
129
|
+
### Slice 5: Verification
|
|
130
|
+
|
|
131
|
+
- [x] 5.1 Run full suite (`bundle exec rake`) — 29 tests, 115 assertions, 0 failures. RuboCop: only pre-existing offenses, no new ones.
|
|
132
|
+
- [ ] 5.2 Manual verification with real editor (user to perform)
|
|
133
|
+
|
|
134
|
+
## Completed
|
|
135
|
+
|
|
136
|
+
- Slice 0: PathEntry/PathEntryList refactor — `PathEntry` wraps paths with filesystem queries, `PathEntryList` is an Enumerable collection. `Change` delegates to `PathEntry`. `Reen` and `ChangesFile` accept `PathEntryList`. All 17 tests pass.
|
|
137
|
+
- Slice 1: Dash-operator ambiguity fix — `Change#extract_request` now requires space after `-`/`--` for delete operators. `-file` is rename to `-file`, `- file` is delete. All 21 tests pass.
|
|
138
|
+
- Slice 2: Numbered prefixes — `PathEntryList#to_numbered` and `.from_numbered` implemented. `ChangesFile` writes `[NN] path` lines and updated `INSTRUCTIONS`.
|
|
139
|
+
- Slice 3: Number-based matching — `Reen#compare_lists` uses `from_numbered` for number-keyed matching instead of positional zip. Reordering works. All 27 tests pass (110 assertions).
|
|
140
|
+
- Slice 4: Documentation — README updated with numbered prefixes, dash-space syntax, reordering docs, programmatic examples. CLI help unchanged (banner only). INSTRUCTIONS verified.
|
|
141
|
+
- Slice 5: Verification — 29 tests, 115 assertions, 0 failures. No new RuboCop offenses. Manual editor test pending.
|
|
142
|
+
|
|
143
|
+
---
|
|
144
|
+
|
|
145
|
+
Last updated: 2026-03-10
|
data/Gemfile
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
source "https://rubygems.org"
|
|
4
|
+
|
|
5
|
+
# Specify your gem's dependencies in reen.gemspec
|
|
6
|
+
gemspec
|
|
7
|
+
|
|
8
|
+
gem "irb"
|
|
9
|
+
gem "minitest", "~> 5.0"
|
|
10
|
+
gem "minitest-rg", "~> 5.0"
|
|
11
|
+
gem "rake", "~> 13.0"
|
|
12
|
+
gem "rerun", "~> 0.13"
|
|
13
|
+
gem "rubocop", "~> 1.21"
|
|
14
|
+
gem "rubyzip", "~> 2.3"
|
|
15
|
+
gem "simplecov"
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2022 Soumya Ray
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# Reen
|
|
2
|
+
|
|
3
|
+
Reen is a utility written in Ruby (requires Ruby installed on your machine) that mass renames/deletes files by allowing the user to modify a list. It includes a command line executable called `reen` that opens the user's default editor to permit interactive changes, or can be used programatically by modifying the list of file names using a code block.
|
|
4
|
+
|
|
5
|
+
[](https://www.youtube.com/watch?v=yJfDRfJr3os)
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
To install the command line utility, use:
|
|
10
|
+
|
|
11
|
+
$ gem install reen
|
|
12
|
+
|
|
13
|
+
Or add this line to your Ruby application's Gemfile for programmatic use:
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
gem 'reen'
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Usage
|
|
20
|
+
|
|
21
|
+
### Command line
|
|
22
|
+
|
|
23
|
+
From command line, run `reen` with file list:
|
|
24
|
+
|
|
25
|
+
reen files [options]
|
|
26
|
+
|
|
27
|
+
where `files` are a list of files or wildcard pattern (defaults to `*`; see examples)
|
|
28
|
+
|
|
29
|
+
Options include:
|
|
30
|
+
|
|
31
|
+
- `--help` or `-h` to see options help
|
|
32
|
+
- `--editor [EDITOR]` or `-e [EDITOR]` to use a specific editor, or just `-e` to use `$EDITOR`
|
|
33
|
+
- `--visual [EDITOR]` or `-v [EDITOR]` to use a specific editor, or just `-v` to use `$VISUAL`
|
|
34
|
+
- `--review` or `-r` request to review and confirm changes
|
|
35
|
+
|
|
36
|
+
The editor must block until the file is closed — for VS Code use `'code -w'`, for Sublime use `'subl -w'`. Without `-e` or `-v`, Reen defaults to `$VISUAL` then `$EDITOR`.
|
|
37
|
+
|
|
38
|
+
Examples:
|
|
39
|
+
|
|
40
|
+
reen # reen all files (*)
|
|
41
|
+
reen **/* # reen all files in all subfolders
|
|
42
|
+
reen myfolder/**/*.mov # reen all mov files in subfolders
|
|
43
|
+
reen -e # reen all files using $EDITOR
|
|
44
|
+
reen -v # reen all files using $VISUAL
|
|
45
|
+
reen -e vi *.md # reen all markdown files using vi
|
|
46
|
+
reen --editor 'code -w' # reen all files using vscode
|
|
47
|
+
|
|
48
|
+
### Specifying changes through the editor
|
|
49
|
+
|
|
50
|
+
Upon running Reen on file list, your editor will open with a numbered list of file/folder names. Each line has a number prefix like `[01]` that tracks the original file. For example:
|
|
51
|
+
|
|
52
|
+
```
|
|
53
|
+
[01] LICENSE.txt
|
|
54
|
+
[02] README.md
|
|
55
|
+
[03] SETUP.txt
|
|
56
|
+
[04] bin
|
|
57
|
+
[05] bin/help
|
|
58
|
+
[06] bin/myexec
|
|
59
|
+
[07] tests
|
|
60
|
+
[08] tests/fixtures
|
|
61
|
+
[09] tests/fixtures/a.json
|
|
62
|
+
[10] tests/fixtures/b.json
|
|
63
|
+
[11] tests/fixtures/c.json
|
|
64
|
+
[12] tests/helper.code
|
|
65
|
+
[13] tests/tests.code
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Specify changes to each file you wish changed modifying it in your editor:
|
|
69
|
+
|
|
70
|
+
- Change the file/folder name (after the number prefix) to rename it
|
|
71
|
+
- Put `- ` (dash followed by a space) after the number prefix to delete a file or empty folder
|
|
72
|
+
- Put `-- ` (double dash followed by a space) to force delete a file or non-empty folder (recursively)
|
|
73
|
+
- You may freely reorder lines — the number prefixes track which original file each line refers to
|
|
74
|
+
- Do not change or remove the `[NN]` number prefixes
|
|
75
|
+
|
|
76
|
+
For example, if we wanted to (a) rename `LICENSE.txt` to `LICENSE.md`, (b) delete `SETUP.txt`, and (c) recursively delete the `bin/` folder:
|
|
77
|
+
|
|
78
|
+
```
|
|
79
|
+
[01] LICENSE.md
|
|
80
|
+
[02] README.md
|
|
81
|
+
[03] - SETUP.txt
|
|
82
|
+
[04] -- bin
|
|
83
|
+
[05] bin/help
|
|
84
|
+
[06] bin/myexec
|
|
85
|
+
[07] tests
|
|
86
|
+
[08] tests/fixtures
|
|
87
|
+
[09] tests/fixtures/a.json
|
|
88
|
+
[10] tests/fixtures/b.json
|
|
89
|
+
[11] tests/fixtures/c.json
|
|
90
|
+
[12] tests/helper.code
|
|
91
|
+
[13] tests/tests.code
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Note: filenames starting with dashes (e.g., `-myfile`) are safe — only a dash followed by a space triggers deletion.
|
|
95
|
+
|
|
96
|
+
Upon saving and exiting the editor, Reen will execute all the changes.
|
|
97
|
+
|
|
98
|
+
### Ruby application
|
|
99
|
+
|
|
100
|
+
Use Reen programmatically using the `reen` gem. In the example below, we specify that we do not want to use an actual editor to modify the list, but rather alter the list file using a block. Note that `file.list` contains numbered lines (e.g., `[1] LICENSE.txt`).
|
|
101
|
+
|
|
102
|
+
```ruby
|
|
103
|
+
require 'reen'
|
|
104
|
+
|
|
105
|
+
glob = Dir.glob("*")
|
|
106
|
+
reen = Reen::Reen.new(editor: nil)
|
|
107
|
+
|
|
108
|
+
reen.execute(glob) do |file|
|
|
109
|
+
# Rename LICENSE.txt -> LICENSE.md (gsub works on the path portion)
|
|
110
|
+
index = file.list.index { |l| l.include? "LICENSE.txt" }
|
|
111
|
+
file.list[index] = file.list[index].gsub("txt", "md")
|
|
112
|
+
|
|
113
|
+
# Delete a file — insert "- " after the number prefix
|
|
114
|
+
index = file.list.index { |l| l.include? "SETUP.txt" }
|
|
115
|
+
file.list[index] = file.list[index].sub("] ", "] - ")
|
|
116
|
+
end
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
You may also pass a block with an editor specified, in which case the block is run after the editor has finished.
|
|
120
|
+
|
|
121
|
+
## Development
|
|
122
|
+
|
|
123
|
+
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
|
124
|
+
|
|
125
|
+
## Contributing
|
|
126
|
+
|
|
127
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/soumyaray/reen.
|
|
128
|
+
|
|
129
|
+
## License
|
|
130
|
+
|
|
131
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/gem_tasks"
|
|
4
|
+
require "rake/testtask"
|
|
5
|
+
|
|
6
|
+
Rake::TestTask.new(:spec) do |t|
|
|
7
|
+
t.libs << "spec"
|
|
8
|
+
t.libs << "lib"
|
|
9
|
+
t.test_files = FileList["spec/**/spec_*.rb"]
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
task :respec do
|
|
13
|
+
sh "rerun -c --ignore 'spec/fixtures/*' rake spec"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
namespace :example do
|
|
17
|
+
task :helper do
|
|
18
|
+
require_relative "spec/fixture_helper"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
desc "Recreates the example fixture folder"
|
|
22
|
+
task :recreate => :helper do
|
|
23
|
+
FixtureHelper.recreate_example_dir
|
|
24
|
+
puts "Example fixture recreated"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
desc "Deletes the example fixture folder"
|
|
28
|
+
task :remove => :helper do
|
|
29
|
+
FixtureHelper.remove_example_dirs
|
|
30
|
+
puts "Example fixture removed"
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
require "rubocop/rake_task"
|
|
35
|
+
|
|
36
|
+
RuboCop::RakeTask.new
|
|
37
|
+
|
|
38
|
+
task default: %i[spec rubocop]
|
data/bin/reen
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
$LOAD_PATH.unshift File.join(File.dirname(__FILE__), *%w[.. lib])
|
|
5
|
+
require "reen"
|
|
6
|
+
require "optparse"
|
|
7
|
+
|
|
8
|
+
# Reen command line application
|
|
9
|
+
class ReenCLI
|
|
10
|
+
EDITOR_MSG = "Editor not set in $VISUAL or $EDITOR -- please set one of those environment variables"
|
|
11
|
+
Options = Struct.new(:editor, :review) do
|
|
12
|
+
def initialize(editor: nil, review: false)
|
|
13
|
+
editor ||= ENV["VISUAL"] || ENV["EDITOR"] # rubocop:disable Style/FetchEnvVar
|
|
14
|
+
super(editor, review)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
attr_reader :options, :files
|
|
19
|
+
|
|
20
|
+
def initialize(args)
|
|
21
|
+
@options = Options.new
|
|
22
|
+
optparse = OptionParser.new { |parser| setup_options(parser) }
|
|
23
|
+
optparse.parse!(args, into: @options)
|
|
24
|
+
|
|
25
|
+
exit_with_msg(EDITOR_MSG) unless options.editor
|
|
26
|
+
|
|
27
|
+
@files = args.empty? ? Dir.glob("*") : args
|
|
28
|
+
rescue OptionParser::InvalidOption => e
|
|
29
|
+
puts "#{e.message}\n\n"
|
|
30
|
+
exit_with_msg(optparse)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def setup_options(parser) # rubocop:disable Metrics/MethodLength
|
|
34
|
+
parser.banner = "Usage: reen files [options]"
|
|
35
|
+
parser.version = Reen::VERSION
|
|
36
|
+
|
|
37
|
+
parser.on("-e", "--editor [EDITOR]", "Use EDITOR (default: $EDITOR)") do |ed|
|
|
38
|
+
@options.editor = ed || ENV["EDITOR"] # rubocop:disable Style/FetchEnvVar
|
|
39
|
+
end
|
|
40
|
+
parser.on("-v", "--visual [EDITOR]", "Use EDITOR (default: $VISUAL)") do |ed|
|
|
41
|
+
@options.editor = ed || ENV["VISUAL"] # rubocop:disable Style/FetchEnvVar
|
|
42
|
+
end
|
|
43
|
+
parser.on("-r", "--review", "Require review and confirmation of changes")
|
|
44
|
+
|
|
45
|
+
parser.on("-h", "--help", "Show help for options") do
|
|
46
|
+
exit_with_msg(parser)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def exit_with_msg(message)
|
|
51
|
+
puts message
|
|
52
|
+
exit(false)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def review_changes
|
|
56
|
+
puts
|
|
57
|
+
puts @requests.change_requested.summarize
|
|
58
|
+
print "\nContinue? (y/n) "
|
|
59
|
+
confirmation = %w[y yes].include?($stdin.gets.chomp.downcase)
|
|
60
|
+
exit_with_msg("Nothing changed") unless confirmation
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def check_inputs
|
|
64
|
+
@files = files.empty? ? Dir.glob("*") : files
|
|
65
|
+
exit_with_msg(EDITOR_MSG) unless options.editor
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def user_review?
|
|
69
|
+
@options.review && @requests.changes_requested?
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def call
|
|
73
|
+
@requests = Reen::Reen.new(editor: options.editor).request(files)
|
|
74
|
+
review_changes if user_review?
|
|
75
|
+
|
|
76
|
+
changes = @requests
|
|
77
|
+
.execute_all
|
|
78
|
+
.change_requested
|
|
79
|
+
|
|
80
|
+
if user_review? && changes.all_executed?
|
|
81
|
+
puts "Changes made"
|
|
82
|
+
else
|
|
83
|
+
puts changes.summarize
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
begin
|
|
89
|
+
ReenCLI.new(ARGV).call
|
|
90
|
+
rescue Reen::Error => e
|
|
91
|
+
puts "#{e.message}\n"
|
|
92
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reen
|
|
4
|
+
module Actions
|
|
5
|
+
# Deletes a file
|
|
6
|
+
class Delete
|
|
7
|
+
def initialize(old_name, new_name)
|
|
8
|
+
@old_name = old_name
|
|
9
|
+
@new_name = new_name
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def call
|
|
13
|
+
File.delete(@old_name)
|
|
14
|
+
nil
|
|
15
|
+
rescue Errno::ENOENT
|
|
16
|
+
"Could not delete"
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reen
|
|
4
|
+
module Actions
|
|
5
|
+
# Deletes a file
|
|
6
|
+
class ForceDelete
|
|
7
|
+
def initialize(old_name, new_name)
|
|
8
|
+
@old_name = old_name
|
|
9
|
+
@new_name = new_name
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def call
|
|
13
|
+
FileUtils.rm_rf(@old_name)
|
|
14
|
+
nil
|
|
15
|
+
rescue Errno::ENOENT
|
|
16
|
+
"Could not force delete"
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reen
|
|
4
|
+
module Actions
|
|
5
|
+
# Renames files
|
|
6
|
+
class Rename
|
|
7
|
+
def initialize(old_name, new_name)
|
|
8
|
+
@old_name = old_name
|
|
9
|
+
@new_name = new_name
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def call
|
|
13
|
+
File.rename(@old_name, @new_name)
|
|
14
|
+
nil
|
|
15
|
+
rescue Errno::ENOENT
|
|
16
|
+
"No such target file or directory"
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
data/lib/reen/change.rb
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "actions/delete"
|
|
4
|
+
require_relative "actions/force_delete"
|
|
5
|
+
require_relative "actions/rename"
|
|
6
|
+
require_relative "actions/nothing"
|
|
7
|
+
|
|
8
|
+
module Reen
|
|
9
|
+
# Change to an orignal file
|
|
10
|
+
class Change
|
|
11
|
+
attr_reader :original, :requested, :change, :status
|
|
12
|
+
|
|
13
|
+
module STATUS
|
|
14
|
+
ACCEPTED = :accepted
|
|
15
|
+
REJECTED = :rejected
|
|
16
|
+
EXECUTED = :executed
|
|
17
|
+
FAILED = :failed
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
module CHANGE
|
|
21
|
+
NONE = :none
|
|
22
|
+
DELETE = :delete
|
|
23
|
+
FORCE_DELETE = :force_delete
|
|
24
|
+
RENAME = :rename
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
module OBJECT
|
|
28
|
+
FILE = :file
|
|
29
|
+
FOLDER = :folder
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
ACTION_HANDLER = {
|
|
33
|
+
CHANGE::NONE => Actions::DoNothing,
|
|
34
|
+
CHANGE::DELETE => Actions::Delete,
|
|
35
|
+
CHANGE::FORCE_DELETE => Actions::ForceDelete,
|
|
36
|
+
CHANGE::RENAME => Actions::Rename
|
|
37
|
+
}.freeze
|
|
38
|
+
|
|
39
|
+
CHANGES_DESC = {
|
|
40
|
+
CHANGE::NONE => "Nothing",
|
|
41
|
+
CHANGE::DELETE => "Deleting",
|
|
42
|
+
CHANGE::FORCE_DELETE => "Force deleting",
|
|
43
|
+
CHANGE::RENAME => "Renaming"
|
|
44
|
+
}.freeze
|
|
45
|
+
|
|
46
|
+
def initialize(original, requested)
|
|
47
|
+
@entry = original
|
|
48
|
+
@original = @entry.path
|
|
49
|
+
extract_request(requested)
|
|
50
|
+
decide_object
|
|
51
|
+
decide_change
|
|
52
|
+
decide_status_reason
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def extract_request(str)
|
|
56
|
+
requested = str.match(/^\s*(?<op>--|-) +(?<name>.*)/) || str.match(/^(?<op>)(?<name>.*)/)
|
|
57
|
+
|
|
58
|
+
@operator = requested[:op]
|
|
59
|
+
@requested = requested[:name].strip
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def decide_change
|
|
63
|
+
@change =
|
|
64
|
+
if @operator == "--"
|
|
65
|
+
CHANGE::FORCE_DELETE
|
|
66
|
+
elsif @operator == "-"
|
|
67
|
+
CHANGE::DELETE
|
|
68
|
+
elsif @original != @requested
|
|
69
|
+
CHANGE::RENAME
|
|
70
|
+
else
|
|
71
|
+
CHANGE::NONE
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def decide_object
|
|
76
|
+
@object = OBJECT::FILE if request_file?
|
|
77
|
+
@object = OBJECT::FOLDER if request_dir?
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def decide_status_reason
|
|
81
|
+
if request_full_dir? && request_delete?
|
|
82
|
+
@status = STATUS::REJECTED
|
|
83
|
+
@reason = "Directories with files cannot be changed"
|
|
84
|
+
else
|
|
85
|
+
@status = STATUS::ACCEPTED
|
|
86
|
+
@reason = ""
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def execute
|
|
91
|
+
return self if not_accepted?
|
|
92
|
+
|
|
93
|
+
@status = STATUS::EXECUTED
|
|
94
|
+
error = ACTION_HANDLER[@change].new(@original, @requested).call
|
|
95
|
+
|
|
96
|
+
if error
|
|
97
|
+
@status = STATUS::FAILED
|
|
98
|
+
@reason = error
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
self
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Predicates
|
|
105
|
+
|
|
106
|
+
def request_dir? = @entry.dir?
|
|
107
|
+
|
|
108
|
+
def request_file? = @entry.file?
|
|
109
|
+
|
|
110
|
+
def request_nothing? = @change == CHANGE::NONE
|
|
111
|
+
|
|
112
|
+
def request_rename? = @change == CHANGE::RENAME
|
|
113
|
+
|
|
114
|
+
def request_delete? = @change == CHANGE::DELETE
|
|
115
|
+
|
|
116
|
+
def request_empty_dir? = @entry.empty_dir?
|
|
117
|
+
|
|
118
|
+
def accepted? = @status == STATUS::ACCEPTED
|
|
119
|
+
|
|
120
|
+
def not_accepted? = !accepted?
|
|
121
|
+
|
|
122
|
+
def executed? = @status == STATUS::EXECUTED
|
|
123
|
+
|
|
124
|
+
def rejected? = @status == STATUS::REJECTED
|
|
125
|
+
|
|
126
|
+
def failed? = @status == STATUS::FAILED
|
|
127
|
+
|
|
128
|
+
def request_full_dir? = @entry.full_dir?
|
|
129
|
+
|
|
130
|
+
def executed_or_rejected? = %i[executed rejected].include?(@status)
|
|
131
|
+
|
|
132
|
+
# Decoration
|
|
133
|
+
|
|
134
|
+
def to_s
|
|
135
|
+
file_desc =
|
|
136
|
+
case @change
|
|
137
|
+
when CHANGE::RENAME
|
|
138
|
+
"#{@original} -> #{@requested}"
|
|
139
|
+
else
|
|
140
|
+
@original
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
reason_desc = rejected? || failed? ? " (failed: #{@reason})" : ""
|
|
144
|
+
|
|
145
|
+
"#{CHANGES_DESC[@change]}: #{file_desc}#{reason_desc}"
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
data/lib/reen/changes.rb
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "change"
|
|
4
|
+
|
|
5
|
+
module Reen
|
|
6
|
+
# Change to an orignal file
|
|
7
|
+
class Changes
|
|
8
|
+
attr_reader :list
|
|
9
|
+
|
|
10
|
+
def initialize(changes_list)
|
|
11
|
+
@list = changes_list
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def execute_all
|
|
15
|
+
@list.map(&:execute)
|
|
16
|
+
self
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Queries
|
|
20
|
+
|
|
21
|
+
def rename_requested
|
|
22
|
+
Changes.new(@list.select(&:request_rename?))
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def delete_requested
|
|
26
|
+
Changes.new(@list.select(&:request_delete?))
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def change_requested
|
|
30
|
+
Changes.new(@list.reject(&:request_nothing?))
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def rejected
|
|
34
|
+
Changes.new(@list.select(&:rejected?))
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def accepted
|
|
38
|
+
Changes.new(@list.select(&:accepted?))
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def executed
|
|
42
|
+
Changes.new(@list.select(&:executed?))
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def failed
|
|
46
|
+
Changes.new(@list.select(&:failed?))
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def any?
|
|
50
|
+
!@list.empty?
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def count
|
|
54
|
+
@list.size
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Decoration
|
|
58
|
+
|
|
59
|
+
def summarize
|
|
60
|
+
return "Nothing changed" if @list.empty?
|
|
61
|
+
|
|
62
|
+
@list.join("\n")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Predicates
|
|
66
|
+
|
|
67
|
+
def no_changes_requested?
|
|
68
|
+
list.map(&:change).all? Change::CHANGE::NONE
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def changes_requested?
|
|
72
|
+
!no_changes_requested?
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def all_executed?
|
|
76
|
+
@list.all?(&:executed?)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tempfile"
|
|
4
|
+
|
|
5
|
+
module Reen
|
|
6
|
+
# Manages a temporary file with requested changes
|
|
7
|
+
class ChangesFile
|
|
8
|
+
INSTRUCTIONS = <<~COMMENTS
|
|
9
|
+
# Edit the names of any files/folders to rename or move them
|
|
10
|
+
# - Put a dash followed by a space to delete a file or empty folder: - filename
|
|
11
|
+
# - Put double dash followed by a space to force delete: -- filename
|
|
12
|
+
# - Do not change or remove the [NN] number prefixes
|
|
13
|
+
# - You may reorder lines freely; numbers track the original files
|
|
14
|
+
|
|
15
|
+
COMMENTS
|
|
16
|
+
|
|
17
|
+
attr_accessor :list
|
|
18
|
+
|
|
19
|
+
def initialize(entry_list)
|
|
20
|
+
@entry_list = entry_list
|
|
21
|
+
@list_file = Tempfile.new("reen-")
|
|
22
|
+
@list_file.write(INSTRUCTIONS)
|
|
23
|
+
@list_file.write(@entry_list.to_numbered.join("\n"))
|
|
24
|
+
@list_file.close
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def allow_changes(editor, &block)
|
|
28
|
+
await_editor(editor) if editor
|
|
29
|
+
@list = File.read(path).split("\n").map(&:strip)
|
|
30
|
+
.reject { |line| line.start_with?("#") || line.empty? }
|
|
31
|
+
|
|
32
|
+
block&.call(self)
|
|
33
|
+
@list
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def path
|
|
39
|
+
@list_file.path
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def await_editor(editor)
|
|
43
|
+
system("#{editor} #{@list_file.path}")
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reen
|
|
4
|
+
# Wraps a file path and answers filesystem queries
|
|
5
|
+
class PathEntry
|
|
6
|
+
attr_reader :path
|
|
7
|
+
|
|
8
|
+
def initialize(path)
|
|
9
|
+
@path = path
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def file? = File.file?(@path)
|
|
13
|
+
|
|
14
|
+
def dir? = Dir.exist?(@path)
|
|
15
|
+
|
|
16
|
+
def empty_dir? = dir? && Dir.empty?(@path)
|
|
17
|
+
|
|
18
|
+
def full_dir? = dir? && !Dir.empty?(@path)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "path_entry"
|
|
4
|
+
|
|
5
|
+
module Reen
|
|
6
|
+
# Collection of PathEntry objects, constructed from an array of path strings
|
|
7
|
+
class PathEntryList
|
|
8
|
+
include Enumerable
|
|
9
|
+
|
|
10
|
+
def initialize(path_strings)
|
|
11
|
+
@entries = path_strings.map { |p| PathEntry.new(p) }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def each(&block)
|
|
15
|
+
@entries.each(&block)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def paths
|
|
19
|
+
@entries.map(&:path)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def to_numbered
|
|
23
|
+
width = @entries.size.to_s.length
|
|
24
|
+
@entries.each_with_index.map do |entry, i|
|
|
25
|
+
num = format("%0#{width}d", i + 1)
|
|
26
|
+
"[#{num}] #{entry.path}"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.from_numbered(lines)
|
|
31
|
+
lines.each_with_object({}) do |line, hash|
|
|
32
|
+
match = line.match(/^\[(\d+)\] (.*)/)
|
|
33
|
+
next unless match
|
|
34
|
+
|
|
35
|
+
hash[match[1].to_i] = match[2]
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
data/lib/reen/reen.rb
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reen
|
|
4
|
+
# Renames pattern of files with given editor
|
|
5
|
+
# Examples:
|
|
6
|
+
# Reen::Reen.new(editor: "code -w").call("spec/fixtures/example/*")
|
|
7
|
+
# Reen::Reen.new(editor: nil).call("spec/fixtures/example/*") { ... }
|
|
8
|
+
class Reen
|
|
9
|
+
DEL_ERROR = "Do not remove any file/folder names (no changes made)"
|
|
10
|
+
|
|
11
|
+
attr_reader :changes
|
|
12
|
+
|
|
13
|
+
def initialize(editor: "emacs", options: {})
|
|
14
|
+
@editor = editor
|
|
15
|
+
@options = options
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def request(original_list, &block)
|
|
19
|
+
@entry_list = PathEntryList.new(original_list)
|
|
20
|
+
changed_list = ChangesFile.new(@entry_list).allow_changes(@editor, &block)
|
|
21
|
+
|
|
22
|
+
raise(Error, DEL_ERROR) if changed_list.size != @entry_list.count
|
|
23
|
+
|
|
24
|
+
@changes = compare_lists(@entry_list, changed_list)
|
|
25
|
+
.then { |change_array| Changes.new(change_array) }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def execute(original_list, &block)
|
|
29
|
+
@changes ||= request(original_list, &block)
|
|
30
|
+
@changes = @changes.execute_all
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def compare_lists(entry_list, changed_list)
|
|
36
|
+
changed_by_number = PathEntryList.from_numbered(changed_list)
|
|
37
|
+
entries = entry_list.to_a
|
|
38
|
+
|
|
39
|
+
entries.each_with_index.map do |entry, i|
|
|
40
|
+
number = i + 1
|
|
41
|
+
revised = changed_by_number[number]
|
|
42
|
+
Change.new(entry, revised)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
data/lib/reen/version.rb
ADDED
data/lib/reen.rb
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "reen/version"
|
|
4
|
+
require_relative "reen/path_entry"
|
|
5
|
+
require_relative "reen/path_entry_list"
|
|
6
|
+
require_relative "reen/changes_file"
|
|
7
|
+
require_relative "reen/change"
|
|
8
|
+
require_relative "reen/changes"
|
|
9
|
+
require_relative "reen/reen"
|
|
10
|
+
|
|
11
|
+
module Reen
|
|
12
|
+
class Error < StandardError; end
|
|
13
|
+
end
|
data/sig/reenrb.rbs
ADDED
metadata
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: reen
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 1.0.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Soumya Ray
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
description: Renames or deletes a pattern of files using your favorite editor
|
|
13
|
+
email:
|
|
14
|
+
- soumya.ray@gmail.com
|
|
15
|
+
executables:
|
|
16
|
+
- reen
|
|
17
|
+
extensions: []
|
|
18
|
+
extra_rdoc_files: []
|
|
19
|
+
files:
|
|
20
|
+
- ".claude/CLAUDE.md"
|
|
21
|
+
- ".markdownlint.json"
|
|
22
|
+
- ".rubocop.yml"
|
|
23
|
+
- CHANGELOG.md
|
|
24
|
+
- CLAUDE.feature-numbered-lists.md
|
|
25
|
+
- Gemfile
|
|
26
|
+
- LICENSE.txt
|
|
27
|
+
- README.md
|
|
28
|
+
- Rakefile
|
|
29
|
+
- bin/reen
|
|
30
|
+
- lib/reen.rb
|
|
31
|
+
- lib/reen/actions/delete.rb
|
|
32
|
+
- lib/reen/actions/force_delete.rb
|
|
33
|
+
- lib/reen/actions/nothing.rb
|
|
34
|
+
- lib/reen/actions/rename.rb
|
|
35
|
+
- lib/reen/change.rb
|
|
36
|
+
- lib/reen/changes.rb
|
|
37
|
+
- lib/reen/changes_file.rb
|
|
38
|
+
- lib/reen/path_entry.rb
|
|
39
|
+
- lib/reen/path_entry_list.rb
|
|
40
|
+
- lib/reen/reen.rb
|
|
41
|
+
- lib/reen/version.rb
|
|
42
|
+
- sig/reenrb.rbs
|
|
43
|
+
homepage: https://github.com/soumyaray/reen
|
|
44
|
+
licenses:
|
|
45
|
+
- MIT
|
|
46
|
+
metadata:
|
|
47
|
+
homepage_uri: https://github.com/soumyaray/reen
|
|
48
|
+
source_code_uri: https://github.com/soumyaray/reen
|
|
49
|
+
rubygems_mfa_required: 'true'
|
|
50
|
+
rdoc_options: []
|
|
51
|
+
require_paths:
|
|
52
|
+
- lib
|
|
53
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
54
|
+
requirements:
|
|
55
|
+
- - ">="
|
|
56
|
+
- !ruby/object:Gem::Version
|
|
57
|
+
version: '3.0'
|
|
58
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
59
|
+
requirements:
|
|
60
|
+
- - ">="
|
|
61
|
+
- !ruby/object:Gem::Version
|
|
62
|
+
version: '0'
|
|
63
|
+
requirements: []
|
|
64
|
+
rubygems_version: 4.0.7
|
|
65
|
+
specification_version: 4
|
|
66
|
+
summary: Renames or deletes a pattern of files using your favorite editor
|
|
67
|
+
test_files: []
|