초기 커밋.

This commit is contained in:
2025-12-17 17:03:50 +09:00
committed by seungmuk.oh
commit 4de4d36282
28 changed files with 2169 additions and 0 deletions

7
.editorconfig Normal file
View File

@@ -0,0 +1,7 @@
[*.cs]
# CS8602: null 가능 참조에 대한 역참조입니다.
dotnet_diagnostic.CS8602.severity = none
# CS8618: null을 허용하지 않는 필드는 생성자를 종료할 때 null이 아닌 값을 포함해야 합니다. 'required' 한정자를 추가하거나 nullable로 선언하는 것이 좋습니다.
dotnet_diagnostic.CS8618.severity = none

485
.gitignore vendored Normal file
View File

@@ -0,0 +1,485 @@
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
## Get latest from `dotnet new gitignore`
# dotenv files
.env
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Mono auto generated files
mono_crash.*
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Ll]og/
[Ll]ogs/
# Visual Studio 2015/2017 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# Visual Studio 2017 auto generated files
Generated\ Files/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# NUnit
*.VisualState.xml
TestResult.xml
nunit-*.xml
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# Benchmark Results
BenchmarkDotNet.Artifacts/
# .NET
project.lock.json
project.fragment.lock.json
artifacts/
# Tye
.tye/
# ASP.NET Scaffolding
ScaffoldingReadMe.txt
# StyleCop
StyleCopReport.xml
# Files built by Visual Studio
*_i.c
*_p.c
*_h.h
*.ilk
*.meta
*.obj
*.iobj
*.pch
*.pdb
*.ipdb
*.pgc
*.pgd
*.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*_wpftmp.csproj
*.log
*.tlog
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# Visual Studio Trace Files
*.e2e
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# AxoCover is a Code Coverage Tool
.axoCover/*
!.axoCover/settings.json
# Coverlet is a free, cross platform Code Coverage Tool
coverage*.json
coverage*.xml
coverage*.info
# Visual Studio code coverage results
*.coverage
*.coveragexml
# NCrunch
_NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/*.hhc
DocProject/Help/*.hhk
DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# Note: Comment the next line if you want to checkin your web deploy settings,
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these scripts will be unencrypted
PublishScripts/
# NuGet Packages
*.nupkg
# NuGet Symbol Packages
*.snupkg
# The packages folder can be ignored because of Package Restore
**/[Pp]ackages/*
# except build/, which is used as an MSBuild target.
!**/[Pp]ackages/build/
# Uncomment if necessary however generally it will be regenerated when needed
#!**/[Pp]ackages/repositories.config
# NuGet v3's project.json files produces more ignorable files
*.nuget.props
*.nuget.targets
# Microsoft Azure Build Output
csx/
*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
*.appx
*.appxbundle
*.appxupload
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!?*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
orleans.codegen.cs
# Including strong name files can present a security risk
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
#*.snk
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
ServiceFabricBackup/
*.rptproj.bak
# SQL Server files
*.mdf
*.ldf
*.ndf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
*.rptproj.rsuser
*- [Bb]ackup.rdl
*- [Bb]ackup ([0-9]).rdl
*- [Bb]ackup ([0-9][0-9]).rdl
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
node_modules/
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
*.vbw
# Visual Studio 6 auto-generated project file (contains which files were open etc.)
*.vbp
# Visual Studio 6 workspace and project file (working project files containing files to include in project)
*.dsw
*.dsp
# Visual Studio 6 technical files
*.ncb
*.aps
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
# Paket dependency manager
.paket/paket.exe
paket-files/
# FAKE - F# Make
.fake/
# CodeRush personal settings
.cr/personal
# Python Tools for Visual Studio (PTVS)
__pycache__/
*.pyc
# Cake - Uncomment if you are using it
# tools/**
# !tools/packages.config
# Tabs Studio
*.tss
# Telerik's JustMock configuration file
*.jmconfig
# BizTalk build output
*.btp.cs
*.btm.cs
*.odx.cs
*.xsd.cs
# OpenCover UI analysis results
OpenCover/
# Azure Stream Analytics local run output
ASALocalRun/
# MSBuild Binary and Structured Log
*.binlog
# NVidia Nsight GPU debugger configuration file
*.nvuser
# MFractors (Xamarin productivity tool) working folder
.mfractor/
# Local History for Visual Studio
.localhistory/
# Visual Studio History (VSHistory) files
.vshistory/
# BeatPulse healthcheck temp database
healthchecksdb
# Backup folder for Package Reference Convert tool in Visual Studio 2017
MigrationBackup/
# Ionide (cross platform F# VS Code tools) working folder
.ionide/
# Fody - auto-generated XML schema
FodyWeavers.xsd
# VS Code files for those working on multiple tools
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
*.code-workspace
# Local History for Visual Studio Code
.history/
# Windows Installer files from build outputs
*.cab
*.msi
*.msix
*.msm
*.msp
# JetBrains Rider
*.sln.iml
.idea/
##
## Visual studio for Mac
##
# globs
Makefile.in
*.userprefs
*.usertasks
config.make
config.status
aclocal.m4
install-sh
autom4te.cache/
*.tar.gz
tarballs/
test-results/
# Mac bundle stuff
*.dmg
*.app
# content below from: https://github.com/github/gitignore/blob/main/Global/macOS.gitignore
# General
.DS_Store
.AppleDouble
.LSOverride
# Icon must end with two \r
Icon
# Thumbnails
._*
# Files that might appear in the root of a volume
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
# Directories potentially created on remote AFP share
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk
# content below from: https://github.com/github/gitignore/blob/main/Global/Windows.gitignore
# Windows thumbnail cache files
Thumbs.db
ehthumbs.db
ehthumbs_vista.db
# Dump file
*.stackdump
# Folder config file
[Dd]esktop.ini
# Recycle Bin used on file shares
$RECYCLE.BIN/
# Windows Installer files
*.cab
*.msi
*.msix
*.msm
*.msp
# Windows shortcuts
*.lnk
# Vim temporary swap files
*.swp
*.bkp

8
VTSFetcher/App.xaml Normal file
View File

@@ -0,0 +1,8 @@
<Application x:Class="VTSFetcher.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:VTSFetcher">
<Application.Resources>
</Application.Resources>
</Application>

155
VTSFetcher/App.xaml.cs Normal file
View File

@@ -0,0 +1,155 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Serilog;
using Serilog.Formatting.Json;
using System.IO;
using System.IO.Pipes;
using System.Windows;
using VTSFetcher.Models;
using VTSFetcher.Repositories;
using VTSFetcher.Services;
using VTSFetcher.ViewModels;
using VTSFetcher.Views;
namespace VTSFetcher;
/// <summary>
/// Interaction logic for App.xaml
/// </summary>
public partial class App : System.Windows.Application
{
private const string MutexName = "VTSFetcher_SingleInstance";
private const string PipeName = "VTSFetcher_SingleInstance_Pipe";
private Mutex _mutex;
protected override void OnStartup(StartupEventArgs e)
{
bool createdNew;
_mutex = new Mutex(true, MutexName, out createdNew);
if(!createdNew)
{
// 이미 실행 중이면 파이프를 통해 기존 창 열기 요청
using (var client = new NamedPipeClientStream(".", PipeName, PipeDirection.Out))
{
try
{
client.Connect(500); // 0.5초 대기
using (var writer = new StreamWriter(client))
{
writer.WriteLine("SHOW");
writer.Flush();
}
}
catch
{
// 연결 실패 시 무시
}
}
Shutdown();
return;
}
// Start named pipe server for single instance communication
Task.Run(() => StartPipeServer());
base.OnStartup(e);
var config = new ConfigurationBuilder()
.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true)
.Build();
SetSerilog();
var services = new ServiceCollection();
services.Configure<AppSettings>(config);
services.AddSingleton<AnytypeService>();
services.AddSingleton<VtsService>();
services.AddSingleton<SlackService>();
services.AddDbContext<AnytypeVtsContext>();
services.AddSingleton<AnytypeVtsRepository>();
services.AddTransient<MainViewModel>();
services.AddTransient<MainWindow>();
var provider = services.BuildServiceProvider();
try
{
using (var scope = provider.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<AnytypeVtsContext>();
db.Database.EnsureCreated();
}
var mainWindow = provider.GetRequiredService<MainWindow>();
mainWindow.Show();
}
catch(Exception ex)
{
Log.Error("Unhandled exception occured during starting application: {error_message}", ex.Message);
}
Log.Information("Application started.");
}
protected override void OnExit(ExitEventArgs e)
{
Log.Information("Application ended.");
Log.CloseAndFlush();
base.OnExit(e);
}
private void StartPipeServer()
{
while (true)
{
try
{
using (var server = new NamedPipeServerStream(PipeName, PipeDirection.In))
{
server.WaitForConnection();
using (var reader = new StreamReader(server))
{
string command = reader.ReadLine();
if (command == "SHOW")
{
Dispatcher.Invoke(() =>
{
if (Current.MainWindow != null)
{
if (Current.MainWindow.WindowState == WindowState.Minimized)
Current.MainWindow.WindowState = WindowState.Normal;
Current.MainWindow.Show();
Current.MainWindow.Activate();
Current.MainWindow.Topmost = true; // 맨 위로 올림
Current.MainWindow.Topmost = false; // 다시 원래대로
}
});
}
}
}
}
catch
{
// 에러 발생 시 루프 재시작
}
}
}
private void SetSerilog()
{
var exePath = AppDomain.CurrentDomain.BaseDirectory;
var logDir = Path.Combine(exePath, "logs");
if (!Directory.Exists(logDir))
Directory.CreateDirectory(logDir);
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Debug()
.WriteTo.Console()
.WriteTo.File(
formatter: new JsonFormatter(renderMessage: true),
path: Path.Combine(logDir, "log-.log"),
rollingInterval: RollingInterval.Day,
retainedFileCountLimit: 30
)
.WriteTo.Seq("http://localhost:5341")
.CreateLogger();
}
}

