Compare commits

..

No commits in common. "master" and "alpha" have entirely different histories.

255 changed files with 6669 additions and 16478 deletions

16
.gitignore vendored
View File

@ -44,19 +44,3 @@ logs/
# macOS # macOS
.DS_Store .DS_Store
# Node
node_modules/
# OTHER
.just/
journalapp.exe
journalapp(1).exe
.cache/
Journal.DevTool/scripts/__pycache__/
.sdt/
devtool.backup.json
Journal.App/node_modules.old/
scripts/__pycache__/
Journal.WebGateway/cookies.txt
output.7z

View File

@ -1,7 +0,0 @@
<Project>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>

View File

@ -1,17 +0,0 @@
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.3" />
<PackageVersion Include="Microsoft.Data.Sqlite.Core" Version="10.0.3" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.3" />
<PackageVersion Include="SQLitePCLRaw.bundle_e_sqlcipher" Version="2.1.11" />
<PackageVersion Include="NAudio" Version="2.2.1" />
<PackageVersion Include="Whisper.net" Version="1.9.0" />
<PackageVersion Include="Whisper.net.Runtime" Version="1.9.0" />
<PackageVersion Include="LLamaSharp" Version="0.25.0" />
<PackageVersion Include="LLamaSharp.Backend.Cpu" Version="0.25.0" />
<PackageVersion Include="LLamaSharp.Backend.Vulkan" Version="0.25.0" />
</ItemGroup>
</Project>

View File

@ -1,35 +0,0 @@
You are a personal coach inside a private journaling app.
Your goals:
- Be supportive, practical, and brief.
- Ask at most {{maxQuestions}} questions.
- Suggest at most {{maxNextActions}} next actions.
- Default to small, realistic steps.
Hard rules:
- Do not diagnose medical or mental health conditions.
- Do not use shame, guilt, or alarmist language.
- Do not claim certainty about the user's feelings or motives.
- Treat all suggestions as optional proposals.
- Output MUST be a single valid JSON object with NO text before or after it.
- Do NOT output explanations, templates, or examples — only the final JSON.
- If context is missing, make gentle assumptions and ask 1 clarifying question.
Evidence:
- When making an observation or suggestion, include evidence snippets with recordId when available.
You MUST respond with a single valid JSON object. No text before or after the JSON.
Required JSON schema:
{
"kind": "daily_checkin" | "evening_review" | "weekly_review",
"title": "<short title>",
"summary": "<2-4 sentence summary>",
"questions": ["<question>"],
"suggestedNextActions": ["<action>"],
"suggestedTags": ["<tag>"],
"evidence": [{"recordId": null, "text": "<snippet>"}],
"patchProposal": null
}
The top-level "kind" MUST be exactly one of: "daily_checkin", "evening_review", "weekly_review".
Set patchProposal to null unless a draft is clearly helpful. If needed use: {"kind": "createDraft", "description": "<why>", "content": "<markdown>"}.
Do NOT confuse patchProposal.kind with the top-level kind field.

View File

@ -1,20 +0,0 @@
{{CoachRules}}
Task:
Generate a DAILY CHECK-IN plan for date {{dateLocal}}.
Context (JSON):
{{contextJson}}
Preferences (JSON):
{{preferencesJson}}
Output:
Return a CoachPlanDto JSON with kind="daily_checkin".
Include:
- A short title
- A 2-4 sentence summary
- 1-{{maxQuestions}} questions
- 1-{{maxNextActions}} suggestedNextActions
- suggestedTags if relevant
- patchProposal that creates a fragment or dailyEntry draft ONLY if it is clearly helpful.

View File

@ -1,19 +0,0 @@
{{CoachRules}}
Task:
Generate an EVENING REVIEW for date {{dateLocal}}.
Context (JSON):
{{contextJson}}
Preferences (JSON):
{{preferencesJson}}
Output:
Return a CoachPlanDto JSON with kind="evening_review".
Include:
- Brief recap of today
- 2-4 observations with evidence
- 1-{{maxQuestions}} reflection questions
- 1-{{maxNextActions}} next actions for tomorrow
- patchProposal that creates: (optional) tomorrow-intent fragment AND/OR todos.

View File

@ -1,20 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<PackageReference Include="LLamaSharp" />
<PackageReference Include="LLamaSharp.Backend.Cpu" />
<PackageReference Include="LLamaSharp.Backend.Vulkan" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Journal.Core\Journal.Core.csproj" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Coach-Rules.txt" />
<EmbeddedResource Include="Daily-Check-In.txt" />
<EmbeddedResource Include="Evening-Review.txt" />
<EmbeddedResource Include="Weekly-Review.txt" />
</ItemGroup>
</Project>

View File

@ -1,329 +0,0 @@
using System.Text;
using System.Text.RegularExpressions;
using Journal.Core.Dtos;
using Journal.Core.Models;
using Journal.Core.Services.Ai;
using LLama;
using LLama.Common;
using LLama.Sampling;
namespace Journal.AI;
public sealed partial class LlamaSharpAiService(JournalConfig config) : IAiService, IDisposable
{
private const string DefaultModelUrl =
"https://huggingface.co/microsoft/Phi-3-mini-4k-instruct-gguf/resolve/main/Phi-3-mini-4k-instruct-q4.gguf";
private const string DefaultModelFileName = "Phi-3-mini-4k-instruct-q4.gguf";
private const string ModelSubDirectory = "ai-models";
private readonly string _configuredModelPath = config.GgufModelPath;
private readonly uint _contextSize = (uint)Math.Clamp(config.ModelContextTokens, 512, 4096);
private readonly int _gpuLayers = config.GpuLayerCount;
private readonly Lock _sync = new();
private string? _resolvedModelPath;
private LLamaWeights? _weights;
private bool _disposed;
public Task<AiHealthDto> HealthAsync(CancellationToken cancellationToken = default)
{
var resolved = _resolvedModelPath ?? _configuredModelPath;
var modelExists = File.Exists(resolved) || File.Exists(GetDefaultModelPath());
var loaded = _weights is not null;
return Task.FromResult(new AiHealthDto(
Provider: "llamasharp",
Enabled: true,
Healthy: modelExists || loaded,
Message: loaded
? "Model loaded."
: modelExists
? "Model found (will load on first use)."
: "Model not found locally. It will be downloaded on first use."));
}
private static string BuildChatSystemPrompt()
{
var dateStr = DateTime.Now.ToString("MMMM d, yyyy");
return $"You are a supportive conversational coach inside a private journaling app. " +
$"Today's date is {dateStr}. " +
$"Reply in plain natural language only. Never output JSON, code blocks, or structured data. " +
$"Be warm, practical, and concise. Do not repeat yourself.";
}
public async Task<string> ChatAsync(string prompt, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(prompt))
throw new ArgumentException("Prompt is required.", nameof(prompt));
var raw = await RunSessionAsync(prompt, BuildChatSystemPrompt(),
maxTokens: 512, cancellationToken: cancellationToken);
return CleanChatResponse(raw);
}
public async Task<string> ChatWithHistoryAsync(IReadOnlyList<(string Role, string Text)> history,
string prompt, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(prompt))
throw new ArgumentException("Prompt is required.", nameof(prompt));
var modelPath = await EnsureModelAsync(cancellationToken);
EnsureWeights(modelPath);
using var context = _weights!.CreateContext(new ModelParams(modelPath)
{
ContextSize = _contextSize,
GpuLayerCount = _gpuLayers
});
var executor = new StatelessExecutor(_weights!, context.Params);
var sb = new StringBuilder();
sb.Append($"<|system|>\n{BuildChatSystemPrompt()}<|end|>\n");
foreach (var (role, text) in history)
{
var tag = string.Equals(role, "user", StringComparison.OrdinalIgnoreCase) ? "user" : "assistant";
sb.Append($"<|{tag}|>\n{text}<|end|>\n");
}
sb.Append($"<|user|>\n{prompt}<|end|>\n");
sb.Append("<|assistant|>\n");
var inferenceParams = new InferenceParams
{
MaxTokens = 512,
AntiPrompts = ["<|user|>", "<|system|>", "<|end|>", "<|endoftext|>"],
SamplingPipeline = new DefaultSamplingPipeline { Temperature = 0.7f }
};
var result = new StringBuilder();
await foreach (var token in executor.InferAsync(sb.ToString(), inferenceParams, cancellationToken))
{
result.Append(token);
}
return CleanChatResponse(StripSpecialTokens(result.ToString()));
}
internal async Task<string> ChatJsonAsync(string prompt, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(prompt))
throw new ArgumentException("Prompt is required.", nameof(prompt));
var dateStr = DateTime.Now.ToString("MMMM d, yyyy");
return await RunSessionAsync(prompt,
$"You are a coaching assistant inside a private journaling app. " +
$"Today's date is {dateStr}. " +
$"You MUST respond with ONLY a single valid JSON object. " +
$"Do NOT write any text, explanation, or commentary before or after the JSON. " +
$"Output MUST start with {{ and end with }}.",
maxTokens: 2048, temperature: 0.2f, cancellationToken: cancellationToken);
}
public async Task<string> SummarizeEntryAsync(string content, string? fileStem = null,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(content))
throw new ArgumentException("Entry content is required.", nameof(content));
var prompt = fileStem is not null
? $"Summarize this journal entry ({fileStem}) concisely:\n\n{content}"
: $"Summarize this journal entry concisely:\n\n{content}";
return await ChatAsync(prompt, cancellationToken);
}
public async Task<string> SummarizeAllAsync(IReadOnlyList<string> entries,
CancellationToken cancellationToken = default)
{
if (entries is null || entries.Count == 0)
return "No entries to summarize.";
var combined = string.Join("\n\n---\n\n", entries);
var prompt = $"Summarize the following {entries.Count} journal entries into a concise overview:\n\n{combined}";
return await ChatAsync(prompt, cancellationToken);
}
public async Task<IReadOnlyList<double>> EmbedAsync(string content,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(content))
throw new ArgumentException("Content is required.", nameof(content));
var modelPath = await EnsureModelAsync(cancellationToken);
try
{
EnsureWeights(modelPath);
var embedder = new LLamaEmbedder(_weights!, new ModelParams(modelPath)
{
Embeddings = true,
ContextSize = _contextSize,
GpuLayerCount = _gpuLayers
});
var embeddingArrays = await embedder.GetEmbeddings(content, cancellationToken);
var result = new List<double>();
foreach (var arr in embeddingArrays)
{
foreach (var val in arr)
{
result.Add(val);
}
}
return result;
}
catch
{
return [];
}
}
// ── Model download (mirrors LocalWhisperS2TService.EnsureModelAsync) ───
private static string GetDefaultModelPath()
{
var modelDirectory = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"ProjectJournal",
ModelSubDirectory);
return Path.Combine(modelDirectory, DefaultModelFileName);
}
private async Task<string> EnsureModelAsync(CancellationToken cancellationToken = default)
{
if (File.Exists(_configuredModelPath))
return _configuredModelPath;
var defaultPath = GetDefaultModelPath();
if (File.Exists(defaultPath))
return defaultPath;
var modelDirectory = Path.GetDirectoryName(defaultPath)!;
Directory.CreateDirectory(modelDirectory);
var tempPath = defaultPath + ".download";
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(TimeSpan.FromMinutes(30));
using var httpClient = new HttpClient();
httpClient.Timeout = TimeSpan.FromMinutes(30);
using var response = await httpClient.GetAsync(DefaultModelUrl,
HttpCompletionOption.ResponseHeadersRead, cts.Token);
response.EnsureSuccessStatusCode();
await using var contentStream = await response.Content.ReadAsStreamAsync(cts.Token);
await using var fileStream = new FileStream(tempPath, FileMode.Create, FileAccess.Write, FileShare.None);
await contentStream.CopyToAsync(fileStream, cts.Token);
await fileStream.FlushAsync(cts.Token);
fileStream.Close();
File.Move(tempPath, defaultPath, overwrite: true);
return defaultPath;
}
// ── Session / weights lifecycle ────────────────────────────────────────
private Task<string> RunSessionAsync(string prompt, string systemPrompt,
int maxTokens, CancellationToken cancellationToken)
=> RunSessionAsync(prompt, systemPrompt, maxTokens, temperature: 0.7f, cancellationToken);
private async Task<string> RunSessionAsync(string prompt, string systemPrompt,
int maxTokens, float temperature, CancellationToken cancellationToken)
{
var modelPath = await EnsureModelAsync(cancellationToken);
EnsureWeights(modelPath);
using var context = _weights!.CreateContext(new ModelParams(modelPath)
{
ContextSize = _contextSize,
GpuLayerCount = _gpuLayers
});
var executor = new StatelessExecutor(_weights!, context.Params);
var fullPrompt = $"<|system|>\n{systemPrompt}<|end|>\n" +
$"<|user|>\n{prompt}<|end|>\n" +
$"<|assistant|>\n";
var inferenceParams = new InferenceParams
{
MaxTokens = maxTokens,
AntiPrompts = [
"<|user|>",
"<|system|>",
"<|end|>",
"<|endoftext|>",
],
SamplingPipeline = new DefaultSamplingPipeline
{
Temperature = temperature
}
};
var sb = new StringBuilder();
await foreach (var token in executor.InferAsync(fullPrompt, inferenceParams, cancellationToken))
{
sb.Append(token);
}
return StripSpecialTokens(sb.ToString());
}
private static string StripSpecialTokens(string raw)
{
var text = raw;
foreach (var marker in new[] { "<|assistant|>", "<|user|>", "<|system|>", "<|end|>", "<|endoftext|>" })
text = text.Replace(marker, "");
return text.Trim();
}
private static readonly Regex RoleMarkerRegex = MyRegex();
private static string CleanChatResponse(string raw)
{
var text = StripSpecialTokens(raw);
text = RoleMarkerRegex.Replace(text, "");
text = text.Replace("**", "");
text = MyRegex2().Replace(text, "\n\n");
return text.Trim();
}
private void EnsureWeights(string modelPath)
{
if (_weights is not null) return;
lock (_sync)
{
if (_weights is not null) return;
_resolvedModelPath = modelPath;
_weights = LLamaWeights.LoadFromFile(new ModelParams(modelPath)
{
ContextSize = _contextSize,
GpuLayerCount = _gpuLayers
});
}
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_weights?.Dispose();
}
[GeneratedRegex(@"\*{0,2}(System|Assistant|User):\*{0,2}", RegexOptions.IgnoreCase | RegexOptions.Compiled, "en-US")]
private static partial Regex MyRegex();
[GeneratedRegex(@"\n{3,}")]
private static partial Regex MyRegex1();
[GeneratedRegex(@"\n{3,}")]
private static partial Regex MyRegex2();
}

View File

@ -1,229 +0,0 @@
using System.Reflection;
using System.Text;
using System.Text.Json;
using Journal.Core.Dtos;
using Journal.Core.Services.Ai;
namespace Journal.AI;
public sealed class LlamaSharpCoachService(LlamaSharpAiService ai) : ICoachService
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true
};
private readonly LlamaSharpAiService _ai = ai;
private readonly string _coachRules = LoadEmbeddedResource("Coach-Rules.txt");
private readonly string _dailyTemplate = LoadEmbeddedResource("Daily-Check-In.txt");
private readonly string _eveningTemplate = LoadEmbeddedResource("Evening-Review.txt");
private readonly string _weeklyTemplate = LoadEmbeddedResource("Weekly-Review.txt");
public async Task<CoachPlanDto> DailyCheckInAsync(CoachContextDto context,
CancellationToken cancellationToken = default)
{
var prefs = context.Preferences ?? new CoachPreferencesDto();
var prompt = InterpolateTemplate(_dailyTemplate, context, prefs);
return await RunCoachPromptAsync(prompt, "daily_checkin", cancellationToken);
}
public async Task<CoachPlanDto> EveningReviewAsync(CoachContextDto context,
CancellationToken cancellationToken = default)
{
var prefs = context.Preferences ?? new CoachPreferencesDto();
var prompt = InterpolateTemplate(_eveningTemplate, context, prefs);
return await RunCoachPromptAsync(prompt, "evening_review", cancellationToken);
}
public async Task<CoachPlanDto> WeeklyReviewAsync(CoachContextDto context,
CancellationToken cancellationToken = default)
{
var prefs = context.Preferences ?? new CoachPreferencesDto();
var prompt = InterpolateTemplate(_weeklyTemplate, context, prefs);
return await RunCoachPromptAsync(prompt, "weekly_review", cancellationToken);
}
private async Task<CoachPlanDto> RunCoachPromptAsync(string prompt, string fallbackKind,
CancellationToken cancellationToken)
{
var raw = await _ai.ChatJsonAsync(prompt, cancellationToken);
var json = ExtractJson(raw);
if (json is not null)
{
try
{
var parsed = JsonSerializer.Deserialize<CoachPlanDto>(json, JsonOptions);
if (parsed is not null)
return parsed with { Kind = fallbackKind };
}
catch (JsonException)
{
}
}
return new CoachPlanDto(
Kind: fallbackKind,
Title: "Coach Response",
Summary: raw,
Questions: [],
SuggestedNextActions: [],
SuggestedTags: [],
Evidence: []);
}
private string InterpolateTemplate(string template, CoachContextDto context, CoachPreferencesDto prefs)
{
var contextJson = JsonSerializer.Serialize(new
{
recentEntries = context.RecentEntries ?? [],
recentFragments = context.RecentFragments ?? []
});
var preferencesJson = JsonSerializer.Serialize(new
{
prefs.MaxQuestions,
prefs.MaxNextActions
});
var result = template
.Replace("{{CoachRules}}", _coachRules)
.Replace("{{dateLocal}}", context.DateLocal)
.Replace("{{weekStartLocal}}", context.WeekStartLocal ?? "")
.Replace("{{weekEndLocal}}", context.WeekEndLocal ?? "")
.Replace("{{contextJson}}", contextJson)
.Replace("{{preferencesJson}}", preferencesJson)
.Replace("{{maxQuestions}}", prefs.MaxQuestions.ToString())
.Replace("{{maxNextActions}}", prefs.MaxNextActions.ToString());
return result;
}
private static string? ExtractJson(string text)
{
var codeBlockStart = text.IndexOf("```json", StringComparison.Ordinal);
if (codeBlockStart >= 0)
{
var jsonStart = text.IndexOf('{', codeBlockStart);
var codeBlockEnd = text.IndexOf("```", codeBlockStart + 7, StringComparison.Ordinal);
if (jsonStart >= 0 && codeBlockEnd > jsonStart)
{
var candidate = text[jsonStart..codeBlockEnd].Trim();
if (TryValidateJson(candidate))
return candidate;
}
}
var kindMarker = text.IndexOf("{\"kind\"", StringComparison.Ordinal);
if (kindMarker < 0)
kindMarker = text.IndexOf("{ \"kind\"", StringComparison.Ordinal);
if (kindMarker >= 0)
{
var lastBrace = text.LastIndexOf('}');
if (lastBrace > kindMarker)
{
var candidate = text[kindMarker..(lastBrace + 1)];
if (TryValidateJson(candidate))
return candidate;
}
}
var searchFrom = 0;
var globalLastBrace = text.LastIndexOf('}');
while (searchFrom < text.Length && globalLastBrace > searchFrom)
{
var bracePos = text.IndexOf('{', searchFrom);
if (bracePos < 0 || bracePos >= globalLastBrace)
break;
var candidate = text[bracePos..(globalLastBrace + 1)];
if (TryValidateJson(candidate))
return candidate;
searchFrom = bracePos + 1;
}
var firstBrace = text.IndexOf('{');
if (firstBrace >= 0)
{
var repaired = TryRepairJson(text[firstBrace..]);
if (repaired is not null)
return repaired;
}
return null;
}
private static string? TryRepairJson(string text)
{
var trimmed = text.TrimEnd();
var lastUseful = trimmed.Length - 1;
while (lastUseful >= 0)
{
var ch = trimmed[lastUseful];
if (ch is '}' or ']' or '"' or ',' or ':' or '{' or '[' || char.IsDigit(ch)
|| ch is 't' or 'r' or 'u' or 'e' or 'f' or 'a' or 'l' or 's' or 'n')
break;
lastUseful--;
}
if (lastUseful < 0) return null;
trimmed = trimmed[..(lastUseful + 1)];
if (trimmed.EndsWith(','))
trimmed = trimmed[..^1];
var openBraces = 0;
var openBrackets = 0;
var inString = false;
var escape = false;
foreach (var ch in trimmed)
{
if (escape) { escape = false; continue; }
if (ch == '\\' && inString) { escape = true; continue; }
if (ch == '"') { inString = !inString; continue; }
if (inString) continue;
switch (ch)
{
case '{': openBraces++; break;
case '}': openBraces--; break;
case '[': openBrackets++; break;
case ']': openBrackets--; break;
}
}
if (openBraces <= 0 && openBrackets <= 0) return null;
var sb = new StringBuilder(trimmed);
for (var i = 0; i < openBrackets; i++) sb.Append(']');
for (var i = 0; i < openBraces; i++) sb.Append('}');
var repaired = sb.ToString();
return TryValidateJson(repaired) ? repaired : null;
}
private static bool TryValidateJson(string json)
{
try
{
using var doc = JsonDocument.Parse(json);
return doc.RootElement.ValueKind == JsonValueKind.Object;
}
catch
{
return false;
}
}
private static string LoadEmbeddedResource(string fileName)
{
var assembly = Assembly.GetExecutingAssembly();
var resourceName = assembly.GetManifestResourceNames()
.FirstOrDefault(n => n.EndsWith(fileName, StringComparison.OrdinalIgnoreCase)) ?? throw new FileNotFoundException($"Embedded resource not found: {fileName}");
using var stream = assembly.GetManifestResourceStream(resourceName)!;
using var reader = new StreamReader(stream, Encoding.UTF8);
return reader.ReadToEnd().Trim();
}
}

View File

@ -1,57 +0,0 @@
using Journal.Core.Services.Ai;
using Journal.Core.Services.Config;
using Microsoft.Extensions.DependencyInjection;
namespace Journal.AI;
public static class ServiceCollectionExtensions
{
/// <summary>
/// Registers LLamaSharp-based AI and Coach services.
/// Call this AFTER <c>AddFragmentServices()</c> so that <c>IJournalConfigService</c> is available.
/// When the provider is "llamasharp", this replaces the default <c>IAiService</c> registration.
/// </summary>
public static IServiceCollection AddLlamaSharpServices(this IServiceCollection services)
{
// Override IAiService — last registration wins in MS DI
services.AddSingleton<IAiService>(provider =>
{
var config = provider.GetRequiredService<IJournalConfigService>().Current;
if (string.Equals(config.AiProvider, "llamasharp", StringComparison.OrdinalIgnoreCase))
{
try
{
return new LlamaSharpAiService(config);
}
catch (Exception ex)
{
return new DisabledAiService(
provider: "llamasharp",
message: $"LLamaSharp unavailable: {ex.Message}",
healthy: false);
}
}
return new DisabledAiService(config.AiProvider);
});
// Register coach service
services.AddSingleton<ICoachService>(provider =>
{
var config = provider.GetRequiredService<IJournalConfigService>().Current;
var ai = provider.GetRequiredService<IAiService>();
if (ai is LlamaSharpAiService llamaAi)
return new LlamaSharpCoachService(llamaAi);
if (string.Equals(config.AiProvider, "none", StringComparison.OrdinalIgnoreCase))
return new DisabledCoachService();
return new DisabledCoachService(
$"Coach requires llamasharp provider (current: {config.AiProvider}).");
});
return services;
}
}

View File

@ -1,19 +0,0 @@
{{CoachRules}}
Task:
Generate a WEEKLY REVIEW for {{weekStartLocal}} to {{weekEndLocal}}.
Context (JSON):
{{contextJson}}
Preferences (JSON):
{{preferencesJson}}
Output:
Return a CoachPlanDto JSON with kind="weekly_review".
Include:
- Themes (top 2-4) and blockers (top 1-3)
- Wins (top 1-3)
- 1-{{maxQuestions}} questions to set next weeks focus
- 2-{{maxNextActions}} suggestedNextActions
- patchProposal should only propose creating todos or a planning fragment, not editing old entries.

View File

@ -1,8 +0,0 @@
node_modules/
build/
.svelte-kit/
.vscode/
dist/
coverage/
target/
src-tauri/target/

View File

