Files
crm-wiki/backend/Wiki.Api/Store.cs

309 lines
14 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Security.Cryptography;
using System.Text.RegularExpressions;
using Microsoft.Data.Sqlite;
namespace Wiki.Api;
public sealed record NodeDto(
string Id, string? ParentId, string Kind, string Title, int Version,
string CreatedAt, string UpdatedAt, string UpdatedBy, string? UpdatedByName);
public sealed record Crumb(string Id, string Kind, string Title);
public sealed record PageDto(
string Id, string? ParentId, string Kind, string Title, int Version,
string CreatedAt, string UpdatedAt, string UpdatedBy, string? UpdatedByName,
string Markdown, IReadOnlyList<Crumb> Path);
public sealed record SearchHit(string Id, string Kind, string Title, string Snippet, IReadOnlyList<Crumb> Path);
public sealed record ImageRow(string Id, string WorkspaceId, string Mime, string FileName);
public sealed class WikiError(int status, string message) : Exception(message)
{
public int Status { get; } = status;
}
/// <summary>Доступ к данным вики. Каждый запрос ограничен workspace.</summary>
public sealed partial class Store(Db db)
{
public const int MaxTitle = 200;
public const int MaxContent = 2_000_000;
private const string Cols = "id, parent_id, kind, title, version, created_at, updated_at, updated_by, updated_by_name";
public static string NewId() => Convert.ToHexString(RandomNumberGenerator.GetBytes(8)).ToLowerInvariant();
private static string Now() => DateTime.UtcNow.ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'");
public List<NodeDto> Tree(string ws)
{
using var c = db.Open();
using var cmd = c.CreateCommand();
cmd.CommandText = $"SELECT {Cols} FROM nodes WHERE workspace_id=$ws";
cmd.Parameters.AddWithValue("$ws", ws);
using var r = cmd.ExecuteReader();
var list = new List<NodeDto>();
while (r.Read()) list.Add(ReadNode(r));
return list;
}
public PageDto? Get(string ws, string id)
{
using var c = db.Open();
return Get(c, ws, id);
}
private static PageDto? Get(SqliteConnection c, string ws, string id)
{
using var cmd = c.CreateCommand();
cmd.CommandText = $"SELECT {Cols}, content FROM nodes WHERE workspace_id=$ws AND id=$id";
cmd.Parameters.AddWithValue("$ws", ws);
cmd.Parameters.AddWithValue("$id", id);
using var r = cmd.ExecuteReader();
if (!r.Read()) return null;
var n = ReadNode(r);
var content = r.GetString(9);
return new PageDto(n.Id, n.ParentId, n.Kind, n.Title, n.Version, n.CreatedAt, n.UpdatedAt, n.UpdatedBy,
n.UpdatedByName, content, Path(c, ws, n.ParentId));
}
/// <summary>Цепочка папок от корня до parentId включительно (хлебные крошки).</summary>
private static List<Crumb> Path(SqliteConnection c, string ws, string? parentId)
{
var list = new List<Crumb>();
if (parentId is null) return list;
using var cmd = c.CreateCommand();
cmd.CommandText = """
WITH RECURSIVE up(id, parent_id, kind, title, depth) AS (
SELECT id, parent_id, kind, title, 0 FROM nodes WHERE id=$id AND workspace_id=$ws
UNION ALL
SELECT n.id, n.parent_id, n.kind, n.title, up.depth+1 FROM nodes n JOIN up ON n.id = up.parent_id
WHERE n.workspace_id=$ws AND up.depth < 100
)
SELECT id, kind, title FROM up ORDER BY depth DESC
""";
cmd.Parameters.AddWithValue("$ws", ws);
cmd.Parameters.AddWithValue("$id", parentId);
using var r = cmd.ExecuteReader();
while (r.Read()) list.Add(new Crumb(r.GetString(0), r.GetString(1), r.GetString(2)));
return list;
}
public PageDto Create(string ws, string kind, string? title, string? parentId, string? markdown, WikiUser u)
{
var t = CleanTitle(title);
var content = kind == "page" ? CleanContent(markdown) : "";
using var c = db.Open();
using var tx = c.BeginTransaction();
parentId = NullIfEmpty(parentId);
if (parentId is not null) EnsureFolder(c, ws, parentId);
var id = NewId();
var now = Now();
using (var cmd = c.CreateCommand())
{
cmd.CommandText = """
INSERT INTO nodes(id, workspace_id, parent_id, kind, title, content, version, created_at, created_by, updated_at, updated_by, updated_by_name)
VALUES ($id, $ws, $p, $k, $t, $c, 1, $now, $u, $now, $u, $un)
""";
cmd.Parameters.AddWithValue("$id", id);
cmd.Parameters.AddWithValue("$ws", ws);
cmd.Parameters.AddWithValue("$p", (object?)parentId ?? DBNull.Value);
cmd.Parameters.AddWithValue("$k", kind);
cmd.Parameters.AddWithValue("$t", t);
cmd.Parameters.AddWithValue("$c", content);
cmd.Parameters.AddWithValue("$now", now);
cmd.Parameters.AddWithValue("$u", u.Id);
cmd.Parameters.AddWithValue("$un", (object?)(u.Name ?? u.Email) ?? DBNull.Value);
cmd.ExecuteNonQuery();
}
tx.Commit();
return Get(c, ws, id)!;
}
/// <summary>Сохранение страницы с проверкой версии. Конфликт — WikiError 409, current — актуальная версия.</summary>
public (PageDto? Saved, PageDto? Current) Save(string ws, string id, string? title, string? markdown, int version, WikiUser u)
{
using var c = db.Open();
var cur = Get(c, ws, id) ?? throw new WikiError(404, "страница не найдена");
if (cur.Kind != "page") throw new WikiError(400, "это папка, у неё нет текста");
var t = title is null ? cur.Title : CleanTitle(title);
var content = markdown is null ? cur.Markdown : CleanContent(markdown);
using var cmd = c.CreateCommand();
cmd.CommandText = """
UPDATE nodes SET title=$t, content=$c, version=version+1, updated_at=$now, updated_by=$u, updated_by_name=$un
WHERE id=$id AND workspace_id=$ws AND version=$v
""";
cmd.Parameters.AddWithValue("$t", t);
cmd.Parameters.AddWithValue("$c", content);
cmd.Parameters.AddWithValue("$now", Now());
cmd.Parameters.AddWithValue("$u", u.Id);
cmd.Parameters.AddWithValue("$un", (object?)(u.Name ?? u.Email) ?? DBNull.Value);
cmd.Parameters.AddWithValue("$id", id);
cmd.Parameters.AddWithValue("$ws", ws);
cmd.Parameters.AddWithValue("$v", version);
if (cmd.ExecuteNonQuery() == 0) return (null, Get(c, ws, id));
return (Get(c, ws, id), null);
}
/// <summary>Переименование и/или перенос. Переименование страницы увеличивает версию.</summary>
public PageDto Patch(string ws, string id, string? title, bool moveSet, string? parentId, int? version, WikiUser u)
{
using var c = db.Open();
using var tx = c.BeginTransaction();
var cur = Get(c, ws, id) ?? throw new WikiError(404, "не найдено");
if (version is { } v && v != cur.Version) throw new WikiError(409, "страницу уже изменили");
var newTitle = title is null ? cur.Title : CleanTitle(title);
var newParent = moveSet ? NullIfEmpty(parentId) : cur.ParentId;
if (moveSet && newParent is not null)
{
EnsureFolder(c, ws, newParent);
if (newParent == id || Path(c, ws, newParent).Any(p => p.Id == id))
throw new WikiError(400, "нельзя перенести папку внутрь неё самой");
}
var renamed = newTitle != cur.Title;
using (var cmd = c.CreateCommand())
{
cmd.CommandText = """
UPDATE nodes SET title=$t, parent_id=$p,
version = version + $bump, updated_at=$now, updated_by=$u, updated_by_name=$un
WHERE id=$id AND workspace_id=$ws
""";
cmd.Parameters.AddWithValue("$t", newTitle);
cmd.Parameters.AddWithValue("$p", (object?)newParent ?? DBNull.Value);
cmd.Parameters.AddWithValue("$bump", renamed ? 1 : 0);
cmd.Parameters.AddWithValue("$now", Now());
cmd.Parameters.AddWithValue("$u", u.Id);
cmd.Parameters.AddWithValue("$un", (object?)(u.Name ?? u.Email) ?? DBNull.Value);
cmd.Parameters.AddWithValue("$id", id);
cmd.Parameters.AddWithValue("$ws", ws);
cmd.ExecuteNonQuery();
}
tx.Commit();
return Get(c, ws, id)!;
}
/// <summary>Удаляет узел со всем содержимым. Возвращает число удалённых узлов.</summary>
public int Delete(string ws, string id)
{
using var c = db.Open();
using var tx = c.BeginTransaction();
int count;
using (var cmd = c.CreateCommand())
{
cmd.CommandText = """
WITH RECURSIVE sub(id) AS (
SELECT id FROM nodes WHERE id=$id AND workspace_id=$ws
UNION ALL
SELECT n.id FROM nodes n JOIN sub ON n.parent_id = sub.id
)
SELECT count(*) FROM sub
""";
cmd.Parameters.AddWithValue("$id", id);
cmd.Parameters.AddWithValue("$ws", ws);
count = Convert.ToInt32(cmd.ExecuteScalar());
}
if (count == 0) throw new WikiError(404, "не найдено");
using (var cmd = c.CreateCommand())
{
cmd.CommandText = "DELETE FROM nodes WHERE id=$id AND workspace_id=$ws";
cmd.Parameters.AddWithValue("$id", id);
cmd.Parameters.AddWithValue("$ws", ws);
cmd.ExecuteNonQuery();
}
tx.Commit();
return count;
}
[GeneratedRegex(@"[\p{L}\p{N}_]+")]
private static partial Regex WordRx();
public List<SearchHit> Search(string ws, string q, int limit)
{
var words = WordRx().Matches(q).Select(m => m.Value).Take(8).ToList();
var hits = new List<SearchHit>();
if (words.Count == 0) return hits;
var match = string.Join(" ", words.Select(w => $"\"{w}\"*"));
using var c = db.Open();
using var cmd = c.CreateCommand();
// \u0002 … \u0003 — границы совпадения в сниппете, клиент сам их подсвечивает.
cmd.CommandText = """
SELECT n.id, n.kind, n.title, n.parent_id,
snippet(nodes_fts, 1, char(2), char(3), '…', 14)
FROM nodes_fts JOIN nodes n ON n.rowid = nodes_fts.rowid
WHERE nodes_fts MATCH $q AND n.workspace_id = $ws
ORDER BY bm25(nodes_fts, 8.0, 1.0)
LIMIT $lim
""";
cmd.Parameters.AddWithValue("$q", match);
cmd.Parameters.AddWithValue("$ws", ws);
cmd.Parameters.AddWithValue("$lim", Math.Clamp(limit, 1, 100));
var rows = new List<(string Id, string Kind, string Title, string? Parent, string Snip)>();
using (var r = cmd.ExecuteReader())
while (r.Read())
rows.Add((r.GetString(0), r.GetString(1), r.GetString(2), r.IsDBNull(3) ? null : r.GetString(3),
r.IsDBNull(4) ? "" : r.GetString(4)));
foreach (var x in rows) hits.Add(new SearchHit(x.Id, x.Kind, x.Title, x.Snip, Path(c, ws, x.Parent)));
return hits;
}
public void AddImage(string id, string ws, string? pageId, string mime, long size, string fileName, WikiUser u)
{
using var c = db.Open();
using var cmd = c.CreateCommand();
cmd.CommandText = """
INSERT INTO images(id, workspace_id, page_id, mime, size, file_name, created_at, created_by)
VALUES ($id, $ws, $p, $m, $s, $f, $now, $u)
""";
cmd.Parameters.AddWithValue("$id", id);
cmd.Parameters.AddWithValue("$ws", ws);
cmd.Parameters.AddWithValue("$p", (object?)pageId ?? DBNull.Value);
cmd.Parameters.AddWithValue("$m", mime);
cmd.Parameters.AddWithValue("$s", size);
cmd.Parameters.AddWithValue("$f", fileName);
cmd.Parameters.AddWithValue("$now", Now());
cmd.Parameters.AddWithValue("$u", u.Id);
cmd.ExecuteNonQuery();
}
public ImageRow? GetImage(string id)
{
using var c = db.Open();
using var cmd = c.CreateCommand();
cmd.CommandText = "SELECT id, workspace_id, mime, file_name FROM images WHERE id=$id";
cmd.Parameters.AddWithValue("$id", id);
using var r = cmd.ExecuteReader();
return r.Read() ? new ImageRow(r.GetString(0), r.GetString(1), r.GetString(2), r.GetString(3)) : null;
}
private static void EnsureFolder(SqliteConnection c, string ws, string id)
{
using var cmd = c.CreateCommand();
cmd.CommandText = "SELECT kind FROM nodes WHERE id=$id AND workspace_id=$ws";
cmd.Parameters.AddWithValue("$id", id);
cmd.Parameters.AddWithValue("$ws", ws);
var kind = cmd.ExecuteScalar() as string ?? throw new WikiError(400, "папка назначения не найдена");
if (kind != "folder") throw new WikiError(400, "класть можно только в папку");
}
private static NodeDto ReadNode(SqliteDataReader r) => new(
r.GetString(0), r.IsDBNull(1) ? null : r.GetString(1), r.GetString(2), r.GetString(3), r.GetInt32(4),
r.GetString(5), r.GetString(6), r.GetString(7), r.IsDBNull(8) ? null : r.GetString(8));
private static string CleanTitle(string? t)
{
t = (t ?? "").Trim();
if (t.Length == 0) throw new WikiError(400, "название не может быть пустым");
if (t.Length > MaxTitle) throw new WikiError(400, $"название длиннее {MaxTitle} символов");
return t;
}
private static string CleanContent(string? md)
{
md ??= "";
if (md.Length > MaxContent) throw new WikiError(413, "текст страницы слишком большой");
return md;
}
private static string? NullIfEmpty(string? s) => string.IsNullOrWhiteSpace(s) ? null : s;
}