View File

@@ -0,0 +1,10 @@
using System.Windows;
[assembly: ThemeInfo(
ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located
//(used if a resource is not found in the page,
// or application resource dictionaries)
ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located
//(used if a resource is not found in the page,
// app, or any theme specific resource dictionaries)
)]

View File

@@ -0,0 +1,42 @@
using System.Windows.Input;
namespace VTSFetcher.Commands;
/// <summary>
/// MVVM에서 버튼 클릭 등 UI 이벤트를 ViewModel 메서드로 중계하는 ICommand 구현
/// </summary>
public class RelayCommand : ICommand
{
private readonly Action<object?> _execute;
private readonly Func<object?, bool>? _canExecute;
/// <summary>
/// 생성자
/// </summary>
/// <param name="execute">실행할 Action</param>
/// <param name="canExecute">실행 가능 조건 (선택)</param>
public RelayCommand(Action<object?> execute, Func<object?, bool>? canExecute = null)
{
_execute = execute ?? throw new ArgumentNullException(nameof(execute));
_canExecute = canExecute;
}
/// <summary>
/// 명령 실행 가능 여부
/// </summary>
public bool CanExecute(object? parameter) => _canExecute?.Invoke(parameter) ?? true;
/// <summary>
/// 명령 실행
/// </summary>
public void Execute(object? parameter) => _execute(parameter);
/// <summary>
/// CanExecuteChanged 이벤트 (UI 갱신용)
/// </summary>
public event EventHandler? CanExecuteChanged
{
add => CommandManager.RequerySuggested += value;
remove => CommandManager.RequerySuggested -= value;
}
}

View File

