초기 커밋.
This commit is contained in:
7
.editorconfig
Normal file
7
.editorconfig
Normal 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
485
.gitignore
vendored
Normal 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
8
VTSFetcher/App.xaml
Normal 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
155
VTSFetcher/App.xaml.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
10
VTSFetcher/AssemblyInfo.cs
Normal file
10
VTSFetcher/AssemblyInfo.cs
Normal 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)
|
||||||
|
)]
|
||||||
42
VTSFetcher/Commands/RelayCommand.cs
Normal file
42
VTSFetcher/Commands/RelayCommand.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
141
VTSFetcher/Documents/VtsFetcherWorkflow.drawio
Normal file
141
VTSFetcher/Documents/VtsFetcherWorkflow.drawio
Normal 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>
|
||||||
16
VTSFetcher/Helpers/Schedule.cs
Normal file
16
VTSFetcher/Helpers/Schedule.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
29
VTSFetcher/Models/AnytypePage.cs
Normal file
29
VTSFetcher/Models/AnytypePage.cs
Normal 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; }
|
||||||
|
}
|
||||||
|
|
||||||
19
VTSFetcher/Models/AnytypePageResponse.cs
Normal file
19
VTSFetcher/Models/AnytypePageResponse.cs
Normal 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; }
|
||||||
|
}
|
||||||
32
VTSFetcher/Models/AnytypePropertyResponse.cs
Normal file
32
VTSFetcher/Models/AnytypePropertyResponse.cs
Normal 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; }
|
||||||
|
}
|
||||||
53
VTSFetcher/Models/AppSettings.cs
Normal file
53
VTSFetcher/Models/AppSettings.cs
Normal 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;
|
||||||
|
}
|
||||||
104
VTSFetcher/Models/VtsResponse.cs
Normal file
104
VTSFetcher/Models/VtsResponse.cs
Normal 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; }
|
||||||
|
}
|
||||||
|
|
||||||
18
VTSFetcher/Repositories/AnytpeVtsSqls.cs
Normal file
18
VTSFetcher/Repositories/AnytpeVtsSqls.cs
Normal 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;
|
||||||
|
";
|
||||||
|
}
|
||||||
27
VTSFetcher/Repositories/AnytypeVtsContext.cs
Normal file
27
VTSFetcher/Repositories/AnytypeVtsContext.cs
Normal 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}");
|
||||||
|
}
|
||||||
82
VTSFetcher/Repositories/AnytypeVtsRepository.cs
Normal file
82
VTSFetcher/Repositories/AnytypeVtsRepository.cs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
13
VTSFetcher/Repositories/Entities/Config.cs
Normal file
13
VTSFetcher/Repositories/Entities/Config.cs
Normal 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; }
|
||||||
|
}
|
||||||
20
VTSFetcher/Repositories/Entities/VtsIssue.cs
Normal file
20
VTSFetcher/Repositories/Entities/VtsIssue.cs
Normal 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; }
|
||||||
|
}
|
||||||
173
VTSFetcher/Services/AnytypeService.cs
Normal file
173
VTSFetcher/Services/AnytypeService.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
86
VTSFetcher/Services/SlackService.cs
Normal file
86
VTSFetcher/Services/SlackService.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
82
VTSFetcher/Services/VtsService.cs
Normal file
82
VTSFetcher/Services/VtsService.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
36
VTSFetcher/VTSFetcher.csproj
Normal file
36
VTSFetcher/VTSFetcher.csproj
Normal 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>
|
||||||
373
VTSFetcher/ViewModels/MainViewModel.cs
Normal file
373
VTSFetcher/ViewModels/MainViewModel.cs
Normal 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));
|
||||||
|
}
|
||||||
27
VTSFetcher/Views/MainWindow.xaml
Normal file
27
VTSFetcher/Views/MainWindow.xaml
Normal 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>
|
||||||
80
VTSFetcher/Views/MainWindow.xaml.cs
Normal file
80
VTSFetcher/Views/MainWindow.xaml.cs
Normal 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
6
VaLogger/VaLogger.cs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
namespace VaLogger;
|
||||||
|
|
||||||
|
public class VaLogger
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
9
VaLogger/VaLogger.csproj
Normal file
9
VaLogger/VaLogger.csproj
Normal 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
36
Vanadium.sln
Normal 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
|
||||||
Reference in New Issue
Block a user