├── .gitignore
├── FileWatcher.sln
├── FileWatcher
└── Properties
│ └── PublishProfiles
│ └── win-x64.pubxml
├── FwInstaller
├── FwInstaller.wixproj
├── Package.wxs
└── config.xml.sample
├── LICENSE
├── README.md
├── src
├── Configuration
│ ├── Action.cs
│ ├── Actions.cs
│ ├── ChangeInfo.cs
│ ├── Command.cs
│ ├── Commands.cs
│ ├── Data.cs
│ ├── Exclusions.cs
│ ├── Filters.cs
│ ├── Header.cs
│ ├── Headers.cs
│ ├── IConfigurationFile.cs
│ ├── ItemBase.cs
│ ├── Logging.cs
│ ├── Notification.cs
│ ├── Notifications.cs
│ ├── RunnableBase.cs
│ ├── Triggers.cs
│ ├── Watch.cs
│ ├── Watches.cs
│ └── XmlFile.cs
├── FileSystem
│ ├── Directory.cs
│ └── File.cs
├── FileWatcher.csproj
├── FileWatcherException.cs
├── FileWatcherTriggerNotMatchException .cs
├── GlobalSuppressions.cs
├── IO
│ ├── Attributes.cs
│ ├── FileBase.cs
│ ├── Files.cs
│ ├── Folders.cs
│ ├── MatchBase.cs
│ ├── Name.cs
│ ├── Paths.cs
│ └── PatternMatcher.cs
├── Log
│ ├── Logger.cs
│ └── Message.cs
├── Net
│ ├── Request.cs
│ └── Response.cs
├── Placeholder.cs
├── Program.cs
└── appsettings.json
└── templates
└── config-template.xml
/.gitignore:
--------------------------------------------------------------------------------
1 | ## Ignore Visual Studio temporary files, build results, and
2 | ## files generated by popular Visual Studio add-ons.
3 | ##
4 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
5 |
6 | # User-specific files
7 | *.rsuser
8 | *.suo
9 | *.user
10 | *.userosscache
11 | *.sln.docstates
12 |
13 | # User-specific files (MonoDevelop/Xamarin Studio)
14 | *.userprefs
15 |
16 | # Mono auto generated files
17 | mono_crash.*
18 |
19 | # Build results
20 | [Dd]ebug/
21 | [Dd]ebugPublic/
22 | [Rr]elease/
23 | [Rr]eleases/
24 | x64/
25 | x86/
26 | [Aa][Rr][Mm]/
27 | [Aa][Rr][Mm]64/
28 | bld/
29 | [Bb]in/
30 | [Oo]bj/
31 | [Ll]og/
32 | [Ll]ogs/
33 |
34 | # Visual Studio 2015/2017 cache/options directory
35 | .vs/
36 | # Uncomment if you have tasks that create the project's static files in wwwroot
37 | #wwwroot/
38 |
39 | # Visual Studio 2017 auto generated files
40 | Generated\ Files/
41 |
42 | # MSTest test Results
43 | [Tt]est[Rr]esult*/
44 | [Bb]uild[Ll]og.*
45 |
46 | # NUnit
47 | *.VisualState.xml
48 | TestResult.xml
49 | nunit-*.xml
50 |
51 | # Build Results of an ATL Project
52 | [Dd]ebugPS/
53 | [Rr]eleasePS/
54 | dlldata.c
55 |
56 | # Benchmark Results
57 | BenchmarkDotNet.Artifacts/
58 |
59 | # .NET Core
60 | project.lock.json
61 | project.fragment.lock.json
62 | artifacts/
63 |
64 | # StyleCop
65 | StyleCopReport.xml
66 |
67 | # Files built by Visual Studio
68 | *_i.c
69 | *_p.c
70 | *_h.h
71 | *.ilk
72 | *.meta
73 | *.obj
74 | *.iobj
75 | *.pch
76 | *.pdb
77 | *.ipdb
78 | *.pgc
79 | *.pgd
80 | *.rsp
81 | *.sbr
82 | *.tlb
83 | *.tli
84 | *.tlh
85 | *.tmp
86 | *.tmp_proj
87 | *_wpftmp.csproj
88 | *.log
89 | *.vspscc
90 | *.vssscc
91 | .builds
92 | *.pidb
93 | *.svclog
94 | *.scc
95 |
96 | # Chutzpah Test files
97 | _Chutzpah*
98 |
99 | # Visual C++ cache files
100 | ipch/
101 | *.aps
102 | *.ncb
103 | *.opendb
104 | *.opensdf
105 | *.sdf
106 | *.cachefile
107 | *.VC.db
108 | *.VC.VC.opendb
109 |
110 | # Visual Studio profiler
111 | *.psess
112 | *.vsp
113 | *.vspx
114 | *.sap
115 |
116 | # Visual Studio Trace Files
117 | *.e2e
118 |
119 | # TFS 2012 Local Workspace
120 | $tf/
121 |
122 | # Guidance Automation Toolkit
123 | *.gpState
124 |
125 | # ReSharper is a .NET coding add-in
126 | _ReSharper*/
127 | *.[Rr]e[Ss]harper
128 | *.DotSettings.user
129 |
130 | # TeamCity is a build add-in
131 | _TeamCity*
132 |
133 | # DotCover is a Code Coverage Tool
134 | *.dotCover
135 |
136 | # AxoCover is a Code Coverage Tool
137 | .axoCover/*
138 | !.axoCover/settings.json
139 |
140 | # Visual Studio code coverage results
141 | *.coverage
142 | *.coveragexml
143 |
144 | # NCrunch
145 | _NCrunch_*
146 | .*crunch*.local.xml
147 | nCrunchTemp_*
148 |
149 | # MightyMoose
150 | *.mm.*
151 | AutoTest.Net/
152 |
153 | # Web workbench (sass)
154 | .sass-cache/
155 |
156 | # Installshield output folder
157 | [Ee]xpress/
158 |
159 | # DocProject is a documentation generator add-in
160 | DocProject/buildhelp/
161 | DocProject/Help/*.HxT
162 | DocProject/Help/*.HxC
163 | DocProject/Help/*.hhc
164 | DocProject/Help/*.hhk
165 | DocProject/Help/*.hhp
166 | DocProject/Help/Html2
167 | DocProject/Help/html
168 |
169 | # Click-Once directory
170 | publish/
171 |
172 | # Publish Web Output
173 | *.[Pp]ublish.xml
174 | *.azurePubxml
175 | # Note: Comment the next line if you want to checkin your web deploy settings,
176 | # but database connection strings (with potential passwords) will be unencrypted
177 | *.pubxml
178 | *.publishproj
179 |
180 | # Microsoft Azure Web App publish settings. Comment the next line if you want to
181 | # checkin your Azure Web App publish settings, but sensitive information contained
182 | # in these scripts will be unencrypted
183 | PublishScripts/
184 |
185 | # NuGet Packages
186 | *.nupkg
187 | # NuGet Symbol Packages
188 | *.snupkg
189 | # The packages folder can be ignored because of Package Restore
190 | **/[Pp]ackages/*
191 | # except build/, which is used as an MSBuild target.
192 | !**/[Pp]ackages/build/
193 | # Uncomment if necessary however generally it will be regenerated when needed
194 | #!**/[Pp]ackages/repositories.config
195 | # NuGet v3's project.json files produces more ignorable files
196 | *.nuget.props
197 | *.nuget.targets
198 |
199 | # Microsoft Azure Build Output
200 | csx/
201 | *.build.csdef
202 |
203 | # Microsoft Azure Emulator
204 | ecf/
205 | rcf/
206 |
207 | # Windows Store app package directories and files
208 | AppPackages/
209 | BundleArtifacts/
210 | Package.StoreAssociation.xml
211 | _pkginfo.txt
212 | *.appx
213 | *.appxbundle
214 | *.appxupload
215 |
216 | # Visual Studio cache files
217 | # files ending in .cache can be ignored
218 | *.[Cc]ache
219 | # but keep track of directories ending in .cache
220 | !?*.[Cc]ache/
221 |
222 | # Others
223 | ClientBin/
224 | ~$*
225 | *~
226 | *.dbmdl
227 | *.dbproj.schemaview
228 | *.jfm
229 | *.pfx
230 | *.publishsettings
231 | orleans.codegen.cs
232 |
233 | # Including strong name files can present a security risk
234 | # (https://github.com/github/gitignore/pull/2483#issue-259490424)
235 | #*.snk
236 |
237 | # Since there are multiple workflows, uncomment next line to ignore bower_components
238 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
239 | #bower_components/
240 |
241 | # RIA/Silverlight projects
242 | Generated_Code/
243 |
244 | # Backup & report files from converting an old project file
245 | # to a newer Visual Studio version. Backup files are not needed,
246 | # because we have git ;-)
247 | _UpgradeReport_Files/
248 | Backup*/
249 | UpgradeLog*.XML
250 | UpgradeLog*.htm
251 | ServiceFabricBackup/
252 | *.rptproj.bak
253 |
254 | # SQL Server files
255 | *.mdf
256 | *.ldf
257 | *.ndf
258 |
259 | # Business Intelligence projects
260 | *.rdl.data
261 | *.bim.layout
262 | *.bim_*.settings
263 | *.rptproj.rsuser
264 | *- [Bb]ackup.rdl
265 | *- [Bb]ackup ([0-9]).rdl
266 | *- [Bb]ackup ([0-9][0-9]).rdl
267 |
268 | # Microsoft Fakes
269 | FakesAssemblies/
270 |
271 | # GhostDoc plugin setting file
272 | *.GhostDoc.xml
273 |
274 | # Node.js Tools for Visual Studio
275 | .ntvs_analysis.dat
276 | node_modules/
277 |
278 | # Visual Studio 6 build log
279 | *.plg
280 |
281 | # Visual Studio 6 workspace options file
282 | *.opt
283 |
284 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
285 | *.vbw
286 |
287 | # Visual Studio LightSwitch build output
288 | **/*.HTMLClient/GeneratedArtifacts
289 | **/*.DesktopClient/GeneratedArtifacts
290 | **/*.DesktopClient/ModelManifest.xml
291 | **/*.Server/GeneratedArtifacts
292 | **/*.Server/ModelManifest.xml
293 | _Pvt_Extensions
294 |
295 | # Paket dependency manager
296 | .paket/paket.exe
297 | paket-files/
298 |
299 | # FAKE - F# Make
300 | .fake/
301 |
302 | # CodeRush personal settings
303 | .cr/personal
304 |
305 | # Python Tools for Visual Studio (PTVS)
306 | __pycache__/
307 | *.pyc
308 |
309 | # Cake - Uncomment if you are using it
310 | # tools/**
311 | # !tools/packages.config
312 |
313 | # Tabs Studio
314 | *.tss
315 |
316 | # Telerik's JustMock configuration file
317 | *.jmconfig
318 |
319 | # BizTalk build output
320 | *.btp.cs
321 | *.btm.cs
322 | *.odx.cs
323 | *.xsd.cs
324 |
325 | # OpenCover UI analysis results
326 | OpenCover/
327 |
328 | # Azure Stream Analytics local run output
329 | ASALocalRun/
330 |
331 | # MSBuild Binary and Structured Log
332 | *.binlog
333 |
334 | # NVidia Nsight GPU debugger configuration file
335 | *.nvuser
336 |
337 | # MFractors (Xamarin productivity tool) working folder
338 | .mfractor/
339 |
340 | # Local History for Visual Studio
341 | .localhistory/
342 |
343 | # BeatPulse healthcheck temp database
344 | healthchecksdb
345 |
346 | # Backup folder for Package Reference Convert tool in Visual Studio 2017
347 | MigrationBackup/
348 |
349 | # Ionide (cross platform F# VS Code tools) working folder
350 | .ionide/
351 | /src/Properties/launchSettings.json
352 |
--------------------------------------------------------------------------------
/FileWatcher.sln:
--------------------------------------------------------------------------------
1 | Microsoft Visual Studio Solution File, Format Version 12.00
2 | # Visual Studio Version 17
3 | VisualStudioVersion = 17.0.32112.339
4 | MinimumVisualStudioVersion = 10.0.40219.1
5 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FileWatcher", "src\FileWatcher.csproj", "{F46457BB-E373-462E-8E5A-8EE669A4B57A}"
6 | EndProject
7 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "templates", "templates", "{571453A7-4850-4D19-8F22-F419C63F45CE}"
8 | ProjectSection(SolutionItems) = preProject
9 | templates\config-template.xml = templates\config-template.xml
10 | EndProjectSection
11 | EndProject
12 | Project("{B7DD6F7E-DEF8-4E67-B5B7-07EF123DB6F0}") = "FwInstaller", "FwInstaller\FwInstaller.wixproj", "{7ABA5E77-930C-490D-A73B-556589F70F6E}"
13 | EndProject
14 | Global
15 | GlobalSection(SolutionConfigurationPlatforms) = preSolution
16 | Debug|Any CPU = Debug|Any CPU
17 | Debug|ARM64 = Debug|ARM64
18 | Debug|x64 = Debug|x64
19 | Debug|x86 = Debug|x86
20 | Release|Any CPU = Release|Any CPU
21 | Release|ARM64 = Release|ARM64
22 | Release|x64 = Release|x64
23 | Release|x86 = Release|x86
24 | EndGlobalSection
25 | GlobalSection(ProjectConfigurationPlatforms) = postSolution
26 | {F46457BB-E373-462E-8E5A-8EE669A4B57A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
27 | {F46457BB-E373-462E-8E5A-8EE669A4B57A}.Debug|Any CPU.Build.0 = Debug|Any CPU
28 | {F46457BB-E373-462E-8E5A-8EE669A4B57A}.Debug|ARM64.ActiveCfg = Debug|Any CPU
29 | {F46457BB-E373-462E-8E5A-8EE669A4B57A}.Debug|ARM64.Build.0 = Debug|Any CPU
30 | {F46457BB-E373-462E-8E5A-8EE669A4B57A}.Debug|x64.ActiveCfg = Debug|Any CPU
31 | {F46457BB-E373-462E-8E5A-8EE669A4B57A}.Debug|x64.Build.0 = Debug|Any CPU
32 | {F46457BB-E373-462E-8E5A-8EE669A4B57A}.Debug|x86.ActiveCfg = Debug|Any CPU
33 | {F46457BB-E373-462E-8E5A-8EE669A4B57A}.Debug|x86.Build.0 = Debug|Any CPU
34 | {F46457BB-E373-462E-8E5A-8EE669A4B57A}.Release|Any CPU.ActiveCfg = Release|Any CPU
35 | {F46457BB-E373-462E-8E5A-8EE669A4B57A}.Release|Any CPU.Build.0 = Release|Any CPU
36 | {F46457BB-E373-462E-8E5A-8EE669A4B57A}.Release|ARM64.ActiveCfg = Release|Any CPU
37 | {F46457BB-E373-462E-8E5A-8EE669A4B57A}.Release|ARM64.Build.0 = Release|Any CPU
38 | {F46457BB-E373-462E-8E5A-8EE669A4B57A}.Release|x64.ActiveCfg = Release|Any CPU
39 | {F46457BB-E373-462E-8E5A-8EE669A4B57A}.Release|x64.Build.0 = Release|Any CPU
40 | {F46457BB-E373-462E-8E5A-8EE669A4B57A}.Release|x86.ActiveCfg = Release|Any CPU
41 | {F46457BB-E373-462E-8E5A-8EE669A4B57A}.Release|x86.Build.0 = Release|Any CPU
42 | {7ABA5E77-930C-490D-A73B-556589F70F6E}.Debug|Any CPU.ActiveCfg = Debug|x64
43 | {7ABA5E77-930C-490D-A73B-556589F70F6E}.Debug|Any CPU.Build.0 = Debug|x64
44 | {7ABA5E77-930C-490D-A73B-556589F70F6E}.Debug|ARM64.ActiveCfg = Debug|ARM64
45 | {7ABA5E77-930C-490D-A73B-556589F70F6E}.Debug|ARM64.Build.0 = Debug|ARM64
46 | {7ABA5E77-930C-490D-A73B-556589F70F6E}.Debug|x64.ActiveCfg = Debug|x64
47 | {7ABA5E77-930C-490D-A73B-556589F70F6E}.Debug|x64.Build.0 = Debug|x64
48 | {7ABA5E77-930C-490D-A73B-556589F70F6E}.Debug|x86.ActiveCfg = Debug|x86
49 | {7ABA5E77-930C-490D-A73B-556589F70F6E}.Debug|x86.Build.0 = Debug|x86
50 | {7ABA5E77-930C-490D-A73B-556589F70F6E}.Release|Any CPU.ActiveCfg = Release|x64
51 | {7ABA5E77-930C-490D-A73B-556589F70F6E}.Release|Any CPU.Build.0 = Release|x64
52 | {7ABA5E77-930C-490D-A73B-556589F70F6E}.Release|ARM64.ActiveCfg = Release|ARM64
53 | {7ABA5E77-930C-490D-A73B-556589F70F6E}.Release|ARM64.Build.0 = Release|ARM64
54 | {7ABA5E77-930C-490D-A73B-556589F70F6E}.Release|x64.ActiveCfg = Release|x64
55 | {7ABA5E77-930C-490D-A73B-556589F70F6E}.Release|x64.Build.0 = Release|x64
56 | {7ABA5E77-930C-490D-A73B-556589F70F6E}.Release|x86.ActiveCfg = Release|x86
57 | {7ABA5E77-930C-490D-A73B-556589F70F6E}.Release|x86.Build.0 = Release|x86
58 | EndGlobalSection
59 | GlobalSection(SolutionProperties) = preSolution
60 | HideSolutionNode = FALSE
61 | EndGlobalSection
62 | GlobalSection(ExtensibilityGlobals) = postSolution
63 | SolutionGuid = {75F729D8-AFCB-4AE3-8FEE-5B4A658ACE0D}
64 | EndGlobalSection
65 | EndGlobal
66 |
--------------------------------------------------------------------------------
/FileWatcher/Properties/PublishProfiles/win-x64.pubxml:
--------------------------------------------------------------------------------
1 |
2 |
5 |
6 |
7 | Release
8 | Any CPU
9 | bin\Release\net6.0\win-x64\publish
10 | FileSystem
11 | net6.0
12 | win-x64
13 | true
14 | false
15 |
16 |
--------------------------------------------------------------------------------
/FwInstaller/FwInstaller.wixproj:
--------------------------------------------------------------------------------
1 |
2 |
3 | ..\FileWatcher\bin\Release\net6.0\publish\win-x64
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
--------------------------------------------------------------------------------
/FwInstaller/Package.wxs:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
32 |
33 |
34 |
35 |
36 |
37 |
39 |
42 |
43 |
46 |
47 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
63 |
64 |
65 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
--------------------------------------------------------------------------------
/FwInstaller/config.xml.sample:
--------------------------------------------------------------------------------
1 |
2 |
3 |
8 | c:\temp\fw.log
9 |
15 | 5
16 |
22 | 10
23 |
24 |
25 |
26 |
30 | c:\temp\test
31 |
37 | 1
38 |
39 |
43 |
44 |
45 |
46 |
51 |
52 |
53 |
54 |
66 |
67 |
68 |
69 |
75 |
76 |
77 |
78 |
79 |
80 |
84 |
85 | ExcludeThis
86 |
87 |
92 |
93 | ExcludeThis
94 |
95 |
107 |
108 |
109 |
110 |
116 |
117 | ExcludeThis
118 |
119 |
120 |
121 |
126 | 30000
127 |
128 |
129 |
138 |
139 |
148 |
149 |
150 |
151 |
152 |
153 |
157 |
158 |
162 |
163 |
170 |
171 |
172 |
173 |
174 |
177 |
178 |
179 |
188 |
189 | Change
190 |
191 |
199 | Copy
200 |
211 | [exactpath]
212 |
227 | c:\temp\dest\[file]
228 |
235 | false
236 |
243 | true
244 |
245 |
246 |
249 |
250 |
251 |
260 |
261 |
262 |
263 |
274 |
275 |
286 |
287 |
288 |
289 |
290 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 Paul Salmon
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # File Watcher
2 |
3 | File Watcher is an application designed to monitor folders and files on the local system. When specific changes are detected a notification, can be sent to an endpoint via an API request, an action (copy, move, delete) can be performed, or a command executed.
4 |
5 | ## Features
6 |
7 | File Watcher includes the following:
8 |
9 | **Monitor files and folders.** Specify paths to folders on a local or external hard drive, and perform an action when a file or folder is created, modified, or deleted in the path.
10 |
11 | **Exclude, or include, specific files and folders.** Files and folders can be excluded from monitoring based on the name, attribute, or path.
12 |
13 | **Send notifications to an API endpoint.** Send an API request to an endpoint when a file or folder is created, modified, or deleted.
14 |
15 | **Perform an action.** Copy, move, or delete a file or folder when a change is detected.
16 |
17 | **Run a command.** Run a command, such as an executable or script, when a file or folder change is detected.
18 |
19 | **Portable.** No installation is required. Download the [latest release](https://github.com/TechieGuy12/FileWatcher/releases/latest) and unzip the contents into a folder. Create the [configuration file](https://github.com/TechieGuy12/FileWatcher/wiki/Configuration-File) and then run the executable.
20 |
21 | **Low resource usage.** With 7 watches monitoring a mix of internal and USB-connected external hard drives, File Watcher uses less than 40 MB of RAM and negligible CPU usage.
22 |
23 | **Logging.** Writes to a log file, that includes rollover functionality.
24 |
25 | ## System Support
26 |
27 | - Windows
28 | - MacOS
29 | - Linux
30 |
31 | For information using File Watcher, please read the [Wiki](https://github.com/TechieGuy12/FileWatcher/wiki).
32 |
33 | For example use cases for File Watcher, please read [Use Cases](https://github.com/TechieGuy12/FileWatcher/wiki/Use-Cases).
34 |
--------------------------------------------------------------------------------
/src/Configuration/Action.cs:
--------------------------------------------------------------------------------
1 | using System.Xml.Serialization;
2 | using TE.FileWatcher.Log;
3 | using TEFS = TE.FileWatcher.FileSystem;
4 |
5 | namespace TE.FileWatcher.Configuration
6 | {
7 | ///
8 | /// The Action to perform during a watch event.
9 | ///
10 | public class Action : RunnableBase
11 | {
12 | ///
13 | /// The type of action to perform.
14 | ///
15 | [Serializable]
16 | public enum ActionType
17 | {
18 | ///
19 | /// Copy a file.
20 | ///
21 | Copy,
22 | ///
23 | /// Move a file.
24 | ///
25 | Move,
26 | ///
27 | /// Delete a file.
28 | ///
29 | Delete
30 | }
31 |
32 | ///
33 | /// Gets or sets the type of action to perform.
34 | ///
35 | [XmlElement("type")]
36 | public ActionType Type { get; set; }
37 |
38 | ///
39 | /// Gets or sets the source of the action.
40 | ///
41 | [XmlElement("source")]
42 | public string Source { get; set; } = Placeholder.PLACEHOLDERFULLPATH;
43 |
44 | ///
45 | /// Gets or sets the destination of the action.
46 | ///
47 | [XmlElement("destination")]
48 | public string? Destination { get; set; }
49 |
50 | ///
51 | /// Gets or sets the verify flag.
52 | ///
53 | [XmlElement(ElementName = "verify", DataType = "boolean")]
54 | public bool Verify { get; set; }
55 |
56 | ///
57 | /// Gets or sets the keep timestamps flag.
58 | ///
59 | [XmlElement(ElementName = "keepTimestamps", DataType = "boolean")]
60 | public bool KeepTimestamps { get; set; }
61 |
62 | ///
63 | /// Runs the action.
64 | ///
65 | ///
66 | /// The watch path.
67 | ///
68 | ///
69 | /// The full path to the changed file or folder.
70 | ///
71 | ///
72 | /// The trigger for the action.
73 | ///
74 | public new void Run(ChangeInfo change, TriggerType trigger)
75 | {
76 | try
77 | {
78 | base.Run(change, trigger);
79 | }
80 | catch (ArgumentNullException e)
81 | {
82 | Logger.WriteLine(e.Message);
83 | return;
84 | }
85 | catch (InvalidOperationException e)
86 | {
87 | Logger.WriteLine(e.Message);
88 | return;
89 | }
90 | catch (FileWatcherTriggerNotMatchException)
91 | {
92 | return;
93 | }
94 |
95 | Logger.WriteLine($"Waiting for {WaitBefore} milliseconds.");
96 | Thread.Sleep(WaitBefore);
97 |
98 | string? source = GetSource();
99 | string? destination = GetDestination();
100 |
101 | if (string.IsNullOrWhiteSpace(source))
102 | {
103 | if (Change != null)
104 | {
105 | Logger.WriteLine(
106 | $"The source file could not be determined. Watch path: {Change.WatchPath}, changed: {Change.FullPath}.",
107 | LogLevel.ERROR);
108 | }
109 | return;
110 | }
111 |
112 | try
113 | {
114 | if (!TEFS.File.IsValid(source))
115 | {
116 | Logger.WriteLine(
117 | $"The file '{source}' could not be {GetActionString()} because the path was not valid, the file doesn't exists, or it was in use.",
118 | LogLevel.ERROR);
119 | return;
120 | }
121 |
122 | switch (Type)
123 | {
124 | case ActionType.Copy:
125 | if (string.IsNullOrWhiteSpace(destination))
126 | {
127 | Logger.WriteLine(
128 | $"The file '{source}' could not be copied because the destination file could not be determined. Destination in config file: {Destination}.",
129 | LogLevel.ERROR);
130 | return;
131 | }
132 |
133 | TEFS.File.Copy(source, destination, Verify, KeepTimestamps);
134 | Logger.WriteLine($"Copied {source} to {destination}. Verify: {Verify}. Keep timestamps: {KeepTimestamps}.");
135 | break;
136 |
137 | case ActionType.Move:
138 | if (string.IsNullOrWhiteSpace(destination))
139 | {
140 | Logger.WriteLine(
141 | $"The file '{source}' could not be moved because the destination file could not be determined. Destination in config file: {Destination}.",
142 | LogLevel.ERROR);
143 | return;
144 | }
145 |
146 | TEFS.File.Move(source, destination, Verify, KeepTimestamps);
147 | Logger.WriteLine($"Moved {source} to {destination}. Verify: {Verify}. Keep timestamps: {KeepTimestamps}.");
148 | break;
149 |
150 | case ActionType.Delete:
151 | TEFS.File.Delete(source);
152 | Logger.WriteLine($"Deleted {source}.");
153 | break;
154 | }
155 | }
156 | catch (Exception ex)
157 | when (ex is ArgumentNullException || ex is FileNotFoundException || ex is FileWatcherException)
158 | {
159 | Exception exception = ex.InnerException ?? ex;
160 | Logger.WriteLine(
161 | $"Could not {Type.ToString().ToLower(System.Globalization.CultureInfo.CurrentCulture)} file '{source}.' Reason: {exception.Message}",
162 | LogLevel.ERROR);
163 | if (ex.StackTrace != null)
164 | {
165 | Logger.WriteLine(ex.StackTrace);
166 | }
167 | return;
168 | }
169 | }
170 |
171 | ///
172 | /// Gets the string value that represents the action type.
173 | ///
174 | ///
175 | /// A string value for the action type, otherwise null.
176 | ///
177 | private string? GetActionString()
178 | {
179 | return Type switch
180 | {
181 | ActionType.Copy => "copied",
182 | ActionType.Move => "moved",
183 | ActionType.Delete => "deleted",
184 | _ => null
185 | };
186 | }
187 |
188 | ///
189 | /// Gets the destination value by replacing any placeholders with the
190 | /// actual string values.
191 | ///
192 | ///
193 | /// The destination string value.
194 | ///
195 | private string? GetDestination()
196 | {
197 | if (string.IsNullOrWhiteSpace(Destination) || Change == null)
198 | {
199 | return null;
200 | }
201 |
202 | return Placeholder.ReplacePlaceholders(
203 | Destination,
204 | Change.WatchPath,
205 | Change.FullPath,
206 | Change.OldPath);
207 | }
208 |
209 | ///
210 | /// Gets the source value by replacing any placeholders with the actual
211 | /// string values.
212 | ///
213 | ///
214 | /// The source string value.
215 | ///
216 | private string? GetSource()
217 | {
218 | if (string.IsNullOrWhiteSpace(Source) || Change == null)
219 | {
220 | return null;
221 | }
222 |
223 | return Placeholder.ReplacePlaceholders(
224 | Source,
225 | Change.WatchPath,
226 | Change.FullPath,
227 | Change.OldPath);
228 | }
229 | }
230 | }
231 |
--------------------------------------------------------------------------------
/src/Configuration/Actions.cs:
--------------------------------------------------------------------------------
1 | using System.Collections.ObjectModel;
2 | using System.Xml.Serialization;
3 |
4 | namespace TE.FileWatcher.Configuration
5 | {
6 | ///
7 | /// Contains information about all actions for a watch.
8 | ///
9 | [XmlRoot("actions")]
10 | public class Actions
11 | {
12 | ///
13 | /// Gets or sets the list of actions to perform.
14 | ///
15 | [XmlElement("action")]
16 | public Collection? ActionList { get; set; }
17 |
18 | ///
19 | /// Runs all the actions for the watch.
20 | ///
21 | ///
22 | /// Information about the change.
23 | ///
24 | public void Run(TriggerType trigger, ChangeInfo change)
25 | {
26 | if (ActionList == null || ActionList.Count <= 0)
27 | {
28 | return;
29 | }
30 |
31 | foreach (Action action in ActionList)
32 | {
33 | action.Run(change, trigger);
34 | }
35 | }
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/src/Configuration/ChangeInfo.cs:
--------------------------------------------------------------------------------
1 | namespace TE.FileWatcher.Configuration
2 | {
3 | ///
4 | /// Information about the change.
5 | ///
6 | public class ChangeInfo
7 | {
8 | ///
9 | /// Gets the trigger for the change.
10 | ///
11 | public TriggerType Trigger { get; private set; }
12 |
13 | ///
14 | /// Gets the name of the file/folder.
15 | ///
16 | public string Name { get; private set; }
17 |
18 | ///
19 | /// Gets the full path to the file/folder.
20 | ///
21 | public string FullPath { get; private set; }
22 |
23 | ///
24 | /// Gets the old name of the file/folder on rename. This value is
25 | /// null for all other changes.
26 | ///
27 | public string? OldName { get; private set; }
28 |
29 | ///
30 | /// Gets the old path of the file/folder on rename. This value is
31 | /// null for all other changes.
32 | ///
33 | public string? OldPath { get; private set; }
34 |
35 | ///
36 | /// Gets the watch path of the file/folder.
37 | ///
38 | public string WatchPath { get; private set; }
39 |
40 | ///
41 | /// Initializes an instance of the .
42 | ///
43 | //public ChangeInfo() { }
44 |
45 | ///
46 | /// Initializes an instance of the class when
47 | /// provided with the trigger, the file/folder name, and the full path
48 | /// to the file/folder.
49 | ///
50 | ///
51 | /// The type of change.
52 | ///
53 | ///
54 | /// The name of the file or folder.
55 | ///
56 | ///
57 | /// The full path of the file or folder.
58 | ///
59 | ///
60 | /// The old name of the file or folder.
61 | ///
62 | ///
63 | /// The old path of the file or folder.
64 | ///
65 | ///
66 | /// Thrown when a parameter is null.
67 | ///
68 | public ChangeInfo(TriggerType trigger, string watchPath, string name, string fullPath, string? oldName, string? oldPath)
69 | {
70 | Trigger = trigger;
71 | WatchPath = watchPath ?? throw new ArgumentNullException(nameof(watchPath));
72 | Name = name ?? throw new ArgumentNullException(nameof(name));
73 | FullPath = fullPath ?? throw new ArgumentNullException(nameof(fullPath));
74 | OldName = oldName;
75 | OldPath = oldPath;
76 | }
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/src/Configuration/Command.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Collections.Concurrent;
3 | using System.Diagnostics;
4 | using System.Xml.Serialization;
5 | using TE.FileWatcher.Log;
6 |
7 | namespace TE.FileWatcher.Configuration
8 | {
9 | ///
10 | /// A command to run for a change.
11 | ///
12 | public class Command : RunnableBase, IDisposable
13 | {
14 | // The process that will run the command
15 | private Process? _process;
16 |
17 | // A queue containing the information to start the command process
18 | private ConcurrentQueue? _processInfo;
19 |
20 | // Flag indicating that a process is running
21 | private bool _isProcessRunning;
22 |
23 | // Flag indicating the class is disposed
24 | private bool _disposed;
25 |
26 | ///
27 | /// Gets or sets the arguments associated with the file to execute.
28 | ///
29 | [XmlElement("arguments")]
30 | public string? Arguments { get; set; }
31 |
32 | ///
33 | /// Gets or sets the full path to the file to executed.
34 | ///
35 | [XmlElement("path")]
36 | public string? Path { get; set; }
37 |
38 | ///
39 | /// Queues the command process to be run.
40 | ///
41 | ///
42 | /// The watch path.
43 | ///
44 | ///
45 | /// The full path to the changed file or folder.
46 | ///
47 | ///
48 | /// The trigger for the command.
49 | ///
50 | public new void Run(ChangeInfo change, TriggerType trigger)
51 | {
52 | try
53 | {
54 | base.Run(change, trigger);
55 | }
56 | catch (ArgumentNullException e)
57 | {
58 | Logger.WriteLine(e.Message);
59 | return;
60 | }
61 | catch (InvalidOperationException e)
62 | {
63 | Logger.WriteLine(e.Message);
64 | return;
65 | }
66 | catch (FileWatcherTriggerNotMatchException)
67 | {
68 | return;
69 | }
70 |
71 | Logger.WriteLine($"Waiting for {WaitBefore} milliseconds.");
72 | Thread.Sleep(WaitBefore);
73 |
74 | string? commandPath = GetCommand();
75 | string? arguments = GetArguments();
76 |
77 | if (string.IsNullOrWhiteSpace(commandPath))
78 | {
79 | Logger.WriteLine($"The command was not provided. Command was not run.",
80 | LogLevel.ERROR);
81 | return;
82 | }
83 |
84 | if (!File.Exists(commandPath))
85 | {
86 | Logger.WriteLine(
87 | $"The command '{commandPath}' was not found. Command was not run.",
88 | LogLevel.ERROR);
89 | return;
90 | }
91 |
92 | _processInfo ??= new ConcurrentQueue();
93 |
94 | ProcessStartInfo startInfo = new()
95 | {
96 | FileName = commandPath,
97 | RedirectStandardOutput = true
98 | };
99 |
100 | if (arguments != null)
101 | {
102 | startInfo.Arguments = arguments;
103 | }
104 |
105 | _processInfo.Enqueue(startInfo);
106 |
107 | // Execute the next process in the queue
108 | Execute();
109 | }
110 |
111 | ///
112 | /// Releases all resources used by the class.
113 | ///
114 | public void Dispose()
115 | {
116 | Dispose(true);
117 | GC.SuppressFinalize(this);
118 | }
119 |
120 | ///
121 | /// Release all resources used by the class.
122 | ///
123 | ///
124 | /// Indicates the whether the class is disposing.
125 | ///
126 | protected virtual void Dispose(bool disposing)
127 | {
128 | if (_disposed)
129 | {
130 | return;
131 | }
132 |
133 | if (disposing)
134 | {
135 | _process?.Dispose();
136 | }
137 |
138 | _disposed = true;
139 | }
140 |
141 | ///
142 | /// Executes the next command process from the queue.
143 | ///
144 | private void Execute()
145 | {
146 | // If the queue is null or empty, then no command is waiting to nbe
147 | // executed
148 | if (_processInfo == null || _processInfo.IsEmpty)
149 | {
150 | return;
151 | }
152 |
153 | // If a command is currently running, then don't start another
154 | if (_isProcessRunning)
155 | {
156 | return;
157 | }
158 |
159 | try
160 | {
161 | if (_processInfo.TryDequeue(out ProcessStartInfo? startInfo))
162 | {
163 | if (File.Exists(startInfo.FileName))
164 | {
165 | _process = new Process
166 | {
167 | StartInfo = startInfo
168 | };
169 | _process.StartInfo.CreateNoWindow = true;
170 | _process.StartInfo.UseShellExecute = false;
171 | _process.EnableRaisingEvents = true;
172 | _process.Exited += OnProcessExit;
173 | _process.OutputDataReceived += OnOutputDataReceived;
174 | _isProcessRunning = _process.Start();
175 | _process.BeginOutputReadLine();
176 | }
177 | else
178 | {
179 | Logger.WriteLine(
180 | $"The command '{startInfo.FileName}' was not found. Command was not run.",
181 | LogLevel.ERROR);
182 |
183 | // Execute the next process in the queue
184 | Execute();
185 | }
186 | }
187 | }
188 | catch (Exception ex)
189 | when (ex is ArgumentNullException || ex is InvalidOperationException || ex is PlatformNotSupportedException || ex is System.ComponentModel.Win32Exception || ex is ObjectDisposedException)
190 | {
191 | if (_process != null)
192 | {
193 | Logger.WriteLine(
194 | $"Could not run the command '{_process.StartInfo.FileName} {_process.StartInfo.Arguments}'. Reason: {ex.Message}",
195 | LogLevel.ERROR);
196 | _process.Close();
197 | }
198 | else
199 | {
200 | Logger.WriteLine(
201 | $"Could not run the command. Reason: {ex.Message}",
202 | LogLevel.ERROR);
203 | }
204 | }
205 | }
206 |
207 | ///
208 | /// Gets the arguments value by replacing any placeholders with the
209 | /// actual string values.
210 | ///
211 | ///
212 | /// The command path string value.
213 | ///
214 | private string? GetArguments()
215 | {
216 | if (string.IsNullOrWhiteSpace(Arguments) || Change == null)
217 | {
218 | return null;
219 | }
220 |
221 | return Placeholder.ReplacePlaceholders(
222 | Arguments,
223 | Change.WatchPath,
224 | Change.FullPath,
225 | Change.OldPath);
226 | }
227 |
228 | ///
229 | /// Gets the command path value by replacing any placeholders with the
230 | /// actual string values.
231 | ///
232 | ///
233 | /// The command path string value.
234 | ///
235 | private string? GetCommand()
236 | {
237 | if (string.IsNullOrWhiteSpace(Path) || Change == null)
238 | {
239 | return null;
240 | }
241 |
242 | return Placeholder.ReplacePlaceholders(
243 | Path,
244 | Change.WatchPath,
245 | Change.FullPath,
246 | Change.OldPath);
247 | }
248 |
249 | ///
250 | /// The event that is raised when the process has exied.
251 | ///
252 | ///
253 | /// The sender.
254 | ///
255 | ///
256 | /// The event arguments.
257 | ///
258 | private void OnProcessExit(object? sender, EventArgs args)
259 | {
260 | _isProcessRunning = false;
261 |
262 | if (_process == null)
263 | {
264 | return;
265 | }
266 |
267 | Logger.WriteLine($"The execution '{_process.StartInfo.FileName} {_process.StartInfo.Arguments}' has exited. Exit code: {_process.ExitCode}.");
268 | _process.Dispose();
269 | _process = null;
270 |
271 | // Execute the next process in the queue
272 | Execute();
273 | }
274 |
275 | ///
276 | /// Write the output of the command to the log file.
277 | ///
278 | ///
279 | /// The sender.
280 | ///
281 | ///
282 | /// The event arguments.
283 | ///
284 | private void OnOutputDataReceived(object? sender, DataReceivedEventArgs e)
285 | {
286 | if (!string.IsNullOrEmpty(e.Data))
287 | {
288 | Logger.WriteLine($"{e.Data}");
289 | }
290 | }
291 | }
292 | }
293 |
--------------------------------------------------------------------------------
/src/Configuration/Commands.cs:
--------------------------------------------------------------------------------
1 | using System.Collections.ObjectModel;
2 | using System.Xml.Serialization;
3 |
4 | namespace TE.FileWatcher.Configuration
5 | {
6 | ///
7 | /// The commands to run when a change is detected.
8 | ///
9 | [XmlRoot("commands")]
10 | public class Commands
11 | {
12 | ///
13 | /// Gets or sets the list of actions to perform.
14 | ///
15 | [XmlElement("command")]
16 | public Collection? CommandList { get; set; }
17 |
18 | ///
19 | /// Runs all the commands for the watch.
20 | ///
21 | ///
22 | /// Information about the change.
23 | ///
24 | public void Run(TriggerType trigger, ChangeInfo change)
25 | {
26 | if (CommandList == null || CommandList.Count <= 0)
27 | {
28 | return;
29 | }
30 |
31 | foreach (Command command in CommandList)
32 | {
33 | command.Run(change, trigger);
34 | }
35 | }
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/src/Configuration/Data.cs:
--------------------------------------------------------------------------------
1 | using System.Xml.Serialization;
2 | using TE.FileWatcher.Net;
3 |
4 | namespace TE.FileWatcher.Configuration
5 | {
6 | ///
7 | /// Contains the data used to send the request.
8 | ///
9 | public class Data
10 | {
11 | // The MIME type
12 | private string _mimeType = Request.JSON_NAME;
13 |
14 | ///
15 | /// Gets or sets the headers for the request.
16 | ///
17 | [XmlElement("headers")]
18 | public Headers? Headers { get; set; }
19 |
20 | ///
21 | /// Gets or sets the body for the request.
22 | ///
23 | [XmlElement("body")]
24 | public string? Body { get; set; }
25 |
26 | ///
27 | /// Gets or sets the MIME type string value.
28 | ///
29 | [XmlElement("type")]
30 | public string MimeTypeString
31 | {
32 | get
33 | {
34 | return _mimeType;
35 | }
36 | set
37 | {
38 | _mimeType = value == Request.JSON_NAME || value == Request.XML_NAME ? value : Request.JSON_NAME;
39 | }
40 | }
41 |
42 | ///
43 | /// Gets the MIME type from the string value.
44 | ///
45 | [XmlIgnore]
46 | internal Request.MimeType MimeType
47 | {
48 | get
49 | {
50 | if (_mimeType == Request.XML_NAME)
51 | {
52 | return Request.MimeType.Xml;
53 | }
54 | else
55 | {
56 | return Request.MimeType.Json;
57 | }
58 | }
59 | }
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/src/Configuration/Exclusions.cs:
--------------------------------------------------------------------------------
1 | using TE.FileWatcher.IO;
2 |
3 | namespace TE.FileWatcher.Configuration
4 | {
5 | ///
6 | /// An exclusions node in the XML file.
7 | ///
8 | public class Exclusions : MatchBase
9 | {
10 | ///
11 | /// Returns the flag indicating if the change is to be ignored.
12 | ///
13 | ///
14 | /// Information about the change.
15 | ///
16 | ///
17 | /// True if the change is to be ignored, otherwise false.
18 | ///
19 | ///
20 | /// Thrown when there is a problem with the path.
21 | ///
22 | public bool Exclude(ChangeInfo change)
23 | {
24 | FilterTypeName = "Exclude";
25 | return IsMatchFound(change);
26 | }
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/src/Configuration/Filters.cs:
--------------------------------------------------------------------------------
1 | using TE.FileWatcher.IO;
2 |
3 | namespace TE.FileWatcher.Configuration
4 | {
5 | ///
6 | /// A filters node in the XML file.
7 | ///
8 | public class Filters : MatchBase
9 | {
10 | ///
11 | /// Returns the flag indicating if the change is a match.
12 | ///
13 | ///
14 | /// Information about the change.
15 | ///
16 | ///
17 | /// True if the change is to be ignored, otherwise false.
18 | ///
19 | ///
20 | /// Thrown when the change parameter is null.
21 | ///
22 | ///
23 | /// Thrown when there is a problem with the path.
24 | ///
25 | public bool IsMatch(ChangeInfo change)
26 | {
27 | if (change == null)
28 | {
29 | throw new ArgumentNullException(nameof(change));
30 | }
31 |
32 | FilterTypeName = "Filter";
33 | return IsMatchFound(change);
34 | }
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/src/Configuration/Header.cs:
--------------------------------------------------------------------------------
1 | using System.Xml.Serialization;
2 |
3 | namespace TE.FileWatcher.Configuration
4 | {
5 | public class Header
6 | {
7 | ///
8 | /// Gets or sets the name of the header.
9 | ///
10 | [XmlElement("name")]
11 | public string? Name { get; set; }
12 |
13 | ///
14 | /// Gets or sets the value of othe header.
15 | ///
16 | [XmlElement("value")]
17 | public string? Value { get; set; }
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/src/Configuration/Headers.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Collections.ObjectModel;
3 | using System.Xml.Serialization;
4 |
5 | namespace TE.FileWatcher.Configuration
6 | {
7 | ///
8 | /// Contains the headers information.
9 | ///
10 | public class Headers : ItemBase
11 | {
12 | ///
13 | /// Get or sets the list of headers to add to a request.
14 | ///
15 | [XmlElement("header")]
16 | public Collection? HeaderList { get; set; }
17 |
18 | ///
19 | /// Sets the headers for a request.
20 | ///
21 | ///
22 | /// The request that will include the headers.
23 | ///
24 | public void Set(HttpRequestMessage request)
25 | {
26 | if (request == null || Change == null)
27 | {
28 | return;
29 | }
30 |
31 | if (HeaderList == null || HeaderList.Count <= 0)
32 | {
33 | return;
34 | }
35 |
36 | foreach (Header header in HeaderList)
37 | {
38 | if (!string.IsNullOrWhiteSpace(header.Name))
39 | {
40 | string? value = header.Value;
41 | if (!string.IsNullOrWhiteSpace(value))
42 | {
43 | value = Placeholder.ReplacePlaceholders(
44 | value,
45 | Change.WatchPath,
46 | Change.FullPath,
47 | Change.OldPath);
48 | }
49 |
50 | request.Headers.Add(header.Name, value);
51 | }
52 | }
53 | }
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/src/Configuration/IConfigurationFile.cs:
--------------------------------------------------------------------------------
1 | using System.Diagnostics.CodeAnalysis;
2 |
3 | namespace TE.FileWatcher.Configuration
4 | {
5 | ///
6 | /// Interface for the configuration file.
7 | ///
8 | interface IConfigurationFile
9 | {
10 | ///
11 | /// Reads the configuration file.
12 | ///
13 | ///
14 | /// A object if the file was read successfully,
15 | /// otherwise null.
16 | ///
17 | [RequiresUnreferencedCode("Could call functionality incompatible with trimming.")]
18 | public Watches? Read();
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/src/Configuration/ItemBase.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Collections.Generic;
3 | using System.Linq;
4 | using System.Text;
5 | using System.Threading.Tasks;
6 | using System.Xml.Serialization;
7 |
8 | namespace TE.FileWatcher.Configuration
9 | {
10 | public abstract class ItemBase
11 | {
12 |
13 | ///
14 | /// The object used to replace placeholders in strings.
15 | ///
16 | protected Placeholder Placeholder { get; } = new Placeholder();
17 |
18 | ///
19 | /// Gets or sets the change information.
20 | ///
21 | [XmlIgnore]
22 | public ChangeInfo? Change { get; set; }
23 |
24 | ///
25 | /// Gets or sets the triggers of the action.
26 | ///
27 | [XmlElement("triggers")]
28 | public Triggers Triggers { get; set; } = new Triggers();
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/src/Configuration/Logging.cs:
--------------------------------------------------------------------------------
1 | using System.Security;
2 | using System.Xml.Serialization;
3 | using TE.FileWatcher.Log;
4 |
5 | namespace TE.FileWatcher.Configuration
6 | {
7 | ///
8 | /// Contains information about the log file.
9 | ///
10 | public class Logging
11 | {
12 | ///
13 | /// The default log size.
14 | ///
15 | public const int DEFAULTLOGSIZE = 5;
16 |
17 | ///
18 | /// The default log number.
19 | ///
20 | public const int DEFAULTLOGNUMBER = 10;
21 |
22 | // The log path
23 | private string? _logPath;
24 |
25 | // The size of the log file
26 | private int _logSize = DEFAULTLOGSIZE;
27 |
28 | // The number of log files to retain
29 | private int _logNumber = DEFAULTLOGNUMBER;
30 |
31 | ///
32 | /// Gets or sets the path of the log file.
33 | ///
34 | [XmlElement("path")]
35 | public string? LogPath
36 | {
37 | get
38 | {
39 | return _logPath;
40 | }
41 | set
42 | {
43 | _logPath = !string.IsNullOrWhiteSpace(value) ? value : Path.Combine(Path.GetTempPath(), Logger.DEFAULTLOGNAME);
44 | }
45 | }
46 |
47 | ///
48 | /// Gets or sets the size (in megabytes) of a log file before it is
49 | /// backed up and a new log file is created.
50 | ///
51 | [XmlElement("size")]
52 | public int Size
53 | {
54 | get
55 | {
56 | return _logSize;
57 | }
58 | set
59 | {
60 | _logSize = value > 0 ? value : DEFAULTLOGSIZE;
61 | }
62 | }
63 |
64 | ///
65 | /// Gets or sets the number of log file to retain.
66 | ///
67 | [XmlElement("number")]
68 | public int Number
69 | {
70 | get
71 | {
72 | return _logNumber;
73 | }
74 | set
75 | {
76 | _logNumber = value > 0 ? value : DEFAULTLOGNUMBER;
77 | }
78 | }
79 |
80 | ///
81 | /// Initializes an instance of the class.
82 | ///
83 | ///
84 | /// Thrown when the default log path could not be created.
85 | ///
86 | ///
87 | /// Thrown when the default log path could not be created.
88 | ///
89 | ///
90 | /// Thrown when the temporary folder can't be accessed by the user.
91 | ///
92 | public Logging()
93 | {
94 | LogPath = Path.Combine(Path.GetTempPath(), Logger.DEFAULTLOGNAME);
95 | Size = DEFAULTLOGSIZE;
96 | Number = DEFAULTLOGNUMBER;
97 | }
98 | }
99 | }
100 |
--------------------------------------------------------------------------------
/src/Configuration/Notification.cs:
--------------------------------------------------------------------------------
1 | using System.Globalization;
2 | using System.Text;
3 | using System.Xml.Serialization;
4 | using TE.FileWatcher.Net;
5 |
6 | namespace TE.FileWatcher.Configuration
7 | {
8 | ///
9 | /// A notification that will be triggered.
10 | ///
11 | public class Notification : ItemBase
12 | {
13 | // The message to send with the request
14 | private readonly StringBuilder _message;
15 |
16 | ///
17 | /// Gets or sets the URL of the request.
18 | ///
19 | [XmlElement("url")]
20 | public string? Url { get; set; }
21 |
22 | ///
23 | /// Gets or sets the string representation of the request method.
24 | ///
25 | [XmlElement("method")]
26 | public string? MethodString { get; set; }
27 |
28 | ///
29 | /// Gets the request method.
30 | ///
31 | [XmlIgnore]
32 | public HttpMethod Method
33 | {
34 | get
35 | {
36 | if (string.IsNullOrWhiteSpace(MethodString))
37 | {
38 | return HttpMethod.Post;
39 | }
40 |
41 | return MethodString.ToLower(CultureInfo.CurrentCulture) switch
42 | {
43 | "get" => HttpMethod.Get,
44 | "delete" => HttpMethod.Delete,
45 | "put" => HttpMethod.Put,
46 | _ => HttpMethod.Post,
47 | };
48 | }
49 | }
50 |
51 | ///
52 | /// Gets or sets the data to send for the request.
53 | ///
54 | [XmlElement("data")]
55 | public Data? Data { get; set; }
56 |
57 | ///
58 | /// Returns a value indicating if there is a message waiting to be sent
59 | /// for the notification.
60 | ///
61 | [XmlIgnore]
62 | public bool HasMessage
63 | {
64 | get
65 | {
66 | if (_message == null)
67 | {
68 | return false;
69 | }
70 |
71 | return _message.Length > 0;
72 | }
73 | }
74 |
75 | ///
76 | /// Initializes an instance of the class.
77 | ///
78 | public Notification()
79 | {
80 | _message = new StringBuilder();
81 | }
82 |
83 | ///
84 | /// Sends the notification.
85 | ///
86 | ///
87 | /// The value that replaces the [message] placeholder.
88 | ///
89 | ///
90 | /// The trigger for the request.
91 | ///
92 | ///
93 | /// The watch path.
94 | ///
95 | ///
96 | /// Information about the change.
97 | ///
98 | internal void QueueRequest(string message, TriggerType trigger, ChangeInfo change)
99 | {
100 | if (Triggers == null || Triggers.TriggerList == null || Triggers.TriggerList.Count <= 0)
101 | {
102 | return;
103 | }
104 |
105 | if (Triggers.Current.HasFlag(trigger))
106 | {
107 | _message.Append(CleanMessage(message) + @"\n");
108 | }
109 |
110 | Change = change;
111 | }
112 |
113 | ///
114 | /// Send the notification request.
115 | ///
116 | ///
117 | /// Thrown when the URL is null or empty.
118 | ///
119 | ///
120 | /// Thrown when the URL is not in a valid format.
121 | ///
122 | internal async Task SendAsync()
123 | {
124 | // If there isn't a message to be sent, then just return
125 | if (_message == null || _message.Length <= 0)
126 | {
127 | return null;
128 | }
129 |
130 | if (GetUri() == null)
131 | {
132 | throw new InvalidOperationException("The URL is null or empty.");
133 | }
134 |
135 | Data ??= new Data();
136 | if (Data.Headers != null)
137 | {
138 | Data.Headers.Change = Change;
139 | }
140 |
141 | string? content = string.Empty;
142 | if (Data.Body != null)
143 | {
144 | content = Data.Body.Replace("[message]", _message.ToString(), StringComparison.OrdinalIgnoreCase);
145 |
146 | if (Change != null)
147 | {
148 | content = Placeholder.ReplacePlaceholders(
149 | content,
150 | Change.WatchPath,
151 | Change.FullPath,
152 | Change.OldPath);
153 | }
154 | }
155 |
156 | Response response =
157 | await Request.SendAsync(
158 | Method,
159 | GetUri(),
160 | Data.Headers,
161 | content,
162 | Data.MimeType).ConfigureAwait(false);
163 |
164 | _message.Clear();
165 | return response;
166 | }
167 |
168 | ///
169 | /// Escapes the special characters in the message so it can be sent as
170 | /// a JSON string.
171 | ///
172 | ///
173 | /// The message to escape.
174 | ///
175 | ///
176 | /// The JSON string with the special characters escaped.
177 | ///
178 | private static string CleanMessage(string s)
179 | {
180 | if (s == null || s.Length == 0)
181 | {
182 | return "";
183 | }
184 |
185 | char c = '\0';
186 | int i;
187 | int len = s.Length;
188 | StringBuilder sb = new(len + 4);
189 | string t;
190 |
191 | for (i = 0; i < len; i += 1)
192 | {
193 | c = s[i];
194 | switch (c)
195 | {
196 | case '\\':
197 | case '"':
198 | sb.Append('\\');
199 | sb.Append(c);
200 | break;
201 | case '/':
202 | sb.Append('\\');
203 | sb.Append(c);
204 | break;
205 | case '\b':
206 | sb.Append("\\b");
207 | break;
208 | case '\t':
209 | sb.Append("\\t");
210 | break;
211 | case '\n':
212 | sb.Append("\\n");
213 | break;
214 | case '\f':
215 | sb.Append("\\f");
216 | break;
217 | case '\r':
218 | sb.Append("\\r");
219 | break;
220 | default:
221 | if (c < ' ')
222 | {
223 | t = "000" + string.Format(CultureInfo.CurrentCulture,"{0:X}", c);
224 | sb.Append(string.Concat("\\u", t.AsSpan(t.Length - 4)));
225 | }
226 | else
227 | {
228 | sb.Append(c);
229 | }
230 | break;
231 | }
232 | }
233 | return sb.ToString();
234 | }
235 |
236 | ///
237 | /// Gets the URI value of the string URL.
238 | ///
239 | ///
240 | /// Thrown when either the watch path or the change information was not provided.
241 | ///
242 | ///
243 | /// Thrown if the URL is not in a valid format.
244 | ///
245 | private Uri GetUri()
246 | {
247 | if (Change == null)
248 | {
249 | throw new InvalidOperationException("The change information cannot be null.");
250 | }
251 |
252 | if (string.IsNullOrWhiteSpace(Url))
253 | {
254 | throw new UriFormatException();
255 | }
256 |
257 | string? url = Placeholder.ReplacePlaceholders(
258 | Url,
259 | Change.WatchPath,
260 | Change.FullPath,
261 | Change.OldPath);
262 |
263 | if (string.IsNullOrWhiteSpace(url))
264 | {
265 | throw new UriFormatException($"The notification URL: {url} is not valid.");
266 | }
267 |
268 | Uri uri = new(url);
269 | return uri;
270 | }
271 | }
272 | }
273 |
--------------------------------------------------------------------------------
/src/Configuration/Notifications.cs:
--------------------------------------------------------------------------------
1 | using System.Collections.ObjectModel;
2 | using System.Runtime.CompilerServices;
3 | using System.Timers;
4 | using System.Xml.Serialization;
5 | using TE.FileWatcher.Log;
6 | using TE.FileWatcher.Net;
7 |
8 | namespace TE.FileWatcher.Configuration
9 | {
10 | ///
11 | /// The notifications root node in the XML file.
12 | ///
13 | [XmlRoot("notifications")]
14 | public class Notifications : IDisposable
15 | {
16 | // The default wait time
17 | private const int DEFAULT_WAIT_TIME = 30000;
18 |
19 | // The minimum wait time
20 | private const int MIN_WAIT_TIME = 30000;
21 |
22 | // The timer
23 | private readonly System.Timers.Timer _timer;
24 |
25 | // Flag indicating the class is disposed
26 | private bool _disposed;
27 |
28 | private int currentWaitTime;
29 |
30 | ///
31 | /// Gets or sets the wait time between notification requests.
32 | ///
33 | [XmlElement("waittime")]
34 | public int? WaitTime { get; set; }
35 |
36 | ///
37 | /// Gets or sets the notifications list.
38 | ///
39 | [XmlElement("notification")]
40 | public Collection? NotificationList { get; set; }
41 |
42 | ///
43 | /// Initializes an instance of the class.
44 | ///
45 | public Notifications()
46 | {
47 | currentWaitTime = WaitTime ?? DEFAULT_WAIT_TIME;
48 |
49 | _timer = new System.Timers.Timer(currentWaitTime);
50 | _timer.Elapsed += OnElapsed;
51 | }
52 |
53 | ///
54 | /// Releases all resources used by the class.
55 | ///
56 | public void Dispose()
57 | {
58 | Dispose(true);
59 | GC.SuppressFinalize(this);
60 | }
61 |
62 | ///
63 | /// Release all resources used by the class.
64 | ///
65 | ///
66 | /// Indicates the whether the class is disposing.
67 | ///
68 | protected virtual void Dispose(bool disposing)
69 | {
70 | if (_disposed)
71 | {
72 | return;
73 | }
74 |
75 | if (disposing)
76 | {
77 | _timer?.Dispose();
78 | }
79 |
80 | _disposed = true;
81 | }
82 |
83 | ///
84 | /// Called when the timers elapsed time has been reached.
85 | ///
86 | ///
87 | /// The timer object.
88 | ///
89 | ///
90 | /// The information associated witht he elapsed time.
91 | ///
92 | private async void OnElapsed(object? source, ElapsedEventArgs e)
93 | {
94 | // If there are no notifications, then stop the timer
95 | if (NotificationList == null || NotificationList.Count <= 0)
96 | {
97 | _timer.Stop();
98 | return;
99 | }
100 |
101 | foreach (Notification notification in NotificationList)
102 | {
103 | // If the notification doesn't have a message to send, then
104 | // continue to the next notification
105 | if (!notification.HasMessage)
106 | {
107 | continue;
108 | }
109 |
110 | try
111 | {
112 | Response? response =
113 | await notification.SendAsync().ConfigureAwait(false);
114 |
115 | if (response == null)
116 | {
117 | continue;
118 | }
119 |
120 | Logger.WriteLine($"Response: {response.StatusCode}. URL: {response.Url}. Content: {response.Content}");
121 |
122 | }
123 | catch (AggregateException aex)
124 | {
125 | foreach (Exception ex in aex.Flatten().InnerExceptions)
126 | {
127 | Logger.WriteLine(ex.Message, LogLevel.ERROR);
128 | Logger.WriteLine(
129 | $"StackTrace:{Environment.NewLine}{ex.StackTrace}",
130 | LogLevel.ERROR);
131 | }
132 | }
133 | catch (NullReferenceException ex)
134 | {
135 | Logger.WriteLine(ex.Message, LogLevel.ERROR);
136 | Logger.WriteLine(
137 | $"StackTrace:{Environment.NewLine}{ex.StackTrace}",
138 | LogLevel.ERROR);
139 | }
140 | }
141 |
142 | if (NotificationList.Count <= 0)
143 | {
144 | _timer.Stop();
145 | }
146 | }
147 |
148 | ///
149 | /// Sends the notification request.
150 | ///
151 | ///
152 | /// The trigger associated with the request.
153 | ///
154 | ///
155 | /// Information about the change.
156 | ///
157 | ///
158 | /// The message to include in the request.
159 | ///
160 | public void Send(TriggerType trigger, ChangeInfo change, string message)
161 | {
162 | if (change == null)
163 | {
164 | return;
165 | }
166 |
167 | if (NotificationList == null || NotificationList.Count <= 0)
168 | {
169 | return;
170 | }
171 |
172 | foreach (Notification notification in NotificationList)
173 | {
174 | notification.QueueRequest(message, trigger, change);
175 | }
176 |
177 | if (!_timer.Enabled)
178 | {
179 | currentWaitTime = WaitTime ?? DEFAULT_WAIT_TIME;
180 | if (currentWaitTime < MIN_WAIT_TIME)
181 | {
182 | currentWaitTime = MIN_WAIT_TIME;
183 | }
184 |
185 | _timer.Interval = currentWaitTime;
186 | _timer.Start();
187 | }
188 | }
189 | }
190 | }
191 |
--------------------------------------------------------------------------------
/src/Configuration/RunnableBase.cs:
--------------------------------------------------------------------------------
1 | using System.Text.RegularExpressions;
2 | using System.IO;
3 | using TE.FileWatcher.Log;
4 | using TEFS = TE.FileWatcher.FileSystem;
5 | using System.Xml.Serialization;
6 | using System.Globalization;
7 | using System.Web;
8 | using System;
9 |
10 | namespace TE.FileWatcher.Configuration
11 | {
12 | ///
13 | /// A base abstract class for the classes which require execution on the
14 | /// machine and includes placeholders in the data that need to be replaced.
15 | ///
16 | public abstract class RunnableBase : ItemBase
17 | {
18 | ///
19 | /// Gets or sets the number of milliseconds to wait before running.
20 | ///
21 | [XmlElement("waitbefore")]
22 | public int WaitBefore { get; set; }
23 |
24 | ///
25 | /// The abstract method to Run.
26 | ///
27 | ///
28 | /// Information about the change.
29 | ///
30 | ///
31 | /// The trigger for the action.
32 | ///
33 | public void Run(ChangeInfo change, TriggerType trigger)
34 | {
35 | if (Triggers == null || Triggers.TriggerList == null)
36 | {
37 | throw new InvalidOperationException("The list of triggers was not provided.");
38 | }
39 |
40 | if (Triggers.TriggerList.Count <= 0)
41 | {
42 | throw new InvalidOperationException("No triggers were defined.");
43 | }
44 |
45 | if (!Triggers.Current.HasFlag(trigger))
46 | {
47 | throw new FileWatcherTriggerNotMatchException(
48 | "The trigger doesn't match the list of triggers for this watch.");
49 | }
50 |
51 | Change = change ?? throw new ArgumentNullException(nameof(change));
52 | }
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/src/Configuration/Triggers.cs:
--------------------------------------------------------------------------------
1 | using System.Collections.ObjectModel;
2 | using System.Xml.Serialization;
3 |
4 | namespace TE.FileWatcher.Configuration
5 | {
6 | ///
7 | /// The notification triggers.
8 | ///
9 | [Flags]
10 | [Serializable]
11 | public enum TriggerType
12 | {
13 | ///
14 | /// No triggers are specified.
15 | ///
16 | None = 0,
17 | ///
18 | /// Change notification.
19 | ///
20 | Change = 1,
21 | ///
22 | /// Create notification.
23 | ///
24 | Create = 2,
25 | ///
26 | /// Delete notification.
27 | ///
28 | Delete = 4,
29 | ///
30 | /// Rename notification.
31 | ///
32 | Rename = 8
33 | }
34 | ///
35 | /// The triggers that will indicate a notification is to be sent.
36 | ///
37 | public class Triggers
38 | {
39 | // The flags for the triggers
40 | TriggerType _triggers = TriggerType.None;
41 |
42 | ///
43 | /// Gets or sets a list of notification triggers.
44 | ///
45 | [XmlElement("trigger")]
46 | public Collection? TriggerList { get; set; }
47 |
48 | ///
49 | /// Gets the current combined triggers using the list from the
50 | /// property.
51 | ///
52 | [XmlIgnore]
53 | public TriggerType Current
54 | {
55 | get
56 | {
57 | // Return the triggers if they have already been combined
58 | // by checking if they are not equal to the default value
59 | if (_triggers != TriggerType.None)
60 | {
61 | return _triggers;
62 | }
63 |
64 | if (TriggerList != null && TriggerList.Count > 0)
65 | {
66 | foreach (TriggerType trigger in TriggerList)
67 | {
68 | _triggers |= trigger;
69 | }
70 | }
71 |
72 | return _triggers;
73 | }
74 | }
75 | }
76 | }
77 |
--------------------------------------------------------------------------------
/src/Configuration/Watches.cs:
--------------------------------------------------------------------------------
1 | using System.Collections.ObjectModel;
2 | using System.Xml.Serialization;
3 | using TE.FileWatcher.Log;
4 |
5 | namespace TE.FileWatcher.Configuration
6 | {
7 | ///
8 | /// The watches root node in the XML file.
9 | ///
10 | [XmlRoot("watches")]
11 | public class Watches
12 | {
13 | ///
14 | /// Gets or sets the logging information.
15 | ///
16 | [XmlElement("logging")]
17 | public Logging Logging { get; set; } = new Logging();
18 |
19 | ///
20 | /// Gets or sets the watches list.
21 | ///
22 | [XmlElement("watch")]
23 | public Collection? WatchList { get; set; }
24 |
25 | ///
26 | /// Starts the watches.
27 | ///
28 | public void Start()
29 | {
30 | if (WatchList == null || WatchList.Count <= 0)
31 | {
32 | Logger.WriteLine("No watches were specified.", LogLevel.ERROR);
33 | return;
34 | }
35 |
36 | foreach (Watch watch in WatchList)
37 | {
38 | try
39 | {
40 | Task.Run(() => watch.Start());
41 | }
42 | catch (Exception ex)
43 | {
44 | Logger.WriteLine(ex.Message, LogLevel.ERROR);
45 | }
46 | }
47 | }
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/src/Configuration/XmlFile.cs:
--------------------------------------------------------------------------------
1 | using System.Diagnostics;
2 | using System.Diagnostics.CodeAnalysis;
3 | using System.Xml;
4 | using System.Xml.Serialization;
5 |
6 | namespace TE.FileWatcher.Configuration
7 | {
8 | ///
9 | /// The XML configuration file.
10 | ///
11 | public class XmlFile : IConfigurationFile
12 | {
13 | // The default configuration file name
14 | public const string DEFAULTCONFIGFILE = "config.xml";
15 |
16 | // Path to the configuration file
17 | private readonly string? _fullPath;
18 |
19 | ///
20 | /// Initializes an instance of the class when
21 | /// provided with the path and name of the config file.
22 | ///
23 | ///
24 | /// The path to the config file.
25 | ///
26 | ///
27 | /// The name of the config file.
28 | ///
29 | ///
30 | /// If the parameter is null, then the
31 | /// current folder path is used instead.
32 | /// If the parameter is null, then the
33 | /// value of .
34 | ///
35 | public XmlFile(string? path, string? name)
36 | {
37 | _fullPath = GetFullPath(path, name);
38 | }
39 |
40 | ///
41 | /// Gets the folder path containing the configuration file.
42 | ///
43 | ///
44 | /// The folder path.
45 | ///
46 | ///
47 | /// The folder path of the files, otherwise null.
48 | ///
49 | private static string? GetFolderPath(string? path)
50 | {
51 | if (string.IsNullOrWhiteSpace(path))
52 | {
53 | try
54 | {
55 | path = Path.GetDirectoryName(Process.GetCurrentProcess().MainModule?.FileName);
56 | }
57 | catch (Exception ex)
58 | when (ex is ArgumentException || ex is PathTooLongException)
59 | {
60 | Console.Error.WriteLine($"The folder name is null or empty. Couldn't get the current location. Reason: {ex.Message}");
61 | return null;
62 | }
63 | }
64 |
65 | if (Directory.Exists(path))
66 | {
67 | return path;
68 | }
69 | else
70 | {
71 | Console.Error.WriteLine("The folder does not exist.");
72 | return null;
73 | }
74 | }
75 |
76 | ///
77 | /// Gets the full path to the configuration file.
78 | ///
79 | ///
80 | /// The path to the configuration file.
81 | ///
82 | ///
83 | /// The name of the configuration file.
84 | ///
85 | ///
86 | /// The full path to the configuration file, otherwise null.
87 | ///
88 | private static string? GetFullPath(string? path, string? name)
89 | {
90 | string? folderPath = GetFolderPath(path);
91 | if (folderPath == null)
92 | {
93 | return null;
94 | }
95 |
96 | if (string.IsNullOrWhiteSpace(name))
97 | {
98 | name = DEFAULTCONFIGFILE;
99 | }
100 |
101 | try
102 | {
103 | string fullPath = Path.Combine(folderPath, name);
104 | if (File.Exists(fullPath))
105 | {
106 | Console.WriteLine($"Configuration file: {fullPath}.");
107 | return fullPath;
108 | }
109 | else
110 | {
111 | Console.Error.WriteLine($"The configuration file '{fullPath}' was not found.");
112 | return null;
113 | }
114 | }
115 | catch (Exception ex)
116 | when (ex is ArgumentException || ex is ArgumentNullException)
117 | {
118 | Console.Error.WriteLine($"Could not get the path to the configuration file. Reason: {ex.Message}");
119 | return null;
120 | }
121 | }
122 |
123 | ///
124 | /// Reads the configuration file.
125 | ///
126 | ///
127 | /// A object if the file was read successfully,
128 | /// otherwise null.
129 | ///
130 | [RequiresUnreferencedCode("Calls XmlSerializer")]
131 | public Watches? Read()
132 | {
133 | if (string.IsNullOrWhiteSpace(_fullPath))
134 | {
135 | Console.Error.WriteLine("The configuration file path was null or empty.");
136 | return null;
137 | }
138 |
139 | if (!File.Exists(_fullPath))
140 | {
141 | Console.Error.WriteLine($"The configuration file path '{_fullPath}' does not exist.");
142 | return null;
143 | }
144 |
145 | try
146 | {
147 | XmlSerializer serializer = new(typeof(Watches));
148 | using FileStream fs = new(_fullPath, FileMode.Open, FileAccess.Read);
149 | return (Watches?)serializer.Deserialize(fs);
150 | }
151 | catch (Exception ex)
152 | {
153 | Console.Error.WriteLine($"The configuration file could not be read. Reason: {ex.Message}");
154 | return null;
155 | }
156 | }
157 | }
158 | }
159 |
--------------------------------------------------------------------------------
/src/FileSystem/Directory.cs:
--------------------------------------------------------------------------------
1 | using DotNetIO = System.IO;
2 |
3 | namespace TE.FileWatcher.FileSystem
4 | {
5 | ///
6 | /// Contains methods to work with directories.
7 | ///
8 | public static class Directory
9 | {
10 | ///
11 | /// Creates the folder structure specified by a path.
12 | ///
13 | ///
14 | /// The path that includes the folder structure to create.
15 | ///
16 | ///
17 | /// Thrown when an argument is null or empty.
18 | ///
19 | ///
20 | /// Thrown when the directory could not be created.
21 | ///
22 | ///
23 | /// Thrown when the directory from the path is null.
24 | ///
25 | public static void Create(string path)
26 | {
27 | if (string.IsNullOrWhiteSpace(path))
28 | {
29 | throw new ArgumentNullException(nameof(path));
30 | }
31 |
32 | string? folders = Path.GetDirectoryName(path);
33 |
34 | if (!string.IsNullOrWhiteSpace(folders))
35 | {
36 | // If the destination directory doesn't exist, create
37 | // create it to avoid any exceptions
38 | if (!DotNetIO.Directory.Exists(folders))
39 | {
40 | DotNetIO.Directory.CreateDirectory(folders);
41 | if (!DotNetIO.Directory.Exists(folders))
42 | {
43 | throw new FileWatcherException($"The directory {folders} could not be created.");
44 | }
45 | }
46 | }
47 | else
48 | {
49 | throw new InvalidOperationException("The directory path could not be determined.");
50 | }
51 | }
52 |
53 | ///
54 | /// Returns a value indicating the path provided is a valid directory.
55 | ///
56 | ///
57 | /// The path of the directory.
58 | ///
59 | ///
60 | /// true if the path is a valid directory, otherwise false.
61 | ///
62 | public static bool IsValid(string path)
63 | {
64 | if (string.IsNullOrWhiteSpace(path))
65 | {
66 | return false;
67 | }
68 |
69 | return DotNetIO.Directory.Exists(path);
70 | }
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/src/FileSystem/File.cs:
--------------------------------------------------------------------------------
1 | using DotNetIO = System.IO;
2 | using System.Security.Cryptography;
3 |
4 | namespace TE.FileWatcher.FileSystem
5 | {
6 | ///
7 | /// A wrapper class to manage the File actions.
8 | ///
9 |
10 | public static class File
11 | {
12 | // The number of times to retry a file action
13 | private const int RETRIES = 5;
14 |
15 | // A megabyte
16 | private const int MEGABYTE = 1024 * 1024;
17 |
18 | ///
19 | /// Gets the hash of the file.
20 | ///
21 | ///
22 | /// The full path to the file.
23 | ///
24 | ///
25 | /// The hash of the file, otherwise null.
26 | ///
27 | private static string? GetFileHash(string fullPath)
28 | {
29 | try
30 | {
31 | using (var hashAlgorithm = SHA256.Create())
32 | {
33 | using (var stream =
34 | new FileStream(
35 | fullPath,
36 | FileMode.Open,
37 | FileAccess.Read,
38 | FileShare.None,
39 | MEGABYTE))
40 | {
41 | var hash = hashAlgorithm.ComputeHash(stream);
42 | return BitConverter.ToString(hash).Replace("-", "", StringComparison.OrdinalIgnoreCase);
43 | }
44 | }
45 | }
46 | catch (Exception ex)
47 | when (ex is ArgumentException || ex is ArgumentNullException || ex is ObjectDisposedException || ex is System.Reflection.TargetInvocationException)
48 | {
49 | return null;
50 | }
51 | }
52 |
53 | ///
54 | /// Verifies the files and returns a value indicating whether the files
55 | /// are the same.
56 | ///
57 | ///
58 | /// The full path to the source file.
59 | ///
60 | ///
61 | /// The full path to the destination file.
62 | ///
63 | ///
64 | /// true if the verification was successful, otherwise false.
65 | ///
66 | private static bool Verify(string source, string destination)
67 | {
68 | if (string.IsNullOrWhiteSpace(source) || string.IsNullOrWhiteSpace(destination))
69 | {
70 | return false;
71 | }
72 |
73 | string? sourceHash = GetFileHash(source);
74 | string? destinationHash = GetFileHash(destination);
75 |
76 | if (string.IsNullOrWhiteSpace(sourceHash))
77 | {
78 | return false;
79 | }
80 |
81 | return (sourceHash.Equals(destinationHash, StringComparison.OrdinalIgnoreCase));
82 | }
83 |
84 | ///
85 | /// Waits for a file to be accessible.
86 | ///
87 | ///
88 | /// Path to the file.
89 | ///
90 | ///
91 | /// path is a zero-length string, contains only white space, or
92 | /// contains one or more invalid characters.
93 | ///
94 | ///
95 | /// path is null.
96 | ///
97 | ///
98 | /// The specified path, file name, or both exceed the system-defined
99 | /// maximum length.
100 | ///
101 | ///
102 | /// The specified path is invalid, (for example, it is on an unmapped
103 | /// drive).
104 | ///
105 | ///
106 | /// path specified a directory, or, the caller does not have the
107 | /// required permission.
108 | ///
109 | ///
110 | /// The file specified in path was not found.
111 | ///
112 | ///
113 | /// path is an invalid format.
114 | ///
115 | private static void WaitForFile(string path)
116 | {
117 | if (string.IsNullOrWhiteSpace(path))
118 | {
119 | throw new ArgumentNullException(nameof(path));
120 | }
121 |
122 | if (!DotNetIO.File.Exists(path))
123 | {
124 | throw new FileNotFoundException(
125 | $"The file '{path}' was not found.", path);
126 | }
127 |
128 | int maxChecks = 600;
129 | bool isFileLocked = true;
130 | int checkCounter = 0;
131 | while (isFileLocked && (checkCounter <= maxChecks))
132 | {
133 | try
134 | {
135 | using (FileStream fileStream = DotNetIO.File.OpenRead(path))
136 | {
137 | isFileLocked = false;
138 | }
139 | }
140 | catch (IOException)
141 | {
142 | checkCounter++;
143 | Thread.Sleep(1000);
144 | }
145 | }
146 | }
147 |
148 | ///
149 | /// Copies a file to a specified location.
150 | ///
151 | ///
152 | /// The source file to copy.
153 | ///
154 | ///
155 | /// The copy destination location.
156 | ///
157 | ///
158 | /// Verify the file after the copy has completed.
159 | ///
160 | ///
161 | /// true if the file was copied successfully, otherwise false.
162 | ///
163 | ///
164 | /// Thrown when one of the parameters are null.
165 | ///
166 | ///
167 | /// Thrown when the file specified by the is not found.
168 | ///
169 | ///
170 | /// Thrown when the file could not be copied to the destination.
171 | ///
172 | public static void Copy(string source, string destination, bool verify, bool keepTimestamp)
173 | {
174 | if (string.IsNullOrWhiteSpace(source))
175 | {
176 | throw new ArgumentNullException(nameof(source));
177 | }
178 |
179 | if (string.IsNullOrWhiteSpace(destination))
180 | {
181 | throw new ArgumentNullException(nameof(destination));
182 | }
183 |
184 | if (!DotNetIO.File.Exists(source))
185 | {
186 | return;
187 | }
188 |
189 | if (Directory.IsValid(source))
190 | {
191 | return;
192 | }
193 |
194 | try
195 | {
196 | WaitForFile(source);
197 | Directory.Create(destination);
198 |
199 | int attempts = 0;
200 | bool fileCopied = false;
201 | while ((attempts <= RETRIES) && !fileCopied)
202 | {
203 | DotNetIO.File.Copy(source, destination, true);
204 | WaitForFile(destination);
205 |
206 | fileCopied = verify != true || Verify(source, destination);
207 |
208 | if (!fileCopied)
209 | {
210 | attempts++;
211 | }
212 | }
213 |
214 | if (keepTimestamp)
215 | {
216 | // Set the time of the destination file to match the source file
217 | // because the file was moved and not a new copy
218 | SetDestinationCreationTime(source, destination);
219 | SetDestinationModifiedTime(source, destination);
220 | }
221 | }
222 | catch (Exception ex)
223 | {
224 | throw new FileWatcherException("The file could not be copied.", ex);
225 | }
226 | }
227 |
228 | ///
229 | /// Gets the creation date/time for the file.
230 | ///
231 | ///
232 | /// The full path to the file.
233 | ///
234 | ///
235 | /// The creation date/time of the file, otherwise null.
236 | ///
237 | ///
238 | /// Thrown when the creation time could not be retrieved from the file.
239 | ///
240 | public static DateTime? GetCreatedDate(string path)
241 | {
242 | if (string.IsNullOrWhiteSpace(path) || !DotNetIO.File.Exists(path))
243 | {
244 | return null;
245 | }
246 |
247 | try
248 | {
249 | return DotNetIO.File.GetCreationTime(path);
250 | }
251 | catch (Exception ex)
252 | {
253 | throw new FileWatcherException($"The creation time could not be retrieved from the file '{path}'. Reason: {ex.Message}");
254 | }
255 | }
256 |
257 | ///
258 | /// Gets the file extension.
259 | ///
260 | ///
261 | /// The full path to the file.
262 | ///
263 | ///
264 | /// The extension of the full, otherwise null.
265 | ///
266 | public static string? GetExtension(string path)
267 | {
268 | if (string.IsNullOrWhiteSpace(path) || !DotNetIO.File.Exists(path))
269 | {
270 | return null;
271 | }
272 |
273 | return Path.GetExtension(path);
274 | }
275 |
276 | ///
277 | /// Gets the modified date/time for the file.
278 | ///
279 | ///
280 | /// The full path to the file.
281 | ///
282 | ///
283 | /// The modified date/time of the file, otherwise null.
284 | ///
285 | ///
286 | /// Thrown when the modified time could not be retrieved from the file.
287 | ///
288 | public static DateTime? GetModifiedDate(string path)
289 | {
290 | if (string.IsNullOrWhiteSpace(path) || !DotNetIO.File.Exists(path))
291 | {
292 | return null;
293 | }
294 |
295 | try
296 | {
297 | return DotNetIO.File.GetLastWriteTime(path);
298 | }
299 | catch (Exception ex)
300 | {
301 | throw new FileWatcherException($"The modified time could not be retrieved from the file '{path}'. Reason: {ex.Message}");
302 | }
303 | }
304 |
305 | ///
306 | /// Gets the name of the file with or without the extension.
307 | ///
308 | ///
309 | /// The full path to the file.
310 | ///
311 | ///
312 | /// The name of the file, otherwise null.
313 | ///
314 | public static string? GetName(string path, bool includeExtension)
315 | {
316 | if (string.IsNullOrWhiteSpace(path) || !DotNetIO.File.Exists(path))
317 | {
318 | return null;
319 | }
320 |
321 | return includeExtension ? Path.GetFileNameWithoutExtension(path) : Path.GetFileName(path);
322 | }
323 |
324 | ///
325 | /// Returns a flag indicating the file is valid.
326 | ///
327 | ///
328 | /// The path to the file.
329 | ///
330 | ///
331 | /// true if the file is valid, otherwise false.
332 | ///
333 | ///
334 | /// Thrown when the file could not be validated.
335 | ///
336 | public static bool IsValid(string path)
337 | {
338 | if (string.IsNullOrWhiteSpace(path))
339 | {
340 | return false;
341 | }
342 |
343 | if (DotNetIO.File.Exists(path))
344 | {
345 | try
346 | {
347 | WaitForFile(path);
348 | return true;
349 | }
350 | catch (Exception ex)
351 | {
352 | throw new FileWatcherException($"The file '{path}' is not valid. Reason: {ex.Message}");
353 | }
354 | }
355 | else
356 | {
357 | return false;
358 | }
359 | }
360 |
361 | ///
362 | /// Moves a file to a specified location.
363 | ///
364 | ///
365 | /// The source file to move.
366 | ///
367 | ///
368 | /// The move destination location.
369 | ///
370 | ///
371 | /// Verify the file after the copy has completed.
372 | ///
373 | ///
374 | /// Flag indicating the created and modified timestamps of the source
375 | /// file will be applied to the destination file.
376 | ///
377 | ///
378 | /// true if the file was moved successfully, otherwise false.
379 | ///
380 | ///
381 | /// Thrown when one of the parameters are null.
382 | ///
383 | ///
384 | /// Thrown when the file specified by the is not found.
385 | ///
386 | ///
387 | /// Thrown when the file could not be moved to the destination.
388 | ///
389 | public static void Move(string source, string destination, bool verify, bool keepTimestamp)
390 | {
391 | if (string.IsNullOrWhiteSpace(source))
392 | {
393 | throw new ArgumentNullException(nameof(source));
394 | }
395 |
396 | if (string.IsNullOrWhiteSpace(destination))
397 | {
398 | throw new ArgumentNullException(nameof(destination));
399 | }
400 |
401 | Copy(source, destination, verify, keepTimestamp);
402 | Delete(source);
403 | }
404 |
405 | ///
406 | /// Deletes a file.
407 | ///
408 | ///
409 | /// The full path of the file to delete.
410 | ///
411 | ///
412 | /// true the file was deleted successfully, otherwise false.
413 | ///
414 | ///
415 | /// Thrown when one of the parameters are null.
416 | ///
417 | ///
418 | /// Thrown when the file could not be deleted.
419 | ///
420 | public static void Delete(string source)
421 | {
422 | if (string.IsNullOrWhiteSpace(source))
423 | {
424 | throw new ArgumentNullException(nameof(source));
425 | }
426 |
427 | if (!DotNetIO.File.Exists(source))
428 | {
429 | return;
430 | }
431 |
432 | try
433 | {
434 | int attempts = 0;
435 | bool fileDeleted = false;
436 | while ((attempts <= RETRIES) && !fileDeleted)
437 | {
438 | DotNetIO.File.Delete(source);
439 | fileDeleted = !DotNetIO.File.Exists(source);
440 |
441 | if (!fileDeleted)
442 | {
443 | attempts++;
444 | }
445 | }
446 | }
447 | catch (Exception ex)
448 | {
449 | throw new FileWatcherException("The file could not be deleted.", ex);
450 | }
451 | }
452 |
453 | ///
454 | /// Set the creation time on the destionation file to match the source
455 | /// file.
456 | ///
457 | ///
458 | /// Full path to the source file.
459 | ///
460 | ///
461 | /// Full path to the destination file.
462 | ///
463 | private static void SetDestinationCreationTime(string source, string destination)
464 | {
465 | if (string.IsNullOrWhiteSpace(source) || string.IsNullOrWhiteSpace(destination))
466 | {
467 | return;
468 | }
469 |
470 | if (!IsValid(source) || !IsValid(destination))
471 | {
472 | return;
473 | }
474 |
475 | DateTime? sourceTime = GetCreatedDate(source);
476 | if (sourceTime == null)
477 | {
478 | return;
479 | }
480 |
481 | try
482 | {
483 | DotNetIO.File.SetCreationTime(destination, (DateTime)sourceTime);
484 | }
485 | catch
486 | {
487 | // Just swallow the exception as we are just setting the time
488 | return;
489 | }
490 | }
491 |
492 | ///
493 | /// Set the modified time on the destionation file to match the source
494 | /// file.
495 | ///
496 | ///
497 | /// Full path to the source file.
498 | ///
499 | ///
500 | /// Full path to the destination file.
501 | ///
502 | private static void SetDestinationModifiedTime(string source, string destination)
503 | {
504 | if (string.IsNullOrWhiteSpace(source) || string.IsNullOrWhiteSpace(destination))
505 | {
506 | return;
507 | }
508 |
509 | if (!IsValid(source) || !IsValid(destination))
510 | {
511 | return;
512 | }
513 |
514 | DateTime? sourceTime = GetModifiedDate(source);
515 | if (sourceTime == null)
516 | {
517 | return;
518 | }
519 |
520 | try
521 | {
522 | DotNetIO.File.SetLastWriteTime(destination, (DateTime)sourceTime);
523 | }
524 | catch
525 | {
526 | // Just swallow the exception as we are just setting the time
527 | return;
528 | }
529 | }
530 | }
531 | }
532 |
--------------------------------------------------------------------------------
/src/FileWatcher.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Exe
5 | net6.0
6 | enable
7 | enable
8 | TE.FileWatcher
9 | fw
10 | 1.5.1
11 | true
12 | true
13 | true
14 | win-x64
15 | true
16 | Paul Salmon
17 | FileWatcher
18 | Monitor folders and files on the local system for changes.
19 | ©2023
20 | https://github.com/TechieGuy12/FileWatcher
21 | 1.5.1.0
22 | 1.5.1.0
23 | filesystem file-monitoring folder-monitoring
24 | MIT
25 | README.md
26 | TE.FileWatcher
27 |
28 |
29 |
30 |
31 | True
32 | \
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 | PreserveNewest
46 | true
47 |
48 |
49 | Never
50 |
51 |
52 |
53 |
54 |
--------------------------------------------------------------------------------
/src/FileWatcherException.cs:
--------------------------------------------------------------------------------
1 | namespace TE.FileWatcher
2 | {
3 | using System;
4 |
5 | public class FileWatcherException : Exception
6 | {
7 | public FileWatcherException()
8 | {
9 | }
10 |
11 | public FileWatcherException(string message)
12 | : base(message)
13 | {
14 | }
15 |
16 | public FileWatcherException(string message, Exception inner)
17 | : base(message, inner)
18 | {
19 | }
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/src/FileWatcherTriggerNotMatchException .cs:
--------------------------------------------------------------------------------
1 | namespace TE.FileWatcher
2 | {
3 | using System;
4 |
5 | public class FileWatcherTriggerNotMatchException : Exception
6 | {
7 | public FileWatcherTriggerNotMatchException()
8 | {
9 | }
10 |
11 | public FileWatcherTriggerNotMatchException(string message)
12 | : base(message)
13 | {
14 | }
15 |
16 | public FileWatcherTriggerNotMatchException(string message, Exception inner)
17 | : base(message, inner)
18 | {
19 | }
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/src/GlobalSuppressions.cs:
--------------------------------------------------------------------------------
1 | // This file is used by Code Analysis to maintain SuppressMessage
2 | // attributes that are applied to this project.
3 | // Project-level suppressions either have no target or are given
4 | // a specific target and scoped to a namespace, type, member, etc.
5 |
6 | using System.Diagnostics.CodeAnalysis;
7 |
8 | [assembly: SuppressMessage("Style", "IDE0063:Use simple 'using' statement", Justification = "", Scope = "member", Target = "~M:TE.FileWatcher.Configuration.Notifications.OnElapsed(System.Object,System.Timers.ElapsedEventArgs)")]
9 | [assembly: SuppressMessage("Style", "IDE0090:Use 'new(...)'", Justification = "", Scope = "member", Target = "~F:TE.FileWatcher.Net.Request._services")]
10 | [assembly: SuppressMessage("Reliability", "CA2000:Dispose objects before losing scope", Justification = "", Scope = "member", Target = "~M:TE.FileWatcher.Program.StartWatchers(TE.FileWatcher.Configuration.Watches)~System.Boolean")]
11 | [assembly: SuppressMessage("Usage", "CA2227:Collection properties should be read only", Justification = "", Scope = "member", Target = "~P:TE.FileWatcher.Configuration.Actions.ActionList")]
12 | [assembly: SuppressMessage("Usage", "CA2227:Collection properties should be read only", Justification = "", Scope = "member", Target = "~P:TE.FileWatcher.Configuration.Commands.CommandList")]
13 | [assembly: SuppressMessage("Style", "IDE0059:Unnecessary assignment of a value", Justification = "", Scope = "member", Target = "~M:TE.FileWatcher.Configuration.Notification.CleanMessage(System.String)~System.String")]
14 | [assembly: SuppressMessage("Usage", "CA2227:Collection properties should be read only", Justification = "", Scope = "member", Target = "~P:TE.FileWatcher.Configuration.Notifications.NotificationList")]
15 | [assembly: SuppressMessage("Usage", "CA2227:Collection properties should be read only", Justification = "", Scope = "member", Target = "~P:TE.FileWatcher.Configuration.Triggers.TriggerList")]
16 | [assembly: SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "", Scope = "member", Target = "~M:TE.FileWatcher.Configuration.Watch.GetChange(TE.FileWatcher.Configuration.TriggerType,System.String,System.String)~TE.FileWatcher.Configuration.ChangeInfo")]
17 | [assembly: SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "", Scope = "member", Target = "~M:TE.FileWatcher.Configuration.Watch.NotAccessibleError(System.IO.FileSystemWatcher,System.IO.ErrorEventArgs)")]
18 | [assembly: SuppressMessage("Style", "IDE0060:Remove unused parameter", Justification = "", Scope = "member", Target = "~M:TE.FileWatcher.Configuration.Watch.NotAccessibleError(System.IO.FileSystemWatcher,System.IO.ErrorEventArgs)")]
19 | [assembly: SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "", Scope = "member", Target = "~M:TE.FileWatcher.Configuration.Watches.Start")]
20 | [assembly: SuppressMessage("Usage", "CA2227:Collection properties should be read only", Justification = "", Scope = "member", Target = "~P:TE.FileWatcher.Configuration.Watches.WatchList")]
21 | [assembly: SuppressMessage("Style", "IDE0063:Use simple 'using' statement", Justification = "", Scope = "member", Target = "~M:TE.FileWatcher.Configuration.XmlFile.Read~TE.FileWatcher.Configuration.Watches")]
22 | [assembly: SuppressMessage("Style", "IDE0090:Use 'new(...)'", Justification = "", Scope = "member", Target = "~M:TE.FileWatcher.Configuration.XmlFile.Read~TE.FileWatcher.Configuration.Watches")]
23 | [assembly: SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "", Scope = "member", Target = "~M:TE.FileWatcher.Configuration.XmlFile.Read~TE.FileWatcher.Configuration.Watches")]
24 | [assembly: SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "", Scope = "member", Target = "~M:TE.FileWatcher.FileSystem.File.WaitForFile(System.String)")]
25 | [assembly: SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "", Scope = "member", Target = "~M:TE.FileWatcher.FileSystem.File.SetDestinationCreationTime(System.String,System.String)")]
26 | [assembly: SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "", Scope = "member", Target = "~M:TE.FileWatcher.FileSystem.File.SetDestinationModifiedTime(System.String,System.String)")]
27 | [assembly: SuppressMessage("Style", "IDE0063:Use simple 'using' statement", Justification = "", Scope = "member", Target = "~M:TE.FileWatcher.FileSystem.File.GetFileHash(System.String)~System.String")]
28 | [assembly: SuppressMessage("Style", "IDE0063:Use simple 'using' statement", Justification = "", Scope = "member", Target = "~M:TE.FileWatcher.Net.Request.SendAsync(System.Net.Http.HttpMethod,System.Uri,TE.FileWatcher.Configuration.Headers,System.String,TE.FileWatcher.Net.Request.MimeType)~System.Threading.Tasks.Task{TE.FileWatcher.Net.Response}")]
29 | [assembly: SuppressMessage("Performance", "CA1810:Initialize reference type static fields inline", Justification = "", Scope = "member", Target = "~M:TE.FileWatcher.Log.Logger.#cctor")]
30 | [assembly: SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "", Scope = "member", Target = "~M:TE.FileWatcher.Log.Logger.IsFolderValid(System.String)~System.Boolean")]
31 | [assembly: SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "", Scope = "member", Target = "~M:TE.FileWatcher.Log.Logger.RolloverLog")]
32 | [assembly: SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "", Scope = "member", Target = "~M:TE.FileWatcher.Log.Logger.WriteToLog")]
33 | [assembly: SuppressMessage("Style", "IDE0063:Use simple 'using' statement", Justification = "", Scope = "member", Target = "~M:TE.FileWatcher.Log.Logger.WriteToLog")]
34 |
--------------------------------------------------------------------------------
/src/IO/Attributes.cs:
--------------------------------------------------------------------------------
1 | using System.Xml.Serialization;
2 |
3 | namespace TE.FileWatcher.IO
4 | {
5 | ///
6 | /// The Attributes node in the XML file.
7 | ///
8 | public class Attributes
9 | {
10 | ///
11 | /// Gets the list of file attributes in string value form.
12 | ///
13 | [XmlElement("attribute")]
14 | public HashSet AttributeStrings { get; private set; } = new HashSet();
15 |
16 | ///
17 | /// Gets the list of file attributes.
18 | ///
19 | [XmlIgnore]
20 | public HashSet Attribute
21 | {
22 | get
23 | {
24 | HashSet attributes = new(AttributeStrings.Count);
25 | foreach (string attribute in AttributeStrings)
26 | {
27 | try
28 | {
29 | FileAttributes fileAttributes =
30 | (FileAttributes)Enum.Parse(typeof(FileAttributes), attribute);
31 | attributes.Add(fileAttributes);
32 | }
33 | catch (Exception ex)
34 | when (ex is ArgumentNullException || ex is ArgumentException || ex is OverflowException)
35 | {
36 | continue;
37 | }
38 | }
39 |
40 | return attributes;
41 | }
42 | }
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/src/IO/FileBase.cs:
--------------------------------------------------------------------------------
1 | using System.Xml.Serialization;
2 |
3 | namespace TE.FileWatcher.IO
4 | {
5 | ///
6 | /// The base class used by the files and folders nodes in the XML file.
7 | ///
8 | public abstract class FileBase
9 | {
10 | ///
11 | /// Gets or sets the name of the file.
12 | ///
13 | [XmlElement("name")]
14 | public HashSet Name { get; set; } = new HashSet();
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/src/IO/Files.cs:
--------------------------------------------------------------------------------
1 | namespace TE.FileWatcher.IO
2 | {
3 | ///
4 | /// The files node in the XML file.
5 | ///
6 | public class Files : FileBase { }
7 | }
8 |
--------------------------------------------------------------------------------
/src/IO/Folders.cs:
--------------------------------------------------------------------------------
1 | namespace TE.FileWatcher.IO
2 | {
3 | ///
4 | /// A folders node in the XML file.
5 | ///
6 | public class Folders : FileBase { }
7 | }
8 |
--------------------------------------------------------------------------------
/src/IO/MatchBase.cs:
--------------------------------------------------------------------------------
1 | using System.Xml.Serialization;
2 | using TE.FileWatcher.Configuration;
3 | using TE.FileWatcher.Log;
4 |
5 | namespace TE.FileWatcher.IO
6 | {
7 | ///
8 | /// A base class containing the properties and methods for filtering the
9 | /// files and folders of the watch.
10 | ///
11 | public abstract class MatchBase
12 | {
13 | // The set of full path to the folders to ignore
14 | private protected HashSet? _folders;
15 |
16 | // The set of full path to the paths to ignore
17 | private protected HashSet? _paths;
18 |
19 | // Sets the flag indicating the ignore lists have been populated
20 | private protected bool _initialized;
21 |
22 | // The path associated with the watch
23 | private protected string? _watchPath;
24 |
25 | ///
26 | /// Gets or sets the files node.
27 | ///
28 | [XmlElement("files")]
29 | public Files? Files { get; set; }
30 |
31 | ///
32 | /// Gets or sets the folders node.
33 | ///
34 | [XmlElement("folders")]
35 | public Folders? Folders { get; set; }
36 |
37 | ///
38 | /// Gets or sets the paths node.
39 | ///
40 | [XmlElement("paths")]
41 | public Paths? Paths { get; set; }
42 |
43 | ///
44 | /// Gets or sets the attributes node.
45 | ///
46 | [XmlElement("attributes")]
47 | public Attributes? Attributes { get; set; }
48 |
49 | [XmlElement("log", DataType = "boolean")]
50 | public bool Log { get; set; } = true;
51 |
52 | ///
53 | /// Gets or sets the type of filter used for logging.
54 | ///
55 | [XmlIgnore]
56 | private protected string FilterTypeName { get; set; } = "Filter";
57 |
58 | ///
59 | /// Gets a value indicating if at least one valid filtering value has
60 | /// been specified. An empty element could be added to the XML file,
61 | /// so this method ensures a filtering element has a valid value
62 | /// specified.
63 | ///
64 | ///
65 | /// true if at least one filtering value is specified, otherwise
66 | /// false.
67 | ///
68 | public bool IsSpecified()
69 | {
70 | bool isSpecified = false;
71 | if (Files != null && Files.Name.Count > 0)
72 | {
73 | isSpecified = true;
74 | }
75 |
76 | if (Folders != null && Folders.Name.Count > 0)
77 | {
78 | isSpecified = true;
79 | }
80 |
81 | if (Attributes != null && Attributes.Attribute.Count > 0)
82 | {
83 | isSpecified = true;
84 | }
85 |
86 | if (Paths != null && Paths.Path.Count > 0)
87 | {
88 | isSpecified = true;
89 | }
90 |
91 | return isSpecified;
92 | }
93 |
94 | ///
95 | /// Returns the flag indicating whether the attribute for a file that
96 | /// is changed matches the attributes from the configuration file.
97 | /// When a file is deleted, the attributes of the file cannot be checked
98 | /// since the file is no longer available, so the attributes cannot be
99 | /// determined, so on deletion this function will always return
100 | /// false.
101 | ///
102 | ///
103 | /// The full path to the file.
104 | ///
105 | ///
106 | /// True if the file change is a match, otherwise false.
107 | ///
108 | private protected bool AttributeMatch(string path)
109 | {
110 | if (Attributes == null || Attributes.Attribute.Count <= 0)
111 | {
112 | return false;
113 | }
114 |
115 | if (string.IsNullOrWhiteSpace(path))
116 | {
117 | return false;
118 | }
119 |
120 | if (!File.Exists(path) && !Directory.Exists(path))
121 | {
122 | return false;
123 | }
124 |
125 | bool hasAttribute = false;
126 | try
127 | {
128 | FileAttributes fileAttributes = File.GetAttributes(path);
129 | foreach (FileAttributes attribute in Attributes.Attribute)
130 | {
131 | if (fileAttributes.HasFlag(attribute))
132 | {
133 | if (Log)
134 | {
135 | Logger.WriteLine($"{FilterTypeName}: The path '{path}' has the attribute '{attribute}'.");
136 | }
137 | hasAttribute = true;
138 | break;
139 | }
140 | }
141 | }
142 | catch
143 | {
144 | hasAttribute = false;
145 | }
146 | return hasAttribute;
147 | }
148 |
149 | ///
150 | /// Returns the flag indicating whether the current file changed is
151 | /// a match that is found for files.
152 | ///
153 | ///
154 | /// The name of the file.
155 | ///
156 | ///
157 | /// True if the file change is a match, otherwise false.
158 | ///
159 | private protected bool FileMatch(string name)
160 | {
161 | if (Files == null || Files.Name.Count <= 0)
162 | {
163 | return false;
164 | }
165 |
166 | if (string.IsNullOrWhiteSpace(name))
167 | {
168 | return false;
169 | }
170 |
171 | bool isMatch = false;
172 | foreach (Name fileName in Files.Name)
173 | {
174 | isMatch = fileName.IsMatch(name);
175 | if (isMatch)
176 | {
177 | if (Log)
178 | {
179 | Logger.WriteLine($"{FilterTypeName}: The match pattern '{fileName.Pattern}' is a match for file {name}.");
180 | }
181 | break;
182 | }
183 | }
184 |
185 | return isMatch;
186 | }
187 |
188 | ///
189 | /// Returns the flag indicating whether the current folder change is a
190 | /// match when reporting the change.
191 | ///
192 | ///
193 | /// The path of the folder that was changed.
194 | ///
195 | ///
196 | /// True if the folder change is a match, otherwise false.
197 | ///
198 | private protected bool FolderMatch(string path)
199 | {
200 | if (Folders == null || Folders.Name.Count <= 0)
201 | {
202 | return false;
203 | }
204 |
205 | if (string.IsNullOrWhiteSpace(path))
206 | {
207 | return false;
208 | }
209 |
210 | bool isMatch = false;
211 | foreach (Name folder in Folders.Name)
212 | {
213 | isMatch = folder.IsMatch(path);
214 | if (isMatch)
215 | {
216 | if (Log)
217 | {
218 | Logger.WriteLine($"{FilterTypeName}: The match pattern '{folder.Pattern}' is a match for folder '{path}'.");
219 | }
220 | break;
221 | }
222 | }
223 |
224 | return isMatch;
225 | }
226 |
227 | ///
228 | /// Returns the flag indicating whether the current path change is a
229 | /// match when reporting the change.
230 | ///
231 | ///
232 | /// The full path.
233 | ///
234 | ///
235 | /// True if the path change is a match, otherwise false.
236 | ///
237 | private protected bool PathMatch(string path)
238 | {
239 | if (_paths == null || _paths.Count <= 0)
240 | {
241 | return false;
242 | }
243 |
244 | if (string.IsNullOrWhiteSpace(path))
245 | {
246 | return false;
247 | }
248 |
249 | bool isMatch = false;
250 | foreach (string aPath in _paths)
251 | {
252 | if (path.Contains(aPath, StringComparison.OrdinalIgnoreCase))
253 | {
254 | if (Log)
255 | {
256 | Logger.WriteLine($"{FilterTypeName}: The path '{path}' contains the path '{aPath}'.");
257 | }
258 | isMatch = true;
259 | break;
260 | }
261 | }
262 |
263 | return isMatch;
264 | }
265 |
266 | ///
267 | /// The folder paths stored in the class are
268 | /// relative to the property. To compare
269 | /// the folders, the relative folder location are combined with
270 | /// the to create the absolute path of
271 | /// the folders. This is then used to compare with any folder that
272 | /// is changed.
273 | ///
274 | ///
275 | /// Thrown when there is a problem with the path.
276 | ///
277 | private void GetFolders()
278 | {
279 | if (Folders == null)
280 | {
281 | return;
282 | }
283 |
284 | if (!IsPathValid(_watchPath))
285 | {
286 | return;
287 | }
288 |
289 | foreach (Name folderName in Folders.Name)
290 | {
291 | if (_watchPath != null && folderName.Pattern != null)
292 | {
293 | folderName.Pattern =
294 | Path.Combine(_watchPath, folderName.Pattern);
295 | }
296 | }
297 | }
298 |
299 | ///
300 | /// The paths stored in the class are relative to
301 | /// the property. To compare the paths, the
302 | /// relative folder location are combined with the
303 | /// to create the absolute path of each specified path value. This is
304 | /// then used to compare with any path that is changed.
305 | ///
306 | ///
307 | /// Thrown when there is a problem with the path.
308 | ///
309 | private void GetPaths()
310 | {
311 | if (Paths == null)
312 | {
313 | return;
314 | }
315 |
316 | if (!IsPathValid(_watchPath))
317 | {
318 | return;
319 | }
320 |
321 | _paths = new HashSet(
322 | Paths.Path.Count,
323 | StringComparer.OrdinalIgnoreCase);
324 |
325 | foreach (string path in Paths.Path)
326 | {
327 | if (_watchPath != null)
328 | {
329 | string fullPath = Path.Combine(_watchPath, path);
330 | _paths.Add(fullPath);
331 | }
332 | }
333 | }
334 |
335 | ///
336 | /// Initialize the values in the exclusion lists.
337 | ///
338 | ///
339 | /// The path to watch.
340 | ///
341 | ///
342 | /// Thrown when there is a problem with the path.
343 | ///
344 | private protected void Initialize(string watchPath)
345 | {
346 | _watchPath = watchPath;
347 |
348 | IsPathValid(_watchPath);
349 | GetFolders();
350 | GetPaths();
351 |
352 | _initialized = true;
353 | }
354 |
355 | ///
356 | /// Gets a value indicating if a match is found between the changed
357 | /// file/folder data, and the specified patterns.
358 | ///
359 | ///
360 | /// The path being watched.
361 | ///
362 | ///
363 | /// Information about the change.
364 | ///
365 | ///
366 | /// true of a match is found, otherwise false.
367 | ///
368 | ///
369 | /// Thrown when there is a problem with the path.
370 | ///
371 | private protected bool IsMatchFound(ChangeInfo change)
372 | {
373 | if (change == null || string.IsNullOrWhiteSpace(change.WatchPath))
374 | {
375 | return false;
376 | }
377 |
378 | if (!_initialized)
379 | {
380 | Initialize(change.WatchPath);
381 | }
382 |
383 | bool isMatch = false;
384 | if (Files != null && Files.Name.Count > 0)
385 | {
386 | isMatch |= FileMatch(change.Name);
387 | }
388 |
389 | if (Folders != null && Folders.Name.Count > 0)
390 | {
391 | isMatch |= FolderMatch(change.FullPath);
392 | }
393 |
394 | if (Attributes != null && Attributes.Attribute.Count > 0)
395 | {
396 | isMatch |= AttributeMatch(change.FullPath);
397 | }
398 |
399 | if (Paths != null && Paths.Path.Count > 0)
400 | {
401 | isMatch |= PathMatch(change.FullPath);
402 | }
403 |
404 | return isMatch;
405 | }
406 |
407 | ///
408 | /// Checks to ensure the provided path is valid.
409 | ///
410 | ///
411 | ///
412 | /// Thrown when there is a problem with the path.
413 | ///
414 | private protected static bool IsPathValid(string? path)
415 | {
416 | if (string.IsNullOrWhiteSpace(path))
417 | {
418 | throw new FileWatcherException("The path is null or empty.");
419 | }
420 |
421 | if (!Directory.Exists(path))
422 | {
423 | throw new FileWatcherException($"The path '{path}' does not exist.");
424 | }
425 |
426 | return true;
427 | }
428 | }
429 | }
430 |
--------------------------------------------------------------------------------
/src/IO/Name.cs:
--------------------------------------------------------------------------------
1 | using System.Globalization;
2 | using System.Text.RegularExpressions;
3 | using System.Xml.Serialization;
4 |
5 | namespace TE.FileWatcher.IO
6 | {
7 | ///
8 | /// Methods and properties that manage a name value.
9 | ///
10 | [XmlRoot(ElementName = "name")]
11 | public class Name
12 | {
13 | // The regular expression
14 | private Regex? _regex;
15 |
16 | ///
17 | /// Gets or sets the name pattern to match.
18 | ///
19 | [XmlText]
20 | public string? Pattern { get; set; }
21 |
22 | ///
23 | /// Checks to see if the property provides a
24 | /// match for the value parameter.
25 | ///
26 | ///
27 | /// The value to compare with the property.
28 | ///
29 | ///
30 | /// true if the value is a match for the pattern, otherwise
31 | /// false.
32 | ///
33 | ///
34 | /// The parameter is null or empty.
35 | ///
36 | public bool IsMatch(string value)
37 | {
38 | if (string.IsNullOrWhiteSpace(value))
39 | {
40 | throw new ArgumentNullException(nameof(value));
41 | }
42 |
43 | if (string.IsNullOrWhiteSpace(Pattern))
44 | {
45 | return false;
46 | }
47 |
48 | bool isMatch = value.Equals(Pattern, StringComparison.Ordinal);
49 | if (!isMatch)
50 | {
51 | isMatch = value.Contains(Pattern, StringComparison.Ordinal);
52 | }
53 |
54 | if (!isMatch)
55 | {
56 | isMatch = PatternMatcher.StrictMatchPattern(
57 | Pattern.ToUpper(CultureInfo.InvariantCulture),
58 | value.ToUpper(CultureInfo.InvariantCulture));
59 | }
60 |
61 | if (!isMatch)
62 | {
63 | try
64 | {
65 | if (_regex == null)
66 | {
67 | string escapedPattern = Pattern.Replace(@"\", @"\\", StringComparison.Ordinal);
68 | _regex = new Regex(escapedPattern, RegexOptions.IgnoreCase);
69 | }
70 | isMatch = _regex.IsMatch(value);
71 | }
72 | catch
73 | {
74 | isMatch = false;
75 | }
76 | }
77 |
78 | return isMatch;
79 | }
80 | }
81 | }
82 |
--------------------------------------------------------------------------------
/src/IO/Paths.cs:
--------------------------------------------------------------------------------
1 | using System.Xml.Serialization;
2 |
3 | namespace TE.FileWatcher.IO
4 | {
5 | ///
6 | /// The paths node within the exclusions node in the XML file.
7 | ///
8 | public class Paths
9 | {
10 | ///
11 | /// Gets or sets a list of paths.
12 | ///
13 | [XmlElement("path")]
14 | public HashSet Path { get; set; } = new HashSet(StringComparer.OrdinalIgnoreCase);
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/src/Log/Logger.cs:
--------------------------------------------------------------------------------
1 | using System.Collections.Concurrent;
2 | using System.Runtime.InteropServices;
3 |
4 | namespace TE.FileWatcher.Log
5 | {
6 | ///
7 | /// The log level of a log message.
8 | ///
9 | public enum LogLevel
10 | {
11 | INFO = 0,
12 | WARNING,
13 | ERROR,
14 | FATAL
15 | }
16 |
17 | ///
18 | /// The logger class that contains the properties and methods needed to
19 | /// manage the log file.
20 | ///
21 | public static class Logger
22 | {
23 | ///
24 | /// The default log file name.
25 | ///
26 | public const string DEFAULTLOGNAME = "fw.log";
27 |
28 | // A megabyte - for the purists, this is actually a mebibyte, but let's
29 | // not split hairs as this is just a log file size after all
30 | private const int MEGABYTE = 1048576;
31 |
32 | // The queue of log messages
33 | private static readonly ConcurrentQueue queue;
34 |
35 | ///
36 | /// Gets the path to the log.
37 | ///
38 | public static string LogPath { get; private set; }
39 |
40 | ///
41 | /// Gets the name of the log file.
42 | ///
43 | public static string LogName { get; private set; }
44 |
45 | ///
46 | /// Gets or sets the full path of the log file.
47 | ///
48 | public static string LogFullPath { get; private set; }
49 |
50 | ///
51 | /// Gets or sets the size (in megabytes) of a log file before it is
52 | /// backed up and a new log file is created.
53 | ///
54 | public static int LogSize { get; private set; }
55 |
56 | ///
57 | /// Gets or sets the number of log file to retain.
58 | ///
59 | public static int LogNumber { get; private set; }
60 |
61 | // The object used for the lock
62 | private static readonly object locker = new();
63 |
64 | ///
65 | /// Initializes an instance of the class.
66 | ///
67 | ///
68 | /// Thrown when the logger could not be initialized.
69 | ///
70 | static Logger()
71 | {
72 | LogPath = Path.GetTempPath();
73 | LogName = DEFAULTLOGNAME;
74 | LogSize = Configuration.Logging.DEFAULTLOGSIZE;
75 | LogNumber = Configuration.Logging.DEFAULTLOGNUMBER;
76 |
77 | try
78 | {
79 | LogFullPath = Path.Combine(LogPath, LogName);
80 | }
81 | catch (Exception ex)
82 | when (ex is ArgumentException || ex is ArgumentNullException || ex is IOException)
83 | {
84 | throw new InvalidOperationException($"The logger could not be initialized. Reason: {ex.Message}");
85 | }
86 |
87 | queue = new ConcurrentQueue();
88 | }
89 |
90 | ///
91 | /// Sets the logger options.
92 | ///
93 | ///
94 | /// The options for the logger.
95 | ///
96 | public static void SetLogger(Configuration.Logging logOptions)
97 | {
98 | if (logOptions == null)
99 | {
100 | return;
101 | }
102 |
103 | if (!string.IsNullOrWhiteSpace(logOptions.LogPath))
104 | {
105 | SetFullPath(logOptions.LogPath);
106 | }
107 |
108 | LogSize = logOptions.Size;
109 | LogNumber = logOptions.Number;
110 | }
111 |
112 | ///
113 | /// Adds a message to be added to the log. This method defaults the log
114 | /// level to .
115 | ///
116 | ///
117 | /// The message to write.
118 | ///
119 | public static void WriteLine(string message)
120 | {
121 | WriteLine(message, LogLevel.INFO);
122 | }
123 |
124 | ///
125 | /// Adds a message to be added to the log.
126 | ///
127 | ///
128 | /// The message to write.
129 | ///
130 | ///
131 | /// The log level associated with the message.
132 | ///
133 | public static void WriteLine(string message, LogLevel level)
134 | {
135 | queue.Enqueue(new Message(message, level));
136 | WriteToLog();
137 | }
138 |
139 | ///
140 | /// Sets the full path to the log file.
141 | ///
142 | ///
143 | /// The full path to the log file.
144 | ///
145 | ///
146 | /// Thrown when the argument is null or
147 | /// empty.
148 | ///
149 | ///
150 | /// Thrown when either the folder or log file name are not valid.
151 | ///
152 | private static void SetFullPath(string fullPath)
153 | {
154 | if (string.IsNullOrWhiteSpace(fullPath))
155 | {
156 | throw new ArgumentNullException(nameof(fullPath));
157 | }
158 |
159 | // Separate the path and log name so each can be check to ensure
160 | // they are valid
161 | string? path = Path.GetDirectoryName(fullPath);
162 | string name = Path.GetFileName(fullPath);
163 |
164 | if (string.IsNullOrWhiteSpace(path))
165 | {
166 | throw new IOException($"The directory path is null or empty.");
167 | }
168 |
169 | if (!IsFolderValid(path))
170 | {
171 | throw new IOException($"The directory name '{path}' is not valid.");
172 | }
173 |
174 | if (!IsFileNameValid(name))
175 | {
176 | throw new IOException($"The log file name '{name}' is not valid");
177 | }
178 |
179 | if (!string.IsNullOrWhiteSpace(path))
180 | {
181 | // Store the path, name and the full path if all the checks pass
182 | LogPath = path;
183 | LogName = name;
184 | LogFullPath = Path.Combine(path, name);
185 | }
186 | else
187 | {
188 | LogPath = string.Empty;
189 | LogName = name;
190 | LogFullPath = string.Empty;
191 | }
192 | }
193 |
194 | ///
195 | /// Checks if the folder provided is a valid folder. The folder will
196 | /// be created if it is valid.
197 | ///
198 | ///
199 | /// The path to the folder.
200 | ///
201 | ///
202 | /// True if the folder is valid and exists, otherwise false.
203 | ///
204 | private static bool IsFolderValid(string folder)
205 | {
206 | if (string.IsNullOrWhiteSpace(folder))
207 | {
208 | return false;
209 | }
210 |
211 | if (Directory.Exists(folder))
212 | {
213 | return true;
214 | }
215 | else
216 | {
217 | // Check for any invalid characters in the folder
218 | if (folder.IndexOfAny(Path.GetInvalidPathChars()) != -1)
219 | {
220 | return false;
221 | }
222 |
223 | try
224 | {
225 | Directory.CreateDirectory(folder);
226 | return Directory.Exists(folder);
227 | }
228 | catch
229 | {
230 | return false;
231 | }
232 | }
233 | }
234 |
235 | ///
236 | /// Checks if the file name is valid.
237 | ///
238 | ///
239 | /// The name of the file.
240 | ///
241 | ///
242 | /// True if the file name is valid, otherwise false.
243 | ///
244 | private static bool IsFileNameValid(string fileName)
245 | {
246 | if (string.IsNullOrWhiteSpace(fileName))
247 | {
248 | return false;
249 | }
250 |
251 | // Check for any invalid characters in the file name
252 | if (fileName.IndexOfAny(Path.GetInvalidFileNameChars()) != -1)
253 | {
254 | return false;
255 | }
256 |
257 | return true;
258 | }
259 |
260 | ///
261 | /// Rolls over the current log file if the size matches the specfied
262 | /// max size for the log file.
263 | ///
264 | private static void RolloverLog()
265 | {
266 | // Check to ensure the log file exists before trying to get the
267 | // size of the file
268 | if (!File.Exists(LogFullPath))
269 | {
270 | return;
271 | }
272 |
273 | try
274 | {
275 | // Get and check the log size to see if it is still less than the
276 | // specified log size
277 | FileInfo fileInfo = new(LogFullPath);
278 | if (fileInfo.Length < (LogSize * MEGABYTE))
279 | {
280 | return;
281 | }
282 |
283 | int totalLogs = LogNumber - 1;
284 |
285 | if (totalLogs > 0)
286 | {
287 | // Loop through the number of specified log files, and then copy
288 | // previous log files to the next log number and then delete the
289 | // log so the previous one can be copied
290 | for (int i = totalLogs; i > 0; i--)
291 | {
292 | string logFile = LogFullPath + $".{i - 1}";
293 | if (File.Exists(logFile))
294 | {
295 | string nextlogFile = LogFullPath + $".{i}";
296 | File.Copy(logFile, nextlogFile, true);
297 | File.Delete(logFile);
298 | }
299 | }
300 |
301 | // Copy the current log file to the first log number backup and
302 | // then delete the log file so it can be recreated
303 | string newLogFile = LogFullPath + $".1";
304 | File.Copy(LogFullPath, newLogFile, true);
305 | }
306 |
307 | File.Delete(LogFullPath);
308 | File.Create(LogFullPath).Close();
309 | }
310 | catch (Exception ex)
311 | {
312 | Console.WriteLine($"{ DateTime.Now:yyyy - MM - dd HH: mm: ss} Could not rollover the log file. Reason: {ex.Message}");
313 | }
314 | }
315 |
316 | ///
317 | /// Writes a line from the queue to the log file.
318 | ///
319 | private static void WriteToLog()
320 | {
321 | while (queue.TryDequeue(out Message? message))
322 | {
323 | try
324 | {
325 | lock (locker)
326 | {
327 | RolloverLog();
328 | using StreamWriter writer = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? new(LogFullPath, true, System.Text.Encoding.UTF8) : new(LogFullPath, true);
329 | writer.WriteLine($"{DateTime.Now:yyyy-MM-dd HH:mm:ss} {message.LevelString} {message.Value}");
330 | }
331 | }
332 | catch (Exception ex)
333 | {
334 | Message error = new($"Couldn't write to the log. Reason: {ex.Message}", LogLevel.WARNING);
335 | Console.WriteLine($"{DateTime.Now:yyyy-MM-dd HH:mm:ss} {error.LevelString} {error.Value}");
336 | }
337 | }
338 | }
339 | }
340 | }
--------------------------------------------------------------------------------
/src/Log/Message.cs:
--------------------------------------------------------------------------------
1 | namespace TE.FileWatcher.Log
2 | {
3 | ///
4 | /// The message to write to the log.
5 | ///
6 | public class Message
7 | {
8 | ///
9 | /// Gets or sets the value of the message.
10 | ///
11 | public string Value { get; set; }
12 |
13 | ///
14 | /// Gets or sets the level of the message.
15 | ///
16 | public LogLevel Level { get; set; } = LogLevel.INFO;
17 |
18 | ///
19 | /// Gets the string representation of the log level.
20 | ///
21 | public string LevelString
22 | {
23 | get
24 | {
25 | return Level switch
26 | {
27 | LogLevel.WARNING => "WARN",
28 | LogLevel.ERROR => "ERROR",
29 | LogLevel.FATAL => "FATAL",
30 | _ => "INFO",
31 | };
32 | }
33 | }
34 |
35 | ///
36 | /// Initializes a class when provided with the
37 | /// message value and log level.
38 | ///
39 | ///
40 | /// The message value.
41 | ///
42 | ///
43 | /// The level of the message.
44 | ///
45 | public Message(string value, LogLevel level)
46 | {
47 | Value = value;
48 | Level = level;
49 | }
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/src/Net/Request.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.Extensions.DependencyInjection;
2 | using System.Text;
3 | using TE.FileWatcher.Configuration;
4 |
5 | namespace TE.FileWatcher.Net
6 | {
7 | internal static class Request
8 | {
9 | ///
10 | /// The MIME type used for the request.
11 | ///
12 | internal enum MimeType
13 | {
14 | ///
15 | /// JSON
16 | ///
17 | Json,
18 | ///
19 | /// XML
20 | ///
21 | Xml
22 | }
23 |
24 | ///
25 | /// The valid JSON name.
26 | ///
27 | internal const string JSON_NAME = "JSON";
28 |
29 | ///
30 | /// The valid XML name.
31 | ///
32 | internal const string XML_NAME = "XML";
33 |
34 | // JSON mime type
35 | private const string MIME_TYPE_JSON = "application/json";
36 |
37 | // XML mime type
38 | private const string MIME_TYPE_XML = "application/xml";
39 |
40 | // The collection of services - contains the HTTP clients
41 | private static readonly ServiceCollection _services = new ServiceCollection();
42 |
43 | // The provider of the service - the HTTP client
44 | private static ServiceProvider? _serviceProvider;
45 |
46 | ///
47 | /// Sends a request to a remote system asychronously.
48 | ///
49 | ///
50 | /// The HTTP method to use for the request.
51 | ///
52 | /// The URL of the request.
53 | ///
54 | /// The object associated with the request.
55 | ///
56 | /// The content body of the request.
57 | ///
58 | ///
59 | /// The MIME type associated with the request.
60 | ///
61 | ///
62 | /// The response message of the request.
63 | ///
64 | ///
65 | /// Thrown when an argument is null or empty.
66 | ///
67 | internal static async Task SendAsync(
68 | HttpMethod method,
69 | Uri uri,
70 | Headers? headers,
71 | string? body,
72 | MimeType mimeType)
73 | {
74 | if (uri == null)
75 | {
76 | throw new ArgumentNullException(nameof(uri));
77 | }
78 |
79 | if (_serviceProvider == null)
80 | {
81 | _services.AddHttpClient();
82 | _serviceProvider = _services.BuildServiceProvider();
83 | }
84 |
85 | using (HttpRequestMessage request = new HttpRequestMessage(method, uri))
86 | {
87 | headers?.Set(request);
88 |
89 | if (body != null)
90 | {
91 | request.Content = new StringContent(body, Encoding.UTF8, GetMimeTypeString(mimeType));
92 | }
93 |
94 | try
95 | {
96 | var client = _serviceProvider.GetService();
97 | if (client != null)
98 | {
99 | using (HttpResponseMessage requestResponse =
100 | await client.SendAsync(request).ConfigureAwait(false))
101 | {
102 | using (HttpContent httpContent = requestResponse.Content)
103 | {
104 | string resultContent =
105 | await httpContent.ReadAsStringAsync().ConfigureAwait(false);
106 |
107 | return new Response(
108 | requestResponse.StatusCode,
109 | requestResponse.ReasonPhrase,
110 | resultContent,
111 | uri.OriginalString);
112 | }
113 | }
114 | }
115 | else
116 | {
117 | return new Response(
118 | System.Net.HttpStatusCode.InternalServerError,
119 | "Request could not be sent. Reason: The HTTP client service could not be initialized.",
120 | null,
121 | uri.OriginalString); ;
122 | }
123 | }
124 | catch (Exception ex)
125 | when (ex is ArgumentNullException || ex is InvalidOperationException || ex is HttpRequestException || ex is TaskCanceledException)
126 | {
127 | return new Response(
128 | System.Net.HttpStatusCode.InternalServerError,
129 | $"Request could not be sent. Reason: {ex.Message}",
130 | null,
131 | uri.OriginalString);
132 | }
133 | }
134 | }
135 |
136 | ///
137 | /// Gets the string value of the specified MIME type.
138 | ///
139 | ///
140 | /// The MIME type used for the request.
141 | ///
142 | ///
143 | /// The string value of the specified MIME type.
144 | ///
145 | private static string GetMimeTypeString(MimeType mimeType)
146 | {
147 | string type = MIME_TYPE_JSON;
148 | if (mimeType == MimeType.Xml)
149 | {
150 | type = MIME_TYPE_XML;
151 | }
152 |
153 | return type;
154 | }
155 | }
156 | }
157 |
--------------------------------------------------------------------------------
/src/Net/Response.cs:
--------------------------------------------------------------------------------
1 | using System.Net;
2 |
3 | namespace TE.FileWatcher.Net
4 | {
5 | ///
6 | /// The information from a request response.
7 | ///
8 | internal class Response
9 | {
10 | ///
11 | /// Gets the status code.
12 | ///
13 | internal HttpStatusCode StatusCode { get; private set; }
14 |
15 | ///
16 | /// Gets the reason phrase.
17 | ///
18 | internal string? ReasonPhrase { get; private set; }
19 |
20 | ///
21 | /// Gets the content for the response.
22 | ///
23 | internal string? Content { get; private set; }
24 |
25 | ///
26 | /// Gets the URL for the response.
27 | ///
28 | internal string? Url { get; private set; }
29 |
30 | ///
31 | /// Initializes a new instance of the class
32 | /// when provided with the status code, reason phrase and the
33 | /// content.
34 | ///
35 | ///
36 | /// The response status code.
37 | ///
38 | ///
39 | /// The reason phrase.
40 | ///
41 | ///
42 | /// The content from the request.
43 | ///
44 | internal Response(HttpStatusCode statusCode, string? reasonPhrase, string? content, string? url)
45 | {
46 | StatusCode = statusCode;
47 | ReasonPhrase = reasonPhrase;
48 | Content = content;
49 | Url = url;
50 | }
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/src/Placeholder.cs:
--------------------------------------------------------------------------------
1 | using System.Text.RegularExpressions;
2 | using TE.FileWatcher.Log;
3 | using TEFS = TE.FileWatcher.FileSystem;
4 | using System.Globalization;
5 | using System.Web;
6 | using System;
7 |
8 | namespace TE.FileWatcher
9 | {
10 | public sealed class Placeholder
11 | {
12 | // The exact path placeholder
13 | internal const string PLACEHOLDEREXACTPATH = "[exactpath]";
14 |
15 | // The full path placeholder
16 | internal const string PLACEHOLDERFULLPATH = "[fullpath]";
17 |
18 | // The path placholder
19 | internal const string PLACEHOLDERPATH = "[path]";
20 |
21 | // The file placeholder
22 | internal const string PLACEHOLDERFILE = "[file]";
23 |
24 | // The file name placeholder
25 | internal const string PLACEHOLDERFILENAME = "[filename]";
26 |
27 | // The file extension placeholder
28 | internal const string PLACEHOLDEREXTENSION = "[extension]";
29 |
30 | // The old exact path placeholder when a file/folder is renamed
31 | internal const string PLACEHOLDEROLDEXACTPATH = "[oldexactpath]";
32 |
33 | // The old full path placeholder when a file/folder is renamed
34 | internal const string PLACEHOLDEROLDFULLPATH = "[oldfullpath]";
35 |
36 | // The old path placholder when a file/folder is renamed
37 | internal const string PLACEHOLDEROLDPATH = "[oldpath]";
38 |
39 | // The old file placeholder when a file/folder is renamed
40 | internal const string PLACEHOLDEROLDFILE = "[oldfile]";
41 |
42 | // The old file name placeholder when a file/folder is renamed
43 | internal const string PLACEHOLDEROLDFILENAME = "[oldfilename]";
44 |
45 | // The old file extension placeholder when a file/folder is renamed
46 | internal const string PLACEHOLDEROLDEXTENSION = "[oldextension]";
47 |
48 | // The created date placeholder value
49 | internal const string PLACEHOLDERCREATEDDATE = "createddate";
50 |
51 | // The modified date placholder value
52 | internal const string PLACEHOLDERMODIFIEDDATE = "modifieddate";
53 |
54 | // The current date placeholder value
55 | internal const string PLACEHOLDERCURRENTDATE = "currentdate";
56 |
57 | // Environment variable placeholder value
58 | internal const string PLACEHOLDERENVVAR = "env";
59 |
60 | // The URL encode placeholder value
61 | internal const string PLACEHOLDERURLENCODE = "urlenc";
62 |
63 | // The regular expresson pattern for extracting the type and the
64 | // specified format/value to be used
65 | const string PATTERN = @"\[(?.*?):(?.*?)\]";
66 |
67 | // The regular expression
68 | private static readonly Regex _regex = new Regex(PATTERN, RegexOptions.Compiled);
69 |
70 | ///
71 | /// Replaces the placeholders in a string with the actual values.
72 | ///
73 | ///
74 | /// The value containing the placeholders.
75 | ///
76 | ///
77 | /// The watch path.
78 | ///
79 | ///
80 | /// The full path of the changed file.
81 | ///
82 | ///
83 | /// The old path to the file or folder.
84 | ///
85 | ///
86 | /// The value with the placeholders replaced with the actual strings,
87 | /// otherwise null.
88 | ///
89 | internal string? ReplacePlaceholders(string value, string watchPath, string fullPath, string? oldPath)
90 | {
91 | string? changedValue = ReplaceFileFolderPlaceholders(value, watchPath, fullPath, oldPath);
92 | if (!string.IsNullOrWhiteSpace(changedValue))
93 | {
94 | changedValue = ReplaceFormatPlaceholders(changedValue, fullPath);
95 | }
96 |
97 | return changedValue;
98 | }
99 |
100 | ///
101 | /// Gets the date value for the specified date type using the full path
102 | /// of the changed file.
103 | ///
104 | ///
105 | /// The type of date.
106 | ///
107 | ///
108 | /// The value for the type, otherwise null.
109 | ///
110 | ///
111 | /// Thrown when the date could not be determined.
112 | ///
113 | private DateTime? GetDate(string dateType, string fullPath)
114 | {
115 |
116 | // Determine the type of date type, and then get
117 | // the value for the date
118 | return dateType switch
119 | {
120 | PLACEHOLDERCREATEDDATE => TEFS.File.GetCreatedDate(fullPath),
121 | PLACEHOLDERMODIFIEDDATE => TEFS.File.GetModifiedDate(fullPath),
122 | PLACEHOLDERCURRENTDATE => DateTime.Now,
123 | _ => null
124 | };
125 | }
126 |
127 | ///
128 | /// Gets the date string value using the specified date and format.
129 | ///
130 | ///
131 | /// The date to be formatted.
132 | ///
133 | ///
134 | /// The format string.
135 | ///
136 | ///
137 | /// The formatted string value
138 | ///
139 | ///
140 | /// Thrown when the date string value can not be created.
141 | ///
142 | private string? GetDateString(DateTime date, string format)
143 | {
144 | if (string.IsNullOrEmpty(format))
145 | {
146 | Logger.WriteLine("The date format was not provided.");
147 | return null;
148 | }
149 |
150 | try
151 | {
152 | // Format the date, or return null if there is an
153 | // issue trying to format the date
154 | string? dateString = date.ToString(format, CultureInfo.CurrentCulture);
155 | if (string.IsNullOrWhiteSpace(dateString))
156 | {
157 | // There was an issue formatting the date, and
158 | // the date string value was null or contained
159 | // no value, so write a log message, and then
160 | // continue to the next match
161 | throw new FileWatcherException(
162 | $"The date could not be formatted. Format: {format}, date: {date}.");
163 | }
164 |
165 | return dateString;
166 | }
167 | catch (Exception ex)
168 | when (ex is ArgumentException || ex is FormatException)
169 | {
170 | throw new FileWatcherException(
171 | $"The date could not be formatted properly using '{format}'. Reason: {ex.Message}");
172 | }
173 | }
174 |
175 | ///
176 | /// Gets the date value for the specified date type and format, and
177 | /// replaces the date placeholder with the date value.
178 | ///
179 | ///
180 | /// The placeholder in the value.
181 | ///
182 | ///
183 | /// The string value containing the placeholder.
184 | ///
185 | ///
186 | /// The date type.
187 | ///
188 | ///
189 | /// The format of the date.
190 | ///
191 | ///
192 | /// The date string value.
193 | ///
194 | private string GetDateValue(string placeholder, string value, string type, string format, string fullPath)
195 | {
196 | // Get the date for the specified date type
197 | DateTime? date = GetDate(type, fullPath);
198 | if (date != null)
199 | {
200 | // The string value for the date time using the date type
201 | // and format
202 | string? dateString = GetDateString((DateTime)date, format);
203 |
204 | // Replace the date placeholder with the formatted date
205 | // value
206 | value = value.Replace(placeholder, dateString, StringComparison.OrdinalIgnoreCase);
207 | }
208 |
209 | return value;
210 | }
211 |
212 | ///
213 | /// Gets the environment variable value and replaces the environment
214 | /// variable placeholder with the environment variable value.
215 | ///
216 | ///
217 | /// The placeholder in the value.
218 | ///
219 | ///
220 | /// The string value containing the placeholder.
221 | ///
222 | ///
223 | /// The name of the environment variable.
224 | ///
225 | ///
226 | /// The value of the environment variable.
227 | ///
228 | private string GetEnvironmentVariableValue(string placeholder, string value, string envName)
229 | {
230 |
231 | string? envValue = Environment.GetEnvironmentVariable(envName);
232 | if (envValue != null)
233 | {
234 | value = value.Replace(placeholder, envValue, StringComparison.OrdinalIgnoreCase);
235 | }
236 |
237 | return value;
238 | }
239 |
240 | ///
241 | /// Gets the relative path from the watch path using the full path.
242 | ///
243 | ///
244 | /// The relative path.
245 | ///
246 | private string GetRelativeFullPath(string fullPath, string watchPath)
247 | {
248 | if (string.IsNullOrWhiteSpace(watchPath))
249 | {
250 | return "";
251 | }
252 |
253 | try
254 | {
255 | int index = fullPath.IndexOf(watchPath, StringComparison.OrdinalIgnoreCase);
256 | return index < 0 ? fullPath : fullPath.Remove(index, watchPath.Length).Trim(Path.DirectorySeparatorChar);
257 | }
258 | catch (Exception ex)
259 | when (ex is ArgumentException || ex is ArgumentNullException)
260 | {
261 | return fullPath;
262 | }
263 | }
264 |
265 | ///
266 | /// Gets the relative path without the file name from the watch path
267 | /// using the full path.
268 | ///
269 | ///
270 | /// The path.
271 | ///
272 | ///
273 | /// The relative path without the file name, otherwise null.
274 | ///
275 | private string? GetRelativePath(string path, string watchPath)
276 | {
277 | string? relativeFullPath = Path.GetDirectoryName(path);
278 | if (relativeFullPath == null)
279 | {
280 | return null;
281 | }
282 |
283 | return GetRelativeFullPath(relativeFullPath, watchPath);
284 | }
285 |
286 | ///
287 | /// Encodes the URL value and replaces the URL encode placeholder with
288 | /// the URL encoded value.
289 | ///
290 | ///
291 | /// The placeholder in the value.
292 | ///
293 | ///
294 | /// The string value containing the placeholder.
295 | ///
296 | ///
297 | /// The URL string.
298 | ///
299 | ///
300 | private string GetUrlEncodedValue(string placeholder, string value, string url)
301 | {
302 | string? encodedValue = HttpUtility.UrlEncode(url);
303 | if (encodedValue != null)
304 | {
305 | value = value.Replace(placeholder, encodedValue, StringComparison.OrdinalIgnoreCase);
306 | }
307 |
308 | return value;
309 | }
310 |
311 | ///
312 | /// Gets the value with the folder placeholders replaced with the
313 | /// correct file and folder information.
314 | ///
315 | ///
316 | /// The value containing the placeholders.
317 | ///
318 | ///
319 | /// The watch path.
320 | ///
321 | ///
322 | /// The full path to the file or folder.
323 | ///
324 | ///
325 | /// The old path to the file or folder.
326 | ///
327 | ///
328 | /// The value with the placeholders replaced with the actual strings,
329 | /// otherwise null.
330 | ///
331 | private string? ReplaceFileFolderPlaceholders(string value, string watchPath, string fullPath, string? oldPath)
332 | {
333 | if (string.IsNullOrWhiteSpace(value) || string.IsNullOrWhiteSpace(fullPath))
334 | {
335 | return null;
336 | }
337 |
338 | string relativeFullPath = GetRelativeFullPath(fullPath, watchPath);
339 | string? relativePath = GetRelativePath(fullPath, watchPath);
340 | string? fileName = TEFS.File.GetName(fullPath, true);
341 | string? fileNameWithoutExtension = TEFS.File.GetName(fullPath, false);
342 | string? extension = TEFS.File.GetExtension(fullPath);
343 |
344 | string replacedValue = value;
345 | replacedValue = replacedValue.Replace(PLACEHOLDEREXACTPATH, fullPath, StringComparison.OrdinalIgnoreCase);
346 | replacedValue = replacedValue.Replace(PLACEHOLDERFULLPATH, relativeFullPath, StringComparison.OrdinalIgnoreCase);
347 | replacedValue = replacedValue.Replace(PLACEHOLDERPATH, relativePath, StringComparison.OrdinalIgnoreCase);
348 | replacedValue = replacedValue.Replace(PLACEHOLDERFILENAME, fileName, StringComparison.OrdinalIgnoreCase);
349 | replacedValue = replacedValue.Replace(PLACEHOLDERFILE, fileNameWithoutExtension, StringComparison.OrdinalIgnoreCase);
350 | replacedValue = replacedValue.Replace(PLACEHOLDEREXTENSION, extension, StringComparison.OrdinalIgnoreCase);
351 |
352 | // If the changes include an old path, such as when a file/folder
353 | // is renamed, then replace those placeholders
354 | if (!string.IsNullOrWhiteSpace(oldPath))
355 | {
356 | string oldRelativeFullPath = GetRelativeFullPath(oldPath, watchPath);
357 | string? oldRelativePath = GetRelativePath(oldPath, watchPath);
358 | string? oldFileName = TEFS.File.GetName(oldPath, true);
359 | string? oldFileNameWithoutExtension = TEFS.File.GetName(oldPath, false);
360 | string? oldExtension = TEFS.File.GetExtension(oldPath);
361 |
362 | replacedValue = replacedValue.Replace(PLACEHOLDEROLDEXACTPATH, oldPath, StringComparison.OrdinalIgnoreCase);
363 | replacedValue = replacedValue.Replace(PLACEHOLDEROLDFULLPATH, oldRelativeFullPath, StringComparison.OrdinalIgnoreCase);
364 | replacedValue = replacedValue.Replace(PLACEHOLDEROLDPATH, oldRelativePath, StringComparison.OrdinalIgnoreCase);
365 | replacedValue = replacedValue.Replace(PLACEHOLDEROLDFILENAME, oldFileName, StringComparison.OrdinalIgnoreCase);
366 | replacedValue = replacedValue.Replace(PLACEHOLDEROLDFILE, oldFileNameWithoutExtension, StringComparison.OrdinalIgnoreCase);
367 | replacedValue = replacedValue.Replace(PLACEHOLDEROLDEXTENSION, oldExtension, StringComparison.OrdinalIgnoreCase);
368 | }
369 |
370 | return replacedValue;
371 | }
372 |
373 | ///
374 | /// Replaces the formatted placeholders in a string with the actual values.
375 | ///
376 | ///
377 | /// The value containing the placeholders.
378 | ///
379 | ///
380 | /// The watch path.
381 | ///
382 | ///
383 | /// The full path of the changed file.
384 | ///
385 | ///
386 | /// The value with the placeholders replaced with the actual strings,
387 | /// otherwise null.
388 | ///
389 | private string? ReplaceFormatPlaceholders(string value, string fullPath)
390 | {
391 | if (string.IsNullOrWhiteSpace(value))
392 | {
393 | return null;
394 | }
395 |
396 | if (_regex.IsMatch(value))
397 | {
398 | // Find all the regex matches that are in the string since there
399 | // could be multiple date matches
400 | MatchCollection matches = _regex.Matches(value);
401 | if (matches.Count > 0)
402 | {
403 | // Loop through each of the matches so the placeholder can
404 | // be replaced with the actual date values
405 | foreach (Match match in matches.Cast())
406 | {
407 |
408 | // Store the date type (createddate, modifieddate,
409 | // or currentdate) and change it to lowercase so it can
410 | // be easily compared later
411 | string type = match.Groups["type"].Value.ToLower(CultureInfo.CurrentCulture);
412 | // Store the specified date format
413 | string format = match.Groups["format"].Value;
414 | try
415 | {
416 | // Determine the type of date type, and then get
417 | // the value for the date
418 | switch (type)
419 | {
420 | case PLACEHOLDERCREATEDDATE:
421 | case PLACEHOLDERMODIFIEDDATE:
422 | case PLACEHOLDERCURRENTDATE:
423 | value = GetDateValue(match.Value, value, type, format, fullPath);
424 | break;
425 | case PLACEHOLDERENVVAR:
426 | value = GetEnvironmentVariableValue(match.Value, value, format);
427 | break;
428 | case PLACEHOLDERURLENCODE:
429 | value = GetUrlEncodedValue(match.Value, value, format);
430 | break;
431 | };
432 |
433 | }
434 | catch (FileWatcherException ex)
435 | {
436 | Logger.WriteLine(ex.Message, LogLevel.ERROR);
437 | continue;
438 | }
439 | }
440 | }
441 | }
442 |
443 | return value;
444 | }
445 | }
446 | }
447 |
--------------------------------------------------------------------------------
/src/Program.cs:
--------------------------------------------------------------------------------
1 | using System.CommandLine;
2 | using System.CommandLine.Invocation;
3 | using System.Diagnostics.CodeAnalysis;
4 | using TE.FileWatcher.Configuration;
5 | using TE.FileWatcher.Log;
6 | using Microsoft.Extensions.Hosting;
7 | using Microsoft.Extensions.Hosting.WindowsServices;
8 | using Microsoft.Extensions.DependencyInjection;
9 | using Microsoft.Extensions.Logging;
10 | using Microsoft.Extensions.Configuration;
11 | using System.CommandLine.IO;
12 | using System.Text;
13 | using System;
14 |
15 | namespace TE.FileWatcher
16 | {
17 | ///
18 | /// The main program class.
19 | ///
20 | internal class Program
21 | {
22 | // Success return code
23 | private const int SUCCESS = 0;
24 |
25 | // Error return code
26 | private const int ERROR = -1;
27 |
28 | ///
29 | /// The main function.
30 | ///
31 | ///
32 | /// Arguments passed into the application.
33 | ///
34 | ///
35 | /// Returns 0 on success, otherwise non-zero.
36 | ///
37 | [UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code", Justification = "")]
38 | static int Main(string[] args)
39 | {
40 | if (WindowsServiceHelpers.IsWindowsService())
41 | {
42 | return ServiceMain(args);
43 | }
44 | else
45 | {
46 | return InitWatcher(args);
47 | }
48 | }
49 |
50 | ///
51 | /// Sets up the RootCommand, which parses the command line and runs the filewatcher.
52 | /// This is both used directly from Main, but also indirectly from the service, if running as a Windows Service.
53 | ///
54 | ///
55 | ///
56 | ///
57 | ///
58 | [RequiresUnreferencedCode("Calls TE.FileWatcher.Configuration.IConfigurationFile.Read()")]
59 | public static int InitWatcher(string[] args, CancellationToken? stoppingToken = null, WindowsBackgroundService.LoggingConsole? console = null)
60 | {
61 | RootCommand rootCommand = new()
62 | {
63 | new Option(
64 | aliases: new string[] { "--folder", "-f" },
65 | description: "The folder containing the configuration XML file."),
66 |
67 | new Option(
68 | aliases: new string[] { "--configFile", "-cf" },
69 | description: "The name of the configuration XML file."),
70 | };
71 | if (stoppingToken != null)
72 | {
73 | rootCommand.AddOption(new Option(
74 | alias: "--stoppingToken",
75 | getDefaultValue: () => stoppingToken,
76 | description: "CancellationToken")
77 | { IsHidden = true }
78 | );
79 | }
80 | rootCommand.Description = "Monitors files and folders for changes.";
81 | rootCommand.Handler = CommandHandler.Create(Run);
82 |
83 | rootCommand.TreatUnmatchedTokensAsErrors = true;
84 | return rootCommand.Invoke(args, console);
85 | }
86 |
87 | ///
88 | /// Runs the file/folder watcher.
89 | ///
90 | ///
91 | /// The folder where the config and notifications files are stored.
92 | ///
93 | ///
94 | /// The name of the configuration file.
95 | ///
96 | ///
97 | /// Returns 0 if no error occurred, otherwise non-zero.
98 | ///
99 | [RequiresUnreferencedCode("Calls TE.FileWatcher.Configuration.IConfigurationFile.Read()")]
100 | internal static int Run(string? folder, string? configFile, CancellationToken? stoppingToken = null)
101 | {
102 | IConfigurationFile config = new XmlFile(folder, configFile);
103 |
104 | // Load the watches information from the config XML file
105 | Watches? watches = config.Read();
106 | if (watches == null)
107 | {
108 | return ERROR;
109 | }
110 |
111 | // Set the logger
112 | if (!SetLogger(watches))
113 | {
114 | return ERROR;
115 | }
116 |
117 | // Run the watcher tasks
118 | if (StartWatchers(watches, stoppingToken))
119 | {
120 | return SUCCESS;
121 | }
122 | else
123 | {
124 | return ERROR;
125 | }
126 | }
127 |
128 | ///
129 | /// Runs the file/folder watcher as a Windows Service.
130 | ///
131 | ///
132 | /// The folder where the config and notifications files are stored.
133 | ///
134 | ///
135 | /// The name of the configuration file.
136 | ///
137 | ///
138 | /// Returns 0 if no error occurred, otherwise non-zero.
139 | ///
140 | [RequiresUnreferencedCode("Calls TE.FileWatcher.Configuration.IConfigurationFile.Read()")]
141 | internal static int ServiceMain(string[] args)
142 | {
143 | HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);
144 | builder.Services.AddWindowsService(options =>
145 | {
146 | options.ServiceName = "FileWatcher Service";
147 | });
148 |
149 | builder.Services.AddHostedService();
150 |
151 | // See: https://github.com/dotnet/runtime/issues/47303
152 | builder.Logging.AddConfiguration(builder.Configuration.GetSection("Logging"));
153 |
154 | IHost host = builder.Build();
155 | host.Run();
156 | return SUCCESS;
157 | }
158 |
159 | ///
160 | /// Sets the logger.
161 | ///
162 | ///
163 | /// The watches object that contains the log path.
164 | ///
165 | ///
166 | /// True if the Logger was set, otherwise false.
167 | ///
168 | private static bool SetLogger(Watches watches)
169 | {
170 | if (watches == null)
171 | {
172 | Console.WriteLine("The watches object was not initialized.");
173 | return false;
174 | }
175 |
176 | try
177 | {
178 | Logger.SetLogger(watches.Logging);
179 | return true;
180 | }
181 | catch (Exception ex)
182 | {
183 | Console.WriteLine($"The log file could not be set. Reason: {ex.Message}");
184 | return false;
185 | }
186 | }
187 |
188 | ///
189 | /// Runs the watcher tasks as defined in the configuration XML file.
190 | ///
191 | ///
192 | /// The watches.
193 | ///
194 | ///
195 | /// True if the tasks were started and run successfully, otherwise false.
196 | ///
197 | private static bool StartWatchers(Watches watches, CancellationToken? stoppingToken = null)
198 | {
199 | if (watches == null)
200 | {
201 | Console.WriteLine("The watches object was not initialized.");
202 | return false;
203 | }
204 |
205 | watches.Start();
206 | if (stoppingToken != null)
207 | {
208 | stoppingToken.Value.WaitHandle.WaitOne();
209 | }
210 | else
211 | {
212 | new AutoResetEvent(false).WaitOne(); // Will never return
213 | }
214 |
215 | Logger.WriteLine("All watchers have closed.");
216 | return true;
217 | }
218 |
219 | }
220 |
221 | public sealed class WindowsBackgroundService : BackgroundService
222 | {
223 | private readonly ILogger _eventLogger;
224 |
225 | public WindowsBackgroundService(
226 | ILogger logger) =>
227 | (_eventLogger) = (logger);
228 |
229 | [UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code", Justification = "")]
230 | protected override async Task ExecuteAsync(CancellationToken stoppingToken)
231 | {
232 | try
233 | {
234 | _eventLogger.LogInformation($"CommandLineArgs: {Environment.CommandLine}");
235 |
236 | LoggingConsole loggingConsole = new LoggingConsole(_eventLogger);
237 | var errorLogWriter = new ErrorLogWriter(_eventLogger);
238 | Console.SetError(errorLogWriter);
239 | var result = await Task.Run( () => Program.InitWatcher(Environment.GetCommandLineArgs(), stoppingToken, loggingConsole));
240 | if (result != 0)
241 | {
242 | errorLogWriter.WriteLine();
243 | _eventLogger.LogError("Process: {ProcessPath}\r\n{Message}", Environment.ProcessPath, $"Service stopped with error: {result}");
244 | Environment.Exit(result);
245 | }
246 | }
247 | catch (TaskCanceledException)
248 | {
249 | // When the stopping token is canceled, for example, a call made from services.msc,
250 | // we shouldn't exit with a non-zero exit code. In other words, this is expected...
251 | }
252 | catch (Exception ex)
253 | {
254 | _eventLogger.LogError(ex, "Process: {ProcessPath}\r\n{Message}", Environment.ProcessPath, ex.Message);
255 |
256 | // Terminates this process and returns an exit code to the operating system.
257 | // This is required to avoid the 'BackgroundServiceExceptionBehavior', which
258 | // performs one of two scenarios:
259 | // 1. When set to "Ignore": will do nothing at all, errors cause zombie services.
260 | // 2. When set to "StopHost": will cleanly stop the host, and log errors.
261 | //
262 | // In order for the Windows Service Management system to leverage configured
263 | // recovery options, we need to terminate the process with a non-zero exit code.
264 | Environment.Exit(1);
265 | }
266 | }
267 | public class LoggingConsole : IConsole
268 | {
269 | public readonly ILogger _eventLogger;
270 |
271 | public LoggingConsole(ILogger eventLogger)
272 | {
273 | this._eventLogger = eventLogger;
274 | }
275 |
276 | public IStandardStreamWriter Out => new LoggingStandardWriter(this);
277 |
278 | public bool IsOutputRedirected => true;
279 |
280 | public IStandardStreamWriter Error => new LoggingErrorWriter(this);
281 |
282 | public bool IsErrorRedirected => true;
283 |
284 | public bool IsInputRedirected => false;
285 |
286 | private class LoggingStandardWriter : IStandardStreamWriter
287 | {
288 | private readonly LoggingConsole _console;
289 |
290 | public LoggingStandardWriter(LoggingConsole console)
291 | {
292 | _console = console;
293 | }
294 |
295 | public void Write(string value)
296 | {
297 | if (!string.IsNullOrEmpty(value?.Trim()))
298 | _console._eventLogger.LogDebug("Process: {ProcessPath}\r\n{value}", Environment.ProcessPath, value);
299 | }
300 | }
301 | private class LoggingErrorWriter : IStandardStreamWriter
302 | {
303 | private readonly LoggingConsole _console;
304 |
305 | public LoggingErrorWriter(LoggingConsole console)
306 | {
307 | _console = console;
308 | }
309 | public void Write(string value)
310 | {
311 | if (!string.IsNullOrEmpty(value?.Trim()))
312 | _console._eventLogger.LogError("Process: {ProcessPath}\r\n{value}", Environment.ProcessPath, value);
313 | }
314 | }
315 | }
316 | public class ErrorLogWriter : TextWriter
317 | {
318 | public override Encoding Encoding => Encoding.UTF8;
319 |
320 | public readonly ILogger _eventLogger;
321 | StringBuilder _message = new StringBuilder();
322 |
323 | public ErrorLogWriter(ILogger eventLogger)
324 | {
325 | this._eventLogger = eventLogger;
326 | }
327 |
328 | public override void Write(string? value)
329 | {
330 | if (!string.IsNullOrEmpty(value))
331 | _message.Append(value);
332 | }
333 |
334 | public override void Write(char ch)
335 | {
336 | _message.Append(ch);
337 | }
338 |
339 | public override void WriteLine(string? value)
340 | {
341 | if (!string.IsNullOrEmpty(value?.Trim()))
342 | {
343 | _message.Append(value);
344 | }
345 | WriteLine();
346 | }
347 | public override void WriteLine()
348 | {
349 | if (_message.Length > 0)
350 | {
351 | _eventLogger.LogError("Process: {ProcessPath}\r\n{value}", Environment.ProcessPath, _message);
352 | _message.Clear();
353 | }
354 | }
355 |
356 | }
357 | }
358 | }
359 |
--------------------------------------------------------------------------------
/src/appsettings.json:
--------------------------------------------------------------------------------
1 | {
2 | "Logging": {
3 | "LogLevel": {
4 | "Default": "Warning"
5 | },
6 | "EventLog": {
7 | "SourceName": "FileWatcher Service",
8 | "LogName": "Application",
9 | "LogLevel": {
10 | "Microsoft": "Information",
11 | "Microsoft.Hosting.Lifetime": "Information",
12 | "TE.FileWatcher.WindowsBackgroundService": "Error"
13 | }
14 | }
15 | }
16 | }
--------------------------------------------------------------------------------
/templates/config-template.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
8 |
9 |
15 |
16 |
22 |
23 |
24 |
25 |
26 |
30 |
31 |
37 |
38 |
39 |
43 |
44 |
45 |
46 |
51 |
52 |
53 |
54 |
66 |
67 |
68 |
69 |
75 |
76 |
77 |
78 |
79 |
80 |
84 |
85 |
86 |
87 |
92 |
93 |
94 |
95 |
107 |
108 |
109 |
110 |
116 |
117 |
118 |
119 |
120 |
121 |
126 |
127 |
128 |
129 |
138 |
139 |
148 |
149 |
150 |
151 |
152 |
153 |
157 |
158 |
162 |
163 |
170 |
171 |
172 |
173 |
174 |
177 |
178 |
179 |
188 |
189 |
190 |
191 |
199 |
200 |
211 |
212 |
227 |
228 |
235 |
236 |
243 |
244 |
245 |
246 |
249 |
250 |
251 |
260 |
261 |
262 |
263 |
274 |
275 |
286 |
287 |
288 |
289 |
290 |
--------------------------------------------------------------------------------