@@ -0,0 +1,141 @@
<mxfile host="Electron" agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) draw.io/28.2.5 Chrome/138.0.7204.251 Electron/37.6.1 Safari/537.36" version="28.2.5">
<diagram name="페이지-1" id="QgJmRnvOjF1n6O1NZE9H">
<mxGraphModel dx="2235" dy="823" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="827" pageHeight="1169" math="0" shadow="0">
<root>
<mxCell id="0" />
<mxCell id="1" parent="0" />
<mxCell id="B76gYM8LnXhayGnT7YrE-24" value="Anytype" style="shape=cylinder3;whiteSpace=wrap;html=1;boundedLbl=1;backgroundOutline=1;size=15;" parent="1" vertex="1">
<mxGeometry x="120" y="1290" width="60" height="80" as="geometry" />
</mxCell>
<mxCell id="B76gYM8LnXhayGnT7YrE-25" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;dashed=1;" parent="1" source="B76gYM8LnXhayGnT7YrE-22" target="B76gYM8LnXhayGnT7YrE-24" edge="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="B76gYM8LnXhayGnT7YrE-39" value="VTS" style="shape=cylinder3;whiteSpace=wrap;html=1;boundedLbl=1;backgroundOutline=1;size=15;" parent="1" vertex="1">
<mxGeometry x="-330" y="635" width="60" height="80" as="geometry" />
</mxCell>
<mxCell id="B76gYM8LnXhayGnT7YrE-40" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;dashed=1;exitX=1;exitY=0.5;exitDx=0;exitDy=0;exitPerimeter=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;" parent="1" source="B76gYM8LnXhayGnT7YrE-39" target="B76gYM8LnXhayGnT7YrE-7" edge="1">
<mxGeometry relative="1" as="geometry">
<mxPoint x="-250" y="840" as="sourcePoint" />
<mxPoint x="-250" y="960" as="targetPoint" />
</mxGeometry>
</mxCell>
<mxCell id="B76gYM8LnXhayGnT7YrE-41" value="VTS Fetcher" style="swimlane;whiteSpace=wrap;html=1;" parent="1" vertex="1">
<mxGeometry x="-200" y="460" width="1280" height="780" as="geometry" />
</mxCell>
<mxCell id="B76gYM8LnXhayGnT7YrE-2" value="VTS Service" style="swimlane;whiteSpace=wrap;html=1;" parent="B76gYM8LnXhayGnT7YrE-41" vertex="1">
<mxGeometry x="70" y="80" width="590" height="250" as="geometry" />
</mxCell>
<mxCell id="B76gYM8LnXhayGnT7YrE-11" value="UpsertVtsIssues" style="rounded=1;whiteSpace=wrap;html=1;" parent="B76gYM8LnXhayGnT7YrE-2" vertex="1">
<mxGeometry x="230" y="105" width="120" height="60" as="geometry" />
</mxCell>
<mxCell id="B76gYM8LnXhayGnT7YrE-3" value="Anytype Service" style="swimlane;whiteSpace=wrap;html=1;" parent="B76gYM8LnXhayGnT7YrE-41" vertex="1">
<mxGeometry x="60" y="550" width="600" height="200" as="geometry" />
</mxCell>
<mxCell id="B76gYM8LnXhayGnT7YrE-23" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="B76gYM8LnXhayGnT7YrE-3" source="B76gYM8LnXhayGnT7YrE-19" target="B76gYM8LnXhayGnT7YrE-22" edge="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="B76gYM8LnXhayGnT7YrE-19" value="GetIssuesToPublish()" style="rounded=1;whiteSpace=wrap;html=1;" parent="B76gYM8LnXhayGnT7YrE-3" vertex="1">
<mxGeometry x="30" y="100" width="120" height="60" as="geometry" />
</mxCell>
<mxCell id="B76gYM8LnXhayGnT7YrE-27" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0;entryY=0.5;entryDx=0;entryDy=0;" parent="B76gYM8LnXhayGnT7YrE-3" source="B76gYM8LnXhayGnT7YrE-22" target="B76gYM8LnXhayGnT7YrE-26" edge="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="B76gYM8LnXhayGnT7YrE-22" value="PublishPageAsync" style="rounded=1;whiteSpace=wrap;html=1;" parent="B76gYM8LnXhayGnT7YrE-3" vertex="1">
<mxGeometry x="230" y="100" width="120" height="60" as="geometry" />
</mxCell>
<mxCell id="B76gYM8LnXhayGnT7YrE-26" value="UpsertVtsIssues" style="rounded=1;whiteSpace=wrap;html=1;" parent="B76gYM8LnXhayGnT7YrE-3" vertex="1">
<mxGeometry x="433.5" y="100" width="120" height="60" as="geometry" />
</mxCell>
<mxCell id="B76gYM8LnXhayGnT7YrE-4" value="Slack Service" style="swimlane;whiteSpace=wrap;html=1;" parent="B76gYM8LnXhayGnT7YrE-41" vertex="1">
<mxGeometry x="820" y="550" width="420" height="200" as="geometry" />
</mxCell>
<mxCell id="B76gYM8LnXhayGnT7YrE-38" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0;entryY=0.5;entryDx=0;entryDy=0;" parent="B76gYM8LnXhayGnT7YrE-4" source="B76gYM8LnXhayGnT7YrE-34" target="B76gYM8LnXhayGnT7YrE-37" edge="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="B76gYM8LnXhayGnT7YrE-34" value="TryDequeue" style="rounded=1;whiteSpace=wrap;html=1;" parent="B76gYM8LnXhayGnT7YrE-4" vertex="1">
<mxGeometry x="30" y="99" width="120" height="60" as="geometry" />
</mxCell>
<mxCell id="B76gYM8LnXhayGnT7YrE-37" value="SendSlackMessageAsync" style="rounded=1;whiteSpace=wrap;html=1;" parent="B76gYM8LnXhayGnT7YrE-4" vertex="1">
<mxGeometry x="207" y="99" width="183" height="60" as="geometry" />
</mxCell>
<mxCell id="B76gYM8LnXhayGnT7YrE-12" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="B76gYM8LnXhayGnT7YrE-41" source="B76gYM8LnXhayGnT7YrE-7" target="B76gYM8LnXhayGnT7YrE-11" edge="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="B76gYM8LnXhayGnT7YrE-7" value="FetchVtsIssues()" style="rounded=1;whiteSpace=wrap;html=1;" parent="B76gYM8LnXhayGnT7YrE-41" vertex="1">
<mxGeometry x="120" y="185" width="120" height="60" as="geometry" />
</mxCell>
<mxCell id="B76gYM8LnXhayGnT7YrE-13" value="UpdateLastFetchTime" style="rounded=1;whiteSpace=wrap;html=1;" parent="B76gYM8LnXhayGnT7YrE-41" vertex="1">
<mxGeometry x="490" y="185" width="150" height="60" as="geometry" />
</mxCell>
<mxCell id="B76gYM8LnXhayGnT7YrE-14" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="B76gYM8LnXhayGnT7YrE-41" source="B76gYM8LnXhayGnT7YrE-11" target="B76gYM8LnXhayGnT7YrE-13" edge="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="B76gYM8LnXhayGnT7YrE-15" value="DB" style="shape=cylinder3;whiteSpace=wrap;html=1;boundedLbl=1;backgroundOutline=1;size=15;" parent="B76gYM8LnXhayGnT7YrE-41" vertex="1">
<mxGeometry x="330" y="400" width="60" height="80" as="geometry" />
</mxCell>
<mxCell id="B76gYM8LnXhayGnT7YrE-16" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;dashed=1;" parent="B76gYM8LnXhayGnT7YrE-41" source="B76gYM8LnXhayGnT7YrE-11" target="B76gYM8LnXhayGnT7YrE-15" edge="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="B76gYM8LnXhayGnT7YrE-18" value="vtsIssues" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="B76gYM8LnXhayGnT7YrE-16" vertex="1" connectable="0">
<mxGeometry x="-0.4968" y="-1" relative="1" as="geometry">
<mxPoint as="offset" />
</mxGeometry>
</mxCell>
<mxCell id="B76gYM8LnXhayGnT7YrE-17" value="Config.LastFetchTimeKey" style="endArrow=classic;html=1;rounded=0;exitX=0.593;exitY=0.983;exitDx=0;exitDy=0;exitPerimeter=0;dashed=1;" parent="B76gYM8LnXhayGnT7YrE-41" source="B76gYM8LnXhayGnT7YrE-13" edge="1">
<mxGeometry x="-0.4616" y="17" width="50" height="50" relative="1" as="geometry">
<mxPoint x="450" y="370" as="sourcePoint" />
<mxPoint x="360" y="400" as="targetPoint" />
<mxPoint as="offset" />
</mxGeometry>
</mxCell>
<mxCell id="B76gYM8LnXhayGnT7YrE-20" value="" style="endArrow=classic;html=1;rounded=0;entryX=0.442;entryY=-0.05;entryDx=0;entryDy=0;entryPerimeter=0;exitX=0.4;exitY=1.025;exitDx=0;exitDy=0;exitPerimeter=0;dashed=1;" parent="B76gYM8LnXhayGnT7YrE-41" source="B76gYM8LnXhayGnT7YrE-15" target="B76gYM8LnXhayGnT7YrE-19" edge="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="330" y="540" as="sourcePoint" />
<mxPoint x="380" y="490" as="targetPoint" />
</mxGeometry>
</mxCell>
<mxCell id="B76gYM8LnXhayGnT7YrE-21" value="VtsIssues" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="B76gYM8LnXhayGnT7YrE-20" vertex="1" connectable="0">
<mxGeometry x="0.4927" y="2" relative="1" as="geometry">
<mxPoint as="offset" />
</mxGeometry>
</mxCell>
<mxCell id="B76gYM8LnXhayGnT7YrE-28" value="" style="endArrow=classic;html=1;rounded=0;entryX=0.5;entryY=1;entryDx=0;entryDy=0;entryPerimeter=0;exitX=0.563;exitY=0.05;exitDx=0;exitDy=0;exitPerimeter=0;dashed=1;" parent="B76gYM8LnXhayGnT7YrE-41" source="B76gYM8LnXhayGnT7YrE-26" target="B76gYM8LnXhayGnT7YrE-15" edge="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="711" y="500" as="sourcePoint" />
<mxPoint x="500" y="665" as="targetPoint" />
</mxGeometry>
</mxCell>
<mxCell id="B76gYM8LnXhayGnT7YrE-29" value="VtsIssues" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="B76gYM8LnXhayGnT7YrE-28" vertex="1" connectable="0">
<mxGeometry x="0.4927" y="2" relative="1" as="geometry">
<mxPoint x="110" y="95" as="offset" />
</mxGeometry>
</mxCell>
<mxCell id="B76gYM8LnXhayGnT7YrE-30" value="_publishedIssuesQueue" style="shape=cylinder3;whiteSpace=wrap;html=1;boundedLbl=1;backgroundOutline=1;size=15;" parent="B76gYM8LnXhayGnT7YrE-41" vertex="1">
<mxGeometry x="570" y="400" width="180" height="80" as="geometry" />
</mxCell>
<mxCell id="B76gYM8LnXhayGnT7YrE-31" value="" style="endArrow=classic;html=1;rounded=0;entryX=0.461;entryY=1.025;entryDx=0;entryDy=0;entryPerimeter=0;exitX=0.5;exitY=0;exitDx=0;exitDy=0;dashed=1;" parent="B76gYM8LnXhayGnT7YrE-41" source="B76gYM8LnXhayGnT7YrE-22" target="B76gYM8LnXhayGnT7YrE-30" edge="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="771" y="693" as="sourcePoint" />
<mxPoint x="570" y="520" as="targetPoint" />
</mxGeometry>
</mxCell>
<mxCell id="B76gYM8LnXhayGnT7YrE-33" value="VtsIssue" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="B76gYM8LnXhayGnT7YrE-31" vertex="1" connectable="0">
<mxGeometry x="-0.649" y="-2" relative="1" as="geometry">
<mxPoint as="offset" />
</mxGeometry>
</mxCell>
<mxCell id="B76gYM8LnXhayGnT7YrE-35" value="" style="endArrow=classic;html=1;rounded=0;entryX=0.5;entryY=0;entryDx=0;entryDy=0;exitX=0.5;exitY=1;exitDx=0;exitDy=0;dashed=1;exitPerimeter=0;" parent="B76gYM8LnXhayGnT7YrE-41" source="B76gYM8LnXhayGnT7YrE-30" target="B76gYM8LnXhayGnT7YrE-34" edge="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="720" y="668" as="sourcePoint" />
<mxPoint x="1023" y="500" as="targetPoint" />
</mxGeometry>
</mxCell>
<mxCell id="B76gYM8LnXhayGnT7YrE-36" value="VtsIssue" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="B76gYM8LnXhayGnT7YrE-35" vertex="1" connectable="0">
<mxGeometry x="-0.649" y="-2" relative="1" as="geometry">
<mxPoint x="177" y="109" as="offset" />
</mxGeometry>
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>

