Add Obsidian Wrapped feature (inside-LMS, client-side vault parsing)
- WrappedRun Prisma model + migration (aggregates only, no note names/text) - /wrapped authed route: FSA + webkitdirectory + drag-drop reader, Web Worker parser - archetype + score heuristics, ДС-2 result card - POST /api/wrapped/run (userId from session), public /share/wrapped/[token] - next/og OG image (Satori+resvg-wasm, alpine-safe) + Fira Mono ttf - middleware: /share/wrapped public Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
// Web Worker: парсит .md-тексты в агрегаты (off-main, без фриза UI).
|
||||
// Получает RawNote[], возвращает Stats. Тексты заметок дальше воркера не идут.
|
||||
|
||||
import type { RawNote, Stats, Period, Archetype } from "./types";
|
||||
|
||||
const WIKILINK = /\[\[([^\]|#]+)/g;
|
||||
const TAG = /(^|\s)#([\p{L}\d_/-]+)/gu;
|
||||
const DAY = 864e5;
|
||||
|
||||
function filterByPeriod(notes: RawNote[], period: Period): RawNote[] {
|
||||
if (period === "all") return notes;
|
||||
const cutoff = Date.now() - (period === "year" ? 365 * DAY : 90 * DAY);
|
||||
return notes.filter((n) => n.mtime >= cutoff);
|
||||
}
|
||||
|
||||
function buildStats(
|
||||
notes: RawNote[],
|
||||
degree: Map<string, number>,
|
||||
tags: Map<string, number>,
|
||||
linkCount: number,
|
||||
period: Period,
|
||||
): Stats {
|
||||
const noteCount = notes.length;
|
||||
const topTags = [...tags.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5).map(([tag, n]) => ({ tag, n }));
|
||||
const topLinked = [...degree.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5).map(([name, d]) => ({ name, degree: d }));
|
||||
|
||||
// Сирота — заметка, на которую никто не ссылается (грубая оценка по имени).
|
||||
const linkedTargets = new Set(degree.keys());
|
||||
const orphans = notes.filter((n) => !linkedTargets.has(n.name.replace(/\.md$/i, ""))).length;
|
||||
|
||||
const months = new Map<string, number>();
|
||||
for (const n of notes) {
|
||||
const d = new Date(n.mtime);
|
||||
const k = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
|
||||
months.set(k, (months.get(k) ?? 0) + 1);
|
||||
}
|
||||
const busiestMonth = [...months.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? "";
|
||||
|
||||
const linkDensity = noteCount ? linkCount / noteCount : 0;
|
||||
const tagDensity = noteCount ? tags.size / noteCount : 0;
|
||||
const orphanRatio = noteCount ? orphans / noteCount : 1;
|
||||
|
||||
let archetype: Archetype;
|
||||
if (linkDensity >= 2) archetype = "architect";
|
||||
else if (tagDensity >= 0.5) archetype = "librarian";
|
||||
else if (orphanRatio >= 0.4) archetype = "gardener";
|
||||
else archetype = "pragmatist";
|
||||
|
||||
const score = Math.max(0, Math.min(100, Math.round(
|
||||
Math.min(linkDensity / 3, 1) * 45 + Math.min(tagDensity, 1) * 25 + Math.min(noteCount / 300, 1) * 30,
|
||||
)));
|
||||
|
||||
const achievements: string[] = [];
|
||||
if (noteCount >= 100) achievements.push(`${noteCount} заметок`);
|
||||
if (linkCount >= 200) achievements.push(`${linkCount} связей`);
|
||||
if (tags.size >= 30) achievements.push(`${tags.size} тегов`);
|
||||
if (linkDensity >= 3) achievements.push("Плотная сеть знаний");
|
||||
if (orphanRatio < 0.1 && noteCount >= 50) achievements.push("Почти нет сирот");
|
||||
|
||||
return { noteCount, linkCount, tagCount: tags.size, topTags, topLinked, orphans, busiestMonth, achievements, archetype, score, period };
|
||||
}
|
||||
|
||||
self.onmessage = (e: MessageEvent<{ notes: RawNote[]; period: Period }>) => {
|
||||
const { notes, period } = e.data;
|
||||
const filtered = filterByPeriod(notes, period);
|
||||
const degree = new Map<string, number>();
|
||||
const tags = new Map<string, number>();
|
||||
let linkCount = 0;
|
||||
for (const n of filtered) {
|
||||
for (const m of n.text.matchAll(WIKILINK)) {
|
||||
linkCount++;
|
||||
const t = m[1].trim();
|
||||
if (t) degree.set(t, (degree.get(t) ?? 0) + 1);
|
||||
}
|
||||
for (const m of n.text.matchAll(TAG)) {
|
||||
const t = m[2];
|
||||
tags.set(t, (tags.get(t) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
self.postMessage(buildStats(filtered, degree, tags, linkCount, period));
|
||||
};
|
||||
@@ -0,0 +1,79 @@
|
||||
// Чтение хранилища в браузере — три пути ввода, все → RawNote[].
|
||||
// Содержимое заметок остаётся в браузере (приватность-моат).
|
||||
|
||||
import type { RawNote } from "./types";
|
||||
|
||||
const SKIP = ["/.obsidian/", "/.trash/", "/.git/"];
|
||||
function isMd(path: string): boolean {
|
||||
return path.toLowerCase().endsWith(".md") && !SKIP.some((s) => path.includes(s));
|
||||
}
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
export function supportsFSA(): boolean {
|
||||
return typeof window !== "undefined" && "showDirectoryPicker" in window;
|
||||
}
|
||||
|
||||
// 1. File System Access API (Chromium: Chrome/Edge/Brave)
|
||||
export async function readViaFSA(): Promise<RawNote[]> {
|
||||
const dir: any = await (window as any).showDirectoryPicker();
|
||||
const notes: RawNote[] = [];
|
||||
async function walk(handle: any, prefix: string): Promise<void> {
|
||||
for await (const [name, h] of handle.entries()) {
|
||||
const path = prefix + "/" + name;
|
||||
if (h.kind === "directory") {
|
||||
if (name.startsWith(".")) continue;
|
||||
await walk(h, path);
|
||||
} else if (isMd(path)) {
|
||||
const file: File = await h.getFile();
|
||||
notes.push({ name, text: await file.text(), ctime: file.lastModified, mtime: file.lastModified });
|
||||
}
|
||||
}
|
||||
}
|
||||
await walk(dir, "");
|
||||
return notes;
|
||||
}
|
||||
|
||||
// 2. <input type="file" webkitdirectory> (Safari / Firefox)
|
||||
export async function readViaInput(files: FileList): Promise<RawNote[]> {
|
||||
const notes: RawNote[] = [];
|
||||
for (const file of Array.from(files)) {
|
||||
const path = (file as any).webkitRelativePath || file.name;
|
||||
if (!isMd(path)) continue;
|
||||
notes.push({ name: file.name, text: await file.text(), ctime: file.lastModified, mtime: file.lastModified });
|
||||
}
|
||||
return notes;
|
||||
}
|
||||
|
||||
// 3. drag-drop папки (webkitGetAsEntry)
|
||||
export async function readViaDrop(items: DataTransferItemList): Promise<RawNote[]> {
|
||||
const notes: RawNote[] = [];
|
||||
const roots: any[] = [];
|
||||
for (const item of Array.from(items)) {
|
||||
const entry = (item as any).webkitGetAsEntry?.();
|
||||
if (entry) roots.push(entry);
|
||||
}
|
||||
async function readEntry(entry: any, prefix: string): Promise<void> {
|
||||
const path = prefix + "/" + entry.name;
|
||||
if (entry.isDirectory) {
|
||||
if (entry.name.startsWith(".")) return;
|
||||
const reader = entry.createReader();
|
||||
const children: any[] = await new Promise((resolve) => {
|
||||
const all: any[] = [];
|
||||
const readBatch = () =>
|
||||
reader.readEntries((batch: any[]) => {
|
||||
if (!batch.length) return resolve(all);
|
||||
all.push(...batch);
|
||||
readBatch();
|
||||
});
|
||||
readBatch();
|
||||
});
|
||||
for (const c of children) await readEntry(c, path);
|
||||
} else if (isMd(path)) {
|
||||
const file: File = await new Promise((resolve, reject) => entry.file(resolve, reject));
|
||||
notes.push({ name: entry.name, text: await file.text(), ctime: file.lastModified, mtime: file.lastModified });
|
||||
}
|
||||
}
|
||||
for (const e of roots) await readEntry(e, "");
|
||||
return notes;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// Сквозные типы Obsidian Wrapped (общие: reader, worker, рендер, API).
|
||||
// Приватность: имена/тексты заметок живут ТОЛЬКО в браузере; в БД/сеть уходят
|
||||
// лишь числовые/строковые агрегаты (RunPayload).
|
||||
|
||||
export type Archetype = "architect" | "gardener" | "librarian" | "pragmatist";
|
||||
export type Period = "year" | "quarter" | "all";
|
||||
|
||||
export interface Stats {
|
||||
noteCount: number;
|
||||
linkCount: number;
|
||||
tagCount: number;
|
||||
topTags: { tag: string; n: number }[]; // top-5
|
||||
topLinked: { name: string; degree: number }[]; // top-5 самых связанных
|
||||
orphans: number;
|
||||
busiestMonth: string; // "2026-03"
|
||||
achievements: string[];
|
||||
archetype: Archetype;
|
||||
score: number; // 0..100
|
||||
period: Period;
|
||||
}
|
||||
|
||||
// Сырьё — НИКОГДА не покидает браузер.
|
||||
export interface RawNote {
|
||||
name: string;
|
||||
text: string;
|
||||
ctime: number;
|
||||
mtime: number;
|
||||
}
|
||||
|
||||
// То, что реально уходит в POST /api/wrapped/run — БЕЗ имён/текстов.
|
||||
export interface RunPayload {
|
||||
period: Period;
|
||||
noteCount: number;
|
||||
linkCount: number;
|
||||
tagCount: number;
|
||||
topTag: string | null;
|
||||
archetype: Archetype;
|
||||
score: number;
|
||||
}
|
||||
Reference in New Issue
Block a user