├── .config
└── dotnet-tools.json
├── .github
├── dependabot.yml
└── workflows
│ └── build.yml
├── .gitignore
├── LICENSE
├── README.md
├── assets
├── logo-64x64.png
├── logo.png
└── terminal.gif
├── extLauncher.Tests
├── AppTests.fs
├── ConsoleTests.fs
├── DomainTests.fs
├── Program.fs
└── extLauncher.Tests.fsproj
├── extLauncher.sln
└── extLauncher
├── App.fs
├── Console.fs
├── Domain.fs
├── Infra.fs
├── Program.fs
└── extLauncher.fsproj
/.config/dotnet-tools.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": 1,
3 | "isRoot": true,
4 | "tools": {
5 | "extLauncher": {
6 | "version": "1.0.0",
7 | "commands": [
8 | "extLauncher"
9 | ]
10 | }
11 | }
12 | }
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | # Please see the documentation for all configuration options:
2 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
3 |
4 | version: 2
5 | updates:
6 | - package-ecosystem: "nuget" # See documentation for possible values
7 | directory: "/" # Location of package manifests
8 | schedule:
9 | interval: "daily"
10 |
--------------------------------------------------------------------------------
/.github/workflows/build.yml:
--------------------------------------------------------------------------------
1 | name: .NET Core
2 | on:
3 | push:
4 | pull_request:
5 | release:
6 | types:
7 | - published
8 | env:
9 | # Stop wasting time caching packages
10 | DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true
11 | # Disable sending usage data to Microsoft
12 | DOTNET_CLI_TELEMETRY_OPTOUT: true
13 | # Project name to pack and publish
14 | PROJECT_NAME: extLauncher
15 | # GitHub Packages Feed settings
16 | GITHUB_FEED: https://nuget.pkg.github.com/d-edge/
17 | GITHUB_USER: akhansari
18 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
19 | # Official NuGet Feed settings
20 | NUGET_FEED: https://api.nuget.org/v3/index.json
21 | NUGET_KEY: ${{ secrets.NUGET_KEY }}
22 | jobs:
23 | build:
24 | runs-on: ${{ matrix.os }}
25 | strategy:
26 | matrix:
27 | os: [ ubuntu-latest, windows-latest, macos-latest ]
28 | steps:
29 | - name: Checkout
30 | uses: actions/checkout@v2
31 | - name: Setup .NET Core
32 | uses: actions/setup-dotnet@v1
33 | with:
34 | dotnet-version: 6.0.100
35 | - name: Restore
36 | run: dotnet restore
37 | - name: Build
38 | run: dotnet build -c Release --no-restore
39 | - name: Test
40 | run: dotnet test -c Release
41 | - name: Strip HTML from README
42 | uses: d-edge/strip-markdown-html@v0.2
43 | with:
44 | input-path: README.md
45 | - name: Pack
46 | if: matrix.os == 'ubuntu-latest'
47 | run: |
48 | if [ "$GITHUB_REF_TYPE" = "tag" ]; then
49 | arrTag=(${GITHUB_REF//\// })
50 | version="${arrTag[2]}"
51 | echo Version: $version
52 | version="${version//v}"
53 | echo Clean Version: $version
54 | else
55 | git fetch --prune --unshallow --tags --quiet
56 | latestTag=$(git describe --tags --abbrev=0 2>/dev/null || echo 0.0.1)
57 | runId=$GITHUB_RUN_ID
58 | version="${latestTag//v}-build.${runId}"
59 | echo Non-release version: $version
60 | fi
61 | dotnet pack -v normal -c Release --no-restore --include-symbols --include-source -p:PackageVersion=$version $PROJECT_NAME/$PROJECT_NAME.*proj
62 | - name: Upload Artifact
63 | if: matrix.os == 'ubuntu-latest'
64 | uses: actions/upload-artifact@v2
65 | with:
66 | name: nupkg
67 | path: ./${{ env.PROJECT_NAME }}/nupkg/*.nupkg
68 | prerelease:
69 | needs: build
70 | if: github.ref == 'refs/heads/main'
71 | runs-on: ubuntu-latest
72 | steps:
73 | - name: Download Artifact
74 | uses: actions/download-artifact@v1
75 | with:
76 | name: nupkg
77 | - name: Push to GitHub Feed
78 | run: |
79 | for f in ./nupkg/*.nupkg
80 | do
81 | curl -vX PUT -u "$GITHUB_USER:$GITHUB_TOKEN" -F package=@$f $GITHUB_FEED
82 | done
83 | deploy:
84 | needs: build
85 | if: github.event_name == 'release'
86 | runs-on: ubuntu-latest
87 | steps:
88 | - uses: actions/checkout@v2
89 | - name: Setup .NET Core
90 | uses: actions/setup-dotnet@v1
91 | with:
92 | dotnet-version: 6.0.100
93 | - name: Download Artifact
94 | uses: actions/download-artifact@v1
95 | with:
96 | name: nupkg
97 | - name: Push to GitHub Feed
98 | run: |
99 | for f in ./nupkg/*.nupkg
100 | do
101 | curl -vX PUT -u "$GITHUB_USER:$GITHUB_TOKEN" -F package=@$f $GITHUB_FEED
102 | done
103 | - name: Push to NuGet Feed
104 | run: dotnet nuget push ./nupkg/*.nupkg --source $NUGET_FEED --skip-duplicate --api-key $NUGET_KEY
105 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.suo
2 | *.user
3 | .vs
4 | .idea
5 | .ionide
6 | *.ncrunchsolution*
7 | nCrunchTemp_*
8 | _NCrunch_*
9 |
10 | [Bb]in/
11 | [Oo]bj/
12 |
13 | paket.local
14 | paket-files
15 |
16 | # From https://gist.github.com/geoder101/aa2ab1ab417fbc52bb8b
17 | # NuGet Packages
18 | *.nupkg
19 | # The packages folder can be ignored because of Package Restore
20 | **/packages/*
21 | # except build/, which is used as an MSBuild target.
22 | !**/packages/build/
23 | # Uncomment if necessary however generally it will be regenerated when needed
24 | #!**/packages/repositories.config
25 | # NuGet v3's project.json files produces more ignoreable files
26 | *.nuget.props
27 | *.nuget.targets
28 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 |
2 | The MIT License (MIT)
3 |
4 | Copyright (c) 2022 Amin Khansari, D-EDGE
5 |
6 | Permission is hereby granted, free of charge, to any person obtaining a copy
7 | of this software and associated documentation files (the "Software"), to deal
8 | in the Software without restriction, including without limitation the rights
9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10 | copies of the Software, and to permit persons to whom the Software is
11 | furnished to do so, subject to the following conditions:
12 |
13 | The above copyright notice and this permission notice shall be included in all
14 | copies or substantial portions of the Software.
15 |
16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 | SOFTWARE.
23 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 | extLauncher is a dotnet tool to search and launch quickly projects in the user's preferred application. extLauncher is maintained by folks at [D-EDGE](https://www.d-edge.com/).
15 |
16 |
17 |
18 |
19 |
20 | # Getting Started
21 |
22 | Install extLauncher as a global dotnet tool
23 |
24 | ``` bash
25 | dotnet tool install extLauncher -g
26 | ```
27 |
28 | or as a dotnet local tool
29 |
30 | ``` bash
31 | dotnet new tool-manifest
32 | dotnet tool install extLauncher
33 | ````
34 |
35 | # Usage
36 |
37 | ```
38 | USAGE:
39 | extLauncher [OPTIONS]
40 |
41 | EXAMPLES:
42 | extLauncher index *.sln
43 | extLauncher index "(.*)[.](fs|cs)proj$" --regex
44 | extLauncher launcher mylauncher set execpath
45 | extLauncher launcher mylauncher remove
46 | extLauncher launcher vscode set /usr/bin/code --choose file --args="-r %s"
47 | extLauncher launcher vscode set "$env:LOCALAPPDATA\Programs\Microsoft VS Code\bin\code.cmd" --choose directory
48 | extLauncher launcher explorer set explorer.exe --choose directory
49 |
50 | OPTIONS:
51 | -h, --help Prints help information
52 |
53 | COMMANDS:
54 | prompt (default command) Type to search. Arrows Up/Down to navigate. Enter to launch. Escape to quit
55 | index Indexes all files recursively with a specific pattern which can be a wildcard (default) or a regular expression
56 | launcher Add, update or remove a launcher (optional)
57 | deindex Clears the current index
58 | info Prints the current pattern and all the indexed files
59 | refresh Updates the current index
60 | ```
61 |
62 | # Build locally
63 |
64 | - Clone the repository
65 | - Open the repository
66 | - Invoke the tool by running the `dotnet tool run` command: `dotnet tool run extLauncher` (with your arguments)
67 |
68 | # Caches and data generated by extLauncher
69 |
70 | This tool maintains a database to improve its performance. You should be able to find it in the most obvious place for your operating system:
71 |
72 | - Windows: `%appdata%\Roaming\extLauncher\extLauncher.db`
73 | - Linux: `~/.config/extLauncher/extLauncher.db`
74 |
75 | # License
76 |
77 | [MIT](https://github.com/d-edge/extLauncher/blob/main/LICENSE)
78 |
--------------------------------------------------------------------------------
/assets/logo-64x64.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/d-edge/extLauncher/6a397930ff35b12f3f49640a35857703e7e09ed5/assets/logo-64x64.png
--------------------------------------------------------------------------------
/assets/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/d-edge/extLauncher/6a397930ff35b12f3f49640a35857703e7e09ed5/assets/logo.png
--------------------------------------------------------------------------------
/assets/terminal.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/d-edge/extLauncher/6a397930ff35b12f3f49640a35857703e7e09ed5/assets/terminal.gif
--------------------------------------------------------------------------------
/extLauncher.Tests/AppTests.fs:
--------------------------------------------------------------------------------
1 | module extLauncher.AppTests
2 |
3 | open Swensen.Unquote
4 | open Xunit
5 |
6 | []
7 | let ``should load a folder`` () =
8 | let folderPath = "/test"
9 | let pattern = "*.ext"
10 | let folder =
11 | let loadFiles _ _ =
12 | [| "/test/file2.ext", "file2"
13 | "/test/file1.ext", "file1" |]
14 | App.loadFolder loadFiles
15 | { Path = folderPath
16 | Pattern = Pattern.from pattern false
17 | Launchers = Array.empty }
18 | folder =! Some
19 | { Id = folderPath
20 | Pattern = pattern
21 | IsRegex = false
22 | Files =
23 | [| File.create "/test/file1.ext" "file1"
24 | File.create "/test/file2.ext" "file2" |]
25 | Launchers = Array.empty }
26 |
27 | []
28 | let ``should not load a folder if no result`` () =
29 | let folder =
30 | let loadFiles _ _ = Array.empty
31 | App.loadFolder loadFiles
32 | { Path = ""
33 | Pattern = Pattern.from "" false
34 | Launchers = Array.empty }
35 | folder =! None
36 |
37 | []
38 | let ``refresh should synchronize files`` () =
39 | let newFolder =
40 | let loadFiles _ _ =
41 | [| "file1", ""
42 | "file3", "" |]
43 | let save = id
44 | { Id = ""
45 | Pattern = ""
46 | IsRegex = false
47 | Files =
48 | [| File.create "file1" ""
49 | File.create "file2" "" |]
50 | Launchers = Array.empty }
51 | |> App.refresh loadFiles save
52 | |> Option.get
53 |
54 | newFolder.Files.[0].Id =! "file1"
55 | newFolder.Files.[1].Id =! "file3"
56 |
57 | []
58 | let ``refresh should keep triggers`` () =
59 | let newFolder =
60 | let loadFiles _ _ =
61 | [| "file1", ""
62 | "file2", "" |]
63 | let save = id
64 | { Id = ""
65 | Pattern = ""
66 | IsRegex = false
67 | Files =
68 | [| File.create "file1" "" |> File.triggered
69 | File.create "file2" "" |]
70 | Launchers = Array.empty }
71 | |> App.refresh loadFiles save
72 | |> Option.get
73 |
74 | newFolder.Files.[0].Triggered =! 1
75 | newFolder.Files.[1].Triggered =! 0
76 |
--------------------------------------------------------------------------------
/extLauncher.Tests/ConsoleTests.fs:
--------------------------------------------------------------------------------
1 | module extLauncher.ConsoleTests
2 |
3 | open System
4 | open System.Collections.Generic
5 | open Swensen.Unquote
6 | open Xunit
7 |
8 | let Terminal
9 | (strReader: Queue)
10 | (keyReader: Queue)
11 | (buffer: ResizeArray)
12 | =
13 | let mutable left = 0
14 | let mutable top = 0
15 | { new Console.ITerminal with
16 | member _.ShowCursor() = ()
17 | member _.HideCursor() = ()
18 | member _.ToggleCursorVisibility() = ()
19 | member _.GetCursorPosition() =
20 | left, top
21 | member _.SetCursorPosition(l, t) =
22 | left <- l
23 | top <- t
24 | member _.ReadKey () =
25 | keyReader.Dequeue()
26 | member _.ReadLine() =
27 | strReader.Dequeue()
28 | member _.Write str =
29 | if top < buffer.Count then
30 | buffer[top] <- buffer[top].Insert(left, str)
31 | else
32 | buffer.Add str
33 | left <- str.Length
34 | member _.WriteLine str =
35 | if top < buffer.Count then
36 | buffer[top] <- str
37 | else
38 | buffer.Add str
39 | top <- top + 1
40 | left <- 0
41 | member this.Markup str = this.Write str
42 | member this.MarkupLine str = this.WriteLine str
43 | member _.ClearLine() =
44 | buffer[top] <- String.Empty
45 | left <- 0
46 | }
47 |
48 | let newTerminal = Terminal (Queue())
49 | let noConsoleModifier = enum 0
50 | let enterKey = ConsoleKey.Enter, char ConsoleKey.Enter, noConsoleModifier
51 | let downKey = ConsoleKey.DownArrow, char ConsoleKey.DownArrow, noConsoleModifier
52 | let upKey = ConsoleKey.UpArrow, char ConsoleKey.UpArrow, noConsoleModifier
53 | let backspaceKey = ConsoleKey.Backspace, char ConsoleKey.Backspace, noConsoleModifier
54 | let escapeKey = ConsoleKey.Escape, char ConsoleKey.Escape, ConsoleModifiers.Alt
55 | let aKey k = enum(Char.ToUpper k |> int), k, noConsoleModifier
56 | let [] PromptTitle = "Search and choose"
57 | let [] PrintedTitle = "[teal]" + PromptTitle + "[/] "
58 |
59 | let printedLines maxChoices itemsCount chosenNum = [
60 | PrintedTitle
61 | for n in 1..itemsCount do
62 | $"""[yellow]{if n = chosenNum then ">" else " "} [/]{n}"""
63 | for _ in itemsCount+1..maxChoices do
64 | ""
65 | ]
66 |
67 | []
68 | let ``prompt should print choices`` () =
69 | let lines = ResizeArray()
70 | let keyReader = Queue [ escapeKey ]
71 | let term = newTerminal keyReader lines
72 |
73 | let _ =
74 | fun _ -> [| 1; 2; 3 |]
75 | |> Console.prompt term PromptTitle 5
76 |
77 | List.ofSeq lines =! printedLines 5 3 1
78 |
79 | []
80 | let ``prompt should go down`` () =
81 | let lines = ResizeArray()
82 | let keyReader = Queue [ downKey; escapeKey ]
83 | let term = newTerminal keyReader lines
84 |
85 | let _ =
86 | fun _ -> [| 1; 2; 3 |]
87 | |> Console.prompt term PromptTitle 5
88 |
89 | List.ofSeq lines =! printedLines 5 3 2
90 |
91 | []
92 | let ``prompt should go up`` () =
93 | let lines = ResizeArray()
94 | let keyReader = Queue [ downKey; upKey; escapeKey ]
95 | let term = newTerminal keyReader lines
96 |
97 | let _ =
98 | fun _ -> [| 1; 2; 3 |]
99 | |> Console.prompt term PromptTitle 5
100 |
101 | List.ofSeq lines =! printedLines 5 3 1
102 |
103 | []
104 | let ``prompt should stay up`` () =
105 | let lines = ResizeArray()
106 | let keyReader = Queue [ upKey; upKey; escapeKey ]
107 | let term = newTerminal keyReader lines
108 |
109 | let _ =
110 | fun _ -> [| 1; 2; 3 |]
111 | |> Console.prompt term PromptTitle 5
112 |
113 | List.ofSeq lines =! printedLines 5 3 1
114 |
115 | []
116 | let ``prompt should stay down`` () =
117 | let lines = ResizeArray()
118 | let keyReader = Queue [ downKey; downKey; downKey; downKey; downKey; escapeKey ]
119 | let term = newTerminal keyReader lines
120 |
121 | let _ =
122 | fun _ -> [| 1; 2; 3 |]
123 | |> Console.prompt term PromptTitle 5
124 |
125 | List.ofSeq lines =! printedLines 5 3 3
126 |
127 | []
128 | let ``prompt should choose the second choice and clear`` () =
129 | let lines = ResizeArray()
130 | let keyReader = Queue [ downKey; enterKey ]
131 | let term = newTerminal keyReader lines
132 |
133 | let chosen =
134 | fun _ -> [| 1; 2; 3 |]
135 | |> Console.prompt term PromptTitle 5
136 |
137 | chosen =! Some 2
138 | Seq.forall ((=) "") lines =! true
139 |
140 | []
141 | let ``prompt should print error if no match`` () =
142 | let lines = ResizeArray()
143 | let keyReader = Queue [ escapeKey ]
144 | let term = newTerminal keyReader lines
145 |
146 | let _ =
147 | fun _ -> Array.empty
148 | |> Console.prompt term PromptTitle 1
149 |
150 | List.ofSeq lines =! [
151 | PrintedTitle
152 | "No items match your search."
153 | ]
154 |
155 | []
156 | let ``prompt should print the search title`` () =
157 | let lines = ResizeArray()
158 | let keyReader = Queue [ aKey 't'; aKey 'e'; aKey 's'; aKey 't'; escapeKey ]
159 | let term = newTerminal keyReader lines
160 |
161 | let _ =
162 | fun _ -> [| 1; 2; 3 |]
163 | |> Console.prompt term PromptTitle 1
164 |
165 | Seq.head lines =! $"{PrintedTitle}test"
166 |
167 | []
168 | let ``prompt should print the search chars supporting backspace`` () =
169 | let lines = ResizeArray()
170 | let keyReader = Queue [ aKey 't'; aKey 'e'; backspaceKey; aKey 's'; aKey 't'; escapeKey ]
171 | let term = newTerminal keyReader lines
172 |
173 | let _ =
174 | fun _ -> [| 1; 2; 3 |]
175 | |> Console.prompt term PromptTitle 1
176 |
177 | Seq.head lines =! $"{PrintedTitle}tst"
178 |
179 | []
180 | let ``prompt should clear when exit`` () =
181 | let lines = ResizeArray()
182 | let keyReader = Queue [ ConsoleKey.Escape, char ConsoleKey.Escape, noConsoleModifier ]
183 | let term = newTerminal keyReader lines
184 |
185 | let _ =
186 | fun _ -> [| 1; 2; 3 |]
187 | |> Console.prompt term PromptTitle 5
188 |
189 | Seq.forall ((=) "") lines =! true
190 |
--------------------------------------------------------------------------------
/extLauncher.Tests/DomainTests.fs:
--------------------------------------------------------------------------------
1 | module extLauncher.DomainTests
2 |
3 | open System
4 | open FsCheck.Xunit
5 | open Swensen.Unquote
6 |
7 | []
8 | let ``File with the same Id should be equal`` (file1: File) (file2: File) =
9 | let file1 = { file1 with Id = file2.Id }
10 | file1 =! file2
11 |
12 | []
13 | let ``File with different Id should not be equal`` (file1: File) (file2: File) =
14 | let file1 = { file1 with Id = Guid.NewGuid().ToString() }
15 | let file2 = { file2 with Id = Guid.NewGuid().ToString() }
16 | file1 <>! file2
17 |
18 | []
19 | let ``File with a higher trigger should precede in the sort order`` (file1: File) (file2: File) =
20 | let file1 = { file1 with Triggered = file2.Triggered + 1 }
21 | compare file1 file2 =! -1
22 |
23 | []
24 | let ``File with a lower trigger should follow in the sort order`` (file1: File) (file2: File) =
25 | let file1 = { file1 with Triggered = file2.Triggered - 1 }
26 | compare file1 file2 =! 1
27 |
28 | []
29 | let ``File with the same trigger should be sorted alphabetically`` (file1: File) (file2: File) =
30 | let file1 = { file1 with Triggered = 0; Name = "a" }
31 | let file2 = { file2 with Triggered = 0; Name = "b" }
32 | compare file1 file2 =! -1
33 |
34 | []
35 | let ``searchByName should search for the containing string ignoring case`` (file: File) (files: File array) =
36 | let file = { file with Name = "Hello World" }
37 | let files = Array.insertAt 0 file files
38 | Helpers.searchByName files "world" =! [| file |]
39 |
--------------------------------------------------------------------------------
/extLauncher.Tests/Program.fs:
--------------------------------------------------------------------------------
1 | module Program = let [] main _ = 0
2 |
--------------------------------------------------------------------------------
/extLauncher.Tests/extLauncher.Tests.fsproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | net6.0
5 |
6 | false
7 | false
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 | runtime; build; native; contentfiles; analyzers; buildtransitive
24 | all
25 |
26 |
27 | runtime; build; native; contentfiles; analyzers; buildtransitive
28 | all
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
--------------------------------------------------------------------------------
/extLauncher.sln:
--------------------------------------------------------------------------------
1 |
2 | Microsoft Visual Studio Solution File, Format Version 12.00
3 | # Visual Studio Version 17
4 | VisualStudioVersion = 17.0.32112.339
5 | MinimumVisualStudioVersion = 10.0.40219.1
6 | Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "extLauncher", "extLauncher\extLauncher.fsproj", "{075AB28F-511D-4C65-97B9-399F442EC8B8}"
7 | EndProject
8 | Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "extLauncher.Tests", "extLauncher.Tests\extLauncher.Tests.fsproj", "{687867BF-FB37-43BF-B0FF-148313A40D5B}"
9 | EndProject
10 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{503F5C7D-95C0-4B13-8BC6-AAF31EA9FDE2}"
11 | ProjectSection(SolutionItems) = preProject
12 | README.md = README.md
13 | EndProjectSection
14 | EndProject
15 | Global
16 | GlobalSection(SolutionConfigurationPlatforms) = preSolution
17 | Debug|Any CPU = Debug|Any CPU
18 | Release|Any CPU = Release|Any CPU
19 | EndGlobalSection
20 | GlobalSection(ProjectConfigurationPlatforms) = postSolution
21 | {075AB28F-511D-4C65-97B9-399F442EC8B8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
22 | {075AB28F-511D-4C65-97B9-399F442EC8B8}.Debug|Any CPU.Build.0 = Debug|Any CPU
23 | {075AB28F-511D-4C65-97B9-399F442EC8B8}.Release|Any CPU.ActiveCfg = Release|Any CPU
24 | {075AB28F-511D-4C65-97B9-399F442EC8B8}.Release|Any CPU.Build.0 = Release|Any CPU
25 | {687867BF-FB37-43BF-B0FF-148313A40D5B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
26 | {687867BF-FB37-43BF-B0FF-148313A40D5B}.Debug|Any CPU.Build.0 = Debug|Any CPU
27 | {687867BF-FB37-43BF-B0FF-148313A40D5B}.Release|Any CPU.ActiveCfg = Release|Any CPU
28 | {687867BF-FB37-43BF-B0FF-148313A40D5B}.Release|Any CPU.Build.0 = Release|Any CPU
29 | EndGlobalSection
30 | GlobalSection(SolutionProperties) = preSolution
31 | HideSolutionNode = FALSE
32 | EndGlobalSection
33 | GlobalSection(ExtensibilityGlobals) = postSolution
34 | SolutionGuid = {C8D85EC1-5DB0-4C30-BE67-D44D7E59789F}
35 | EndGlobalSection
36 | EndGlobal
37 |
--------------------------------------------------------------------------------
/extLauncher/App.fs:
--------------------------------------------------------------------------------
1 | module extLauncher.App
2 |
3 | type FolderConf =
4 | { Path: string
5 | Pattern: Pattern
6 | Launchers: Launcher array }
7 |
8 | let loadFolder loadFiles conf : Folder option =
9 | loadFiles conf.Path conf.Pattern
10 | |> Array.map ((<||) File.create)
11 | |> Array.sort
12 | |> function
13 | | [||] -> None
14 | | files ->
15 | { Id = conf.Path
16 | Pattern = Pattern.value conf.Pattern
17 | IsRegex = Pattern.isRegex conf.Pattern
18 | Launchers = conf.Launchers
19 | Files = files }
20 | |> Some
21 |
22 | let index loadFiles save conf : Folder option =
23 | loadFolder loadFiles conf
24 | |> Option.map save
25 |
26 | let refresh loadFiles save (folder: Folder) : Folder option =
27 |
28 | let newFiles =
29 | Pattern.from folder.Pattern folder.IsRegex
30 | |> loadFiles folder.Id
31 | |> Array.map ((<||) File.create)
32 |
33 | let currentFiles =
34 | folder.Files
35 | |> Array.map (fun f -> f.Id, f)
36 | |> Map
37 |
38 | newFiles
39 | |> Array.map (fun newFile ->
40 | match currentFiles.TryFind newFile.Id with
41 | | Some current -> { newFile with Triggered = current.Triggered }
42 | | None -> newFile)
43 | |> fun files -> { folder with Files = files }
44 | |> save
45 | |> Some
46 |
47 | let makeSearcher folder str =
48 | Helpers.searchByName folder.Files str
49 | |> Array.sort
50 |
--------------------------------------------------------------------------------
/extLauncher/Console.fs:
--------------------------------------------------------------------------------
1 | module extLauncher.Console
2 |
3 | open System
4 | open Spectre.Console
5 |
6 | type TerminalKey = ConsoleKey * char * ConsoleModifiers
7 |
8 | type ITerminal =
9 | abstract member ShowCursor: unit -> unit
10 | abstract member HideCursor: unit -> unit
11 | abstract member ToggleCursorVisibility: unit -> unit
12 | abstract member GetCursorPosition: unit -> int32 * int32
13 | abstract member SetCursorPosition: int32 * int32 -> unit
14 | abstract member ReadKey: unit -> TerminalKey
15 | abstract member ReadLine: unit -> string
16 | abstract member Write: string -> unit
17 | abstract member WriteLine: string -> unit
18 | abstract member Markup: string -> unit
19 | abstract member MarkupLine: string -> unit
20 | abstract member ClearLine: unit -> unit
21 |
22 | let Terminal = { new ITerminal with
23 | member _.ShowCursor () =
24 | Console.CursorVisible <- true
25 | member _.HideCursor () =
26 | Console.CursorVisible <- false
27 | member _.ToggleCursorVisibility () =
28 | Console.CursorVisible <- not Console.CursorVisible
29 | member _.GetCursorPosition () =
30 | Console.CursorLeft, Console.CursorTop
31 | member _.SetCursorPosition (left, top) =
32 | Console.SetCursorPosition(
33 | (if left < 0 then 0 else left),
34 | (if top < 0 then 0 else top))
35 | member _.ReadKey () =
36 | let k = Console.ReadKey()
37 | k.Key, k.KeyChar, k.Modifiers
38 | member _.ReadLine () =
39 | Console.ReadLine()
40 | member _.Write str = AnsiConsole.Write str
41 | member _.WriteLine str = AnsiConsole.WriteLine str
42 | member _.Markup str = AnsiConsole.Markup str
43 | member _.MarkupLine str = AnsiConsole.MarkupLine str
44 | member this.ClearLine () =
45 | String(' ', Console.BufferWidth - 1) |> this.WriteLine
46 | }
47 |
48 | let [] NoMatch = "No items match your search."
49 |
50 | let clearUp (term: ITerminal) cursorTop count =
51 | for top = cursorTop to cursorTop + count do
52 | term.SetCursorPosition (0, top)
53 | term.ClearLine ()
54 | term.SetCursorPosition (0, cursorTop)
55 |
56 | let checkNoMatch (term: ITerminal) (search: string -> 'T array) =
57 | if search String.Empty |> Array.isEmpty then
58 | term.MarkupLine NoMatch
59 | None
60 | else
61 | Some search
62 |
63 | let prompt<'T> (term: ITerminal) title maxChoices (search: string -> 'T array) =
64 |
65 | for _ in 0..maxChoices do term.WriteLine "" // allocate buffer area
66 | let cursorTop =
67 | let _, top = term.GetCursorPosition()
68 | top - maxChoices - 1
69 |
70 | let search str =
71 | let choices = search str |> Array.truncate maxChoices
72 | (choices, str, 0)
73 |
74 | let print (choices: 'T array, str, pos) =
75 | term.HideCursor ()
76 | let pos = max 0 (min (Array.length choices - 1) pos)
77 | clearUp term cursorTop maxChoices
78 | term.WriteLine ""
79 | if Array.isEmpty choices then
80 | term.MarkupLine NoMatch
81 | else
82 | choices
83 | |> Array.iteri (fun i choice ->
84 | sprintf "[yellow]%s[/]%s"
85 | (if i = pos then "> " else " ")
86 | (string choice)
87 | |> term.MarkupLine)
88 | term.SetCursorPosition (0, cursorTop)
89 | term.Markup $"[teal]%s{title}[/] %s{str}"
90 | term.ShowCursor ()
91 | (choices, str, pos)
92 |
93 | let rec read (choices: 'T array, str, pos) =
94 | match term.ReadKey () with
95 | | ConsoleKey.Escape, _, ConsoleModifiers.Alt ->
96 | None // no clear alternative
97 | | ConsoleKey.Escape, _, _ ->
98 | term.Write (string '\u200B') // hack to force the clear
99 | clearUp term cursorTop maxChoices
100 | None
101 | | ConsoleKey.Enter, _, _ ->
102 | if Array.isEmpty choices
103 | then read (choices, str, pos)
104 | else
105 | clearUp term cursorTop maxChoices
106 | Some choices[pos]
107 | | ConsoleKey.UpArrow, _, _ ->
108 | read ((choices, str, pos - 1) |> print)
109 | | ConsoleKey.DownArrow, _, _ ->
110 | read ((choices, str, pos + 1) |> print)
111 | | ConsoleKey.Backspace, _, _ ->
112 | if str.Length = 0
113 | then read (choices, str, pos)
114 | else read (search str[..^1] |> print)
115 | | _, key, _ ->
116 | read (search $"{str}{key}" |> print)
117 |
118 | search String.Empty |> print |> read
119 |
--------------------------------------------------------------------------------
/extLauncher/Domain.fs:
--------------------------------------------------------------------------------
1 | namespace extLauncher
2 |
3 | open System
4 |
5 | []
6 | type File =
7 | { Id: string
8 | Name: string
9 | Triggered: int32 }
10 |
11 | override this.ToString() = this.Name
12 |
13 | override this.GetHashCode() = this.Id.GetHashCode()
14 |
15 | override this.Equals other =
16 | match other with
17 | | :? File as other -> this.Id = other.Id
18 | | _ -> ArgumentException() |> raise
19 |
20 | interface IComparable with
21 | member this.CompareTo other =
22 | match other with
23 | | :? File as other ->
24 | if this.Triggered = other.Triggered then String.Compare(this.Name, other.Name, true)
25 | elif this.Triggered > other.Triggered then -1
26 | else 1
27 | | _ ->
28 | ArgumentException() |> raise
29 |
30 | module File =
31 |
32 | let create id name =
33 | { Id = id; Name = name; Triggered = 0 }
34 |
35 | let triggered file =
36 | { file with Triggered = file.Triggered + 1 }
37 |
38 | type Choose =
39 | | File = 0
40 | | Directory = 1
41 |
42 | type Launcher =
43 | { Name: string
44 | Path: string
45 | Arguments: string
46 | Choose: Choose }
47 | override this.ToString() = this.Name
48 |
49 | module Launcher =
50 | let buildArgs launcher tolaunch =
51 | if String.IsNullOrEmpty launcher.Arguments
52 | then tolaunch
53 | else launcher.Arguments.Replace("%s", $"\"{tolaunch}\"")
54 |
55 | // Should be serializable to BSON
56 | []
57 | type Folder =
58 | { Id: string
59 | Pattern: string
60 | IsRegex: bool
61 | Launchers: Launcher array
62 | Files: File array }
63 | override this.ToString() = this.Id
64 |
65 | type Pattern =
66 | | WildcardPattern of string
67 | | RegexPattern of string
68 |
69 | module Pattern =
70 | let value = function WildcardPattern p | RegexPattern p -> p
71 | let isRegex = function WildcardPattern _ -> false | RegexPattern _ -> true
72 | let from value isRegex = if isRegex then RegexPattern value else WildcardPattern value
73 |
74 | module Helpers =
75 |
76 | let inline searchByName items str =
77 | if String.IsNullOrEmpty str then
78 | items
79 | else
80 | items
81 | |> Array.filter (fun item ->
82 | match (^T: (member Name: string) item) with
83 | | null -> false
84 | | name -> name.Contains(str, StringComparison.OrdinalIgnoreCase))
85 |
--------------------------------------------------------------------------------
/extLauncher/Infra.fs:
--------------------------------------------------------------------------------
1 | namespace extLauncher
2 |
3 | open System
4 |
5 | module IO =
6 | open System.IO
7 | open System.Text.RegularExpressions
8 |
9 | let [] AppName = "extLauncher"
10 |
11 | let userPath =
12 | let path = Path.Combine(Environment.GetFolderPath Environment.SpecialFolder.ApplicationData, AppName)
13 | Directory.CreateDirectory path |> ignore
14 | path
15 |
16 | let userPathCombine path =
17 | Path.Combine(userPath, path)
18 |
19 | let private enumerateFiles folderPath = function
20 | | WildcardPattern pattern ->
21 | Directory.EnumerateFiles(folderPath, pattern, SearchOption.AllDirectories)
22 | | RegexPattern pattern ->
23 | let regex = Regex pattern
24 | let opt = EnumerationOptions()
25 | opt.RecurseSubdirectories <- true
26 | Directory.EnumerateFiles(folderPath, "*", opt)
27 | |> Seq.filter (Path.GetFileName >> regex.IsMatch)
28 |
29 | let getFiles folderPath pattern =
30 | enumerateFiles folderPath pattern
31 | |> Seq.map (fun path -> path, Path.GetFileNameWithoutExtension path)
32 | |> Seq.toArray
33 |
34 | module Db =
35 | open LiteDB
36 |
37 | BsonMapper.Global.Entity().DbRef(fun f -> f.Files) |> ignore
38 |
39 | let dbPath = IO.userPathCombine $"{IO.AppName}.db"
40 | let newReadOnlyDb () = new LiteDatabase($"Filename=%s{dbPath}; Mode=ReadOnly")
41 | let newSharedDb () = new LiteDatabase($"Filename=%s{dbPath}; Mode=Shared")
42 |
43 | let findFolder (path: string) =
44 | use db = newReadOnlyDb ()
45 | let doc = db.GetCollection().Include(fun f -> f.Files).FindById path
46 | if box doc <> null then Some doc else None
47 |
48 | let updateFile (file: File) =
49 | use db = newSharedDb ()
50 | db.GetCollection().Update file |> ignore
51 | file
52 |
53 | let deleteFolder path =
54 | match findFolder path with
55 | | None -> ()
56 | | Some folder ->
57 | use db = newSharedDb ()
58 | for file in folder.Files do
59 | db.GetCollection().Delete file.Id |> ignore
60 | db.GetCollection().Delete folder.Id |> ignore
61 |
62 | let upsertFolder (folder: Folder) =
63 | deleteFolder folder.Id
64 | use db = newSharedDb ()
65 | db.GetCollection().InsertBulk folder.Files |> ignore
66 | db.GetCollection().Insert folder |> ignore
67 | folder
68 |
--------------------------------------------------------------------------------
/extLauncher/Program.fs:
--------------------------------------------------------------------------------
1 | namespace extLauncher
2 |
3 | open System
4 | open System.ComponentModel
5 | open Spectre.Console
6 | open Spectre.Console.Cli
7 |
8 | []
9 | module private Implementations =
10 | open System.Diagnostics
11 | type Path = System.IO.Path
12 |
13 | let markup value = AnsiConsole.MarkupLine value
14 |
15 | let notInitialized () =
16 | markup $"Folder not yet indexed: [yellow]{IO.AppName}[/] index [gray]--help[/]"
17 | 1
18 |
19 | let run (file: File) launcher =
20 | markup $"""Launching [green]{file.Name}[/]..."""
21 | let file = file |> File.triggered |> Db.updateFile
22 | match launcher with
23 | | None ->
24 | let psi = ProcessStartInfo file.Id
25 | psi.UseShellExecute <- true
26 | Process.Start psi |> ignore
27 | | Some launcher ->
28 | let path =
29 | match launcher.Choose with
30 | | Choose.File -> file.Id
31 | | Choose.Directory -> Path.GetDirectoryName file.Id
32 | | _ -> NotImplementedException() |> raise
33 | let psi = ProcessStartInfo launcher.Path
34 | psi.Arguments <- Launcher.buildArgs launcher path
35 | Process.Start psi |> ignore
36 |
37 | let chooseLauncher folder file =
38 | match folder.Launchers with
39 | | [| |] ->
40 | run file None
41 | | [| launcher |] ->
42 | run file (Some launcher)
43 | | launchers ->
44 | Helpers.searchByName launchers
45 | |> Console.prompt Console.Terminal "With which launcher?" 10
46 | |> function
47 | | Some launcher -> run file (Some launcher)
48 | | None -> ()
49 |
50 | let prompt folder =
51 | folder
52 | |> App.makeSearcher
53 | |> Console.prompt Console.Terminal "Search and launch:" 10
54 | |> Option.iter (chooseLauncher folder)
55 |
56 | let withLoader<'T> (worker: StatusContext -> 'T) =
57 | AnsiConsole.Status().Start("Indexing...", worker)
58 |
59 | let currentPath =
60 | Environment.CurrentDirectory
61 |
62 | let findFolder () =
63 | let rec find path =
64 | if isNull path then None else
65 | match Db.findFolder path with
66 | | Some f -> Some f
67 | | None -> find (Path.GetDirectoryName path)
68 | find currentPath
69 |
70 | let toCount str num =
71 | if num > 1 then $"{num} {str}s" else $"{num} {str}"
72 |
73 | let noNull s = if isNull s then "" else s
74 |
75 | let printLaunchers folder =
76 | let launchers = Table().AddColumns([| "Name"; "Choose"; "Path"; "Arguments" |])
77 | launchers.Border <- TableBorder.Minimal
78 | for l in folder.Launchers do
79 | launchers.AddRow([| l.Name; string l.Choose; l.Path; noNull l.Arguments |]) |> ignore
80 | AnsiConsole.Write launchers
81 |
82 | type PromptCommand () =
83 | inherit Command ()
84 | override _.Execute c =
85 | findFolder ()
86 | |> Option.map (prompt >> fun () -> 0)
87 | |> Option.defaultWith notInitialized
88 |
89 | type IndexSettings () =
90 | inherit CommandSettings ()
91 | [")>]
92 | []
93 | member val Pattern = "" with get, set
94 | []
95 | []
96 | member val IsRegex = false with get, set
97 |
98 | type IndexCommand () =
99 | inherit Command ()
100 | override _.Execute (_, settings) =
101 | fun _ ->
102 | App.index IO.getFiles Db.upsertFolder
103 | { Path = currentPath
104 | Pattern = Pattern.from settings.Pattern settings.IsRegex
105 | Launchers = Array.empty }
106 | |> withLoader
107 | |> function
108 | | Some folder ->
109 | printfn $"""{toCount "file" folder.Files.Length} indexed."""
110 | markup $"Start to search and launch: [yellow]{IO.AppName}[/]"
111 | markup $"Add a specific launcher: [yellow]{IO.AppName}[/] launcher [gray]--help[/]"
112 | 0
113 | | None ->
114 | printfn $"{Console.NoMatch}"
115 | -1
116 |
117 | type LauncherSettings () =
118 | inherit CommandSettings ()
119 | [")>]
120 | []
121 | member val Name = "" with get, set
122 |
123 | type SetLauncherSettings () =
124 | inherit LauncherSettings ()
125 | [")>]
126 | []
127 | member val Path = "" with get, set
128 | []
129 | []
130 | member val Arguments = "" with get, set
131 | []
132 | []
133 | member val Choose = Choose.File with get, set
134 |
135 | type RemoveLauncherSettings () =
136 | inherit LauncherSettings ()
137 |
138 | type SetLauncherCommand () =
139 | inherit Command ()
140 | override _.Execute (_, settings) =
141 | match findFolder () with
142 | | None -> notInitialized ()
143 | | Some folder ->
144 | markup $"[teal]{settings.Name}[/] launcher updated."
145 | { Name = settings.Name
146 | Path = settings.Path
147 | Arguments = settings.Arguments
148 | Choose = settings.Choose }
149 | |> fun launcher ->
150 | match folder.Launchers |> Array.tryFindIndex (fun l -> l.Name = launcher.Name) with
151 | | Some index ->
152 | folder.Launchers.[index] <- launcher
153 | folder
154 | | None ->
155 | { folder with Launchers = Array.insertAt 0 launcher folder.Launchers }
156 | |> Db.upsertFolder
157 | |> printLaunchers
158 | 0
159 | interface ICommandLimiter
160 |
161 | type RemoveLauncherCommand () =
162 | inherit Command ()
163 | override _.Execute (_, settings) =
164 | match findFolder () with
165 | | None -> notInitialized ()
166 | | Some folder ->
167 | match folder.Launchers |> Array.tryFindIndex (fun l -> l.Name = settings.Name) with
168 | | Some index ->
169 | markup $"[green]{settings.Name}[/] launcher removed."
170 | { folder with Launchers = Array.removeAt index folder.Launchers }
171 | |> Db.upsertFolder
172 | |> printLaunchers
173 | 0
174 | | None ->
175 | markup $"[green]{settings.Name}[/] launcher not found."
176 | printLaunchers folder
177 | 0
178 | interface ICommandLimiter
179 |
180 | type DeindexCommand () =
181 | inherit Command ()
182 | override _.Execute _ =
183 | match Db.findFolder currentPath with
184 | | None -> notInitialized ()
185 | | Some folder ->
186 | Db.deleteFolder folder.Id
187 | printfn "Deindexed"
188 | 0
189 |
190 | type InfoCommand () =
191 | inherit Command ()
192 | override _.Execute _ =
193 | match findFolder () with
194 | | None -> notInitialized ()
195 | | Some folder ->
196 | markup $"[teal]Path:[/]\n {folder.Id.EscapeMarkup()}"
197 |
198 | markup $"\n[teal]Pattern:[/]\n {folder.Pattern.EscapeMarkup()}"
199 |
200 | markup $"\n[teal]Launchers:[/]"
201 | if Array.isEmpty folder.Launchers
202 | then printfn " -\n"
203 | else printLaunchers folder
204 |
205 | markup $"[teal]Indexed files:[/]"
206 | let files = Table().AddColumns([| "Name"; "Triggered"; "Path" |])
207 | files.Border <- TableBorder.Minimal
208 | for f in folder.Files do
209 | let path = f.Id.Remove(0, folder.Id.Length)
210 | files.AddRow([| f.Name; string f.Triggered; path |]) |> ignore
211 | AnsiConsole.Write files
212 |
213 | 0
214 |
215 | type RefreshCommand () =
216 | inherit Command ()
217 | override _.Execute _ =
218 | match findFolder () with
219 | | None -> notInitialized ()
220 | | Some folder ->
221 | fun _ ->
222 | folder
223 | |> App.refresh IO.getFiles Db.upsertFolder
224 | |> withLoader
225 | |> Option.iter prompt
226 | 0
227 |
228 | module Program =
229 |
230 | []
231 | let main args =
232 | let app = CommandApp()
233 | app.Configure (fun conf ->
234 | conf.SetApplicationName(IO.AppName) |> ignore
235 |
236 | conf.AddCommand("prompt")
237 | .WithDescription("[italic](default command)[/] Type to search. Arrows Up/Down to navigate. Enter to launch. Escape to quit.") |> ignore
238 |
239 | conf.AddCommand("index")
240 | .WithDescription("Indexes all files recursively with a specific pattern which can be a wildcard [italic](default)[/] or a regular expression.") |> ignore
241 |
242 | conf.AddBranch("launcher", fun launcher ->
243 | launcher.SetDescription("Add, update or remove a launcher [italic](optional)[/].")
244 | launcher.AddCommand("set")
245 | .WithDescription("Add or update a launcher.") |> ignore
246 | launcher.AddCommand("remove")
247 | .WithDescription("Remove a launcher.") |> ignore
248 | ) |> ignore
249 |
250 | conf.AddCommand("deindex")
251 | .WithDescription("Clears the current index.") |> ignore
252 |
253 | conf.AddCommand("info")
254 | .WithDescription("Prints the current pattern and all the indexed files.") |> ignore
255 |
256 | conf.AddCommand("refresh")
257 | .WithDescription("Updates the current index.") |> ignore
258 |
259 | conf.AddExample([| "index"; "*.sln" |])
260 | conf.AddExample([| "index"; "\"(.*)[.](fs|cs)proj$\""; "--regex" |])
261 | conf.AddExample([| "launcher"; "mylauncher"; "set"; "execpath" |])
262 | conf.AddExample([| "launcher"; "mylauncher"; "remove" |])
263 | conf.AddExample([| "launcher"; "vscode"; "set"; "/usr/bin/code"; "--choose"; "file"; "--args=\"-r %s\"" |])
264 | conf.AddExample([| "launcher"; "vscode"; "set"; @"""$env:LOCALAPPDATA\Programs\Microsoft VS Code\bin\code.cmd"""; "--choose"; "directory" |])
265 | conf.AddExample([| "launcher"; "explorer"; "set"; "explorer.exe"; "--choose"; "directory" |])
266 |
267 | #if DEBUG
268 | conf.ValidateExamples() |> ignore
269 | #endif
270 | )
271 | app.Run args
272 |
--------------------------------------------------------------------------------
/extLauncher/extLauncher.fsproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Exe
5 | netcoreapp3.1;net6.0
6 | preview
7 | true
8 |
9 | true
10 | extLauncher
11 | ./nupkg
12 |
13 | Copyright (c) 2022 D-EDGE
14 | Amin Khansari
15 |
16 |
17 | extLauncher
18 | DEdge;launcher;extLauncher
19 | https://github.com/d-edge/extLauncher/releases/
20 | https://github.com/d-edge/extLauncher
21 | MIT
22 | logo-64x64.png
23 | true
24 | git
25 | https://github.com/d-edge/extLauncher
26 |
27 | README.md
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 | true
40 | $(PackageIconUrl)
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
--------------------------------------------------------------------------------