Projektdateien hinzufügen.

This commit is contained in:
mr_sc
2026-08-19 19:27:41 +02:00
parent 0889f1f6c7
commit 7fe54c0785
32 changed files with 2406 additions and 0 deletions

13
BUILD_INFO.txt Normal file
View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,43 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<startup useLegacyV2RuntimeActivationPolicy="true">
<supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.8" />
</startup>
<appSettings>
<!-- =============================================================== -->
<!-- iTAC IMSApiDotNet / Login -->
<!-- Values correspond to the original FileInterface configuration. -->
<!-- =============================================================== -->
<add key="IMS.AppID" value="FileInterfaceCronScheduler" />
<add key="IMS.ServerUrl" value="http://YOUR-IMS-SERVER:PORT" />
<add key="IMS.StationNumber" value="YOUR_STATION" />
<add key="IMS.ClientNumber" value="1" />
<add key="IMS.RegistrationType" value="S" />
<!-- Relative paths are resolved from the EXE directory. -->
<add key="IMS.PropertyDirectory" value="." />
<!-- false = Cronjobs duerfen die IMS-CustomFunction parallel aufrufen. -->
<add key="IMS.SerializeCalls" value="false" />
<!-- Scheduler -->
<add key="Scheduler.ConfigFile" value="cronjobs.json" />
<add key="Watcher.ConfigFile" value="watcherjobs.json" />
<add key="Scheduler.WebPrefix" value="http://127.0.0.1:8095/" />
<!-- HTTP Basic Authentication for the administration web client. -->
<!-- Default login: admin / admin. Change both values before production use. -->
<!-- Set Scheduler.WebUser empty only if authentication shall explicitly be disabled. -->
<add key="Scheduler.WebUser" value="admin" />
<add key="Scheduler.WebPassword" value="admin" />
<add key="ClientSettingsProvider.ServiceUri" value="" />
</appSettings>
<system.web>
<membership defaultProvider="ClientAuthenticationMembershipProvider">
<providers>
<add name="ClientAuthenticationMembershipProvider" type="System.Web.ClientServices.Providers.ClientFormsAuthenticationMembershipProvider, System.Web.Extensions, Version=4.0.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35" serviceUri="" />
</providers>
</membership>
<roleManager defaultProvider="ClientRoleProvider" enabled="true">
<providers>
<add name="ClientRoleProvider" type="System.Web.ClientServices.Providers.ClientRoleProvider, System.Web.Extensions, Version=4.0.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35" serviceUri="" cacheTimeout="86400" />
</providers>
</roleManager>
</system.web>
</configuration>

View File