View File

@@ -0,0 +1,16 @@
namespace VTSFetcher.Helpers;
public static class Schedule
{
public static bool CheckWorkingTime()
{
var now = DateTime.Now;
if (now.DayOfWeek >= DayOfWeek.Monday &&
now.DayOfWeek <= DayOfWeek.Friday &&
now.Hour >= 8 && now.Hour < 19)
{
return true;
}
return false;
}
}

View File

@@ -0,0 +1,29 @@
using System.Text.Json.Serialization;
namespace VTSFetcher.Modelsl;
public class AnytypePage
{
[JsonPropertyName("name")]
public string Name { get; set; } = string.Empty;
[JsonPropertyName("type_key")]
public string TypeKey { get; } = "page";
[JsonPropertyName("template_id")]
public string TemplateId { get; set; } = string.Empty;
[JsonPropertyName("properties")]
public List<PageProperty> Properties { get; set; } = new();
}
public class PageProperty
{
[JsonPropertyName("key")]
public string Key { get; set; } = string.Empty;
[JsonPropertyName("url")]
public string Url { get; set; }
[JsonPropertyName("select")]
public string Select { get; set; }
[JsonPropertyName("text")]
public string Text { get; set; }
[JsonPropertyName("date")]
public string Date { get; set; }
}

View File

@@ -0,0 +1,19 @@
using System.Text.Json.Serialization;
namespace VTSFetcher.Models;
internal class AnytypePageResponse
{
[JsonPropertyName("object")]
public AnytypePageObject Object { get; set; }
}
public class AnytypePageObject
{
[JsonPropertyName("object")]
public string ObjectType { get; set; }
[JsonPropertyName("id")]
public string ObjectId { get; set; }
[JsonPropertyName("space_id")]
public string SpaceId { get; set; }
}

View File

@@ -0,0 +1,32 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
namespace VTSFetcher.Models;
public class AnytypePropertyResponse
{
public Property[] data { get; set; }
public Pagination pagination { get; set; }
}
public class Pagination
{
public int total { get; set; }
public int offset { get; set; }
public int limit { get; set; }
public bool has_more { get; set; }
}
public class Property
{
[JsonPropertyName("object")]
public string Object { get; set; }
public string Id { get; set; }
public string Key { get; set; }
public string Name { get; set; }
public string Format { get; set; }
}

View File

@@ -0,0 +1,53 @@
namespace VTSFetcher.Models;
public class AppSettings
{
public Logging Logging { get; set; } = new();
public VTS VTS { get; set; } = new();
public DB DB { get; set; } = new();
public Anytype Anytype { get; set; } = new();
public Slack Slack { get; set; } = new();
}
public class Logging
{
public Loglevel LogLevel { get; set; } = new();
}
public class Loglevel
{
public string Default { get; set; } = string.Empty;
public string MicrosoftHostingLifetime { get; set; } = string.Empty;
}
public class DB
{
public string Database { get; set; } = string.Empty;
}
public class VTS
{
public string BaseUrl { get; set; } = string.Empty ;
public string ApiBaseUrl { get; set; } = string.Empty;
public string AccessToken { get; set; } = string.Empty;
public List<string> TargetProjects { get; set; } = [];
public int FetchInterval { get; set; } = 60;
public string SearchFields { get; set; } = string.Empty;
}
public class Anytype
{
public string ApiBaseUrl { get; set; } = string.Empty;
public string Version { get; set; } = string.Empty;
public string ApiKey { get; set; } = string.Empty;
public string SpaceId { get; set; } = string.Empty;
public string TemplateId { get; set; } = string.Empty;
public string[] Properties { get; set; } = new string[0];
}
public class Slack
{
public string ApiBaseUrl { get; set; } = string.Empty;
public string BotUserOAuthToken { get; set; } = string.Empty;
public string ChannelId { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,104 @@
namespace VTSFetcher.Models;
public class VTSResponse
{
public string expand { get; set; }
public int startAt { get; set; }
public int maxResults { get; set; }
public int total { get; set; }
public Issue[] issues { get; set; }
}
public class Issue
{
public string expand { get; set; }
public string id { get; set; }
public string self { get; set; }
public string key { get; set; } // RYXMARUV1-NNNN
public Fields fields { get; set; }
}
public class Fields
{
public string summary { get; set; } // Title
public Assignee assignee { get; set; }
public Reporter reporter { get; set; }
public string updated { get; set; }
public Status status { get; set; }
public Issuetype issuetype { get; set; }
public DateTimeOffset UpdatedAt
{
get
{
// "2025-09-03T15:31:51.000+0900" → "2025-09-03T15:31:51.000+09:00"
var fixedValue = updated.Insert(updated.Length - 2, ":");
return DateTimeOffset.Parse(fixedValue);
}
}
public DateTime? duedate { get; set; }
public Issue parent { get; set; }
public string customfield_10808 { get; set; } // Epic key
}
public class Assignee
{
public string self { get; set; }
public string name { get; set; }
public string key { get; set; }
public string emailAddress { get; set; }
public Avatarurls avatarUrls { get; set; }
public string displayName { get; set; } // Korean name
public bool active { get; set; }
public string timeZone { get; set; }
}
public class Reporter
{
public string self { get; set; }
public string name { get; set; }
public string key { get; set; }
public string emailAddress { get; set; }
public Avatarurls avatarUrls { get; set; }
public string displayName { get; set; } // Korean name
public bool active { get; set; }
public string timeZone { get; set; }
}
public class Avatarurls
{
public string _48x48 { get; set; }
public string _24x24 { get; set; }
public string _16x16 { get; set; }
public string _32x32 { get; set; }
}
public class Status
{
public string self { get; set; }
public string description { get; set; }
public string iconUrl { get; set; }
public string name { get; set; } // Status: Open InProgress ...
public string id { get; set; }
public Statuscategory statusCategory { get; set; }
}
public class Statuscategory
{
public string self { get; set; }
public int id { get; set; }
public string key { get; set; }
public string colorName { get; set; }
public string name { get; set; }
}
public class Issuetype
{
public string self { get; set; }
public string id { get; set; }
public string description { get; set; }
public string iconUrl { get; set; }
public string name { get; set; } // Type
public bool subtask { get; set; }
public int avatarId { get; set; }
}

View File

@@ -0,0 +1,18 @@
namespace VTSFetcher.Repositories;
public partial class AnytypeVtsContext
{
public const string UpsertVtsIssues = @"
INSERT INTO vtsIssues (Key, Summary, Type, Status, Assignee, Due, Updated, Parent)
VALUES ($Key, $Summary, $Type, $Status, $Assignee, $Due, $Updated, $Parent)
ON CONFLICT(Id) DO
UPDATE SET
Summary = excluded.Summary,
Type = excluded.Type,
Status = excluded.Status,
Assignee = excluded.Assignee,
Due = excluded.Due,
Updated = excluded.Updated,
Parent = excluded.Parent;
";
}

View File

@@ -0,0 +1,27 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System.IO;
using VTSFetcher.Models;
using VTSFetcher.Repositories.Entities;
namespace VTSFetcher.Repositories;
public partial class AnytypeVtsContext : DbContext
{
private readonly AppSettings _appSettings;
public DbSet<VtsIssue> VtsIssues { get;set; }
public DbSet<Config> Configs { get; set; }
public string DbPath { get; }
public AnytypeVtsContext(IOptions<AppSettings> options)
{
_appSettings = options.Value;
var folder = Environment.SpecialFolder.LocalApplicationData;
var path = Environment.GetFolderPath(folder);
DbPath = Path.Join(path, _appSettings.DB.Database);
}
protected override void OnConfiguring(DbContextOptionsBuilder options)
=> options.UseSqlite($"Data Source={DbPath}");
}

View File

@@ -0,0 +1,82 @@
using EFCore.BulkExtensions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using VTSFetcher.Repositories.Entities;
namespace VTSFetcher.Repositories
{
public class AnytypeVtsRepository
{
private readonly IServiceScopeFactory _scopeFactory;
public AnytypeVtsRepository(IServiceScopeFactory scopeFactory)
{
_scopeFactory = scopeFactory;
}
#region Configs
public Dictionary<string, string> GetConfigs()
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AnytypeVtsContext>();
var result = db.Configs.ToDictionary(c => c.Key, c => c.Value);
return result;
}
public void UpdateLastFetchTime(DateTimeOffset dateTimeOffert)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AnytypeVtsContext>();
db.Configs
.Where(i => i.Key == Config.LastFetchTimeKey)
.ExecuteUpdate(s => s
.SetProperty(i => i.Value, dateTimeOffert.ToString("yyyy-MM-ddTHH:mm:sszzz"))
);
db.SaveChanges();
}
#endregion Configs
#region VtsIssues
public async Task<VtsIssue?> GetVtsIssueAsync(string key)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AnytypeVtsContext>();
var result = await db.VtsIssues.FirstOrDefaultAsync(i => i.Key == key);
return result;
}
public async Task<List<VtsIssue>> GetVtsIssues(List<string> keys)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AnytypeVtsContext>();
var result = await db.VtsIssues
.Where(i => keys.Contains(i.Key))
.ToListAsync();
return result;
}
public void UpsertVtsIssues(List<VtsIssue> vtsIssues, List<string> excludedColumns)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AnytypeVtsContext>();
// BulkInsertOrUpdateAsync does not need SaveChangesAsync
db.BulkInsertOrUpdate(vtsIssues, new BulkConfig
{
PropertiesToExclude = excludedColumns
});
}
public List<VtsIssue> GetIssuesToPublish(int count = 1)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AnytypeVtsContext>();
var result = db.VtsIssues
.AsEnumerable() // DB에서 먼저 가져오고, 이후 LINQ 연산은 메모리에서 수행
.Where(p => p.Published < p.Updated)
//.OrderBy(p => p.Published)
.Take(count)
.ToList();
return result;
}
#endregion VtsIsseus
}
}

