Compare commits
11 Commits
057481803f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f6bccc5434 | |||
| 2e2edc8dac | |||
| 5ec2f8119e | |||
| 4cb7797314 | |||
| 9b66273d20 | |||
| b904fa8b2d | |||
| 92a5195183 | |||
| cf0b6907f7 | |||
| 632f834ac5 | |||
| 9aaa4670ae | |||
| e17889748a |
@@ -17,4 +17,19 @@ public class JiraIssue
|
|||||||
public DateTimeOffset Published { get; set; }
|
public DateTimeOffset Published { get; set; }
|
||||||
public string? ObjectId { get; set; }
|
public string? ObjectId { get; set; }
|
||||||
public int NeedNotify { get; set; }
|
public int NeedNotify { get; set; }
|
||||||
|
|
||||||
|
public JiraIssue() { }
|
||||||
|
protected JiraIssue(JiraIssue issue)
|
||||||
|
{
|
||||||
|
this.Key = issue.Key;
|
||||||
|
this.Summary = issue.Summary;
|
||||||
|
this.Parent = issue.Parent;
|
||||||
|
this.Type = issue.Type;
|
||||||
|
this.Status = issue.Status;
|
||||||
|
this.Assignee = issue.Assignee;
|
||||||
|
this.Manager = issue.Manager;
|
||||||
|
this.Published = issue.Published;
|
||||||
|
this.ObjectId = issue.ObjectId;
|
||||||
|
this.NeedNotify = issue.NeedNotify;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -15,9 +15,9 @@ public class Trilium
|
|||||||
{
|
{
|
||||||
public string EtapiToken { get; set; } = string.Empty;
|
public string EtapiToken { get; set; } = string.Empty;
|
||||||
public string EtapiBaseUrl { get; set; } = "https://localhost/etapi";
|
public string EtapiBaseUrl { get; set; } = "https://localhost/etapi";
|
||||||
public string JiraOpenedIssuePageId = string.Empty;
|
public string JiraOpenedIssuePageId { get; set; } = string.Empty;
|
||||||
public string JiraWorkingIssuePageId = string.Empty;
|
public string JiraWorkingIssuePageId { get; set; } = string.Empty;
|
||||||
public string JiraResolvedIssuePageId = string.Empty;
|
public string JiraResolvedIssuePageId { get; set; } = string.Empty;
|
||||||
public string DividendPageId { get; set; } = String.Empty;
|
public string DividendPageId { get; set; } = String.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
29
Models/TriliumNote.cs
Normal file
29
Models/TriliumNote.cs
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using TriliumMind.Data.Entities;
|
||||||
|
|
||||||
|
namespace TriliumMind.Models;
|
||||||
|
|
||||||
|
public class TriliumNote : JiraIssue
|
||||||
|
{
|
||||||
|
public string ParentNoteId { get; set; } = string.Empty;
|
||||||
|
public TriliumResponse? TriliumNoteData { get; set; } = null;
|
||||||
|
|
||||||
|
public TriliumNote(JiraIssue issue, string parentNoteId) : base(issue)
|
||||||
|
{
|
||||||
|
this.ParentNoteId = parentNoteId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public StringContent ToNoteContent()
|
||||||
|
{
|
||||||
|
var noteData = new
|
||||||
|
{
|
||||||
|
parentNoteId = this.ParentNoteId,
|
||||||
|
title = $"{this.Key} {this.Summary}",
|
||||||
|
type = "text",
|
||||||
|
content = ""
|
||||||
|
};
|
||||||
|
|
||||||
|
var jsonContent = JsonSerializer.Serialize(noteData);
|
||||||
|
return new StringContent(jsonContent, null, "application/json");
|
||||||
|
}
|
||||||
|
}
|
||||||
48
Models/TriliumResponse.cs
Normal file
48
Models/TriliumResponse.cs
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
|
||||||
|
public class TriliumResponse
|
||||||
|
{
|
||||||
|
public Note note { get; set; }
|
||||||
|
public Branch branch { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class Note
|
||||||
|
{
|
||||||
|
public string noteId { get; set; }
|
||||||
|
public bool isProtected { get; set; }
|
||||||
|
public string title { get; set; }
|
||||||
|
public string type { get; set; }
|
||||||
|
public string mime { get; set; }
|
||||||
|
public string blobId { get; set; }
|
||||||
|
public string dateCreated { get; set; }
|
||||||
|
public string dateModified { get; set; }
|
||||||
|
public string utcDateCreated { get; set; }
|
||||||
|
public string utcDateModified { get; set; }
|
||||||
|
public string[] parentNoteIds { get; set; }
|
||||||
|
public object[] childNoteIds { get; set; }
|
||||||
|
public string[] parentBranchIds { get; set; }
|
||||||
|
public object[] childBranchIds { get; set; }
|
||||||
|
public Attribute[] attributes { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class Attribute
|
||||||
|
{
|
||||||
|
public string attributeId { get; set; }
|
||||||
|
public string noteId { get; set; }
|
||||||
|
public string type { get; set; }
|
||||||
|
public string name { get; set; }
|
||||||
|
public string value { get; set; }
|
||||||
|
public int position { get; set; }
|
||||||
|
public bool isInheritable { get; set; }
|
||||||
|
public string utcDateModified { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class Branch
|
||||||
|
{
|
||||||
|
public string branchId { get; set; }
|
||||||
|
public string noteId { get; set; }
|
||||||
|
public string parentNoteId { get; set; }
|
||||||
|
public object prefix { get; set; }
|
||||||
|
public int notePosition { get; set; }
|
||||||
|
public bool isExpanded { get; set; }
|
||||||
|
public string utcDateModified { get; set; }
|
||||||
|
}
|
||||||
16
Program.cs
16
Program.cs
@@ -58,6 +58,7 @@ namespace TriliumMind
|
|||||||
// Register Workers
|
// Register Workers
|
||||||
builder.Services.AddHostedService<JiraWorker>();
|
builder.Services.AddHostedService<JiraWorker>();
|
||||||
builder.Services.AddHostedService<TriliumWorker>();
|
builder.Services.AddHostedService<TriliumWorker>();
|
||||||
|
builder.Services.AddHostedService<AppDbWorker>();
|
||||||
|
|
||||||
// Register Channels
|
// Register Channels
|
||||||
builder.Services.AddSingleton(Channel.CreateUnbounded<Issue>());
|
builder.Services.AddSingleton(Channel.CreateUnbounded<Issue>());
|
||||||
@@ -69,12 +70,19 @@ namespace TriliumMind
|
|||||||
Log.Information("Starting database migration...");
|
Log.Information("Starting database migration...");
|
||||||
|
|
||||||
using var scope = host.Services.CreateScope();
|
using var scope = host.Services.CreateScope();
|
||||||
|
var dbContext = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
|
||||||
|
if(dbContext is DbContext context)
|
||||||
|
{
|
||||||
|
var created = await context.Database.EnsureCreatedAsync();
|
||||||
|
if(created)
|
||||||
|
Log.Information("Database created successfully");
|
||||||
|
else
|
||||||
|
Log.Information("Database already exists");
|
||||||
|
}
|
||||||
|
|
||||||
var db = scope.ServiceProvider.GetRequiredService<AppDbService>();
|
var dbService = scope.ServiceProvider.GetRequiredService<AppDbService>();
|
||||||
await db.InitializeDatabaseAsync();
|
|
||||||
|
|
||||||
var appCoinfigs = host.Services.GetRequiredService<AppConfigs>();
|
var appCoinfigs = host.Services.GetRequiredService<AppConfigs>();
|
||||||
appCoinfigs.RuntimeConfigs = await db.LoadConfigAsync();
|
appCoinfigs.RuntimeConfigs = await dbService.LoadConfigAsync();
|
||||||
|
|
||||||
Log.Information("Database initialization completed successfully");
|
Log.Information("Database initialization completed successfully");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,15 +31,6 @@ public class AppDbService
|
|||||||
{
|
{
|
||||||
_log.Information("Database already exists");
|
_log.Information("Database already exists");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Migration
|
|
||||||
var pendingMigrations = await _db.Database.GetPendingMigrationsAsync();
|
|
||||||
if (pendingMigrations.Any())
|
|
||||||
{
|
|
||||||
_log.Information("Applying pending migrations...");
|
|
||||||
await _db.Database.MigrateAsync();
|
|
||||||
_log.Information("Migrations applied successfully");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -48,6 +39,7 @@ public class AppDbService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#region Table: Configs
|
||||||
public async Task<Dictionary<string, string>> LoadConfigAsync()
|
public async Task<Dictionary<string, string>> LoadConfigAsync()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -100,6 +92,134 @@ public class AppDbService
|
|||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#endregion Table: Configs
|
||||||
|
|
||||||
|
#region Table: JiraIssues
|
||||||
|
public async Task<(bool isInserted, bool isUpdated)> UpsertJiraIssueAsync(JiraIssue issue, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var existing = await _db.JiraIssues.FindAsync([issue.Key], ct);
|
||||||
|
|
||||||
|
bool isInserted = false;
|
||||||
|
bool isUpdated = false;
|
||||||
|
|
||||||
|
if (existing == null)
|
||||||
|
{
|
||||||
|
_db.JiraIssues.Add(issue);
|
||||||
|
isInserted = true;
|
||||||
|
_log.Debug("Inserting new Jira issue: {key}", issue.Key);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (issue.Updated > existing.Updated)
|
||||||
|
{
|
||||||
|
existing.Summary = issue.Summary;
|
||||||
|
existing.Parent = issue.Parent;
|
||||||
|
existing.Type = issue.Type;
|
||||||
|
existing.Status = issue.Status;
|
||||||
|
existing.Assignee = issue.Assignee;
|
||||||
|
existing.Manager = issue.Manager;
|
||||||
|
existing.Due = issue.Due;
|
||||||
|
existing.Updated = issue.Updated;
|
||||||
|
existing.ObjectId = issue.ObjectId;
|
||||||
|
existing.NeedNotify = issue.NeedNotify;
|
||||||
|
|
||||||
|
_db.JiraIssues.Update(existing);
|
||||||
|
isUpdated = true;
|
||||||
|
_log.Debug("Updating existing Jira issue: {key}", issue.Key);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_log.Debug("Skipping Jira issue (not newer): {key}", issue.Key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
return (isInserted, isUpdated);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_log.Error(ex, "Failed to upsert Jira issue: {key}", issue.Key);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<(List<string> inserted, List<string> updated)> UpsertJiraIssuesBatchAsync(IEnumerable<JiraIssue> issues, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var issueList = issues.ToList();
|
||||||
|
if (!issueList.Any())
|
||||||
|
return (new List<string>(), new List<string>());
|
||||||
|
|
||||||
|
var keys = issueList.Select(i => i.Key).ToList();
|
||||||
|
var existingIssues = await _db.JiraIssues
|
||||||
|
.Where(ji => keys.Contains(ji.Key))
|
||||||
|
.ToDictionaryAsync(ji => ji.Key, ct);
|
||||||
|
|
||||||
|
var insertedKeys = new List<string>();
|
||||||
|
var updatedKeys = new List<string>();
|
||||||
|
int skipCount = 0;
|
||||||
|
|
||||||
|
foreach (var issue in issueList)
|
||||||
|
{
|
||||||
|
if (!existingIssues.TryGetValue(issue.Key, out var existing))
|
||||||
|
{
|
||||||
|
_db.JiraIssues.Add(issue);
|
||||||
|
insertedKeys.Add(issue.Key);
|
||||||
|
}
|
||||||
|
else if (issue.Updated > existing.Updated)
|
||||||
|
{
|
||||||
|
existing.Summary = issue.Summary;
|
||||||
|
existing.Parent = issue.Parent;
|
||||||
|
existing.Type = issue.Type;
|
||||||
|
existing.Status = issue.Status;
|
||||||
|
existing.Assignee = issue.Assignee;
|
||||||
|
existing.Manager = issue.Manager;
|
||||||
|
existing.Due = issue.Due;
|
||||||
|
existing.Updated = issue.Updated;
|
||||||
|
existing.ObjectId = issue.ObjectId;
|
||||||
|
existing.NeedNotify = issue.NeedNotify;
|
||||||
|
|
||||||
|
_db.JiraIssues.Update(existing);
|
||||||
|
updatedKeys.Add(issue.Key);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
skipCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
_log.Information("Batch completed: {insert} inserted, {update} updated, {skip} skipped out of {total} issues",
|
||||||
|
insertedKeys.Count, updatedKeys.Count, skipCount, issueList.Count);
|
||||||
|
|
||||||
|
return (insertedKeys, updatedKeys);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_log.Error(ex, "Failed to batch upsert Jira issues");
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<JiraIssue>> GetUnpublishedJiraIssuesAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var unpublishedIssues = await _db.JiraIssues
|
||||||
|
.Where(ji => ji.Published < ji.Updated)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
_log.Information("Found {count} unpublished Jira issues", unpublishedIssues.Count);
|
||||||
|
return unpublishedIssues;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_log.Error(ex, "Failed to get unpublished Jira issues");
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endregion Table: JiraIssues
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ public class JiraService
|
|||||||
+ $"AND updated >= \"{lastFetchTime.ToString("yyyy-MM-dd HH:mm")}\" "
|
+ $"AND updated >= \"{lastFetchTime.ToString("yyyy-MM-dd HH:mm")}\" "
|
||||||
+ $"&fields={_configs.AppSettings.Jira.SearchFields}"
|
+ $"&fields={_configs.AppSettings.Jira.SearchFields}"
|
||||||
+ $"&startAt={startAt}&maxResults=50";
|
+ $"&startAt={startAt}&maxResults=50";
|
||||||
|
Log.Verbose("Jira API Request URI: {uri}", uri);
|
||||||
var request = new HttpRequestMessage(HttpMethod.Get, uri);
|
var request = new HttpRequestMessage(HttpMethod.Get, uri);
|
||||||
request.Headers.Add("Accept", "application/json");
|
request.Headers.Add("Accept", "application/json");
|
||||||
request.Headers.Add("Authorization", $"Bearer {_configs.AppSettings.Jira.AccessToken}");
|
request.Headers.Add("Authorization", $"Bearer {_configs.AppSettings.Jira.AccessToken}");
|
||||||
|
|||||||
41
Services/Mappers/JiraIssueMapper.cs
Normal file
41
Services/Mappers/JiraIssueMapper.cs
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
using System;
|
||||||
|
using System.Reflection.Metadata;
|
||||||
|
using TriliumMind.Data.Entities;
|
||||||
|
using TriliumMind.Models;
|
||||||
|
|
||||||
|
namespace TriliumMind.Services.Mappers;
|
||||||
|
|
||||||
|
public static class JiraIssueMapper
|
||||||
|
{
|
||||||
|
private static readonly char[] _specialCharacters = ['(', '/'];
|
||||||
|
|
||||||
|
public static JiraIssue ToEntity(this Issue issue)
|
||||||
|
{
|
||||||
|
return new JiraIssue
|
||||||
|
{
|
||||||
|
Key = issue.key,
|
||||||
|
Summary = issue.fields.summary ?? string.Empty,
|
||||||
|
Parent = issue.fields.parent?.key ?? issue.fields.customfield_10808,
|
||||||
|
Type = issue.fields.issuetype?.name ?? string.Empty,
|
||||||
|
Status = issue.fields.status?.name ?? string.Empty,
|
||||||
|
Assignee = CleanDisplayName(issue.fields.assignee?.displayName),
|
||||||
|
Manager = CleanDisplayName(issue.fields.reporter?.displayName),
|
||||||
|
Due = issue.fields.duedate?.ToUniversalTime() ?? DateTimeOffset.MinValue.ToUniversalTime(),
|
||||||
|
Updated = issue.fields.UpdatedAt.ToUniversalTime(),
|
||||||
|
Published = DateTimeOffset.MinValue.ToUniversalTime(),
|
||||||
|
ObjectId = null,
|
||||||
|
NeedNotify = 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
private static string CleanDisplayName(string? displayName)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(displayName))
|
||||||
|
return string.Empty;
|
||||||
|
|
||||||
|
var specialCharIndex = displayName.IndexOfAny(_specialCharacters);
|
||||||
|
if (specialCharIndex >= 0)
|
||||||
|
displayName = displayName[..specialCharIndex];
|
||||||
|
|
||||||
|
return displayName.Trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ using System.Collections.Generic;
|
|||||||
using System.Net.Http.Headers;
|
using System.Net.Http.Headers;
|
||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
using TriliumMind.Data.Entities;
|
||||||
using TriliumMind.Models;
|
using TriliumMind.Models;
|
||||||
|
|
||||||
namespace TriliumMind.Services;
|
namespace TriliumMind.Services;
|
||||||
@@ -18,7 +19,73 @@ public class TriliumService
|
|||||||
_log = Log.ForContext<TriliumService>();
|
_log = Log.ForContext<TriliumService>();
|
||||||
_configs = configs;
|
_configs = configs;
|
||||||
}
|
}
|
||||||
public async Task<string> FettchPageContentsAsync(Issue issue)
|
|
||||||
|
public async Task<IEnumerable<JiraIssue>> PublishNotesAsync(IEnumerable<JiraIssue> jiraIssues)
|
||||||
|
{
|
||||||
|
var issuesToPatch = new List<JiraIssue>();
|
||||||
|
var issuesToPost = new List<JiraIssue>();
|
||||||
|
foreach (var jiraIssue in jiraIssues)
|
||||||
|
{
|
||||||
|
if(jiraIssue.Published > DateTimeOffset.MinValue)
|
||||||
|
issuesToPatch.Add(jiraIssue);
|
||||||
|
else
|
||||||
|
issuesToPost.Add(jiraIssue);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Post note does not support attribute, so additional work is required.
|
||||||
|
var postedNotes = await PostNotesAsync(issuesToPost);
|
||||||
|
//issuesToPatch.AddRange(postedIssues);
|
||||||
|
//var patchedIssues = await PatchNotesAsync(issuesToPatch);
|
||||||
|
|
||||||
|
return issuesToPatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IEnumerable<TriliumNote>> PostNotesAsync(IEnumerable<JiraIssue> jiraIssues)
|
||||||
|
{
|
||||||
|
var baseUrl = _configs.AppSettings.Trilium.EtapiBaseUrl;
|
||||||
|
var token = _configs.AppSettings.Trilium.EtapiToken;
|
||||||
|
var apiUrl = $"{baseUrl}/create-note";
|
||||||
|
|
||||||
|
var triliumNotes = new List<TriliumNote>();
|
||||||
|
var lastRequestTime = DateTimeOffset.MinValue;
|
||||||
|
foreach (var jiraIssue in jiraIssues)
|
||||||
|
{
|
||||||
|
var timeSinceLastRequest = DateTimeOffset.UtcNow - lastRequestTime;
|
||||||
|
if (timeSinceLastRequest < TimeSpan.FromSeconds(1))
|
||||||
|
{
|
||||||
|
var delayTime = TimeSpan.FromSeconds(1) - timeSinceLastRequest;
|
||||||
|
await Task.Delay(delayTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
var note = new TriliumNote(jiraIssue, GetParentPageId(jiraIssue.Status));
|
||||||
|
var request = new HttpRequestMessage(HttpMethod.Post, apiUrl);
|
||||||
|
request.Headers.TryAddWithoutValidation("Authorization", token);
|
||||||
|
|
||||||
|
request.Content = note.ToNoteContent();
|
||||||
|
using var client = new HttpClient();
|
||||||
|
var response = await client.SendAsync(request);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
// Deserialize to TriliumResponse
|
||||||
|
var triliumResponse = await response.Content.ReadFromJsonAsync<TriliumResponse>();
|
||||||
|
lastRequestTime = DateTimeOffset.UtcNow;
|
||||||
|
note.ObjectId = triliumResponse?.note.noteId;
|
||||||
|
note.Published = lastRequestTime;
|
||||||
|
note.TriliumNoteData = triliumResponse;
|
||||||
|
triliumNotes.Add(note);
|
||||||
|
}
|
||||||
|
|
||||||
|
return triliumNotes;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<IEnumerable<JiraIssue>> PatchNotesAsync(IEnumerable<JiraIssue> jiraIssues)
|
||||||
|
{
|
||||||
|
throw new NotImplementedException();
|
||||||
|
// Update attributes
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string> FetchPageContentsAsync(Issue issue)
|
||||||
{
|
{
|
||||||
var baseUrl = _configs.AppSettings.Trilium.EtapiBaseUrl;
|
var baseUrl = _configs.AppSettings.Trilium.EtapiBaseUrl;
|
||||||
var pageId = "QxxFqCNAtIOy";
|
var pageId = "QxxFqCNAtIOy";
|
||||||
@@ -34,4 +101,26 @@ public class TriliumService
|
|||||||
// Return new page ID after creating a new page
|
// Return new page ID after creating a new page
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private string GetParentPageId(string issueStatus)
|
||||||
|
{
|
||||||
|
var readyPageId = _configs.AppSettings.Trilium.JiraOpenedIssuePageId;
|
||||||
|
var workingPageId = _configs.AppSettings.Trilium.JiraWorkingIssuePageId;
|
||||||
|
var resolvedPageId = _configs.AppSettings.Trilium.JiraResolvedIssuePageId;
|
||||||
|
string pageId = string.Empty;
|
||||||
|
if (issueStatus.Equals("Open", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| issueStatus.Equals("Reopend", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
pageId = readyPageId;
|
||||||
|
}
|
||||||
|
else if (issueStatus.Equals("In progress", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
pageId = workingPageId;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
pageId = resolvedPageId;
|
||||||
|
}
|
||||||
|
return pageId;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,13 @@
|
|||||||
<UserSecretsId>dotnet-TriliumMind-bf69239d-751f-426d-afd4-3f66bcf0dc42</UserSecretsId>
|
<UserSecretsId>dotnet-TriliumMind-bf69239d-751f-426d-afd4-3f66bcf0dc42</UserSecretsId>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Compile Remove="publish\**" />
|
||||||
|
<Content Remove="publish\**" />
|
||||||
|
<EmbeddedResource Remove="publish\**" />
|
||||||
|
<None Remove="publish\**" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.0" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.0" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.0" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.0" />
|
||||||
|
|||||||
113
Workers/AppDbWorker.cs
Normal file
113
Workers/AppDbWorker.cs
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
using Serilog;
|
||||||
|
using System.Threading.Channels;
|
||||||
|
using TriliumMind.Models;
|
||||||
|
using TriliumMind.Services;
|
||||||
|
using TriliumMind.Services.Mappers;
|
||||||
|
|
||||||
|
namespace TriliumMind.Workers;
|
||||||
|
|
||||||
|
public class AppDbWorker : BackgroundService
|
||||||
|
{
|
||||||
|
private readonly Serilog.ILogger _log;
|
||||||
|
private readonly AppConfigs _configs;
|
||||||
|
private readonly IServiceScopeFactory _scopeFactory; // Singleton services cannot directly inject Scoped services.
|
||||||
|
private readonly Channel<Issue> _issueChannel;
|
||||||
|
|
||||||
|
private readonly int _batchSize;
|
||||||
|
private readonly TimeSpan _batchTimeout;
|
||||||
|
|
||||||
|
public AppDbWorker(AppConfigs configs, IServiceScopeFactory scopeFactory, Channel<Issue> issueChannel)
|
||||||
|
{
|
||||||
|
_log = Log.ForContext<AppDbWorker>();
|
||||||
|
_configs = configs;
|
||||||
|
_scopeFactory = scopeFactory;
|
||||||
|
_issueChannel = issueChannel;
|
||||||
|
|
||||||
|
_batchSize = 50;
|
||||||
|
_batchTimeout = TimeSpan.FromSeconds(5);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
_log.Information("AppDbWorker started");
|
||||||
|
|
||||||
|
var batch = new List<Issue>();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);
|
||||||
|
timeoutCts.CancelAfter(_batchTimeout);
|
||||||
|
|
||||||
|
while (batch.Count < _batchSize && !stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_issueChannel.Reader.TryRead(out var issue))
|
||||||
|
{
|
||||||
|
batch.Add(issue);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Wait if channel is empty
|
||||||
|
var readTask = _issueChannel.Reader.WaitToReadAsync(timeoutCts.Token);
|
||||||
|
if (await readTask.ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
if (_issueChannel.Reader.TryRead(out issue))
|
||||||
|
{
|
||||||
|
batch.Add(issue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
break; // Channel is closed.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested && !stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
break; // Timeout
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (batch.Count > 0)
|
||||||
|
{
|
||||||
|
using var scope = _scopeFactory.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbService>();
|
||||||
|
var jiraIssues = batch.Select(i => i.ToEntity()).ToList();
|
||||||
|
await db.UpsertJiraIssuesBatchAsync(jiraIssues, stoppingToken);
|
||||||
|
_log.Information("Processed batch of {count} Jira issues", batch.Count);
|
||||||
|
batch.Clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_log.Error(ex, "Error processing batch");
|
||||||
|
batch.Clear();
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(1), stoppingToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process any remaining issues in the batch
|
||||||
|
if (batch.Count > 0)
|
||||||
|
{
|
||||||
|
using var scope = _scopeFactory.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbService>();
|
||||||
|
var jiraIssues = batch.Select(i => i.ToEntity()).ToList();
|
||||||
|
await db.UpsertJiraIssuesBatchAsync(jiraIssues, CancellationToken.None);
|
||||||
|
_log.Information("Processed final batch of {count} Jira issues", batch.Count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_log.Error(ex, "Fatal error in JiraDbWorker");
|
||||||
|
}
|
||||||
|
_log.Information("AppDbWorker stopped");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,13 +11,16 @@ public class JiraWorker : BackgroundService
|
|||||||
{
|
{
|
||||||
private readonly Serilog.ILogger _log;
|
private readonly Serilog.ILogger _log;
|
||||||
private readonly AppConfigs _configs;
|
private readonly AppConfigs _configs;
|
||||||
|
private readonly IServiceScopeFactory _scopeFactory; // Singleton services cannot directly inject Scoped services.
|
||||||
private readonly JiraService _jira;
|
private readonly JiraService _jira;
|
||||||
private readonly Channel<Issue> _issueChannel;
|
private readonly Channel<Issue> _issueChannel;
|
||||||
|
|
||||||
public JiraWorker(AppConfigs configs, JiraService jaraService, Channel<Issue> issueChannel)
|
public JiraWorker(AppConfigs configs, IServiceScopeFactory serviceScopeFactory,
|
||||||
|
JiraService jaraService, Channel<Issue> issueChannel)
|
||||||
{
|
{
|
||||||
_log = Log.ForContext<JiraWorker>(); ;
|
_log = Log.ForContext<JiraWorker>(); ;
|
||||||
_configs = configs;
|
_configs = configs;
|
||||||
|
_scopeFactory = serviceScopeFactory;
|
||||||
_jira = jaraService;
|
_jira = jaraService;
|
||||||
_issueChannel = issueChannel;
|
_issueChannel = issueChannel;
|
||||||
}
|
}
|
||||||
@@ -28,15 +31,27 @@ public class JiraWorker : BackgroundService
|
|||||||
{
|
{
|
||||||
_log.Debug("Worker running at: {time}", DateTimeOffset.Now);
|
_log.Debug("Worker running at: {time}", DateTimeOffset.Now);
|
||||||
|
|
||||||
var lastFetchTime = DateTimeOffset.Parse(_configs.RuntimeConfigs[Consts.ConfigLastFetchTimeKey]);
|
var configLastFetchTime = _configs.RuntimeConfigs.GetValueOrDefault(Consts.ConfigLastFetchTimeKey);
|
||||||
|
if (string.IsNullOrEmpty(configLastFetchTime))
|
||||||
|
{
|
||||||
|
using (var scope = _scopeFactory.CreateScope())
|
||||||
|
{
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbService>();
|
||||||
|
var lastFetchTimeText = await db.GetConfigAsync(Consts.ConfigLastFetchTimeKey);
|
||||||
|
configLastFetchTime = (lastFetchTimeText != null)
|
||||||
|
? lastFetchTimeText : DateTimeOffset.Now.AddDays(-1).ToString("yyyy-MM-ddTHH:mm:sszzz");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var lastFetchTime = DateTimeOffset.Parse(configLastFetchTime);
|
||||||
|
|
||||||
var issueList = new List<Issue>();
|
var issueList = new List<Issue>();
|
||||||
|
var currentFetchTime = DateTimeOffset.Now;
|
||||||
foreach (var targetProject in _configs.AppSettings.Jira.TargetProjects)
|
foreach (var targetProject in _configs.AppSettings.Jira.TargetProjects)
|
||||||
{
|
{
|
||||||
int startAt = 0, total = 0;
|
int startAt = 0, total = 0;
|
||||||
do
|
do
|
||||||
{
|
{
|
||||||
var response = await _jira.FetchJiraIssuesAsync(DateTimeOffset.Now.AddDays(-1), targetProject, startAt, stoppingToken);
|
var response = await _jira.FetchJiraIssuesAsync(lastFetchTime, targetProject, startAt, stoppingToken);
|
||||||
if (response != null)
|
if (response != null)
|
||||||
{
|
{
|
||||||
total = response.total;
|
total = response.total;
|
||||||
@@ -49,6 +64,14 @@ public class JiraWorker : BackgroundService
|
|||||||
{
|
{
|
||||||
await _issueChannel.Writer.WriteAsync(item, stoppingToken);
|
await _issueChannel.Writer.WriteAsync(item, stoppingToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
using (var scope = _scopeFactory.CreateScope())
|
||||||
|
{
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbService>();
|
||||||
|
// Update last fetch time
|
||||||
|
await db.SetConfigAsync(Consts.ConfigLastFetchTimeKey, currentFetchTime.ToString("yyyy-MM-ddTHH:mm:sszzz"));
|
||||||
|
_configs.RuntimeConfigs[Consts.ConfigLastFetchTimeKey] = currentFetchTime.ToString("yyyy-MM-ddTHH:mm:sszzz");
|
||||||
|
}
|
||||||
await Task.Delay(_configs.AppSettings.Jira.FetchInterval * 1000, stoppingToken);
|
await Task.Delay(_configs.AppSettings.Jira.FetchInterval * 1000, stoppingToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
using System.Threading.Channels;
|
using System.Threading.Channels;
|
||||||
|
using TriliumMind.Data.Entities;
|
||||||
using TriliumMind.Models;
|
using TriliumMind.Models;
|
||||||
using TriliumMind.Services;
|
using TriliumMind.Services;
|
||||||
|
|
||||||
@@ -10,24 +11,33 @@ public class TriliumWorker : BackgroundService
|
|||||||
{
|
{
|
||||||
private readonly Serilog.ILogger _log;
|
private readonly Serilog.ILogger _log;
|
||||||
private readonly AppConfigs _config;
|
private readonly AppConfigs _config;
|
||||||
private readonly TriliumService _triliumService;
|
private readonly IServiceScopeFactory _scopeFactory; // Singleton services cannot directly inject Scoped services.
|
||||||
|
private readonly TriliumService _trilium;
|
||||||
private readonly Channel<Issue> _issueChannel;
|
private readonly Channel<Issue> _issueChannel;
|
||||||
|
|
||||||
public TriliumWorker(AppConfigs configs, TriliumService triliumService, Channel<Issue> issueChannel)
|
public TriliumWorker(AppConfigs configs, IServiceScopeFactory serviceScopeFactory,
|
||||||
|
TriliumService triliumService, Channel<Issue> issueChannel)
|
||||||
{
|
{
|
||||||
_log = Log.ForContext<TriliumWorker>();
|
_log = Log.ForContext<TriliumWorker>();
|
||||||
_config = configs;
|
_config = configs;
|
||||||
_triliumService = triliumService;
|
_scopeFactory = serviceScopeFactory;
|
||||||
|
_trilium = triliumService;
|
||||||
_issueChannel = issueChannel;
|
_issueChannel = issueChannel;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
{
|
{
|
||||||
await foreach(var item in _issueChannel.Reader.ReadAllAsync(stoppingToken))
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
_log.Information("Processing issue {issue_key} - {issue_summary}", item.key, item.fields.summary);
|
_log.Debug("Worker running at: {time}", DateTimeOffset.Now);
|
||||||
// Add your processing logic here
|
|
||||||
//await _triliumService.FettchPageContentsAsync(item);
|
using var scope = _scopeFactory.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbService>();
|
||||||
|
var unpublishedIssues = await db.GetUnpublishedJiraIssuesAsync(stoppingToken);
|
||||||
|
if (unpublishedIssues != null)
|
||||||
|
await _trilium.PublishNotesAsync(unpublishedIssues);
|
||||||
|
|
||||||
|
await Task.Delay(10 *1000, stoppingToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user