@@ -0,0 +1,96 @@
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" />
<PropertyGroup>
<Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
<Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform>
<ProjectGuid>{D7E916B9-969E-4CFD-B6A3-527B7091D45A}</ProjectGuid>
<OutputType>Exe</OutputType>
<RootNamespace>FileinterfaceCronScheduler</RootNamespace>
<AssemblyName>FileinterfaceCronScheduler</AssemblyName>
<TargetFrameworkVersion>v4.8</TargetFrameworkVersion>
<FileAlignment>512</FileAlignment>
<AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects>
<Deterministic>true</Deterministic>
<LangVersion>7.3</LangVersion>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
<PlatformTarget>x86</PlatformTarget>
<DebugSymbols>true</DebugSymbols>
<DebugType>full</DebugType>
<Optimize>false</Optimize>
<OutputPath>bin\Debug\</OutputPath>
<DefineConstants>DEBUG;TRACE</DefineConstants>
<ErrorReport>prompt</ErrorReport>
<WarningLevel>4</WarningLevel>
<Prefer32Bit>false</Prefer32Bit>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' ">
<PlatformTarget>x86</PlatformTarget>
<DebugType>pdbonly</DebugType>
<Optimize>true</Optimize>
<OutputPath>bin\Release\</OutputPath>
<DefineConstants>TRACE</DefineConstants>
<ErrorReport>prompt</ErrorReport>
<WarningLevel>4</WarningLevel>
<Prefer32Bit>false</Prefer32Bit>
</PropertyGroup>
<PropertyGroup>
<StartupObject>FileinterfaceCronScheduler.Program</StartupObject>
</PropertyGroup>
<PropertyGroup>
<SignManifests>false</SignManifests>
</PropertyGroup>
<ItemGroup>
<Reference Include="IMSApiDotNet">
<HintPath>lib\IMSApiDotNet.dll</HintPath>
<Private>true</Private>
</Reference>
<Reference Include="System" />
<Reference Include="System.Core" />
<Reference Include="System.Configuration" />
<Reference Include="System.ServiceProcess" />
<Reference Include="System.Web.Extensions" />
<Reference Include="Microsoft.CSharp" />
</ItemGroup>
<ItemGroup>
<Compile Include="Program.cs" />
<Compile Include="FileinterfaceCronSchedulerService.cs">
<SubType>Component</SubType>
</Compile>
<Compile Include="RuntimeFactory.cs" />
<Compile Include="ImsCustomFunctionInvoker.cs" />
<Compile Include="IMSApi\ImsApiClient.cs" />
<Compile Include="Scheduler\CronExpression.cs" />
<Compile Include="Scheduler\CronJobConfig.cs" />
<Compile Include="Scheduler\CronJobExecutor.cs" />
<Compile Include="Scheduler\CronJobRepository.cs" />
<Compile Include="Scheduler\CronScheduler.cs" />
<Compile Include="Scheduler\WatcherJobConfig.cs" />
<Compile Include="Scheduler\WatcherJobRepository.cs" />
<Compile Include="Scheduler\WatcherJobExecutor.cs" />
<Compile Include="Scheduler\FileSystemWatcherManager.cs" />
<Compile Include="Scheduler\CustomFunctionInvoker.cs" />
<Compile Include="Scheduler\JobLog.cs" />
<Compile Include="Scheduler\SchedulerRuntime.cs" />
<Compile Include="Scheduler\WebAdminServer.cs" />
<Compile Include="Properties\AssemblyInfo.cs" />
</ItemGroup>
<ItemGroup>
<None Include="App.config" />
<None Include="lib\README.txt" />
<Content Include="ihas.properties">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="cronjobs.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="watcherjobs.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
<Target Name="CheckIMSApiDll" BeforeTargets="ResolveAssemblyReferences">
<Error Condition="!Exists('$(MSBuildProjectDirectory)\lib\IMSApiDotNet.dll')" Text="IMSApiDotNet.dll fehlt. Erwarteter Pfad: FileinterfaceCronScheduler\lib\IMSApiDotNet.dll" />
</Target>
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
</Project>

View File

@@ -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;
}
}
}
}

View File

@@ -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
{
/// <summary>
/// Initializes IMSApiDotNet, logs in once and exposes customFunction to the scheduler.
/// Configuration is read from App.config.
/// </summary>
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);
}
}
}

View File

@@ -0,0 +1,37 @@
using System;
using FileInterface.Scheduler;
using FileinterfaceCronScheduler.IMSApi;
namespace FileinterfaceCronScheduler
{
/// <summary>
/// Thin scheduler adapter around the real IMSApiClient.
/// </summary>
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();
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -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")]

View File

@@ -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();
}
}
}

View File

@@ -0,0 +1,131 @@
using System;
using System.Collections.Generic;
using System.Globalization;
namespace FileInterface.Scheduler
{
/// <summary>
/// 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.
/// </summary>
public sealed class CronExpression
{
private readonly HashSet<int> _minutes;
private readonly HashSet<int> _hours;
private readonly HashSet<int> _days;
private readonly HashSet<int> _months;
private readonly HashSet<int> _daysOfWeek;
public string Expression { get; private set; }
private CronExpression(string expression,
HashSet<int> minutes,
HashSet<int> hours,
HashSet<int> days,
HashSet<int> months,
HashSet<int> 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<int> ParseField(string text, int min, int max, bool normalizeSunday)
{
var result = new HashSet<int>();
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;
}
}
}

View File

@@ -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<string> 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<string>();
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 = "";
}
}
}

View File