View File

@@ -0,0 +1,13 @@
using System.ComponentModel.DataAnnotations;
namespace VTSFetcher.Repositories.Entities;
public class Config
{
public const string LastFetchTimeKey = "LAST_FETCH_TIME";
[Key]
public string Key { get; set; }
public string Value { get; set; }
}

View File

@@ -0,0 +1,20 @@
using System.ComponentModel.DataAnnotations;
namespace VTSFetcher.Repositories.Entities;
public class VtsIssue
{
[Key]
public string Key { get; set; }
public string Summary { get; set; }
public string? Parent { get; set; }
public string Type { get; set; }
public string Status { get; set; }
public string Assignee { get; set; }
public string Manager { get; set; }
public DateTimeOffset Due { get; set; }
public DateTimeOffset Updated { get; set; }
public DateTimeOffset Published { get; set; }
public string? ObjectId { get; set; }
public int NeedNotify { get; set; }
}

View File

@@ -0,0 +1,173 @@
using Microsoft.Extensions.Options;
using System.Net.Http;
using System.Text.Json;
using System.Text.Json.Serialization;
using VTSFetcher.Models;
using VTSFetcher.Modelsl;
using VTSFetcher.Repositories.Entities;
namespace VTSFetcher.Services;
public class AnytypeService
{
// Managed Property names
const string VtsKeyPropertyName = "VTS_Key";
const string VtsLinkPropertyName = "VTS_Link";
const string VtsTypePropertyName = "VTS_Type";
const string VtsStatusPropertyName = "VTS_Status";
const string VtsAssigneePropertyName = "VTS_Assignee";
const string VtsManagerPropertyName = "VTS_Manager";
const string VtsDueDatePropertyName = "VTS_Due";
private readonly AppSettings _appSettings;
private Dictionary<string, Property> _managedProperties = new();
private Dictionary<string, Property[]> _managedTags = new();
public AnytypeService(IOptions<AppSettings> options)
{
_appSettings = options.Value;
LoadProperties().Wait();
}
public async Task<VtsIssue> PublishPageAsync(VtsIssue issue)
{
List<PageProperty> pageProperties = new();
foreach (var propertyName in _managedProperties.Keys)
{
var property = _managedProperties[propertyName];
var pageProperty = GetPageProperty(property, issue);
if(pageProperty != null)
pageProperties.Add(pageProperty);
}
var page = new AnytypePage
{
Name = $"{issue.Key} {issue.Summary}",
TemplateId = _appSettings.Anytype.TemplateId,
Properties = pageProperties,
};
var options = new JsonSerializerOptions
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
PropertyNameCaseInsensitive = true
};
var jsingString = JsonSerializer.Serialize(page, options);
var content = new StringContent(jsingString);
using var client = new HttpClient();
HttpRequestMessage request;
if (string.IsNullOrEmpty(issue.ObjectId))
request = new HttpRequestMessage(
HttpMethod.Post,
$"{_appSettings.Anytype.ApiBaseUrl}/spaces/{_appSettings.Anytype.SpaceId}/objects"
);
else
request = new HttpRequestMessage(
HttpMethod.Patch,
$"{_appSettings.Anytype.ApiBaseUrl}/spaces/{_appSettings.Anytype.SpaceId}/objects/{issue.ObjectId}"
);
request.Headers.Add("Authorization", $"Bearer {_appSettings.Anytype.ApiKey}");
request.Headers.Add("Anytype-Version", $"{_appSettings.Anytype.Version}");
request.Content = content;
var response = await client.SendAsync(request).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var jsonResponse = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
var pageResponse = JsonSerializer.Deserialize<AnytypePageResponse>(jsonResponse, options);
issue.ObjectId = pageResponse.Object.ObjectId;
issue.Published = DateTimeOffset.Now;
return issue;
}
private async Task LoadProperties()
{
using var client = new HttpClient();
var uri = $"{_appSettings.Anytype.ApiBaseUrl}/spaces/{_appSettings.Anytype.SpaceId}/properties";
var request = new HttpRequestMessage(HttpMethod.Get, uri);
request.Headers.Add("Accept", "application/json");
request.Headers.Add("Authorization", $"Bearer {_appSettings.Anytype.ApiKey}");
request.Headers.Add("Anytype-Version", $"{_appSettings.Anytype.Version}");
var response = await client.SendAsync(request).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var jsonResponse = await response.Content.ReadAsStringAsync();
var jsonSerializerOptions = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
var anytypePropertyResponse = JsonSerializer.Deserialize<AnytypePropertyResponse>(jsonResponse, jsonSerializerOptions);
var managedPropertyNameSet = new HashSet<string>(_appSettings.Anytype.Properties, StringComparer.OrdinalIgnoreCase);
var matchedProperties = anytypePropertyResponse.data
.Where(d => managedPropertyNameSet.Contains(d.Name)).ToList();
foreach (var item in matchedProperties)
{
_managedProperties.Add(item.Name, item);
if (!string.Equals(item.Format, "select", StringComparison.OrdinalIgnoreCase))
continue;
var tags = await LoadTags(item.Id);
_managedTags.Add(item.Name, tags);
}
}
private async Task<Property[]> LoadTags(string propertyId)
{
using var client = new HttpClient();
var uri = $"{_appSettings.Anytype.ApiBaseUrl}"
+ $"/spaces/{_appSettings.Anytype.SpaceId}"
+ $"/properties/{propertyId}/tags";
var request = new HttpRequestMessage(HttpMethod.Get, uri);
request.Headers.Add("Accept", "application/json");
request.Headers.Add("Authorization", $"Bearer {_appSettings.Anytype.ApiKey}");
request.Headers.Add("Anytype-Version", $"{_appSettings.Anytype.Version}");
var response = await client.SendAsync(request);
response.EnsureSuccessStatusCode();
var jsonResponse = await response.Content.ReadAsStringAsync();
var jsonSerializerOptions = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
var anytypeTagsesponse = JsonSerializer.Deserialize<AnytypePropertyResponse>(jsonResponse, jsonSerializerOptions);
var result = anytypeTagsesponse.data;
return result;
}
private PageProperty? GetPageProperty(Property property, VtsIssue issue)
{
var pageProperty = new PageProperty();
pageProperty.Key = property.Key;
switch (property.Name)
{
case VtsKeyPropertyName:
pageProperty.Text = issue.Key;
break;
case VtsLinkPropertyName:
pageProperty.Url = $"{_appSettings.VTS.BaseUrl}/browse/{issue.Key}";
break;
case VtsTypePropertyName:
pageProperty.Select = GetTagId(property, issue.Type);
break;
case VtsStatusPropertyName:
pageProperty.Select = GetTagId(property, issue.Status);
break;
case VtsAssigneePropertyName:
pageProperty.Text = $"{issue.Assignee}";
break;
case VtsManagerPropertyName:
pageProperty.Text = $"{issue.Manager}";
break;
case VtsDueDatePropertyName:
if (issue.Due < new DateTime(2000, 1, 1, 0, 0, 0))
return null;
pageProperty.Date = $"{issue.Due.ToString("yyyy-MM-ddT23:59:59Z")}";
break;
default:
break;
}
return pageProperty;
}
private string GetTagId(Property property, string tagName)
{
var tags = _managedTags[property.Name];
var tag = tags.Where<Property>(t => t.Name == tagName).FirstOrDefault();
return tag.Id;
}
}

