From 2465b56acb4946ef0ed38c59defb6e528c3e7872 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Dec 2025 18:15:14 +0000 Subject: [PATCH 01/15] Initial plan From 9714c5441959eecdc4bb9a8c215d79011761cc95 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Dec 2025 18:20:04 +0000 Subject: [PATCH 02/15] Add archiving UI and functionality Co-authored-by: jlahtela <13746676+jlahtela@users.noreply.github.com> --- RASP.lua | 29 +++++++++ modules/archiving.lua | 138 ++++++++++++++++++++++++++++++++++++++++++ modules/config.lua | 5 +- modules/gui.lua | 100 ++++++++++++++++++++++++++++-- 4 files changed, 265 insertions(+), 7 deletions(-) create mode 100644 modules/archiving.lua diff --git a/RASP.lua b/RASP.lua index 6293719..13fea7f 100644 --- a/RASP.lua +++ b/RASP.lua @@ -32,6 +32,7 @@ local config = require("config") local gui = require("gui") local versioning = require("versioning") local file_ops = require("file_operations") +local archiving = require("archiving") -- Initialize configuration config.init() @@ -55,6 +56,34 @@ local function main() end end + -- Handle archive browsing + if gui.should_browse_archive_dest then + gui.should_browse_archive_dest = false + + local retval, selected_path = reaper.GetUserInputs("Archive Destination", 1, "Folder path:", config.get("archive_destination")) + if retval and selected_path ~= "" then + config.set("archive_destination", selected_path) + gui.update_project_info() + gui.set_status("Archive destination updated") + end + end + + -- Handle archiving + if gui.should_archive_now then + gui.should_archive_now = false + gui.set_status("Archiving old versions...") + + local archive_dest = config.get("archive_destination") + local versions_to_keep = config.get("versions_to_keep") + + local success, result = archiving.archive_versions(archive_dest, versions_to_keep) + if success then + gui.set_status(result) + else + gui.set_status("Error: " .. result, true) + end + end + -- Continue loop if window is open if should_continue then reaper.defer(main) diff --git a/modules/archiving.lua b/modules/archiving.lua new file mode 100644 index 0000000..f1b6a40 --- /dev/null +++ b/modules/archiving.lua @@ -0,0 +1,138 @@ +--[[ + RASP Archiving Module + + Handles project archiving logic: + - Find all version folders for a project + - Determine which versions should be archived + - Move old versions to archive destination +]]-- + +local archiving = {} + +-- Load dependencies +local config = require("config") +local file_ops = require("file_operations") +local versioning = require("versioning") + +-- Find all version folders for a project in the parent directory +function archiving.find_all_versions(parent_dir, base_name) + if not parent_dir then return {} end + + local versions = {} + local prefix = config.get("version_prefix") + + local i = 0 + while true do + local subdir = reaper.EnumerateSubdirectories(parent_dir, i) + if not subdir then break end + + -- Check if this directory matches our naming pattern + if subdir:sub(1, #base_name) == base_name then + local version = config.parse_version(subdir) + if version then + table.insert(versions, { + name = subdir, + version = version, + path = file_ops.join_path(parent_dir, subdir) + }) + end + end + + i = i + 1 + end + + -- Sort by version number + table.sort(versions, function(a, b) return a.version < b.version end) + + return versions +end + +-- Get versions that should be archived based on current version and keep count +function archiving.get_versions_to_archive(current_version, versions_to_keep) + local info = versioning.get_project_info() + if not info then + return {}, "No project loaded" + end + + -- Find all versions + local all_versions = archiving.find_all_versions(info.parent_directory, info.base_name) + + if #all_versions == 0 then + return {}, "No versioned folders found" + end + + -- Determine which versions to archive + local to_archive = {} + local cutoff_version = current_version - versions_to_keep + + for _, ver in ipairs(all_versions) do + -- Archive if version is less than cutoff AND not the current version + if ver.version < cutoff_version and ver.version ~= current_version then + table.insert(to_archive, ver) + end + end + + return to_archive, nil +end + +-- Archive versions by moving them to archive destination +function archiving.archive_versions(archive_dest, versions_to_keep) + -- Validate archive destination + if not archive_dest or archive_dest == "" then + return false, "Archive destination not set" + end + + -- Create archive destination if it doesn't exist + if not file_ops.dir_exists(archive_dest) then + local success = file_ops.create_directory(archive_dest) + if not success then + return false, "Could not create archive destination" + end + end + + -- Get project info + local info = versioning.get_project_info() + if not info then + return false, "No project loaded" + end + + -- Get current version + local current_version = info.current_version or 0 + + -- Get versions to archive + local to_archive, err = archiving.get_versions_to_archive(current_version, versions_to_keep) + if err then + return false, err + end + + if #to_archive == 0 then + return true, "No versions to archive" + end + + -- Archive each version + local archived_count = 0 + local errors = {} + + for _, ver in ipairs(to_archive) do + local dest_path = file_ops.join_path(archive_dest, ver.name) + + -- Copy directory to archive location + local success = file_ops.copy_directory(ver.path, dest_path) + + if success then + -- Optionally remove original after successful copy + -- For safety, we'll keep the original for now + archived_count = archived_count + 1 + else + table.insert(errors, "Failed to archive: " .. ver.name) + end + end + + if #errors > 0 then + return false, string.format("Archived %d/%d versions, errors occurred", archived_count, #to_archive) + end + + return true, string.format("Successfully archived %d version(s)", archived_count) +end + +return archiving diff --git a/modules/config.lua b/modules/config.lua index dadf935..dc37ef9 100644 --- a/modules/config.lua +++ b/modules/config.lua @@ -18,12 +18,15 @@ local defaults = { version_digits = 3, -- Starting version number for new projects start_version = 1, + -- Archiving settings + archive_destination = "", + versions_to_keep = 3, -- Window state defaults window_dock = 0, window_x = 100, window_y = 100, window_width = 350, - window_height = 250, + window_height = 400, } -- Current configuration (loaded from ExtState or defaults) diff --git a/modules/gui.lua b/modules/gui.lua index 53f3a06..e9d929f 100644 --- a/modules/gui.lua +++ b/modules/gui.lua @@ -44,6 +44,15 @@ local state = { -- Button states version_button_hover = false, + -- Archiving settings + archive_destination = "", + versions_to_keep = 3, + + -- Input field state + input_active = false, + input_text = "", + input_cursor = 0, + -- Fonts font_normal = 1, font_large = 2, @@ -52,6 +61,8 @@ local state = { -- Public flags gui.should_create_version = false +gui.should_archive_now = false +gui.should_browse_archive_dest = false -- Set status message function gui.set_status(text, is_error) @@ -72,6 +83,12 @@ function gui.update_project_info() else state.project_path = "" end + + -- Load archiving settings + local config = require("config") + state.archive_destination = config.get("archive_destination") + state.versions_to_keep = config.get("versions_to_keep") + state.input_text = tostring(state.versions_to_keep) end -- Helper: Set drawing color @@ -139,6 +156,31 @@ local function draw_button(text, x, y, w, h, enabled) return false end +-- Helper: Draw input field for numbers +local function draw_input_field(value, x, y, w, h) + local hover = mouse_in(x, y, w, h) + + -- Draw background + local bg_color = hover and colors.panel or colors.background + draw_rect(x, y, w, h, bg_color) + draw_border(x, y, w, h, colors.border) + + -- Draw value + set_color(colors.text) + gfx.setfont(state.font_normal) + local text_w, text_h = gfx.measurestr(tostring(value)) + gfx.x = x + (w - text_w) / 2 + gfx.y = y + (h - text_h) / 2 + gfx.drawstr(tostring(value)) + + -- Return true if clicked (for editing) + if hover and gfx.mouse_cap == 0 and gui._last_mouse_cap == 1 then + return true + end + + return false +end + -- Draw header section local function draw_header(x, y, w) local h = 60 @@ -232,17 +274,63 @@ local function draw_status_bar(x, y, w) return h end --- Draw future features placeholder -local function draw_future_section(x, y, w) - local h = 50 +-- Draw archiving section +local function draw_archiving_section(x, y, w) + local h = 180 local padding = 15 + local btn_w = w - padding * 2 + local btn_h = 36 + local small_btn_w = 100 -- Section divider set_color(colors.border) gfx.line(x + 10, y, x + w - 10, y) - -- Coming soon text - draw_text("Archiving features coming in v0.2+", x + padding, y + 15, colors.text_dim, state.font_small) + -- Section title + draw_text("Archiving", x + padding, y + 10, colors.text_dim, state.font_small) + + -- Versions to keep + draw_text("Versions to keep active:", x + padding, y + 35, colors.text, state.font_small) + + -- Input field for versions to keep (compact) + local input_x = x + padding + 160 + local input_w = 50 + if draw_input_field(state.versions_to_keep, input_x, y + 30, input_w, 24) then + -- Show input dialog + local retval, new_value = reaper.GetUserInputs("Versions to Keep", 1, "Number of versions to keep active:", tostring(state.versions_to_keep)) + if retval then + local num = tonumber(new_value) + if num and num >= 1 then + state.versions_to_keep = math.floor(num) + local config = require("config") + config.set("versions_to_keep", state.versions_to_keep) + end + end + end + + -- Archive destination + draw_text("Archive destination:", x + padding, y + 65, colors.text, state.font_small) + + -- Display current destination (truncated) + local dest_display = state.archive_destination + if dest_display == "" then + dest_display = "Not set" + elseif #dest_display > 30 then + dest_display = "..." .. dest_display:sub(-27) + end + draw_text(dest_display, x + padding, y + 82, colors.text_dim, state.font_small) + + -- Browse button + if draw_button("Browse...", x + padding, y + 100, small_btn_w, 28) then + gui.should_browse_archive_dest = true + end + + -- Archive now button + local has_project = state.project_name ~= "" and state.project_name ~= "No project loaded" + local has_dest = state.archive_destination ~= "" + if draw_button("Archive Now", x + padding, y + 138, btn_w, btn_h, has_project and has_dest) then + gui.should_archive_now = true + end return h end @@ -305,7 +393,7 @@ function gui.update() y_offset = y_offset + draw_header(0, y_offset, w) y_offset = y_offset + draw_project_info(0, y_offset, w) y_offset = y_offset + draw_versioning_section(0, y_offset, w) - y_offset = y_offset + draw_future_section(0, y_offset, w) + y_offset = y_offset + draw_archiving_section(0, y_offset, w) -- Status bar at bottom draw_status_bar(0, h - 25, w) From 79234188f9a1fa87cb003e591ed5a36910d0b3d2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Dec 2025 18:22:53 +0000 Subject: [PATCH 03/15] Address code review feedback: clean up unused variables and improve folder browser Co-authored-by: jlahtela <13746676+jlahtela@users.noreply.github.com> --- RASP.lua | 31 ++++++++++++++++++++++++++----- modules/archiving.lua | 4 +--- modules/gui.lua | 6 ------ 3 files changed, 27 insertions(+), 14 deletions(-) diff --git a/RASP.lua b/RASP.lua index 13fea7f..8fe7bf2 100644 --- a/RASP.lua +++ b/RASP.lua @@ -60,11 +60,32 @@ local function main() if gui.should_browse_archive_dest then gui.should_browse_archive_dest = false - local retval, selected_path = reaper.GetUserInputs("Archive Destination", 1, "Folder path:", config.get("archive_destination")) - if retval and selected_path ~= "" then - config.set("archive_destination", selected_path) - gui.update_project_info() - gui.set_status("Archive destination updated") + -- Try to use JS extension for folder browser if available + local has_js = reaper.JS_Dialog_BrowseForFolder and true or false + local selected_path + + if has_js then + local retval + retval, selected_path = reaper.JS_Dialog_BrowseForFolder("Select Archive Destination", config.get("archive_destination")) + if retval == 1 and selected_path and selected_path ~= "" then + config.set("archive_destination", selected_path) + gui.update_project_info() + gui.set_status("Archive destination updated") + end + else + -- Fallback to text input dialog + local retval + retval, selected_path = reaper.GetUserInputs("Archive Destination", 1, "Folder path (full path required):", config.get("archive_destination")) + if retval and selected_path ~= "" then + -- Basic validation: check if path looks reasonable + if selected_path:match("[/\\]") or selected_path:len() > 2 then + config.set("archive_destination", selected_path) + gui.update_project_info() + gui.set_status("Archive destination updated") + else + gui.set_status("Invalid path format. Use full folder path.", true) + end + end end end diff --git a/modules/archiving.lua b/modules/archiving.lua index f1b6a40..bec4589 100644 --- a/modules/archiving.lua +++ b/modules/archiving.lua @@ -109,7 +109,7 @@ function archiving.archive_versions(archive_dest, versions_to_keep) return true, "No versions to archive" end - -- Archive each version + -- Archive each version (creates copies, originals are preserved) local archived_count = 0 local errors = {} @@ -120,8 +120,6 @@ function archiving.archive_versions(archive_dest, versions_to_keep) local success = file_ops.copy_directory(ver.path, dest_path) if success then - -- Optionally remove original after successful copy - -- For safety, we'll keep the original for now archived_count = archived_count + 1 else table.insert(errors, "Failed to archive: " .. ver.name) diff --git a/modules/gui.lua b/modules/gui.lua index e9d929f..e7116fa 100644 --- a/modules/gui.lua +++ b/modules/gui.lua @@ -48,11 +48,6 @@ local state = { archive_destination = "", versions_to_keep = 3, - -- Input field state - input_active = false, - input_text = "", - input_cursor = 0, - -- Fonts font_normal = 1, font_large = 2, @@ -88,7 +83,6 @@ function gui.update_project_info() local config = require("config") state.archive_destination = config.get("archive_destination") state.versions_to_keep = config.get("versions_to_keep") - state.input_text = tostring(state.versions_to_keep) end -- Helper: Set drawing color From 943e7a0e42ffa33b6d8c769cbb1cef6e9e036861 Mon Sep 17 00:00:00 2001 From: Jesse Lahtela Date: Sat, 20 Dec 2025 18:16:13 +0200 Subject: [PATCH 04/15] (not tested) Enhance archiving functionality: improve version matching logic and add confirmation dialogs for archiving and existing versions --- modules/archiving.lua | 150 ++++++++++++++++++++++++++++++++---- modules/file_operations.lua | 141 ++++++++++++++++++++++++++++++--- 2 files changed, 268 insertions(+), 23 deletions(-) diff --git a/modules/archiving.lua b/modules/archiving.lua index bec4589..1bc7ba1 100644 --- a/modules/archiving.lua +++ b/modules/archiving.lua @@ -15,6 +15,7 @@ local file_ops = require("file_operations") local versioning = require("versioning") -- Find all version folders for a project in the parent directory +-- Only matches exact project name or project name with version suffix function archiving.find_all_versions(parent_dir, base_name) if not parent_dir then return {} end @@ -26,15 +27,35 @@ function archiving.find_all_versions(parent_dir, base_name) local subdir = reaper.EnumerateSubdirectories(parent_dir, i) if not subdir then break end - -- Check if this directory matches our naming pattern - if subdir:sub(1, #base_name) == base_name then - local version = config.parse_version(subdir) + -- Check if this directory matches our naming pattern EXACTLY + -- Valid: "ProjectName" (version 0) or "ProjectName_v001" (version 1) + -- Invalid: "ProjectName_remix_v001" or "ProjectNameExtra" + + if subdir == base_name then + -- Exact match without version suffix = version 0 + table.insert(versions, { + name = subdir, + version = 0, + path = file_ops.join_path(parent_dir, subdir) + }) + elseif subdir:sub(1, #base_name) == base_name then + -- Check if the rest is exactly a version suffix (e.g., "_v001") + local remainder = subdir:sub(#base_name + 1) + local version = config.parse_version(base_name .. remainder) + + -- Only include if version suffix starts immediately after base_name + -- This ensures "ProjectName_v001" matches but "ProjectName_extra_v001" doesn't if version then - table.insert(versions, { - name = subdir, - version = version, - path = file_ops.join_path(parent_dir, subdir) - }) + local escaped_prefix = prefix:gsub("([%.%-%+%*%?%[%]%^%$%(%)%%])", "%%%1") + local expected_suffix = prefix .. string.format(config.get_version_format(), version) + + if remainder == expected_suffix then + table.insert(versions, { + name = subdir, + version = version, + path = file_ops.join_path(parent_dir, subdir) + }) + end end end @@ -48,6 +69,7 @@ function archiving.find_all_versions(parent_dir, base_name) end -- Get versions that should be archived based on current version and keep count +-- Never includes the current active version function archiving.get_versions_to_archive(current_version, versions_to_keep) local info = versioning.get_project_info() if not info then @@ -67,6 +89,7 @@ function archiving.get_versions_to_archive(current_version, versions_to_keep) for _, ver in ipairs(all_versions) do -- Archive if version is less than cutoff AND not the current version + -- Current version is NEVER archived, even if it would be below cutoff if ver.version < cutoff_version and ver.version ~= current_version then table.insert(to_archive, ver) end @@ -75,7 +98,60 @@ function archiving.get_versions_to_archive(current_version, versions_to_keep) return to_archive, nil end +-- Show confirmation dialog for archiving +-- Returns: "yes" (proceed), "no" (cancel), or nil if no versions to archive +function archiving.show_archive_confirmation(to_archive, archive_dest) + if #to_archive == 0 then + return nil + end + + -- Build list of versions + local version_list = "" + for _, ver in ipairs(to_archive) do + local version_display = ver.version == 0 and "(v0 - original)" or "" + version_list = version_list .. " • " .. ver.name .. " " .. version_display .. "\n" + end + + local message = string.format( + "Archive and REMOVE the following versions from source?\n\n%s\nDestination: %s\n\nThis action cannot be undone.", + version_list, + archive_dest + ) + + -- 1 = OK/Cancel, 4 = Yes/No + local result = reaper.ShowMessageBox(message, "Archive Confirmation", 4) + + -- 6 = Yes, 7 = No + if result == 6 then + return "yes" + else + return "no" + end +end + +-- Show dialog when archive destination already has a version +-- Returns: "skip", "replace", or "cancel" +function archiving.show_existing_version_dialog(version_name) + local message = string.format( + "\"%s\" already exists in archive.\n\n• Yes = Skip this version\n• No = Replace existing archive\n• Cancel = Abort entire operation", + version_name + ) + + -- 3 = Yes/No/Cancel + local result = reaper.ShowMessageBox(message, "Version Already Exists", 3) + + -- 6 = Yes (Skip), 7 = No (Replace), 2 = Cancel + if result == 6 then + return "skip" + elseif result == 7 then + return "replace" + else + return "cancel" + end +end + -- Archive versions by moving them to archive destination +-- Shows confirmation dialog and handles existing archives function archiving.archive_versions(archive_dest, versions_to_keep) -- Validate archive destination if not archive_dest or archive_dest == "" then @@ -109,28 +185,74 @@ function archiving.archive_versions(archive_dest, versions_to_keep) return true, "No versions to archive" end - -- Archive each version (creates copies, originals are preserved) + -- Show confirmation dialog + local confirm = archiving.show_archive_confirmation(to_archive, archive_dest) + if confirm ~= "yes" then + return true, "Archiving cancelled by user" + end + + -- Archive each version (copy then delete source) local archived_count = 0 + local skipped_count = 0 local errors = {} for _, ver in ipairs(to_archive) do local dest_path = file_ops.join_path(archive_dest, ver.name) + -- Check if destination already exists + if file_ops.dir_exists(dest_path) then + local action = archiving.show_existing_version_dialog(ver.name) + + if action == "cancel" then + return false, string.format("Archiving aborted. Archived %d version(s) before cancellation.", archived_count) + elseif action == "skip" then + skipped_count = skipped_count + 1 + goto continue + elseif action == "replace" then + -- Delete existing archive + local del_success, del_err = file_ops.delete_directory(dest_path) + if not del_success then + table.insert(errors, "Could not remove existing archive: " .. ver.name .. " - " .. (del_err or "unknown error")) + goto continue + end + end + end + -- Copy directory to archive location - local success = file_ops.copy_directory(ver.path, dest_path) + local success, copy_err = file_ops.copy_directory(ver.path, dest_path) if success then - archived_count = archived_count + 1 + -- Verify copy succeeded before deleting source + if file_ops.dir_exists(dest_path) then + -- Delete source directory + local del_success, del_err = file_ops.delete_directory(ver.path) + if del_success then + archived_count = archived_count + 1 + else + table.insert(errors, "Archived but could not delete source: " .. ver.name .. " - " .. (del_err or "unknown error")) + end + else + table.insert(errors, "Copy verification failed: " .. ver.name) + end else - table.insert(errors, "Failed to archive: " .. ver.name) + table.insert(errors, "Failed to archive: " .. ver.name .. " - " .. (copy_err or "unknown error")) end + + ::continue:: end if #errors > 0 then - return false, string.format("Archived %d/%d versions, errors occurred", archived_count, #to_archive) + local error_summary = table.concat(errors, "\n") + return false, string.format("Archived %d/%d versions, skipped %d, errors:\n%s", + archived_count, #to_archive, skipped_count, error_summary) + end + + local msg = string.format("Successfully archived %d version(s)", archived_count) + if skipped_count > 0 then + msg = msg .. string.format(", skipped %d", skipped_count) end - return true, string.format("Successfully archived %d version(s)", archived_count) + return true, msg end return archiving diff --git a/modules/file_operations.lua b/modules/file_operations.lua index 4338336..117245b 100644 --- a/modules/file_operations.lua +++ b/modules/file_operations.lua @@ -187,31 +187,154 @@ function file_ops.copy_file(source, dest) end end +-- Count files in a directory (for verification) +function file_ops.count_files_in_dir(path) + if not path then return 0 end + path = file_ops.normalize_path(path) + + local count = 0 + + local function scan_dir(dir) + -- Count files + local i = 0 + while true do + local filename = reaper.EnumerateFiles(dir, i) + if not filename then break end + count = count + 1 + i = i + 1 + end + + -- Recurse into subdirectories + i = 0 + while true do + local subdir = reaper.EnumerateSubdirectories(dir, i) + if not subdir then break end + scan_dir(file_ops.join_path(dir, subdir)) + i = i + 1 + end + end + + scan_dir(path) + return count +end + +-- Execute command and get exit code (cross-platform) +local function execute_with_exitcode(cmd) + local os_type = file_ops.get_os() + local full_cmd + + if os_type == "windows" then + -- Windows: use cmd /c and echo ERRORLEVEL + full_cmd = string.format('cmd /c "%s & echo %%ERRORLEVEL%%"', cmd) + else + -- Unix: append exit code + full_cmd = string.format('%s ; echo $?', cmd) + end + + local handle = io.popen(full_cmd) + if not handle then + return nil, "Failed to execute command" + end + + local result = handle:read("*a") + handle:close() + + -- Extract exit code from last line + local exit_code = tonumber(result:match("(%d+)%s*$")) + return exit_code +end + -- Copy entire directory (cross-platform) function file_ops.copy_directory(source, dest) if not source or not dest then return false, "Invalid paths" end + if not file_ops.dir_exists(source) then return false, "Source directory not found: " .. source end source = file_ops.normalize_path(source) dest = file_ops.normalize_path(dest) + -- Create destination if it doesn't exist + if not file_ops.dir_exists(dest) then + file_ops.create_directory(dest) + end + + -- Count source files for verification + local source_count = file_ops.count_files_in_dir(source) + local os_type = file_ops.get_os() local cmd + local exit_code if os_type == "windows" then -- Use robocopy on Windows (returns 0-7 for success) cmd = string.format('robocopy "%s" "%s" /E /NFL /NDL /NJH /NJS', source, dest) - local result = os.execute(cmd) - -- Robocopy returns 0-7 for various success states - if type(result) == "number" then - return result <= 7 + exit_code = execute_with_exitcode(cmd) + + -- Robocopy: 0-7 = success (with various copy states), 8+ = error + if not exit_code or exit_code >= 8 then + return false, "Robocopy failed with exit code: " .. tostring(exit_code) end - return result == true else - -- Use cp -r on Linux/macOS - cmd = string.format('cp -r "%s"/* "%s"/', source, dest) - local result = os.execute(cmd) - return result == 0 or result == true + -- Use cp -r on Linux/macOS (use /. to include hidden files) + cmd = string.format('cp -r "%s"/. "%s"/', source, dest) + exit_code = execute_with_exitcode(cmd) + + if not exit_code or exit_code ~= 0 then + return false, "Copy failed with exit code: " .. tostring(exit_code) + end end + + -- Verify destination has files + local dest_count = file_ops.count_files_in_dir(dest) + if dest_count == 0 and source_count > 0 then + return false, "Copy verification failed: destination is empty" + end + + return true +end + +-- Delete entire directory (cross-platform) +function file_ops.delete_directory(path) + if not path or path == "" then return false, "Invalid path" end + if not file_ops.dir_exists(path) then return false, "Directory not found: " .. path end + + path = file_ops.normalize_path(path) + + -- Safety check: don't delete root or very short paths + if #path < 10 then + return false, "Safety check: path too short to delete" + end + + local os_type = file_ops.get_os() + local cmd + local exit_code + + if os_type == "windows" then + -- Use rmdir /S /Q on Windows + cmd = string.format('rmdir /S /Q "%s"', path) + else + -- Use rm -rf on Linux/macOS + cmd = string.format('rm -rf "%s"', path) + end + + exit_code = execute_with_exitcode(cmd) + + if os_type == "windows" then + -- rmdir returns 0 on success + if exit_code ~= 0 then + return false, "Delete failed with exit code: " .. tostring(exit_code) + end + else + if exit_code ~= 0 then + return false, "Delete failed with exit code: " .. tostring(exit_code) + end + end + + -- Verify directory is gone + if file_ops.dir_exists(path) then + return false, "Delete verification failed: directory still exists" + end + + return true end -- Get all media files used in current project From 65fb59123f3ae659f621b1132d56d1d6547dc59b Mon Sep 17 00:00:00 2001 From: Jesse Lahtela Date: Sat, 20 Dec 2025 19:01:19 +0200 Subject: [PATCH 05/15] Versionin options native and custom. --- RASP.lua | 18 ++++- README.md | 131 +++++++++++++++++++++++++++---- modules/config.lua | 2 + modules/gui.lua | 68 ++++++++++++++++- modules/versioning.lua | 170 ++++++++++++++++++++++++++++++++++++++++- 5 files changed, 363 insertions(+), 26 deletions(-) diff --git a/RASP.lua b/RASP.lua index 8fe7bf2..edca0bd 100644 --- a/RASP.lua +++ b/RASP.lua @@ -45,14 +45,24 @@ local function main() -- Handle GUI events if gui.should_create_version then gui.should_create_version = false - gui.set_status("Creating new version...") - local success, result = versioning.create_new_version() + local mode = gui.get_versioning_mode() + if mode == "auto" then + gui.set_status("Creating new version (auto)...") + else + gui.set_status("Opening Save As dialog...") + end + + local success, result = versioning.create_new_version(mode) if success then - gui.set_status("Version created: " .. result) + if mode == "native" then + gui.set_status("Save As dialog opened") + else + gui.set_status("Version created successfully") + end gui.update_project_info() else - gui.set_status("Error: " .. result) + gui.set_status("Error: " .. (result or "Unknown error"), true) end end diff --git a/README.md b/README.md index 7b36b1d..985c1c4 100644 --- a/README.md +++ b/README.md @@ -3,10 +3,12 @@ Reaper Archiving System Project A Lua plugin for Reaper DAW that provides automatic project versioning with full media backup. -## Features (v0.1) +## Features (v0.2) - **Dockable UI** - Native Reaper interface -- **Auto-versioning** - Open save as dialog +- **Safe Auto-versioning** - Copies entire project with all media files +- **Dual Saving Mode** - Choose between Native (Reaper dialog) or Auto (fully automated) +- **Conflict Handling** - Smart handling when version folder already exists - **Cross-platform** - Works on Linux (Debian) and Windows ## Requirements @@ -16,7 +18,7 @@ A Lua plugin for Reaper DAW that provides automatic project versioning with full - **Operating System**: Linux (Debian) or Windows 11 (tested) ### Recommended -No extensions or additonal needed. +No extensions or additional software needed. ## Project Structure @@ -37,39 +39,138 @@ RASP/ 1. Copy `RASP/` folder to Reaper's `Scripts/` directory 2. In Reaper: Actions → Load ReaScript → Select `RASP.lua` 3. Run the script to open RASP window -4. Click "Create New Version" to version your project +4. Select saving method (Native/Auto) +5. Click "Create New Version" to version your project See [installation guide](docs/installation.md) for detailed instructions. ## Version Format ``` -MyProject/MyProject.rpp → Original +MyProject/MyProject.rpp → Original MyProject_v001/MyProject_v001.rpp → Version 1 MyProject_v002/MyProject_v002.rpp → Version 2 +MyProject_v002_a/MyProject_v002_a.rpp → Version 2 (alongside) ``` --- +## Versioning Guide + +### Saving Method Selection + +RASP provides two methods for creating new versions. You can switch between them using the **Native / Auto** toggle in the Versioning section. + + + +### Native Mode + +Opens Reaper's built-in "Save As" dialog. + +**When to use:** +- You want full control over save location +- You need to manually select which media to copy +- You're familiar with Reaper's "Copy all media" options + +**How it works:** +1. Click "Create New Version" +2. Reaper's Save As dialog opens +3. Choose location and enable "Copy all media into project directory" +4. Save + +**Console output:** +``` +RASP: Opening Save As dialog... + 💡 Tip: Enable 'Copy all media into project directory' for safe versioning +``` + +### Auto Mode (Recommended) + +Fully automated versioning that guarantees all media files are copied. + +**When to use:** +- You want fast, reliable versioning +- You want to ensure no media references break +- You want the new version to open automatically + +**How it works:** +1. Click "Create New Version" +2. RASP automatically: + - Calculates next version number (v001 → v002) + - Creates new folder `ProjectName_v002/` + - Copies ALL project files (audio, MIDI, peaks, etc.) + - Saves project file with new name + - Opens the new version in Reaper + +**Console output (success):** +``` +RASP: Creating version _v002... + 📁 Target: /home/user/Projects/MySong_v002 +✅ RASP: Version created successfully! + 📄 Project: MySong_v002.rpp + 🎵 Files: 47 copied + 📂 Location: /home/user/Projects/MySong_v002 +``` + +**Console output (error):** +``` +❌ RASP Error: Copy failed + Reason: Source directory not found: /path/to/project +``` + +### Conflict Handling + +If the target version folder already exists, RASP shows a dialog with three options: + + + +| Option | Result | +|--------|--------| +| **Yes** (Create alongside) | Creates `MySong_v002_a/` (or `_b`, `_c`, etc.) | +| **No** (Overwrite) | Copies files over existing ones (no deletion) | +| **Cancel** | Aborts versioning, no changes made | + +### Why Auto Mode is Safer + +Reaper projects can have media references that are: +- **Absolute paths** - Point to specific locations on disk +- **Relative paths** - Point relative to project file location + +When you manually copy/move a project without its media, these references break. RASP's Auto mode: + +1. ✅ Copies the **entire project folder** (all files) +2. ✅ Creates a new `.rpp` file with the version name +3. ✅ Preserves relative path structure +4. ✅ Automatically opens the new version + +This ensures your versioned projects are **100% self-contained** and portable. + +--- + ## Roadmap -### Version 0.1 goals +### Version 0.1 ✅ - RASP UI / plugin to Reaper - ability to "auto version" from RASP UI - increase version number when versioning -### Version 0.2 goals -- make archiving action of projects to local drive +### Version 0.2 ✅ +- Safe versioning with full media copy +- Native/Auto mode selection +- Conflict handling (alongside/overwrite/cancel) +- Console logging with detailed feedback + +### Version 0.3 (planned) +- Archiving action of projects to local drive - UI for archiving -- Select how many versions are kept and rest are achived +- Select how many versions are kept and rest are archived -### Version 0.3 goals +### Version 0.4 (planned) - Make archiving action of projects to Backblaze B2 - Able to pull back project from archive from Backblaze B2 -### Version 0.3 goals - -- find all repaer projects (from REPER media folder) - - Able to choose what projects will be archived and how many versions are kept - - Configuration option for singe "Reaper media" folder +### Future +- Find all Reaper projects (from Reaper media folder) +- Choose what projects will be archived and how many versions are kept +- Configuration option for single "Reaper media" folder diff --git a/modules/config.lua b/modules/config.lua index dc37ef9..3ed86c5 100644 --- a/modules/config.lua +++ b/modules/config.lua @@ -18,6 +18,8 @@ local defaults = { version_digits = 3, -- Starting version number for new projects start_version = 1, + -- Versioning mode: "native" (Reaper Save As dialog) or "auto" (full automated copy) + versioning_mode = "auto", -- Archiving settings archive_destination = "", versions_to_keep = 3, diff --git a/modules/gui.lua b/modules/gui.lua index e7116fa..2cb61cb 100644 --- a/modules/gui.lua +++ b/modules/gui.lua @@ -44,6 +44,9 @@ local state = { -- Button states version_button_hover = false, + -- Versioning settings + versioning_mode = "auto", -- "native" or "auto" + -- Archiving settings archive_destination = "", versions_to_keep = 3, @@ -59,6 +62,11 @@ gui.should_create_version = false gui.should_archive_now = false gui.should_browse_archive_dest = false +-- Get current versioning mode +function gui.get_versioning_mode() + return state.versioning_mode +end + -- Set status message function gui.set_status(text, is_error) state.status_text = text @@ -79,10 +87,11 @@ function gui.update_project_info() state.project_path = "" end - -- Load archiving settings + -- Load settings local config = require("config") state.archive_destination = config.get("archive_destination") state.versions_to_keep = config.get("versions_to_keep") + state.versioning_mode = config.get("versioning_mode") or "auto" end -- Helper: Set drawing color @@ -175,6 +184,39 @@ local function draw_input_field(value, x, y, w, h) return false end +-- Helper: Draw switch with two options (returns "left" or "right" if clicked, nil otherwise) +local function draw_switch(left_text, right_text, selected, x, y, w, h) + local half_w = w / 2 + local is_left = (selected == "left" or selected == left_text:lower()) + + -- Left side + local left_hover = mouse_in(x, y, half_w, h) + local left_bg = is_left and colors.accent or (left_hover and colors.panel or colors.background) + draw_rect(x, y, half_w, h, left_bg) + draw_border(x, y, half_w, h, colors.border) + local left_text_color = is_left and colors.button_text or colors.text_dim + draw_text_centered(left_text, x, y, half_w, h, left_text_color) + + -- Right side + local right_hover = mouse_in(x + half_w, y, half_w, h) + local right_bg = not is_left and colors.accent or (right_hover and colors.panel or colors.background) + draw_rect(x + half_w, y, half_w, h, right_bg) + draw_border(x + half_w, y, half_w, h, colors.border) + local right_text_color = not is_left and colors.button_text or colors.text_dim + draw_text_centered(right_text, x + half_w, y, half_w, h, right_text_color) + + -- Handle clicks (only on mouse release) + if gfx.mouse_cap == 0 and gui._last_mouse_cap == 1 then + if left_hover and not is_left then + return "left" + elseif right_hover and is_left then + return "right" + end + end + + return nil +end + -- Draw header section local function draw_header(x, y, w) local h = 60 @@ -228,17 +270,37 @@ end -- Draw versioning section local function draw_versioning_section(x, y, w) - local h = 70 + local h = 105 local padding = 15 local btn_w = w - padding * 2 local btn_h = 36 + local switch_w = 160 + local switch_h = 24 -- Section title draw_text("Versioning", x + padding, y + 5, colors.text_dim, state.font_small) + -- Saving method label and switch + draw_text("Saving method:", x + padding, y + 28, colors.text, state.font_small) + + -- Determine current selection for switch + local switch_selected = state.versioning_mode == "native" and "left" or "right" + local switch_result = draw_switch("Native", "Auto", switch_selected, x + padding + 110, y + 24, switch_w, switch_h) + + if switch_result then + local config = require("config") + if switch_result == "left" then + state.versioning_mode = "native" + config.set("versioning_mode", "native") + else + state.versioning_mode = "auto" + config.set("versioning_mode", "auto") + end + end + -- Create new version button local has_project = state.project_name ~= "" and state.project_name ~= "No project loaded" - if draw_button("Create New Version", x + padding, y + 25, btn_w, btn_h, has_project) then + if draw_button("Create New Version", x + padding, y + 58, btn_w, btn_h, has_project) then gui.should_create_version = true end diff --git a/modules/versioning.lua b/modules/versioning.lua index 3690f43..a285a92 100644 --- a/modules/versioning.lua +++ b/modules/versioning.lua @@ -104,11 +104,173 @@ function versioning.get_next_version(info) end end +-- Find available suffix when folder exists (a, b, c, ... z) +local function find_available_suffix(base_path) + for i = 1, 26 do + local suffix = "_" .. string.char(96 + i) -- 'a', 'b', 'c', ... + local new_path = base_path .. suffix + if not file_ops.dir_exists(new_path) then + return suffix + end + end + return nil -- All suffixes used (unlikely) +end + +-- Log message to REAPER console +local function log_message(msg) + reaper.ShowConsoleMsg(msg .. "\n") +end + +-- Handle version folder conflict +-- Returns: new_path (modified if needed), or nil if user cancelled +local function handle_version_conflict(target_path, version_name) + local msg = string.format( + "Folder '%s' already exists!\n\nChoose action:\n" .. + "YES = Create alongside (add suffix _a, _b, etc.)\n" .. + "NO = Overwrite (merge files)\n" .. + "CANCEL = Abort operation", + version_name + ) + + local result = reaper.ShowMessageBox(msg, "RASP - Version Conflict", 3) + + if result == 6 then -- Yes = Create alongside + local suffix = find_available_suffix(target_path) + if suffix then + return target_path .. suffix + else + log_message("❌ RASP Error: All suffixes (a-z) are used for this version") + return nil + end + elseif result == 7 then -- No = Overwrite + return target_path -- Keep original path, will overwrite + else -- Cancel + return nil + end +end + +-- Verify version copy was successful +local function verify_version_copy(new_dir, new_rpp_path, original_file_count) + local errors = {} + + -- Check directory exists + if not file_ops.dir_exists(new_dir) then + table.insert(errors, "Target directory was not created: " .. new_dir) + end + + -- Check RPP file exists + if not file_ops.file_exists(new_rpp_path) then + table.insert(errors, "Project file was not created: " .. new_rpp_path) + end + + -- Check file count + local new_count = file_ops.count_files_in_dir(new_dir) + if new_count == 0 and original_file_count > 0 then + table.insert(errors, string.format("No files copied (expected at least %d)", original_file_count)) + end + + if #errors > 0 then + return false, table.concat(errors, "\n ") + end + + return true, new_count +end + +-- Create new version with full automated copy (safe mode) +function versioning.create_new_version_safe() + -- Get current project info + local info, err = versioning.get_project_info() + if not info then + log_message("❌ RASP Error: " .. (err or "No project loaded")) + return false, err or "No project loaded" + end + + -- Calculate next version + local next_version = versioning.get_next_version(info) + local version_suffix = config.format_version(next_version) + local new_folder_name = info.base_name .. version_suffix + local new_folder_path = file_ops.join_path(info.parent_directory, new_folder_name) + + log_message("RASP: Creating version " .. version_suffix .. "...") + log_message(" 📁 Target: " .. new_folder_path) + + -- Check if target folder already exists + if file_ops.dir_exists(new_folder_path) then + new_folder_path = handle_version_conflict(new_folder_path, new_folder_name) + if not new_folder_path then + log_message(" ⚠️ Operation cancelled by user") + return false, "Operation cancelled" + end + -- Update folder name from potentially modified path + new_folder_name = file_ops.get_filename(new_folder_path) + log_message(" 📁 Using: " .. new_folder_path) + end + + -- Count original files for verification + local original_count = file_ops.count_files_in_dir(info.directory) + + -- Create target directory + if not file_ops.create_directory(new_folder_path) then + local err_msg = "Failed to create directory: " .. new_folder_path + log_message("❌ RASP Error: " .. err_msg) + return false, err_msg + end + + -- Copy all project files (excluding .rpp files - we'll save a new one) + local copy_success, copy_msg = file_ops.copy_project_files(info.directory, new_folder_path, true) + if not copy_success then + log_message("❌ RASP Error: Copy failed") + log_message(" Reason: " .. (copy_msg or "Unknown error")) + return false, copy_msg + end + + -- Construct new RPP filename and path + local new_rpp_name = new_folder_name .. ".rpp" + local new_rpp_path = file_ops.join_path(new_folder_path, new_rpp_name) + + -- Save project to new location + -- Use Main_SaveProjectEx with option 0 (normal save) + reaper.Main_SaveProjectEx(0, new_rpp_path, 0) + + -- Verify the copy was successful + local verify_success, verify_result = verify_version_copy(new_folder_path, new_rpp_path, original_count) + + if not verify_success then + log_message("❌ RASP Error: Verification failed") + log_message(" " .. verify_result) + return false, "Verification failed: " .. verify_result + end + + -- Open the new project in REAPER + reaper.Main_openProject(new_rpp_path) + + -- Success logging + log_message("✅ RASP: Version created successfully!") + log_message(" 📄 Project: " .. new_rpp_name) + log_message(" 🎵 Files: " .. verify_result .. " copied") + log_message(" 📂 Location: " .. new_folder_path) + + return true, string.format("Version %s created with %d files", version_suffix, verify_result) +end + -- Create a new versioned copy of the project -function versioning.create_new_version() - -- Open Reaper's native Save As dialog - reaper.Main_OnCommand(40022, 0) - return true, "Save As dialog opened" +-- Mode is determined by config setting (native or auto) +function versioning.create_new_version(mode) + -- Get mode from parameter or config + if not mode then + mode = config.get("versioning_mode") or "auto" + end + + if mode == "native" then + -- Open Reaper's native Save As dialog + log_message("RASP: Opening Save As dialog...") + log_message(" 💡 Tip: Enable 'Copy all media into project directory' for safe versioning") + reaper.Main_OnCommand(40022, 0) + return true, "Save As dialog opened" + else + -- Use automated safe copy + return versioning.create_new_version_safe() + end end -- Get display string for current version From dc7d8c41a2856ee93580ed2b8422faa0b84db792 Mon Sep 17 00:00:00 2001 From: Jesse Lahtela Date: Fri, 13 Mar 2026 17:14:14 +0200 Subject: [PATCH 06/15] Use Main_SaveProjectEx flag 2 for atomic save-with-media-copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace manual robocopy/cp approach with reaper.Main_SaveProjectEx(0, path, 2) which saves the .rpp, copies all media, and rewrites internal references atomically — identical to Save As with "Copy all media" ticked. Remove file_operations helpers that existed only for the old manual copy approach: copy_file, count_files_in_dir, get_project_media_files, get_all_project_files, copy_project_files. Also add .claude/ and CLAUDE.md to .gitignore (not for public repo). Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 4 + modules/file_operations.lua | 171 ------------------------------------ modules/versioning.lua | 98 ++++++--------------- 3 files changed, 30 insertions(+), 243 deletions(-) diff --git a/.gitignore b/.gitignore index 259148f..a67347a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ +# Claude Code files (not for version control) +.claude/ +CLAUDE.md + # Prerequisites *.d diff --git a/modules/file_operations.lua b/modules/file_operations.lua index 117245b..205908b 100644 --- a/modules/file_operations.lua +++ b/modules/file_operations.lua @@ -159,65 +159,6 @@ function file_ops.create_directory(path) return true end --- Copy a single file (cross-platform) -function file_ops.copy_file(source, dest) - if not source or not dest then return false, "Invalid paths" end - if not file_ops.file_exists(source) then return false, "Source not found: " .. source end - - source = file_ops.normalize_path(source) - dest = file_ops.normalize_path(dest) - - local os_type = file_ops.get_os() - local cmd - - if os_type == "windows" then - -- Use copy command on Windows - cmd = string.format('copy /Y "%s" "%s"', source, dest) - else - -- Use cp on Linux/macOS - cmd = string.format('cp "%s" "%s"', source, dest) - end - - local result = os.execute(cmd) - - if result == 0 or result == true then - return true - else - return false, "Copy failed: " .. cmd - end -end - --- Count files in a directory (for verification) -function file_ops.count_files_in_dir(path) - if not path then return 0 end - path = file_ops.normalize_path(path) - - local count = 0 - - local function scan_dir(dir) - -- Count files - local i = 0 - while true do - local filename = reaper.EnumerateFiles(dir, i) - if not filename then break end - count = count + 1 - i = i + 1 - end - - -- Recurse into subdirectories - i = 0 - while true do - local subdir = reaper.EnumerateSubdirectories(dir, i) - if not subdir then break end - scan_dir(file_ops.join_path(dir, subdir)) - i = i + 1 - end - end - - scan_dir(path) - return count -end - -- Execute command and get exit code (cross-platform) local function execute_with_exitcode(cmd) local os_type = file_ops.get_os() @@ -337,116 +278,4 @@ function file_ops.delete_directory(path) return true end --- Get all media files used in current project -function file_ops.get_project_media_files() - local media_files = {} - local seen = {} -- Avoid duplicates - - -- Iterate through all media items - local item_count = reaper.CountMediaItems(0) - for i = 0, item_count - 1 do - local item = reaper.GetMediaItem(0, i) - local take_count = reaper.CountTakes(item) - - for t = 0, take_count - 1 do - local take = reaper.GetTake(item, t) - if take then - local source = reaper.GetMediaItemTake_Source(take) - if source then - local filename = reaper.GetMediaSourceFileName(source) - if filename and filename ~= "" and not seen[filename] then - seen[filename] = true - table.insert(media_files, filename) - end - end - end - end - end - - return media_files -end - --- Get all files in project directory (recursive) -function file_ops.get_all_project_files(project_dir) - local files = {} - - local function scan_dir(dir, relative_path) - -- Scan files - local i = 0 - while true do - local filename = reaper.EnumerateFiles(dir, i) - if not filename then break end - - local full_path = file_ops.join_path(dir, filename) - local rel_path = relative_path ~= "" and file_ops.join_path(relative_path, filename) or filename - - table.insert(files, { - full_path = full_path, - relative_path = rel_path, - filename = filename - }) - - i = i + 1 - end - - -- Scan subdirectories - i = 0 - while true do - local subdir = reaper.EnumerateSubdirectories(dir, i) - if not subdir then break end - - local full_subdir = file_ops.join_path(dir, subdir) - local rel_subdir = relative_path ~= "" and file_ops.join_path(relative_path, subdir) or subdir - - scan_dir(full_subdir, rel_subdir) - - i = i + 1 - end - end - - scan_dir(project_dir, "") - return files -end - --- Copy all project files to new directory -function file_ops.copy_project_files(source_dir, dest_dir, exclude_rpp) - if not file_ops.create_directory(dest_dir) then - return false, "Could not create destination directory" - end - - local files = file_ops.get_all_project_files(source_dir) - local copied = 0 - local errors = {} - - for _, file_info in ipairs(files) do - -- Skip .rpp files if requested (we'll save a new one) - local skip = exclude_rpp and file_info.filename:match("%.rpp$") - - if not skip then - -- Create subdirectory if needed - local rel_dir = file_ops.get_directory(file_info.relative_path) - if rel_dir and rel_dir ~= "" then - local sub_dest = file_ops.join_path(dest_dir, rel_dir) - file_ops.create_directory(sub_dest) - end - - -- Copy file - local dest_path = file_ops.join_path(dest_dir, file_info.relative_path) - local success, err = file_ops.copy_file(file_info.full_path, dest_path) - - if success then - copied = copied + 1 - else - table.insert(errors, err or ("Failed: " .. file_info.relative_path)) - end - end - end - - if #errors > 0 then - return false, string.format("Copied %d files, %d errors", copied, #errors) - end - - return true, string.format("Copied %d files", copied) -end - return file_ops diff --git a/modules/versioning.lua b/modules/versioning.lua index a285a92..abb2996 100644 --- a/modules/versioning.lua +++ b/modules/versioning.lua @@ -149,108 +149,62 @@ local function handle_version_conflict(target_path, version_name) end end --- Verify version copy was successful -local function verify_version_copy(new_dir, new_rpp_path, original_file_count) - local errors = {} - - -- Check directory exists - if not file_ops.dir_exists(new_dir) then - table.insert(errors, "Target directory was not created: " .. new_dir) - end - - -- Check RPP file exists - if not file_ops.file_exists(new_rpp_path) then - table.insert(errors, "Project file was not created: " .. new_rpp_path) - end - - -- Check file count - local new_count = file_ops.count_files_in_dir(new_dir) - if new_count == 0 and original_file_count > 0 then - table.insert(errors, string.format("No files copied (expected at least %d)", original_file_count)) - end - - if #errors > 0 then - return false, table.concat(errors, "\n ") - end - - return true, new_count -end - --- Create new version with full automated copy (safe mode) +-- Create new version using Reaper's save engine (auto mode) +-- Reaper handles .rpp save, media copying, and path rewriting atomically (same as Save As) function versioning.create_new_version_safe() - -- Get current project info local info, err = versioning.get_project_info() if not info then log_message("❌ RASP Error: " .. (err or "No project loaded")) return false, err or "No project loaded" end - - -- Calculate next version + local next_version = versioning.get_next_version(info) local version_suffix = config.format_version(next_version) local new_folder_name = info.base_name .. version_suffix local new_folder_path = file_ops.join_path(info.parent_directory, new_folder_name) - + log_message("RASP: Creating version " .. version_suffix .. "...") log_message(" 📁 Target: " .. new_folder_path) - - -- Check if target folder already exists + + -- Handle existing folder conflict if file_ops.dir_exists(new_folder_path) then new_folder_path = handle_version_conflict(new_folder_path, new_folder_name) if not new_folder_path then log_message(" ⚠️ Operation cancelled by user") return false, "Operation cancelled" end - -- Update folder name from potentially modified path new_folder_name = file_ops.get_filename(new_folder_path) log_message(" 📁 Using: " .. new_folder_path) end - - -- Count original files for verification - local original_count = file_ops.count_files_in_dir(info.directory) - - -- Create target directory + + -- Build project file path + local new_rpp_name = new_folder_name .. ".rpp" + local new_rpp_path = file_ops.join_path(new_folder_path, new_rpp_name) + new_rpp_path = file_ops.normalize_path(new_rpp_path) + + -- Create destination directory before calling Main_SaveProjectEx if not file_ops.create_directory(new_folder_path) then local err_msg = "Failed to create directory: " .. new_folder_path log_message("❌ RASP Error: " .. err_msg) return false, err_msg end - - -- Copy all project files (excluding .rpp files - we'll save a new one) - local copy_success, copy_msg = file_ops.copy_project_files(info.directory, new_folder_path, true) - if not copy_success then - log_message("❌ RASP Error: Copy failed") - log_message(" Reason: " .. (copy_msg or "Unknown error")) - return false, copy_msg - end - - -- Construct new RPP filename and path - local new_rpp_name = new_folder_name .. ".rpp" - local new_rpp_path = file_ops.join_path(new_folder_path, new_rpp_name) - - -- Save project to new location - -- Use Main_SaveProjectEx with option 0 (normal save) - reaper.Main_SaveProjectEx(0, new_rpp_path, 0) - - -- Verify the copy was successful - local verify_success, verify_result = verify_version_copy(new_folder_path, new_rpp_path, original_count) - - if not verify_success then - log_message("❌ RASP Error: Verification failed") - log_message(" " .. verify_result) - return false, "Verification failed: " .. verify_result + + -- Save using Reaper's engine with flag 2: copies all media into project directory + -- and rewrites internal .rpp references — identical to Save As with "Copy all media" ticked. + -- Main_SaveProjectEx returns void; verify success by checking file existence. + reaper.Main_SaveProjectEx(0, new_rpp_path, 2) + + if not file_ops.file_exists(new_rpp_path) then + local err_msg = "Save failed: project file not found at " .. new_rpp_path + log_message("❌ RASP Error: " .. err_msg) + return false, err_msg end - - -- Open the new project in REAPER - reaper.Main_openProject(new_rpp_path) - - -- Success logging + log_message("✅ RASP: Version created successfully!") log_message(" 📄 Project: " .. new_rpp_name) - log_message(" 🎵 Files: " .. verify_result .. " copied") log_message(" 📂 Location: " .. new_folder_path) - - return true, string.format("Version %s created with %d files", version_suffix, verify_result) + + return true, string.format("Version %s created", version_suffix) end -- Create a new versioned copy of the project From 988ff6eff24d79e11af8ca6ea00bb72b1666e063 Mon Sep 17 00:00:00 2001 From: Jesse Lahtela Date: Fri, 13 Mar 2026 17:20:44 +0200 Subject: [PATCH 07/15] Pass explicit project handle to Main_SaveProjectEx Use info.project from EnumProjects(-1) instead of 0 to be explicit about which project is being saved. Co-Authored-By: Claude Sonnet 4.6 --- modules/versioning.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/versioning.lua b/modules/versioning.lua index abb2996..3f6ea60 100644 --- a/modules/versioning.lua +++ b/modules/versioning.lua @@ -192,7 +192,7 @@ function versioning.create_new_version_safe() -- Save using Reaper's engine with flag 2: copies all media into project directory -- and rewrites internal .rpp references — identical to Save As with "Copy all media" ticked. -- Main_SaveProjectEx returns void; verify success by checking file existence. - reaper.Main_SaveProjectEx(0, new_rpp_path, 2) + reaper.Main_SaveProjectEx(info.project, new_rpp_path, 2) if not file_ops.file_exists(new_rpp_path) then local err_msg = "Save failed: project file not found at " .. new_rpp_path From a09987eb3896acc86c0668a2f4802247aaeba538 Mon Sep 17 00:00:00 2001 From: Jesse Lahtela Date: Fri, 13 Mar 2026 19:17:31 +0200 Subject: [PATCH 08/15] Address Copilot review: fix archiving ops, conflict handling, and drop Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - file_operations: remove execute_with_exitcode and all Windows-specific shell commands (robocopy, rmdir). Archiving now uses cp/rm directly via os.execute — Linux/macOS only. Remove broken count_files_in_dir reference from copy_directory. - versioning: replace alongside (_a/_b) conflict option with "increment version number" (finds next available version). Fix success message to report actual created folder name instead of bare version suffix. - archiving: remove unused escaped_prefix variable. Fix create_directory check to use dir_exists() after creation instead of trusting return value. - README: update console output examples to match actual log messages, update conflict handling table, note Windows not supported. Co-Authored-By: Claude Sonnet 4.6 --- README.md | 16 +++-- modules/archiving.lua | 9 ++- modules/file_operations.lua | 121 ++++++++---------------------------- modules/versioning.lua | 58 ++++++++--------- 4 files changed, 62 insertions(+), 142 deletions(-) diff --git a/README.md b/README.md index 985c1c4..64717f6 100644 --- a/README.md +++ b/README.md @@ -15,11 +15,13 @@ A Lua plugin for Reaper DAW that provides automatic project versioning with full ### Required - **Reaper DAW** v6.0 or newer (tested with v7.x) -- **Operating System**: Linux (Debian) or Windows 11 (tested) +- **Operating System**: Linux (Debian) or macOS (Windows not supported — see note below) ### Recommended No extensions or additional software needed. +> **Note:** Windows is not supported. Versioning uses Reaper's native `Main_SaveProjectEx` which works on all platforms, but archiving (moving version folders) relies on `cp` and `rm` shell commands which are not available on Windows. + ## Project Structure ``` @@ -108,27 +110,23 @@ RASP: Creating version _v002... 📁 Target: /home/user/Projects/MySong_v002 ✅ RASP: Version created successfully! 📄 Project: MySong_v002.rpp - 🎵 Files: 47 copied 📂 Location: /home/user/Projects/MySong_v002 ``` **Console output (error):** ``` -❌ RASP Error: Copy failed - Reason: Source directory not found: /path/to/project +❌ RASP Error: Save failed: project file not found at /home/user/Projects/MySong_v002/MySong_v002.rpp ``` ### Conflict Handling If the target version folder already exists, RASP shows a dialog with three options: - - | Option | Result | |--------|--------| -| **Yes** (Create alongside) | Creates `MySong_v002_a/` (or `_b`, `_c`, etc.) | -| **No** (Overwrite) | Copies files over existing ones (no deletion) | -| **Cancel** | Aborts versioning, no changes made | +| **Yes** (Increment version) | Skips to the next available version number | +| **No** (Overwrite) | Saves into the existing folder | +| **Cancel** (Do nothing) | Aborts, no changes made | ### Why Auto Mode is Safer diff --git a/modules/archiving.lua b/modules/archiving.lua index 1bc7ba1..cd1a473 100644 --- a/modules/archiving.lua +++ b/modules/archiving.lua @@ -46,9 +46,8 @@ function archiving.find_all_versions(parent_dir, base_name) -- Only include if version suffix starts immediately after base_name -- This ensures "ProjectName_v001" matches but "ProjectName_extra_v001" doesn't if version then - local escaped_prefix = prefix:gsub("([%.%-%+%*%?%[%]%^%$%(%)%%])", "%%%1") local expected_suffix = prefix .. string.format(config.get_version_format(), version) - + if remainder == expected_suffix then table.insert(versions, { name = subdir, @@ -160,9 +159,9 @@ function archiving.archive_versions(archive_dest, versions_to_keep) -- Create archive destination if it doesn't exist if not file_ops.dir_exists(archive_dest) then - local success = file_ops.create_directory(archive_dest) - if not success then - return false, "Could not create archive destination" + file_ops.create_directory(archive_dest) + if not file_ops.dir_exists(archive_dest) then + return false, "Could not create archive destination: " .. archive_dest end end diff --git a/modules/file_operations.lua b/modules/file_operations.lua index 205908b..db8e691 100644 --- a/modules/file_operations.lua +++ b/modules/file_operations.lua @@ -1,10 +1,10 @@ --[[ RASP File Operations Module - - Handles file system operations including: - - Directory creation - - File copying (cross-platform) - - Media file collection from Reaper project + + Handles file system operations for Linux and macOS only. + Windows is not supported — Reaper's native Main_SaveProjectEx handles + versioning on all platforms, but archiving (copy/delete directories) + relies on shell commands (cp, rm) that are not available on Windows. ]]-- local file_ops = {} @@ -159,122 +159,51 @@ function file_ops.create_directory(path) return true end --- Execute command and get exit code (cross-platform) -local function execute_with_exitcode(cmd) - local os_type = file_ops.get_os() - local full_cmd - - if os_type == "windows" then - -- Windows: use cmd /c and echo ERRORLEVEL - full_cmd = string.format('cmd /c "%s & echo %%ERRORLEVEL%%"', cmd) - else - -- Unix: append exit code - full_cmd = string.format('%s ; echo $?', cmd) - end - - local handle = io.popen(full_cmd) - if not handle then - return nil, "Failed to execute command" - end - - local result = handle:read("*a") - handle:close() - - -- Extract exit code from last line - local exit_code = tonumber(result:match("(%d+)%s*$")) - return exit_code -end - --- Copy entire directory (cross-platform) +-- Copy entire directory (Linux/macOS only) function file_ops.copy_directory(source, dest) if not source or not dest then return false, "Invalid paths" end if not file_ops.dir_exists(source) then return false, "Source directory not found: " .. source end - + source = file_ops.normalize_path(source) dest = file_ops.normalize_path(dest) - - -- Create destination if it doesn't exist + if not file_ops.dir_exists(dest) then file_ops.create_directory(dest) end - - -- Count source files for verification - local source_count = file_ops.count_files_in_dir(source) - - local os_type = file_ops.get_os() - local cmd - local exit_code - - if os_type == "windows" then - -- Use robocopy on Windows (returns 0-7 for success) - cmd = string.format('robocopy "%s" "%s" /E /NFL /NDL /NJH /NJS', source, dest) - exit_code = execute_with_exitcode(cmd) - - -- Robocopy: 0-7 = success (with various copy states), 8+ = error - if not exit_code or exit_code >= 8 then - return false, "Robocopy failed with exit code: " .. tostring(exit_code) - end - else - -- Use cp -r on Linux/macOS (use /. to include hidden files) - cmd = string.format('cp -r "%s"/. "%s"/', source, dest) - exit_code = execute_with_exitcode(cmd) - - if not exit_code or exit_code ~= 0 then - return false, "Copy failed with exit code: " .. tostring(exit_code) - end + + local result = os.execute(string.format('cp -r "%s/." "%s"', source, dest)) + if result ~= true and result ~= 0 then + return false, "Copy failed for: " .. source end - - -- Verify destination has files - local dest_count = file_ops.count_files_in_dir(dest) - if dest_count == 0 and source_count > 0 then - return false, "Copy verification failed: destination is empty" + + if not file_ops.dir_exists(dest) then + return false, "Copy verification failed: destination does not exist" end - + return true end --- Delete entire directory (cross-platform) +-- Delete entire directory (Linux/macOS only) function file_ops.delete_directory(path) if not path or path == "" then return false, "Invalid path" end if not file_ops.dir_exists(path) then return false, "Directory not found: " .. path end - + path = file_ops.normalize_path(path) - + -- Safety check: don't delete root or very short paths if #path < 10 then return false, "Safety check: path too short to delete" end - - local os_type = file_ops.get_os() - local cmd - local exit_code - - if os_type == "windows" then - -- Use rmdir /S /Q on Windows - cmd = string.format('rmdir /S /Q "%s"', path) - else - -- Use rm -rf on Linux/macOS - cmd = string.format('rm -rf "%s"', path) - end - - exit_code = execute_with_exitcode(cmd) - - if os_type == "windows" then - -- rmdir returns 0 on success - if exit_code ~= 0 then - return false, "Delete failed with exit code: " .. tostring(exit_code) - end - else - if exit_code ~= 0 then - return false, "Delete failed with exit code: " .. tostring(exit_code) - end + + local result = os.execute(string.format('rm -rf "%s"', path)) + if result ~= true and result ~= 0 then + return false, "Delete failed for: " .. path end - - -- Verify directory is gone + if file_ops.dir_exists(path) then return false, "Delete verification failed: directory still exists" end - + return true end diff --git a/modules/versioning.lua b/modules/versioning.lua index 3f6ea60..ec0719e 100644 --- a/modules/versioning.lua +++ b/modules/versioning.lua @@ -104,47 +104,40 @@ function versioning.get_next_version(info) end end --- Find available suffix when folder exists (a, b, c, ... z) -local function find_available_suffix(base_path) - for i = 1, 26 do - local suffix = "_" .. string.char(96 + i) -- 'a', 'b', 'c', ... - local new_path = base_path .. suffix - if not file_ops.dir_exists(new_path) then - return suffix - end - end - return nil -- All suffixes used (unlikely) -end - -- Log message to REAPER console local function log_message(msg) reaper.ShowConsoleMsg(msg .. "\n") end -- Handle version folder conflict --- Returns: new_path (modified if needed), or nil if user cancelled -local function handle_version_conflict(target_path, version_name) +-- Returns: resolved_path, resolved_folder_name, or nil if user cancelled +local function handle_version_conflict(target_path, version_name, info, next_version) local msg = string.format( "Folder '%s' already exists!\n\nChoose action:\n" .. - "YES = Create alongside (add suffix _a, _b, etc.)\n" .. - "NO = Overwrite (merge files)\n" .. - "CANCEL = Abort operation", + "YES = Increment version number (use next available)\n" .. + "NO = Overwrite existing folder\n" .. + "CANCEL = Do nothing", version_name ) - + local result = reaper.ShowMessageBox(msg, "RASP - Version Conflict", 3) - - if result == 6 then -- Yes = Create alongside - local suffix = find_available_suffix(target_path) - if suffix then - return target_path .. suffix - else - log_message("❌ RASP Error: All suffixes (a-z) are used for this version") - return nil + + if result == 6 then -- Yes = Increment version + local try_version = next_version + 1 + while try_version <= 999 do + local try_suffix = config.format_version(try_version) + local try_name = info.base_name .. try_suffix + local try_path = file_ops.join_path(info.parent_directory, try_name) + if not file_ops.dir_exists(try_path) then + return try_path, try_name + end + try_version = try_version + 1 end + log_message("❌ RASP Error: No available version number found (tried up to v999)") + return nil elseif result == 7 then -- No = Overwrite - return target_path -- Keep original path, will overwrite - else -- Cancel + return target_path, version_name + else -- Cancel = Do nothing return nil end end @@ -168,12 +161,13 @@ function versioning.create_new_version_safe() -- Handle existing folder conflict if file_ops.dir_exists(new_folder_path) then - new_folder_path = handle_version_conflict(new_folder_path, new_folder_name) - if not new_folder_path then + local resolved_path, resolved_name = handle_version_conflict(new_folder_path, new_folder_name, info, next_version) + if not resolved_path then log_message(" ⚠️ Operation cancelled by user") return false, "Operation cancelled" end - new_folder_name = file_ops.get_filename(new_folder_path) + new_folder_path = resolved_path + new_folder_name = resolved_name log_message(" 📁 Using: " .. new_folder_path) end @@ -204,7 +198,7 @@ function versioning.create_new_version_safe() log_message(" 📄 Project: " .. new_rpp_name) log_message(" 📂 Location: " .. new_folder_path) - return true, string.format("Version %s created", version_suffix) + return true, string.format("Version %s created", new_folder_name) end -- Create a new versioned copy of the project From e1fdeba849cec67c51d48047e5b7e70d1c025487 Mon Sep 17 00:00:00 2001 From: Jesse Lahtela Date: Fri, 13 Mar 2026 19:26:24 +0200 Subject: [PATCH 09/15] Remove macOS mentions, add Windows to Future section Windows is explicitly not planned due to shell command dependencies. macOS was never tested and shouldn't be listed as supported. Co-Authored-By: Claude Sonnet 4.6 --- README.md | 7 ++++--- modules/file_operations.lua | 5 ++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 64717f6..9587082 100644 --- a/README.md +++ b/README.md @@ -9,18 +9,18 @@ A Lua plugin for Reaper DAW that provides automatic project versioning with full - **Safe Auto-versioning** - Copies entire project with all media files - **Dual Saving Mode** - Choose between Native (Reaper dialog) or Auto (fully automated) - **Conflict Handling** - Smart handling when version folder already exists -- **Cross-platform** - Works on Linux (Debian) and Windows +- **Linux support** - Tested on Debian Linux ## Requirements ### Required - **Reaper DAW** v6.0 or newer (tested with v7.x) -- **Operating System**: Linux (Debian) or macOS (Windows not supported — see note below) +- **Operating System**: Linux (Debian) ### Recommended No extensions or additional software needed. -> **Note:** Windows is not supported. Versioning uses Reaper's native `Main_SaveProjectEx` which works on all platforms, but archiving (moving version folders) relies on `cp` and `rm` shell commands which are not available on Windows. +> **Note:** Windows is not supported. Archiving relies on `cp` and `rm` shell commands which are not available on Windows. ## Project Structure @@ -171,4 +171,5 @@ This ensures your versioned projects are **100% self-contained** and portable. - Find all Reaper projects (from Reaper media folder) - Choose what projects will be archived and how many versions are kept - Configuration option for single "Reaper media" folder +- Windows support (no plans currently — archiving requires shell commands unavailable on Windows) diff --git a/modules/file_operations.lua b/modules/file_operations.lua index db8e691..cd77919 100644 --- a/modules/file_operations.lua +++ b/modules/file_operations.lua @@ -1,9 +1,8 @@ --[[ RASP File Operations Module - Handles file system operations for Linux and macOS only. - Windows is not supported — Reaper's native Main_SaveProjectEx handles - versioning on all platforms, but archiving (copy/delete directories) + Handles file system operations for Linux only. + Windows is not supported — archiving (copy/delete directories) relies on shell commands (cp, rm) that are not available on Windows. ]]-- From 367ba09b7a39c8a7b3ce9eda61de938d7bf4072a Mon Sep 17 00:00:00 2001 From: Jesse Lahtela Date: Fri, 13 Mar 2026 22:47:41 +0200 Subject: [PATCH 10/15] Publish CLAUDE.md for contributors using Claude Code Keep .claude/ personal directory gitignored. Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 3 +-- CLAUDE.md | 56 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 2 deletions(-) create mode 100644 CLAUDE.md diff --git a/.gitignore b/.gitignore index a67347a..5b1b2d7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,5 @@ -# Claude Code files (not for version control) +# Claude Code personal files (not for version control) .claude/ -CLAUDE.md # Prerequisites *.d diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..4ccfd5a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,56 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +RASP (Reaper Archiving System Project) is a Lua ReaScript plugin for the Reaper DAW. It provides automatic project versioning with full media backup and local archiving. No build step — Lua is interpreted directly by Reaper. + +**Installation**: Copy the `RASP/` folder to Reaper's `Scripts/` directory, then load `RASP.lua` via Reaper's Actions menu. + +## Architecture + +``` +RASP.lua # Entry point: loads modules, runs reaper.defer() main loop +modules/ + config.lua # Settings persistence via Reaper's ExtState API + gui.lua # Dockable UI using Reaper's gfx rendering API + versioning.lua # Version number parsing, next-version calculation, file copying + file_operations.lua # Cross-platform file/directory ops (robocopy on Windows, cp on Unix) + archiving.lua # Find/move old versioned folders to archive destination +``` + +**Data flow**: `RASP.lua` polls GUI state each defer cycle → dispatches to versioning or archiving modules → those call `file_operations` for disk work → `config` is read/written at any point. + +**Settings persistence**: All user settings are stored in Reaper's ExtState under section `"RASP"`. There are no config files on disk. + +## Key Design Patterns + +- **Main loop**: Reaper plugins use `reaper.defer()` for a non-blocking event loop. The loop is in `RASP.lua` and re-registers itself each cycle. +- **Cross-platform file ops**: `file_operations.lua` provides a unified API. Windows uses `robocopy /E /NFL /NDL`; Linux/macOS uses `cp -r`. Always go through this module for file/directory operations. +- **Two versioning modes**: + - *Native*: Opens Reaper's built-in Save As dialog (user controls the path). + - *Auto*: Fully automated — calls `reaper.Main_SaveProjectEx(0, path, 2)` which saves the `.rpp`, copies all media into the new directory, and rewrites internal path references atomically. This is equivalent to Save As with "Copy all media into project directory" ticked. Do NOT replace this with manual file copying (`robocopy`/`cp`) — that approach cannot rewrite `.rpp` internal references and will produce a broken project. +- **Safety**: Auto mode verifies the `.rpp` file exists after saving. Archiving never touches the currently open version. Destructive operations always show a confirmation dialog. +- **Version folder naming**: Projects are versioned by suffix on the folder name using a configurable prefix (default `_v`), digit count (default 3), and start number (default 1), e.g. `MyProject_v001/`. + +## Reaper API Conventions + +- `reaper.*` — core Reaper API functions +- `gfx.*` — immediate-mode graphics for the UI (gui.lua) +- `reaper.GetExtState` / `reaper.SetExtState` — persistent key-value storage +- `reaper.ShowMessageBox` — confirmation dialogs +- `reaper.GetProjectPath` / `reaper.GetProjectName` — current open project info + +## Current Development State + +**Active branch:** `V0.1_features` — implements roadmap v0.2 features (untested as of 2026-03-13): +- Local archiving: move old version folders to a configurable archive destination +- Configurable "versions to keep" setting (default 3) +- Dual save mode toggle: Native (Reaper's Save As dialog) vs Auto (fully automated via `Main_SaveProjectEx`) + +`master` contains the stable v0.1 release (basic versioning only). + +**Branch history note:** `V0.1_features` was built on top of the archiving code — the two feature sets (archiving + save mode toggle) are tightly coupled in gui.lua and cannot be separated cleanly. Both will merge to master together as v0.2. + +**Next:** `V0.3_features` — cloud archiving via Backblaze B2 (not started). From d9b747405829b8881fdbb2a77eae3215040486bc Mon Sep 17 00:00:00 2001 From: Jesse Lahtela Date: Fri, 13 Mar 2026 23:29:52 +0200 Subject: [PATCH 11/15] Fix auto versioning: copy media with cp -r, save .rpp separately Main_SaveProjectEx flag 2 did not copy media files on Linux. New approach: 1. cp -r copies all project files (media, peaks, etc.) to new folder 2. Main_SaveProjectEx flag 0 saves .rpp with new name, Reaper rewrites internal references 3. Old .rpp removed from destination folder Co-Authored-By: Claude Sonnet 4.6 --- README.md | 8 +++----- modules/versioning.lua | 30 ++++++++++++++++++++++++------ 2 files changed, 27 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 9587082..36f3b69 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,7 @@ Fully automated versioning that guarantees all media files are copied. **When to use:** - You want fast, reliable versioning - You want to ensure no media references break -- You want the new version to open automatically +- You want to ensure no media references break **How it works:** 1. Click "Create New Version" @@ -101,8 +101,7 @@ Fully automated versioning that guarantees all media files are copied. - Calculates next version number (v001 → v002) - Creates new folder `ProjectName_v002/` - Copies ALL project files (audio, MIDI, peaks, etc.) - - Saves project file with new name - - Opens the new version in Reaper + - Saves project file with new name (Reaper rewrites internal path references) **Console output (success):** ``` @@ -138,8 +137,7 @@ When you manually copy/move a project without its media, these references break. 1. ✅ Copies the **entire project folder** (all files) 2. ✅ Creates a new `.rpp` file with the version name -3. ✅ Preserves relative path structure -4. ✅ Automatically opens the new version +3. ✅ Reaper rewrites internal path references to match the new location This ensures your versioned projects are **100% self-contained** and portable. diff --git a/modules/versioning.lua b/modules/versioning.lua index ec0719e..3f34904 100644 --- a/modules/versioning.lua +++ b/modules/versioning.lua @@ -142,8 +142,11 @@ local function handle_version_conflict(target_path, version_name, info, next_ver end end --- Create new version using Reaper's save engine (auto mode) --- Reaper handles .rpp save, media copying, and path rewriting atomically (same as Save As) +-- Create new version (auto mode) +-- Strategy: +-- 1. cp -r copies all project files (media, peaks, etc.) to new folder +-- 2. Main_SaveProjectEx flag 0 saves .rpp with new name; Reaper rewrites internal path references +-- 3. Old .rpp is removed from new folder (it was copied in step 1, now replaced by step 2) function versioning.create_new_version_safe() local info, err = versioning.get_project_info() if not info then @@ -176,17 +179,24 @@ function versioning.create_new_version_safe() local new_rpp_path = file_ops.join_path(new_folder_path, new_rpp_name) new_rpp_path = file_ops.normalize_path(new_rpp_path) - -- Create destination directory before calling Main_SaveProjectEx + -- Create destination directory if not file_ops.create_directory(new_folder_path) then local err_msg = "Failed to create directory: " .. new_folder_path log_message("❌ RASP Error: " .. err_msg) return false, err_msg end - -- Save using Reaper's engine with flag 2: copies all media into project directory - -- and rewrites internal .rpp references — identical to Save As with "Copy all media" ticked. + -- Copy all project files (media, peaks, etc.) from source directory + local copy_success, copy_err = file_ops.copy_directory(info.directory, new_folder_path) + if not copy_success then + local err_msg = "Failed to copy project files: " .. (copy_err or "unknown error") + log_message("❌ RASP Error: " .. err_msg) + return false, err_msg + end + + -- Save .rpp with new name; flag 0 = save to path, Reaper rewrites internal references -- Main_SaveProjectEx returns void; verify success by checking file existence. - reaper.Main_SaveProjectEx(info.project, new_rpp_path, 2) + reaper.Main_SaveProjectEx(info.project, new_rpp_path, 0) if not file_ops.file_exists(new_rpp_path) then local err_msg = "Save failed: project file not found at " .. new_rpp_path @@ -194,6 +204,14 @@ function versioning.create_new_version_safe() return false, err_msg end + -- Remove old .rpp that was copied in step 1 (name differs from new .rpp) + if info.filename ~= new_rpp_name then + local old_rpp_in_dest = file_ops.join_path(new_folder_path, info.filename) + if file_ops.file_exists(old_rpp_in_dest) then + os.remove(old_rpp_in_dest) + end + end + log_message("✅ RASP: Version created successfully!") log_message(" 📄 Project: " .. new_rpp_name) log_message(" 📂 Location: " .. new_folder_path) From c25aefbd78921845332a7f37539febaa0c583ab0 Mon Sep 17 00:00:00 2001 From: Jesse Lahtela Date: Sat, 14 Mar 2026 09:01:16 +0200 Subject: [PATCH 12/15] Try Main_SaveProjectEx flag 3 for versioning (flag 1 + flag 2) Flag 2 alone did not copy media on Linux. Trying flag 3 = flag 1 (create subdirectory) + flag 2 (copy all media), which may require Reaper to create the directory itself. Removed manual cp-r and directory creation. If flag 3 also fails, fallback is cp-r + flag 0 + .rpp path patching. Co-Authored-By: Claude Sonnet 4.6 --- modules/versioning.lua | 36 +++++++----------------------------- 1 file changed, 7 insertions(+), 29 deletions(-) diff --git a/modules/versioning.lua b/modules/versioning.lua index 3f34904..084c5b2 100644 --- a/modules/versioning.lua +++ b/modules/versioning.lua @@ -143,10 +143,9 @@ local function handle_version_conflict(target_path, version_name, info, next_ver end -- Create new version (auto mode) --- Strategy: --- 1. cp -r copies all project files (media, peaks, etc.) to new folder --- 2. Main_SaveProjectEx flag 0 saves .rpp with new name; Reaper rewrites internal path references --- 3. Old .rpp is removed from new folder (it was copied in step 1, now replaced by step 2) +-- Uses Main_SaveProjectEx flag 3 = flag 1 (create subdirectory) + flag 2 (copy all media) +-- Reaper creates the folder, copies media, saves .rpp, and rewrites internal path references atomically. +-- Do NOT pre-create the directory — flag 1 expects to create it itself. function versioning.create_new_version_safe() local info, err = versioning.get_project_info() if not info then @@ -179,24 +178,11 @@ function versioning.create_new_version_safe() local new_rpp_path = file_ops.join_path(new_folder_path, new_rpp_name) new_rpp_path = file_ops.normalize_path(new_rpp_path) - -- Create destination directory - if not file_ops.create_directory(new_folder_path) then - local err_msg = "Failed to create directory: " .. new_folder_path - log_message("❌ RASP Error: " .. err_msg) - return false, err_msg - end - - -- Copy all project files (media, peaks, etc.) from source directory - local copy_success, copy_err = file_ops.copy_directory(info.directory, new_folder_path) - if not copy_success then - local err_msg = "Failed to copy project files: " .. (copy_err or "unknown error") - log_message("❌ RASP Error: " .. err_msg) - return false, err_msg - end - - -- Save .rpp with new name; flag 0 = save to path, Reaper rewrites internal references + -- Save using flag 3 = flag 1 (create subdirectory) + flag 2 (copy all media into project dir) + -- Reaper creates the folder, copies all media, saves .rpp, and rewrites internal references. + -- Do NOT pre-create the directory — flag 1 requires Reaper to create it itself. -- Main_SaveProjectEx returns void; verify success by checking file existence. - reaper.Main_SaveProjectEx(info.project, new_rpp_path, 0) + reaper.Main_SaveProjectEx(info.project, new_rpp_path, 3) if not file_ops.file_exists(new_rpp_path) then local err_msg = "Save failed: project file not found at " .. new_rpp_path @@ -204,14 +190,6 @@ function versioning.create_new_version_safe() return false, err_msg end - -- Remove old .rpp that was copied in step 1 (name differs from new .rpp) - if info.filename ~= new_rpp_name then - local old_rpp_in_dest = file_ops.join_path(new_folder_path, info.filename) - if file_ops.file_exists(old_rpp_in_dest) then - os.remove(old_rpp_in_dest) - end - end - log_message("✅ RASP: Version created successfully!") log_message(" 📄 Project: " .. new_rpp_name) log_message(" 📂 Location: " .. new_folder_path) From c673125a0dda62175f14939d0f8a1237ab089aa3 Mon Sep 17 00:00:00 2001 From: Jesse Lahtela Date: Sat, 14 Mar 2026 09:44:08 +0200 Subject: [PATCH 13/15] Add language requirement to CLAUDE.md: English only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Public repo — all comments, commits, issues, and docs must be in English. Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 4ccfd5a..7cba6c4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,6 +42,10 @@ modules/ - `reaper.ShowMessageBox` — confirmation dialogs - `reaper.GetProjectPath` / `reaper.GetProjectName` — current open project info +## Language + +All code comments, commit messages, issue comments, PR descriptions, and documentation must be written in **English**. This is a public repository. + ## Current Development State **Active branch:** `V0.1_features` — implements roadmap v0.2 features (untested as of 2026-03-13): From eb4104e2c5ae162da4661e81f567868cdd52f8b6 Mon Sep 17 00:00:00 2001 From: Jesse Lahtela Date: Sun, 15 Mar 2026 16:44:26 +0200 Subject: [PATCH 14/15] Fix archiving off-by-one and versioning cancel UX - archiving.lua: fix cutoff comparison (< -> <=) so versions_to_keep=3 correctly keeps exactly 3 versions instead of 4 - versioning.lua: return true (not false) when user cancels conflict dialog, so cancellation is not shown as a red error in the UI Co-Authored-By: Claude Sonnet 4.6 --- modules/archiving.lua | 2 +- modules/versioning.lua | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/archiving.lua b/modules/archiving.lua index cd1a473..97e7031 100644 --- a/modules/archiving.lua +++ b/modules/archiving.lua @@ -89,7 +89,7 @@ function archiving.get_versions_to_archive(current_version, versions_to_keep) for _, ver in ipairs(all_versions) do -- Archive if version is less than cutoff AND not the current version -- Current version is NEVER archived, even if it would be below cutoff - if ver.version < cutoff_version and ver.version ~= current_version then + if ver.version <= cutoff_version and ver.version ~= current_version then table.insert(to_archive, ver) end end diff --git a/modules/versioning.lua b/modules/versioning.lua index 084c5b2..de9399b 100644 --- a/modules/versioning.lua +++ b/modules/versioning.lua @@ -166,7 +166,7 @@ function versioning.create_new_version_safe() local resolved_path, resolved_name = handle_version_conflict(new_folder_path, new_folder_name, info, next_version) if not resolved_path then log_message(" ⚠️ Operation cancelled by user") - return false, "Operation cancelled" + return true, "Operation cancelled" end new_folder_path = resolved_path new_folder_name = resolved_name From 784095e2cdcaacfa5b1ad1f7a3f42b906fea7321 Mon Sep 17 00:00:00 2001 From: Jesse Lahtela Date: Sun, 15 Mar 2026 16:44:34 +0200 Subject: [PATCH 15/15] Fix outdated docs: roadmap, version format, CLAUDE.md architecture - README: remove _v002_a alongside example (feature was removed) - README: remove duplicate Auto Mode bullet point - README: move local archiving to v0.2 (already shipped), update v0.3 to Backblaze B2 cloud archiving - CLAUDE.md: update file_operations.lua description (Linux-only, no robocopy) - CLAUDE.md: fix Main_SaveProjectEx flag reference (2 -> 3) Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 6 +++--- README.md | 13 ++++--------- 2 files changed, 7 insertions(+), 12 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7cba6c4..e35266b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,7 +16,7 @@ modules/ config.lua # Settings persistence via Reaper's ExtState API gui.lua # Dockable UI using Reaper's gfx rendering API versioning.lua # Version number parsing, next-version calculation, file copying - file_operations.lua # Cross-platform file/directory ops (robocopy on Windows, cp on Unix) + file_operations.lua # File/directory ops for Linux (cp, rm via os.execute) archiving.lua # Find/move old versioned folders to archive destination ``` @@ -27,10 +27,10 @@ modules/ ## Key Design Patterns - **Main loop**: Reaper plugins use `reaper.defer()` for a non-blocking event loop. The loop is in `RASP.lua` and re-registers itself each cycle. -- **Cross-platform file ops**: `file_operations.lua` provides a unified API. Windows uses `robocopy /E /NFL /NDL`; Linux/macOS uses `cp -r`. Always go through this module for file/directory operations. +- **File ops (Linux only)**: `file_operations.lua` provides a unified API using `cp -r` and `rm -rf` via `os.execute`. Windows is not supported — archiving requires shell commands unavailable on Windows. Always go through this module for file/directory operations. - **Two versioning modes**: - *Native*: Opens Reaper's built-in Save As dialog (user controls the path). - - *Auto*: Fully automated — calls `reaper.Main_SaveProjectEx(0, path, 2)` which saves the `.rpp`, copies all media into the new directory, and rewrites internal path references atomically. This is equivalent to Save As with "Copy all media into project directory" ticked. Do NOT replace this with manual file copying (`robocopy`/`cp`) — that approach cannot rewrite `.rpp` internal references and will produce a broken project. + - *Auto*: Fully automated — calls `reaper.Main_SaveProjectEx(0, path, 3)` (flag 3 = flag 1 "create subdirectory" + flag 2 "copy all media") which saves the `.rpp`, copies all media into the new directory, and rewrites internal path references atomically. This is equivalent to Save As with "Copy all media into project directory" ticked. Do NOT replace this with manual file copying (`cp`) — that approach cannot rewrite `.rpp` internal references and will produce a broken project. - **Safety**: Auto mode verifies the `.rpp` file exists after saving. Archiving never touches the currently open version. Destructive operations always show a confirmation dialog. - **Version folder naming**: Projects are versioned by suffix on the folder name using a configurable prefix (default `_v`), digit count (default 3), and start number (default 1), e.g. `MyProject_v001/`. diff --git a/README.md b/README.md index 36f3b69..23c56af 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,6 @@ See [installation guide](docs/installation.md) for detailed instructions. MyProject/MyProject.rpp → Original MyProject_v001/MyProject_v001.rpp → Version 1 MyProject_v002/MyProject_v002.rpp → Version 2 -MyProject_v002_a/MyProject_v002_a.rpp → Version 2 (alongside) ``` --- @@ -93,7 +92,6 @@ Fully automated versioning that guarantees all media files are copied. **When to use:** - You want fast, reliable versioning - You want to ensure no media references break -- You want to ensure no media references break **How it works:** 1. Click "Create New Version" @@ -153,16 +151,13 @@ This ensures your versioned projects are **100% self-contained** and portable. ### Version 0.2 ✅ - Safe versioning with full media copy - Native/Auto mode selection -- Conflict handling (alongside/overwrite/cancel) +- Conflict handling (increment/overwrite/cancel) - Console logging with detailed feedback +- Local archiving: move old versions to a configurable archive destination +- Configurable "versions to keep" setting (default 3) ### Version 0.3 (planned) -- Archiving action of projects to local drive -- UI for archiving -- Select how many versions are kept and rest are archived - -### Version 0.4 (planned) -- Make archiving action of projects to Backblaze B2 +- Cloud archiving via Backblaze B2 - Able to pull back project from archive from Backblaze B2 ### Future