@@ -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<string>())
.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;
}
}
}

View File

@@ -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<CronJobConfig> _jobs;
public CronJobRepository(string fileName)
{
_fileName = Path.GetFullPath(fileName);
_serializer.MaxJsonLength = Int32.MaxValue;
_jobs = LoadInternal();
}
public IList<CronJobConfig> 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<CronJobConfig> LoadInternal()
{
if (!File.Exists(_fileName)) return new List<CronJobConfig>();
string json = File.ReadAllText(_fileName, Encoding.UTF8);
if (String.IsNullOrWhiteSpace(json)) return new List<CronJobConfig>();
return _serializer.Deserialize<List<CronJobConfig>>(json) ?? new List<CronJobConfig>();
}
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<string>();
if (String.IsNullOrWhiteSpace(job.SearchPattern)) job.SearchPattern = "*.*";
if (job.JoinSeparator == null) job.JoinSeparator = ";";
}
private T Clone<T>(T value)
{
return _serializer.Deserialize<T>(_serializer.Serialize(value));
}
}
}

View File

@@ -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<string, byte> _running = new ConcurrentDictionary<string, byte>(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<string, string> _lastMinuteRun = new ConcurrentDictionary<string, string>(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<string> GetRunningJobIds()
{
return new List<string>(_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();
}
}
}

View File

@@ -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);
}
/// <summary>
/// Lets the existing FileInterface IMS code remain untouched. Wire the current
/// IMS CustomFunction call into this delegate in Program.cs / service startup.
/// </summary>
public sealed class DelegateCustomFunctionInvoker : ICustomFunctionInvoker
{
private readonly Func<string, string[], CustomFunctionCallResult> _handler;
public DelegateCustomFunctionInvoker(Func<string, string[], CustomFunctionCallResult> handler)
{
if (handler == null) throw new ArgumentNullException("handler");
_handler = handler;
}
public CustomFunctionCallResult Invoke(string customFunctionName, string[] inArgs)
{
return _handler(customFunctionName, inArgs);
}
}
}

View File

@@ -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<string, ActiveWatcher> _watchers = new Dictionary<string, ActiveWatcher>(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<string, DateTime> _lastEvents = new ConcurrentDictionary<string, DateTime>(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<string, byte> _runningFiles = new ConcurrentDictionary<string, byte>(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<string> 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();
}
}
}
}

View File

@@ -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<JobLogEntry> _entries = new Queue<JobLogEntry>();
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<JobLogEntry> 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();
}
}
}
}

View File

@@ -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(); }
}
}
}
}
}

View File

@@ -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<string> 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<string>();
MoveFileAfterSuccess = false;
MoveTargetDirectory = "";
OverwriteTargetFile = false;
}
}
}

View File

@@ -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<string>())
.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;
}
}
}

View File

@@ -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<WatcherJobConfig> _jobs;
public WatcherJobRepository(string fileName)
{
_fileName = Path.GetFullPath(fileName);
_serializer.MaxJsonLength = Int32.MaxValue;
_jobs = LoadInternal();
}
public IList<WatcherJobConfig> 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<WatcherJobConfig> LoadInternal()
{
if (!File.Exists(_fileName)) return new List<WatcherJobConfig>();
string json = File.ReadAllText(_fileName, Encoding.UTF8);
if (String.IsNullOrWhiteSpace(json)) return new List<WatcherJobConfig>();
return _serializer.Deserialize<List<WatcherJobConfig>>(json) ?? new List<WatcherJobConfig>();
}
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<string>();
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>(T value)
{
return _serializer.Deserialize<T>(_serializer.Serialize(value));
}
}
}

View File