View File

@@ -0,0 +1,86 @@
using Microsoft.Extensions.Options;
using Serilog;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using VTSFetcher.Models;
using VTSFetcher.Repositories.Entities;
namespace VTSFetcher.Services
{
public class SlackService
{
private readonly AppSettings _appSettings;
public SlackService(IOptions<AppSettings> options)
{
_appSettings = options.Value;
}
public async Task SendSlackMessageAsync(List<VtsIssue> issues)
{
using var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _appSettings.Slack.BotUserOAuthToken);
var payload = CreateMessagePayload(issues);
try
{
var content = new StringContent(payload, Encoding.UTF8, "application/json");
var response = await client.PostAsync(_appSettings.Slack.ApiBaseUrl, content);
var result = response.EnsureSuccessStatusCode();
}
catch(Exception ex)
{
Log.Error("Failed to send message to slack. {error_message}", ex.Message);
}
finally
{
}
}
private string CreateMessagePayload(List<VtsIssue> issues)
{
var payload = @$"{{ ""channel"":""{ _appSettings.Slack.ChannelId }"", ";
payload += @" ""blocks"":
[
{
""type"": ""section"",
""text"": {
""type"": ""mrkdwn"",
""text"": ""@channel""
}
},
{
""type"": ""divider""
},";
foreach(var issue in issues)
{
payload += $@"
{{
""type"": ""section"",
""text"": {{
""type"": ""mrkdwn"",
""text"": "" *[{issue.Key}]*\n *<{_appSettings.VTS.BaseUrl}/browse/{issue.Key}|{issue.Summary}>*""
}},
""fields"": [
{{
""type"": ""mrkdwn"",
""text"": ""*Type:* `{issue.Type}`""
}},
{{
""type"": ""mrkdwn"",
""text"": ""*Status:* `{issue.Status}`""
}}
]
}},
{{
""type"": ""divider""
}},
";
}
payload += "]}";
return payload;
}
}
}

View File

@@ -0,0 +1,82 @@
using Microsoft.Extensions.Options;
using Serilog;
using System.Net.Http;
using System.Text.Json;
using VTSFetcher.Models;
using VTSFetcher.Repositories.Entities;
namespace VTSFetcher.Services;
public class VtsService
{
private readonly AppSettings _appSettings;
public VtsService(IOptions<AppSettings> options)
{
_appSettings = options.Value;
}
public async Task<VTSResponse> FetchVtsIssues(DateTimeOffset lastFetchTime, string targetProject,
int startAt, CancellationToken ct)
{
var uri = $"{_appSettings.VTS.ApiBaseUrl}/"
+ $"search?jql=project={targetProject} "
+ $"AND updated >= \"{lastFetchTime.ToString("yyyy-MM-dd HH:mm")}\" "
+ $"&fields={_appSettings.VTS.SearchFields}"
+ $"&startAt={startAt}&maxResults=50";
var request = new HttpRequestMessage(HttpMethod.Get, uri);
request.Headers.Add("Accept", "application/json");
request.Headers.Add("Authorization", $"Bearer {_appSettings.VTS.AccessToken}");
Log.Debug("Fetch VTS issues ready. Fetch the issues updated after {last_fetch_time} in Project {target_project}.",
lastFetchTime.ToString("yyyy-MM-dd HH:mm"), targetProject);
using var client = new HttpClient();
var response = await client.SendAsync(request, ct).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var jsonResponse = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
var vtsResponse = JsonSerializer.Deserialize<VTSResponse>(jsonResponse);
return vtsResponse;
}
public async Task<Issue> FetchVtsIssueAsync(string targetIssueKey, CancellationToken ct)
{
var uri = $"{_appSettings.VTS.ApiBaseUrl}/issue/{targetIssueKey}";
var request = new HttpRequestMessage(HttpMethod.Get, uri);
request.Headers.Add("Accept", "application/json");
request.Headers.Add("Authorization", $"Bearer {_appSettings.VTS.AccessToken}");
Log.Debug("Fetch a VTS issue({issue_key}) ready.", targetIssueKey);
using var client = new HttpClient();
var response = await client.SendAsync(request, ct).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var jsonResponse = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
var issue = JsonSerializer.Deserialize<Issue>(jsonResponse);
return issue;
}
private List<VtsIssue> JsonDeserializeToVtsIssues(string json)
{
var result = new List<VtsIssue>();
var jsonSerializerOptions = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
var vtsResponse = JsonSerializer.Deserialize<VTSResponse>(json, jsonSerializerOptions);
if (vtsResponse?.issues.Length > 0)
{
result = [.. vtsResponse.issues.Select(i => new VtsIssue
{
Key = i.key,
Summary = i.fields.summary,
Type = i.fields.issuetype.name,
Status = i.fields.status.name,
Assignee = i.fields.assignee.displayName,
Manager = i.fields.reporter.displayName,
Due = i.fields.duedate ?? new DateTime(1999, 12, 23, 23, 59, 59),
Updated = i.fields.UpdatedAt,
Parent = i.fields.parent?.key,
})];
}
return result;
}
}

View File

