초기 커밋.
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