@ -18,12 +18,12 @@ npm run tauri dev # Tauri desktop window (connects to dev server)
## Build Targets ## Build Targets
| Command | Output | Use case | | Command | Output | Use case |
| ------------------------------------------------------------ | ----------------------------------------- | ----------------------------------- | |---------|--------|----------|
| `npm run build` | `Journal.App/build/` | Web bundle for `Journal.WebGateway` | | `npm run build` | `Journal.App/build/` | Web bundle for `Journal.WebGateway` |
| `.\scripts\publish-app.ps1 -Target web` | `Journal.App/build/` | Same, via script | | `.\scripts\publish-app.ps1 -Target web` | `Journal.App/build/` | Same, via script |
| `.\scripts\publish-app.ps1 -Target tauri -TauriBundles none` | `src-tauri/target/release/journalapp.exe` | Raw desktop exe | | `.\scripts\publish-app.ps1 -Target tauri -TauriBundles none` | `src-tauri/target/release/journalapp.exe` | Raw desktop exe |
| `.\scripts\publish-app.ps1 -Target tauri -TauriBundles nsis` | NSIS installer | Packaged installer | | `.\scripts\publish-app.ps1 -Target tauri -TauriBundles nsis` | NSIS installer | Packaged installer |
## Frontend State Management ## Frontend State Management
@ -31,13 +31,13 @@ Svelte stores are the source of truth for all feature state.
### Current Stores ### Current Stores
| Store file | State exports | Notes | | Store file | State exports | Notes |
| ----------------------------- | --------------------------------------- | ------------------------------------------------- | |-----------|---------------|-------|
| `src/lib/stores/entries.ts` | `entriesStore` | Entry list, `getDefaultEntry`, `createEntryDraft` | | `src/lib/stores/entries.ts` | `entriesStore` | Entry list, `getDefaultEntry`, `createEntryDraft` |
| `src/lib/stores/fragments.ts` | `fragmentsStore` | Fragment CRUD + parse/serialize helpers | | `src/lib/stores/fragments.ts` | `fragmentsStore` | Fragment CRUD + parse/serialize helpers |
| `src/lib/stores/todos.ts` | `todoListsStore`, `todosStore` | Todo list and item CRUD | | `src/lib/stores/todos.ts` | `todoListsStore`, `todosStore` | Todo list and item CRUD |
| `src/lib/stores/lists.ts` | `listsStore` | Generic list CRUD, `createListDraft` | | `src/lib/stores/lists.ts` | `listsStore` | Generic list CRUD, `createListDraft` |
| `src/lib/stores/settings.ts` | `settingsTags`, `settingsFragmentTypes` | Tag/type config | | `src/lib/stores/settings.ts` | `settingsTags`, `settingsFragmentTypes` | Tag/type config |
### Store-First Rule ### Store-First Rule
@ -47,14 +47,14 @@ Svelte stores are the source of truth for all feature state.
## Tauri Commands (Rust → Frontend) ## Tauri Commands (Rust → Frontend)
| Command | Description | | Command | Description |
| ------------------ | ------------------------------------------------------------------------------------ | |---------|-------------|
| `sidecar_command` | Forward a `CommandEnvelope` to `Journal.Sidecar` stdin/stdout and return parsed JSON | | `sidecar_command` | Forward a `CommandEnvelope` to `Journal.Sidecar` stdin/stdout and return parsed JSON |
| `get_sidecar_root` | Get currently resolved sidecar root path | | `get_sidecar_root` | Get currently resolved sidecar root path |
| `set_sidecar_root` | Override root path (saved to `settings.json`, restarts sidecar) | | `set_sidecar_root` | Override root path (saved to `settings.json`, restarts sidecar) |
| `get_ui_settings` | Load tag/fragment-type settings | | `get_ui_settings` | Load tag/fragment-type settings |
| `set_ui_settings` | Persist tag/fragment-type settings | | `set_ui_settings` | Persist tag/fragment-type settings |
| `shutdown` | Stop sidecar, exit app | | `shutdown` | Stop sidecar, exit app |
## Sidecar Path Resolution ## Sidecar Path Resolution

View File

@ -1,31 +1,22 @@
{ {
"name": "journal", "name": "journalapp",
"version": "0.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "journal",
"workspaces": [
"Journal.App"
]
},
"Journal.App": {
"name": "journalapp", "name": "journalapp",
"version": "0.1.0", "version": "0.1.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.6.0", "@tauri-apps/plugin-opener": "^2"
"@tauri-apps/plugin-opener": "^2",
"tauri-plugin-mic-recorder-api": "^2.0.0"
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-static": "^3.0.6", "@sveltejs/adapter-static": "^3.0.6",
"@sveltejs/kit": "^2.9.0", "@sveltejs/kit": "^2.9.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0", "@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tauri-apps/cli": "^2", "@tauri-apps/cli": "^2",
"prettier": "^3.8.1",
"prettier-plugin-svelte": "^3.5.0",
"svelte": "^5.0.0", "svelte": "^5.0.0",
"svelte-check": "^4.0.0", "svelte-check": "^4.0.0",
"typescript": "~5.6.2", "typescript": "~5.6.2",
@ -623,9 +614,6 @@
"arm" "arm"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -640,9 +628,6 @@
"arm" "arm"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -657,9 +642,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -674,9 +656,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -691,9 +670,6 @@
"loong64" "loong64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -708,9 +684,6 @@
"loong64" "loong64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -725,9 +698,6 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -742,9 +712,6 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -759,9 +726,6 @@
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -776,9 +740,6 @@
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -793,9 +754,6 @@
"s390x" "s390x"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -810,9 +768,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -827,9 +782,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -948,9 +900,9 @@
} }
}, },
"node_modules/@sveltejs/kit": { "node_modules/@sveltejs/kit": {
"version": "2.53.4", "version": "2.53.1",
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.53.4.tgz", "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.53.1.tgz",
"integrity": "sha512-iAIPEahFgDJJyvz8g0jP08KvqnM6JvdW8YfsygZ+pMeMvyM2zssWMltcsotETvjSZ82G3VlitgDtBIvpQSZrTA==", "integrity": "sha512-NXsZLvalgI3HrHG6ogoEVzjyV7bSFQNqQeekfU7nNufQFrRyV3EBDfQKEwxx50peu7spZR42JuC1PFhwxuvBrg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -1040,9 +992,9 @@
} }
}, },
"node_modules/@tauri-apps/cli": { "node_modules/@tauri-apps/cli": {
"version": "2.10.1", "version": "2.10.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.10.1.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.10.0.tgz",
"integrity": "sha512-jQNGF/5quwORdZSSLtTluyKQ+o6SMa/AUICfhf4egCGFdMHqWssApVgYSbg+jmrZoc8e1DscNvjTnXtlHLS11g==", "integrity": "sha512-ZwT0T+7bw4+DPCSWzmviwq5XbXlM0cNoleDKOYPFYqcZqeKY31KlpoMW/MOON/tOFBPgi31a2v3w9gliqwL2+Q==",
"dev": true, "dev": true,
"license": "Apache-2.0 OR MIT", "license": "Apache-2.0 OR MIT",
"bin": { "bin": {
@ -1056,23 +1008,23 @@
"url": "https://opencollective.com/tauri" "url": "https://opencollective.com/tauri"
}, },
"optionalDependencies": { "optionalDependencies": {
"@tauri-apps/cli-darwin-arm64": "2.10.1", "@tauri-apps/cli-darwin-arm64": "2.10.0",
"@tauri-apps/cli-darwin-x64": "2.10.1", "@tauri-apps/cli-darwin-x64": "2.10.0",
"@tauri-apps/cli-linux-arm-gnueabihf": "2.10.1", "@tauri-apps/cli-linux-arm-gnueabihf": "2.10.0",
"@tauri-apps/cli-linux-arm64-gnu": "2.10.1", "@tauri-apps/cli-linux-arm64-gnu": "2.10.0",
"@tauri-apps/cli-linux-arm64-musl": "2.10.1", "@tauri-apps/cli-linux-arm64-musl": "2.10.0",
"@tauri-apps/cli-linux-riscv64-gnu": "2.10.1", "@tauri-apps/cli-linux-riscv64-gnu": "2.10.0",
"@tauri-apps/cli-linux-x64-gnu": "2.10.1", "@tauri-apps/cli-linux-x64-gnu": "2.10.0",
"@tauri-apps/cli-linux-x64-musl": "2.10.1", "@tauri-apps/cli-linux-x64-musl": "2.10.0",
"@tauri-apps/cli-win32-arm64-msvc": "2.10.1", "@tauri-apps/cli-win32-arm64-msvc": "2.10.0",
"@tauri-apps/cli-win32-ia32-msvc": "2.10.1", "@tauri-apps/cli-win32-ia32-msvc": "2.10.0",
"@tauri-apps/cli-win32-x64-msvc": "2.10.1" "@tauri-apps/cli-win32-x64-msvc": "2.10.0"
} }
}, },
"node_modules/@tauri-apps/cli-darwin-arm64": { "node_modules/@tauri-apps/cli-darwin-arm64": {
"version": "2.10.1", "version": "2.10.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.10.1.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.10.0.tgz",
"integrity": "sha512-Z2OjCXiZ+fbYZy7PmP3WRnOpM9+Fy+oonKDEmUE6MwN4IGaYqgceTjwHucc/kEEYZos5GICve35f7ZiizgqEnQ==", "integrity": "sha512-avqHD4HRjrMamE/7R/kzJPcAJnZs0IIS+1nkDP5b+TNBn3py7N2aIo9LIpy+VQq0AkN8G5dDpZtOOBkmWt/zjA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -1087,9 +1039,9 @@
} }
}, },
"node_modules/@tauri-apps/cli-darwin-x64": { "node_modules/@tauri-apps/cli-darwin-x64": {
"version": "2.10.1", "version": "2.10.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.10.1.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.10.0.tgz",
"integrity": "sha512-V/irQVvjPMGOTQqNj55PnQPVuH4VJP8vZCN7ajnj+ZS8Kom1tEM2hR3qbbIRoS3dBKs5mbG8yg1WC+97dq17Pw==", "integrity": "sha512-keDmlvJRStzVFjZTd0xYkBONLtgBC9eMTpmXnBXzsHuawV2q9PvDo2x6D5mhuoMVrJ9QWjgaPKBBCFks4dK71Q==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -1104,9 +1056,9 @@
} }
}, },
"node_modules/@tauri-apps/cli-linux-arm-gnueabihf": { "node_modules/@tauri-apps/cli-linux-arm-gnueabihf": {
"version": "2.10.1", "version": "2.10.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.10.1.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.10.0.tgz",
"integrity": "sha512-Hyzwsb4VnCWKGfTw+wSt15Z2pLw2f0JdFBfq2vHBOBhvg7oi6uhKiF87hmbXOBXUZaGkyRDkCHsdzJcIfoJC2w==", "integrity": "sha512-e5u0VfLZsMAC9iHaOEANumgl6lfnJx0Dtjkd8IJpysZ8jp0tJ6wrIkto2OzQgzcYyRCKgX72aKE0PFgZputA8g==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@ -1121,16 +1073,13 @@
} }
}, },
"node_modules/@tauri-apps/cli-linux-arm64-gnu": { "node_modules/@tauri-apps/cli-linux-arm64-gnu": {
"version": "2.10.1", "version": "2.10.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.10.1.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.10.0.tgz",
"integrity": "sha512-OyOYs2t5GkBIvyWjA1+h4CZxTcdz1OZPCWAPz5DYEfB0cnWHERTnQ/SLayQzncrT0kwRoSfSz9KxenkyJoTelA==", "integrity": "sha512-YrYYk2dfmBs5m+OIMCrb+JH/oo+4FtlpcrTCgiFYc7vcs6m3QDd1TTyWu0u01ewsCtK2kOdluhr/zKku+KP7HA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 OR MIT", "license": "Apache-2.0 OR MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -1141,16 +1090,13 @@
} }
}, },
"node_modules/@tauri-apps/cli-linux-arm64-musl": { "node_modules/@tauri-apps/cli-linux-arm64-musl": {
"version": "2.10.1", "version": "2.10.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.10.1.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.10.0.tgz",
"integrity": "sha512-MIj78PDDGjkg3NqGptDOGgfXks7SYJwhiMh8SBoZS+vfdz7yP5jN18bNaLnDhsVIPARcAhE1TlsZe/8Yxo2zqg==", "integrity": "sha512-GUoPdVJmrJRIXFfW3Rkt+eGK9ygOdyISACZfC/bCSfOnGt8kNdQIQr5WRH9QUaTVFIwxMlQyV3m+yXYP+xhSVA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "Apache-2.0 OR MIT", "license": "Apache-2.0 OR MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -1161,16 +1107,13 @@
} }
}, },
"node_modules/@tauri-apps/cli-linux-riscv64-gnu": { "node_modules/@tauri-apps/cli-linux-riscv64-gnu": {
"version": "2.10.1", "version": "2.10.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.10.1.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.10.0.tgz",
"integrity": "sha512-X0lvOVUg8PCVaoEtEAnpxmnkwlE1gcMDTqfhbefICKDnOTJ5Est3qL0SrWxizDackIOKBcvtpejrSiVpuJI1kw==", "integrity": "sha512-JO7s3TlSxshwsoKNCDkyvsx5gw2QAs/Y2GbR5UE2d5kkU138ATKoPOtxn8G1fFT1aDW4LH0rYAAfBpGkDyJJnw==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 OR MIT", "license": "Apache-2.0 OR MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -1181,16 +1124,13 @@
} }
}, },
"node_modules/@tauri-apps/cli-linux-x64-gnu": { "node_modules/@tauri-apps/cli-linux-x64-gnu": {
"version": "2.10.1", "version": "2.10.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.10.1.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.10.0.tgz",
"integrity": "sha512-2/12bEzsJS9fAKybxgicCDFxYD1WEI9kO+tlDwX5znWG2GwMBaiWcmhGlZ8fi+DMe9CXlcVarMTYc0L3REIRxw==", "integrity": "sha512-Uvh4SUUp4A6DVRSMWjelww0GnZI3PlVy7VS+DRF5napKuIehVjGl9XD0uKoCoxwAQBLctvipyEK+pDXpJeoHng==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 OR MIT", "license": "Apache-2.0 OR MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -1201,16 +1141,13 @@
} }
}, },
"node_modules/@tauri-apps/cli-linux-x64-musl": { "node_modules/@tauri-apps/cli-linux-x64-musl": {
"version": "2.10.1", "version": "2.10.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.10.1.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.10.0.tgz",
"integrity": "sha512-Y8J0ZzswPz50UcGOFuXGEMrxbjwKSPgXftx5qnkuMs2rmwQB5ssvLb6tn54wDSYxe7S6vlLob9vt0VKuNOaCIQ==", "integrity": "sha512-AP0KRK6bJuTpQ8kMNWvhIpKUkQJfcPFeba7QshOQZjJ8wOS6emwTN4K5g/d3AbCMo0RRdnZWwu67MlmtJyxC1Q==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "Apache-2.0 OR MIT", "license": "Apache-2.0 OR MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -1221,9 +1158,9 @@
} }
}, },
"node_modules/@tauri-apps/cli-win32-arm64-msvc": { "node_modules/@tauri-apps/cli-win32-arm64-msvc": {
"version": "2.10.1", "version": "2.10.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.10.1.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.10.0.tgz",
"integrity": "sha512-iSt5B86jHYAPJa/IlYw++SXtFPGnWtFJriHn7X0NFBVunF6zu9+/zOn8OgqIWSl8RgzhLGXQEEtGBdR4wzpVgg==", "integrity": "sha512-97DXVU3dJystrq7W41IX+82JEorLNY+3+ECYxvXWqkq7DBN6FsA08x/EFGE8N/b0LTOui9X2dvpGGoeZKKV08g==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -1238,9 +1175,9 @@
} }
}, },
"node_modules/@tauri-apps/cli-win32-ia32-msvc": { "node_modules/@tauri-apps/cli-win32-ia32-msvc": {
"version": "2.10.1", "version": "2.10.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.10.1.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.10.0.tgz",
"integrity": "sha512-gXyxgEzsFegmnWywYU5pEBURkcFN/Oo45EAwvZrHMh+zUSEAvO5E8TXsgPADYm31d1u7OQU3O3HsYfVBf2moHw==", "integrity": "sha512-EHyQ1iwrWy1CwMalEm9z2a6L5isQ121pe7FcA2xe4VWMJp+GHSDDGvbTv/OPdkt2Lyr7DAZBpZHM6nvlHXEc4A==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
@ -1255,9 +1192,9 @@
} }
}, },
"node_modules/@tauri-apps/cli-win32-x64-msvc": { "node_modules/@tauri-apps/cli-win32-x64-msvc": {
"version": "2.10.1", "version": "2.10.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.10.1.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.10.0.tgz",
"integrity": "sha512-6Cn7YpPFwzChy0ERz6djKEmUehWrYlM+xTaNzGPgZocw3BD7OfwfWHKVWxXzdjEW2KfKkHddfdxK1XXTYqBRLg==", "integrity": "sha512-NTpyQxkpzGmU6ceWBTY2xRIEaS0ZLbVx1HE1zTA3TY/pV3+cPoPPOs+7YScr4IMzXMtOw7tLw5LEXo5oIG3qaQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -1271,15 +1208,6 @@
"node": ">= 10" "node": ">= 10"
} }
}, },
"node_modules/@tauri-apps/plugin-dialog": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.6.0.tgz",
"integrity": "sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.8.0"
}
},
"node_modules/@tauri-apps/plugin-opener": { "node_modules/@tauri-apps/plugin-opener": {
"version": "2.5.3", "version": "2.5.3",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.3.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.3.tgz",
@ -1516,10 +1444,6 @@
"@types/estree": "^1.0.6" "@types/estree": "^1.0.6"
} }
}, },
"node_modules/journalapp": {
"resolved": "Journal.App",
"link": true
},
"node_modules/kleur": { "node_modules/kleur": {
"version": "4.1.5", "version": "4.1.5",
"resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
@ -1614,9 +1538,9 @@
} }
}, },
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.8", "version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
"integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@ -1642,33 +1566,6 @@
"node": "^10 || ^12 || >=14" "node": "^10 || ^12 || >=14"
} }
}, },
"node_modules/prettier": {
"version": "3.8.1",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz",
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
"dev": true,
"license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/prettier-plugin-svelte": {
"version": "3.5.1",
"resolved": "https://registry.npmjs.org/prettier-plugin-svelte/-/prettier-plugin-svelte-3.5.1.tgz",
"integrity": "sha512-65+fr5+cgIKWKiqM1Doum4uX6bY8iFCdztvvp2RcF+AJoieaw9kJOFMNcJo/bkmKYsxFaM9OsVZK/gWauG/5mg==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"prettier": "^3.0.0",
"svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0"
}
},
"node_modules/readdirp": { "node_modules/readdirp": {
"version": "4.1.2", "version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
@ -1774,9 +1671,9 @@
} }
}, },
"node_modules/svelte": { "node_modules/svelte": {
"version": "5.53.8", "version": "5.53.3",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.53.8.tgz", "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.53.3.tgz",
"integrity": "sha512-UD++BnEc3PUFgjin381LiMHzDjT187Fy+KsPZxvaKrYPZqR0GQ/Ha8h7GDoegIF8tFl1uogoNUejKgcRk77T2Q==", "integrity": "sha512-pRUBr6j6uQDgBi208gHnGRMykw0Rf2Yr1HmLyRucsvcaYgIUxswJkT93WZJflsmezu5s8Lq+q78EoyLv2yaFCg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -1802,9 +1699,9 @@
} }
}, },
"node_modules/svelte-check": { "node_modules/svelte-check": {
"version": "4.4.5", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.4.5.tgz", "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.4.3.tgz",
"integrity": "sha512-1bSwIRCvvmSHrlK52fOlZmVtUZgil43jNL/2H18pRpa+eQjzGt6e3zayxhp1S7GajPFKNM/2PMCG+DZFHlG9fw==", "integrity": "sha512-4HtdEv2hOoLCEsSXI+RDELk9okP/4sImWa7X02OjMFFOWeSdFF3NFy3vqpw0z+eH9C88J9vxZfUXz/Uv2A1ANw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -1825,15 +1722,6 @@
"typescript": ">=5.0.0" "typescript": ">=5.0.0"
} }
}, },
"node_modules/tauri-plugin-mic-recorder-api": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/tauri-plugin-mic-recorder-api/-/tauri-plugin-mic-recorder-api-2.0.0.tgz",
"integrity": "sha512-04wqYCX4WIlYd6KUY7aS3+W4B5RtnSoVczaQCBSXKpQkEx9XdaaBN05XCee2unxGva0btSXBItFqQSdosnS4jQ==",
"license": "MIT",
"dependencies": {
"@tauri-apps/api": ">=2.0.0-beta.6"
}
},
"node_modules/tinyglobby": { "node_modules/tinyglobby": {
"version": "0.2.15", "version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",

View File

@ -6,31 +6,24 @@
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
"build": "vite build", "build": "vite build",
"tauri:prebuild": "node ./scripts/tauri-prebuild.mjs",
"preview": "vite preview", "preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"tauri": "tauri", "tauri": "tauri"
"format": "prettier --write .",
"format:check": "prettier --check ."
}, },
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.6.0", "@tauri-apps/plugin-opener": "^2"
"@tauri-apps/plugin-opener": "^2",
"tauri-plugin-mic-recorder-api": "^2.0.0"
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-static": "^3.0.6", "@sveltejs/adapter-static": "^3.0.6",
"@sveltejs/kit": "^2.9.0", "@sveltejs/kit": "^2.9.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0", "@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tauri-apps/cli": "^2",
"prettier": "^3.8.1",
"prettier-plugin-svelte": "^3.5.0",
"svelte": "^5.0.0", "svelte": "^5.0.0",
"svelte-check": "^4.0.0", "svelte-check": "^4.0.0",
"typescript": "~5.6.2", "typescript": "~5.6.2",
"vite": "^6.0.3" "vite": "^6.0.3",
"@tauri-apps/cli": "^2"
} }
} }

View File

@ -1,11 +0,0 @@
module.exports = {
plugins: ["prettier-plugin-svelte"],
overrides: [
{
files: "*.svelte",
options: {
parser: "svelte",
},
},
],
};

View File

@ -1,127 +0,0 @@
import { spawnSync } from "node:child_process";
import { copyFileSync, cpSync, existsSync, mkdirSync, readdirSync, rmSync, statSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const appRoot = path.resolve(__dirname, "..");
const repoRoot = path.resolve(appRoot, "..");
const sidecarProject = path.join(
repoRoot,
"Journal.Sidecar",
"Journal.Sidecar.csproj",
);
const publishOutputDir = path.join(repoRoot, "output");
const tauriBinDir = path.join(appRoot, "src-tauri", "bin");
function runtimeForCurrentPlatform() {
const arch = process.arch;
if (process.platform === "win32") {
if (arch === "arm64") return "win-arm64";
return "win-x64";
}
if (process.platform === "linux") {
if (arch === "arm64") return "linux-arm64";
return "linux-x64";
}
if (process.platform === "darwin") {
if (arch === "arm64") return "osx-arm64";
return "osx-x64";
}
throw new Error(
`Unsupported platform '${process.platform}' for sidecar publish.`,
);
}
function sidecarFileName() {
return process.platform === "win32"
? "Journal.Sidecar.exe"
: "Journal.Sidecar";
}
function publishProject(projectPath, runtime) {
const publishArgs = [
"publish",
projectPath,
"-c",
"Release",
"-r",
runtime,
"--self-contained",
"-p:PublishSingleFile=true",
"-p:IncludeNativeLibrariesForSelfExtract=false",
"-p:RestoreIgnoreFailedSources=true",
"-p:NuGetAudit=false",
"-p:ErrorOnDuplicatePublishOutputFiles=false",
"-o",
publishOutputDir,
];
const publish = spawnSync("dotnet", publishArgs, {
cwd: repoRoot,
stdio: "inherit",
});
if (publish.error) {
throw publish.error;
}
if (publish.status !== 0) {
process.exit(publish.status ?? 1);
}
}
function stageOutput(fileName) {
const publishedBinary = path.join(publishOutputDir, fileName);
if (!existsSync(publishedBinary)) {
throw new Error(`Published binary not found: ${publishedBinary}`);
}
mkdirSync(tauriBinDir, { recursive: true });
const skipExts = new Set([".pdb", ".metal"]);
for (const entry of readdirSync(publishOutputDir)) {
const ext = path.extname(entry).toLowerCase();
if (skipExts.has(ext)) continue;
const src = path.join(publishOutputDir, entry);
const dest = path.join(tauriBinDir, entry);
if (entry === "runtimes") {
// Only copy the runtimes subdirectory matching the target platform
stageRuntimes(src, dest, runtime);
} else if (statSync(src).isDirectory()) {
cpSync(src, dest, { recursive: true, force: true });
} else {
copyFileSync(src, dest);
}
}
console.log(`Staged sidecar + native libs to: ${tauriBinDir}`);
}
function stageRuntimes(runtimesDir, destDir, rid) {
// Only copy the subdirectory that matches our runtime identifier (e.g. win-x64)
const ridDir = path.join(runtimesDir, rid);
if (!existsSync(ridDir)) {
console.warn(`No runtimes found for ${rid}, skipping runtimes/`);
return;
}
// Remove stale runtimes from previous builds
if (existsSync(destDir)) {
rmSync(destDir, { recursive: true, force: true });
}
const destRidDir = path.join(destDir, rid);
cpSync(ridDir, destRidDir, { recursive: true, force: true });
console.log(`Copied runtimes/${rid} (skipped ${readdirSync(runtimesDir).length - 1} other platform(s))`);
}
const runtime = runtimeForCurrentPlatform();
const sidecarName = sidecarFileName();
console.log(
`Publishing sidecar for ${process.platform}/${process.arch} (${runtime})...`,
);
console.log("Publishing Journal.Sidecar...");
publishProject(sidecarProject, runtime);
stageOutput(sidecarName);

File diff suppressed because it is too large Load Diff

View File

@ -2,7 +2,7 @@
name = "journalapp" name = "journalapp"
version = "0.1.0" version = "0.1.0"
description = "A Tauri App" description = "A Tauri App"
authors = ["Stan", "J. Schmidt"] authors = ["Stan"]
edition = "2021" edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
@ -19,10 +19,7 @@ tauri-build = { version = "2", features = [] }
[dependencies] [dependencies]
tauri = { version = "2", features = [] } tauri = { version = "2", features = [] }
tauri-plugin-dialog = "2"
tauri-plugin-opener = "2" tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
tokio = { version = "1", features = ["process", "io-util", "sync", "time"] } tokio = { version = "1", features = ["process", "io-util", "sync"] }
tauri-plugin-mic-recorder = "2.0.0"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }

View File

@ -5,8 +5,6 @@
"windows": ["main"], "windows": ["main"],
"permissions": [ "permissions": [
"core:default", "core:default",
"dialog:default", "opener:default"
"opener:default",
"mic-recorder:default"
] ]
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 995 B

After

Width:  |  Height:  |  Size: 974 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 935 B

After

Width:  |  Height:  |  Size: 903 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<background android:drawable="@color/ic_launcher_background"/>
</adaptive-icon>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

View File

@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#fff</color>
</resources>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 552 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 815 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 477 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

View File