@@ -0,0 +1,36 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net9.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UseWPF>true</UseWPF>
<UseWindowsForms>true</UseWindowsForms>
<AssemblyVersion>1.0.0.1</AssemblyVersion>
</PropertyGroup>
<ItemGroup>
<Folder Include="Models\" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="EFCore.BulkExtensions" Version="9.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.8" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="9.0.9" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.8" />
<PackageReference Include="Serilog" Version="4.3.0" />
<PackageReference Include="Serilog.Extensions.Logging" Version="9.0.2" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
<PackageReference Include="Serilog.Sinks.Seq" Version="9.0.0" />
</ItemGroup>
<ItemGroup>
<None Update="appsettings.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,373 @@
using Microsoft.Extensions.Options;
using Serilog;
using System.Collections.Concurrent;
using System.ComponentModel;
using System.Diagnostics;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Runtime.Intrinsics.X86;
using System.Windows.Input;
using System.Windows.Threading;
using VTSFetcher.Commands;
using VTSFetcher.Models;
using VTSFetcher.Repositories;
using VTSFetcher.Repositories.Entities;
using VTSFetcher.Services;
namespace VTSFetcher.ViewModels;
public class MainViewModel : INotifyPropertyChanged
{
#region Fields
const string MyName = "오승묵";
const string StatusReopen = "Reopened";
private readonly Stopwatch _sw = new();
private readonly AppSettings _appSettings;
private readonly AnytypeVtsRepository _repo;
private readonly VtsService _vts;
private readonly AnytypeService _anytype;
private readonly SlackService _slack;
private readonly Dictionary<string, string> _configs;
private readonly DispatcherTimer _fetchTimer;
private readonly DispatcherTimer _publishTimer;
private readonly DispatcherTimer _slackTimer;
private readonly CancellationTokenSource _cts;
private ConcurrentQueue<VtsIssue> _publishedIssuesQueue;
private DateTimeOffset _lastFetchTimeOffset;
private string _lastFetchTime;
public string LastFetchTime
{
get => _lastFetchTime;
set
{
if (_lastFetchTime != value)
{
_lastFetchTime = value;
OnPropertyChanged(nameof(LastFetchTime));
}
}
}
private int _nextFetchInSeconds; // Seconds
private string _nextFetchIn; // "M:SS" format
public string NextFetchIn
{
get => _nextFetchIn;
set
{
if (_nextFetchIn != value)
{
_nextFetchIn = value;
OnPropertyChanged(nameof(NextFetchIn));
}
}
}
private string _targetIssueKey;
public string TargetIssueKey
{
get => _targetIssueKey;
set
{
if (_targetIssueKey != value)
{
_targetIssueKey = value;
OnPropertyChanged(nameof(TargetIssueKey));
}
}
}
private bool _isWorking = true;
public ICommand FetchNowCommand { get; }
#endregion Fields
public MainViewModel(IOptions<AppSettings> options, AnytypeVtsRepository repo,
VtsService vtsService, AnytypeService anytypeService, SlackService slack)
{
_appSettings = options.Value;
_repo = repo;
_vts = vtsService;
_anytype = anytypeService;
_slack = slack;
_configs = _repo.GetConfigs();
_lastFetchTimeOffset = DateTimeOffset.Parse(_configs[Config.LastFetchTimeKey]);
_nextFetchInSeconds = 5; // Initial fetch after 5 seconds
UpdateLabels();
_fetchTimer = new DispatcherTimer();
_fetchTimer.Interval = TimeSpan.FromSeconds(1);
_fetchTimer.Tick += FetchTimerTick;
_fetchTimer.Start();
_publishTimer = new DispatcherTimer();
_publishTimer.Interval = TimeSpan.FromSeconds(1);
_publishTimer.Tick += PublishTimerTick;
_publishTimer.Start();
_slackTimer = new DispatcherTimer();
_slackTimer.Interval = TimeSpan.FromSeconds(30);
_slackTimer.Tick += SlackTick;
_slackTimer.Start();
FetchNowCommand = new RelayCommand(_ => FetchNow());
_cts = new CancellationTokenSource();
_publishedIssuesQueue = new();
}
#region FetchEvents
private void FetchTimerTick(object? sender, EventArgs e)
{
var isScheduled = CheckSchedule();
if (_isWorking && !isScheduled)
{
_isWorking = false;
_nextFetchInSeconds = 1;
Log.Information("Stop working by schedue.");
}
else if (!_isWorking && isScheduled)
{
_isWorking = true;
_nextFetchInSeconds = 1;
Log.Information("Start working by schedue.");
}
else if(!_isWorking && !isScheduled)
{
return;
}
_nextFetchInSeconds--;
if (_nextFetchInSeconds <= 0)
{
Log.Information("Start fetching by timer...");
if(FetchVtsIssues())
{
_nextFetchInSeconds = _appSettings.VTS.FetchInterval;
}
}
UpdateLabels();
}
private bool CheckSchedule()
{
DateTime now = DateTime.Now;
bool isWeekday = now.DayOfWeek >= DayOfWeek.Monday && now.DayOfWeek <= DayOfWeek.Friday;
TimeSpan start = new TimeSpan(8, 00, 0); // 08:00:00
TimeSpan end = new TimeSpan(19, 00, 0); // 19:00:00
bool isWorkingHours = now.TimeOfDay >= start && now.TimeOfDay <= end;
return isWeekday && isWorkingHours;
}
/// <summary>
/// Fetch VTS issues and update Anytype pages.
/// LastFetchTime is updated inside this method.
/// </summary>
/// <returns></returns>
private bool FetchVtsIssues()
{
var now = DateTimeOffset.Now;
foreach(var project in _appSettings.VTS.TargetProjects)
{
try
{
List<VtsIssue> issues = [];
_sw.Restart();
var vtsResponses = new List<VTSResponse>();
VTSResponse vtsResponse;
vtsResponse = _vts.FetchVtsIssues(_lastFetchTimeOffset, project, 0, _cts.Token).GetAwaiter().GetResult();
vtsResponses.Add(vtsResponse);
var startAt = vtsResponse?.maxResults ?? 0 ;
var total = vtsResponse?.total ?? 0;
while(startAt < total)
{
vtsResponse = _vts.FetchVtsIssues(_lastFetchTimeOffset, project, startAt, _cts.Token).GetAwaiter().GetResult();
vtsResponses.Add(vtsResponse);
startAt += vtsResponse?.maxResults ?? 50;
}
issues = vtsResponses.SelectMany(r => r.issues.Select(i => new VtsIssue
{
Key = i.key,
Summary = i.fields.summary,
Type = i.fields.issuetype.name,
Status = i.fields.status.name,
Assignee = i.fields.assignee?.displayName ?? "",
Manager = i.fields.reporter?.displayName ?? "",
Due = i.fields.duedate ?? new DateTime(1999, 12, 23, 23, 59, 59),
Updated = i.fields.UpdatedAt,
Parent = i.fields.parent?.key,
})).ToList();
_sw.Stop();
Log.Information("Fetched {issue_count} issues from VTS in {operation_time}ms"
, issues.Count, _sw.ElapsedMilliseconds);
foreach(var i in issues)
{
i.NeedNotify = (IsNeedNotify(i)) ? 1 : 0;
Log.Debug("{issue_key}: {issue_summary} Updated at {issue_updated}", i.Key, i.Summary, i.Updated);
}
_repo.UpsertVtsIssues(issues, ["Published", "ObjectId"]);
_repo.UpdateLastFetchTime(now); // Update the timer after fetch
_lastFetchTimeOffset = now;
}
catch (Exception ex)
{
Log.Error("Unhandled exception occured during fetching VTS issues: {error_message}", ex.Message);
return false;
}
}
return true;
}
private bool FetchVtsIssue(string targetIssueKey)
{
try
{
_sw.Restart();
var response = _vts.FetchVtsIssueAsync(targetIssueKey, _cts.Token).GetAwaiter().GetResult();
var issue = new VtsIssue
{
Key = response.key,
Summary = response.fields.summary,
Type = response.fields.issuetype.name,
Status = response.fields.status.name,
Assignee = response.fields.assignee?.displayName ?? "",
Manager = response.fields.reporter?.displayName ?? "",
Due = response.fields.duedate ?? new DateTime(1999, 12, 23, 23, 59, 59),
Updated = response.fields.UpdatedAt,
Parent = response.fields.parent?.key,
};
issue.NeedNotify = (IsNeedNotify(issue)) ? 1 : 0;
_repo.UpsertVtsIssues(new List<VtsIssue> { issue }, ["Published", "ObjectId"]);
}
catch (Exception ex)
{
Log.Error("Unhandled exception occured during fetching VTS issue({issue_key}): {error_message}", targetIssueKey, ex.Message);
return false;
}
finally
{
_sw.Stop();
Log.Information("Fetched an issue({issue_key}) from VTS in {operation_time}ms"
, targetIssueKey, _sw.ElapsedMilliseconds);
}
return true;
}
private void FetchNow()
{
Log.Information("Start fetching by the button...");
if(!string.IsNullOrEmpty(_targetIssueKey))
{
var result = FetchVtsIssue(_targetIssueKey);
if (result)
TargetIssueKey = "";
return;
}
if (FetchVtsIssues())
{
_lastFetchTime = DateTimeOffset.Now.ToString();
_nextFetchInSeconds = _nextFetchInSeconds = _appSettings.VTS.FetchInterval;
UpdateLabels();
}
}
#endregion FetchEvents
#region PublishEvents
private void PublishTimerTick(object? sender, EventArgs e)
{
var issues = _repo.GetIssuesToPublish();
var issueKeys = issues.Select(i => i.Key).ToList();
var existingIssues = _repo.GetVtsIssues(issueKeys).GetAwaiter().GetResult();
var newIssues = issues.Where(i => !existingIssues.Any(e => e.Key == i.Key)).ToList();
issues = newIssues.Concat(existingIssues).ToList();
_sw.Restart();
foreach (var issue in issues)
{
try
{
var existingIssue = existingIssues.Where(i => issue.Key == i.Key).FirstOrDefault();
var publishedIssue = _anytype.PublishPageAsync(issue).GetAwaiter().GetResult();
_repo.UpsertVtsIssues(new List<VtsIssue> { publishedIssue }, []); // Since it will be performed one by one anyway, we will proceed with Upsert right here.
Log.Debug("Issue {issue_key} is {publishd_or_updated} to the page({page_link})",
publishedIssue.Key, (string.IsNullOrEmpty(existingIssue.ObjectId)) ? "published" : "updated",
$"https://object.any.coop/{publishedIssue.ObjectId}?spaceId={_appSettings.Anytype.SpaceId}");
}
catch (Exception ex)
{
Log.Error("Unhandled exception occured during publishing Anytype page({issue_key}): {error_message}",
issue.Key, ex.Message);
}
}
_sw.Stop();
}
#endregion PublishEvents
#region SlackEvents
private bool IsNeedNotify(VtsIssue issue)
{
var orgIssue = _repo.GetVtsIssueAsync(issue.Key).GetAwaiter().GetResult();
if (orgIssue == null) // A new issue assigned to me.
{
Log.Debug("{method_name} - A new issue({target_keys}) assigned to me.", nameof(IsNeedNotify), issue.Key, issue.Assignee);
if (issue.Assignee == MyName)
{
_publishedIssuesQueue.Enqueue(issue);
return true;
}
}
else
{
if (orgIssue.Assignee != MyName && issue.Assignee == MyName)
{
Log.Debug("{method_name} - Existing issue({target_key}) assigned to me.", nameof(IsNeedNotify), issue.Key);
_publishedIssuesQueue.Enqueue(issue);
return true;
}
else if(orgIssue.Status != StatusReopen && issue.Status == StatusReopen)
{
Log.Debug("{method_name} - My issue({target_key}) has been reopened.", nameof(IsNeedNotify), issue.Key);
_publishedIssuesQueue.Enqueue(issue);
return true;
}
}
return false;
}
private void SlackTick(object? sender, EventArgs e)
{
var updatedIssues = new List<VtsIssue>();
VtsIssue issue;
while(_publishedIssuesQueue.TryDequeue(out issue))
{
if (issue == null) break;
Log.Debug("{method_name} - Get published issue {issue_key}", nameof(SlackTick), issue);
updatedIssues.Add(issue);
}
if (updatedIssues.Count < 1) return;
_slack.SendSlackMessageAsync(updatedIssues);
}
#endregion SlackEvents
#region UIEvents
private void UpdateLabels()
{
UpdateNextFetchLabel();
UpdateLastFetchTimeLabel();
}
private void UpdateNextFetchLabel()
{
int minutes = _nextFetchInSeconds / 60;
int seconds = _nextFetchInSeconds % 60;
NextFetchIn = $"{minutes}:{seconds:D2}";
}
private void UpdateLastFetchTimeLabel()
{
LastFetchTime = _lastFetchTimeOffset.ToString("MM'/'dd HH:mm");
}
#endregion UIEvents
public event PropertyChangedEventHandler? PropertyChanged;
protected void OnPropertyChanged(string propertyName)
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}

