cabriolet 0.1.2 → 0.2.1

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.
Files changed (102) hide show
  1. checksums.yaml +4 -4
  2. data/README.adoc +703 -38
  3. data/lib/cabriolet/algorithm_factory.rb +250 -0
  4. data/lib/cabriolet/base_compressor.rb +206 -0
  5. data/lib/cabriolet/binary/bitstream.rb +167 -16
  6. data/lib/cabriolet/binary/bitstream_writer.rb +150 -21
  7. data/lib/cabriolet/binary/chm_structures.rb +2 -2
  8. data/lib/cabriolet/binary/hlp_structures.rb +258 -37
  9. data/lib/cabriolet/binary/lit_structures.rb +231 -65
  10. data/lib/cabriolet/binary/oab_structures.rb +17 -1
  11. data/lib/cabriolet/cab/command_handler.rb +226 -0
  12. data/lib/cabriolet/cab/compressor.rb +108 -84
  13. data/lib/cabriolet/cab/decompressor.rb +16 -20
  14. data/lib/cabriolet/cab/extractor.rb +142 -66
  15. data/lib/cabriolet/cab/file_compression_work.rb +52 -0
  16. data/lib/cabriolet/cab/file_compression_worker.rb +89 -0
  17. data/lib/cabriolet/checksum.rb +49 -0
  18. data/lib/cabriolet/chm/command_handler.rb +227 -0
  19. data/lib/cabriolet/chm/compressor.rb +7 -3
  20. data/lib/cabriolet/chm/decompressor.rb +39 -21
  21. data/lib/cabriolet/chm/parser.rb +5 -2
  22. data/lib/cabriolet/cli/base_command_handler.rb +127 -0
  23. data/lib/cabriolet/cli/command_dispatcher.rb +140 -0
  24. data/lib/cabriolet/cli/command_registry.rb +83 -0
  25. data/lib/cabriolet/cli.rb +356 -607
  26. data/lib/cabriolet/collections/file_collection.rb +175 -0
  27. data/lib/cabriolet/compressors/base.rb +1 -1
  28. data/lib/cabriolet/compressors/lzx.rb +241 -54
  29. data/lib/cabriolet/compressors/mszip.rb +35 -3
  30. data/lib/cabriolet/compressors/quantum.rb +36 -95
  31. data/lib/cabriolet/decompressors/base.rb +1 -1
  32. data/lib/cabriolet/decompressors/lzss.rb +13 -3
  33. data/lib/cabriolet/decompressors/lzx.rb +70 -33
  34. data/lib/cabriolet/decompressors/mszip.rb +126 -39
  35. data/lib/cabriolet/decompressors/quantum.rb +83 -53
  36. data/lib/cabriolet/errors.rb +3 -0
  37. data/lib/cabriolet/extraction/base_extractor.rb +88 -0
  38. data/lib/cabriolet/extraction/extractor.rb +171 -0
  39. data/lib/cabriolet/extraction/file_extraction_work.rb +60 -0
  40. data/lib/cabriolet/extraction/file_extraction_worker.rb +106 -0
  41. data/lib/cabriolet/file_entry.rb +156 -0
  42. data/lib/cabriolet/file_manager.rb +144 -0
  43. data/lib/cabriolet/format_base.rb +79 -0
  44. data/lib/cabriolet/hlp/command_handler.rb +282 -0
  45. data/lib/cabriolet/hlp/compressor.rb +28 -238
  46. data/lib/cabriolet/hlp/decompressor.rb +107 -147
  47. data/lib/cabriolet/hlp/parser.rb +52 -101
  48. data/lib/cabriolet/hlp/quickhelp/compression_stream.rb +138 -0
  49. data/lib/cabriolet/hlp/quickhelp/compressor.rb +151 -0
  50. data/lib/cabriolet/hlp/quickhelp/decompressor.rb +558 -0
  51. data/lib/cabriolet/hlp/quickhelp/file_writer.rb +125 -0
  52. data/lib/cabriolet/hlp/quickhelp/huffman_stream.rb +74 -0
  53. data/lib/cabriolet/hlp/quickhelp/huffman_tree.rb +167 -0
  54. data/lib/cabriolet/hlp/quickhelp/offset_calculator.rb +61 -0
  55. data/lib/cabriolet/hlp/quickhelp/parser.rb +274 -0
  56. data/lib/cabriolet/hlp/quickhelp/structure_builder.rb +93 -0
  57. data/lib/cabriolet/hlp/quickhelp/topic_builder.rb +52 -0
  58. data/lib/cabriolet/hlp/quickhelp/topic_compressor.rb +83 -0
  59. data/lib/cabriolet/hlp/winhelp/btree_builder.rb +289 -0
  60. data/lib/cabriolet/hlp/winhelp/compressor.rb +400 -0
  61. data/lib/cabriolet/hlp/winhelp/decompressor.rb +192 -0
  62. data/lib/cabriolet/hlp/winhelp/parser.rb +484 -0
  63. data/lib/cabriolet/hlp/winhelp/zeck_lz77.rb +271 -0
  64. data/lib/cabriolet/huffman/encoder.rb +15 -12
  65. data/lib/cabriolet/huffman/tree.rb +85 -1
  66. data/lib/cabriolet/kwaj/command_handler.rb +213 -0
  67. data/lib/cabriolet/kwaj/compressor.rb +7 -3
  68. data/lib/cabriolet/kwaj/decompressor.rb +18 -12
  69. data/lib/cabriolet/lit/command_handler.rb +221 -0
  70. data/lib/cabriolet/lit/compressor.rb +119 -168
  71. data/lib/cabriolet/lit/content_encoder.rb +76 -0
  72. data/lib/cabriolet/lit/content_type_detector.rb +50 -0
  73. data/lib/cabriolet/lit/decompressor.rb +518 -152
  74. data/lib/cabriolet/lit/directory_builder.rb +153 -0
  75. data/lib/cabriolet/lit/guid_generator.rb +16 -0
  76. data/lib/cabriolet/lit/header_writer.rb +124 -0
  77. data/lib/cabriolet/lit/parser.rb +670 -0
  78. data/lib/cabriolet/lit/piece_builder.rb +74 -0
  79. data/lib/cabriolet/lit/structure_builder.rb +252 -0
  80. data/lib/cabriolet/models/hlp_file.rb +130 -29
  81. data/lib/cabriolet/models/hlp_header.rb +105 -17
  82. data/lib/cabriolet/models/lit_header.rb +212 -25
  83. data/lib/cabriolet/models/szdd_header.rb +10 -2
  84. data/lib/cabriolet/models/winhelp_header.rb +127 -0
  85. data/lib/cabriolet/oab/command_handler.rb +257 -0
  86. data/lib/cabriolet/oab/compressor.rb +17 -8
  87. data/lib/cabriolet/oab/decompressor.rb +41 -10
  88. data/lib/cabriolet/offset_calculator.rb +81 -0
  89. data/lib/cabriolet/plugin.rb +233 -0
  90. data/lib/cabriolet/plugin_manager.rb +453 -0
  91. data/lib/cabriolet/plugin_validator.rb +422 -0
  92. data/lib/cabriolet/quantum_shared.rb +105 -0
  93. data/lib/cabriolet/system/io_system.rb +3 -0
  94. data/lib/cabriolet/system/memory_handle.rb +17 -4
  95. data/lib/cabriolet/szdd/command_handler.rb +217 -0
  96. data/lib/cabriolet/szdd/compressor.rb +15 -11
  97. data/lib/cabriolet/szdd/decompressor.rb +18 -9
  98. data/lib/cabriolet/version.rb +1 -1
  99. data/lib/cabriolet.rb +181 -20
  100. metadata +69 -4
  101. data/lib/cabriolet/auto.rb +0 -173
  102. data/lib/cabriolet/parallel.rb +0 -333
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fractor"
4
+
5
+ module Cabriolet
6
+ module Extraction
7
+ # Work item for file extraction using Fractor
8
+ class FileExtractionWork < Fractor::Work
9
+ # Initialize work item for extracting a single file
10
+ #
11
+ # @param file [Object] File object from archive (responds to :name, :data)
12
+ # @param output_dir [String] Output directory path
13
+ # @param preserve_paths [Boolean] Whether to preserve directory structure
14
+ # @param overwrite [Boolean] Whether to overwrite existing files
15
+ def initialize(file, output_dir:, preserve_paths: true, overwrite: false)
16
+ super({
17
+ file: file,
18
+ output_dir: output_dir,
19
+ preserve_paths: preserve_paths,
20
+ overwrite: overwrite,
21
+ })
22
+ end
23
+
24
+ # The file object to extract
25
+ #
26
+ # @return [Object] File from archive
27
+ def file
28
+ input[:file]
29
+ end
30
+
31
+ # Output directory for extraction
32
+ #
33
+ # @return [String] Directory path
34
+ def output_dir
35
+ input[:output_dir]
36
+ end
37
+
38
+ # Whether to preserve directory structure
39
+ #
40
+ # @return [Boolean]
41
+ def preserve_paths
42
+ input[:preserve_paths]
43
+ end
44
+
45
+ # Whether to overwrite existing files
46
+ #
47
+ # @return [Boolean]
48
+ def overwrite
49
+ input[:overwrite]
50
+ end
51
+
52
+ # Unique identifier for this work item (filename based)
53
+ #
54
+ # @return [String] Unique identifier
55
+ def id
56
+ file.name
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Cabriolet
6
+ module Extraction
7
+ # Worker for extracting files using Fractor
8
+ class FileExtractionWorker < Fractor::Worker
9
+ # Process a file extraction work item
10
+ #
11
+ # @param work [FileExtractionWork] Work item to process
12
+ # @return [Fractor::WorkResult] Result of extraction
13
+ def process(work)
14
+ output_path = build_output_path(work)
15
+
16
+ # Check if file exists and skip if not overwriting
17
+ if ::File.exist?(output_path) && !work.overwrite
18
+ return skipped_result(work, "File already exists")
19
+ end
20
+
21
+ # Create parent directory
22
+ dir = ::File.dirname(output_path)
23
+ FileUtils.mkdir_p(dir) unless ::File.directory?(dir)
24
+
25
+ # Get file data
26
+ data = work.file.data
27
+ unless data
28
+ return skipped_result(work, "No data available")
29
+ end
30
+
31
+ # Write file data
32
+ ::File.binwrite(output_path, data)
33
+
34
+ # Preserve file attributes if available
35
+ preserve_file_attributes(output_path, work.file)
36
+
37
+ # Return success result
38
+ Fractor::WorkResult.new(
39
+ result: {
40
+ path: output_path,
41
+ size: data.bytesize,
42
+ name: work.file.name,
43
+ },
44
+ work: work,
45
+ )
46
+ rescue StandardError => e
47
+ # Return error result
48
+ Fractor::WorkResult.new(
49
+ error: {
50
+ message: e.message,
51
+ class: e.class.name,
52
+ backtrace: e.backtrace.first(5),
53
+ },
54
+ work: work,
55
+ )
56
+ end
57
+
58
+ private
59
+
60
+ # Build the output path for a file
61
+ #
62
+ # @param work [FileExtractionWork] Work item containing file and options
63
+ # @return [String] Full output path
64
+ def build_output_path(work)
65
+ # Normalize path separators (Windows archives use backslashes)
66
+ clean_name = work.file.name.gsub("\\", "/")
67
+
68
+ if work.preserve_paths
69
+ ::File.join(work.output_dir, clean_name)
70
+ else
71
+ ::File.join(work.output_dir, ::File.basename(clean_name))
72
+ end
73
+ end
74
+
75
+ # Preserve file attributes (timestamps, etc.)
76
+ #
77
+ # @param path [String] Path to extracted file
78
+ # @param file [Object] File object from archive
79
+ def preserve_file_attributes(path, file)
80
+ # Try various timestamp attributes that different formats use
81
+ if file.respond_to?(:datetime) && file.datetime
82
+ ::File.utime(::File.atime(path), file.datetime, path)
83
+ elsif file.respond_to?(:mtime) && file.mtime
84
+ atime = file.respond_to?(:atime) ? file.atime : ::File.atime(path)
85
+ ::File.utime(atime, file.mtime, path)
86
+ end
87
+ end
88
+
89
+ # Create a skipped result
90
+ #
91
+ # @param work [FileExtractionWork] Work item that was skipped
92
+ # @param reason [String] Reason for skipping
93
+ # @return [Fractor::WorkResult] Skipped result
94
+ def skipped_result(work, reason)
95
+ Fractor::WorkResult.new(
96
+ result: {
97
+ status: :skipped,
98
+ name: work.file.name,
99
+ reason: reason,
100
+ },
101
+ work: work,
102
+ )
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cabriolet
4
+ # Represents a file to be added to an archive
5
+ #
6
+ # Single responsibility: Encapsulate file metadata and data access.
7
+ # Supports both disk files and memory data, providing unified interface
8
+ # for file operations across all format compressors.
9
+ #
10
+ # @example Adding a disk file
11
+ # entry = FileEntry.new(
12
+ # source: "/path/to/file.txt",
13
+ # archive_path: "docs/file.txt"
14
+ # )
15
+ #
16
+ # @example Adding memory data
17
+ # entry = FileEntry.new(
18
+ # data: "Hello, World!",
19
+ # archive_path: "greeting.txt"
20
+ # )
21
+ class FileEntry
22
+ attr_reader :source_path, :archive_path, :data, :options
23
+
24
+ # Initialize a file entry
25
+ #
26
+ # @param source [String, nil] Path to source file on disk
27
+ # @param data [String, nil] File data in memory
28
+ # @param archive_path [String] Path within the archive
29
+ # @param options [Hash] Format-specific options
30
+ # @raise [ArgumentError] if validation fails
31
+ def initialize(archive_path:, source: nil, data: nil, **options)
32
+ @source_path = source
33
+ @data = data
34
+ @archive_path = archive_path
35
+ @options = options
36
+
37
+ validate!
38
+ end
39
+
40
+ # Check if file data is from disk
41
+ #
42
+ # @return [Boolean] true if file is on disk
43
+ def from_disk?
44
+ !@source_path.nil?
45
+ end
46
+
47
+ # Check if file data is in memory
48
+ #
49
+ # @return [Boolean] true if data is in memory
50
+ def from_memory?
51
+ !@data.nil?
52
+ end
53
+
54
+ # Read file data (from disk or memory)
55
+ #
56
+ # @return [String] File contents
57
+ def read_data
58
+ return @data if from_memory?
59
+
60
+ File.binread(@source_path)
61
+ end
62
+
63
+ # Get file size
64
+ #
65
+ # @return [Integer] File size in bytes
66
+ def size
67
+ return @data.bytesize if from_memory?
68
+
69
+ File.size(@source_path)
70
+ end
71
+
72
+ # Get file stat (disk files only)
73
+ #
74
+ # @return [File::Stat, nil] File stat or nil for memory files
75
+ def stat
76
+ return nil if from_memory?
77
+
78
+ File.stat(@source_path)
79
+ end
80
+
81
+ # Get modification time
82
+ #
83
+ # @return [Time] Modification time (current time for memory files)
84
+ def mtime
85
+ return Time.now if from_memory?
86
+
87
+ stat&.mtime || Time.now
88
+ end
89
+
90
+ # Get file attributes
91
+ #
92
+ # @return [Integer] File attributes flags
93
+ def attributes
94
+ return @options[:attributes] if @options[:attributes]
95
+ return Constants::ATTRIB_ARCH if from_memory?
96
+
97
+ calculate_disk_attributes
98
+ end
99
+
100
+ # Get compression flag from options
101
+ #
102
+ # @return [Boolean] Whether to compress this file
103
+ def compress?
104
+ @options.fetch(:compress, true)
105
+ end
106
+
107
+ private
108
+
109
+ # Validate entry parameters
110
+ #
111
+ # @raise [ArgumentError] if invalid
112
+ def validate!
113
+ if @source_path.nil? && @data.nil?
114
+ raise ArgumentError,
115
+ "Must provide either source or data"
116
+ end
117
+
118
+ if @source_path && @data
119
+ raise ArgumentError,
120
+ "Cannot provide both source and data"
121
+ end
122
+
123
+ if @source_path
124
+ unless File.exist?(@source_path)
125
+ raise ArgumentError,
126
+ "File not found: #{@source_path}"
127
+ end
128
+
129
+ unless File.file?(@source_path)
130
+ raise ArgumentError,
131
+ "Not a file: #{@source_path}"
132
+ end
133
+ end
134
+
135
+ raise ArgumentError, "Archive path required" if @archive_path.nil?
136
+ end
137
+
138
+ # Calculate attributes from disk file stat
139
+ #
140
+ # @return [Integer] Attribute flags
141
+ def calculate_disk_attributes
142
+ file_stat = stat
143
+ return Constants::ATTRIB_ARCH unless file_stat
144
+
145
+ attribs = Constants::ATTRIB_ARCH
146
+
147
+ # Read-only flag
148
+ attribs |= Constants::ATTRIB_READONLY unless file_stat.writable?
149
+
150
+ # Executable flag (Unix systems)
151
+ attribs |= Constants::ATTRIB_EXEC if file_stat.executable?
152
+
153
+ attribs
154
+ end
155
+ end
156
+ end
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "file_entry"
4
+
5
+ module Cabriolet
6
+ # Manages collection of files for archive creation
7
+ #
8
+ # Single responsibility: File list management and enumeration.
9
+ # Provides unified interface for adding files from disk or memory,
10
+ # and supports standard Ruby enumeration patterns.
11
+ #
12
+ # @example Basic usage
13
+ # manager = FileManager.new
14
+ # manager.add_file("/path/to/file.txt", "docs/file.txt")
15
+ # manager.add_data("Hello", "greeting.txt")
16
+ # manager.each { |entry| puts entry.archive_path }
17
+ class FileManager
18
+ include Enumerable
19
+
20
+ # Initialize empty file manager
21
+ def initialize
22
+ @entries = []
23
+ end
24
+
25
+ # Add file from disk
26
+ #
27
+ # @param source_path [String] Path to source file
28
+ # @param archive_path [String, nil] Path in archive (nil = use basename)
29
+ # @param options [Hash] Format-specific options
30
+ # @return [FileEntry] Added entry
31
+ # @raise [ArgumentError] if file doesn't exist
32
+ def add_file(source_path, archive_path = nil, **options)
33
+ archive_path ||= File.basename(source_path)
34
+
35
+ entry = FileEntry.new(
36
+ source: source_path,
37
+ archive_path: archive_path,
38
+ **options,
39
+ )
40
+
41
+ @entries << entry
42
+ entry
43
+ end
44
+
45
+ # Add file from memory
46
+ #
47
+ # @param data [String] File data
48
+ # @param archive_path [String] Path in archive
49
+ # @param options [Hash] Format-specific options
50
+ # @return [FileEntry] Added entry
51
+ def add_data(data, archive_path, **options)
52
+ entry = FileEntry.new(
53
+ data: data,
54
+ archive_path: archive_path,
55
+ **options,
56
+ )
57
+
58
+ @entries << entry
59
+ entry
60
+ end
61
+
62
+ # Enumerate entries (Enumerable interface)
63
+ #
64
+ # @yield [FileEntry] Each file entry
65
+ def each(&)
66
+ @entries.each(&)
67
+ end
68
+
69
+ # Check if empty
70
+ #
71
+ # @return [Boolean] true if no files added
72
+ def empty?
73
+ @entries.empty?
74
+ end
75
+
76
+ # Get count of entries
77
+ #
78
+ # @return [Integer] Number of entries
79
+ def size
80
+ @entries.size
81
+ end
82
+ alias count size
83
+
84
+ # Get entry by index
85
+ #
86
+ # @param index [Integer] Entry index
87
+ # @return [FileEntry, nil] Entry or nil if out of bounds
88
+ def [](index)
89
+ @entries[index]
90
+ end
91
+
92
+ # Get all entries
93
+ #
94
+ # @return [Array<FileEntry>] Copy of entries array
95
+ def all
96
+ @entries.dup
97
+ end
98
+
99
+ # Clear all entries
100
+ #
101
+ # @return [self]
102
+ def clear
103
+ @entries.clear
104
+ self
105
+ end
106
+
107
+ # Calculate total size of all files
108
+ #
109
+ # @return [Integer] Total size in bytes
110
+ def total_size
111
+ @entries.sum(&:size)
112
+ end
113
+
114
+ # Get files from disk
115
+ #
116
+ # @return [Array<FileEntry>] Disk-based entries
117
+ def disk_files
118
+ @entries.select(&:from_disk?)
119
+ end
120
+
121
+ # Get files from memory
122
+ #
123
+ # @return [Array<FileEntry>] Memory-based entries
124
+ def memory_files
125
+ @entries.select(&:from_memory?)
126
+ end
127
+
128
+ # Find entry by archive path
129
+ #
130
+ # @param path [String] Archive path to find
131
+ # @return [FileEntry, nil] Entry or nil if not found
132
+ def find_by_path(path)
133
+ @entries.find { |entry| entry.archive_path == path }
134
+ end
135
+
136
+ # Check if archive path exists
137
+ #
138
+ # @param path [String] Archive path to check
139
+ # @return [Boolean] true if path exists
140
+ def path_exists?(path)
141
+ !find_by_path(path).nil?
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cabriolet
4
+ # FormatBase provides common functionality for all format-specific compressors
5
+ # and decompressors, reducing code duplication and establishing consistent patterns.
6
+ class FormatBase
7
+ # Initialize a format handler with common dependencies
8
+ #
9
+ # @param io_system [System::IOSystem, nil] I/O system for file operations
10
+ # @param algorithm_factory [AlgorithmFactory, nil] Factory for compression algorithms
11
+ def initialize(io_system = nil, algorithm_factory = nil)
12
+ @io_system = io_system || System::IOSystem.new
13
+ @algorithm_factory = algorithm_factory || Cabriolet.algorithm_factory
14
+ end
15
+
16
+ protected
17
+
18
+ # Execute a block with file handles, automatically closing them after completion
19
+ #
20
+ # @param input_path [String] Path to input file
21
+ # @param output_path [String, nil] Path to output file (optional)
22
+ # @yield [Array<System::FileHandle>] File handles for input and output
23
+ # @return [Object] Return value of the block
24
+ def with_file_handles(input_path, output_path = nil)
25
+ input_handle = @io_system.open(input_path, Constants::MODE_READ)
26
+ output_handle = if output_path
27
+ @io_system.open(output_path,
28
+ Constants::MODE_WRITE)
29
+ end
30
+
31
+ begin
32
+ yield [input_handle, output_handle].compact
33
+ ensure
34
+ @io_system.close(input_handle) if input_handle
35
+ @io_system.close(output_handle) if output_handle
36
+ end
37
+ end
38
+
39
+ # Create a compressor using the algorithm factory
40
+ #
41
+ # @param algorithm [Symbol] Compression algorithm type
42
+ # @param input [System::FileHandle, System::MemoryHandle] Input handle
43
+ # @param output [System::FileHandle, System::MemoryHandle] Output handle
44
+ # @param size [Integer] Data size
45
+ # @param options [Hash] Additional options for the compressor
46
+ # @return [Object] Compressor instance
47
+ def create_compressor(algorithm, input, output, size, **options)
48
+ @algorithm_factory.create(
49
+ algorithm,
50
+ :compressor,
51
+ @io_system,
52
+ input,
53
+ output,
54
+ size,
55
+ **options,
56
+ )
57
+ end
58
+
59
+ # Create a decompressor using the algorithm factory
60
+ #
61
+ # @param algorithm [Symbol] Compression algorithm type
62
+ # @param input [System::FileHandle, System::MemoryHandle] Input handle
63
+ # @param output [System::FileHandle, System::MemoryHandle] Output handle
64
+ # @param size [Integer] Data size
65
+ # @param options [Hash] Additional options for the decompressor
66
+ # @return [Object] Decompressor instance
67
+ def create_decompressor(algorithm, input, output, size, **options)
68
+ @algorithm_factory.create(
69
+ algorithm,
70
+ :decompressor,
71
+ @io_system,
72
+ input,
73
+ output,
74
+ size,
75
+ **options,
76
+ )
77
+ end
78
+ end
79
+ end