@ -5,7 +5,7 @@ use std::env;
use std::fs; use std::fs;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::Stdio; use std::process::Stdio;
use tauri::{Emitter, Manager}; use tauri::Manager;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::process::{Child, ChildStdin, ChildStdout, Command}; use tokio::process::{Child, ChildStdin, ChildStdout, Command};
use tokio::sync::Mutex; use tokio::sync::Mutex;
@ -28,7 +28,7 @@ struct CommandEnvelope {
const DEFAULT_SETTINGS_TAGS: &[&str] = &["Personal", "Work", "Ideas", "Journal"]; const DEFAULT_SETTINGS_TAGS: &[&str] = &["Personal", "Work", "Ideas", "Journal"];
const DEFAULT_FRAGMENT_TYPES: &[&str] = &["Quote", "Snippet", "Reference"]; const DEFAULT_FRAGMENT_TYPES: &[&str] = &["Quote", "Snippet", "Reference"];
const DEFAULT_STARTUP_VIEW: &str = "entries";
#[derive(Deserialize, Serialize)] #[derive(Deserialize, Serialize)]
struct AppSettings { struct AppSettings {
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
@ -37,8 +37,6 @@ struct AppSettings {
tags: Vec<String>, tags: Vec<String>,
#[serde(default = "default_fragment_types")] #[serde(default = "default_fragment_types")]
fragment_types: Vec<String>, fragment_types: Vec<String>,
#[serde(default = "default_startup_view")]
default_startup_view: String,
} }
impl Default for AppSettings { impl Default for AppSettings {
@ -47,7 +45,6 @@ impl Default for AppSettings {
sidecar_root: None, sidecar_root: None,
tags: default_settings_tags(), tags: default_settings_tags(),
fragment_types: default_fragment_types(), fragment_types: default_fragment_types(),
default_startup_view: default_startup_view(),
} }
} }
} }
@ -66,10 +63,6 @@ fn default_fragment_types() -> Vec<String> {
.collect() .collect()
} }
fn default_startup_view() -> String {
DEFAULT_STARTUP_VIEW.to_string()
}
fn normalize_items(values: Vec<String>, fallback: &[&str]) -> Vec<String> { fn normalize_items(values: Vec<String>, fallback: &[&str]) -> Vec<String> {
let mut seen = HashSet::new(); let mut seen = HashSet::new();
let mut normalized = Vec::new(); let mut normalized = Vec::new();
@ -91,30 +84,15 @@ fn normalize_items(values: Vec<String>, fallback: &[&str]) -> Vec<String> {
normalized normalized
} }
fn normalize_startup_view(value: Option<String>) -> String {
let normalized = value
.unwrap_or_else(default_startup_view)
.trim()
.to_lowercase();
match normalized.as_str() {
"entries" | "calendar" | "fragments" | "todos" | "lists" => normalized,
_ => default_startup_view(),
}
}
struct ManagedSidecar { struct ManagedSidecar {
child: Child, child: Child,
stdin: ChildStdin, stdin: ChildStdin,
stdout: BufReader<ChildStdout>, stdout: BufReader<ChildStdout>,
} }
struct ManagedSpeechProcess {
poll_task: tokio::task::JoinHandle<()>,
}
impl ManagedSidecar { impl ManagedSidecar {
fn start(root: &Path, resource_dir: Option<&Path>) -> Result<Self, String> { fn start(root: &Path) -> Result<Self, String> {
let sidecar_path = resolve_sidecar_path(root, resource_dir)?; let sidecar_path = resolve_sidecar_path(root)?;
let mut cmd = Command::new(sidecar_path); let mut cmd = Command::new(sidecar_path);
cmd.stdin(Stdio::piped()) cmd.stdin(Stdio::piped())
.stdout(Stdio::piped()) .stdout(Stdio::piped())
@ -185,18 +163,10 @@ impl Drop for ManagedSidecar {
fn drop(&mut self) {} fn drop(&mut self) {}
} }
impl ManagedSpeechProcess {
fn is_running(&self) -> bool {
!self.poll_task.is_finished()
}
}
struct SidecarState { struct SidecarState {
process: Mutex<Option<ManagedSidecar>>, process: Mutex<Option<ManagedSidecar>>,
speech_process: Mutex<Option<ManagedSpeechProcess>>,
root_override: Mutex<Option<PathBuf>>, root_override: Mutex<Option<PathBuf>>,
config_path: PathBuf, config_path: PathBuf,
resource_dir: Option<PathBuf>,
} }
fn load_settings(path: &Path) -> AppSettings { fn load_settings(path: &Path) -> AppSettings {
@ -212,77 +182,17 @@ fn save_settings(path: &Path, settings: &AppSettings) -> Result<(), String> {
fs::write(path, json).map_err(|e| format!("Failed to save settings: {e}")) fs::write(path, json).map_err(|e| format!("Failed to save settings: {e}"))
} }
fn candidate_roots(root: &Path) -> Vec<PathBuf> {
let mut candidates = Vec::new();
let push_unique = |value: PathBuf, items: &mut Vec<PathBuf>| {
if !items.iter().any(|existing| existing == &value) {
items.push(value);
}
};
push_unique(root.to_path_buf(), &mut candidates);
if let Some(name) = root.file_name().and_then(|v| v.to_str()) {
if name.eq_ignore_ascii_case("output") {
if let Some(parent) = root.parent() {
push_unique(parent.to_path_buf(), &mut candidates);
}
} else if name.eq_ignore_ascii_case("webgateway") {
if let Some(parent) = root.parent() {
push_unique(parent.to_path_buf(), &mut candidates);
}
if let Some(parent) = root.parent().and_then(|v| v.parent()) {
push_unique(parent.to_path_buf(), &mut candidates);
}
}
}
candidates
}
fn detect_root_from(start: &Path) -> Option<PathBuf> {
let mut current = start.to_path_buf();
loop {
let has_repo_markers = current.join("Journal.Sidecar").exists()
|| current.join("Journal.Core").exists()
|| current.join("Journal.slnx").exists()
|| current.join("Journal.Sidecar.exe").exists()
|| current.join("Journal.Sidecar").is_file();
if has_repo_markers {
return Some(current);
}
if !current.pop() {
return None;
}
}
}
fn auto_detect_root() -> Result<PathBuf, String> { fn auto_detect_root() -> Result<PathBuf, String> {
if let Some(env_root) = env::var_os("JOURNAL_PROJECT_ROOT") { let mut current =
let env_path = PathBuf::from(env_root); env::current_dir().map_err(|err| format!("Unable to read current dir: {err}"))?;
if env_path.exists() { loop {
return Ok(env_path); if current.join("Journal.Sidecar").exists() {
return Ok(current);
}
if !current.pop() {
return Err("Unable to locate repository root containing Journal.Sidecar.".to_string());
} }
} }
let mut starts = Vec::new();
if let Ok(current) = env::current_dir() {
starts.push(current);
}
if let Ok(exe) = env::current_exe() {
if let Some(parent) = exe.parent() {
starts.push(parent.to_path_buf());
}
}
for start in starts {
if let Some(root) = detect_root_from(&start) {
return Ok(root);
}
}
Err("Unable to locate repository root containing Journal.Sidecar.".to_string())
} }
fn effective_root(root_override: &Option<PathBuf>) -> Result<PathBuf, String> { fn effective_root(root_override: &Option<PathBuf>) -> Result<PathBuf, String> {
@ -292,59 +202,38 @@ fn effective_root(root_override: &Option<PathBuf>) -> Result<PathBuf, String> {
auto_detect_root() auto_detect_root()
} }
fn resolve_sidecar_path(root: &Path, resource_dir: Option<&Path>) -> Result<PathBuf, String> { fn resolve_sidecar_path(root: &Path) -> Result<PathBuf, String> {
#[cfg(windows)] let root_exe_path = root.join("Journal.Sidecar.exe");
let exe_name = "Journal.Sidecar.exe"; if root_exe_path.exists() {
#[cfg(not(windows))] return Ok(root_exe_path);
let exe_name = "Journal.Sidecar";
if root.is_file() && root.file_name().and_then(|n| n.to_str()) == Some(exe_name) {
return Ok(root.to_path_buf());
} }
let direct = root.join(exe_name); let root_publish_exe_path = root.join("publish").join("Journal.Sidecar.exe");
if direct.exists() { if root_publish_exe_path.exists() {
return Ok(direct); return Ok(root_publish_exe_path);
} }
let tauri_bin_sidecar_path = root let debug_path = root.join("Journal.Sidecar/bin/Debug/net10.0/Journal.Sidecar.exe");
.join("Journal.App") if debug_path.exists() {
.join("src-tauri") return Ok(debug_path);
.join("bin")
.join(exe_name);
if tauri_bin_sidecar_path.exists() {
return Ok(tauri_bin_sidecar_path);
} }
let sidecar_src_root = root.join("Journal.Sidecar"); let release_path =
if let Some(path) = find_sidecar_executable(&sidecar_src_root, exe_name) { root.join("Journal.Sidecar/bin/Release/net10.0/win-x64/publish/Journal.Sidecar.exe");
if release_path.exists() {
return Ok(release_path);
}
let sidecar_root = root.join("Journal.Sidecar");
if let Some(path) = find_sidecar_executable(&sidecar_root) {
return Ok(path); return Ok(path);
} }
if let Some(resource_dir) = resource_dir { Err("Journal.Sidecar.exe not found. Build Journal.Sidecar first.".to_string())
let resource_sidecar_path = resource_dir.join("bin").join(exe_name);
if resource_sidecar_path.exists() {
return Ok(resource_sidecar_path);
}
}
Err(format!(
"{exe_name} not found in root, Journal.Sidecar tree, or resource dir for {}.",
root.display()
))
} }
fn parse_command_response(response_line: &str) -> Result<Value, String> { fn find_sidecar_executable(search_root: &Path) -> Option<PathBuf> {
serde_json::from_str::<Value>(response_line) if !search_root.exists() {
.map_err(|err| format!("Invalid sidecar JSON response: {err}"))
}
fn read_field<'a>(data: &'a Value, camel: &str, pascal: &str) -> Option<&'a Value> {
data.get(camel).or_else(|| data.get(pascal))
}
fn find_sidecar_executable(search_root: &Path, exe_name: &str) -> Option<PathBuf> {
if !search_root.is_dir() {
return None; return None;
} }
@ -360,11 +249,6 @@ fn find_sidecar_executable(search_root: &Path, exe_name: &str) -> Option<PathBuf
}; };
let path = entry.path(); let path = entry.path();
if path.is_dir() { if path.is_dir() {
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
if name == "node_modules" || name == ".git" || name == ".vs" {
continue;
}
}
stack.push(path); stack.push(path);
continue; continue;
} }
@ -372,7 +256,7 @@ fn find_sidecar_executable(search_root: &Path, exe_name: &str) -> Option<PathBuf
let is_sidecar_exe = path let is_sidecar_exe = path
.file_name() .file_name()
.and_then(|name| name.to_str()) .and_then(|name| name.to_str())
.map(|name| name.eq_ignore_ascii_case(exe_name)) .map(|name| name.eq_ignore_ascii_case("Journal.Sidecar.exe"))
.unwrap_or(false); .unwrap_or(false);
if is_sidecar_exe { if is_sidecar_exe {
return Some(path); return Some(path);
@ -383,37 +267,6 @@ fn find_sidecar_executable(search_root: &Path, exe_name: &str) -> Option<PathBuf
None None
} }
fn resolve_gateway_appsettings_path(root: &Path) -> Result<PathBuf, String> {
for candidate_root in candidate_roots(root) {
let candidates = [
candidate_root.join("webgateway").join("appsettings.json"),
candidate_root.join("output").join("webgateway").join("appsettings.json"),
candidate_root.join("Journal.WebGateway").join("appsettings.json"),
];
for candidate in candidates {
if candidate.exists() {
return Ok(candidate);
}
}
}
Err(format!(
"Gateway appsettings.json not found near {}.",
root.display()
))
}
fn read_gateway_repo_root(config_path: &Path) -> Option<String> {
let json = fs::read_to_string(config_path).ok()?;
let value = serde_json::from_str::<Value>(&json).ok()?;
value
.get("GatewaySettings")
.and_then(|section| section.get("RepoRoot"))
.and_then(|node| node.as_str())
.map(|value| value.to_string())
}
async fn send_with_managed_sidecar( async fn send_with_managed_sidecar(
state: &SidecarState, state: &SidecarState,
input_line: &str, input_line: &str,
@ -430,7 +283,7 @@ async fn send_with_managed_sidecar(
None => true, None => true,
}; };
if should_start { if should_start {
*guard = Some(ManagedSidecar::start(&root, state.resource_dir.as_deref())?); *guard = Some(ManagedSidecar::start(&root)?);
} }
let Some(process) = guard.as_mut() else { let Some(process) = guard.as_mut() else {
@ -451,52 +304,11 @@ async fn send_with_managed_sidecar(
Err("Failed to send command to sidecar.".to_string()) Err("Failed to send command to sidecar.".to_string())
} }
async fn send_sidecar_action(
state: &SidecarState,
action: &str,
payload: Option<Value>,
) -> Result<Value, String> {
let envelope = serde_json::json!({
"action": action,
"payload": payload.unwrap_or_else(|| serde_json::json!({}))
});
let input_line = serde_json::to_string(&envelope)
.map_err(|err| format!("Serialize command failed: {err}"))?;
let response_line = send_with_managed_sidecar(state, &input_line).await?;
let response = parse_command_response(&response_line)?;
let ok = response
.get("ok")
.and_then(|node| node.as_bool())
.unwrap_or(false);
if !ok {
let err = response
.get("error")
.and_then(|node| node.as_str())
.unwrap_or("Sidecar command failed.");
return Err(err.to_string());
}
Ok(response
.get("data")
.cloned()
.unwrap_or_else(|| serde_json::json!({})))
}
async fn stop_managed_sidecar(state: &SidecarState) { async fn stop_managed_sidecar(state: &SidecarState) {
let mut guard = state.process.lock().await; let mut guard = state.process.lock().await;
guard.take(); guard.take();
} }
async fn stop_speech_process(state: &SidecarState) -> Result<(), String> {
let mut guard = state.speech_process.lock().await;
if let Some(process) = guard.take() {
process.poll_task.abort();
}
Ok(())
}
#[tauri::command] #[tauri::command]
async fn get_sidecar_root(state: tauri::State<'_, SidecarState>) -> Result<Value, String> { async fn get_sidecar_root(state: tauri::State<'_, SidecarState>) -> Result<Value, String> {
let root_override = state.root_override.lock().await.clone(); let root_override = state.root_override.lock().await.clone();
@ -507,64 +319,6 @@ async fn get_sidecar_root(state: tauri::State<'_, SidecarState>) -> Result<Value
})) }))
} }
#[tauri::command]
async fn get_gateway_root_status(
state: tauri::State<'_, SidecarState>,
) -> Result<Value, String> {
let root_override = state.root_override.lock().await.clone();
let root = effective_root(&root_override)?;
let config_path = resolve_gateway_appsettings_path(&root)?;
let configured_root = read_gateway_repo_root(&config_path);
let authoritative_root = root.to_string_lossy().into_owned();
Ok(serde_json::json!({
"authoritativeRoot": authoritative_root,
"gatewayConfigPath": config_path.to_string_lossy(),
"configuredRoot": configured_root,
"needsAdoption": configured_root.as_deref() != Some(root.to_string_lossy().as_ref())
}))
}
#[tauri::command]
async fn adopt_sidecar_root_for_gateway(
state: tauri::State<'_, SidecarState>,
) -> Result<Value, String> {
let root_override = state.root_override.lock().await.clone();
let root = effective_root(&root_override)?;
let config_path = resolve_gateway_appsettings_path(&root)?;
let previous_root = read_gateway_repo_root(&config_path);
let json = fs::read_to_string(&config_path)
.map_err(|err| format!("Failed to read gateway appsettings: {err}"))?;
let mut value = serde_json::from_str::<Value>(&json)
.map_err(|err| format!("Invalid gateway appsettings JSON: {err}"))?;
let root_value = Value::String(root.to_string_lossy().into_owned());
let object = value
.as_object_mut()
.ok_or_else(|| "Gateway appsettings root must be a JSON object.".to_string())?;
let gateway_settings = object
.entry("GatewaySettings")
.or_insert_with(|| Value::Object(serde_json::Map::new()));
let settings_object = gateway_settings
.as_object_mut()
.ok_or_else(|| "GatewaySettings must be a JSON object.".to_string())?;
settings_object.insert("RepoRoot".to_string(), root_value);
let updated = serde_json::to_string_pretty(&value)
.map_err(|err| format!("Failed to serialize gateway appsettings: {err}"))?;
fs::write(&config_path, updated)
.map_err(|err| format!("Failed to write gateway appsettings: {err}"))?;
Ok(serde_json::json!({
"authoritativeRoot": root.to_string_lossy(),
"gatewayConfigPath": config_path.to_string_lossy(),
"previousRoot": previous_root,
"configuredRoot": root.to_string_lossy(),
"needsAdoption": false
}))
}
#[tauri::command] #[tauri::command]
async fn set_sidecar_root( async fn set_sidecar_root(
state: tauri::State<'_, SidecarState>, state: tauri::State<'_, SidecarState>,
@ -581,7 +335,7 @@ async fn set_sidecar_root(
new_root.display() new_root.display()
)); ));
} }
resolve_sidecar_path(&new_root, state.resource_dir.as_deref())?; resolve_sidecar_path(&new_root)?;
(Some(new_root.clone()), new_root) (Some(new_root.clone()), new_root)
}; };
@ -590,6 +344,7 @@ async fn set_sidecar_root(
let mut guard = state.process.lock().await; let mut guard = state.process.lock().await;
guard.take(); guard.take();
} }
let is_custom = new_override.is_some(); let is_custom = new_override.is_some();
*state.root_override.lock().await = new_override.clone(); *state.root_override.lock().await = new_override.clone();
@ -608,12 +363,10 @@ async fn get_ui_settings(state: tauri::State<'_, SidecarState>) -> Result<Value,
let settings = load_settings(&state.config_path); let settings = load_settings(&state.config_path);
let tags = normalize_items(settings.tags, DEFAULT_SETTINGS_TAGS); let tags = normalize_items(settings.tags, DEFAULT_SETTINGS_TAGS);
let fragment_types = normalize_items(settings.fragment_types, DEFAULT_FRAGMENT_TYPES); let fragment_types = normalize_items(settings.fragment_types, DEFAULT_FRAGMENT_TYPES);
let startup_view = normalize_startup_view(Some(settings.default_startup_view));
Ok(serde_json::json!({ Ok(serde_json::json!({
"tags": tags, "tags": tags,
"fragmentTypes": fragment_types, "fragmentTypes": fragment_types
"defaultStartupView": startup_view
})) }))
} }
@ -622,18 +375,15 @@ async fn set_ui_settings(
state: tauri::State<'_, SidecarState>, state: tauri::State<'_, SidecarState>,
tags: Vec<String>, tags: Vec<String>,
fragment_types: Vec<String>, fragment_types: Vec<String>,
default_startup_view: Option<String>,
) -> Result<Value, String> { ) -> Result<Value, String> {
let mut settings = load_settings(&state.config_path); let mut settings = load_settings(&state.config_path);
settings.tags = normalize_items(tags, DEFAULT_SETTINGS_TAGS); settings.tags = normalize_items(tags, DEFAULT_SETTINGS_TAGS);
settings.fragment_types = normalize_items(fragment_types, DEFAULT_FRAGMENT_TYPES); settings.fragment_types = normalize_items(fragment_types, DEFAULT_FRAGMENT_TYPES);
settings.default_startup_view = normalize_startup_view(default_startup_view);
save_settings(&state.config_path, &settings)?; save_settings(&state.config_path, &settings)?;
Ok(serde_json::json!({ Ok(serde_json::json!({
"tags": settings.tags, "tags": settings.tags,
"fragmentTypes": settings.fragment_types, "fragmentTypes": settings.fragment_types
"defaultStartupView": settings.default_startup_view
})) }))
} }
@ -642,153 +392,11 @@ async fn shutdown(
state: tauri::State<'_, SidecarState>, state: tauri::State<'_, SidecarState>,
app_handle: tauri::AppHandle, app_handle: tauri::AppHandle,
) -> Result<(), String> { ) -> Result<(), String> {
stop_speech_process(state.inner()).await?;
let _ = send_sidecar_action(state.inner(), "speech.live.stop", None).await;
stop_managed_sidecar(state.inner()).await; stop_managed_sidecar(state.inner()).await;
app_handle.exit(0); app_handle.exit(0);
Ok(()) Ok(())
} }
#[tauri::command]
async fn speech_start(
state: tauri::State<'_, SidecarState>,
app_handle: tauri::AppHandle,
) -> Result<Value, String> {
let _ = app_handle.emit(
"speech-status",
serde_json::json!({ "state": "starting", "message": "Starting speech process..." }),
);
{
let guard = state.speech_process.lock().await;
if let Some(existing) = guard.as_ref() {
if existing.is_running() {
return Ok(serde_json::json!({ "running": true }));
}
}
}
let start_data = send_sidecar_action(state.inner(), "speech.live.start", None).await?;
let running = read_field(&start_data, "running", "Running")
.and_then(|node| node.as_bool())
.unwrap_or(false);
let status = read_field(&start_data, "status", "Status")
.and_then(|node| node.as_str())
.unwrap_or("starting");
let warning = read_field(&start_data, "warning", "Warning")
.and_then(|node| node.as_str())
.map(|v| v.to_string());
let _ = app_handle.emit(
"speech-status",
serde_json::json!({ "state": status, "message": warning.clone().unwrap_or_else(|| status.to_string()) }),
);
if !running {
return Err(warning.unwrap_or_else(|| "Failed to start live speech.".to_string()));
}
let app_for_poll = app_handle.clone();
let poll_task = tokio::spawn(async move {
loop {
let state_handle = app_for_poll.state::<SidecarState>();
let poll_data = match send_sidecar_action(
state_handle.inner(),
"speech.live.poll",
Some(serde_json::json!({ "maxItems": 8 })),
)
.await
{
Ok(value) => value,
Err(err) => {
let _ = app_for_poll.emit(
"speech-status",
serde_json::json!({ "state": "error", "message": err }),
);
break;
}
};
if let Some(items) =
read_field(&poll_data, "items", "Items").and_then(|node| node.as_array())
{
for item in items {
if let Some(text) = item.as_str() {
let _ = app_for_poll
.emit("speech-transcript", serde_json::json!({ "text": text }));
}
}
}
let running = read_field(&poll_data, "running", "Running")
.and_then(|node| node.as_bool())
.unwrap_or(false);
let status = read_field(&poll_data, "status", "Status")
.and_then(|node| node.as_str())
.unwrap_or(if running { "listening" } else { "stopped" });
let warning = read_field(&poll_data, "warning", "Warning")
.and_then(|node| node.as_str())
.map(|v| v.to_string());
if let Some(message) = warning {
let _ = app_for_poll.emit(
"speech-status",
serde_json::json!({ "state": if running { "listening" } else { "error" }, "message": message }),
);
} else {
let _ = app_for_poll.emit(
"speech-status",
serde_json::json!({ "state": status, "message": status }),
);
}
if !running {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(350)).await;
}
});
let mut guard = state.speech_process.lock().await;
*guard = Some(ManagedSpeechProcess { poll_task });
Ok(serde_json::json!({ "running": true }))
}
#[tauri::command]
async fn speech_stop(
state: tauri::State<'_, SidecarState>,
app_handle: tauri::AppHandle,
) -> Result<Value, String> {
stop_speech_process(state.inner()).await?;
let _ = send_sidecar_action(state.inner(), "speech.live.stop", None).await;
let _ = app_handle.emit(
"speech-status",
serde_json::json!({ "state": "stopped", "message": "Dictation stopped." }),
);
Ok(serde_json::json!({ "running": false }))
}
#[tauri::command]
async fn speech_cleanup_probe(path: String) -> Result<Value, String> {
if path.trim().is_empty() {
return Ok(serde_json::json!({ "deleted": false }));
}
let target = PathBuf::from(path);
let normalized = target.to_string_lossy().to_lowercase();
if !normalized.contains("tauri-plugin-mic-recorder") || !normalized.ends_with(".wav") {
return Ok(serde_json::json!({ "deleted": false }));
}
if !target.exists() {
return Ok(serde_json::json!({ "deleted": false }));
}
fs::remove_file(&target).map_err(|err| format!("Failed to remove probe recording: {err}"))?;
Ok(serde_json::json!({ "deleted": true }))
}
#[tauri::command] #[tauri::command]
async fn sidecar_command( async fn sidecar_command(
state: tauri::State<'_, SidecarState>, state: tauri::State<'_, SidecarState>,
@ -801,24 +409,18 @@ async fn sidecar_command(
let input_line = serde_json::to_string(&command) let input_line = serde_json::to_string(&command)
.map_err(|err| format!("Serialize command failed: {err}"))?; .map_err(|err| format!("Serialize command failed: {err}"))?;
let response_line = send_with_managed_sidecar(state.inner(), &input_line).await?; let response_line = send_with_managed_sidecar(state.inner(), &input_line).await?;
parse_command_response(&response_line) serde_json::from_str::<Value>(&response_line)
.map_err(|err| format!("Invalid sidecar JSON response: {err}"))
} }
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
let app = tauri::Builder::default() let app = tauri::Builder::default()
.plugin(tauri_plugin_mic_recorder::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_opener::init())
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
sidecar_command, sidecar_command,
shutdown, shutdown,
speech_start,
speech_stop,
speech_cleanup_probe,
get_sidecar_root, get_sidecar_root,
get_gateway_root_status,
adopt_sidecar_root_for_gateway,
set_sidecar_root, set_sidecar_root,
get_ui_settings, get_ui_settings,
set_ui_settings, set_ui_settings,
@ -828,14 +430,12 @@ pub fn run() {
fs::create_dir_all(&config_dir).ok(); fs::create_dir_all(&config_dir).ok();
let config_path = config_dir.join("settings.json"); let config_path = config_dir.join("settings.json");
let settings = load_settings(&config_path); let settings = load_settings(&config_path);
let root_override = settings.sidecar_root.as_ref().map(PathBuf::from); let root_override = settings.sidecar_root.map(PathBuf::from);
app.manage(SidecarState { app.manage(SidecarState {
process: Mutex::new(None), process: Mutex::new(None),
speech_process: Mutex::new(None),
root_override: Mutex::new(root_override), root_override: Mutex::new(root_override),
config_path, config_path,
resource_dir: app.path().resource_dir().ok(),
}); });
Ok(()) Ok(())
}) })
@ -848,11 +448,6 @@ pub fn run() {
if let Ok(mut guard) = state.process.try_lock() { if let Ok(mut guard) = state.process.try_lock() {
guard.take(); guard.take();
}; };
if let Ok(mut guard) = state.speech_process.try_lock() {
if let Some(speech) = guard.take() {
speech.poll_task.abort();
}
};
} }
}); });
} }

