diff --git a/BUILD_INFO.txt b/BUILD_INFO.txt new file mode 100644 index 0000000..52603d8 --- /dev/null +++ b/BUILD_INFO.txt @@ -0,0 +1,13 @@ +FileInterface Watcher + CronScheduler - COMPLETE +Date: 2026-08-19 +Target: .NET Framework 4.8, x86 +IMS API: IMSApiDotNet.dll 10.0.0.3 included under FileinterfaceCronScheduler/lib +Features: +- FileSystemWatcher jobs configurable through HTTP web client +- Cron jobs configurable through HTTP web client +- Both subsystems run concurrently and share the IMS API client/session +- Unlimited inArgs per job +- Optional file move after successful customFunction +- Watcher Created/Changed/Renamed selection, recursion and debounce +- Live watcher reload after saving/deleting configuration +- HTTP Basic Authentication; default admin/admin; configurable in App.config diff --git a/FileinterfaceCronScheduler.sln b/FileinterfaceCronScheduler.sln new file mode 100644 index 0000000..9d0d4d3 --- /dev/null +++ b/FileinterfaceCronScheduler.sln @@ -0,0 +1,21 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FileinterfaceCronScheduler", "FileinterfaceCronScheduler\FileinterfaceCronScheduler.csproj", "{D7E916B9-969E-4CFD-B6A3-527B7091D45A}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {D7E916B9-969E-4CFD-B6A3-527B7091D45A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D7E916B9-969E-4CFD-B6A3-527B7091D45A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D7E916B9-969E-4CFD-B6A3-527B7091D45A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D7E916B9-969E-4CFD-B6A3-527B7091D45A}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection +EndGlobal diff --git a/FileinterfaceCronScheduler/App.config b/FileinterfaceCronScheduler/App.config new file mode 100644 index 0000000..0a719bc --- /dev/null +++ b/FileinterfaceCronScheduler/App.config @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/FileinterfaceCronScheduler/FileinterfaceCronScheduler.csproj b/FileinterfaceCronScheduler/FileinterfaceCronScheduler.csproj new file mode 100644 index 0000000..b287fda --- /dev/null +++ b/FileinterfaceCronScheduler/FileinterfaceCronScheduler.csproj @@ -0,0 +1,96 @@ + + + + + Debug + AnyCPU + {D7E916B9-969E-4CFD-B6A3-527B7091D45A} + Exe + FileinterfaceCronScheduler + FileinterfaceCronScheduler + v4.8 + 512 + true + true + 7.3 + + + x86 + true + full + false + bin\Debug\ + DEBUG;TRACE + prompt + 4 + false + + + x86 + pdbonly + true + bin\Release\ + TRACE + prompt + 4 + false + + + FileinterfaceCronScheduler.Program + + + false + + + + lib\IMSApiDotNet.dll + true + + + + + + + + + + + + Component + + + + + + + + + + + + + + + + + + + + + + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + + + + + \ No newline at end of file diff --git a/FileinterfaceCronScheduler/FileinterfaceCronSchedulerService.cs b/FileinterfaceCronScheduler/FileinterfaceCronSchedulerService.cs new file mode 100644 index 0000000..40444e4 --- /dev/null +++ b/FileinterfaceCronScheduler/FileinterfaceCronSchedulerService.cs @@ -0,0 +1,46 @@ +using System; +using System.ServiceProcess; +using FileInterface.Scheduler; + +namespace FileinterfaceCronScheduler +{ + public sealed class FileinterfaceCronSchedulerService : ServiceBase + { + private SchedulerRuntime _runtime; + + public FileinterfaceCronSchedulerService() + { + ServiceName = "FileinterfaceCronScheduler"; + CanStop = true; + CanPauseAndContinue = false; + AutoLog = true; + } + + protected override void OnStart(string[] args) + { + try + { + _runtime = RuntimeFactory.Create(); + _runtime.Start(); + } + catch + { + if (_runtime != null) + { + _runtime.Dispose(); + _runtime = null; + } + throw; + } + } + + protected override void OnStop() + { + if (_runtime != null) + { + _runtime.Dispose(); + _runtime = null; + } + } + } +} diff --git a/FileinterfaceCronScheduler/IMSApi/ImsApiClient.cs b/FileinterfaceCronScheduler/IMSApi/ImsApiClient.cs new file mode 100644 index 0000000..27fba7e --- /dev/null +++ b/FileinterfaceCronScheduler/IMSApi/ImsApiClient.cs @@ -0,0 +1,229 @@ +using System; +using System.Configuration; +using System.Diagnostics; +using System.IO; +using System.Reflection; +using FileInterface.Scheduler; +using com.itac.mes.imsapi.client.dotnet; +using com.itac.mes.imsapi.domain.container; + +namespace FileinterfaceCronScheduler.IMSApi +{ + /// + /// Initializes IMSApiDotNet, logs in once and exposes customFunction to the scheduler. + /// Configuration is read from App.config. + /// + public sealed class ImsApiClient : ICustomFunctionInvoker, IDisposable + { + private readonly object _sync = new object(); + private readonly object _callSync = new object(); + private IIMSApiDotNet _imsapi; + private IMSApiSessionContextStruct _sessionContext; + private bool _initialized; + private bool _disposed; + private readonly bool _serializeCalls = String.Equals(ConfigurationManager.AppSettings["IMS.SerializeCalls"], "true", StringComparison.OrdinalIgnoreCase); + + public bool IsInitialized { get { lock (_sync) return _initialized; } } + public bool IsLoggedIn { get { lock (_sync) return _sessionContext != null; } } + public long SessionId { get { lock (_sync) return _sessionContext == null ? 0L : _sessionContext.sessionId; } } + + public void Connect() + { + lock (_sync) + { + ThrowIfDisposed(); + EnsureInitializedNoLock(); + EnsureLoggedInNoLock(); + } + } + + private void EnsureInitializedNoLock() + { + if (_initialized && _imsapi != null) + return; + + string appId = Required("IMS.AppID", "AppID"); + string serverUrl = Required("IMS.ServerUrl", "Server_URL"); + string baseDir = AppDomain.CurrentDomain.BaseDirectory; + string propDir = Get("IMS.PropertyDirectory", baseDir); + if (!Path.IsPathRooted(propDir)) + propDir = Path.Combine(baseDir, propDir); + + Directory.CreateDirectory(propDir); + string ihas = Path.Combine(propDir, "ihas.properties"); + if (!File.Exists(ihas)) + File.WriteAllText(ihas, String.Empty); + + Trace.TraceInformation("Initializing IMSApiDotNet. AppID={0}, Server={1}, PropDir={2}", appId, serverUrl, propDir); + + IMSApiDotNet.setProperty("itac.appid", appId); + IMSApiDotNet.setProperty("itac.artes.clusternodes", serverUrl); + IMSApiDotNet.setProperty("itac.propdir", propDir); + + _imsapi = IMSApiDotNet.loadLibrary(); + if (_imsapi == null) + throw new InvalidOperationException("IMSApiDotNet.loadLibrary() returned null."); + + string version; + int versionResult = _imsapi.imsapiGetLibraryVersion(out version); + if (versionResult == IMSApiDotNetConstants.RES_OK) + Trace.TraceInformation("IMSApiDotNet library version: " + version); + else + Trace.TraceWarning("imsapiGetLibraryVersion returned " + versionResult); + + int initResult = _imsapi.imsapiInit(); + if (initResult != IMSApiDotNetConstants.RES_OK) + throw new InvalidOperationException("imsapiInit failed. Result=" + initResult); + + _initialized = true; + Trace.TraceInformation("IMSApiDotNet initialized successfully."); + } + + private void EnsureLoggedInNoLock() + { + if (_sessionContext != null) + return; + + string station = Required("IMS.StationNumber", "StationNr"); + // Original FileInterface contains the historic typo 'CleintNO'. Both names are supported. + string client = FirstNonEmpty( + ConfigurationManager.AppSettings["IMS.ClientNumber"], + ConfigurationManager.AppSettings["ClientNO"], + ConfigurationManager.AppSettings["CleintNO"]); + if (String.IsNullOrWhiteSpace(client)) + throw new ConfigurationErrorsException("Missing IMS client number. Configure IMS.ClientNumber (or legacy CleintNO)."); + + string registrationType = Get("IMS.RegistrationType", "S"); + var validation = new IMSApiSessionValidationStruct + { + stationNumber = station, + client = client, + registrationType = registrationType + }; + + Trace.TraceInformation("Calling regLogin(). Station={0}, Client={1}, RegistrationType={2}", station, client, registrationType); + + IMSApiSessionContextStruct newSessionContext; + int loginResult = _imsapi.regLogin(validation, out newSessionContext); + if (loginResult != IMSApiDotNetConstants.RES_OK || newSessionContext == null) + throw new InvalidOperationException("IMS regLogin failed. Result=" + loginResult); + + _sessionContext = newSessionContext; + Trace.TraceInformation("IMS login successful. SessionId=" + _sessionContext.sessionId); + } + + public CustomFunctionCallResult Invoke(string customFunctionName, string[] inArgs) + { + if (String.IsNullOrWhiteSpace(customFunctionName)) + return CustomFunctionCallResult.Fail("CustomFunction is empty."); + + try + { + IIMSApiDotNet api; + IMSApiSessionContextStruct session; + + // Only connection/login state is protected. The actual CustomFunction call + // can run concurrently for independent cron jobs. + lock (_sync) + { + ThrowIfDisposed(); + EnsureInitializedNoLock(); + EnsureLoggedInNoLock(); + api = _imsapi; + session = _sessionContext; + } + + if (_serializeCalls) + { + lock (_callSync) + return InvokeCore(api, session, customFunctionName, inArgs); + } + + return InvokeCore(api, session, customFunctionName, inArgs); + } + catch (Exception ex) + { + Trace.TraceError("IMS customFunction failed: " + ex); + return CustomFunctionCallResult.Fail(ex.ToString()); + } + } + + private static CustomFunctionCallResult InvokeCore( + IIMSApiDotNet api, + IMSApiSessionContextStruct session, + string customFunctionName, + string[] inArgs) + { + string[] outArgs; + string customErrorString; + int result = api.customFunction( + session, + customFunctionName, + inArgs ?? new string[0], + out outArgs, + out customErrorString); + + string outText = outArgs == null || outArgs.Length == 0 + ? String.Empty + : String.Join(" | ", outArgs); + + if (result != IMSApiDotNetConstants.RES_OK) + { + return CustomFunctionCallResult.Fail( + "IMS result=" + result + + (String.IsNullOrWhiteSpace(customErrorString) ? String.Empty : "; Error=" + customErrorString) + + (String.IsNullOrWhiteSpace(outText) ? String.Empty : "; OutArgs=" + outText)); + } + + return CustomFunctionCallResult.Ok( + "IMS result=0" + + (String.IsNullOrWhiteSpace(customErrorString) ? String.Empty : "; Info=" + customErrorString) + + (String.IsNullOrWhiteSpace(outText) ? String.Empty : "; OutArgs=" + outText)); + } + + public void Dispose() + { + lock (_sync) + { + if (_disposed) return; + _disposed = true; + + // The FileInterface source currently has no regLogout/imsapiFinish call. + // We therefore release managed references here. If the deployed IMSAPI + // version exposes an explicit logout method, it can safely be added here. + _sessionContext = null; + _imsapi = null; + _initialized = false; + } + } + + private static string Get(string key, string fallback) + { + string value = ConfigurationManager.AppSettings[key]; + return String.IsNullOrWhiteSpace(value) ? fallback : value.Trim(); + } + + private static string Required(string preferredKey, string legacyKey) + { + string value = FirstNonEmpty( + ConfigurationManager.AppSettings[preferredKey], + ConfigurationManager.AppSettings[legacyKey]); + if (String.IsNullOrWhiteSpace(value)) + throw new ConfigurationErrorsException("Missing setting '" + preferredKey + "' (legacy: '" + legacyKey + "')."); + return value.Trim(); + } + + private static string FirstNonEmpty(params string[] values) + { + if (values == null) return null; + foreach (string value in values) + if (!String.IsNullOrWhiteSpace(value)) return value.Trim(); + return null; + } + + private void ThrowIfDisposed() + { + if (_disposed) throw new ObjectDisposedException(GetType().FullName); + } + } +} diff --git a/FileinterfaceCronScheduler/ImsCustomFunctionInvoker.cs b/FileinterfaceCronScheduler/ImsCustomFunctionInvoker.cs new file mode 100644 index 0000000..15931db --- /dev/null +++ b/FileinterfaceCronScheduler/ImsCustomFunctionInvoker.cs @@ -0,0 +1,37 @@ +using System; +using FileInterface.Scheduler; +using FileinterfaceCronScheduler.IMSApi; + +namespace FileinterfaceCronScheduler +{ + /// + /// Thin scheduler adapter around the real IMSApiClient. + /// + public sealed class ImsCustomFunctionInvoker : ICustomFunctionInvoker, IDisposable + { + private readonly ImsApiClient _client; + + public ImsCustomFunctionInvoker() + { + _client = new ImsApiClient(); + } + + public void Connect() + { + _client.Connect(); + } + + public bool IsLoggedIn { get { return _client.IsLoggedIn; } } + public long SessionId { get { return _client.SessionId; } } + + public CustomFunctionCallResult Invoke(string customFunctionName, string[] inArgs) + { + return _client.Invoke(customFunctionName, inArgs); + } + + public void Dispose() + { + _client.Dispose(); + } + } +} diff --git a/FileinterfaceCronScheduler/Program.cs b/FileinterfaceCronScheduler/Program.cs new file mode 100644 index 0000000..905bf3e --- /dev/null +++ b/FileinterfaceCronScheduler/Program.cs @@ -0,0 +1,44 @@ +using System; +using System.Linq; +using System.ServiceProcess; +using System.Threading; + +namespace FileinterfaceCronScheduler +{ + internal static class Program + { + private static int Main(string[] args) + { + bool forceService = args.Any(a => String.Equals(a, "--service", StringComparison.OrdinalIgnoreCase)); + + if (!Environment.UserInteractive || forceService) + { + ServiceBase.Run(new FileinterfaceCronSchedulerService()); + return 0; + } + + Console.Title = "FileInterface Watcher + CronScheduler"; + Console.WriteLine("FileInterface Watcher + CronScheduler"); + Console.WriteLine("===================================="); + Console.WriteLine("Konsole/Debug-Modus. Mit --service als Windows-Dienst starten."); + Console.WriteLine(); + + using (var runtime = RuntimeFactory.Create()) + using (var quit = new ManualResetEvent(false)) + { + Console.CancelKeyPress += (s, e) => + { + e.Cancel = true; + quit.Set(); + }; + + runtime.Start(); + Console.WriteLine("FileSystemWatcher, CronScheduler und Webinterface gestartet."); + Console.WriteLine("Beenden mit Strg+C."); + quit.WaitOne(); + } + + return 0; + } + } +} diff --git a/FileinterfaceCronScheduler/Properties/AssemblyInfo.cs b/FileinterfaceCronScheduler/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..1d35219 --- /dev/null +++ b/FileinterfaceCronScheduler/Properties/AssemblyInfo.cs @@ -0,0 +1,15 @@ +using System.Reflection; +using System.Runtime.InteropServices; + +[assembly: AssemblyTitle("FileinterfaceCronScheduler")] +[assembly: AssemblyDescription("Cron-based FileInterface for iTAC IMS CustomFunctions")] +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("")] +[assembly: AssemblyProduct("FileinterfaceCronScheduler")] +[assembly: AssemblyCopyright("")] +[assembly: AssemblyTrademark("")] +[assembly: AssemblyCulture("")] +[assembly: ComVisible(false)] +[assembly: Guid("e0d85515-96cb-4f8c-97c3-9ec7079780fc")] +[assembly: AssemblyVersion("1.0.0.0")] +[assembly: AssemblyFileVersion("1.0.0.0")] diff --git a/FileinterfaceCronScheduler/RuntimeFactory.cs b/FileinterfaceCronScheduler/RuntimeFactory.cs new file mode 100644 index 0000000..afd6fdf --- /dev/null +++ b/FileinterfaceCronScheduler/RuntimeFactory.cs @@ -0,0 +1,47 @@ +using System; +using System.Configuration; +using System.IO; +using FileInterface.Scheduler; + +namespace FileinterfaceCronScheduler +{ + internal static class RuntimeFactory + { + public static SchedulerRuntime Create() + { + string baseDir = AppDomain.CurrentDomain.BaseDirectory; + string cronConfigFile = Get("Scheduler.ConfigFile", "cronjobs.json"); + string watcherConfigFile = Get("Watcher.ConfigFile", "watcherjobs.json"); + + if (!Path.IsPathRooted(cronConfigFile)) cronConfigFile = Path.Combine(baseDir, cronConfigFile); + if (!Path.IsPathRooted(watcherConfigFile)) watcherConfigFile = Path.Combine(baseDir, watcherConfigFile); + + var options = new SchedulerOptions + { + ConfigFile = cronConfigFile, + WatcherConfigFile = watcherConfigFile, + WebPrefix = Get("Scheduler.WebPrefix", "http://127.0.0.1:8095/"), + WebUser = Get("Scheduler.WebUser", "admin"), + WebPassword = Get("Scheduler.WebPassword", "admin") + }; + + var invoker = new ImsCustomFunctionInvoker(); + try + { + invoker.Connect(); + return new SchedulerRuntime(invoker, options, invoker); + } + catch + { + invoker.Dispose(); + throw; + } + } + + private static string Get(string key, string fallback) + { + string value = ConfigurationManager.AppSettings[key]; + return String.IsNullOrWhiteSpace(value) ? fallback : value.Trim(); + } + } +} diff --git a/FileinterfaceCronScheduler/Scheduler/CronExpression.cs b/FileinterfaceCronScheduler/Scheduler/CronExpression.cs new file mode 100644 index 0000000..348cfa9 --- /dev/null +++ b/FileinterfaceCronScheduler/Scheduler/CronExpression.cs @@ -0,0 +1,131 @@ +using System; +using System.Collections.Generic; +using System.Globalization; + +namespace FileInterface.Scheduler +{ + /// + /// Standard 5-field cron expression: minute hour day-of-month month day-of-week. + /// Supported: *, */n, a,b,c, a-b, a-b/n. Day-of-week: 0 or 7 = Sunday. + /// + public sealed class CronExpression + { + private readonly HashSet _minutes; + private readonly HashSet _hours; + private readonly HashSet _days; + private readonly HashSet _months; + private readonly HashSet _daysOfWeek; + + public string Expression { get; private set; } + + private CronExpression(string expression, + HashSet minutes, + HashSet hours, + HashSet days, + HashSet months, + HashSet daysOfWeek) + { + Expression = expression; + _minutes = minutes; + _hours = hours; + _days = days; + _months = months; + _daysOfWeek = daysOfWeek; + } + + public static CronExpression Parse(string expression) + { + if (String.IsNullOrWhiteSpace(expression)) + throw new FormatException("Cron expression is empty."); + + string[] parts = expression.Trim().Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries); + if (parts.Length != 5) + throw new FormatException("Cron must contain exactly 5 fields: minute hour day month day-of-week."); + + return new CronExpression( + expression.Trim(), + ParseField(parts[0], 0, 59, false), + ParseField(parts[1], 0, 23, false), + ParseField(parts[2], 1, 31, false), + ParseField(parts[3], 1, 12, false), + ParseField(parts[4], 0, 7, true)); + } + + public bool IsMatch(DateTime localTime) + { + int dow = (int)localTime.DayOfWeek; + return _minutes.Contains(localTime.Minute) + && _hours.Contains(localTime.Hour) + && _days.Contains(localTime.Day) + && _months.Contains(localTime.Month) + && _daysOfWeek.Contains(dow); + } + + private static HashSet ParseField(string text, int min, int max, bool normalizeSunday) + { + var result = new HashSet(); + string[] segments = text.Split(','); + foreach (string rawSegment in segments) + { + string segment = rawSegment.Trim(); + if (segment.Length == 0) + throw new FormatException("Empty cron field segment."); + + int step = 1; + string rangePart = segment; + int slash = segment.IndexOf('/'); + if (slash >= 0) + { + rangePart = segment.Substring(0, slash); + string stepText = segment.Substring(slash + 1); + if (!Int32.TryParse(stepText, NumberStyles.Integer, CultureInfo.InvariantCulture, out step) || step <= 0) + throw new FormatException("Invalid cron step: " + segment); + } + + int start; + int end; + if (rangePart == "*") + { + start = min; + end = max; + } + else + { + int dash = rangePart.IndexOf('-'); + if (dash >= 0) + { + start = ParseNumber(rangePart.Substring(0, dash), min, max); + end = ParseNumber(rangePart.Substring(dash + 1), min, max); + if (start > end) + throw new FormatException("Cron range start is greater than end: " + segment); + } + else + { + start = ParseNumber(rangePart, min, max); + end = start; + } + } + + for (int value = start; value <= end; value += step) + { + int normalized = normalizeSunday && value == 7 ? 0 : value; + result.Add(normalized); + } + } + + if (result.Count == 0) + throw new FormatException("Cron field produced no values: " + text); + return result; + } + + private static int ParseNumber(string text, int min, int max) + { + int value; + if (!Int32.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out value)) + throw new FormatException("Invalid cron number: " + text); + if (value < min || value > max) + throw new FormatException("Cron value out of range: " + value + " (allowed " + min + "-" + max + ")"); + return value; + } + } +} diff --git a/FileinterfaceCronScheduler/Scheduler/CronJobConfig.cs b/FileinterfaceCronScheduler/Scheduler/CronJobConfig.cs new file mode 100644 index 0000000..e50670d --- /dev/null +++ b/FileinterfaceCronScheduler/Scheduler/CronJobConfig.cs @@ -0,0 +1,69 @@ +using System; +using System.Collections.Generic; + +namespace FileInterface.Scheduler +{ + public sealed class CronJobConfig + { + public string Id { get; set; } + public string Name { get; set; } + public bool Enabled { get; set; } + public string Cron { get; set; } + + // File processing. If CallOnceWithoutFile=true, SourceDirectory/SearchPattern are ignored. + public bool CallOnceWithoutFile { get; set; } + public string SourceDirectory { get; set; } + public string SearchPattern { get; set; } + public bool IncludeSubdirectories { get; set; } + + // Existing FileInterface-style preprocessing. + public bool RemoveEmptyLines { get; set; } + public string JoinSeparator { get; set; } + + // iTAC CustomFunction. + public string CustomFunction { get; set; } + public List InArgs { get; set; } + + // File handling after successful CF call. + public bool MoveFileAfterSuccess { get; set; } + public string MoveTargetDirectory { get; set; } + public bool OverwriteTargetFile { get; set; } + + public CronJobConfig() + { + Id = Guid.NewGuid().ToString("N"); + Name = "New job"; + Enabled = true; + Cron = "*/5 * * * *"; + CallOnceWithoutFile = false; + SourceDirectory = ""; + SearchPattern = "*.*"; + IncludeSubdirectories = false; + RemoveEmptyLines = true; + JoinSeparator = ";"; + CustomFunction = ""; + InArgs = new List(); + MoveFileAfterSuccess = false; + MoveTargetDirectory = ""; + OverwriteTargetFile = false; + } + } + + public sealed class SchedulerOptions + { + public string ConfigFile { get; set; } + public string WatcherConfigFile { get; set; } + public string WebPrefix { get; set; } + public string WebUser { get; set; } + public string WebPassword { get; set; } + + public SchedulerOptions() + { + ConfigFile = "cronjobs.json"; + WatcherConfigFile = "watcherjobs.json"; + WebPrefix = "http://127.0.0.1:8095/"; + WebUser = ""; + WebPassword = ""; + } + } +} diff --git a/FileinterfaceCronScheduler/Scheduler/CronJobExecutor.cs b/FileinterfaceCronScheduler/Scheduler/CronJobExecutor.cs new file mode 100644 index 0000000..a0be2bb --- /dev/null +++ b/FileinterfaceCronScheduler/Scheduler/CronJobExecutor.cs @@ -0,0 +1,145 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; + +namespace FileInterface.Scheduler +{ + public sealed class CronJobExecutor + { + private readonly ICustomFunctionInvoker _invoker; + private readonly JobLog _log; + + public CronJobExecutor(ICustomFunctionInvoker invoker, JobLog log) + { + _invoker = invoker; + _log = log; + } + + public void Execute(CronJobConfig job) + { + if (job.CallOnceWithoutFile) + { + ExecuteSingle(job, null); + return; + } + + if (!Directory.Exists(job.SourceDirectory)) + { + _log.Error(job, "Source directory does not exist: " + job.SourceDirectory); + return; + } + + SearchOption option = job.IncludeSubdirectories ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly; + string[] files; + try + { + files = Directory.GetFiles(job.SourceDirectory, String.IsNullOrWhiteSpace(job.SearchPattern) ? "*.*" : job.SearchPattern, option) + .OrderBy(f => f, StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + catch (Exception ex) + { + _log.Error(job, "Cannot enumerate source files: " + ex.Message); + return; + } + + _log.Info(job, "Found " + files.Length + " file(s)."); + foreach (string file in files) + { + try { ExecuteSingle(job, file); } + catch (Exception ex) { _log.Error(job, "File failed: " + file + " - " + ex.Message); } + } + } + + private void ExecuteSingle(CronJobConfig job, string file) + { + string rawContent = ""; + string processedContent = ""; + + if (file != null) + { + // Opening with FileShare.ReadWrite prevents common producer/consumer lock issues. + using (var stream = new FileStream(file, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) + using (var reader = new StreamReader(stream, Encoding.UTF8, true)) + rawContent = reader.ReadToEnd(); + + processedContent = ProcessContent(rawContent, job.RemoveEmptyLines, job.JoinSeparator); + } + + string[] inArgs = (job.InArgs ?? new List()) + .Select(arg => Expand(arg, job, file, rawContent, processedContent)) + .ToArray(); + + _log.Info(job, "Calling CustomFunction '" + job.CustomFunction + "' with " + inArgs.Length + " inArg(s)" + (file == null ? "." : " for " + Path.GetFileName(file) + ".")); + CustomFunctionCallResult result = _invoker.Invoke(job.CustomFunction, inArgs); + if (result == null) result = CustomFunctionCallResult.Fail("Invoker returned null."); + + if (!result.Success) + { + _log.Error(job, "CustomFunction failed: " + result.Message); + return; + } + + _log.Info(job, "CustomFunction succeeded: " + result.Message); + if (file != null && job.MoveFileAfterSuccess) + { + string movedTo = MoveProcessedFile(file, job.MoveTargetDirectory, job.OverwriteTargetFile); + _log.Info(job, "Moved file to: " + movedTo); + } + } + + private static string ProcessContent(string text, bool removeEmptyLines, string separator) + { + if (!removeEmptyLines) return text ?? ""; + string[] lines = (text ?? "").Replace("\r\n", "\n").Replace('\r', '\n').Split('\n'); + return String.Join(separator ?? ";", lines.Where(l => !String.IsNullOrWhiteSpace(l)).Select(l => l.Trim())); + } + + private static string Expand(string template, CronJobConfig job, string file, string rawContent, string processedContent) + { + string value = template ?? ""; + string fullPath = file ?? ""; + string fileName = file == null ? "" : Path.GetFileName(file); + string extension = file == null ? "" : Path.GetExtension(file); + string directory = file == null ? "" : Path.GetDirectoryName(file); + + return value + .Replace("{{CONTENT}}", processedContent ?? "") + .Replace("{{RAW_CONTENT}}", rawContent ?? "") + .Replace("{{FILE_NAME}}", fileName ?? "") + .Replace("{{FILE_PATH}}", fullPath) + .Replace("{{FILE_DIR}}", directory ?? "") + .Replace("{{FILE_EXT}}", extension ?? "") + .Replace("{{JOB_NAME}}", job.Name ?? "") + .Replace("{{NOW}}", DateTime.Now.ToString("yyyy-MM-ddTHH:mm:ss.fffK")); + } + + private static string MoveProcessedFile(string source, string targetDirectory, bool overwrite) + { + Directory.CreateDirectory(targetDirectory); + string target = Path.Combine(targetDirectory, Path.GetFileName(source)); + + if (File.Exists(target)) + { + if (overwrite) File.Delete(target); + else + { + string name = Path.GetFileNameWithoutExtension(source); + string ext = Path.GetExtension(source); + target = Path.Combine(targetDirectory, name + "_" + DateTime.Now.ToString("yyyyMMdd_HHmmss_fff") + ext); + int i = 1; + while (File.Exists(target)) + { + target = Path.Combine(targetDirectory, name + "_" + DateTime.Now.ToString("yyyyMMdd_HHmmss_fff") + "_" + i + ext); + i++; + } + } + } + + File.Move(source, target); + return target; + } + } +} diff --git a/FileinterfaceCronScheduler/Scheduler/CronJobRepository.cs b/FileinterfaceCronScheduler/Scheduler/CronJobRepository.cs new file mode 100644 index 0000000..305dd72 --- /dev/null +++ b/FileinterfaceCronScheduler/Scheduler/CronJobRepository.cs @@ -0,0 +1,120 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Web.Script.Serialization; + +namespace FileInterface.Scheduler +{ + public sealed class CronJobRepository + { + private readonly object _sync = new object(); + private readonly string _fileName; + private readonly JavaScriptSerializer _serializer = new JavaScriptSerializer(); + private List _jobs; + + public CronJobRepository(string fileName) + { + _fileName = Path.GetFullPath(fileName); + _serializer.MaxJsonLength = Int32.MaxValue; + _jobs = LoadInternal(); + } + + public IList GetAll() + { + lock (_sync) + { + // Clone through JSON so callers cannot mutate repository state behind our lock. + return Clone(_jobs); + } + } + + public CronJobConfig GetById(string id) + { + lock (_sync) + { + CronJobConfig job = _jobs.FirstOrDefault(j => String.Equals(j.Id, id, StringComparison.OrdinalIgnoreCase)); + return job == null ? null : Clone(job); + } + } + + public CronJobConfig Save(CronJobConfig job) + { + if (job == null) throw new ArgumentNullException("job"); + Validate(job); + + lock (_sync) + { + if (String.IsNullOrWhiteSpace(job.Id)) + job.Id = Guid.NewGuid().ToString("N"); + + int index = _jobs.FindIndex(j => String.Equals(j.Id, job.Id, StringComparison.OrdinalIgnoreCase)); + if (index >= 0) _jobs[index] = Clone(job); + else _jobs.Add(Clone(job)); + + SaveInternal(); + return Clone(job); + } + } + + public bool Delete(string id) + { + lock (_sync) + { + int count = _jobs.RemoveAll(j => String.Equals(j.Id, id, StringComparison.OrdinalIgnoreCase)); + if (count > 0) SaveInternal(); + return count > 0; + } + } + + private List LoadInternal() + { + if (!File.Exists(_fileName)) return new List(); + string json = File.ReadAllText(_fileName, Encoding.UTF8); + if (String.IsNullOrWhiteSpace(json)) return new List(); + return _serializer.Deserialize>(json) ?? new List(); + } + + private void SaveInternal() + { + string dir = Path.GetDirectoryName(_fileName); + if (!String.IsNullOrWhiteSpace(dir)) Directory.CreateDirectory(dir); + + string json = _serializer.Serialize(_jobs); + string tmp = _fileName + ".tmp"; + File.WriteAllText(tmp, json, new UTF8Encoding(false)); + + if (File.Exists(_fileName)) + { + string backup = _fileName + ".bak"; + try { File.Replace(tmp, _fileName, backup, true); } + catch + { + File.Delete(_fileName); + File.Move(tmp, _fileName); + } + } + else File.Move(tmp, _fileName); + } + + private static void Validate(CronJobConfig job) + { + if (String.IsNullOrWhiteSpace(job.Name)) throw new InvalidOperationException("Name is required."); + CronExpression.Parse(job.Cron); + if (String.IsNullOrWhiteSpace(job.CustomFunction)) throw new InvalidOperationException("CustomFunction is required."); + if (!job.CallOnceWithoutFile && String.IsNullOrWhiteSpace(job.SourceDirectory)) + throw new InvalidOperationException("SourceDirectory is required for file jobs."); + if (job.MoveFileAfterSuccess && String.IsNullOrWhiteSpace(job.MoveTargetDirectory)) + throw new InvalidOperationException("MoveTargetDirectory is required when MoveFileAfterSuccess=true."); + if (job.InArgs == null) job.InArgs = new List(); + if (String.IsNullOrWhiteSpace(job.SearchPattern)) job.SearchPattern = "*.*"; + if (job.JoinSeparator == null) job.JoinSeparator = ";"; + } + + private T Clone(T value) + { + return _serializer.Deserialize(_serializer.Serialize(value)); + } + } +} diff --git a/FileinterfaceCronScheduler/Scheduler/CronScheduler.cs b/FileinterfaceCronScheduler/Scheduler/CronScheduler.cs new file mode 100644 index 0000000..29e2154 --- /dev/null +++ b/FileinterfaceCronScheduler/Scheduler/CronScheduler.cs @@ -0,0 +1,106 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace FileInterface.Scheduler +{ + public sealed class CronScheduler : IDisposable + { + private readonly CronJobRepository _repository; + private readonly CronJobExecutor _executor; + private readonly JobLog _log; + private readonly ConcurrentDictionary _running = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); + private readonly ConcurrentDictionary _lastMinuteRun = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); + private Timer _timer; + private int _checking; + + public CronScheduler(CronJobRepository repository, CronJobExecutor executor, JobLog log) + { + _repository = repository; + _executor = executor; + _log = log; + } + + public void Start() + { + if (_timer != null) return; + _timer = new Timer(CheckDueJobs, null, TimeSpan.Zero, TimeSpan.FromSeconds(5)); + } + + public bool RunNow(string jobId) + { + CronJobConfig job = _repository.GetById(jobId); + if (job == null) return false; + QueueExecution(job, "manual"); + return true; + } + + public IList GetRunningJobIds() + { + return new List(_running.Keys); + } + + private void CheckDueJobs(object state) + { + if (Interlocked.Exchange(ref _checking, 1) == 1) return; + try + { + DateTime now = DateTime.Now; + string minuteStamp = now.ToString("yyyyMMddHHmm"); + foreach (CronJobConfig job in _repository.GetAll()) + { + if (!job.Enabled) continue; + try + { + CronExpression cron = CronExpression.Parse(job.Cron); + if (!cron.IsMatch(now)) continue; + + string previous; + if (_lastMinuteRun.TryGetValue(job.Id, out previous) && previous == minuteStamp) continue; + _lastMinuteRun[job.Id] = minuteStamp; + QueueExecution(job, "cron " + job.Cron); + } + catch (Exception ex) + { + _log.Error(job, "Invalid cron/configuration: " + ex.Message); + } + } + } + finally { Interlocked.Exchange(ref _checking, 0); } + } + + private void QueueExecution(CronJobConfig job, string reason) + { + byte ignored; + if (!_running.TryAdd(job.Id, 0)) + { + _log.Info(job, "Skipped " + reason + " start because this job is still running."); + return; + } + + Task.Run(() => + { + try + { + _log.Info(job, "Job started (" + reason + ")."); + _executor.Execute(job); + _log.Info(job, "Job finished."); + } + catch (Exception ex) + { + _log.Error(job, "Job crashed: " + ex); + } + finally { _running.TryRemove(job.Id, out ignored); } + }); + } + + public void Dispose() + { + Timer timer = _timer; + _timer = null; + if (timer != null) timer.Dispose(); + } + } +} diff --git a/FileinterfaceCronScheduler/Scheduler/CustomFunctionInvoker.cs b/FileinterfaceCronScheduler/Scheduler/CustomFunctionInvoker.cs new file mode 100644 index 0000000..56fd6cb --- /dev/null +++ b/FileinterfaceCronScheduler/Scheduler/CustomFunctionInvoker.cs @@ -0,0 +1,45 @@ +using System; + +namespace FileInterface.Scheduler +{ + public sealed class CustomFunctionCallResult + { + public bool Success { get; set; } + public string Message { get; set; } + + public static CustomFunctionCallResult Ok(string message) + { + return new CustomFunctionCallResult { Success = true, Message = message ?? "OK" }; + } + + public static CustomFunctionCallResult Fail(string message) + { + return new CustomFunctionCallResult { Success = false, Message = message ?? "CustomFunction failed" }; + } + } + + public interface ICustomFunctionInvoker + { + CustomFunctionCallResult Invoke(string customFunctionName, string[] inArgs); + } + + /// + /// Lets the existing FileInterface IMS code remain untouched. Wire the current + /// IMS CustomFunction call into this delegate in Program.cs / service startup. + /// + public sealed class DelegateCustomFunctionInvoker : ICustomFunctionInvoker + { + private readonly Func _handler; + + public DelegateCustomFunctionInvoker(Func handler) + { + if (handler == null) throw new ArgumentNullException("handler"); + _handler = handler; + } + + public CustomFunctionCallResult Invoke(string customFunctionName, string[] inArgs) + { + return _handler(customFunctionName, inArgs); + } + } +} diff --git a/FileinterfaceCronScheduler/Scheduler/FileSystemWatcherManager.cs b/FileinterfaceCronScheduler/Scheduler/FileSystemWatcherManager.cs new file mode 100644 index 0000000..3774a93 --- /dev/null +++ b/FileinterfaceCronScheduler/Scheduler/FileSystemWatcherManager.cs @@ -0,0 +1,172 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; + +namespace FileInterface.Scheduler +{ + public sealed class FileSystemWatcherManager : IDisposable + { + private sealed class ActiveWatcher : IDisposable + { + public WatcherJobConfig Job; + public FileSystemWatcher Watcher; + public void Dispose() { if (Watcher != null) { Watcher.EnableRaisingEvents = false; Watcher.Dispose(); } } + } + + private readonly object _sync = new object(); + private readonly WatcherJobRepository _repository; + private readonly WatcherJobExecutor _executor; + private readonly JobLog _log; + private readonly Dictionary _watchers = new Dictionary(StringComparer.OrdinalIgnoreCase); + private readonly ConcurrentDictionary _lastEvents = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); + private readonly ConcurrentDictionary _runningFiles = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); + private bool _disposed; + + public FileSystemWatcherManager(WatcherJobRepository repository, WatcherJobExecutor executor, JobLog log) + { + _repository = repository; + _executor = executor; + _log = log; + } + + public void Start() { Reload(); } + + public void Reload() + { + lock (_sync) + { + ThrowIfDisposed(); + foreach (ActiveWatcher old in _watchers.Values) old.Dispose(); + _watchers.Clear(); + + foreach (WatcherJobConfig job in _repository.GetAll()) + { + if (!job.Enabled) continue; + try + { + if (!Directory.Exists(job.SourceDirectory)) + { + _log.Error(job, "Watcher source directory does not exist: " + job.SourceDirectory); + continue; + } + + var fsw = new FileSystemWatcher(job.SourceDirectory, String.IsNullOrWhiteSpace(job.SearchPattern) ? "*.*" : job.SearchPattern) + { + IncludeSubdirectories = job.IncludeSubdirectories, + NotifyFilter = NotifyFilters.FileName | NotifyFilters.LastWrite | NotifyFilters.Size | NotifyFilters.CreationTime, + EnableRaisingEvents = false + }; + + WatcherJobConfig captured = job; + if (job.WatchCreated) fsw.Created += (s, e) => OnFileEvent(captured, e.FullPath, "Created"); + if (job.WatchChanged) fsw.Changed += (s, e) => OnFileEvent(captured, e.FullPath, "Changed"); + if (job.WatchRenamed) fsw.Renamed += (s, e) => OnFileEvent(captured, e.FullPath, "Renamed"); + fsw.Error += (s, e) => _log.Error(captured, "FileSystemWatcher error: " + (e.GetException() == null ? "unknown" : e.GetException().Message)); + + var active = new ActiveWatcher { Job = job, Watcher = fsw }; + _watchers[job.Id] = active; + fsw.EnableRaisingEvents = true; + _log.Info(job, "FileSystemWatcher active: " + job.SourceDirectory + "\\" + job.SearchPattern); + } + catch (Exception ex) + { + _log.Error(job, "Cannot start FileSystemWatcher: " + ex.Message); + } + } + } + } + + public IList GetActiveWatcherIds() + { + lock (_sync) return _watchers.Keys.ToList(); + } + + public bool ProcessExistingNow(string watcherId) + { + WatcherJobConfig job = _repository.GetById(watcherId); + if (job == null) return false; + Task.Run(() => ProcessExisting(job)); + return true; + } + + private void ProcessExisting(WatcherJobConfig job) + { + if (!Directory.Exists(job.SourceDirectory)) + { + _log.Error(job, "Source directory does not exist: " + job.SourceDirectory); + return; + } + try + { + SearchOption option = job.IncludeSubdirectories ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly; + string[] files = Directory.GetFiles(job.SourceDirectory, String.IsNullOrWhiteSpace(job.SearchPattern) ? "*.*" : job.SearchPattern, option) + .OrderBy(f => f, StringComparer.OrdinalIgnoreCase).ToArray(); + _log.Info(job, "Manual watcher scan found " + files.Length + " file(s)."); + foreach (string file in files) QueueFile(job, file, "ManualScan", false); + } + catch (Exception ex) { _log.Error(job, "Manual watcher scan failed: " + ex.Message); } + } + + private void OnFileEvent(WatcherJobConfig job, string fullPath, string reason) + { + if (!job.Enabled || String.IsNullOrWhiteSpace(fullPath)) return; + if (IsMoveTarget(job, fullPath)) return; + QueueFile(job, fullPath, reason, true); + } + + private void QueueFile(WatcherJobConfig job, string fullPath, string reason, bool debounce) + { + string key = job.Id + "|" + fullPath; + DateTime now = DateTime.UtcNow; + if (debounce) + { + DateTime previous; + if (_lastEvents.TryGetValue(key, out previous) && (now - previous).TotalMilliseconds < Math.Max(0, job.DebounceMilliseconds)) + return; + _lastEvents[key] = now; + } + + byte ignored; + if (!_runningFiles.TryAdd(key, 0)) return; + Task.Run(() => + { + try + { + if (!File.Exists(fullPath)) return; + _log.Info(job, "File event queued: " + reason + " - " + fullPath); + _executor.ExecuteFile(job, fullPath, reason); + } + catch (Exception ex) { _log.Error(job, "Watcher processing failed for " + fullPath + ": " + ex); } + finally { _runningFiles.TryRemove(key, out ignored); } + }); + } + + private static bool IsMoveTarget(WatcherJobConfig job, string fullPath) + { + if (!job.MoveFileAfterSuccess || String.IsNullOrWhiteSpace(job.MoveTargetDirectory)) return false; + try + { + string file = Path.GetFullPath(fullPath).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar; + string target = Path.GetFullPath(job.MoveTargetDirectory).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar; + return file.StartsWith(target, StringComparison.OrdinalIgnoreCase); + } + catch { return false; } + } + + private void ThrowIfDisposed() { if (_disposed) throw new ObjectDisposedException(GetType().FullName); } + + public void Dispose() + { + lock (_sync) + { + if (_disposed) return; + _disposed = true; + foreach (ActiveWatcher w in _watchers.Values) w.Dispose(); + _watchers.Clear(); + } + } + } +} diff --git a/FileinterfaceCronScheduler/Scheduler/JobLog.cs b/FileinterfaceCronScheduler/Scheduler/JobLog.cs new file mode 100644 index 0000000..b2b885e --- /dev/null +++ b/FileinterfaceCronScheduler/Scheduler/JobLog.cs @@ -0,0 +1,60 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace FileInterface.Scheduler +{ + public sealed class JobLogEntry + { + public DateTime Timestamp { get; set; } + public string JobType { get; set; } + public string JobId { get; set; } + public string JobName { get; set; } + public string Level { get; set; } + public string Message { get; set; } + } + + public sealed class JobLog + { + private readonly object _sync = new object(); + private readonly Queue _entries = new Queue(); + private readonly int _maxEntries; + + public JobLog(int maxEntries) + { + _maxEntries = Math.Max(100, maxEntries); + } + + public void Info(CronJobConfig job, string message) { Add("CRON", job == null ? "" : job.Id, job == null ? "" : job.Name, "INFO", message); } + public void Error(CronJobConfig job, string message) { Add("CRON", job == null ? "" : job.Id, job == null ? "" : job.Name, "ERROR", message); } + public void Info(WatcherJobConfig job, string message) { Add("WATCHER", job == null ? "" : job.Id, job == null ? "" : job.Name, "INFO", message); } + public void Error(WatcherJobConfig job, string message) { Add("WATCHER", job == null ? "" : job.Id, job == null ? "" : job.Name, "ERROR", message); } + public void SystemInfo(string message) { Add("SYSTEM", "", "System", "INFO", message); } + public void SystemError(string message) { Add("SYSTEM", "", "System", "ERROR", message); } + + public IList GetLatest(int limit) + { + lock (_sync) + { + return _entries.Reverse().Take(Math.Max(1, Math.Min(limit, _maxEntries))).ToList(); + } + } + + private void Add(string jobType, string jobId, string jobName, string level, string message) + { + lock (_sync) + { + _entries.Enqueue(new JobLogEntry + { + Timestamp = DateTime.Now, + JobType = jobType ?? "", + JobId = jobId ?? "", + JobName = jobName ?? "", + Level = level, + Message = message ?? "" + }); + while (_entries.Count > _maxEntries) _entries.Dequeue(); + } + } + } +} diff --git a/FileinterfaceCronScheduler/Scheduler/SchedulerRuntime.cs b/FileinterfaceCronScheduler/Scheduler/SchedulerRuntime.cs new file mode 100644 index 0000000..02e6f12 --- /dev/null +++ b/FileinterfaceCronScheduler/Scheduler/SchedulerRuntime.cs @@ -0,0 +1,57 @@ +using System; + +namespace FileInterface.Scheduler +{ + public sealed class SchedulerRuntime : IDisposable + { + private readonly CronScheduler _scheduler; + private readonly FileSystemWatcherManager _watcherManager; + private readonly WebAdminServer _web; + private readonly IDisposable _ownedResource; + private bool _disposed; + + public SchedulerRuntime(ICustomFunctionInvoker invoker, SchedulerOptions options) + : this(invoker, options, null) + { + } + + public SchedulerRuntime(ICustomFunctionInvoker invoker, SchedulerOptions options, IDisposable ownedResource) + { + if (invoker == null) throw new ArgumentNullException("invoker"); + if (options == null) options = new SchedulerOptions(); + + _ownedResource = ownedResource; + var cronRepository = new CronJobRepository(options.ConfigFile); + var watcherRepository = new WatcherJobRepository(options.WatcherConfigFile); + var log = new JobLog(5000); + var cronExecutor = new CronJobExecutor(invoker, log); + var watcherExecutor = new WatcherJobExecutor(invoker, log); + _scheduler = new CronScheduler(cronRepository, cronExecutor, log); + _watcherManager = new FileSystemWatcherManager(watcherRepository, watcherExecutor, log); + _web = new WebAdminServer(cronRepository, _scheduler, watcherRepository, _watcherManager, log, options); + } + + public void Start() + { + _scheduler.Start(); + _watcherManager.Start(); + _web.Start(); + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + try { _web.Dispose(); } + finally + { + try { _watcherManager.Dispose(); } + finally + { + try { _scheduler.Dispose(); } + finally { if (_ownedResource != null) _ownedResource.Dispose(); } + } + } + } + } +} diff --git a/FileinterfaceCronScheduler/Scheduler/WatcherJobConfig.cs b/FileinterfaceCronScheduler/Scheduler/WatcherJobConfig.cs new file mode 100644 index 0000000..80bb491 --- /dev/null +++ b/FileinterfaceCronScheduler/Scheduler/WatcherJobConfig.cs @@ -0,0 +1,56 @@ +using System; +using System.Collections.Generic; + +namespace FileInterface.Scheduler +{ + public sealed class WatcherJobConfig + { + public string Id { get; set; } + public string Name { get; set; } + public bool Enabled { get; set; } + + public string SourceDirectory { get; set; } + public string SearchPattern { get; set; } + public bool IncludeSubdirectories { get; set; } + + public bool WatchCreated { get; set; } + public bool WatchChanged { get; set; } + public bool WatchRenamed { get; set; } + public int DebounceMilliseconds { get; set; } + public int FileReadyRetries { get; set; } + public int FileReadyDelayMilliseconds { get; set; } + + public bool RemoveEmptyLines { get; set; } + public string JoinSeparator { get; set; } + + public string CustomFunction { get; set; } + public List InArgs { get; set; } + + public bool MoveFileAfterSuccess { get; set; } + public string MoveTargetDirectory { get; set; } + public bool OverwriteTargetFile { get; set; } + + public WatcherJobConfig() + { + Id = Guid.NewGuid().ToString("N"); + Name = "New watcher"; + Enabled = true; + SourceDirectory = ""; + SearchPattern = "*.*"; + IncludeSubdirectories = false; + WatchCreated = true; + WatchChanged = true; + WatchRenamed = true; + DebounceMilliseconds = 1000; + FileReadyRetries = 10; + FileReadyDelayMilliseconds = 250; + RemoveEmptyLines = true; + JoinSeparator = ";"; + CustomFunction = ""; + InArgs = new List(); + MoveFileAfterSuccess = false; + MoveTargetDirectory = ""; + OverwriteTargetFile = false; + } + } +} diff --git a/FileinterfaceCronScheduler/Scheduler/WatcherJobExecutor.cs b/FileinterfaceCronScheduler/Scheduler/WatcherJobExecutor.cs new file mode 100644 index 0000000..6fe7edb --- /dev/null +++ b/FileinterfaceCronScheduler/Scheduler/WatcherJobExecutor.cs @@ -0,0 +1,149 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading; + +namespace FileInterface.Scheduler +{ + public sealed class WatcherJobExecutor + { + private readonly ICustomFunctionInvoker _invoker; + private readonly JobLog _log; + + public WatcherJobExecutor(ICustomFunctionInvoker invoker, JobLog log) + { + _invoker = invoker; + _log = log; + } + + public void ExecuteFile(WatcherJobConfig job, string file, string reason) + { + if (job == null) throw new ArgumentNullException("job"); + if (String.IsNullOrWhiteSpace(file)) return; + if (!WaitUntilReadable(file, job.FileReadyRetries, job.FileReadyDelayMilliseconds)) + { + _log.Error(job, "File is not readable after retries: " + file); + return; + } + + string rawContent; + using (var stream = new FileStream(file, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) + using (var reader = new StreamReader(stream, Encoding.UTF8, true)) + rawContent = reader.ReadToEnd(); + + string processedContent = ProcessContent(rawContent, job.RemoveEmptyLines, job.JoinSeparator); + string[] inArgs = (job.InArgs ?? new List()) + .Select(arg => Expand(arg, job, file, rawContent, processedContent, reason)) + .ToArray(); + + _log.Info(job, "Calling CustomFunction '" + job.CustomFunction + "' with " + inArgs.Length + " inArg(s) for " + Path.GetFileName(file) + " (" + reason + ")."); + CustomFunctionCallResult result = _invoker.Invoke(job.CustomFunction, inArgs); + if (result == null) result = CustomFunctionCallResult.Fail("Invoker returned null."); + + if (!result.Success) + { + _log.Error(job, "CustomFunction failed: " + result.Message); + return; + } + + _log.Info(job, "CustomFunction succeeded: " + result.Message); + if (job.MoveFileAfterSuccess && File.Exists(file)) + { + string movedTo = MoveProcessedFile(file, job.MoveTargetDirectory, job.OverwriteTargetFile); + _log.Info(job, "Moved file to: " + movedTo); + } + } + + private static bool WaitUntilReadable(string file, int retries, int delayMs) + { + retries = Math.Max(2, retries); + delayMs = Math.Max(10, delayMs); + long previousLength = -1; + DateTime previousWrite = DateTime.MinValue; + int stableChecks = 0; + + for (int i = 0; i < retries; i++) + { + if (!File.Exists(file)) return false; + try + { + var info = new FileInfo(file); + long length = info.Length; + DateTime lastWrite = info.LastWriteTimeUtc; + using (var stream = new FileStream(file, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) + { + if (stream.Length != length) length = stream.Length; + } + + if (length == previousLength && lastWrite == previousWrite) + { + stableChecks++; + if (stableChecks >= 1) return true; + } + else + { + stableChecks = 0; + previousLength = length; + previousWrite = lastWrite; + } + } + catch (IOException) { stableChecks = 0; } + catch (UnauthorizedAccessException) { stableChecks = 0; } + + Thread.Sleep(delayMs); + } + return false; + } + + private static string ProcessContent(string text, bool removeEmptyLines, string separator) + { + if (!removeEmptyLines) return text ?? ""; + string[] lines = (text ?? "").Replace("\r\n", "\n").Replace('\r', '\n').Split('\n'); + return String.Join(separator ?? ";", lines.Where(l => !String.IsNullOrWhiteSpace(l)).Select(l => l.Trim())); + } + + private static string Expand(string template, WatcherJobConfig job, string file, string rawContent, string processedContent, string reason) + { + string value = template ?? ""; + string fileName = Path.GetFileName(file) ?? ""; + string extension = Path.GetExtension(file) ?? ""; + string directory = Path.GetDirectoryName(file) ?? ""; + return value + .Replace("{{CONTENT}}", processedContent ?? "") + .Replace("{{RAW_CONTENT}}", rawContent ?? "") + .Replace("{{FILE_NAME}}", fileName) + .Replace("{{FILE_PATH}}", file ?? "") + .Replace("{{FILE_DIR}}", directory) + .Replace("{{FILE_EXT}}", extension) + .Replace("{{JOB_NAME}}", job.Name ?? "") + .Replace("{{EVENT}}", reason ?? "") + .Replace("{{NOW}}", DateTime.Now.ToString("yyyy-MM-ddTHH:mm:ss.fffK")); + } + + private static string MoveProcessedFile(string source, string targetDirectory, bool overwrite) + { + Directory.CreateDirectory(targetDirectory); + string target = Path.Combine(targetDirectory, Path.GetFileName(source)); + if (File.Exists(target)) + { + if (overwrite) File.Delete(target); + else + { + string name = Path.GetFileNameWithoutExtension(source); + string ext = Path.GetExtension(source); + target = Path.Combine(targetDirectory, name + "_" + DateTime.Now.ToString("yyyyMMdd_HHmmss_fff") + ext); + int i = 1; + while (File.Exists(target)) + { + target = Path.Combine(targetDirectory, name + "_" + DateTime.Now.ToString("yyyyMMdd_HHmmss_fff") + "_" + i + ext); + i++; + } + } + } + File.Move(source, target); + return target; + } + } +} diff --git a/FileinterfaceCronScheduler/Scheduler/WatcherJobRepository.cs b/FileinterfaceCronScheduler/Scheduler/WatcherJobRepository.cs new file mode 100644 index 0000000..f105a9a --- /dev/null +++ b/FileinterfaceCronScheduler/Scheduler/WatcherJobRepository.cs @@ -0,0 +1,109 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Web.Script.Serialization; + +namespace FileInterface.Scheduler +{ + public sealed class WatcherJobRepository + { + private readonly object _sync = new object(); + private readonly string _fileName; + private readonly JavaScriptSerializer _serializer = new JavaScriptSerializer(); + private List _jobs; + + public WatcherJobRepository(string fileName) + { + _fileName = Path.GetFullPath(fileName); + _serializer.MaxJsonLength = Int32.MaxValue; + _jobs = LoadInternal(); + } + + public IList GetAll() + { + lock (_sync) return Clone(_jobs); + } + + public WatcherJobConfig GetById(string id) + { + lock (_sync) + { + WatcherJobConfig job = _jobs.FirstOrDefault(j => String.Equals(j.Id, id, StringComparison.OrdinalIgnoreCase)); + return job == null ? null : Clone(job); + } + } + + public WatcherJobConfig Save(WatcherJobConfig job) + { + if (job == null) throw new ArgumentNullException("job"); + Validate(job); + lock (_sync) + { + if (String.IsNullOrWhiteSpace(job.Id)) job.Id = Guid.NewGuid().ToString("N"); + int index = _jobs.FindIndex(j => String.Equals(j.Id, job.Id, StringComparison.OrdinalIgnoreCase)); + if (index >= 0) _jobs[index] = Clone(job); else _jobs.Add(Clone(job)); + SaveInternal(); + return Clone(job); + } + } + + public bool Delete(string id) + { + lock (_sync) + { + int count = _jobs.RemoveAll(j => String.Equals(j.Id, id, StringComparison.OrdinalIgnoreCase)); + if (count > 0) SaveInternal(); + return count > 0; + } + } + + private List LoadInternal() + { + if (!File.Exists(_fileName)) return new List(); + string json = File.ReadAllText(_fileName, Encoding.UTF8); + if (String.IsNullOrWhiteSpace(json)) return new List(); + return _serializer.Deserialize>(json) ?? new List(); + } + + private void SaveInternal() + { + string dir = Path.GetDirectoryName(_fileName); + if (!String.IsNullOrWhiteSpace(dir)) Directory.CreateDirectory(dir); + string json = _serializer.Serialize(_jobs); + string tmp = _fileName + ".tmp"; + File.WriteAllText(tmp, json, new UTF8Encoding(false)); + if (File.Exists(_fileName)) + { + string backup = _fileName + ".bak"; + try { File.Replace(tmp, _fileName, backup, true); } + catch { File.Delete(_fileName); File.Move(tmp, _fileName); } + } + else File.Move(tmp, _fileName); + } + + private static void Validate(WatcherJobConfig job) + { + if (String.IsNullOrWhiteSpace(job.Name)) throw new InvalidOperationException("Name is required."); + if (String.IsNullOrWhiteSpace(job.SourceDirectory)) throw new InvalidOperationException("SourceDirectory is required."); + if (String.IsNullOrWhiteSpace(job.CustomFunction)) throw new InvalidOperationException("CustomFunction is required."); + if (!job.WatchCreated && !job.WatchChanged && !job.WatchRenamed) + throw new InvalidOperationException("At least one watcher event (Created, Changed, Renamed) must be enabled."); + if (job.MoveFileAfterSuccess && String.IsNullOrWhiteSpace(job.MoveTargetDirectory)) + throw new InvalidOperationException("MoveTargetDirectory is required when MoveFileAfterSuccess=true."); + if (job.InArgs == null) job.InArgs = new List(); + if (String.IsNullOrWhiteSpace(job.SearchPattern)) job.SearchPattern = "*.*"; + if (job.JoinSeparator == null) job.JoinSeparator = ";"; + if (job.DebounceMilliseconds < 0) job.DebounceMilliseconds = 0; + if (job.FileReadyRetries < 1) job.FileReadyRetries = 1; + if (job.FileReadyRetries > 1000) job.FileReadyRetries = 1000; + if (job.FileReadyDelayMilliseconds < 10) job.FileReadyDelayMilliseconds = 10; + } + + private T Clone(T value) + { + return _serializer.Deserialize(_serializer.Serialize(value)); + } + } +} diff --git a/FileinterfaceCronScheduler/Scheduler/WebAdminServer.cs b/FileinterfaceCronScheduler/Scheduler/WebAdminServer.cs new file mode 100644 index 0000000..a20819a --- /dev/null +++ b/FileinterfaceCronScheduler/Scheduler/WebAdminServer.cs @@ -0,0 +1,278 @@ +using System; +using System.IO; +using System.Net; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using System.Web.Script.Serialization; + +namespace FileInterface.Scheduler +{ + public sealed class WebAdminServer : IDisposable + { + private readonly HttpListener _listener = new HttpListener(); + private readonly CronJobRepository _cronRepository; + private readonly CronScheduler _scheduler; + private readonly WatcherJobRepository _watcherRepository; + private readonly FileSystemWatcherManager _watcherManager; + private readonly JobLog _log; + private readonly SchedulerOptions _options; + private readonly JavaScriptSerializer _json = new JavaScriptSerializer(); + private CancellationTokenSource _cts; + + public WebAdminServer( + CronJobRepository cronRepository, + CronScheduler scheduler, + WatcherJobRepository watcherRepository, + FileSystemWatcherManager watcherManager, + JobLog log, + SchedulerOptions options) + { + _cronRepository = cronRepository; + _scheduler = scheduler; + _watcherRepository = watcherRepository; + _watcherManager = watcherManager; + _log = log; + _options = options; + _listener.Prefixes.Add(options.WebPrefix); + _json.MaxJsonLength = Int32.MaxValue; + } + + public void Start() + { + if (_listener.IsListening) return; + _cts = new CancellationTokenSource(); + _listener.Start(); + Task.Run(() => AcceptLoop(_cts.Token)); + } + + private async Task AcceptLoop(CancellationToken token) + { + while (!token.IsCancellationRequested) + { + HttpListenerContext context; + try { context = await _listener.GetContextAsync(); } + catch when (token.IsCancellationRequested) { break; } + catch { continue; } + Task.Run(() => Handle(context)); + } + } + + private void Handle(HttpListenerContext ctx) + { + try + { + if (!IsAuthorized(ctx)) + { + ctx.Response.StatusCode = 401; + ctx.Response.AddHeader("WWW-Authenticate", "Basic realm=FileInterface Admin"); + ctx.Response.Close(); + return; + } + + string path = ctx.Request.Url.AbsolutePath.TrimEnd('/'); + if (path == "") path = "/"; + + if (ctx.Request.HttpMethod == "GET" && path == "/") { WriteHtml(ctx, Html); return; } + + if (ctx.Request.HttpMethod == "GET" && (path == "/api/cronjobs" || path == "/api/jobs")) + { WriteJson(ctx, _cronRepository.GetAll()); return; } + + if (ctx.Request.HttpMethod == "POST" && (path == "/api/cronjobs/save" || path == "/api/jobs/save")) + { + CronJobConfig job = _json.Deserialize(ReadBody(ctx.Request)); + WriteJson(ctx, _cronRepository.Save(job)); return; + } + + if (ctx.Request.HttpMethod == "POST" && (path == "/api/cronjobs/delete" || path == "/api/jobs/delete")) + { WriteJson(ctx, new { deleted = _cronRepository.Delete(ctx.Request.QueryString["id"] ?? "") }); return; } + + if (ctx.Request.HttpMethod == "POST" && (path == "/api/cronjobs/run" || path == "/api/jobs/run")) + { WriteJson(ctx, new { started = _scheduler.RunNow(ctx.Request.QueryString["id"] ?? "") }); return; } + + if (ctx.Request.HttpMethod == "GET" && path == "/api/watchers") + { WriteJson(ctx, _watcherRepository.GetAll()); return; } + + if (ctx.Request.HttpMethod == "POST" && path == "/api/watchers/save") + { + WatcherJobConfig job = _json.Deserialize(ReadBody(ctx.Request)); + WatcherJobConfig saved = _watcherRepository.Save(job); + _watcherManager.Reload(); + WriteJson(ctx, saved); return; + } + + if (ctx.Request.HttpMethod == "POST" && path == "/api/watchers/delete") + { + bool deleted = _watcherRepository.Delete(ctx.Request.QueryString["id"] ?? ""); + _watcherManager.Reload(); + WriteJson(ctx, new { deleted = deleted }); return; + } + + if (ctx.Request.HttpMethod == "POST" && path == "/api/watchers/reload") + { _watcherManager.Reload(); WriteJson(ctx, new { reloaded = true }); return; } + + if (ctx.Request.HttpMethod == "POST" && path == "/api/watchers/scan") + { WriteJson(ctx, new { started = _watcherManager.ProcessExistingNow(ctx.Request.QueryString["id"] ?? "") }); return; } + + if (ctx.Request.HttpMethod == "GET" && path == "/api/logs") + { + int limit = 300; + int parsed; + if (Int32.TryParse(ctx.Request.QueryString["limit"], out parsed) && parsed > 0) limit = parsed; + WriteJson(ctx, _log.GetLatest(limit)); return; + } + + if (ctx.Request.HttpMethod == "GET" && path == "/api/status") + { + WriteJson(ctx, new + { + runningCronJobIds = _scheduler.GetRunningJobIds(), + activeWatcherIds = _watcherManager.GetActiveWatcherIds(), + serverTime = DateTime.Now + }); + return; + } + + ctx.Response.StatusCode = 404; + WriteJson(ctx, new { error = "Not found" }); + } + catch (Exception ex) + { + try { ctx.Response.StatusCode = 500; WriteJson(ctx, new { error = ex.Message }); } + catch { try { ctx.Response.Close(); } catch { } } + } + } + + private bool IsAuthorized(HttpListenerContext ctx) + { + if (String.IsNullOrWhiteSpace(_options.WebUser)) return true; + string header = ctx.Request.Headers["Authorization"]; + if (String.IsNullOrWhiteSpace(header) || !header.StartsWith("Basic ", StringComparison.OrdinalIgnoreCase)) return false; + try + { + string decoded = Encoding.UTF8.GetString(Convert.FromBase64String(header.Substring(6).Trim())); + int colon = decoded.IndexOf(':'); + if (colon < 0) return false; + return decoded.Substring(0, colon) == _options.WebUser && decoded.Substring(colon + 1) == (_options.WebPassword ?? ""); + } + catch { return false; } + } + + private static string ReadBody(HttpListenerRequest request) + { + using (var reader = new StreamReader(request.InputStream, request.ContentEncoding ?? Encoding.UTF8)) + return reader.ReadToEnd(); + } + + private void WriteJson(HttpListenerContext ctx, object value) + { + byte[] data = Encoding.UTF8.GetBytes(_json.Serialize(value)); + ctx.Response.ContentType = "application/json; charset=utf-8"; + ctx.Response.ContentLength64 = data.Length; + ctx.Response.OutputStream.Write(data, 0, data.Length); + ctx.Response.Close(); + } + + private static void WriteHtml(HttpListenerContext ctx, string html) + { + byte[] data = Encoding.UTF8.GetBytes(html); + ctx.Response.ContentType = "text/html; charset=utf-8"; + ctx.Response.ContentLength64 = data.Length; + ctx.Response.OutputStream.Write(data, 0, data.Length); + ctx.Response.Close(); + } + + public void Dispose() + { + if (_cts != null) _cts.Cancel(); + try { _listener.Stop(); } catch { } + try { _listener.Close(); } catch { } + } + + private const string Html = @" + + + +FileInterface Admin + +FileInterface – HTTP AdministrationFileSystemWatcher + CronScheduler · iTAC IMS CustomFunctions + +FileSystemWatcherCronScheduler + + +Watcher+ Neuer Watcher +Watcher bearbeiten +Name + Aktiv +QuellverzeichnisDateifilter + Unterverzeichnisse einbeziehen + Created Changed Renamed +Debounce (ms)File-Ready VersucheAbstand (ms) + Leere Zeilen entfernenZeilentrenner +CustomFunction +inArgs beliebig viele+ inArg +Platzhalter: {{CONTENT}}, {{RAW_CONTENT}}, {{FILE_NAME}}, {{FILE_PATH}}, {{FILE_DIR}}, {{FILE_EXT}}, {{JOB_NAME}}, {{EVENT}}, {{NOW}} + + Datei nach erfolgreichem CF-Aufruf verschieben +Zielverzeichnis Zieldatei überschreiben +SpeichernVorhandene Dateien verarbeitenWatcher neu ladenLöschen + + + +Cronjobs+ Neuer Cronjob +Cronjob bearbeiten +Name +Cron (5 Felder) Aktiv + CustomFunction einmal ohne Datei aufrufen +QuellverzeichnisDateifilter + Unterverzeichnisse einbeziehen + Leere Zeilen entfernenZeilentrenner +CustomFunction +inArgs beliebig viele+ inArg +Platzhalter: {{CONTENT}}, {{RAW_CONTENT}}, {{FILE_NAME}}, {{FILE_PATH}}, {{FILE_DIR}}, {{FILE_EXT}}, {{JOB_NAME}}, {{NOW}} + + Datei nach erfolgreichem CF-Aufruf verschieben +Zielverzeichnis Zieldatei überschreiben +SpeichernJetzt ausführenLöschen + + +Runtime / LogAktualisieren + +"; + } +} diff --git a/FileinterfaceCronScheduler/cronjobs.json b/FileinterfaceCronScheduler/cronjobs.json new file mode 100644 index 0000000..756a689 --- /dev/null +++ b/FileinterfaceCronScheduler/cronjobs.json @@ -0,0 +1,23 @@ +[ + { + "Id": "example_cogi_import", + "Name": "Beispiel - COGI Import", + "Enabled": false, + "Cron": "*/5 * * * *", + "CallOnceWithoutFile": false, + "SourceDirectory": "C:\\FileInterface\\Input", + "SearchPattern": "*.xml", + "IncludeSubdirectories": false, + "RemoveEmptyLines": true, + "JoinSeparator": ";", + "CustomFunction": "BroseREST.restCallCogi1", + "InArgs": [ + "RPCServices-WS", + "{{CONTENT}}", + "{{FILE_NAME}}" + ], + "MoveFileAfterSuccess": true, + "MoveTargetDirectory": "C:\\FileInterface\\Archive", + "OverwriteTargetFile": false + } +] diff --git a/FileinterfaceCronScheduler/ihas.properties b/FileinterfaceCronScheduler/ihas.properties new file mode 100644 index 0000000..e69de29 diff --git a/FileinterfaceCronScheduler/lib/IMSApiDotNet.dll b/FileinterfaceCronScheduler/lib/IMSApiDotNet.dll new file mode 100644 index 0000000..89e0ab4 Binary files /dev/null and b/FileinterfaceCronScheduler/lib/IMSApiDotNet.dll differ diff --git a/FileinterfaceCronScheduler/lib/README.txt b/FileinterfaceCronScheduler/lib/README.txt new file mode 100644 index 0000000..c863192 --- /dev/null +++ b/FileinterfaceCronScheduler/lib/README.txt @@ -0,0 +1,14 @@ +IMSApiDotNet.dll +================ + +Die IMSApiDotNet.dll ist in diesem Projekt bereits enthalten und wird vom +Visual-Studio-Projekt ueber folgende Referenz eingebunden: + + lib\IMSApiDotNet.dll + +Erkannte DLL-Version: + IMSApiDotNet 10.0.0-3 + FileVersion 10.0.0.3 + Plattform: x86 / 32 Bit + +Das Projekt ist deshalb ebenfalls mit PlatformTarget=x86 konfiguriert. diff --git a/FileinterfaceCronScheduler/watcherjobs.json b/FileinterfaceCronScheduler/watcherjobs.json new file mode 100644 index 0000000..4416da0 --- /dev/null +++ b/FileinterfaceCronScheduler/watcherjobs.json @@ -0,0 +1,28 @@ +[ + { + "Id": "example_file_watcher", + "Name": "Beispiel - XML Eingang", + "Enabled": false, + "SourceDirectory": "C:\\FileInterface\\Input", + "SearchPattern": "*.xml", + "IncludeSubdirectories": false, + "WatchCreated": true, + "WatchChanged": true, + "WatchRenamed": true, + "DebounceMilliseconds": 1000, + "FileReadyRetries": 10, + "FileReadyDelayMilliseconds": 250, + "RemoveEmptyLines": true, + "JoinSeparator": ";", + "CustomFunction": "BroseREST.restCallCogi1", + "InArgs": [ + "RPCServices-WS", + "{{CONTENT}}", + "{{FILE_NAME}}", + "{{EVENT}}" + ], + "MoveFileAfterSuccess": true, + "MoveTargetDirectory": "C:\\FileInterface\\Archive", + "OverwriteTargetFile": false + } +] diff --git a/README.md b/README.md new file mode 100644 index 0000000..f5ddf95 --- /dev/null +++ b/README.md @@ -0,0 +1,220 @@ +# FileInterface – FileSystemWatcher + CronScheduler + + + +## HTTP-Webclient Login + +Der Webclient ist standardmaessig per HTTP Basic Authentication geschuetzt. + +```text +Benutzer: admin +Kennwort: admin +``` + +Die Zugangsdaten koennen in `FileinterfaceCronScheduler/App.config` geaendert werden: + +```xml + + +``` + +Wichtig: Fuer den produktiven Betrieb sollte das Standardkennwort geaendert werden. + +Eigenständiges Visual-Studio-/C#-Projekt für ereignisbasierte **und** zeitgesteuerte Datei-Verarbeitung über iTAC IMSAPI CustomFunctions. + +## Architektur + +Beide Mechanismen laufen gleichzeitig und unabhängig voneinander: + +```text +HTTP Webclient + | + +-- FileSystemWatcher-Konfiguration --> beliebig viele FileSystemWatcher + | + +-- Cron-Konfiguration ---------------> beliebig viele Cronjobs + | + +-----------------------------+ + | + gemeinsame IMSAPI Session + | + customFunction(...) +``` + +## Enthalten + +- `FileinterfaceCronScheduler.sln` +- .NET Framework 4.8 / x86 +- Windows-Dienst und Konsolenmodus +- enthaltene `IMSApiDotNet.dll` Version 10.0.0-3 +- IMSAPI Initialisierung und `regLogin(...)` +- beliebig viele FileSystemWatcher +- beliebig viele Cronjobs +- zentrale HTTP-Administration +- beliebig viele `inArgs` pro Watcher/Cronjob +- eigene CustomFunction pro Watcher/Cronjob +- Datei nach Erfolg optional verschieben +- Laufzeit-Log und Status im Browser + +## HTTP Webclient + +Standard: + +```text +http://127.0.0.1:8095/ +``` + +Für Zugriff aus dem Netzwerk in `App.config`: + +```xml + +``` + +Danach `scripts\allow-webinterface-network.bat` als Administrator ausführen. Für Netzwerkzugriff sollten `Scheduler.WebUser` und `Scheduler.WebPassword` gesetzt werden. + +Der Webclient besitzt zwei Bereiche: + +### FileSystemWatcher + +Pro Watcher konfigurierbar: + +- Aktiv / Inaktiv +- Quellverzeichnis +- Dateifilter, z. B. `*.xml` +- Unterverzeichnisse +- Events `Created`, `Changed`, `Renamed` +- Debounce in Millisekunden +- File-Ready Wiederholungen / Wartezeit +- CustomFunction +- beliebig viele `inArgs` +- leere Zeilen entfernen +- Zeilentrenner +- Datei nach Erfolg verschieben Ja/Nein +- Zielverzeichnis +- bestehende Zieldatei überschreiben Ja/Nein +- vorhandene Dateien manuell verarbeiten +- Watcher ohne Dienstneustart neu laden + +Änderungen werden beim Speichern sofort persistent geschrieben und die FileSystemWatcher werden automatisch neu aufgebaut. + +### CronScheduler + +Pro Cronjob konfigurierbar: + +- Aktiv / Inaktiv +- Cron-Ausdruck mit 5 Feldern +- optional CustomFunction ohne Datei aufrufen +- Quellverzeichnis / Filter / Unterverzeichnisse +- CustomFunction +- beliebig viele `inArgs` +- Datei nach Erfolg verschieben Ja/Nein +- manuell `Jetzt ausführen` + +Beispiele: + +```text +*/5 * * * * +0 */2 * * * +15 2 * * * +0 6 * * 1-5 +``` + +## Platzhalter + +Für Watcher und Cronjobs: + +```text +{{CONTENT}} +{{RAW_CONTENT}} +{{FILE_NAME}} +{{FILE_PATH}} +{{FILE_DIR}} +{{FILE_EXT}} +{{JOB_NAME}} +{{NOW}} +``` + +Nur beim FileSystemWatcher zusätzlich: + +```text +{{EVENT}} +``` + +`{{EVENT}}` enthält z. B. `Created`, `Changed`, `Renamed` oder `ManualScan`. + +## Persistente Konfiguration + +Die Jobs sind getrennt gespeichert: + +```text +cronjobs.json +watcherjobs.json +``` + +Pfade können in `App.config` geändert werden: + +```xml + + +``` + +## IMSAPI + +`IMSApiDotNet.dll` liegt bereits unter: + +```text +FileinterfaceCronScheduler\lib\IMSApiDotNet.dll +``` + +Beim Programmstart: + +1. `IMSApiDotNet.setProperty(...)` +2. `IMSApiDotNet.loadLibrary()` +3. `imsapiInit()` +4. `regLogin(...)` +5. Start FileSystemWatcher + CronScheduler + HTTP-Webclient + +Die Aufrufe verwenden anschließend dieselbe Session: + +```csharp +imsapi.customFunction( + sessionContext, + customFunctionName, + inArgs, + out outArgs, + out customErrorString +); +``` + +## IMS-Konfiguration + +In `FileinterfaceCronScheduler\App.config`: + +```xml + + + + + + + +``` + +`IMS.SerializeCalls=false` erlaubt unabhängigen Watchern und Cronjobs parallele CustomFunction-Aufrufe. Wenn die eingesetzte IMSAPI-Umgebung das nicht verträgt, kann auf `true` gestellt werden. + +## Visual Studio + +1. `FileinterfaceCronScheduler.sln` öffnen. +2. `App.config` konfigurieren. +3. Build `Debug` oder `Release`; das Projekt kompiliert als x86. +4. Starten. +5. Browser auf `http://127.0.0.1:8095/` öffnen. + +## Windows-Dienst + +Unter `scripts`: + +- `install-service.bat` +- `uninstall-service.bat` +- `allow-webinterface-network.bat` + +Vor der Dienstinstallation zuerst Release bauen. diff --git a/scripts/allow-webinterface-network.bat b/scripts/allow-webinterface-network.bat new file mode 100644 index 0000000..83caa3f --- /dev/null +++ b/scripts/allow-webinterface-network.bat @@ -0,0 +1,4 @@ +@echo off +REM Als Administrator ausfuehren, wenn Scheduler.WebPrefix = http://+:8095/ genutzt wird. +netsh http add urlacl url=http://+:8095/ user=Everyone +netsh advfirewall firewall add rule name="FileInterface HTTP Admin 8095" dir=in action=allow protocol=TCP localport=8095 diff --git a/scripts/install-service.bat b/scripts/install-service.bat new file mode 100644 index 0000000..3b00571 --- /dev/null +++ b/scripts/install-service.bat @@ -0,0 +1,22 @@ +@echo off +setlocal +set SERVICE_NAME=FileinterfaceCronScheduler +set EXE=%~dp0..\FileinterfaceCronScheduler\bin\Release\FileinterfaceCronScheduler.exe + +if not exist "%EXE%" ( + echo EXE nicht gefunden: %EXE% + echo Bitte Solution zuerst im Release-Modus bauen. + exit /b 1 +) + +sc.exe query "%SERVICE_NAME%" >nul 2>&1 +if %errorlevel%==0 ( + echo Dienst %SERVICE_NAME% existiert bereits. + exit /b 1 +) + +sc.exe create "%SERVICE_NAME%" binPath= "\"%EXE%\" --service" start= auto DisplayName= "FileInterface Watcher + CronScheduler" +if errorlevel 1 exit /b 1 +sc.exe description "%SERVICE_NAME%" "FileSystemWatcher und CronScheduler fuer iTAC IMS CustomFunctions mit HTTP-Administration" +sc.exe start "%SERVICE_NAME%" +endlocal diff --git a/scripts/uninstall-service.bat b/scripts/uninstall-service.bat new file mode 100644 index 0000000..5a50341 --- /dev/null +++ b/scripts/uninstall-service.bat @@ -0,0 +1,7 @@ +@echo off +setlocal +set SERVICE_NAME=FileinterfaceCronScheduler +sc.exe stop "%SERVICE_NAME%" +timeout /t 2 /nobreak >nul +sc.exe delete "%SERVICE_NAME%" +endlocal