@@ -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<CronJobConfig>(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<WatcherJobConfig>(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 = @"<!doctype html>
<html lang='de'>
<head>
<meta charset='utf-8'><meta name='viewport' content='width=device-width,initial-scale=1'>
<title>FileInterface Admin</title>
<style>
*{box-sizing:border-box}body{font-family:Segoe UI,Arial,sans-serif;margin:0;background:#f4f6f8;color:#1f2937}header{background:#111827;color:white;padding:18px 24px}main{max-width:1400px;margin:20px auto;padding:0 16px}.tabs{display:flex;gap:8px;margin-bottom:16px}.tab{background:#e5e7eb}.tab.active{background:#2563eb;color:white}.panel{display:none}.panel.active{display:block}.grid{display:grid;grid-template-columns:minmax(300px,.9fr) minmax(520px,1.5fr);gap:18px}.card{background:white;border:1px solid #dfe3e8;border-radius:10px;padding:16px;box-shadow:0 1px 3px #00000012}button{border:0;border-radius:6px;padding:8px 12px;cursor:pointer}.primary{background:#2563eb;color:white}.danger{background:#dc2626;color:white}.secondary{background:#e5e7eb}.success{background:#15803d;color:white}input,textarea,select{width:100%;padding:8px;border:1px solid #cbd5e1;border-radius:6px}input[type=checkbox]{width:auto}label{font-weight:600;font-size:13px;display:block;margin-top:10px}.row{display:grid;grid-template-columns:1fr 1fr;gap:10px}.row3{display:grid;grid-template-columns:1fr 1fr 1fr;gap:10px}.item{border-bottom:1px solid #e5e7eb;padding:10px 0}.item:last-child{border:0}.muted{color:#64748b;font-size:12px}.arg{display:flex;gap:6px;margin:6px 0}.arg input{flex:1}.log{font-family:Consolas,monospace;font-size:12px;max-height:340px;overflow:auto;background:#0f172a;color:#e2e8f0;padding:10px;border-radius:6px;white-space:pre-wrap}.ok{color:#15803d}.off{color:#9ca3af}.badge{display:inline-block;padding:2px 7px;border-radius:12px;font-size:11px;background:#e5e7eb}.toolbar{display:flex;gap:8px;flex-wrap:wrap;margin-top:16px}.help{background:#eff6ff;border:1px solid #bfdbfe;border-radius:6px;padding:8px;margin-top:10px;font-size:12px}@media(max-width:950px){.grid{grid-template-columns:1fr}.row,.row3{grid-template-columns:1fr}}
</style></head>
<body><header><h2 style='margin:0'>FileInterface HTTP Administration</h2><div style='color:#cbd5e1;font-size:12px'>FileSystemWatcher + CronScheduler · iTAC IMS CustomFunctions</div></header>
<main>
<div class='tabs'><button id='tabWatcher' class='tab active' onclick=""showTab('watcher')"">FileSystemWatcher</button><button id='tabCron' class='tab' onclick=""showTab('cron')"">CronScheduler</button></div>
<section id='watcherPanel' class='panel active'><div class='grid'>
<div class='card'><div style='display:flex;justify-content:space-between;align-items:center'><h3>Watcher</h3><button class='primary' onclick='newWatcher()'>+ Neuer Watcher</button></div><div id='watcherList'></div></div>
<div class='card'><h3 id='watcherTitle'>Watcher bearbeiten</h3><input id='wid' type='hidden'>
<label>Name</label><input id='wname'>
<label><input id='wenabled' type='checkbox'> Aktiv</label>
<div class='row'><div><label>Quellverzeichnis</label><input id='wsource' placeholder='C:\Import'></div><div><label>Dateifilter</label><input id='wpattern' placeholder='*.xml'></div></div>
<label><input id='wrecursive' type='checkbox'> Unterverzeichnisse einbeziehen</label>
<div class='row3'><label><input id='wcreated' type='checkbox'> Created</label><label><input id='wchanged' type='checkbox'> Changed</label><label><input id='wrenamed' type='checkbox'> Renamed</label></div>
<div class='row3'><div><label>Debounce (ms)</label><input id='wdebounce' type='number' min='0'></div><div><label>File-Ready Versuche</label><input id='wretries' type='number' min='1'></div><div><label>Abstand (ms)</label><input id='wretrydelay' type='number' min='10'></div></div>
<div class='row'><div><label><input id='wremoveempty' type='checkbox'> Leere Zeilen entfernen</label></div><div><label>Zeilentrenner</label><input id='wseparator' placeholder=';'></div></div>
<label>CustomFunction</label><input id='wcf' placeholder='Namespace.functionName'>
<label>inArgs <span class='muted'>beliebig viele</span></label><div id='wargs'></div><button class='secondary' onclick='addWatcherArg()'>+ inArg</button>
<div class='help'>Platzhalter: {{CONTENT}}, {{RAW_CONTENT}}, {{FILE_NAME}}, {{FILE_PATH}}, {{FILE_DIR}}, {{FILE_EXT}}, {{JOB_NAME}}, {{EVENT}}, {{NOW}}</div>
<hr style='margin:16px 0;border:0;border-top:1px solid #e5e7eb'>
<label><input id='wmove' type='checkbox'> Datei nach erfolgreichem CF-Aufruf verschieben</label>
<div class='row'><div><label>Zielverzeichnis</label><input id='wtarget'></div><div><label><input id='woverwrite' type='checkbox'> Zieldatei überschreiben</label></div></div>
<div class='toolbar'><button class='primary' onclick='saveWatcher()'>Speichern</button><button class='success' onclick='scanWatcher()'>Vorhandene Dateien verarbeiten</button><button class='secondary' onclick='reloadWatchers()'>Watcher neu laden</button><button class='danger' onclick='deleteWatcher()'>Löschen</button></div><div id='wmsg' class='muted' style='margin-top:10px'></div>
</div></div></section>
<section id='cronPanel' class='panel'><div class='grid'>
<div class='card'><div style='display:flex;justify-content:space-between;align-items:center'><h3>Cronjobs</h3><button class='primary' onclick='newCron()'>+ Neuer Cronjob</button></div><div id='cronList'></div></div>
<div class='card'><h3 id='cronTitle'>Cronjob bearbeiten</h3><input id='cid' type='hidden'>
<label>Name</label><input id='cname'>
<div class='row'><div><label>Cron (5 Felder)</label><input id='ccron' placeholder='*/5 * * * *'></div><div><label><input id='cenabled' type='checkbox'> Aktiv</label></div></div>
<label><input id='cwithoutfile' type='checkbox'> CustomFunction einmal ohne Datei aufrufen</label>
<div class='row'><div><label>Quellverzeichnis</label><input id='csource'></div><div><label>Dateifilter</label><input id='cpattern' placeholder='*.xml'></div></div>
<label><input id='crecursive' type='checkbox'> Unterverzeichnisse einbeziehen</label>
<div class='row'><div><label><input id='cremoveempty' type='checkbox'> Leere Zeilen entfernen</label></div><div><label>Zeilentrenner</label><input id='cseparator' placeholder=';'></div></div>
<label>CustomFunction</label><input id='ccf' placeholder='Namespace.functionName'>
<label>inArgs <span class='muted'>beliebig viele</span></label><div id='cargs'></div><button class='secondary' onclick='addCronArg()'>+ inArg</button>
<div class='help'>Platzhalter: {{CONTENT}}, {{RAW_CONTENT}}, {{FILE_NAME}}, {{FILE_PATH}}, {{FILE_DIR}}, {{FILE_EXT}}, {{JOB_NAME}}, {{NOW}}</div>
<hr style='margin:16px 0;border:0;border-top:1px solid #e5e7eb'>
<label><input id='cmove' type='checkbox'> Datei nach erfolgreichem CF-Aufruf verschieben</label>
<div class='row'><div><label>Zielverzeichnis</label><input id='ctarget'></div><div><label><input id='coverwrite' type='checkbox'> Zieldatei überschreiben</label></div></div>
<div class='toolbar'><button class='primary' onclick='saveCron()'>Speichern</button><button class='success' onclick='runCron()'>Jetzt ausführen</button><button class='danger' onclick='deleteCron()'>Löschen</button></div><div id='cmsg' class='muted' style='margin-top:10px'></div>
</div></div></section>
<div class='card' style='margin-top:18px'><div style='display:flex;justify-content:space-between;align-items:center'><h3>Runtime / Log</h3><div id='status' class='muted'></div><button class='secondary' onclick='loadLogs()'>Aktualisieren</button></div><div id='log' class='log'></div></div>
</main>
<script>
const $=id=>document.getElementById(id);let watchers=[],crons=[];
async function api(url,opt){const r=await fetch(url,opt);const t=await r.text();let x;try{x=JSON.parse(t)}catch(e){throw new Error(t||r.statusText)}if(!r.ok)throw new Error(x.error||r.statusText);return x}
function showTab(t){$('watcherPanel').classList.toggle('active',t==='watcher');$('cronPanel').classList.toggle('active',t==='cron');$('tabWatcher').classList.toggle('active',t==='watcher');$('tabCron').classList.toggle('active',t==='cron')}
function esc(s){return String(s||'').replace(/[&<>]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]))}
function addArg(containerId,v){const d=document.createElement('div');d.className='arg';const i=document.createElement('input');i.value=v==null?'':v;const b=document.createElement('button');b.type='button';b.textContent='';b.className='danger';b.onclick=()=>d.remove();d.append(i,b);$(containerId).append(d)}
function addWatcherArg(v=''){addArg('wargs',v)}function addCronArg(v=''){addArg('cargs',v)}
async function loadWatchers(){watchers=await api('/api/watchers');renderWatchers();if(watchers.length&&!$('wid').value)editWatcher(watchers[0].Id)}
function renderWatchers(){$('watcherList').innerHTML=watchers.length?watchers.map(j=>`<div class='item'><b>${esc(j.Name)}</b> <span class='${j.Enabled?'ok':'off'}'>${j.Enabled?'aktiv':'inaktiv'}</span><div class='muted'>${esc(j.SourceDirectory)} · ${esc(j.SearchPattern)} · ${esc(j.CustomFunction)}</div><div class='muted'>Events: ${[j.WatchCreated?'Created':'',j.WatchChanged?'Changed':'',j.WatchRenamed?'Renamed':''].filter(Boolean).join(', ')}</div><button class='secondary' style='margin-top:6px' data-id='${esc(j.Id)}' onclick='editWatcher(this.dataset.id)'>Bearbeiten</button></div>`).join(''):'<div class=muted>Noch keine Watcher.</div>'}
function newWatcher(){fillWatcher({Id:'',Name:'Neuer Watcher',Enabled:true,SourceDirectory:'',SearchPattern:'*.*',IncludeSubdirectories:false,WatchCreated:true,WatchChanged:true,WatchRenamed:true,DebounceMilliseconds:1000,FileReadyRetries:10,FileReadyDelayMilliseconds:250,RemoveEmptyLines:true,JoinSeparator:';',CustomFunction:'',InArgs:['{{CONTENT}}'],MoveFileAfterSuccess:false,MoveTargetDirectory:'',OverwriteTargetFile:false})}
function editWatcher(id){const j=watchers.find(x=>x.Id===id);if(j)fillWatcher(j)}
function fillWatcher(j){$('wid').value=j.Id||'';$('wname').value=j.Name||'';$('wenabled').checked=!!j.Enabled;$('wsource').value=j.SourceDirectory||'';$('wpattern').value=j.SearchPattern||'*.*';$('wrecursive').checked=!!j.IncludeSubdirectories;$('wcreated').checked=!!j.WatchCreated;$('wchanged').checked=!!j.WatchChanged;$('wrenamed').checked=!!j.WatchRenamed;$('wdebounce').value=j.DebounceMilliseconds==null?1000:j.DebounceMilliseconds;$('wretries').value=j.FileReadyRetries||10;$('wretrydelay').value=j.FileReadyDelayMilliseconds||250;$('wremoveempty').checked=!!j.RemoveEmptyLines;$('wseparator').value=j.JoinSeparator==null?';':j.JoinSeparator;$('wcf').value=j.CustomFunction||'';$('wmove').checked=!!j.MoveFileAfterSuccess;$('wtarget').value=j.MoveTargetDirectory||'';$('woverwrite').checked=!!j.OverwriteTargetFile;$('wargs').innerHTML='';(j.InArgs||[]).forEach(addWatcherArg);$('watcherTitle').textContent=j.Id?'Watcher bearbeiten':'Neuer Watcher'}
function watcherModel(){return {Id:$('wid').value,Name:$('wname').value,Enabled:$('wenabled').checked,SourceDirectory:$('wsource').value,SearchPattern:$('wpattern').value,IncludeSubdirectories:$('wrecursive').checked,WatchCreated:$('wcreated').checked,WatchChanged:$('wchanged').checked,WatchRenamed:$('wrenamed').checked,DebounceMilliseconds:Number($('wdebounce').value||0),FileReadyRetries:Number($('wretries').value||10),FileReadyDelayMilliseconds:Number($('wretrydelay').value||250),RemoveEmptyLines:$('wremoveempty').checked,JoinSeparator:$('wseparator').value,CustomFunction:$('wcf').value,InArgs:[...$('wargs').querySelectorAll('input')].map(x=>x.value),MoveFileAfterSuccess:$('wmove').checked,MoveTargetDirectory:$('wtarget').value,OverwriteTargetFile:$('woverwrite').checked}}
async function saveWatcher(){try{const j=await api('/api/watchers/save',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(watcherModel())});$('wid').value=j.Id;$('wmsg').textContent='Gespeichert und FileSystemWatcher neu geladen.';await loadWatchers();await loadStatus()}catch(e){$('wmsg').textContent='Fehler: '+e.message}}
async function deleteWatcher(){if(!$('wid').value||!confirm('Watcher wirklich löschen?'))return;await api('/api/watchers/delete?id='+encodeURIComponent($('wid').value),{method:'POST'});$('wid').value='';newWatcher();await loadWatchers();await loadStatus()}
async function reloadWatchers(){try{await api('/api/watchers/reload',{method:'POST'});$('wmsg').textContent='Alle FileSystemWatcher neu geladen.';await loadStatus()}catch(e){$('wmsg').textContent='Fehler: '+e.message}}
async function scanWatcher(){if(!$('wid').value){$('wmsg').textContent='Bitte zuerst speichern.';return}const r=await api('/api/watchers/scan?id='+encodeURIComponent($('wid').value),{method:'POST'});$('wmsg').textContent=r.started?'Verzeichnisverarbeitung gestartet.':'Watcher nicht gefunden.';setTimeout(loadLogs,500)}
async function loadCrons(){crons=await api('/api/cronjobs');renderCrons();if(crons.length&&!$('cid').value)editCron(crons[0].Id)}
function renderCrons(){$('cronList').innerHTML=crons.length?crons.map(j=>`<div class='item'><b>${esc(j.Name)}</b> <span class='${j.Enabled?'ok':'off'}'>${j.Enabled?'aktiv':'inaktiv'}</span><div class='muted'>${esc(j.Cron)} · ${esc(j.CustomFunction)}</div><button class='secondary' style='margin-top:6px' data-id='${esc(j.Id)}' onclick='editCron(this.dataset.id)'>Bearbeiten</button></div>`).join(''):'<div class=muted>Noch keine Cronjobs.</div>'}
function newCron(){fillCron({Id:'',Name:'Neuer Cronjob',Enabled:true,Cron:'*/5 * * * *',CallOnceWithoutFile:false,SourceDirectory:'',SearchPattern:'*.*',IncludeSubdirectories:false,RemoveEmptyLines:true,JoinSeparator:';',CustomFunction:'',InArgs:['{{CONTENT}}'],MoveFileAfterSuccess:false,MoveTargetDirectory:'',OverwriteTargetFile:false})}
function editCron(id){const j=crons.find(x=>x.Id===id);if(j)fillCron(j)}
function fillCron(j){$('cid').value=j.Id||'';$('cname').value=j.Name||'';$('ccron').value=j.Cron||'';$('cenabled').checked=!!j.Enabled;$('cwithoutfile').checked=!!j.CallOnceWithoutFile;$('csource').value=j.SourceDirectory||'';$('cpattern').value=j.SearchPattern||'*.*';$('crecursive').checked=!!j.IncludeSubdirectories;$('cremoveempty').checked=!!j.RemoveEmptyLines;$('cseparator').value=j.JoinSeparator==null?';':j.JoinSeparator;$('ccf').value=j.CustomFunction||'';$('cmove').checked=!!j.MoveFileAfterSuccess;$('ctarget').value=j.MoveTargetDirectory||'';$('coverwrite').checked=!!j.OverwriteTargetFile;$('cargs').innerHTML='';(j.InArgs||[]).forEach(addCronArg);$('cronTitle').textContent=j.Id?'Cronjob bearbeiten':'Neuer Cronjob'}
function cronModel(){return {Id:$('cid').value,Name:$('cname').value,Enabled:$('cenabled').checked,Cron:$('ccron').value,CallOnceWithoutFile:$('cwithoutfile').checked,SourceDirectory:$('csource').value,SearchPattern:$('cpattern').value,IncludeSubdirectories:$('crecursive').checked,RemoveEmptyLines:$('cremoveempty').checked,JoinSeparator:$('cseparator').value,CustomFunction:$('ccf').value,InArgs:[...$('cargs').querySelectorAll('input')].map(x=>x.value),MoveFileAfterSuccess:$('cmove').checked,MoveTargetDirectory:$('ctarget').value,OverwriteTargetFile:$('coverwrite').checked}}
async function saveCron(){try{const j=await api('/api/cronjobs/save',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(cronModel())});$('cid').value=j.Id;$('cmsg').textContent='Gespeichert.';await loadCrons()}catch(e){$('cmsg').textContent='Fehler: '+e.message}}
async function deleteCron(){if(!$('cid').value||!confirm('Cronjob wirklich löschen?'))return;await api('/api/cronjobs/delete?id='+encodeURIComponent($('cid').value),{method:'POST'});$('cid').value='';newCron();await loadCrons()}
async function runCron(){if(!$('cid').value){$('cmsg').textContent='Bitte zuerst speichern.';return}const r=await api('/api/cronjobs/run?id='+encodeURIComponent($('cid').value),{method:'POST'});$('cmsg').textContent=r.started?'Cronjob gestartet.':'Cronjob nicht gefunden.';setTimeout(loadLogs,500)}
async function loadStatus(){try{const s=await api('/api/status');$('status').textContent=`Aktive Watcher: ${s.activeWatcherIds.length} · Laufende Cronjobs: ${s.runningCronJobIds.length}`}catch(e){$('status').textContent='Statusfehler: '+e.message}}
async function loadLogs(){try{const l=await api('/api/logs?limit=400');$('log').textContent=l.map(x=>`${formatDate(x.Timestamp)} [${x.Level}] [${x.JobType||''}] ${x.JobName}: ${x.Message}`).join('\n')}catch(e){$('log').textContent='Logfehler: '+e.message}}
function formatDate(v){if(typeof v==='string')return v;return JSON.stringify(v)}
newWatcher();newCron();Promise.all([loadWatchers(),loadCrons(),loadStatus(),loadLogs()]);setInterval(()=>{loadStatus();loadLogs()},10000);
</script></body></html>";
}
}

View File

@@ -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
}
]

Binary file not shown.

View File

@@ -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.

View File

@@ -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
}
]

220
README.md Normal file
View File

@@ -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
<add key="Scheduler.WebUser" value="admin" />
<add key="Scheduler.WebPassword" value="admin" />
```
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
<add key="Scheduler.WebPrefix" value="http://+:8095/" />
```
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
<add key="Scheduler.ConfigFile" value="cronjobs.json" />
<add key="Watcher.ConfigFile" value="watcherjobs.json" />
```
## 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
<add key="IMS.AppID" value="FileInterfaceCronScheduler" />
<add key="IMS.ServerUrl" value="http://YOUR-IMS-SERVER:PORT" />
<add key="IMS.StationNumber" value="YOUR_STATION" />
<add key="IMS.ClientNumber" value="1" />
<add key="IMS.RegistrationType" value="S" />
<add key="IMS.PropertyDirectory" value="." />
<add key="IMS.SerializeCalls" value="false" />
```
`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.

View File

@@ -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

View File

@@ -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

View File

@@ -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