View File

@ -2,11 +2,11 @@
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Project Journal", "productName": "Project Journal",
"version": "0.1.0", "version": "0.1.0",
"identifier": "com.idsolutions.journal", "identifier": "com.stan.journal",
"build": { "build": {
"beforeDevCommand": "npm run dev", "beforeDevCommand": "npm run dev",
"devUrl": "http://localhost:1420", "devUrl": "http://localhost:1420",
"beforeBuildCommand": "npm run tauri:prebuild && npm run build", "beforeBuildCommand": "npm run build",
"frontendDist": "../build" "frontendDist": "../build"
}, },
"app": { "app": {
@ -24,7 +24,6 @@
"bundle": { "bundle": {
"active": true, "active": true,
"targets": "all", "targets": "all",
"resources": ["bin"],
"icon": [ "icon": [
"icons/32x32.png", "icons/32x32.png",
"icons/128x128.png", "icons/128x128.png",

View File

@ -2,20 +2,13 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/icon.ico" /> <link rel="icon" href="%sveltekit.assets%/favicon.png" />
<link <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200" />
rel="stylesheet" <link rel="stylesheet" href="style.css">
href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200" <meta name="viewport" content="width=device-width, initial-scale=1" />
/>
<link rel="stylesheet" href="style.css?v=20260328b" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, viewport-fit=cover"
/>
<title>Journal</title> <title>Journal</title>
%sveltekit.head% %sveltekit.head%
</head> </head>
<body data-sveltekit-preload-data="hover"> <body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div> <div style="display: contents">%sveltekit.body%</div>
</body> </body>

View File

@ -1,222 +0,0 @@
import { sendCommand } from "./client";
import { pickCase } from "./normalize";
//#region Public Types
export type AiHealthDto = {
provider: string;
enabled: boolean;
healthy: boolean;
message: string;
};
export type CoachEvidenceDto = {
recordId: string | null;
text: string;
};
export type CoachPatchProposalDto = {
kind: string;
description: string | null;
content: string | null;
};
export type CoachPlanDto = {
kind: string;
title: string;
summary: string;
questions: string[];
suggestedNextActions: string[];
suggestedTags: string[];
evidence: CoachEvidenceDto[];
patchProposal: CoachPatchProposalDto | null;
};
export type CoachPreferencesDto = {
maxQuestions?: number;
maxNextActions?: number;
};
export type CoachSessionPayload = {
dateLocal?: string;
weekStartLocal?: string;
weekEndLocal?: string;
recentEntries?: string[];
recentFragments?: string[];
preferences?: CoachPreferencesDto;
};
//#endregion
//#region PascalCase Normalizers
type AiHealthDtoRaw = {
provider?: string;
enabled?: boolean;
healthy?: boolean;
message?: string;
Provider?: string;
Enabled?: boolean;
Healthy?: boolean;
Message?: string;
};
type CoachEvidenceDtoRaw = {
recordId?: string | null;
text?: string;
RecordId?: string | null;
Text?: string;
};
type CoachPatchProposalDtoRaw = {
kind?: string;
description?: string | null;
content?: string | null;
Kind?: string;
Description?: string | null;
Content?: string | null;
};
type CoachPlanDtoRaw = {
kind?: string;
title?: string;
summary?: string;
questions?: string[];
suggestedNextActions?: string[];
suggestedTags?: string[];
evidence?: CoachEvidenceDtoRaw[];
patchProposal?: CoachPatchProposalDtoRaw | null;
Kind?: string;
Title?: string;
Summary?: string;
Questions?: string[];
SuggestedNextActions?: string[];
SuggestedTags?: string[];
Evidence?: CoachEvidenceDtoRaw[];
PatchProposal?: CoachPatchProposalDtoRaw | null;
};
//#endregion
//#region Normalizers
function normalizeHealth(raw: AiHealthDtoRaw): AiHealthDto {
return {
provider: pickCase(raw, "provider", "Provider", ""),
enabled: pickCase(raw, "enabled", "Enabled", false),
healthy: pickCase(raw, "healthy", "Healthy", false),
message: pickCase(raw, "message", "Message", ""),
};
}
function normalizeEvidence(raw: CoachEvidenceDtoRaw): CoachEvidenceDto {
return {
recordId: pickCase(raw, "recordId", "RecordId", null as string | null),
text: pickCase(raw, "text", "Text", ""),
};
}
function normalizePatchProposal(
raw: CoachPatchProposalDtoRaw | null | undefined,
): CoachPatchProposalDto | null {
if (!raw) return null;
return {
kind: pickCase(raw, "kind", "Kind", ""),
description: pickCase(
raw,
"description",
"Description",
null as string | null,
),
content: pickCase(raw, "content", "Content", null as string | null),
};
}
function normalizeCoachPlan(raw: CoachPlanDtoRaw): CoachPlanDto {
const evidenceRaw = pickCase(
raw,
"evidence",
"Evidence",
[] as CoachEvidenceDtoRaw[],
);
const patchRaw = pickCase(
raw,
"patchProposal",
"PatchProposal",
null as CoachPatchProposalDtoRaw | null,
);
return {
kind: pickCase(raw, "kind", "Kind", ""),
title: pickCase(raw, "title", "Title", ""),
summary: pickCase(raw, "summary", "Summary", ""),
questions: pickCase(raw, "questions", "Questions", [] as string[]),
suggestedNextActions: pickCase(
raw,
"suggestedNextActions",
"SuggestedNextActions",
[] as string[],
),
suggestedTags: pickCase(
raw,
"suggestedTags",
"SuggestedTags",
[] as string[],
),
evidence: evidenceRaw.map(normalizeEvidence),
patchProposal: normalizePatchProposal(patchRaw),
};
}
//#endregion
//#region API Functions
export async function aiHealth(): Promise<AiHealthDto> {
const data = await sendCommand<AiHealthDtoRaw>({
action: "ai.health",
payload: {},
});
return normalizeHealth(data);
}
export async function aiChat(prompt: string): Promise<string> {
return sendCommand<string>({
action: "ai.chat",
payload: { prompt },
});
}
export async function aiSummarizeEntry(
content: string,
fileStem?: string,
): Promise<string> {
return sendCommand<string>({
action: "ai.summarize_entry",
payload: { content, fileStem },
});
}
export async function coachDaily(
payload: CoachSessionPayload = {},
): Promise<CoachPlanDto> {
const data = await sendCommand<CoachPlanDtoRaw>({
action: "ai.coach.daily",
payload,
});
return normalizeCoachPlan(data);
}
export async function coachEvening(
payload: CoachSessionPayload = {},
): Promise<CoachPlanDto> {
const data = await sendCommand<CoachPlanDtoRaw>({
action: "ai.coach.evening",
payload,
});
return normalizeCoachPlan(data);
}
export async function coachWeekly(
payload: CoachSessionPayload = {},
): Promise<CoachPlanDto> {
const data = await sendCommand<CoachPlanDtoRaw>({
action: "ai.coach.weekly",
payload,
});
return normalizeCoachPlan(data);
}
//#endregion

View File

@ -1,18 +1,22 @@
import { sendCommand } from "./client"; import { sendCommand } from "./client";
import { pickCase } from "./normalize"; import { pickCase } from "./normalize";
export function hydrateWorkspace(password: string): Promise<unknown> { export function hydrateWorkspace(password: string): Promise<unknown> {
return sendCommand<unknown>({ return sendCommand<unknown>({
action: "db.hydrate_workspace", action: "db.hydrate_workspace",
payload: { password }, payload: { password }
}); });
} }
type RuntimeConfigRaw = { type RuntimeConfigRaw = {
dataDirectory?: string;
vaultDirectory?: string; vaultDirectory?: string;
DataDirectory?: string;
VaultDirectory?: string; VaultDirectory?: string;
}; };
type RuntimeConfig = { type RuntimeConfig = {
dataDirectory: string;
vaultDirectory: string; vaultDirectory: string;
}; };
@ -20,18 +24,17 @@ type PersistOptions = {
keepalive?: boolean; keepalive?: boolean;
}; };
async function getRuntimeConfig( async function getRuntimeConfig(options: PersistOptions = {}): Promise<RuntimeConfig> {
options: PersistOptions = {},
): Promise<RuntimeConfig> {
const data = await sendCommand<RuntimeConfigRaw>( const data = await sendCommand<RuntimeConfigRaw>(
{ {
action: "config.get", action: "config.get"
}, },
options, options
); );
return { return {
vaultDirectory: pickCase(data, "vaultDirectory", "VaultDirectory", ""), dataDirectory: pickCase(data, "dataDirectory", "DataDirectory", ""),
vaultDirectory: pickCase(data, "vaultDirectory", "VaultDirectory", "")
}; };
} }
@ -42,7 +45,8 @@ export async function unlockVaultWorkspace(password: string): Promise<void> {
payload: { payload: {
password, password,
vaultDirectory: config.vaultDirectory, vaultDirectory: config.vaultDirectory,
}, dataDirectory: config.dataDirectory
}
}); });
if (!loaded) { if (!loaded) {
@ -53,14 +57,12 @@ export async function unlockVaultWorkspace(password: string): Promise<void> {
action: "db.hydrate_workspace", action: "db.hydrate_workspace",
payload: { payload: {
password, password,
}, dataDirectory: config.dataDirectory
}
}); });
} }
export async function persistAndClearVault( export async function persistAndClearVault(password: string, options: PersistOptions = {}): Promise<void> {
password: string,
options: PersistOptions = {},
): Promise<void> {
const config = await getRuntimeConfig(options); const config = await getRuntimeConfig(options);
await sendCommand<boolean>( await sendCommand<boolean>(
@ -69,16 +71,19 @@ export async function persistAndClearVault(
payload: { payload: {
password, password,
vaultDirectory: config.vaultDirectory, vaultDirectory: config.vaultDirectory,
}, dataDirectory: config.dataDirectory
}
}, },
options, options
); );
await sendCommand<boolean>( await sendCommand<boolean>(
{ {
action: "vault.clear_data_directory", action: "vault.clear_data_directory",
payload: {}, payload: {
dataDirectory: config.dataDirectory
}
}, },
options, options
); );
} }

View File