View File

@@ -0,0 +1,27 @@
<Window x:Class="VTSFetcher.Views.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:VTSFetcher"
xmlns:vm="clr-namespace:VTSFetcher.ViewModels"
mc:Ignorable="d"
Title="VTS Fetcher" Height="150" Width="300" ResizeMode="NoResize">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="150"/>
<ColumnDefinition Width="150"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="*"/>
<RowDefinition Height="*"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<Label Content="Last Fetch Time: " FontSize="16" FontWeight="Bold" HorizontalAlignment="Right" VerticalAlignment="Center" Grid.Row="0" Grid.Column="0"/>
<Label Content="{Binding LastFetchTime}" FontSize="16" HorizontalAlignment="Left" VerticalAlignment="Center" Grid.Row="0" Grid.Column="1"/>
<Label Content="Update remains: " FontSize="16" FontWeight="Bold" HorizontalAlignment="Right" VerticalAlignment="Center" Grid.Row="1" Grid.Column="0"/>
<Label Content="{Binding NextFetchIn}" FontSize="16" HorizontalAlignment="Left" VerticalAlignment="Center" Grid.Row="1" Grid.Column="1"/>
<TextBox Text="{Binding TargetIssueKey}" FontSize="14" HorizontalAlignment="Right" VerticalAlignment="Center" Width="130" Height="25" Grid.Row="2" Grid.Column="0"/>
<Button Content="Fetch Now" Command="{Binding FetchNowCommand}" Width="100" Height="25" Grid.Row="2" Grid.Column="1"/>
</Grid>
</Window>

View File

@@ -0,0 +1,80 @@
using System.Windows;
using VTSFetcher.ViewModels;
namespace VTSFetcher.Views;
/// <summary>
/// Interaction logic for MainWindow.xaml
/// </summary>
public partial class MainWindow : Window
{
private NotifyIcon _notifyIcon;
private bool _exitFlag = false;
public MainWindow(MainViewModel mainViewModel)
{
InitializeComponent();
DataContext = mainViewModel;
// NotifyIcon 생성
_notifyIcon = new NotifyIcon();
_notifyIcon.Icon = new Icon(SystemIcons.Application, 40, 40);
_notifyIcon.Text = "VTS Fetcher";
_notifyIcon.Visible = false;
// 컨텍스트 메뉴 생성
var contextMenu = new ContextMenuStrip();
contextMenu.Items.Add("Open", null, (s, e) => ShowWindow());
contextMenu.Items.Add("Fetch", null, (s, e) => FetchNow());
contextMenu.Items.Add("Exit", null, (s, e) => ExitApp());
_notifyIcon.ContextMenuStrip = contextMenu;
// 더블클릭 시 창 복원
_notifyIcon.DoubleClick += (s, e) => ShowWindow();
}
private void ShowWindow()
{
this.Show();
this.WindowState = WindowState.Normal;
this.Activate();
_notifyIcon.Visible = false;
}
private void FetchNow()
{
if (DataContext is MainViewModel vm)
{
vm.FetchNowCommand.Execute(null);
}
}
private void ExitApp()
{
_exitFlag = true;
this.Close();
}
protected override void OnStateChanged(EventArgs e)
{
base.OnStateChanged(e);
if (this.WindowState == WindowState.Minimized)
{
this.Hide();
_notifyIcon.Visible = true;
}
}
protected override void OnClosing(System.ComponentModel.CancelEventArgs e)
{
if (!_exitFlag)
{
e.Cancel = true;
this.Hide();
_notifyIcon.Visible = true;
}
else
{
_notifyIcon.Dispose();
}
}
}

6
VaLogger/VaLogger.cs Normal file
View File

@@ -0,0 +1,6 @@
namespace VaLogger;
public class VaLogger
{
}

9
VaLogger/VaLogger.csproj Normal file
View File

@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

36
Vanadium.sln Normal file
View File

@@ -0,0 +1,36 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.14.36401.2
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VTSFetcher", "VTSFetcher\VTSFetcher.csproj", "{ECEF57A6-5A17-49D6-8E84-6B2479FFA630}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{C8D5274F-AC00-46C7-1F8D-E88E81087A52}"
ProjectSection(SolutionItems) = preProject
.editorconfig = .editorconfig
EndProjectSection
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VaLogger", "VaLogger\VaLogger.csproj", "{C9AFF5BA-A048-4C0A-A72B-1C21B39A9535}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{ECEF57A6-5A17-49D6-8E84-6B2479FFA630}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{ECEF57A6-5A17-49D6-8E84-6B2479FFA630}.Debug|Any CPU.Build.0 = Debug|Any CPU
{ECEF57A6-5A17-49D6-8E84-6B2479FFA630}.Release|Any CPU.ActiveCfg = Release|Any CPU
{ECEF57A6-5A17-49D6-8E84-6B2479FFA630}.Release|Any CPU.Build.0 = Release|Any CPU
{C9AFF5BA-A048-4C0A-A72B-1C21B39A9535}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C9AFF5BA-A048-4C0A-A72B-1C21B39A9535}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C9AFF5BA-A048-4C0A-A72B-1C21B39A9535}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C9AFF5BA-A048-4C0A-A72B-1C21B39A9535}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {EE08A8F6-E909-4BB6-8781-331B9A477E8B}
EndGlobalSection
EndGlobal