# CutBox CLI

## Coverage Report

#### Created: 2024-03-12 03:43

    .../Sources/CutBoxCLICore/CommandParams.swift

    Line

    Count

    Source

    1

    import Foundation

    2

    3

    107

    func usageInfo() -> String {

    4

    107

        return """

    5

    107

        CutBox history CLI

    6

    107

        ==================

    7

    107

    8

    107

        Display items from CutBox history. Most recent items first.

    9

    107

    10

    107

            cutbox [options]

    11

    107

    12

    107

        Options:

    13

    107

        ========

    14

    107

    15

    107

        Search

    16

    107

        ------

    17

    107

    18

    107

            -f or --fuzzy <query>   Fuzzy match items (case insensitive)

    19

    107

            -r or --regex <query>   Regexp match items

    20

    107

            -i or --regexi <query>  Regexp match items (case insensitive)

    21

    107

            -e or --exact <string>  Exact substring match items (case sensitive)

    22

    107

    23

    107

        Filtering

    24

    107

        ---------

    25

    107

    26

    107

            -l or --limit <num>     Limit to num items

    27

    107

            -F or --favorites       Only list favorites

    28

    107

            -M or --missing-date    Only list items missing a date (copied pre CutBox v1.5.5)

    29

    107

    30

    107

        Filter by time units e.g. 7d, 1min, 5hr, 30s, 25sec, 3days, 2wks, 1.5hours, etc.

    31

    107

        Supports seconds, minutes, hours, days, weeks.

    32

    107

    33

    107

            --since <time>

    34

    107

            --before <time>

    35

    107

    36

    107

        Filter by ISO 8601 date e.g. 2023-06-05T09:21:59Z

    37

    107

    38

    107

            --since-date <date>

    39

    107

            --before-date <date>

    40

    107

    41

    107

        Info

    42

    107

        ----

    43

    107

    44

    107

            --version               Show the current version

    45

    107

            -h or --help            Show this help page

    46

    107

        """

    47

    107

    }

    48

    49

    /// Manages command parameters

    50

    ///

    51

    /// After parsing these properties are available to other objects:

    52

    /// - query - search text/pattern

    53

    /// - searchMode - fuzzy, regex, regexi, exact

    54

    /// - beforeDate - limit search to items before date

    55

    /// - sinceDate - limit search to items since date

    56

    /// - favorites - limit search to favorites

    57

    /// - limit - limit search by num results printed

    58

    /// - missingDate - a check for items saved ≤ v1.5.8

    59

    ///

    60

    /// The parser removes valid arguments, remaining arguments are

    61

    /// collected as errors.

    62

    class CommandParams {

    63

        var query: String?

    64

        var limit: Int?

    65

        var beforeDate: TimeInterval?

    66

        var sinceDate: TimeInterval?

    67

        var searchMode: SearchMode?

    68

        var missingDate: Bool = false

    69

        var favorites: Bool = false

    70

        var showTime: Bool = false

    71

    106

        var errors: [(String, String)] = []

    72

        var arguments: [String]

    73

        var infoFlags: Bool = false

    74

    75

        private var out: Output

    76

    77

        /// Initialize with `Output` & arguments.

    78

        /// Parsing begins automatically at init.

    79

    106

        init(out: Output, arguments: [String]) {

    80

    106

            self.out = out

    81

    106

            self.arguments = arguments

    82

    106

            parse()

    83

    106

        }

    84

    85

        /// Check for a flag in arguments

    86

    1.25k

        func hasFlag(_ flag: String) -> Bool {

    87

    1.25k

            if self.arguments.contains(flag) {

    88

    7

                removeFlagFromArguments(flag)

    89

    7

                return true

    90

    1.25k

            }

    91

    1.25k

            return false

    92

    1.25k

        }

    93

    94

        /// Check for array of flags in arguments (variadic)

    95

    315

        func hasFlag(_ flags: String...) -> Bool {

    96

    315

            return hasFlag(flags)

    97

    315

        }

    98

    99

        /// Check for array of flags in arguments (array)

    100

    526

        func hasFlag(_ flags: [String]) -> Bool {

    101

    1.78k

            return flags.contains(where: hasFlag)

    .../$s13CutBoxCLICore13CommandParamsC7hasFlagySbSaySSGFSbSScACcfu_

    Line

    Count

    Source

    101

    526

            return flags.contains(where: hasFlag)

    .../$s13CutBoxCLICore13CommandParamsC7hasFlagySbSaySSGFSbSScACcfu_SbSScfu0_

    Line

    Count

    Source

    101

    1.25k

            return flags.contains(where: hasFlag)

    102

    526

        }

    103

    104

        /// Remove a flag from arguments

    105

        ///

    106

        /// Arguments remaining after parse are treated as errors.

    107

    7

        func removeFlagFromArguments(_ arg: String) {

    108

    7

            if let argIndex = self.arguments.firstIndex(of: arg) {

    109

    7

                self.arguments.remove(at: argIndex)

    110

    7

            }

    111

    7

        }

    112

    113

        /// Remove an argument and value by arg name

    114

        ///

    115

        /// Arguments remaining after parse are treated as errors.

    116

    25

        func removeOptionWithValueFromArguments<T>(_ arg: String, _ value: T?) {

    117

    25

            if let argIndex = self.arguments.firstIndex(of: arg) {

    118

    25

                self.arguments.remove(at: argIndex)

    119

    25

                if let value = value, let valueIndex = self.arguments

    120

    25

                    .firstIndex(of: String(describing: value)) {

    121

    25

                    self.arguments.remove(at: valueIndex)

    122

    25

                }

    123

    25

            }

    124

    25

        }

    125

    126

        /// Print all errors

    127

    7

        func printErrors() {

    128

    17

            errors.forEach(printError)

    .../$s13CutBoxCLICore13CommandParamsC11printErrorsyyFySS_SSt_tcACcfu_

    Line

    Count

    Source

    128

    7

            errors.forEach(printError)

    .../$s13CutBoxCLICore13CommandParamsC11printErrorsyyFySS_SSt_tcACcfu_ySS_SSt_tcfu0_

    Line

    Count

    Source

    128

    10

            errors.forEach(printError)

    129

    7

        }

    130

    131

        /// Print an error

    132

    10

        func printError(error: (String, String)) {

    133

    10

            out.error("Invalid argument: \(error.0) \(error.1)")

    134

    10

        }

    135

    136

        /// Check arguments for options (variadic)

    137

        ///

    138

        /// Parse option(s) (variadic) for their paired value and removed

    139

        /// from arguments. The value of type T is returned when T is

    140

        /// String, Int or Double.

    141

        ///

    142

        /// - Warning: **Fatal assertion** thrown if T is an unsupported type.

    143

    1.37k

        func hasOption<T>(_ options: String...) -> T? {

    144

    1.37k

            let args = self.arguments

    145

    1.37k

            guard let index = args.firstIndex(where: { options.contains($0) }),

    146

    1.37k

                  let valueIndex = args.index(index, offsetBy: 1, limitedBy: args.endIndex),

    147

    1.37k

                  !args[valueIndex].starts(with: "-") else {

    148

    1.34k

                return nil

    149

    1.34k

            }

    150

    26

    151

    26

            let value = args[valueIndex]

    152

    26

    153

    26

            switch T.self {

    154

    26

            case is String.Type:

    155

    20

                removeOptionWithValueFromArguments(args[index], value)

    156

    20

                return value as? T

    157

    26

            case is Int.Type:

    158

    4

                removeOptionWithValueFromArguments(args[index], value)

    159

    4

                return Int(value) as? T

    160

    26

            case is Double.Type:

    161

    1

                removeOptionWithValueFromArguments(args[index], value)

    162

    1

                return Double(value) as? T

    163

    26

            default:

    164

    1

                fatalError("hasOpt only supports T.Type of String?, Double? or Int? ")

    165

    26

            }

    166

    26

        }

    167

    168

        /// Collect an error, add it to errors.

    169

    16

        func collectError(_ option: String, _ value: Any, description: String = "") {

    170

    16

            errors.append((option, String(describing: value)))

    171

    16

        }

    172

    173

        /// Collect all errors from arguments.

    174

        ///

    175

        /// Note when this is called, all arguments supplied are considered errors.

    176

    107

        func collectErrors(_ arguments: [String]) {

    177

    107

            var currentOption: String?

    178

    107

            var currentValue: String = ""

    179

    107

    180

    107

            for arg in arguments {

    181

    18

                if arg.hasPrefix("-") {

    182

    12

                    if let opt = currentOption {

    183

    6

                        collectError(opt, currentValue)

    184

    12

                    }

    185

    12

                    currentOption = arg

    186

    12

                    currentValue = ""

    187

    18

                } else {

    188

    6

                    currentValue += currentValue.isEmpty ? arg : " \(arg)"

    189

    18

                }

    190

    107

            }

    191

    107

    192

    107

            if let option = currentOption {

    193

    6

                collectError(option, currentValue)

    194

    107

            }

    195

    107

        }

    196

    197

        /// Calls timeOption using a given prefix, e.g. before, since.

    198

        /// It will check for `$option...`: `--$prefix-date` and `--$prefix`

    199

        ///

    200

        /// Returns `TimeInterval?` from `timeOption($option)`

    201

        ///

    202

        /// See: `timeOption(_ options: String) -> TimeInterval?`

    203

    210

        func parseTimeOptions(_ prefix: String) -> TimeInterval? {

    204

    210

            let timeOptionSuffixes = ["", "-date"]

    205

    210

            return timeOptionSuffixes

    206

    420

                .map { "\(prefix)\($0)" }

    207

    420

                .compactMap { timeOption($0) }

    208

    210

                .first

    209

    210

        }

    210

    211

        /// Check for the presence of a time `option` in arguments

    212

        /// When found retrieves the value as `TimeInterval`

    213

        ///

    214

        /// See: `parseToSeconds(_ time: String)`

    215

        ///

    216

        /// Return:`TimeInterval?` (typealias of `Double?`)

    217

    422

        func timeOption(_ option: String) -> TimeInterval? {

    218

    422

            if let value: String = hasOption(option) {

    219

    12

                switch option {

    220

    12

                case _ where NSString(string: option).contains("date"):

    221

    3

                    if let date = iso8601().date(from: value) {

    222

    1

                        return date.timeIntervalSince1970

    223

    2

                    } else {

    224

    2

                        collectError(option, value)

    225

    12

                    }

    226

    12

                case _ where parseToSeconds(value) != nil:

    227

    7

                    if let seconds = parseToSeconds(value) {

    228

    7

                        return Date().timeIntervalSince1970 - seconds

    229

    12

                    }

    230

    12

                default:

    231

    2

                    collectError(option, value)

    232

    12

                }

    233

    422

            }

    234

    422

            return nil

    235

    422

        }

    236

    237

        /// Collect numbers from a string

    238

    80

        func filterNums(_ string: String) -> Double? {

    239

    386

            return Double(string.filter { $0 == "." || $0 >= "0" && $0 <= "9" })

    240

    80

        }

    241

    242

        /// Provide a lookup table for time unit,

    243

        /// pattern/name & factor (seconds per unit)

    244

    106

        let timeUnitsTable = [

    245

    106

            (pattern: "m|minutes|min|mins|minute", factor: 60.0),

    246

    106

            (pattern: "h|hours|hr|hrs|hour", factor: 60.0 * 60.0),

    247

    106

            (pattern: "d|days|day", factor: 24.0 * 60.0 * 60.0),

    248

    106

            (pattern: "w|week|weeks|wk|wks", factor: 7.0 * 24.0 * 60.0 * 60.0),

    249

    106

            (pattern: "s|sec|secs|second|seconds", factor: 1.0)

    250

    106

        ]

    251

    252

        /// Parse time string to seconds, the form of time strings are

    253

        /// simple grammar of `"amount value"`

    254

        ///

    255

        /// - Example:

    256

        /// ```

    257

        /// parseToSeconds("1sec") == 1.0

    258

        /// parseToSeconds("1s") == 1.0

    259

        /// parseToSeconds("1min") == 60.0

    260

        /// parseToSeconds("1hr") == 3600.0

    261

        /// ```

    262

        /// etc.

    263

        ///

    264

        /// - See: `timeUnitsTable` for possible unit names

    265

    80

        func parseToSeconds(_ time: String) -> Double? {

    266

    80

            if let seconds = filterNums(time) {

    267

    390

                return timeUnitsTable.compactMap { (unit: (pattern: String, factor: Double)) -> Double? in

    268

    390

                    if regexpMatch(time, unit.pattern, caseSensitive: false) {

    269

    100

                        return seconds * unit.factor

    270

    290

                    }

    271

    290

                    return nil

    272

    390

                }.first

    273

    78

            }

    274

    2

            return nil

    275

    80

        }

    276

    277

        /// Parse a query option, setting searchMode and removing quotes if needed.

    278

    420

        private func parseQueryOption(_ options: String..., mode: SearchMode, removeQuotes: Bool) {

    279

    839

            for option in options {

    280

    839

                if let rawQuery: String = hasOption(option) {

    281

    7

                    searchMode = mode

    282

    7

                    query = removeQuotes ? rawQuery.replacingOccurrences(of: "\"", with: "") : rawQuery

    283

    7

                    return

    284

    832

                }

    285

    832

            }

    286

    413

        }

    287

    288

        /// Parse all known arguments, collect any unparsed arguments as errors.

    289

        /// It will call exit when parsing help and or version flags.

    290

    106

        func parse() {

    291

    106

            let flagAndInfoPairs: [([String], String)] = [

    292

    106

                (args: ["-h", "--help"], string: usageInfo()),

    293

    106

                (args: ["--version"], string: version)

    294

    106

            ]

    295

    106

    296

    211

            for (args, string) in flagAndInfoPairs where hasFlag(args) {

    297

    211

                out.print(string)

    298

    211

                self.infoFlags = true

    299

    211

                return

    300

    18.4E

            }

    301

    18.4E

    302

    18.4E

            beforeDate = parseTimeOptions("--before")

    303

    18.4E

            sinceDate = parseTimeOptions("--since")

    304

    18.4E

            limit = hasOption("-l", "--limit")

    305

    18.4E

            favorites = hasFlag("-F", "--favorites", "--favorite")

    306

    18.4E

            missingDate = hasFlag("-M", "--missing-date", "--missing-time")

    307

    18.4E

            showTime = hasFlag("-T", "--show-time", "--show-date")

    308

    18.4E

    309

    18.4E

            parseQueryOption("-i", "--regexi", mode: .regexi, removeQuotes: true)

    310

    18.4E

            parseQueryOption("-r", "--regex", mode: .regex, removeQuotes: true)

    311

    18.4E

            parseQueryOption("-e", "--exact", mode: .exact, removeQuotes: false)

    312

    18.4E

            parseQueryOption("-f", "--fuzzy", mode: .fuzzy, removeQuotes: false)

    313

    18.4E

    314

    18.4E

            collectErrors(self.arguments)

    315

    18.4E

        }

    316

    }