@ -1,8 +1,4 @@
import { invoke } from "$lib/runtime/invoke"; import { invoke } from "$lib/runtime/invoke";
import {
clearVaultSession,
requestVaultUnlock,
} from "$lib/stores/session";
import type { BackendCommand, BackendResponse } from "./types"; import type { BackendCommand, BackendResponse } from "./types";
function newCorrelationId(): string { function newCorrelationId(): string {
@ -11,43 +7,20 @@ function newCorrelationId(): string {
type SendCommandOptions = { type SendCommandOptions = {
keepalive?: boolean; keepalive?: boolean;
unlockRetryAttempted?: boolean;
}; };
function isDatabaseLockedError(message: string): boolean { export async function sendCommand<T>(command: BackendCommand, options: SendCommandOptions = {}): Promise<T> {
return message.toLowerCase().includes("database is locked");
}
export async function sendCommand<T>(
command: BackendCommand,
options: SendCommandOptions = {},
): Promise<T> {
const envelope: BackendCommand = { const envelope: BackendCommand = {
...command, ...command,
correlationId: command.correlationId ?? newCorrelationId(), correlationId: command.correlationId ?? newCorrelationId()
}; };
const response = await invoke<BackendResponse<T>>("sidecar_command", { const response = await invoke<BackendResponse<T>>("sidecar_command", {
command: envelope, command: envelope,
keepalive: options.keepalive === true, keepalive: options.keepalive === true
}); });
if (!response.ok) { if (!response.ok) {
const errorMessage = response.error || "Backend command failed"; throw new Error(response.error || "Backend command failed");
if (
!options.unlockRetryAttempted &&
isDatabaseLockedError(errorMessage)
) {
clearVaultSession();
const unlocked = await requestVaultUnlock();
if (unlocked) {
return sendCommand<T>(command, {
...options,
unlockRetryAttempted: true,
});
}
}
throw new Error(errorMessage);
} }
return response.data; return response.data;

View File

@ -1,195 +0,0 @@
import { sendCommand } from "./client";
import { pickCase } from "./normalize";
//#region Public Types
export type ConversationDto = {
id: string;
title: string;
createdAt: string;
updatedAt: string;
};
export type ConversationMessageDto = {
id: string;
role: string;
text: string;
createdAt: string;
};
export type ConversationDetailDto = {
id: string;
title: string;
messages: ConversationMessageDto[];
createdAt: string;
updatedAt: string;
};
export type ConversationChatResult = {
userMessage: ConversationMessageDto;
assistantMessage: ConversationMessageDto;
};
//#endregion
//#region PascalCase Normalizers
type ConversationDtoRaw = {
id?: string;
title?: string;
createdAt?: string;
updatedAt?: string;
Id?: string;
Title?: string;
CreatedAt?: string;
UpdatedAt?: string;
};
type ConversationMessageDtoRaw = {
id?: string;
role?: string;
text?: string;
createdAt?: string;
Id?: string;
Role?: string;
Text?: string;
CreatedAt?: string;
};
type ConversationDetailDtoRaw = {
id?: string;
title?: string;
messages?: ConversationMessageDtoRaw[];
createdAt?: string;
updatedAt?: string;
Id?: string;
Title?: string;
Messages?: ConversationMessageDtoRaw[];
CreatedAt?: string;
UpdatedAt?: string;
};
type ConversationChatResultRaw = {
userMessage?: ConversationMessageDtoRaw;
assistantMessage?: ConversationMessageDtoRaw;
UserMessage?: ConversationMessageDtoRaw;
AssistantMessage?: ConversationMessageDtoRaw;
};
//#endregion
//#region Normalizers
function normalizeMessage(
raw: ConversationMessageDtoRaw,
): ConversationMessageDto {
return {
id: pickCase(raw, "id", "Id", ""),
role: pickCase(raw, "role", "Role", ""),
text: pickCase(raw, "text", "Text", ""),
createdAt: pickCase(raw, "createdAt", "CreatedAt", ""),
};
}
function normalizeConversation(raw: ConversationDtoRaw): ConversationDto {
return {
id: pickCase(raw, "id", "Id", ""),
title: pickCase(raw, "title", "Title", ""),
createdAt: pickCase(raw, "createdAt", "CreatedAt", ""),
updatedAt: pickCase(raw, "updatedAt", "UpdatedAt", ""),
};
}
function normalizeDetail(raw: ConversationDetailDtoRaw): ConversationDetailDto {
const msgs = pickCase(
raw,
"messages",
"Messages",
[] as ConversationMessageDtoRaw[],
);
return {
id: pickCase(raw, "id", "Id", ""),
title: pickCase(raw, "title", "Title", ""),
messages: msgs.map(normalizeMessage),
createdAt: pickCase(raw, "createdAt", "CreatedAt", ""),
updatedAt: pickCase(raw, "updatedAt", "UpdatedAt", ""),
};
}
function normalizeChatResult(
raw: ConversationChatResultRaw,
): ConversationChatResult {
const userRaw = pickCase(
raw,
"userMessage",
"UserMessage",
{} as ConversationMessageDtoRaw,
);
const assistantRaw = pickCase(
raw,
"assistantMessage",
"AssistantMessage",
{} as ConversationMessageDtoRaw,
);
return {
userMessage: normalizeMessage(userRaw),
assistantMessage: normalizeMessage(assistantRaw),
};
}
//#endregion
//#region API Functions
export async function listConversations(): Promise<ConversationDto[]> {
const data = await sendCommand<ConversationDtoRaw[]>({
action: "conversations.list",
payload: {},
});
return (data ?? []).map(normalizeConversation);
}
export async function getConversation(
id: string,
): Promise<ConversationDetailDto> {
const data = await sendCommand<ConversationDetailDtoRaw>({
action: "conversations.get",
id,
payload: {},
});
return normalizeDetail(data);
}
export async function createConversation(
title: string,
): Promise<ConversationDto> {
const data = await sendCommand<ConversationDtoRaw>({
action: "conversations.create",
payload: { title },
});
return normalizeConversation(data);
}
export async function updateConversation(
id: string,
title: string,
): Promise<boolean> {
return sendCommand<boolean>({
action: "conversations.update",
id,
payload: { title },
});
}
export async function deleteConversation(id: string): Promise<boolean> {
return sendCommand<boolean>({
action: "conversations.delete",
id,
payload: {},
});
}
export async function conversationChat(
conversationId: string,
prompt: string,
): Promise<ConversationChatResult> {
const data = await sendCommand<ConversationChatResultRaw>({
action: "conversations.chat",
payload: { conversationId, prompt },
});
return normalizeChatResult(data);
}
//#endregion

View File

@ -1,9 +1,5 @@
import { sendCommand } from "./client"; import { sendCommand } from "./client";
import { import { normalizeFragment, type FragmentDto, type FragmentDtoRaw } from "./fragments";
normalizeFragment,
type FragmentDto,
type FragmentDtoRaw,
} from "./fragments";
import { pickCase } from "./normalize"; import { pickCase } from "./normalize";
export type ParsedSectionDto = { export type ParsedSectionDto = {
@ -35,6 +31,7 @@ export type EntrySaveResultDto = {
}; };
export type EntrySearchRequestDto = { export type EntrySearchRequestDto = {
dataDirectory: string;
query?: string; query?: string;
section?: string; section?: string;
startDate?: string; startDate?: string;
@ -110,124 +107,80 @@ function normalizeSection(raw: ParsedSectionDtoRaw): ParsedSectionDto {
return { return {
title: pickCase(raw, "title", "Title", ""), title: pickCase(raw, "title", "Title", ""),
content: pickCase(raw, "content", "Content", [] as string[]), content: pickCase(raw, "content", "Content", [] as string[]),
checkboxes: pickCase( checkboxes: pickCase(raw, "checkboxes", "Checkboxes", {} as Record<string, boolean>)
raw,
"checkboxes",
"Checkboxes",
{} as Record<string, boolean>,
),
}; };
} }
function normalizeJournalEntry( function normalizeJournalEntry(raw: JournalEntryDtoRaw | undefined): JournalEntryDto {
raw: JournalEntryDtoRaw | undefined, const fragments = pickCase(raw, "fragments", "Fragments", [] as FragmentDtoRaw[]);
): JournalEntryDto { const sections = pickCase(raw, "sections", "Sections", {} as Record<string, ParsedSectionDtoRaw>);
const fragments = pickCase(
raw,
"fragments",
"Fragments",
[] as FragmentDtoRaw[],
);
const sections = pickCase(
raw,
"sections",
"Sections",
{} as Record<string, ParsedSectionDtoRaw>,
);
return { return {
date: pickCase(raw, "date", "Date", ""), date: pickCase(raw, "date", "Date", ""),
fragments: fragments.map(normalizeFragment), fragments: fragments.map(normalizeFragment),
rawContent: pickCase(raw, "rawContent", "RawContent", ""), rawContent: pickCase(raw, "rawContent", "RawContent", ""),
sections: Object.fromEntries( sections: Object.fromEntries(
Object.entries(sections).map(([key, value]) => [ Object.entries(sections).map(([key, value]) => [key, normalizeSection(value)])
key, )
normalizeSection(value),
]),
),
}; };
} }
function normalizeEntryListItem(raw: EntryListItemDtoRaw): EntryListItemDto { function normalizeEntryListItem(raw: EntryListItemDtoRaw): EntryListItemDto {
return { return {
fileName: pickCase(raw, "fileName", "FileName", ""), fileName: pickCase(raw, "fileName", "FileName", ""),
filePath: pickCase(raw, "filePath", "FilePath", ""), filePath: pickCase(raw, "filePath", "FilePath", "")
}; };
} }
function normalizeEntryLoadResult( function normalizeEntryLoadResult(raw: EntryLoadResultDtoRaw): EntryLoadResultDto {
raw: EntryLoadResultDtoRaw, const nestedEntry = pickCase(raw, "entry", "Entry", undefined as JournalEntryDtoRaw | undefined);
): EntryLoadResultDto { const entry =
const nestedEntry = pickCase( nestedEntry
raw, ? normalizeJournalEntry(nestedEntry)
"entry", : normalizeJournalEntry({
"Entry", date: pickCase(raw, "date", "Date", undefined as string | undefined),
undefined as JournalEntryDtoRaw | undefined, rawContent: pickCase(raw, "rawContent", "RawContent", undefined as string | undefined),
); fragments: [],
const entry = nestedEntry sections: {}
? normalizeJournalEntry(nestedEntry) });
: normalizeJournalEntry({
date: pickCase(raw, "date", "Date", undefined as string | undefined),
rawContent: pickCase(
raw,
"rawContent",
"RawContent",
undefined as string | undefined,
),
fragments: [],
sections: {},
});
return { return {
fileName: pickCase(raw, "fileName", "FileName", ""), fileName: pickCase(raw, "fileName", "FileName", ""),
filePath: pickCase(raw, "filePath", "FilePath", ""), filePath: pickCase(raw, "filePath", "FilePath", ""),
entry, entry
}; };
} }
function normalizeEntrySearchResult( function normalizeEntrySearchResult(raw: EntrySearchResultDtoRaw): EntrySearchResultDto {
raw: EntrySearchResultDtoRaw, const nestedEntry = pickCase(raw, "entry", "Entry", undefined as JournalEntryDtoRaw | undefined);
): EntrySearchResultDto { const entry =
const nestedEntry = pickCase( nestedEntry
raw, ? normalizeJournalEntry(nestedEntry)
"entry", : normalizeJournalEntry({
"Entry", date: pickCase(raw, "date", "Date", undefined as string | undefined),
undefined as JournalEntryDtoRaw | undefined, rawContent: pickCase(raw, "rawContent", "RawContent", undefined as string | undefined),
); fragments: [],
const entry = nestedEntry sections: {}
? normalizeJournalEntry(nestedEntry) });
: normalizeJournalEntry({
date: pickCase(raw, "date", "Date", undefined as string | undefined),
rawContent: pickCase(
raw,
"rawContent",
"RawContent",
undefined as string | undefined,
),
fragments: [],
sections: {},
});
return { return {
fileName: pickCase(raw, "fileName", "FileName", ""), fileName: pickCase(raw, "fileName", "FileName", ""),
entry, entry
}; };
} }
export async function listEntries(): Promise<EntryListItemDto[]> { export async function listEntries(dataDirectory?: string): Promise<EntryListItemDto[]> {
const data = await sendCommand<EntryListItemDtoRaw[]>({ const data = await sendCommand<EntryListItemDtoRaw[]>({
action: "entries.list", action: "entries.list",
payload: {}, payload: { dataDirectory }
}); });
return data return data.map(normalizeEntryListItem).filter((item) => Boolean(item.filePath));
.map(normalizeEntryListItem)
.filter((item) => Boolean(item.filePath));
} }
export async function loadEntry(filePath: string): Promise<EntryLoadResultDto> { export async function loadEntry(filePath: string): Promise<EntryLoadResultDto> {
const data = await sendCommand<EntryLoadResultDtoRaw>({ const data = await sendCommand<EntryLoadResultDtoRaw>({
action: "entries.load", action: "entries.load",
payload: { filePath }, payload: { filePath }
}); });
return normalizeEntryLoadResult(data); return normalizeEntryLoadResult(data);
@ -241,30 +194,26 @@ export async function saveEntry(payload: {
}): Promise<EntrySaveResultDto> { }): Promise<EntrySaveResultDto> {
const data = await sendCommand<EntrySaveResultDtoRaw>({ const data = await sendCommand<EntrySaveResultDtoRaw>({
action: "entries.save", action: "entries.save",
payload, payload
}); });
return { return {
filePath: pickCase(data, "filePath", "FilePath", ""), filePath: pickCase(data, "filePath", "FilePath", "")
}; };
} }
export async function deleteEntry(filePath: string): Promise<boolean> { export async function deleteEntry(filePath: string): Promise<boolean> {
return sendCommand<boolean>({ return sendCommand<boolean>({
action: "entries.delete", action: "entries.delete",
payload: { filePath }, payload: { filePath }
}); });
} }
export async function searchEntries( export async function searchEntries(payload: EntrySearchRequestDto): Promise<EntrySearchResultDto[]> {
payload: EntrySearchRequestDto,
): Promise<EntrySearchResultDto[]> {
const data = await sendCommand<EntrySearchResultDtoRaw[]>({ const data = await sendCommand<EntrySearchResultDtoRaw[]>({
action: "search.entries", action: "search.entries",
payload, payload
}); });
return data return data.map(normalizeEntrySearchResult).filter((item) => Boolean(item.fileName));
.map(normalizeEntrySearchResult)
.filter((item) => Boolean(item.fileName));
} }

View File

@ -41,13 +41,13 @@ export function normalizeFragment(raw: FragmentDtoRaw): FragmentDto {
type: pickCase(raw, "type", "Type", ""), type: pickCase(raw, "type", "Type", ""),
description: pickCase(raw, "description", "Description", ""), description: pickCase(raw, "description", "Description", ""),
time: pickCase(raw, "time", "Time", ""), time: pickCase(raw, "time", "Time", ""),
tags: pickCase(raw, "tags", "Tags", [] as string[]), tags: pickCase(raw, "tags", "Tags", [] as string[])
}; };
} }
export async function listFragments(): Promise<FragmentDto[]> { export async function listFragments(): Promise<FragmentDto[]> {
const data = await sendCommand<FragmentDtoRaw[]>({ const data = await sendCommand<FragmentDtoRaw[]>({
action: "fragments.list", action: "fragments.list"
}); });
return data.map(normalizeFragment).filter((item) => Boolean(item.id)); return data.map(normalizeFragment).filter((item) => Boolean(item.id));
} }
@ -55,37 +55,32 @@ export async function listFragments(): Promise<FragmentDto[]> {
export async function getFragment(id: string): Promise<FragmentDto | null> { export async function getFragment(id: string): Promise<FragmentDto | null> {
const data = await sendCommand<FragmentDtoRaw | null>({ const data = await sendCommand<FragmentDtoRaw | null>({
action: "fragments.get", action: "fragments.get",
id, id
}); });
if (!data) return null; if (!data) return null;
const normalized = normalizeFragment(data); const normalized = normalizeFragment(data);
return normalized.id ? normalized : null; return normalized.id ? normalized : null;
} }
export async function createFragment( export async function createFragment(payload: CreateFragmentPayload): Promise<FragmentDto> {
payload: CreateFragmentPayload,
): Promise<FragmentDto> {
const data = await sendCommand<FragmentDtoRaw>({ const data = await sendCommand<FragmentDtoRaw>({
action: "fragments.create", action: "fragments.create",
payload, payload
}); });
return normalizeFragment(data); return normalizeFragment(data);
} }
export function updateFragment( export function updateFragment(id: string, payload: UpdateFragmentPayload): Promise<boolean> {
id: string,
payload: UpdateFragmentPayload,
): Promise<boolean> {
return sendCommand<boolean>({ return sendCommand<boolean>({
action: "fragments.update", action: "fragments.update",
id, id,
payload, payload
}); });
} }
export function deleteFragment(id: string): Promise<boolean> { export function deleteFragment(id: string): Promise<boolean> {
return sendCommand<boolean>({ return sendCommand<boolean>({
action: "fragments.delete", action: "fragments.delete",
id, id
}); });
} }

View File

@ -38,13 +38,13 @@ export function normalizeList(raw: ListDocumentDtoRaw): ListDocumentDto {
label: pickCase(raw, "label", "Label", ""), label: pickCase(raw, "label", "Label", ""),
content: pickCase(raw, "content", "Content", ""), content: pickCase(raw, "content", "Content", ""),
createdAt: pickCase(raw, "createdAt", "CreatedAt", ""), createdAt: pickCase(raw, "createdAt", "CreatedAt", ""),
updatedAt: pickCase(raw, "updatedAt", "UpdatedAt", ""), updatedAt: pickCase(raw, "updatedAt", "UpdatedAt", "")
}; };
} }
export async function listLists(): Promise<ListDocumentDto[]> { export async function listLists(): Promise<ListDocumentDto[]> {
const data = await sendCommand<ListDocumentDtoRaw[]>({ const data = await sendCommand<ListDocumentDtoRaw[]>({
action: "lists.list", action: "lists.list"
}); });
return data.map(normalizeList).filter((item) => Boolean(item.id)); return data.map(normalizeList).filter((item) => Boolean(item.id));
} }
@ -52,37 +52,32 @@ export async function listLists(): Promise<ListDocumentDto[]> {
export async function getList(id: string): Promise<ListDocumentDto | null> { export async function getList(id: string): Promise<ListDocumentDto | null> {
const data = await sendCommand<ListDocumentDtoRaw | null>({ const data = await sendCommand<ListDocumentDtoRaw | null>({
action: "lists.get", action: "lists.get",
id, id
}); });
if (!data) return null; if (!data) return null;
const normalized = normalizeList(data); const normalized = normalizeList(data);
return normalized.id ? normalized : null; return normalized.id ? normalized : null;
} }
export async function createList( export async function createList(payload: CreateListPayload): Promise<ListDocumentDto> {
payload: CreateListPayload,
): Promise<ListDocumentDto> {
const data = await sendCommand<ListDocumentDtoRaw>({ const data = await sendCommand<ListDocumentDtoRaw>({
action: "lists.create", action: "lists.create",
payload, payload
}); });
return normalizeList(data); return normalizeList(data);
} }
export function updateList( export function updateList(id: string, payload: UpdateListPayload): Promise<boolean> {
id: string,
payload: UpdateListPayload,
): Promise<boolean> {
return sendCommand<boolean>({ return sendCommand<boolean>({
action: "lists.update", action: "lists.update",
id, id,
payload, payload
}); });
} }
export function deleteList(id: string): Promise<boolean> { export function deleteList(id: string): Promise<boolean> {
return sendCommand<boolean>({ return sendCommand<boolean>({
action: "lists.delete", action: "lists.delete",
id, id
}); });
} }

View File

@ -1,16 +1,14 @@
type UnknownObject = Record<string, unknown>; type UnknownObject = Record<string, unknown>;
function asObject(value: unknown): UnknownObject | undefined { function asObject(value: unknown): UnknownObject | undefined {
return value && typeof value === "object" return value && typeof value === "object" ? (value as UnknownObject) : undefined;
? (value as UnknownObject)
: undefined;
} }
export function pickCase<T>( export function pickCase<T>(
source: unknown, source: unknown,
camelKey: string, camelKey: string,
pascalKey: string, pascalKey: string,
fallback: T, fallback: T
): T { ): T {
const obj = asObject(source); const obj = asObject(source);
if (!obj) return fallback; if (!obj) return fallback;

View File

@ -1,33 +0,0 @@
import { invoke } from "$lib/runtime/invoke";
import {
startRecording as startMicRecording,
stopRecording as stopMicRecording,
} from "tauri-plugin-mic-recorder-api";
type SpeechControlResult = {
running: boolean;
pid?: number;
launch?: string;
};
export async function startSpeechDictation(): Promise<SpeechControlResult> {
return invoke<SpeechControlResult>("speech_start");
}
export async function stopSpeechDictation(): Promise<SpeechControlResult> {
return invoke<SpeechControlResult>("speech_stop");
}
export async function probeMicrophoneAccess(): Promise<string> {
await startMicRecording();
await new Promise((resolve) => setTimeout(resolve, 300));
const outputPath = await stopMicRecording();
try {
await invoke<{ deleted: boolean }>("speech_cleanup_probe", {
path: outputPath,
});
} catch {
// Keep probe non-blocking; cleanup failure should not break dictation start.
}
return outputPath;
}

View File

@ -37,38 +37,32 @@ type EntryTemplateSaveResultDtoRaw = {
FilePath?: string; FilePath?: string;
}; };
function normalizeTemplateItem( function normalizeTemplateItem(raw: EntryTemplateItemDtoRaw): EntryTemplateItemDto {
raw: EntryTemplateItemDtoRaw,
): EntryTemplateItemDto {
return { return {
fileName: pickCase(raw, "fileName", "FileName", ""), fileName: pickCase(raw, "fileName", "FileName", ""),
filePath: pickCase(raw, "filePath", "FilePath", ""), filePath: pickCase(raw, "filePath", "FilePath", "")
}; };
} }
export async function listEntryTemplates(): Promise<EntryTemplateItemDto[]> { export async function listEntryTemplates(dataDirectory?: string): Promise<EntryTemplateItemDto[]> {
const data = await sendCommand<EntryTemplateItemDtoRaw[]>({ const data = await sendCommand<EntryTemplateItemDtoRaw[]>({
action: "templates.list", action: "templates.list",
payload: {}, payload: { dataDirectory }
}); });
return data return data.map(normalizeTemplateItem).filter((item) => Boolean(item.filePath));
.map(normalizeTemplateItem)
.filter((item) => Boolean(item.filePath));
} }
export async function loadEntryTemplate( export async function loadEntryTemplate(filePath: string): Promise<EntryTemplateLoadResultDto> {
filePath: string,
): Promise<EntryTemplateLoadResultDto> {
const data = await sendCommand<EntryTemplateLoadResultDtoRaw>({ const data = await sendCommand<EntryTemplateLoadResultDtoRaw>({
action: "templates.load", action: "templates.load",
payload: { filePath }, payload: { filePath }
}); });
return { return {
fileName: pickCase(data, "fileName", "FileName", ""), fileName: pickCase(data, "fileName", "FileName", ""),
filePath: pickCase(data, "filePath", "FilePath", ""), filePath: pickCase(data, "filePath", "FilePath", ""),
content: pickCase(data, "content", "Content", ""), content: pickCase(data, "content", "Content", "")
}; };
} }
@ -76,20 +70,21 @@ export async function saveEntryTemplate(payload: {
name: string; name: string;
content: string; content: string;
filePath?: string; filePath?: string;
dataDirectory?: string;
}): Promise<EntryTemplateSaveResultDto> { }): Promise<EntryTemplateSaveResultDto> {
const data = await sendCommand<EntryTemplateSaveResultDtoRaw>({ const data = await sendCommand<EntryTemplateSaveResultDtoRaw>({
action: "templates.save", action: "templates.save",
payload, payload
}); });
return { return {
filePath: pickCase(data, "filePath", "FilePath", ""), filePath: pickCase(data, "filePath", "FilePath", "")
}; };
} }
export async function deleteEntryTemplate(filePath: string): Promise<boolean> { export async function deleteEntryTemplate(filePath: string): Promise<boolean> {
return sendCommand<boolean>({ return sendCommand<boolean>({
action: "templates.delete", action: "templates.delete",
payload: { filePath }, payload: { filePath }
}); });
} }

View File

@ -66,7 +66,7 @@ function normalizeItem(raw: TodoItemDtoRaw): TodoItemDto {
listId: pickCase(raw, "listId", "ListId", ""), listId: pickCase(raw, "listId", "ListId", ""),
text: pickCase(raw, "text", "Text", ""), text: pickCase(raw, "text", "Text", ""),
done: pickCase(raw, "done", "Done", false), done: pickCase(raw, "done", "Done", false),
sortOrder: pickCase(raw, "sortOrder", "SortOrder", 0), sortOrder: pickCase(raw, "sortOrder", "SortOrder", 0)
}; };
} }
@ -76,13 +76,13 @@ function normalizeList(raw: TodoListDtoRaw): TodoListDto {
id: pickCase(raw, "id", "Id", ""), id: pickCase(raw, "id", "Id", ""),
label: pickCase(raw, "label", "Label", ""), label: pickCase(raw, "label", "Label", ""),
createdAt: pickCase(raw, "createdAt", "CreatedAt", ""), createdAt: pickCase(raw, "createdAt", "CreatedAt", ""),
items: rawItems.map(normalizeItem), items: rawItems.map(normalizeItem)
}; };
} }
export async function listTodoLists(): Promise<TodoListDto[]> { export async function listTodoLists(): Promise<TodoListDto[]> {
const data = await sendCommand<TodoListDtoRaw[]>({ const data = await sendCommand<TodoListDtoRaw[]>({
action: "todos.list", action: "todos.list"
}); });
return data.map(normalizeList).filter((item) => Boolean(item.id)); return data.map(normalizeList).filter((item) => Boolean(item.id));
} }
@ -90,65 +90,55 @@ export async function listTodoLists(): Promise<TodoListDto[]> {
export async function getTodoList(id: string): Promise<TodoListDto | null> { export async function getTodoList(id: string): Promise<TodoListDto | null> {
const data = await sendCommand<TodoListDtoRaw | null>({ const data = await sendCommand<TodoListDtoRaw | null>({
action: "todos.get", action: "todos.get",
id, id
}); });
if (!data) return null; if (!data) return null;
const normalized = normalizeList(data); const normalized = normalizeList(data);
return normalized.id ? normalized : null; return normalized.id ? normalized : null;
} }
export async function createTodoList( export async function createTodoList(payload: CreateTodoListPayload): Promise<TodoListDto> {
payload: CreateTodoListPayload,
): Promise<TodoListDto> {
const data = await sendCommand<TodoListDtoRaw>({ const data = await sendCommand<TodoListDtoRaw>({
action: "todos.create", action: "todos.create",
payload, payload
}); });
return normalizeList(data); return normalizeList(data);
} }
export function updateTodoList( export function updateTodoList(id: string, payload: UpdateTodoListPayload): Promise<boolean> {
id: string,
payload: UpdateTodoListPayload,
): Promise<boolean> {
return sendCommand<boolean>({ return sendCommand<boolean>({
action: "todos.update", action: "todos.update",
id, id,
payload, payload
}); });
} }
export function deleteTodoList(id: string): Promise<boolean> { export function deleteTodoList(id: string): Promise<boolean> {
return sendCommand<boolean>({ return sendCommand<boolean>({
action: "todos.delete", action: "todos.delete",
id, id
}); });
} }
export async function createTodoItem( export async function createTodoItem(payload: CreateTodoItemPayload): Promise<TodoItemDto> {
payload: CreateTodoItemPayload,
): Promise<TodoItemDto> {
const data = await sendCommand<TodoItemDtoRaw>({ const data = await sendCommand<TodoItemDtoRaw>({
action: "todos.items.create", action: "todos.items.create",
payload, payload
}); });
return normalizeItem(data); return normalizeItem(data);
} }
export function updateTodoItem( export function updateTodoItem(id: string, payload: UpdateTodoItemPayload): Promise<boolean> {
id: string,
payload: UpdateTodoItemPayload,
): Promise<boolean> {
return sendCommand<boolean>({ return sendCommand<boolean>({
action: "todos.items.update", action: "todos.items.update",
id, id,
payload, payload
}); });
} }
export function deleteTodoItem(id: string): Promise<boolean> { export function deleteTodoItem(id: string): Promise<boolean> {
return sendCommand<boolean>({ return sendCommand<boolean>({
action: "todos.items.delete", action: "todos.items.delete",
id, id
}); });
} }

View File

@ -10,3 +10,4 @@ export type BackendCommand = {
export type BackendOk<T> = { ok: true; data: T }; export type BackendOk<T> = { ok: true; data: T };
export type BackendErr = { ok: false; error: string }; export type BackendErr = { ok: false; error: string };
export type BackendResponse<T> = BackendOk<T> | BackendErr; export type BackendResponse<T> = BackendOk<T> | BackendErr;

View File

@ -1,4 +1,3 @@
<!-- @format -->
<script lang="ts"> <script lang="ts">
import { createEventDispatcher } from "svelte"; import { createEventDispatcher } from "svelte";
@ -68,15 +67,9 @@
<div class="modal-actions"> <div class="modal-actions">
{#if showCancel} {#if showCancel}
<button type="button" class="secondary" on:click={handleCancel} <button type="button" class="secondary" on:click={handleCancel}>{cancelText}</button>
>{cancelText}</button
>
{/if} {/if}
<button <button type="button" class:danger={tone === "danger"} on:click={handleConfirm}>
type="button"
class:danger={tone === "danger"}
on:click={handleConfirm}
>
{confirmText} {confirmText}
</button> </button>
</div> </div>
@ -162,29 +155,4 @@
background: var(--surface-3); background: var(--surface-3);
color: var(--text-primary); color: var(--text-primary);
} }
@media (max-width: 820px) {
.modal-backdrop {
align-items: end;
padding: 12px 12px calc(12px + env(safe-area-inset-bottom, 0px));
}
.modal {
width: 100%;
max-width: none;
border-radius: 16px;
padding: 16px;
gap: 12px;
}
.modal-actions {
flex-direction: column-reverse;
align-items: stretch;
}
.modal-actions button {
width: 100%;
min-height: 42px;
}
}
</style> </style>

View File

@ -1,31 +1,12 @@
<!-- @format -->
<script lang="ts"> <script lang="ts">
export let onVisibleMonthChange: (month: { export let onVisibleMonthChange: (month: { year: number; month: number; label: string }) => void = () => {};
year: number; export let onSelectedDateChange: (payload: { year: number; month: number; day: number; key: string }) => void =
month: number; () => {};
label: string;
}) => void = () => {};
export let onSelectedDateChange: (payload: {
year: number;
month: number;
day: number;
key: string;
}) => void = () => {};
export let onDateActivate: (payload: {
year: number;
month: number;
day: number;
key: string;
}) => void = () => {};
const today = new Date(); const today = new Date();
let currentYear = today.getFullYear(); let currentYear = today.getFullYear();
let currentMonth = today.getMonth(); let currentMonth = today.getMonth();
let selectedDateKey = getDateKey( let selectedDateKey = getDateKey(today.getFullYear(), today.getMonth(), today.getDate());
today.getFullYear(),
today.getMonth(),
today.getDate(),
);
const weekdays = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; const weekdays = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
@ -58,14 +39,7 @@
if (!cell.inMonth) { if (!cell.inMonth) {
setViewDate(cell.year, cell.month); setViewDate(cell.year, cell.month);
} }
const key = getDateKey(cell.year, cell.month, cell.day); selectedDateKey = getDateKey(cell.year, cell.month, cell.day);
selectedDateKey = key;
onDateActivate({
year: cell.year,
month: cell.month,
day: cell.day,
key,
});
} }
function getCalendarCells(year: number, month: number): CalendarCell[] { function getCalendarCells(year: number, month: number): CalendarCell[] {
@ -80,20 +54,14 @@
for (let i = 0; i < startOffset; i += 1) { for (let i = 0; i < startOffset; i += 1) {
const day = prevMonthLastDay - startOffset + i + 1; const day = prevMonthLastDay - startOffset + i + 1;
const prevMonthDate = new Date(year, month - 1, day); const prevMonthDate = new Date(year, month - 1, day);
const key = getDateKey( const key = getDateKey(prevMonthDate.getFullYear(), prevMonthDate.getMonth(), day);
prevMonthDate.getFullYear(),
prevMonthDate.getMonth(),
day,
);
nextCells.push({ nextCells.push({
day, day,
month: prevMonthDate.getMonth(), month: prevMonthDate.getMonth(),
year: prevMonthDate.getFullYear(), year: prevMonthDate.getFullYear(),
inMonth: false, inMonth: false,
isToday: isToday: key === getDateKey(today.getFullYear(), today.getMonth(), today.getDate()),
key === isSelected: key === selectedDateKey
getDateKey(today.getFullYear(), today.getMonth(), today.getDate()),
isSelected: key === selectedDateKey,
}); });
} }
@ -104,73 +72,43 @@
month, month,
year, year,
inMonth: true, inMonth: true,
isToday: isToday: key === getDateKey(today.getFullYear(), today.getMonth(), today.getDate()),
key === isSelected: key === selectedDateKey
getDateKey(today.getFullYear(), today.getMonth(), today.getDate()),
isSelected: key === selectedDateKey,
}); });
} }
const trailing = (7 - (nextCells.length % 7)) % 7; const trailing = (7 - (nextCells.length % 7)) % 7;
for (let day = 1; day <= trailing; day += 1) { for (let day = 1; day <= trailing; day += 1) {
const nextMonthDate = new Date(year, month + 1, day); const nextMonthDate = new Date(year, month + 1, day);
const key = getDateKey( const key = getDateKey(nextMonthDate.getFullYear(), nextMonthDate.getMonth(), day);
nextMonthDate.getFullYear(),
nextMonthDate.getMonth(),
day,
);
nextCells.push({ nextCells.push({
day, day,
month: nextMonthDate.getMonth(), month: nextMonthDate.getMonth(),
year: nextMonthDate.getFullYear(), year: nextMonthDate.getFullYear(),
inMonth: false, inMonth: false,
isToday: isToday: key === getDateKey(today.getFullYear(), today.getMonth(), today.getDate()),
key === isSelected: key === selectedDateKey
getDateKey(today.getFullYear(), today.getMonth(), today.getDate()),
isSelected: key === selectedDateKey,
}); });
} }
return nextCells; return nextCells;
} }
$: monthLabel = new Date(currentYear, currentMonth, 1).toLocaleString( $: monthLabel = new Date(currentYear, currentMonth, 1).toLocaleString(undefined, { month: "long" });
undefined,
{ month: "long" },
);
$: cells = getCalendarCells(currentYear, currentMonth); $: cells = getCalendarCells(currentYear, currentMonth);
$: onVisibleMonthChange({ $: onVisibleMonthChange({ year: currentYear, month: currentMonth, label: monthLabel });
year: currentYear,
month: currentMonth,
label: monthLabel,
});
$: { $: {
const parts = selectedDateKey.split("-"); const parts = selectedDateKey.split("-");
const [year, month, day] = parts.map((value) => Number(value)); const [year, month, day] = parts.map((value) => Number(value));
if ( if (parts.length === 3 && !Number.isNaN(year) && !Number.isNaN(month) && !Number.isNaN(day)) {
parts.length === 3 && onSelectedDateChange({ year, month: month - 1, day, key: selectedDateKey });
!Number.isNaN(year) &&
!Number.isNaN(month) &&
!Number.isNaN(day)
) {
onSelectedDateChange({
year,
month: month - 1,
day,
key: selectedDateKey,
});
} }
} }
</script> </script>
<section class="calendar-widget" aria-label="Monthly calendar"> <section class="calendar-widget" aria-label="Monthly calendar">
<header class="calendar-header"> <header class="calendar-header">
<button <button type="button" class="nav-icon" aria-label="Previous month" on:click={() => changeMonth(-1)}>
type="button"
class="nav-icon"
aria-label="Previous month"
on:click={() => changeMonth(-1)}
>
<span class="material-symbols-outlined">chevron_left</span> <span class="material-symbols-outlined">chevron_left</span>
</button> </button>
@ -179,12 +117,7 @@
<span>{currentYear}</span> <span>{currentYear}</span>
</div> </div>
<button <button type="button" class="nav-icon" aria-label="Next month" on:click={() => changeMonth(1)}>
type="button"
class="nav-icon"
aria-label="Next month"
on:click={() => changeMonth(1)}
>
<span class="material-symbols-outlined">chevron_right</span> <span class="material-symbols-outlined">chevron_right</span>
</button> </button>
</header> </header>
@ -206,7 +139,7 @@
aria-label={`Day ${cell.day}`} aria-label={`Day ${cell.day}`}
on:click={() => selectCell(cell)} on:click={() => selectCell(cell)}
> >
<span class="day-number">{cell.day}</span> {cell.day}
</button> </button>
{/each} {/each}
</div> </div>
@ -280,20 +213,12 @@
} }
.calendar-cell { .calendar-cell {
height: 36px; height: 30px;
border-radius: 7px; border-radius: 7px;
border: 1px solid transparent; border: 1px solid transparent;
font-size: 0.76rem; font-size: 0.76rem;
color: var(--text-muted); color: var(--text-muted);
cursor: pointer; cursor: pointer;
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.day-number {
line-height: 1;
} }
.calendar-cell:hover { .calendar-cell:hover {

View File

@ -1,706 +0,0 @@
<!-- @format -->
<script lang="ts">
import type { AiHealthDto, CoachPlanDto } from "$lib/backend/ai";
import {
activeConversationStore,
sendConversationMessage,
clearActiveConversation,
} from "$lib/stores/conversations";
export let health: AiHealthDto | null = null;
export let healthChecking = false;
export let coachBusy = false;
export let coachError = "";
export let coachPlan: CoachPlanDto | null = null;
export let coachKind = "";
$: chatBusy = $activeConversationStore.busy;
$: chatMessages = $activeConversationStore.messages;
let chatInput = "";
function handleChatSubmit() {
const prompt = chatInput.trim();
if (!prompt) return;
chatInput = "";
void sendConversationMessage(prompt);
}
function handleChatKeydown(event: KeyboardEvent) {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
handleChatSubmit();
}
}
const kindLabels: Record<string, string> = {
daily: "Daily Check-In",
daily_checkin: "Daily Check-In",
evening: "Evening Review",
evening_review: "Evening Review",
weekly: "Weekly Review",
weekly_review: "Weekly Review",
};
</script>
<div class="coach-panel">
<!-- Health banner -->
<div
class="health-banner"
class:is-healthy={health?.healthy}
class:is-unhealthy={health && !health.healthy}
>
{#if healthChecking}
<span class="health-dot checking"></span>
<span class="health-label">Checking AI status…</span>
{:else if health}
<span
class="health-dot"
class:healthy={health.healthy}
class:unhealthy={!health.healthy}
></span>
<span class="health-label">
{health.provider || "AI"}{health.healthy ? "Ready" : "Unavailable"}
</span>
{#if health.message}
<span class="health-message">{health.message}</span>
{/if}
{:else}
<span class="health-dot unknown"></span>
<span class="health-label">AI status unknown</span>
{/if}
</div>
<!-- Coach result -->
{#if coachBusy}
<div class="coach-loading">
<div class="spinner"></div>
<p>Running {kindLabels[coachKind] ?? "coach session"}</p>
<p class="loading-hint">
This may take a moment while the model generates a response.
</p>
</div>
{:else if coachError}
<div class="coach-error">
<span class="material-symbols-outlined error-icon">error</span>
<p>{coachError}</p>
</div>
{:else if coachPlan}
<article class="coach-plan">
<header class="plan-header">
<span class="plan-kind"
>{kindLabels[coachPlan.kind] ?? coachPlan.kind}</span
>
<h2>{coachPlan.title}</h2>
</header>
{#if coachPlan.summary}
<section class="plan-section">
<h3>Summary</h3>
<p>{coachPlan.summary}</p>
</section>
{/if}
{#if coachPlan.questions.length > 0}
<section class="plan-section">
<h3>Reflection Questions</h3>
<ol class="plan-list">
{#each coachPlan.questions as question}
<li>{question}</li>
{/each}
</ol>
</section>
{/if}
{#if coachPlan.suggestedNextActions.length > 0}
<section class="plan-section">
<h3>Suggested Next Actions</h3>
<ul class="plan-list">
{#each coachPlan.suggestedNextActions as action}
<li>{action}</li>
{/each}
</ul>
</section>
{/if}
{#if coachPlan.suggestedTags.length > 0}
<section class="plan-section">
<h3>Suggested Tags</h3>
<div class="tag-list">
{#each coachPlan.suggestedTags as tag}
<span class="tag">{tag}</span>
{/each}
</div>
</section>
{/if}
{#if coachPlan.evidence.length > 0}
<section class="plan-section">
<h3>Evidence</h3>
<ul class="evidence-list">
{#each coachPlan.evidence as item}
<li>
<span class="evidence-text">{item.text}</span>
{#if item.recordId}
<span class="evidence-ref">({item.recordId})</span>
{/if}
</li>
{/each}
</ul>
</section>
{/if}
{#if coachPlan.patchProposal}
<section class="plan-section">
<h3>Patch Proposal</h3>
<div class="patch-proposal">
<span class="patch-kind">{coachPlan.patchProposal.kind}</span>
{#if coachPlan.patchProposal.description}
<p>{coachPlan.patchProposal.description}</p>
{/if}
{#if coachPlan.patchProposal.content}
<pre class="patch-content">{coachPlan.patchProposal.content}</pre>
{/if}
</div>
</section>
{/if}
</article>
{:else if chatMessages.length === 0 && !chatBusy}
<div class="coach-empty">
<span class="material-symbols-outlined empty-icon">psychology</span>
<p>
Select a coaching session from the sidebar, or ask a question using the
input below.
</p>
</div>
{/if}
<!-- Chat conversation -->
{#if chatMessages.length > 0}
<section class="chat-history">
<h3>Conversation</h3>
<div class="chat-messages">
{#each chatMessages as msg}
<div
class="chat-msg"
class:chat-user={msg.role === "user"}
class:chat-assistant={msg.role === "assistant"}
class:chat-error={msg.role === "error"}
>
<span class="chat-role"
>{msg.role === "user"
? "You"
: msg.role === "error"
? "Error"
: "AI"}</span
>
<div class="chat-text">{msg.text}</div>
</div>
{/each}
{#if chatBusy}
<div class="chat-msg chat-assistant">
<span class="chat-role">AI</span>
<div class="chat-thinking">
<div class="spinner-sm"></div>
<span>Thinking…</span>
</div>
</div>
{/if}
</div>
</section>
{:else if chatBusy}
<div class="chat-loading">
<div class="spinner"></div>
<p>Thinking…</p>
</div>
{/if}
<!-- Chat input (pinned at bottom) -->
<div class="chat-input-bar">
<div class="chat-input-row">
<input
type="text"
bind:value={chatInput}
placeholder="Ask a question…"
on:keydown={handleChatKeydown}
disabled={chatBusy}
/>
<button
type="button"
class="chat-send-btn"
on:click={handleChatSubmit}
disabled={chatBusy || !chatInput.trim()}
aria-label="Send"
>
<span class="material-symbols-outlined">send</span>
</button>
</div>
{#if chatMessages.length > 0}
<button
type="button"
class="chat-clear-btn"
on:click={clearActiveConversation}
>
Clear Chat
</button>
{/if}
</div>
</div>
<style>
.coach-panel {
display: flex;
flex-direction: column;
gap: 20px;
padding: 24px 24px 0;
overflow: auto;
min-height: 0;
flex: 1;
}
/* ── Health banner ───────────────────────────────── */
.health-banner {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
border-radius: 10px;
border: 1px solid var(--border-soft);
background: var(--surface-1);
font-size: 0.82rem;
}
.health-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
&.healthy {
background: #4ade80;
}
&.unhealthy {
background: #e06c75;
}
&.checking {
background: var(--text-dim);
animation: pulse 1s ease-in-out infinite;
}
&.unknown {
background: var(--text-dim);
}
}
.health-label {
color: var(--text-primary);
font-weight: 500;
}
.health-message {
color: var(--text-dim);
margin-left: auto;
}
/* ── Coach plan ──────────────────────────────────── */
.coach-plan {
display: flex;
flex-direction: column;
gap: 18px;
}
.plan-header {
display: flex;
flex-direction: column;
gap: 4px;
h2 {
font-size: 1.15rem;
font-weight: 600;
color: var(--text-primary);
}
}
.plan-kind {
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-dim);
}
.plan-section {
display: flex;
flex-direction: column;
gap: 8px;
h3 {
font-size: 0.82rem;
font-weight: 600;
color: var(--text-muted);
letter-spacing: 0.01em;
}
p {
font-size: 0.88rem;
color: var(--text-primary);
line-height: 1.55;
}
}
.plan-list {
display: flex;
flex-direction: column;
gap: 6px;
padding-left: 20px;
li {
font-size: 0.86rem;
color: var(--text-primary);
line-height: 1.5;
}
}
.tag-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.tag {
padding: 3px 10px;
border-radius: 20px;
border: 1px solid var(--border-soft);
background: var(--surface-2);
font-size: 0.76rem;
color: var(--text-muted);
}
.evidence-list {
display: flex;
flex-direction: column;
gap: 6px;
list-style: none;
li {
padding: 8px 12px;
border-radius: 8px;
border: 1px solid var(--border-soft);
background: var(--surface-1);
font-size: 0.84rem;
line-height: 1.45;
}
}
.evidence-text {
color: var(--text-primary);
}
.evidence-ref {
color: var(--text-dim);
font-size: 0.76rem;
margin-left: 6px;
}
.patch-proposal {
padding: 12px;
border-radius: 8px;
border: 1px solid var(--border-soft);
background: var(--surface-1);
display: flex;
flex-direction: column;
gap: 8px;
p {
font-size: 0.84rem;
color: var(--text-primary);
line-height: 1.5;
}
}
.patch-kind {
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-dim);
}
.patch-content {
font-family: "Cascadia Code", "Fira Code", monospace;
font-size: 0.8rem;
color: var(--text-muted);
background: var(--surface-2);
padding: 10px 12px;
border-radius: 6px;
border: 1px solid var(--border-soft);
overflow-x: auto;
white-space: pre-wrap;
word-break: break-word;
}
/* ── Chat conversation ────────────────────────────── */
.chat-history {
display: flex;
flex-direction: column;
gap: 10px;
border-top: 1px solid var(--border-soft);
padding-top: 18px;
h3 {
font-size: 0.82rem;
font-weight: 600;
color: var(--text-muted);
}
}
.chat-messages {
display: flex;
flex-direction: column;
gap: 8px;
}
.chat-msg {
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px 14px;
border-radius: 10px;
border: 1px solid var(--border-soft);
}
.chat-user {
background: var(--surface-2);
align-self: flex-end;
max-width: 85%;
}
.chat-assistant {
background: var(--surface-1);
align-self: flex-start;
max-width: 85%;
}
.chat-error {
background: rgba(224, 108, 117, 0.08);
border-color: #e06c75;
align-self: flex-start;
max-width: 85%;
}
.chat-role {
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-dim);
}
.chat-error .chat-role {
color: #e06c75;
}
.chat-text {
font-size: 0.86rem;
color: var(--text-primary);
line-height: 1.55;
white-space: pre-wrap;
word-break: break-word;
}
.chat-thinking {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.82rem;
color: var(--text-dim);
}
.spinner-sm {
width: 16px;
height: 16px;
border: 2px solid var(--border-soft);
border-top-color: var(--text-muted);
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
/* ── Empty / loading / error ─────────────────────── */
.coach-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
padding: 60px 20px;
text-align: center;
.empty-icon {
font-size: 2.8rem;
color: var(--text-dim);
}
p {
font-size: 0.88rem;
color: var(--text-dim);
max-width: 320px;
}
}
.coach-loading,
.chat-loading {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 40px 20px;
text-align: center;
p {
font-size: 0.88rem;
color: var(--text-muted);
}
}
.loading-hint {
font-size: 0.78rem !important;
color: var(--text-dim) !important;
}
.coach-error {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 12px 14px;
border-radius: 10px;
border: 1px solid #e06c75;
background: rgba(224, 108, 117, 0.08);
.error-icon {
color: #e06c75;
font-size: 1.1rem;
flex-shrink: 0;
margin-top: 1px;
}
p {
font-size: 0.84rem;
color: var(--text-primary);
line-height: 1.45;
}
}
.spinner {
width: 28px;
height: 28px;
border: 3px solid var(--border-soft);
border-top-color: var(--text-muted);
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@keyframes pulse {
0%,
100% {
opacity: 0.4;
}
50% {
opacity: 1;
}
}
/* ── Chat input bar (pinned bottom) ─────────────── */
.chat-input-bar {
position: sticky;
bottom: 0;
display: flex;
flex-direction: column;
gap: 6px;
padding: 12px 0 24px;
background: var(--bg-editor);
margin-top: auto;
}
.chat-input-row {
display: flex;
align-items: center;
gap: 6px;
border: 1px solid var(--border-soft);
border-radius: 10px;
background: var(--surface-1);
padding: 6px 8px 6px 14px;
input {
flex: 1;
min-width: 0;
font-size: 0.86rem;
color: var(--text-primary);
&::placeholder {
color: var(--text-dim);
}
&:disabled {
opacity: 0.5;
}
}
}
.chat-send-btn {
width: 32px;
height: 32px;
display: grid;
place-items: center;
border-radius: 8px;
color: var(--text-muted);
cursor: pointer;
border: 1px solid transparent;
flex-shrink: 0;
.material-symbols-outlined {
font-size: 1rem;
}
&:hover {
background: var(--bg-hover);
border-color: var(--border-soft);
color: var(--text-primary);
}
&:disabled {
opacity: 0.4;
cursor: default;
}
}
.chat-clear-btn {
align-self: flex-start;
padding: 4px 10px;
border-radius: 6px;
border: 1px solid var(--border-soft);
background: transparent;
color: var(--text-dim);
font-size: 0.74rem;
cursor: pointer;
&:hover {
color: var(--text-primary);
border-color: var(--border-strong);
background: var(--bg-hover);
}
}
</style>

View File

@ -1,173 +1,20 @@
<!-- @format -->
<script lang="ts"> <script lang="ts">
import FragmentEditor from "$lib/components/editor/FragmentEditor.svelte"; import FragmentEditor from "$lib/components/editor/FragmentEditor.svelte";
import ListEditor from "$lib/components/editor/ListEditor.svelte";
import TodoEditor from "$lib/components/editor/TodoEditor.svelte"; import TodoEditor from "$lib/components/editor/TodoEditor.svelte";
import MarkdownEditor from "$lib/components/editor/MarkdownEditor.svelte"; import MarkdownEditor from "$lib/components/editor/MarkdownEditor.svelte";
import CoachPanel from "$lib/components/CoachPanel.svelte";
import { aiStatusStore, coachStateStore } from "$lib/stores/ai";
export let activeSection = "entries"; export let activeSection = "entries";
export let openDocumentId = "entries/daily-notes"; export let openDocumentId = "entries/daily-notes";
export let openDocumentName = "Daily Notes"; export let openDocumentName = "Daily Notes";
export let openDocumentContent = ""; export let openDocumentContent = "";
export let onDocumentContentChange: (content: string) => void = () => {}; export let onDocumentContentChange: (content: string) => void = () => {};
export let onOpenDocument: (doc: { export let onOpenDocument: (doc: { id: string; label: string; initialContent: string }) => void = () => {};
id: string;
label: string;
initialContent: string;
linkedFrom?: {
id: string;
label: string;
initialContent: string;
section: "entries" | "fragments" | "todos" | "lists" | "calendar";
};
}) => void = () => {};
export let onDeleteDocument: (id: string) => void = () => {}; export let onDeleteDocument: (id: string) => void = () => {};
export let showLinkedBackButton = false;
export let onLinkedBack: () => void = () => {};
export let calendarItems: Array<{
id: string;
label: string;
initialContent: string;
}> = [];
export let calendarBusy = false;
export let calendarError = "";
export let previewOnly = true; export let previewOnly = true;
export let onForceSave: () => Promise<void> | void = () => {};
export let onRequestEdit: () => void = () => {};
export let onRequestPreview: () => void = () => {};
type CalendarCard = {
id: string;
label: string;
initialContent: string;
title: string;
summary: string;
hasTrigger: boolean;
hasMood: boolean;
hasOpenTodos: boolean;
};
function deriveSummary(content: string): string {
const lines = content.replace(/\r\n/g, "\n").split("\n");
let inFrontmatter = false;
let frontmatterDone = false;
for (const rawLine of lines) {
const line = rawLine.trim();
if (!frontmatterDone && line === "---") {
inFrontmatter = !inFrontmatter;
if (!inFrontmatter) frontmatterDone = true;
continue;
}
if (inFrontmatter || !line) continue;
if (/^#/.test(line)) continue;
if (/^\*\*Date:\*\*/i.test(line)) continue;
if (/^Date:/i.test(line)) continue;
if (/^(Type:|Tags:)/i.test(line)) continue;
return line.length > 180 ? `${line.slice(0, 177)}...` : line;
}
return "No summary available.";
}
function deriveTitle(label: string, content: string): string {
const heading = content.match(/^#\s+(.+)$/m)?.[1]?.trim();
if (heading) return heading;
return label?.trim() || "Untitled Entry";
}
function toCalendarCard(item: {
id: string;
label: string;
initialContent: string;
}): CalendarCard {
const content = item.initialContent ?? "";
const lower = content.toLowerCase();
return {
...item,
title: deriveTitle(item.label, content),
summary: deriveSummary(content),
hasTrigger:
lower.includes("!trigger") ||
lower.includes("#trigger") ||
lower.includes("#stress"),
hasMood:
lower.includes("mental / emotional snapshot") ||
lower.includes("cognitive state"),
hasOpenTodos: /-\s*\[\s\]/.test(content),
};
}
$: calendarCards = calendarItems.map(toCalendarCard);
</script> </script>
<main class="editor-panel" aria-label="Editor area"> <main class="editor-panel" aria-label="Editor area">
{#if showLinkedBackButton} {#if !openDocumentId}
<div class="editor-nav">
<button
type="button"
class="back-btn"
on:click={onLinkedBack}
aria-label="Back to source entry"
>
<span class="material-symbols-outlined" aria-hidden="true"
>arrow_back</span
>
</button>
</div>
{/if}
{#if activeSection === "calendar"}
<section class="calendar-main" aria-label="Calendar timeline results">
<header class="calendar-main-header">
<h2>Filtered Entries</h2>
</header>
{#if calendarBusy}
<p class="calendar-copy">Loading timeline...</p>
{:else if calendarError}
<p class="calendar-copy is-error">{calendarError}</p>
{:else if calendarItems.length === 0}
<p class="calendar-copy">No entries matched the current filters.</p>
{:else}
<ul class="calendar-list">
{#each calendarCards as item}
<li class:is-active={item.id === openDocumentId}>
<button
type="button"
class="calendar-item-btn"
on:click={() => onOpenDocument(item)}
>
<div class="calendar-item-head">
<h3>{item.title}</h3>
<span class="calendar-date">{item.label}</span>
</div>
<p class="calendar-summary">{item.summary}</p>
<div class="calendar-badges">
{#if item.hasMood}<span class="badge mood">Mood</span>{/if}
{#if item.hasTrigger}<span class="badge trigger">Trigger</span
>{/if}
{#if item.hasOpenTodos}<span class="badge todo"
>Open To-Dos</span
>{/if}
</div>
</button>
</li>
{/each}
</ul>
{/if}
</section>
{:else if activeSection === "coach"}
<CoachPanel
health={$aiStatusStore.health}
healthChecking={$aiStatusStore.checking}
coachBusy={$coachStateStore.busy}
coachError={$coachStateStore.error}
coachPlan={$coachStateStore.plan}
coachKind={$coachStateStore.kind}
/>
{:else if !openDocumentId}
<div class="editor-empty"> <div class="editor-empty">
<span class="material-symbols-outlined empty-icon">edit_note</span> <span class="material-symbols-outlined empty-icon">edit_note</span>
<p>Select or create an item to get started</p> <p>Select or create an item to get started</p>
@ -189,31 +36,20 @@
{openDocumentContent} {openDocumentContent}
{onDocumentContentChange} {onDocumentContentChange}
/> />
{:else if activeSection === "lists"}
<ListEditor
{openDocumentId}
{openDocumentName}
{openDocumentContent}
{onDocumentContentChange}
/>
{:else} {:else}
<MarkdownEditor <MarkdownEditor
{openDocumentId} {openDocumentId}
{openDocumentName} {openDocumentName}
{openDocumentContent} {openDocumentContent}
{onDocumentContentChange} {onDocumentContentChange}
{onForceSave}
{onOpenDocument}
{previewOnly} {previewOnly}
{onRequestEdit}
{onRequestPreview}
/> />
{/if} {/if}
</main> </main>
<style> <style>
.editor-panel { .editor-panel {
background: var(--bg-editor); background: linear-gradient(180deg, var(--surface-2) 0%, var(--bg-editor) 100%);
padding: 18px 20px; padding: 18px 20px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -222,30 +58,6 @@
overflow: hidden; overflow: hidden;
} }
.editor-nav {
display: flex;
align-items: center;
justify-content: flex-start;
}
.back-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: 8px;
border: 1px solid var(--border-soft);
background: color-mix(in srgb, var(--surface-1) 90%, var(--bg-editor) 10%);
color: var(--text-primary);
padding: 0;
cursor: pointer;
}
.back-btn:hover {
background: var(--bg-hover);
}
.editor-empty { .editor-empty {
flex: 1; flex: 1;
display: flex; display: flex;
@ -264,145 +76,4 @@
font-size: 0.88rem; font-size: 0.88rem;
} }
} }
.calendar-main {
flex: 1;
min-height: 0;
overflow: auto;
padding: 4px 8px;
display: flex;
flex-direction: column;
gap: 10px;
}
.calendar-main-header h2 {
font-size: 0.96rem;
font-weight: 600;
color: var(--text-primary);
}
.calendar-copy {
font-size: 0.84rem;
color: var(--text-dim);
}
.calendar-copy.is-error {
color: #e74c3c;
}
.calendar-list {
list-style: none;
display: flex;
flex-direction: column;
gap: 6px;
}
.calendar-list li {
border-radius: 8px;
border: 1px solid var(--border-soft);
background: color-mix(in srgb, var(--surface-1) 92%, var(--bg-editor) 8%);
}
.calendar-list li:hover {
background: var(--bg-hover);
}
.calendar-list li.is-active {
border-color: var(--border-strong);
background: var(--bg-active);
}
.calendar-item-btn {
width: 100%;
text-align: left;
padding: 10px 12px;
color: var(--text-primary);
font-size: 0.86rem;
cursor: pointer;
display: flex;
flex-direction: column;
gap: 7px;
}
.calendar-item-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.calendar-item-head h3 {
font-size: 0.9rem;
font-weight: 600;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.calendar-date {
font-size: 0.74rem;
color: var(--text-dim);
white-space: nowrap;
}
.calendar-summary {
font-size: 0.82rem;
color: var(--text-muted);
line-height: 1.45;
}
.calendar-badges {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.badge {
font-size: 0.68rem;
border-radius: 999px;
border: 1px solid var(--border-soft);
padding: 2px 7px;
color: var(--text-dim);
background: color-mix(in srgb, var(--surface-2) 88%, var(--bg-editor) 12%);
}
.badge.mood {
border-color: color-mix(in srgb, #6ba7ff 40%, var(--border-soft) 60%);
color: #8dbbff;
}
.badge.trigger {
border-color: color-mix(in srgb, #f08c6c 40%, var(--border-soft) 60%);
color: #f5ad95;
}
.badge.todo {
border-color: color-mix(in srgb, #f2c266 40%, var(--border-soft) 60%);
color: #f4d690;
}
@media (max-width: 820px) {
.editor-panel {
padding: 12px 12px calc(18px + env(safe-area-inset-bottom, 0px));
gap: 12px;
}
.calendar-main {
padding: 2px 0 12px;
}
.calendar-item-head {
flex-direction: column;
align-items: flex-start;
}
.calendar-item-head h3 {
white-space: normal;
}
.calendar-date {
white-space: normal;
}
}
</style> </style>

View File

@ -1,4 +1,3 @@
<!-- @format -->
<script lang="ts"> <script lang="ts">
export let activeSection: string | null = "entries"; export let activeSection: string | null = "entries";
export let onSelect: (id: string) => void = () => {}; export let onSelect: (id: string) => void = () => {};
@ -14,8 +13,7 @@
{ id: "calendar", label: "Calendar", icon: "calendar_month" }, { id: "calendar", label: "Calendar", icon: "calendar_month" },
{ id: "fragments", label: "Fragments", icon: "auto_stories" }, { id: "fragments", label: "Fragments", icon: "auto_stories" },
{ id: "todos", label: "To-Do List", icon: "checklist" }, { id: "todos", label: "To-Do List", icon: "checklist" },
{ id: "lists", label: "Lists", icon: "lists" }, { id: "lists", label: "Lists", icon: "lists" }
{ id: "coach", label: "Coach", icon: "psychology" },
]; ];
function selectItem(id: string) { function selectItem(id: string) {
@ -25,7 +23,7 @@
<aside class="navbar" aria-label="Primary navigation"> <aside class="navbar" aria-label="Primary navigation">
<div class="navbar-header"> <div class="navbar-header">
<img src="icon.png" alt="Journal logo" style="height: 48px; width: 48px;" /> <img src="svelte.svg" alt="Journal logo" />
</div> </div>
<nav class="nav-groups" aria-label="Journal sections"> <nav class="nav-groups" aria-label="Journal sections">
@ -66,11 +64,7 @@
align-items: center; align-items: center;
gap: 14px; gap: 14px;
padding: 14px 10px; padding: 14px 10px;
background: linear-gradient( background: linear-gradient(180deg, var(--surface-2) 0%, var(--bg-navbar) 100%);
180deg,
var(--surface-2) 0%,
var(--bg-navbar) 100%
);
border-right: 1px solid var(--border-soft); border-right: 1px solid var(--border-soft);
} }
@ -108,10 +102,7 @@
color: var(--text-dim); color: var(--text-dim);
border: 1px solid transparent; border: 1px solid transparent;
cursor: pointer; cursor: pointer;
transition: transition: background-color 0.14s ease, color 0.14s ease, border-color 0.14s ease;
background-color 0.14s ease,
color 0.14s ease,
border-color 0.14s ease;
} }
.nav-button .material-symbols-outlined { .nav-button .material-symbols-outlined {
@ -138,9 +129,7 @@
background: var(--surface-1); background: var(--surface-1);
border: 1px solid var(--border-strong); border: 1px solid var(--border-strong);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.35); box-shadow: 0 10px 30px rgba(0, 0, 0, 0.35);
transition: transition: opacity 0.12s ease, transform 0.12s ease;
opacity 0.12s ease,
transform 0.12s ease;
} }
.nav-button:hover, .nav-button:hover,
@ -191,44 +180,4 @@
height: 40px; height: 40px;
} }
} }
@media (max-width: 820px) {
.navbar {
gap: 10px;
padding: calc(10px + env(safe-area-inset-top, 0px)) 6px 10px;
}
.navbar-header {
display: block;
}
.nav-groups {
width: 100%;
}
.nav-group {
flex-direction: column;
gap: 2px;
}
.nav-button,
.settings-chip {
width: 38px;
height: 38px;
border-radius: 9px;
}
.settings-chip {
margin-top: auto;
}
.nav-tooltip {
display: none;
}
.nav-button .material-symbols-outlined,
.settings-chip .material-symbols-outlined {
font-size: 1.08rem;
}
}
</style> </style>

File diff suppressed because it is too large Load Diff

View File

@ -1,11 +1,4 @@
<!-- @format -->
<script lang="ts"> <script lang="ts">
import {
probeMicrophoneAccess,
startSpeechDictation,
stopSpeechDictation,
} from "$lib/backend/speech";
import { isTauriRuntime } from "$lib/runtime/invoke";
import { import {
createFragmentFromParsed, createFragmentFromParsed,
deleteFragmentByStoreId, deleteFragmentByStoreId,
@ -14,22 +7,17 @@
parseFragmentContent, parseFragmentContent,
serializeFragment, serializeFragment,
updateFragmentFromParsed, updateFragmentFromParsed,
type FragmentItem, type FragmentItem
} from "$lib/stores/fragments"; } from "$lib/stores/fragments";
import { settingsFragmentTypes, settingsTags } from "$lib/stores/settings"; import { settingsFragmentTypes, settingsTags } from "$lib/stores/settings";
import { renderMarkdown } from "$lib/utils/markdown"; import { renderMarkdown } from "$lib/utils/markdown";
import { onDestroy, onMount } from "svelte";
import { get } from "svelte/store"; import { get } from "svelte/store";
export let openDocumentId = ""; export let openDocumentId = "";
export let openDocumentName = ""; export let openDocumentName = "";
export let openDocumentContent = ""; export let openDocumentContent = "";
export let onDocumentContentChange: (content: string) => void = () => {}; export let onDocumentContentChange: (content: string) => void = () => {};
export let onOpenDocument: (doc: { export let onOpenDocument: (doc: { id: string; label: string; initialContent: string }) => void = () => {};
id: string;
label: string;
initialContent: string;
}) => void = () => {};
export let onDeleteDocument: (id: string) => void = () => {}; export let onDeleteDocument: (id: string) => void = () => {};
export let externalEditRequested = false; export let externalEditRequested = false;
@ -43,40 +31,21 @@
let lastFragmentDocumentId = ""; let lastFragmentDocumentId = "";
let fragmentTypeOptions: string[] = []; let fragmentTypeOptions: string[] = [];
let tagOptions: string[] = []; let tagOptions: string[] = [];
let suppressExternalEditRequest = false;
let dictationBusy = false;
let dictationActive = false;
let dictationError = "";
let dictationStatus = "";
let unlistenTranscript: (() => void) | null = null;
let unlistenSpeechStatus: (() => void) | null = null;
const customTypeValue = "__custom_type__"; const customTypeValue = "__custom_type__";
const customTagValue = "__custom_tag__"; const customTagValue = "__custom_tag__";
function buildFragmentContent(): { function buildFragmentContent(): { title: string; resolvedType: string; body: string; content: string; tags: string[] } | null {
title: string;
resolvedType: string;
body: string;
content: string;
tags: string[];
} | null {
const title = fragmentTitle.trim(); const title = fragmentTitle.trim();
if (!title) return null; if (!title) return null;
const resolvedType = const resolvedType = fragmentType === customTypeValue ? customFragmentType.trim() : fragmentType;
fragmentType === customTypeValue
? customFragmentType.trim()
: fragmentType;
if (!resolvedType) return null; if (!resolvedType) return null;
const selectedTags = const selectedTags = fragmentTag && fragmentTag !== customTagValue ? [fragmentTag] : [];
fragmentTag && fragmentTag !== customTagValue ? [fragmentTag] : [];
const customTags = customFragmentTags const customTags = customFragmentTags
.split(",") .split(",")
.map((tag) => tag.trim()) .map((tag) => tag.trim())
.filter(Boolean); .filter(Boolean);
const tagList = [...selectedTags, ...customTags]; const tagList = [...selectedTags, ...customTags];
const uniqueTagList = [ const uniqueTagList = [...new Set(tagList.map((tag) => tag.toLowerCase()))].map((lower) => {
...new Set(tagList.map((tag) => tag.toLowerCase())),
].map((lower) => {
return tagList.find((tag) => tag.toLowerCase() === lower) ?? lower; return tagList.find((tag) => tag.toLowerCase() === lower) ?? lower;
}); });
const body = fragmentBody.trim() || "Add details for this fragment."; const body = fragmentBody.trim() || "Add details for this fragment.";
@ -84,7 +53,7 @@
title, title,
type: resolvedType, type: resolvedType,
tags: uniqueTagList, tags: uniqueTagList,
body, body
}); });
return { title, resolvedType, body, content, tags: uniqueTagList }; return { title, resolvedType, body, content, tags: uniqueTagList };
} }
@ -103,7 +72,7 @@
title: payload.title, title: payload.title,
type: payload.resolvedType, type: payload.resolvedType,
tags: payload.tags, tags: payload.tags,
body: payload.body, body: payload.body
}); });
if (!updated) return; if (!updated) return;
@ -122,7 +91,7 @@
title: payload.title, title: payload.title,
type: payload.resolvedType, type: payload.resolvedType,
tags: payload.tags, tags: payload.tags,
body: payload.body, body: payload.body
}); });
onOpenDocument(item); onOpenDocument(item);
fragmentMode = "view"; fragmentMode = "view";
@ -158,26 +127,18 @@
} }
function cancelFragmentEdit() { function cancelFragmentEdit() {
if (dictationActive) {
void stopDictation();
}
if (fragmentMode === "create") { if (fragmentMode === "create") {
fragmentMode = "view"; fragmentMode = "view";
suppressExternalEditRequest = true;
return; return;
} }
loadFragmentFormFromDocument(); loadFragmentFormFromDocument();
fragmentMode = "view"; fragmentMode = "view";
suppressExternalEditRequest = true;
} }
function loadFragmentFormFromDocument() { function loadFragmentFormFromDocument() {
const content = openDocumentContent ?? ""; const content = openDocumentContent ?? "";
const isDraftFragment = openDocumentId.startsWith("fragments/new-"); const isDraftFragment = openDocumentId.startsWith("fragments/new-");
const parsed = parseFragmentContent( const parsed = parseFragmentContent(content, openDocumentName || "Untitled Fragment");
content,
openDocumentName || "Untitled Fragment",
);
fragmentTitle = parsed.title; fragmentTitle = parsed.title;
const parsedType = parsed.type; const parsedType = parsed.type;
if (!parsedType) { if (!parsedType) {
@ -211,214 +172,32 @@
fragmentMode = isDraftFragment ? "create" : "view"; fragmentMode = isDraftFragment ? "create" : "view";
} }
function appendDictationChunk(text: string) { $: fragmentTypeOptions = $settingsFragmentTypes.length ? $settingsFragmentTypes : ["General"];
const cleaned = text.trim();
if (!cleaned) return;
const prefix =
fragmentBody.length > 0 &&
!fragmentBody.endsWith(" ") &&
!fragmentBody.endsWith("\n") &&
!fragmentBody.endsWith("\t")
? " "
: "";
fragmentBody = `${fragmentBody}${prefix}${cleaned} `;
}
async function startDictation() {
if (dictationBusy || dictationActive) return;
if (!isTauriRuntime()) {
dictationError = "Speech dictation is available in the desktop app only.";
return;
}
dictationBusy = true;
dictationError = "";
dictationStatus = "Checking microphone access...";
try {
await probeMicrophoneAccess();
dictationStatus = "Starting dictation...";
const started = await Promise.race([
startSpeechDictation(),
new Promise<never>((_, reject) =>
setTimeout(
() =>
reject(
new Error("Timed out waiting for speech process startup."),
),
8000,
),
),
]);
dictationActive = true;
if (started?.pid) {
dictationStatus = "Dictation started.";
}
} catch (error) {
dictationError = String(error);
dictationActive = false;
} finally {
dictationBusy = false;
}
}
async function stopDictation() {
if (dictationBusy || !dictationActive) return;
dictationBusy = true;
dictationError = "";
try {
await stopSpeechDictation();
} catch (error) {
dictationError = String(error);
} finally {
dictationActive = false;
dictationStatus = "Dictation stopped.";
dictationBusy = false;
}
}
async function toggleDictation() {
if (dictationActive) {
await stopDictation();
return;
}
await startDictation();
}
onMount(() => {
if (!isTauriRuntime()) return;
let disposed = false;
void (async () => {
const { listen } = await import("@tauri-apps/api/event");
const unlisten = await listen<{ text?: string }>(
"speech-transcript",
(event) => {
if (disposed || !dictationActive) return;
if (fragmentMode !== "edit" && fragmentMode !== "create") return;
const text = event.payload?.text ?? "";
appendDictationChunk(text);
},
);
const unlistenStatus = await listen<{ state?: string; message?: string }>(
"speech-status",
(event) => {
const state = event.payload?.state ?? "";
const message = event.payload?.message ?? "";
if (message) {
dictationStatus = message;
}
if (state === "listening") {
dictationError = "";
return;
}
if (state === "error") {
dictationError = message || "Speech process error.";
dictationActive = false;
return;
}
if (state === "stopped") {
dictationActive = false;
}
},
);
if (disposed) {
unlisten();
return;
}
unlistenTranscript = unlisten;
unlistenSpeechStatus = unlistenStatus;
})();
return () => {
disposed = true;
if (unlistenTranscript) {
unlistenTranscript();
unlistenTranscript = null;
}
if (unlistenSpeechStatus) {
unlistenSpeechStatus();
unlistenSpeechStatus = null;
}
};
});
onDestroy(() => {
if (dictationActive) {
void stopDictation();
}
});
$: fragmentTypeOptions = $settingsFragmentTypes.length
? $settingsFragmentTypes
: ["General"];
$: tagOptions = $settingsTags; $: tagOptions = $settingsTags;
$: if (openDocumentId !== lastFragmentDocumentId) { $: if (openDocumentId !== lastFragmentDocumentId) {
loadFragmentFormFromDocument(); loadFragmentFormFromDocument();
lastFragmentDocumentId = openDocumentId; lastFragmentDocumentId = openDocumentId;
} }
$: if ( $: if (!fragmentType || (!fragmentTypeOptions.includes(fragmentType) && fragmentType !== customTypeValue)) {
!fragmentType ||
(!fragmentTypeOptions.includes(fragmentType) &&
fragmentType !== customTypeValue)
) {
fragmentType = fragmentTypeOptions[0]; fragmentType = fragmentTypeOptions[0];
} }
$: if ( $: if (!fragmentTag || (!tagOptions.includes(fragmentTag) && fragmentTag !== customTagValue)) {
!fragmentTag ||
(!tagOptions.includes(fragmentTag) && fragmentTag !== customTagValue)
) {
fragmentTag = tagOptions[0] ?? customTagValue; fragmentTag = tagOptions[0] ?? customTagValue;
} }
$: if (!externalEditRequested) { $: if (externalEditRequested && fragmentMode === "view") {
suppressExternalEditRequest = false;
}
$: if (
externalEditRequested &&
!suppressExternalEditRequest &&
fragmentMode === "view"
) {
fragmentMode = "edit"; fragmentMode = "edit";
} }
$: if (fragmentMode === "view" && dictationActive) {
void stopDictation();
}
</script> </script>
<section class="fragment-surface"> <section class="fragment-surface">
{#if dictationError || dictationStatus}
<div
class="dictation-indicator"
class:is-error={Boolean(dictationError)}
role="status"
aria-live="polite"
>
{dictationError || dictationStatus}
</div>
{/if}
{#if fragmentMode === "view"} {#if fragmentMode === "view"}
<article class="fragment-view"> <article class="fragment-view">
{@html renderMarkdown(openDocumentContent)} {@html renderMarkdown(openDocumentContent)}
</article> </article>
{:else} {:else}
<form <form class="fragment-form" on:submit|preventDefault={fragmentMode === "create" ? createNewFragment : saveFragmentEdits}>
class="fragment-form"
on:submit|preventDefault={fragmentMode === "create"
? createNewFragment
: saveFragmentEdits}
>
<h2>{fragmentMode === "create" ? "New Fragment" : "Edit Fragment"}</h2> <h2>{fragmentMode === "create" ? "New Fragment" : "Edit Fragment"}</h2>
<input <input type="text" placeholder="Title" bind:value={fragmentTitle} aria-label="Fragment title" />
type="text"
placeholder="Title"
bind:value={fragmentTitle}
aria-label="Fragment title"
/>
<div class="fragment-form-row"> <div class="fragment-form-row">
<select bind:value={fragmentType} aria-label="Fragment type"> <select bind:value={fragmentType} aria-label="Fragment type">
{#each fragmentTypeOptions as type} {#each fragmentTypeOptions as type}
@ -434,12 +213,7 @@
aria-label="Custom fragment type" aria-label="Custom fragment type"
/> />
{:else} {:else}
<input <input type="text" value={fragmentType} disabled aria-label="Selected fragment type" />
type="text"
value={fragmentType}
disabled
aria-label="Selected fragment type"
/>
{/if} {/if}
</div> </div>
<div class="fragment-form-row"> <div class="fragment-form-row">
@ -469,27 +243,8 @@
aria-label="Fragment body" aria-label="Fragment body"
></textarea> ></textarea>
<div class="fragment-actions"> <div class="fragment-actions">
<button <button type="submit" class="fragment-submit">{fragmentMode === "create" ? "Create Fragment" : "Save Fragment"}</button>
type="button" <button type="button" class="fragment-secondary" on:click={cancelFragmentEdit}>Cancel</button>
class="fragment-secondary"
class:is-active={dictationActive}
on:click={() => void toggleDictation()}
disabled={dictationBusy || !isTauriRuntime()}
aria-label={dictationActive ? "Stop dictation" : "Start dictation"}
title={dictationActive ? "Stop dictation" : "Start dictation"}
>
{dictationActive ? "Stop Dictation" : "Start Dictation"}
</button>
<button type="submit" class="fragment-submit"
>{fragmentMode === "create"
? "Create Fragment"
: "Save Fragment"}</button
>
<button
type="button"
class="fragment-secondary"
on:click={cancelFragmentEdit}>Cancel</button
>
</div> </div>
</form> </form>
{/if} {/if}
@ -499,63 +254,34 @@
.fragment-surface { .fragment-surface {
min-height: 0; min-height: 0;
flex: 1; flex: 1;
overflow: auto; padding: 8px;
padding: 0 14px 14px; display: grid;
position: relative; place-items: center;
}
.dictation-indicator {
position: absolute;
top: 8px;
right: 22px;
z-index: 30;
border: 1px solid var(--border-soft);
border-radius: 8px;
background: color-mix(in srgb, var(--surface-1) 94%, var(--bg-editor) 6%);
color: var(--text-dim);
font-size: 0.76rem;
line-height: 1.35;
padding: 6px 9px;
max-width: min(56ch, 60%);
box-shadow: 0 8px 20px
color-mix(in srgb, var(--bg-app) 35%, transparent 65%);
pointer-events: none;
}
.dictation-indicator.is-error {
color: #fca5a5;
border-color: color-mix(in srgb, #ef4444 45%, var(--border-soft) 55%);
background: color-mix(in srgb, #2a1414 52%, var(--surface-1) 48%);
} }
.fragment-form { .fragment-form {
width: min(100%, 920px); width: min(760px, 100%);
margin: 0 auto; border: 1px solid var(--border-soft);
border: none; border-radius: 12px;
border-radius: 0; background: var(--surface-1);
background: transparent; box-shadow: 0 14px 34px rgba(0, 0, 0, 0.25);
padding: 28px 36px; padding: 14px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; gap: 10px;
} }
.fragment-view { .fragment-view {
width: min(100%, 920px); width: min(760px, 100%);
margin: 0 auto; border: 1px solid var(--border-soft);
border: none; border-radius: 12px;
border-radius: 0; background: var(--surface-1);
background: transparent; box-shadow: 0 14px 34px rgba(0, 0, 0, 0.25);
padding: 28px 36px; padding: 14px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 14px; gap: 14px;
color: var(--text-primary); color: var(--text-primary);
font-size: 0.92rem;
line-height: 1.65;
font-family:
"Segoe UI Variable", "Segoe UI", "Segoe UI Emoji", "Apple Color Emoji",
"Noto Color Emoji", Inter, Roboto, Helvetica, Arial, sans-serif;
} }
.fragment-view :global(h1), .fragment-view :global(h1),
@ -584,30 +310,10 @@
font-size: 0.82rem; font-size: 0.82rem;
} }
.fragment-view :global(.markdown-tag-list) {
display: inline-flex;
flex-wrap: wrap;
gap: 6px;
vertical-align: middle;
}
.fragment-view :global(.markdown-tag-chip) {
display: inline-flex;
align-items: center;
border: 1px solid var(--border-soft);
border-radius: 999px;
padding: 1px 8px;
font-size: 0.74rem;
line-height: 1.35;
color: var(--text-muted);
background: color-mix(in srgb, var(--surface-2) 90%, var(--bg-editor) 10%);
}
.fragment-form h2 { .fragment-form h2 {
font-size: 1rem; font-size: 0.94rem;
font-weight: 600; font-weight: 600;
color: var(--text-primary); color: var(--text-primary);
margin-bottom: 2px;
} }
.fragment-form input, .fragment-form input,
@ -616,23 +322,15 @@
width: 100%; width: 100%;
border: 1px solid var(--border-soft); border: 1px solid var(--border-soft);
border-radius: 8px; border-radius: 8px;
background-color: color-mix( background: var(--bg-app);
in srgb,
var(--surface-1) 88%,
var(--bg-editor) 12%
);
color: var(--text-primary); color: var(--text-primary);
padding: 10px 11px; padding: 9px 10px;
font-size: 0.88rem; font-size: 0.86rem;
font-family:
"Segoe UI Variable", "Segoe UI", "Segoe UI Emoji", "Apple Color Emoji",
"Noto Color Emoji", Inter, Roboto, Helvetica, Arial, sans-serif;
} }
.fragment-form textarea { .fragment-form textarea {
resize: vertical; resize: vertical;
min-height: 220px; min-height: 160px;
line-height: 1.55;
} }
.fragment-form-row { .fragment-form-row {
@ -645,7 +343,7 @@
width: fit-content; width: fit-content;
border-radius: 8px; border-radius: 8px;
border: 1px solid var(--border-strong); border: 1px solid var(--border-strong);
background: color-mix(in srgb, var(--surface-2) 84%, var(--bg-hover) 16%); background: var(--surface-3);
color: var(--text-primary); color: var(--text-primary);
padding: 8px 12px; padding: 8px 12px;
font-size: 0.82rem; font-size: 0.82rem;
@ -665,7 +363,7 @@
.fragment-secondary { .fragment-secondary {
border-radius: 8px; border-radius: 8px;
border: 1px solid var(--border-soft); border: 1px solid var(--border-soft);
background: color-mix(in srgb, var(--surface-1) 90%, var(--bg-editor) 10%); background: var(--surface-1);
color: var(--text-muted); color: var(--text-muted);
padding: 8px 12px; padding: 8px 12px;
font-size: 0.82rem; font-size: 0.82rem;
@ -676,33 +374,4 @@
background: var(--bg-hover); background: var(--bg-hover);
color: var(--text-primary); color: var(--text-primary);
} }
.fragment-secondary.is-active {
border-color: var(--border-strong);
color: var(--text-primary);
background: var(--bg-active);
}
@media (max-width: 980px) {
.fragment-surface {
padding: 4px 8px 10px;
}
.dictation-indicator {
right: 10px;
top: 6px;
max-width: calc(100% - 20px);
}
.fragment-form,
.fragment-view {
width: 100%;
padding: 18px 16px;
font-size: 0.89rem;
}
.fragment-form-row {
grid-template-columns: 1fr;
}
}
</style> </style>

View File

@ -1,303 +0,0 @@
<!-- @format -->
<script lang="ts">
export let openDocumentId = "";
export let openDocumentName = "";
export let openDocumentContent = "";
export let onDocumentContentChange: (content: string) => void = () => {};
type SimpleListItem = {
id: number;
text: string;
};
let items: SimpleListItem[] = [];
let nextItemId = 1;
let lastDocumentId = "";
let newItemText = "";
let editingItemId: number | null = null;
let editingItemText = "";
function parseListItems(content: string): SimpleListItem[] {
const lines = (content ?? "").split(/\r?\n/);
const parsed: SimpleListItem[] = [];
for (const line of lines) {
const bullet = line.match(/^\s*(?:[-*+]|\d+\.)\s+(.+)$/);
if (bullet) {
parsed.push({ id: parsed.length + 1, text: bullet[1].trim() });
continue;
}
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
parsed.push({ id: parsed.length + 1, text: trimmed });
}
return parsed;
}
function serializeList(title: string, listItems: SimpleListItem[]): string {
const heading = (title ?? "").trim() || "Untitled List";
if (!listItems.length) {
return `# ${heading}\n\n`;
}
const body = listItems
.map((item) => item.text.trim())
.filter(Boolean)
.map((text) => `- ${text}`)
.join("\n");
return `# ${heading}\n\n${body}`;
}
function persist() {
onDocumentContentChange(serializeList(openDocumentName, items));
}
function resetForDocument() {
items = parseListItems(openDocumentContent);
nextItemId = (items[items.length - 1]?.id ?? 0) + 1;
newItemText = "";
editingItemId = null;
editingItemText = "";
}
function addItem() {
const text = newItemText.trim();
if (!text) return;
items = [{ id: nextItemId, text }, ...items];
nextItemId += 1;
newItemText = "";
persist();
}
function startEditItem(id: number) {
const existing = items.find((item) => item.id === id);
if (!existing) return;
editingItemId = id;
editingItemText = existing.text;
}
function saveEditItem() {
if (editingItemId === null) return;
const text = editingItemText.trim();
if (!text) return;
const id = editingItemId;
items = items.map((item) => (item.id === id ? { ...item, text } : item));
editingItemId = null;
editingItemText = "";
persist();
}
function cancelEditItem() {
editingItemId = null;
editingItemText = "";
}
function removeItem(id: number) {
if (editingItemId === id) {
cancelEditItem();
}
items = items.filter((item) => item.id !== id);
persist();
}
$: if (openDocumentId !== lastDocumentId) {
resetForDocument();
lastDocumentId = openDocumentId;
}
</script>
<section class="list-surface">
<div class="list-card">
<form class="list-create" on:submit|preventDefault={addItem}>
<input
type="text"
placeholder="Add a list item"
bind:value={newItemText}
aria-label="Add list item"
/>
<button type="submit" class="list-add-btn">Add</button>
</form>
<ul class="list-items">
{#each items as item}
<li class="list-item">
{#if editingItemId === item.id}
<input
type="text"
class="list-edit-input"
bind:value={editingItemText}
on:keydown={(event) => {
if (event.key === "Enter") saveEditItem();
if (event.key === "Escape") cancelEditItem();
}}
/>
<div class="list-actions">
<button
type="button"
class="list-btn save"
on:click={saveEditItem}>Save</button
>
<button
type="button"
class="list-btn ghost"
on:click={cancelEditItem}>Cancel</button
>
</div>
{:else}
<span class="list-text">{item.text}</span>
<div class="list-actions">
<button
type="button"
class="list-btn ghost"
on:click={() => startEditItem(item.id)}>Edit</button
>
<button
type="button"
class="list-btn danger"
on:click={() => removeItem(item.id)}>Remove</button
>
</div>
{/if}
</li>
{/each}
</ul>
</div>
</section>
<style>
.list-surface {
min-height: 0;
flex: 1;
overflow: auto;
padding: 0 14px 14px;
}
.list-card {
width: min(100%, 920px);
margin: 0 auto;
border: none;
border-radius: 0;
background: transparent;
padding: 28px 36px;
display: flex;
flex-direction: column;
gap: 12px;
max-height: 100%;
overflow: visible;
}
.list-create {
display: flex;
gap: 8px;
}
.list-create input,
.list-edit-input {
width: 100%;
border: 1px solid var(--border-soft);
border-radius: 8px;
background: color-mix(in srgb, var(--surface-1) 88%, var(--bg-editor) 12%);
color: var(--text-primary);
padding: 10px 11px;
font-size: 0.88rem;
}
.list-add-btn {
border-radius: 8px;
border: 1px solid var(--border-strong);
background: color-mix(in srgb, var(--surface-2) 84%, var(--bg-hover) 16%);
color: var(--text-primary);
padding: 9px 14px;
font-size: 0.82rem;
font-weight: 600;
cursor: pointer;
}
.list-add-btn:hover {
background: var(--bg-hover);
}
.list-items {
list-style: none;
display: flex;
flex-direction: column;
gap: 10px;
}
.list-item {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
border: 1px solid var(--border-soft);
border-radius: 8px;
padding: 10px 12px;
background: color-mix(in srgb, var(--surface-1) 90%, var(--bg-editor) 10%);
}
.list-text {
font-size: 0.9rem;
color: var(--text-primary);
line-height: 1.45;
word-break: break-word;
}
.list-actions {
display: flex;
gap: 6px;
}
.list-btn {
border-radius: 7px;
border: 1px solid var(--border-soft);
background: color-mix(in srgb, var(--surface-1) 90%, var(--bg-editor) 10%);
color: var(--text-muted);
padding: 6px 10px;
font-size: 0.78rem;
cursor: pointer;
}
.list-btn.save {
border-color: var(--border-strong);
background: color-mix(in srgb, var(--surface-2) 84%, var(--bg-hover) 16%);
color: var(--text-primary);
}
.list-btn.danger:hover,
.list-btn.ghost:hover,
.list-btn.save:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
@media (max-width: 980px) {
.list-surface {
padding: 4px 8px 10px;
}
.list-card {
width: 100%;
padding: 18px 16px;
}
.list-create {
flex-wrap: wrap;
}
.list-add-btn {
width: 100%;
}
.list-item {
grid-template-columns: minmax(0, 1fr);
row-gap: 8px;
}
.list-actions {
justify-content: flex-end;
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@ -1,522 +0,0 @@
<!-- @format -->
<script lang="ts">
import type { EntryTemplateItemDto } from "$lib/backend/templates";
type AttachmentOption = { id: string; label: string };
export let isEntryDocument = false;
export let templatesBusy = false;
export let templateOptions: EntryTemplateItemDto[] = [];
export let listMode: "ul" | "ol" | null = null;
export let attachmentsDisabled = false;
export let onApplyHeading: (level: number) => void = () => {};
export let onApplyTemplate: (filePath: string) => void = () => {};
export let onOpenAttachments: () => void = () => {};
export let onBold: () => void = () => {};
export let onItalic: () => void = () => {};
export let onUnderline: () => void = () => {};
export let onTag: () => void = () => {};
export let onLink: () => void = () => {};
export let onToggleUl: () => void = () => {};
export let onToggleOl: () => void = () => {};
export let onCode: () => void = () => {};
export let onSave: () => void = () => {};
export let saveBusy = false;
export let onToggleDictation: () => void = () => {};
export let dictationActive = false;
export let dictationBusy = false;
export let dictationUnavailable = false;
let headingMenuOpen = false;
let headingMenuEl: HTMLDivElement | null = null;
let templateMenuOpen = false;
let templateMenuEl: HTMLDivElement | null = null;
function toggleHeadingMenu() {
headingMenuOpen = !headingMenuOpen;
}
function selectHeading(level: number) {
onApplyHeading(level);
headingMenuOpen = false;
}
function handleHeadingFocusOut(event: FocusEvent) {
const next = event.relatedTarget as Node | null;
if (headingMenuEl && next && headingMenuEl.contains(next)) return;
headingMenuOpen = false;
}
function toggleTemplateMenu() {
if (templatesBusy) return;
templateMenuOpen = !templateMenuOpen;
}
function selectTemplate(filePath: string) {
onApplyTemplate(filePath);
templateMenuOpen = false;
}
function handleTemplateFocusOut(event: FocusEvent) {
const next = event.relatedTarget as Node | null;
if (templateMenuEl && next && templateMenuEl.contains(next)) return;
templateMenuOpen = false;
}
</script>
<div class="editor-toolbar">
<div class="toolbar-group">
<div
class="toolbar-select-wrap heading-wrap"
bind:this={headingMenuEl}
on:focusout={handleHeadingFocusOut}
>
<span class="material-symbols-outlined" aria-hidden="true">title</span>
<button
type="button"
class="heading-trigger"
aria-label="Heading"
title="Heading"
aria-haspopup="listbox"
aria-expanded={headingMenuOpen}
on:click={toggleHeadingMenu}
>
<span class="material-symbols-outlined" aria-hidden="true"
>expand_more</span
>
</button>
{#if headingMenuOpen}
<div class="heading-menu" role="listbox" aria-label="Header size">
<button
type="button"
class="heading-option"
on:click={() => selectHeading(1)}>H1</button
>
<button
type="button"
class="heading-option"
on:click={() => selectHeading(2)}>H2</button
>
<button
type="button"
class="heading-option"
on:click={() => selectHeading(3)}>H3</button
>
<button
type="button"
class="heading-option"
on:click={() => selectHeading(4)}>H4</button
>
<button
type="button"
class="heading-option"
on:click={() => selectHeading(5)}>H5</button
>
<button
type="button"
class="heading-option"
on:click={() => selectHeading(6)}>H6</button
>
</div>
{/if}
</div>
{#if isEntryDocument}
<div
class="toolbar-select-wrap template-wrap"
bind:this={templateMenuEl}
on:focusout={handleTemplateFocusOut}
>
<span class="material-symbols-outlined" aria-hidden="true"
>description</span
>
<button
type="button"
class="template-trigger"
aria-label="Insert template"
title="Insert template"
aria-haspopup="listbox"
aria-expanded={templateMenuOpen}
disabled={templatesBusy}
on:click={toggleTemplateMenu}
>
<span class="template-trigger-text"
>{templatesBusy ? "Loading..." : "Template"}</span
>
<span class="material-symbols-outlined" aria-hidden="true"
>expand_more</span
>
</button>
{#if templateMenuOpen}
<div class="template-menu" role="listbox" aria-label="Template">
{#if templateOptions.length === 0}
<div class="template-empty">No templates</div>
{:else}
{#each templateOptions as template}
<button
type="button"
class="template-option"
on:click={() => selectTemplate(template.filePath)}
>
{template.fileName.replace(/\.template\.md$/i, "")}
</button>
{/each}
{/if}
</div>
{/if}
</div>
<button
type="button"
class="toolbar-btn toolbar-icon-btn"
on:click={onOpenAttachments}
disabled={attachmentsDisabled}
aria-label="Attach item"
title="Attach item"
>
<span class="material-symbols-outlined" aria-hidden="true">add</span>
</button>
{/if}
</div>
<div class="toolbar-divider" aria-hidden="true"></div>
<div class="toolbar-group">
<button
type="button"
class="toolbar-btn toolbar-icon-btn"
on:click={onBold}
aria-label="Bold"
title="Bold"
>
<span class="material-symbols-outlined" aria-hidden="true"
>format_bold</span
>
</button>
<button
type="button"
class="toolbar-btn toolbar-icon-btn"
on:click={onItalic}
aria-label="Italic"
title="Italic"
>
<span class="material-symbols-outlined" aria-hidden="true"
>format_italic</span
>
</button>
<button
type="button"
class="toolbar-btn toolbar-icon-btn"
on:click={onUnderline}
aria-label="Underline"
title="Underline"
>
<span class="material-symbols-outlined" aria-hidden="true"
>format_underlined</span
>
</button>
<button
type="button"
class="toolbar-btn toolbar-icon-btn"
on:click={onTag}
aria-label="Tag"
title="Tag [[...]]"
>
<span class="material-symbols-outlined" aria-hidden="true">sell</span>
</button>
<button
type="button"
class="toolbar-btn toolbar-icon-btn"
on:click={onLink}
aria-label="Link"
title="Link"
>
<span class="material-symbols-outlined" aria-hidden="true">link</span>
</button>
<button
type="button"
class="toolbar-btn toolbar-icon-btn"
class:is-active={listMode === "ul"}
on:click={onToggleUl}
aria-label="Bulleted list"
title="Bulleted list"
>
<span class="material-symbols-outlined" aria-hidden="true"
>format_list_bulleted</span
>
</button>
<button
type="button"
class="toolbar-btn toolbar-icon-btn"
class:is-active={listMode === "ol"}
on:click={onToggleOl}
aria-label="Numbered list"
title="Numbered list"
>
<span class="material-symbols-outlined" aria-hidden="true"
>format_list_numbered</span
>
</button>
<button
type="button"
class="toolbar-btn toolbar-icon-btn"
on:click={onCode}
aria-label="Inline code"
title="Inline code"
>
<span class="material-symbols-outlined" aria-hidden="true">code</span>
</button>
</div>
<div class="toolbar-divider" aria-hidden="true"></div>
<div class="toolbar-group">
<button
type="button"
class="toolbar-btn toolbar-icon-btn"
class:is-active={dictationActive}
on:click={onToggleDictation}
disabled={dictationBusy || dictationUnavailable}
aria-label={dictationActive ? "Stop dictation" : "Start dictation"}
title={dictationActive ? "Stop dictation" : "Start dictation"}
>
<span class="material-symbols-outlined" aria-hidden="true"
>{dictationActive ? "mic_off" : "mic"}</span
>
</button>
<button
type="button"
class="toolbar-btn toolbar-icon-btn"
on:click={onSave}
disabled={saveBusy}
aria-label="Save now"
title="Save now"
>
<span class="material-symbols-outlined" aria-hidden="true">save</span>
</button>
</div>
</div>
<style>
.editor-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
border: 1px solid var(--border-soft);
border-radius: 12px;
background: color-mix(in srgb, var(--surface-1) 90%, var(--zinc-700) 10%);
box-shadow:
inset 0 1px 0 color-mix(in srgb, var(--zinc-300) 9%, transparent 91%),
0 8px 24px color-mix(in srgb, var(--bg-app) 38%, transparent 62%);
padding: 8px 10px;
margin: 0 14px;
}
.toolbar-group {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
}
.toolbar-select-wrap {
display: inline-flex;
align-items: center;
gap: 6px;
border-radius: 8px;
border: 1px solid var(--border-soft);
background: color-mix(in srgb, var(--surface-2) 92%, var(--zinc-700) 8%);
color: color-mix(in srgb, var(--text-muted) 86%, var(--text-primary) 14%);
padding: 0 8px;
min-height: 30px;
}
.toolbar-select-wrap .material-symbols-outlined {
font-size: 16px;
line-height: 1;
}
.toolbar-divider {
width: 1px;
height: 22px;
background: color-mix(in srgb, var(--border-soft) 78%, transparent 22%);
}
.toolbar-btn {
border-radius: 8px;
border: 1px solid var(--border-soft);
background: color-mix(in srgb, var(--surface-2) 92%, var(--zinc-700) 8%);
color: color-mix(in srgb, var(--text-muted) 86%, var(--text-primary) 14%);
padding: 6px 10px;
min-height: 30px;
font-size: 0.73rem;
letter-spacing: 0.01em;
font-weight: 600;
cursor: pointer;
transition:
background-color 120ms ease,
color 120ms ease,
border-color 120ms ease,
transform 120ms ease;
}
.toolbar-icon-btn {
padding: 4px 6px;
width: 32px;
min-width: 32px;
justify-content: center;
display: inline-flex;
align-items: center;
}
.toolbar-icon-btn .material-symbols-outlined {
font-size: 17px;
line-height: 1;
}
.heading-wrap {
padding-right: 6px;
gap: 3px;
position: relative;
}
.heading-trigger {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
min-height: 18px;
color: var(--text-primary);
cursor: pointer;
}
.heading-menu {
position: absolute;
top: calc(100% + 6px);
left: 50%;
transform: translateX(-50%);
width: 100%;
min-width: 68px;
border: 1px solid var(--border-soft);
border-radius: 8px;
background: color-mix(in srgb, var(--surface-1) 94%, var(--bg-editor) 6%);
padding: 4px;
display: grid;
gap: 2px;
z-index: 20;
}
.template-wrap {
position: relative;
padding-right: 6px;
gap: 3px;
}
.template-trigger {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
min-height: 18px;
color: var(--text-primary);
cursor: pointer;
}
.template-trigger:disabled {
opacity: 0.6;
cursor: default;
}
.template-menu {
position: absolute;
top: calc(100% + 6px);
left: 50%;
transform: translateX(-50%);
width: 100%;
min-width: 132px;
max-width: 260px;
border: 1px solid var(--border-soft);
border-radius: 8px;
background: color-mix(in srgb, var(--surface-1) 94%, var(--bg-editor) 6%);
padding: 4px;
display: grid;
gap: 2px;
z-index: 20;
}
.template-empty {
padding: 6px 8px;
font-size: 0.76rem;
color: var(--text-dim);
}
.template-option {
width: 100%;
text-align: left;
border-radius: 6px;
padding: 6px 8px;
font-size: 0.76rem;
color: var(--text-primary);
cursor: pointer;
}
.template-trigger-text {
display: none;
}
.template-option:hover {
background: var(--bg-hover);
}
.heading-option {
width: 100%;
text-align: left;
border-radius: 6px;
padding: 6px 8px;
font-size: 0.76rem;
color: var(--text-primary);
cursor: pointer;
}
.heading-option:hover {
background: var(--bg-hover);
}
.toolbar-btn:hover,
.toolbar-btn:focus-visible {
background: color-mix(in srgb, var(--bg-hover) 88%, var(--surface-2) 12%);
color: var(--text-primary);
border-color: var(--border-strong);
transform: translateY(-1px);
}
.toolbar-btn:focus-visible {
outline: none;
}
.toolbar-btn.is-active {
background: color-mix(in srgb, var(--bg-active) 84%, var(--surface-2) 16%);
border-color: var(--border-strong);
color: var(--text-primary);
}
.toolbar-select-wrap:hover,
.toolbar-select-wrap:focus-within {
background: color-mix(in srgb, var(--bg-hover) 88%, var(--surface-2) 12%);
border-color: var(--border-strong);
}
.toolbar-btn:disabled {
opacity: 0.55;
cursor: default;
transform: none;
}
@media (max-width: 980px) {
.editor-toolbar {
margin: 0 8px;
padding: 7px 8px;
gap: 6px;
}
.toolbar-divider {
display: none;
}
}
</style>

View File

@ -1,4 +1,3 @@
<!-- @format -->
<script lang="ts"> <script lang="ts">
import { import {
addTodoItem, addTodoItem,
@ -13,7 +12,7 @@
todosStore, todosStore,
type TodoItem, type TodoItem,
updateTodoItemText, updateTodoItemText,
updateTodoItemTextBackend, updateTodoItemTextBackend
} from "$lib/stores/todos"; } from "$lib/stores/todos";
import { get } from "svelte/store"; import { get } from "svelte/store";
@ -46,9 +45,7 @@
if (!ok) { if (!ok) {
todoItems = toggleTodoItem(todoItems, id); todoItems = toggleTodoItem(todoItems, id);
} else { } else {
todoItems = todoItems.map((t) => todoItems = todoItems.map((t) => (t.id === id ? { ...t, done: !t.done } : t));
t.id === id ? { ...t, done: !t.done } : t,
);
} }
persistTodosForCurrentList(); persistTodosForCurrentList();
} }
@ -71,9 +68,7 @@
if (!ok) { if (!ok) {
todoItems = updateTodoItemText(todoItems, id, text); todoItems = updateTodoItemText(todoItems, id, text);
} else { } else {
todoItems = todoItems.map((t) => todoItems = todoItems.map((t) => (t.id === id ? { ...t, text: text.trim() } : t));
t.id === id ? { ...t, text: text.trim() } : t,
);
} }
persistTodosForCurrentList(); persistTodosForCurrentList();
} }
@ -143,11 +138,7 @@
{#each todoItems as todo} {#each todoItems as todo}
<li class="todo-item"> <li class="todo-item">
<label class="todo-check"> <label class="todo-check">
<input <input type="checkbox" checked={todo.done} on:change={() => toggleTodoDone(todo.id)} />
type="checkbox"
checked={todo.done}
on:change={() => toggleTodoDone(todo.id)}
/>
</label> </label>
{#if editingTodoId === todo.id} {#if editingTodoId === todo.id}
@ -161,30 +152,14 @@
}} }}
/> />
<div class="todo-actions"> <div class="todo-actions">
<button <button type="button" class="todo-btn save" on:click={saveEditTodo}>Save</button>
type="button" <button type="button" class="todo-btn ghost" on:click={cancelEditTodo}>Cancel</button>
class="todo-btn save"
on:click={saveEditTodo}>Save</button
>
<button
type="button"
class="todo-btn ghost"
on:click={cancelEditTodo}>Cancel</button
>
</div> </div>
{:else} {:else}
<span class="todo-text" class:is-done={todo.done}>{todo.text}</span> <span class="todo-text" class:is-done={todo.done}>{todo.text}</span>
<div class="todo-actions"> <div class="todo-actions">
<button <button type="button" class="todo-btn ghost" on:click={() => startEditTodo(todo.id)}>Edit</button>
type="button" <button type="button" class="todo-btn danger" on:click={() => removeTodo(todo.id)}>Remove</button>
class="todo-btn ghost"
on:click={() => startEditTodo(todo.id)}>Edit</button
>
<button
type="button"
class="todo-btn danger"
on:click={() => removeTodo(todo.id)}>Remove</button
>
</div> </div>
{/if} {/if}
</li> </li>
@ -197,22 +172,23 @@
.todo-surface { .todo-surface {
min-height: 0; min-height: 0;
flex: 1; flex: 1;
overflow: auto; padding: 8px;
padding: 0 14px 14px; display: grid;
place-items: center;
} }
.todo-card { .todo-card {
width: min(100%, 920px); width: min(760px, 100%);
margin: 0 auto; border: 1px solid var(--border-soft);
border: none; border-radius: 12px;
border-radius: 0; background: var(--surface-1);
background: transparent; box-shadow: 0 14px 34px rgba(0, 0, 0, 0.25);
padding: 28px 36px; padding: 14px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; gap: 10px;
max-height: 100%; max-height: 100%;
overflow: visible; overflow: auto;
} }
.todo-create { .todo-create {
@ -225,20 +201,19 @@
width: 100%; width: 100%;
border: 1px solid var(--border-soft); border: 1px solid var(--border-soft);
border-radius: 8px; border-radius: 8px;
background: color-mix(in srgb, var(--surface-1) 88%, var(--bg-editor) 12%); background: var(--bg-app);
color: var(--text-primary); color: var(--text-primary);
padding: 10px 11px; padding: 8px 10px;
font-size: 0.88rem; font-size: 0.86rem;
} }
.todo-add-btn { .todo-add-btn {
border-radius: 8px; border-radius: 8px;
border: 1px solid var(--border-strong); border: 1px solid var(--border-strong);
background: color-mix(in srgb, var(--surface-2) 84%, var(--bg-hover) 16%); background: var(--surface-3);
color: var(--text-primary); color: var(--text-primary);
padding: 9px 14px; padding: 8px 12px;
font-size: 0.82rem; font-size: 0.82rem;
font-weight: 600;
cursor: pointer; cursor: pointer;
} }
@ -250,7 +225,7 @@
list-style: none; list-style: none;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 10px; gap: 8px;
} }
.todo-item { .todo-item {
@ -260,8 +235,8 @@
gap: 10px; gap: 10px;
border: 1px solid var(--border-soft); border: 1px solid var(--border-soft);
border-radius: 8px; border-radius: 8px;
padding: 10px 12px; padding: 8px 10px;
background: color-mix(in srgb, var(--surface-1) 90%, var(--bg-editor) 10%); background: var(--bg-app);
} }
.todo-check { .todo-check {
@ -270,9 +245,8 @@
} }
.todo-text { .todo-text {
font-size: 0.9rem; font-size: 0.86rem;
color: var(--text-primary); color: var(--text-primary);
line-height: 1.45;
} }
.todo-text.is-done { .todo-text.is-done {
@ -288,7 +262,7 @@
.todo-btn { .todo-btn {
border-radius: 7px; border-radius: 7px;
border: 1px solid var(--border-soft); border: 1px solid var(--border-soft);
background: color-mix(in srgb, var(--surface-1) 90%, var(--bg-editor) 10%); background: var(--surface-1);
color: var(--text-muted); color: var(--text-muted);
padding: 6px 10px; padding: 6px 10px;
font-size: 0.78rem; font-size: 0.78rem;
@ -297,7 +271,7 @@
.todo-btn.save { .todo-btn.save {
border-color: var(--border-strong); border-color: var(--border-strong);
background: color-mix(in srgb, var(--surface-2) 84%, var(--bg-hover) 16%); background: var(--surface-3);
color: var(--text-primary); color: var(--text-primary);
} }
@ -307,33 +281,4 @@
background: var(--bg-hover); background: var(--bg-hover);
color: var(--text-primary); color: var(--text-primary);
} }
@media (max-width: 980px) {
.todo-surface {
padding: 4px 8px 10px;
}
.todo-card {
width: 100%;
padding: 18px 16px;
}
.todo-create {
flex-wrap: wrap;
}
.todo-add-btn {
width: 100%;
}
.todo-item {
grid-template-columns: auto minmax(0, 1fr);
row-gap: 8px;
}
.todo-actions {
grid-column: 1 / -1;
justify-content: flex-end;
}
}
</style> </style>

View File

@ -9,7 +9,6 @@ type WindowWithTauri = Window & {
type UiSettingsPayload = { type UiSettingsPayload = {
tags?: string[]; tags?: string[];
fragmentTypes?: string[]; fragmentTypes?: string[];
defaultStartupView?: string;
}; };
type FetchJsonOptions = { type FetchJsonOptions = {
@ -32,10 +31,7 @@ export function isTauriRuntime(): boolean {
return false; return false;
} }
return Object.prototype.hasOwnProperty.call( return Object.prototype.hasOwnProperty.call(window as WindowWithTauri, "__TAURI_INTERNALS__");
window as WindowWithTauri,
"__TAURI_INTERNALS__",
);
} }
function readUiSettingsFromLocalStorage(): UiSettingsPayload { function readUiSettingsFromLocalStorage(): UiSettingsPayload {
@ -52,13 +48,7 @@ function readUiSettingsFromLocalStorage(): UiSettingsPayload {
const parsed = JSON.parse(raw) as UiSettingsPayload; const parsed = JSON.parse(raw) as UiSettingsPayload;
return { return {
tags: Array.isArray(parsed.tags) ? parsed.tags : undefined, tags: Array.isArray(parsed.tags) ? parsed.tags : undefined,
fragmentTypes: Array.isArray(parsed.fragmentTypes) fragmentTypes: Array.isArray(parsed.fragmentTypes) ? parsed.fragmentTypes : undefined
? parsed.fragmentTypes
: undefined,
defaultStartupView:
typeof parsed.defaultStartupView === "string"
? parsed.defaultStartupView
: undefined,
}; };
} catch { } catch {
return {}; return {};
@ -72,31 +62,20 @@ function writeUiSettingsToLocalStorage(payload: UiSettingsPayload): void {
const safePayload: UiSettingsPayload = { const safePayload: UiSettingsPayload = {
tags: Array.isArray(payload.tags) ? payload.tags : undefined, tags: Array.isArray(payload.tags) ? payload.tags : undefined,
fragmentTypes: Array.isArray(payload.fragmentTypes) fragmentTypes: Array.isArray(payload.fragmentTypes) ? payload.fragmentTypes : undefined
? payload.fragmentTypes
: undefined,
defaultStartupView:
typeof payload.defaultStartupView === "string"
? payload.defaultStartupView
: undefined,
}; };
window.localStorage.setItem(UI_SETTINGS_KEY, JSON.stringify(safePayload)); window.localStorage.setItem(UI_SETTINGS_KEY, JSON.stringify(safePayload));
} }
async function fetchJson<T>( async function fetchJson<T>(path: string, init: RequestInit = {}, options: FetchJsonOptions = {}): Promise<T> {
path: string, const response = await fetch(`${normalizedApiBase()}${path}`, {
init: RequestInit = {},
options: FetchJsonOptions = {},
apiBase?: string,
): Promise<T> {
const response = await fetch(`${apiBase ?? normalizedApiBase()}${path}`, {
...init, ...init,
keepalive: options.keepalive === true, keepalive: options.keepalive === true,
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
...(init.headers ?? {}), ...(init.headers ?? {})
}, }
}); });
if (!response.ok) { if (!response.ok) {
@ -111,10 +90,7 @@ async function fetchJson<T>(
return (await response.json()) as T; return (await response.json()) as T;
} }
export async function invoke<T>( export async function invoke<T>(command: string, args?: InvokeArgs): Promise<T> {
command: string,
args?: InvokeArgs,
): Promise<T> {
if (isTauriRuntime()) { if (isTauriRuntime()) {
const tauriCore = await import("@tauri-apps/api/core"); const tauriCore = await import("@tauri-apps/api/core");
return tauriCore.invoke<T>(command, args); return tauriCore.invoke<T>(command, args);
@ -133,9 +109,9 @@ export async function invoke<T>(
"/command", "/command",
{ {
method: "POST", method: "POST",
body: JSON.stringify(envelope as BackendCommand), body: JSON.stringify(envelope as BackendCommand)
}, },
{ keepalive }, { keepalive }
); );
} }
case "get_sidecar_root": case "get_sidecar_root":
@ -144,41 +120,23 @@ export async function invoke<T>(
const path = typeof args?.path === "string" ? args.path : ""; const path = typeof args?.path === "string" ? args.path : "";
return fetchJson<T>("/sidecar/root", { return fetchJson<T>("/sidecar/root", {
method: "POST", method: "POST",
body: JSON.stringify({ path }), body: JSON.stringify({ path })
}); });
} }
case "get_ui_settings": case "get_ui_settings":
return readUiSettingsFromLocalStorage() as T; return readUiSettingsFromLocalStorage() as T;
case "set_ui_settings": { case "set_ui_settings": {
const tags = Array.isArray(args?.tags) const tags = Array.isArray(args?.tags) ? (args?.tags as string[]) : undefined;
? (args?.tags as string[]) const fragmentTypes =
: undefined; Array.isArray(args?.fragmentTypes) ? (args?.fragmentTypes as string[]) :
const fragmentTypes = Array.isArray(args?.fragmentTypes) Array.isArray(args?.fragment_types) ? (args?.fragment_types as string[]) :
? (args?.fragmentTypes as string[]) undefined;
: Array.isArray(args?.fragment_types)
? (args?.fragment_types as string[])
: undefined;
const defaultStartupView =
typeof args?.defaultStartupView === "string"
? args.defaultStartupView
: typeof args?.default_startup_view === "string"
? args.default_startup_view
: undefined;
writeUiSettingsToLocalStorage({ writeUiSettingsToLocalStorage({ tags, fragmentTypes });
tags,
fragmentTypes,
defaultStartupView,
});
return undefined as T; return undefined as T;
} }
case "shutdown": case "shutdown":
return undefined as T; return undefined as T;
case "speech_start":
case "speech_stop":
throw new Error(
"Speech dictation is available in the desktop app runtime only.",
);
default: default:
throw new Error(`Unsupported command in web runtime: ${command}`); throw new Error(`Unsupported command in web runtime: ${command}`);
} }

View File

@ -1,140 +0,0 @@
import { writable } from "svelte/store";
import {
aiHealth as aiHealthCommand,
aiChat as aiChatCommand,
coachDaily,
coachEvening,
coachWeekly,
type AiHealthDto,
type CoachPlanDto,
type CoachSessionPayload,
} from "$lib/backend/ai";
//#region Store Shapes
type AiStatusState = {
checking: boolean;
health: AiHealthDto | null;
};
type CoachState = {
busy: boolean;
error: string;
plan: CoachPlanDto | null;
kind: string;
};
export type ChatMessage = {
role: "user" | "assistant" | "error";
text: string;
};
type ChatState = {
busy: boolean;
messages: ChatMessage[];
};
//#endregion
//#region Stores
export const aiStatusStore = writable<AiStatusState>({
checking: false,
health: null,
});
export const coachStateStore = writable<CoachState>({
busy: false,
error: "",
plan: null,
kind: "",
});
export const chatStateStore = writable<ChatState>({
busy: false,
messages: [],
});
//#endregion
//#region Actions
export async function checkAiHealth(): Promise<void> {
aiStatusStore.update((s) => ({ ...s, checking: true }));
try {
const health = await aiHealthCommand();
aiStatusStore.set({ checking: false, health });
} catch (error) {
aiStatusStore.set({
checking: false,
health: {
provider: "",
enabled: false,
healthy: false,
message: error instanceof Error ? error.message : String(error),
},
});
}
}
const sessionRunners: Record<
string,
(payload: CoachSessionPayload) => Promise<CoachPlanDto>
> = {
daily: coachDaily,
evening: coachEvening,
weekly: coachWeekly,
};
export async function runCoachSession(
kind: string,
payload: CoachSessionPayload = {},
): Promise<void> {
const runner = sessionRunners[kind];
if (!runner) {
coachStateStore.set({
busy: false,
error: `Unknown coach session kind: ${kind}`,
plan: null,
kind,
});
return;
}
coachStateStore.set({ busy: true, error: "", plan: null, kind });
try {
const plan = await runner(payload);
coachStateStore.set({ busy: false, error: "", plan, kind });
} catch (error) {
coachStateStore.set({
busy: false,
error: error instanceof Error ? error.message : String(error),
plan: null,
kind,
});
}
}
export async function runAiChat(prompt: string): Promise<void> {
chatStateStore.update((s) => ({
busy: true,
messages: [...s.messages, { role: "user", text: prompt }],
}));
try {
const response = await aiChatCommand(prompt);
chatStateStore.update((s) => ({
busy: false,
messages: [...s.messages, { role: "assistant", text: response }],
}));
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
chatStateStore.update((s) => ({
busy: false,
messages: [...s.messages, { role: "error", text: msg }],
}));
}
}
export function clearCoachPlan(): void {
coachStateStore.set({ busy: false, error: "", plan: null, kind: "" });
}
export function clearChat(): void {
chatStateStore.set({ busy: false, messages: [] });
}
//#endregion

Some files were not shown because too many files have changed in this diff Show More