Compare commits

...

2 Commits

Author SHA1 Message Date
beo3000 3663b31c64 ignore 2026-02-27 19:36:47 +01:00
beo3000 95c280456d upd cal und deploy with backup 2026-02-27 19:36:33 +01:00
17 changed files with 499 additions and 15 deletions

5
.gitignore vendored
View File

@ -3,3 +3,8 @@ work/
ka-note/server/ka-note.db-wal
ka-note/server/ka-note.db-wal
ka-note/server/ka-note.db-wal
ka-note/server/ka-note.db-shm
ka-note/server/ka-note.db-shm
ka-note/server/ka-note.db-wal
ka-note/server/ka-note.db-shm
ka-note/server/ka-note.db-wal

1
ka-note/.gitignore vendored
View File

@ -9,3 +9,4 @@ build/
*.sqlite
.DS_Store
work/
backups/

View File

@ -1 +1 @@
1.1.84
1.1.85

View File

@ -245,7 +245,7 @@
async function selectCalendarEvent(ev: CalendarEvent) {
newEventTitle = ev.subject;
newEventTime = ev.start;
pendingBodyPreview = ev.bodyPreview;
pendingBodyPreview = ev.body;
calendarPickerOpen = false;
const persons = await db.contexts

View File

@ -5,7 +5,7 @@ export interface CalendarEvent {
subject: string;
start: string; // "HH:MM"
end: string; // "HH:MM"
bodyPreview: string;
body: string;
attendees: { name: string; email: string }[];
}

Binary file not shown.

Binary file not shown.

View File

@ -1,3 +1,9 @@
<#
.PARAMETER CheckOnly
Download and verify the prod DB without building or deploying.
#>
param([switch]$CheckOnly)
$ErrorActionPreference = "Stop"
# Load .env from script directory
@ -14,14 +20,62 @@ $APP = "ka-note"
$RG = "rg-koogle-prod"
$IMAGE = "$ACR.azurecr.io/${APP}:latest"
# Bump patch version in VERSION file
# Read current version (don't bump yet -- only after DB check passes)
$versionFile = Join-Path $PSScriptRoot "VERSION"
$current = (Get-Content $versionFile -Raw).Trim()
$parts = $current -split '\.'
$parts[2] = [int]$parts[2] + 1
$VERSION = $parts -join '.'
if ($CheckOnly) {
$VERSION = $current
} else {
$parts = $current -split '\.'
$parts[2] = [int]$parts[2] + 1
$VERSION = $parts -join '.'
}
$checkLabel = if ($CheckOnly) { " [check-only]" } else { "" }
Write-Host "=== Version: $VERSION$checkLabel ===" -ForegroundColor Yellow
Write-Host "=== Backup DB from Prod ===" -ForegroundColor Cyan
$KuduToken = az account get-access-token --resource "https://management.azure.com" --query accessToken -o tsv
if ($LASTEXITCODE -ne 0) { throw "Failed to get Azure access token" }
$KuduDbUrl = "https://${APP}.scm.azurewebsites.net/api/vfs/data/ka-note.db"
$BackupDir = Join-Path $PSScriptRoot "backups"
$null = New-Item -ItemType Directory -Path $BackupDir -Force
$BackupFile = Join-Path $BackupDir ("ka-note-pre-deploy-$VERSION.db")
Write-Host " Downloading ka-note.db from prod..." -ForegroundColor DarkGray
try {
Invoke-WebRequest -Uri $KuduDbUrl `
-Headers @{ Authorization = "Bearer $KuduToken" } `
-OutFile $BackupFile -UseBasicParsing
} catch {
throw "Kudu DB download failed: $_"
}
$DbSize = (Get-Item $BackupFile).Length
Write-Host " Downloaded: $([math]::Round($DbSize/1KB,1)) KB" -ForegroundColor DarkGray
if ($DbSize -lt 4096) {
throw "DB backup is suspiciously small ($DbSize bytes) - aborting deploy"
}
# Verify with sqlite3 (must be in PATH)
$IntegrityResult = & sqlite3 $BackupFile "PRAGMA integrity_check;" 2>&1
if ($LASTEXITCODE -ne 0 -or $IntegrityResult -ne 'ok') {
throw "DB integrity_check FAILED: $IntegrityResult - aborting deploy. Backup saved at $BackupFile"
}
Write-Host " integrity_check: ok ($([math]::Round($DbSize/1KB,1)) KB)" -ForegroundColor Green
if ($CheckOnly) {
Write-Host ""
Write-Host "=== Check passed -- no deploy performed ===" -ForegroundColor Green
Write-Host " Backup: $BackupFile" -ForegroundColor DarkGray
Write-Host ""
exit 0
}
# Bump version now that DB check passed
Set-Content $versionFile $VERSION -Encoding UTF8 -NoNewline
Write-Host "=== Version: $VERSION ===" -ForegroundColor Yellow
Write-Host "=== Generate migrations ===" -ForegroundColor Cyan
Push-Location server

View File

@ -4384,6 +4384,12 @@
"readable-stream": "^3.4.0"
}
},
"node_modules/boolbase": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
"license": "ISC"
},
"node_modules/brace-expansion": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.2.tgz",
@ -4800,6 +4806,34 @@
"node": ">=8"
}
},
"node_modules/css-select": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
"integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^1.0.0",
"css-what": "^6.1.0",
"domhandler": "^5.0.2",
"domutils": "^3.0.1",
"nth-check": "^2.0.1"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/css-what": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
"integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==",
"license": "BSD-2-Clause",
"engines": {
"node": ">= 6"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/cssesc": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
@ -4991,6 +5025,47 @@
"dev": true,
"license": "MIT"
},
"node_modules/dom-serializer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.2",
"entities": "^4.2.0"
},
"funding": {
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
}
},
"node_modules/domelementtype": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "BSD-2-Clause"
},
"node_modules/domhandler": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
"license": "BSD-2-Clause",
"dependencies": {
"domelementtype": "^2.3.0"
},
"engines": {
"node": ">= 4"
},
"funding": {
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/dompurify": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz",
@ -5000,6 +5075,20 @@
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/domutils": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
"license": "BSD-2-Clause",
"dependencies": {
"dom-serializer": "^2.0.0",
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3"
},
"funding": {
"url": "https://github.com/fb55/domutils?sponsor=1"
}
},
"node_modules/drizzle-kit": {
"version": "0.30.6",
"resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.30.6.tgz",
@ -6497,6 +6586,15 @@
"node": ">= 0.4"
}
},
"node_modules/he": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
"integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
"license": "MIT",
"bin": {
"he": "bin/he"
}
},
"node_modules/hono": {
"version": "4.12.0",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.0.tgz",
@ -7459,6 +7557,28 @@
"node": ">=10"
}
},
"node_modules/node-html-markdown": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/node-html-markdown/-/node-html-markdown-2.0.0.tgz",
"integrity": "sha512-DqUC3GGP7pwSYxS93SwHoP+qCw78xcMP6C6H2DuC8rPD2AweJRjBzQb5SdXpKtDlqAQ7hVotJcfhgU7hU5Gthw==",
"license": "MIT",
"dependencies": {
"node-html-parser": "^6.1.13"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/node-html-parser": {
"version": "6.1.13",
"resolved": "https://registry.npmjs.org/node-html-parser/-/node-html-parser-6.1.13.tgz",
"integrity": "sha512-qIsTMOY4C/dAa5Q5vsobRpOOvPfC4pB61UVW2uSwZNUp0QU/jCekTal1vMmbO0DgdHeLUJpv/ARmDqErVxA3Sg==",
"license": "MIT",
"dependencies": {
"css-select": "^5.1.0",
"he": "1.2.0"
}
},
"node_modules/node-releases": {
"version": "2.0.27",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
@ -7476,6 +7596,18 @@
"node": ">=0.10.0"
}
},
"node_modules/nth-check": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
"integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^1.0.0"
},
"funding": {
"url": "https://github.com/fb55/nth-check?sponsor=1"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@ -10881,6 +11013,7 @@
"fflate": "^0.8.2",
"hono": "^4.7.4",
"jose": "^6.1.3",
"node-html-markdown": "^2.0.0",
"zod": "^4.3.6"
},
"devDependencies": {

View File

@ -1 +0,0 @@
ALTER TABLE `api_keys` ADD `email` text NOT NULL DEFAULT '';

View File

@ -1 +1,2 @@
ALTER TABLE `api_keys` ADD `email` text DEFAULT '' NOT NULL;
-- email column already added during manual db recovery; no-op
SELECT 1;

Binary file not shown.

Binary file not shown.

View File

@ -14,12 +14,13 @@
"@hono/node-server": "^1.13.8",
"@hono/zod-openapi": "^1.2.2",
"@ka-note/shared": "*",
"better-sqlite3": "^11.0.0",
"@scalar/hono-api-reference": "^0.9.44",
"better-sqlite3": "^11.0.0",
"drizzle-orm": "^0.38.0",
"fflate": "^0.8.2",
"hono": "^4.7.4",
"jose": "^6.1.3",
"node-html-markdown": "^2.0.0",
"zod": "^4.3.6"
},
"devDependencies": {

View File

@ -0,0 +1,82 @@
'use strict';
const Database = require('better-sqlite3');
const { readFileSync, readdirSync, existsSync } = require('fs');
const { join } = require('path');
const BUNDLE = 'C:/Users/d-chrka/Downloads/bundle-restore';
const DB_PATH = 'C:/work/chrka/myNote/ka-note/server/ka-note.db';
const b = (v) => (v ? 1 : 0);
const db = new Database(DB_PATH);
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = OFF');
const userId = JSON.parse(readFileSync(join(BUNDLE, 'manifest.json'), 'utf8')).userId;
console.log('userId:', userId);
const ctxRows = JSON.parse(readFileSync(join(BUNDLE, 'contexts.json'), 'utf8'));
const ctxStmt = db.prepare(`INSERT INTO contexts (id,user_id,name,type,sort_order,meta,archived_at,is_favorite,updated_at,deleted_at,purged_at,version) VALUES (@id,@userId,@name,@type,@sortOrder,@meta,@archivedAt,@isFavorite,@updatedAt,@deletedAt,@purgedAt,@version) ON CONFLICT(id,user_id) DO UPDATE SET name=excluded.name,type=excluded.type,sort_order=excluded.sort_order,meta=excluded.meta,archived_at=excluded.archived_at,is_favorite=excluded.is_favorite,updated_at=excluded.updated_at,deleted_at=excluded.deleted_at,purged_at=excluded.purged_at,version=excluded.version`);
db.transaction(() => { for (const r of ctxRows) ctxStmt.run({...r, userId, meta: r.meta ? JSON.stringify(r.meta) : null, isFavorite: b(r.isFavorite)}); })();
console.log('contexts:', ctxRows.length);
const topRows = JSON.parse(readFileSync(join(BUNDLE, 'topics.json'), 'utf8'));
const topStmt = db.prepare(`INSERT INTO topics (id,user_id,context_id,title,status,snooze_until,sort_order,is_new,updated_at,deleted_at,purged_at,version) VALUES (@id,@userId,@contextId,@title,@status,@snoozeUntil,@sortOrder,@isNew,@updatedAt,@deletedAt,@purgedAt,@version) ON CONFLICT(id,user_id) DO UPDATE SET context_id=excluded.context_id,title=excluded.title,status=excluded.status,snooze_until=excluded.snooze_until,sort_order=excluded.sort_order,is_new=excluded.is_new,updated_at=excluded.updated_at,deleted_at=excluded.deleted_at,purged_at=excluded.purged_at,version=excluded.version`);
db.transaction(() => { for (const r of topRows) topStmt.run({...r, userId, isNew: b(r.isNew)}); })();
console.log('topics:', topRows.length);
const histDir = join(BUNDLE, 'history');
if (existsSync(histDir)) {
const files = readdirSync(histDir).filter(f => f.endsWith('.meta.json'));
const hStmt = db.prepare(`INSERT INTO history_entries (id,user_id,topic_id,date,text,sort_order,linked_context_id,done_at,wiedervorlage_date,wiedervorlage_resolved_at,is_private,updated_at,deleted_at,purged_at,version) VALUES (@id,@userId,@topicId,@date,@text,@sortOrder,@linkedContextId,@doneAt,@wiedervorlageDate,@wiedervorlageResolvedAt,@isPrivate,@updatedAt,@deletedAt,@purgedAt,@version) ON CONFLICT(id,user_id) DO UPDATE SET topic_id=excluded.topic_id,date=excluded.date,text=excluded.text,sort_order=excluded.sort_order,linked_context_id=excluded.linked_context_id,done_at=excluded.done_at,wiedervorlage_date=excluded.wiedervorlage_date,wiedervorlage_resolved_at=excluded.wiedervorlage_resolved_at,is_private=excluded.is_private,updated_at=excluded.updated_at,deleted_at=excluded.deleted_at,purged_at=excluded.purged_at,version=excluded.version`);
db.transaction(() => {
for (const f of files) {
const meta = JSON.parse(readFileSync(join(histDir, f), 'utf8'));
const mdFile = join(histDir, f.replace('.meta.json', '.md'));
const text = existsSync(mdFile) ? readFileSync(mdFile, 'utf8') : '';
hStmt.run({...meta, userId, text, isPrivate: b(meta.isPrivate)});
}
})();
console.log('history:', files.length);
}
const ratingRows = JSON.parse(readFileSync(join(BUNDLE, 'ratings.json'), 'utf8'));
console.log('ratings:', ratingRows.length);
const wikiDir = join(BUNDLE, 'wiki');
if (existsSync(wikiDir)) {
const wFiles = readdirSync(wikiDir).filter(f => f.endsWith('.meta.json'));
const pStmt = db.prepare(`INSERT INTO pages (id,user_id,title,body,is_private,is_favorite,sort_order,updated_at,deleted_at,purged_at,version) VALUES (@id,@userId,@title,@body,@isPrivate,@isFavorite,@sortOrder,@updatedAt,@deletedAt,@purgedAt,@version) ON CONFLICT(id,user_id) DO UPDATE SET title=excluded.title,body=excluded.body,is_private=excluded.is_private,is_favorite=excluded.is_favorite,sort_order=excluded.sort_order,updated_at=excluded.updated_at,deleted_at=excluded.deleted_at,purged_at=excluded.purged_at,version=excluded.version`);
db.transaction(() => {
for (const f of wFiles) {
const meta = JSON.parse(readFileSync(join(wikiDir, f), 'utf8'));
const mdFile = join(wikiDir, f.replace('.meta.json', '.md'));
const body = existsSync(mdFile) ? readFileSync(mdFile, 'utf8') : '';
pStmt.run({...meta, userId, body, isPrivate: b(meta.isPrivate), isFavorite: b(meta.isFavorite)});
}
})();
console.log('pages:', wFiles.length);
}
if (existsSync(join(BUNDLE, 'notebooks.json'))) {
const nbRows = JSON.parse(readFileSync(join(BUNDLE, 'notebooks.json'), 'utf8'));
if (nbRows.length) {
const nbStmt = db.prepare(`INSERT INTO notebooks (id,user_id,name,context_id,is_private,is_favorite,sort_order,updated_at,deleted_at,purged_at,version) VALUES (@id,@userId,@name,@contextId,@isPrivate,@isFavorite,@sortOrder,@updatedAt,@deletedAt,@purgedAt,@version) ON CONFLICT(id,user_id) DO UPDATE SET name=excluded.name,context_id=excluded.context_id,is_private=excluded.is_private,is_favorite=excluded.is_favorite,sort_order=excluded.sort_order,updated_at=excluded.updated_at,deleted_at=excluded.deleted_at,purged_at=excluded.purged_at,version=excluded.version`);
db.transaction(() => { for (const r of nbRows) nbStmt.run({...r, userId, isPrivate: b(r.isPrivate), isFavorite: b(r.isFavorite)}); })();
console.log('notebooks:', nbRows.length);
}
}
if (existsSync(join(BUNDLE, 'page_notebooks.json'))) {
const pnRows = JSON.parse(readFileSync(join(BUNDLE, 'page_notebooks.json'), 'utf8'));
if (pnRows.length) {
const pnStmt = db.prepare(`INSERT INTO page_notebooks (id,user_id,page_id,notebook_id,sort_order,updated_at,deleted_at,purged_at,version) VALUES (@id,@userId,@pageId,@notebookId,@sortOrder,@updatedAt,@deletedAt,@purgedAt,@version) ON CONFLICT(id,user_id) DO UPDATE SET sort_order=excluded.sort_order,updated_at=excluded.updated_at,deleted_at=excluded.deleted_at,purged_at=excluded.purged_at,version=excluded.version`);
db.transaction(() => { for (const r of pnRows) pnStmt.run({...r, userId}); })();
console.log('page_notebooks:', pnRows.length);
}
}
const counts = db.prepare(`SELECT (SELECT COUNT(*) FROM contexts WHERE user_id=?) as c,(SELECT COUNT(*) FROM topics WHERE user_id=?) as t,(SELECT COUNT(*) FROM history_entries WHERE user_id=?) as h,(SELECT COUNT(*) FROM pages WHERE user_id=?) as p`).get(userId,userId,userId,userId);
console.log('Final counts:', counts);
console.log('integrity_check:', db.pragma('integrity_check')[0].integrity_check);
db.close();
console.log('Done.');

View File

@ -0,0 +1,194 @@
import Database from 'better-sqlite3';
import { readFileSync, readdirSync, existsSync } from 'fs';
import { join } from 'path';
const BUNDLE = 'C:/Users/d-chrka/Downloads/bundle-restore';
const DB_PATH = 'C:/work/chrka/myNote/ka-note/server/ka-note.db';
const db = new Database(DB_PATH);
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = OFF');
const manifest = JSON.parse(readFileSync(join(BUNDLE, 'manifest.json'), 'utf8'));
const userId = manifest.userId;
console.log(`userId: ${userId}`);
// Helper: upsert with all fields
function upsertContexts() {
const rows = JSON.parse(readFileSync(join(BUNDLE, 'contexts.json'), 'utf8'));
const stmt = db.prepare(`
INSERT INTO contexts (id, user_id, name, type, sort_order, meta, archived_at, is_favorite,
updated_at, deleted_at, purged_at, version)
VALUES (@id, @userId, @name, @type, @sortOrder, @meta, @archivedAt, @isFavorite,
@updatedAt, @deletedAt, @purgedAt, @version)
ON CONFLICT(id, user_id) DO UPDATE SET
name=excluded.name, type=excluded.type, sort_order=excluded.sort_order,
meta=excluded.meta, archived_at=excluded.archived_at, is_favorite=excluded.is_favorite,
updated_at=excluded.updated_at, deleted_at=excluded.deleted_at,
purged_at=excluded.purged_at, version=excluded.version
`);
const run = db.transaction(() => {
for (const r of rows) {
stmt.run({ ...r, userId, meta: r.meta ? JSON.stringify(r.meta) : null, isFavorite: b(r.isFavorite) });
}
});
run();
console.log(`contexts: ${rows.length}`);
}
function upsertTopics() {
const rows = JSON.parse(readFileSync(join(BUNDLE, 'topics.json'), 'utf8'));
const stmt = db.prepare(`
INSERT INTO topics (id, user_id, context_id, title, status, snooze_until, sort_order,
is_new, updated_at, deleted_at, purged_at, version)
VALUES (@id, @userId, @contextId, @title, @status, @snoozeUntil, @sortOrder,
@isNew, @updatedAt, @deletedAt, @purgedAt, @version)
ON CONFLICT(id, user_id) DO UPDATE SET
context_id=excluded.context_id, title=excluded.title, status=excluded.status,
snooze_until=excluded.snooze_until, sort_order=excluded.sort_order,
is_new=excluded.is_new, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at,
purged_at=excluded.purged_at, version=excluded.version
`);
const run = db.transaction(() => {
for (const r of rows) {
stmt.run({ ...r, userId });
}
});
run();
console.log(`topics: ${rows.length}`);
}
function upsertHistory() {
const histDir = join(BUNDLE, 'history');
if (!existsSync(histDir)) { console.log('history: 0'); return; }
const files = readdirSync(histDir).filter(f => f.endsWith('.meta.json'));
const stmt = db.prepare(`
INSERT INTO history_entries (id, user_id, topic_id, date, text, sort_order,
linked_context_id, done_at, wiedervorlage_date, wiedervorlage_resolved_at,
is_private, updated_at, deleted_at, purged_at, version)
VALUES (@id, @userId, @topicId, @date, @text, @sortOrder,
@linkedContextId, @doneAt, @wiedervorlageDate, @wiedervorlageResolvedAt,
@isPrivate, @updatedAt, @deletedAt, @purgedAt, @version)
ON CONFLICT(id, user_id) DO UPDATE SET
topic_id=excluded.topic_id, date=excluded.date, text=excluded.text,
sort_order=excluded.sort_order, linked_context_id=excluded.linked_context_id,
done_at=excluded.done_at, wiedervorlage_date=excluded.wiedervorlage_date,
wiedervorlage_resolved_at=excluded.wiedervorlage_resolved_at,
is_private=excluded.is_private, updated_at=excluded.updated_at,
deleted_at=excluded.deleted_at, purged_at=excluded.purged_at, version=excluded.version
`);
const run = db.transaction(() => {
for (const f of files) {
const meta = JSON.parse(readFileSync(join(histDir, f), 'utf8'));
const mdFile = join(histDir, f.replace('.meta.json', '.md'));
const text = existsSync(mdFile) ? readFileSync(mdFile, 'utf8') : '';
stmt.run({ ...meta, userId, text, isPrivate: b(meta.isPrivate) });
}
});
run();
console.log(`history: ${files.length}`);
}
function upsertRatings() {
const rows = JSON.parse(readFileSync(join(BUNDLE, 'ratings.json'), 'utf8'));
if (!rows.length) { console.log('ratings: 0'); return; }
const stmt = db.prepare(`
INSERT INTO ratings (id, user_id, topic_id, history_entry_id, person_name, value,
comment, updated_at, deleted_at, purged_at, version)
VALUES (@id, @userId, @topicId, @historyEntryId, @personName, @value,
@comment, @updatedAt, @deletedAt, @purgedAt, @version)
ON CONFLICT(id, user_id) DO UPDATE SET
value=excluded.value, comment=excluded.comment, updated_at=excluded.updated_at,
deleted_at=excluded.deleted_at, purged_at=excluded.purged_at, version=excluded.version
`);
const run = db.transaction(() => { for (const r of rows) stmt.run({ ...r, userId, isPrivate: b(r.isPrivate), isFavorite: b(r.isFavorite) }); });
run();
console.log(`ratings: ${rows.length}`);
}
function upsertPages() {
const wikiDir = join(BUNDLE, 'wiki');
if (!existsSync(wikiDir)) { console.log('pages: 0'); return; }
const files = readdirSync(wikiDir).filter(f => f.endsWith('.meta.json'));
const stmt = db.prepare(`
INSERT INTO pages (id, user_id, title, body, is_private, is_favorite, sort_order,
updated_at, deleted_at, purged_at, version)
VALUES (@id, @userId, @title, @body, @isPrivate, @isFavorite, @sortOrder,
@updatedAt, @deletedAt, @purgedAt, @version)
ON CONFLICT(id, user_id) DO UPDATE SET
title=excluded.title, body=excluded.body, is_private=excluded.is_private,
is_favorite=excluded.is_favorite, sort_order=excluded.sort_order,
updated_at=excluded.updated_at, deleted_at=excluded.deleted_at,
purged_at=excluded.purged_at, version=excluded.version
`);
const run = db.transaction(() => {
for (const f of files) {
const meta = JSON.parse(readFileSync(join(wikiDir, f), 'utf8'));
const mdFile = join(wikiDir, f.replace('.meta.json', '.md'));
const body = existsSync(mdFile) ? readFileSync(mdFile, 'utf8') : '';
stmt.run({ ...meta, userId, body, isPrivate: b(meta.isPrivate), isFavorite: b(meta.isFavorite) });
}
});
run();
console.log(`pages: ${files.length}`);
}
function upsertNotebooks() {
if (!existsSync(join(BUNDLE, 'notebooks.json'))) { console.log('notebooks: 0'); return; }
const rows = JSON.parse(readFileSync(join(BUNDLE, 'notebooks.json'), 'utf8'));
if (!rows.length) { console.log('notebooks: 0'); return; }
const stmt = db.prepare(`
INSERT INTO notebooks (id, user_id, name, context_id, is_private, is_favorite,
sort_order, updated_at, deleted_at, purged_at, version)
VALUES (@id, @userId, @name, @contextId, @isPrivate, @isFavorite,
@sortOrder, @updatedAt, @deletedAt, @purgedAt, @version)
ON CONFLICT(id, user_id) DO UPDATE SET
name=excluded.name, context_id=excluded.context_id, is_private=excluded.is_private,
is_favorite=excluded.is_favorite, sort_order=excluded.sort_order,
updated_at=excluded.updated_at, deleted_at=excluded.deleted_at,
purged_at=excluded.purged_at, version=excluded.version
`);
const run = db.transaction(() => { for (const r of rows) stmt.run({ ...r, userId }); });
run();
console.log(`notebooks: ${rows.length}`);
}
function upsertPageNotebooks() {
if (!existsSync(join(BUNDLE, 'page_notebooks.json'))) { console.log('page_notebooks: 0'); return; }
const rows = JSON.parse(readFileSync(join(BUNDLE, 'page_notebooks.json'), 'utf8'));
if (!rows.length) { console.log('page_notebooks: 0'); return; }
const stmt = db.prepare(`
INSERT INTO page_notebooks (id, user_id, page_id, notebook_id, sort_order,
updated_at, deleted_at, purged_at, version)
VALUES (@id, @userId, @pageId, @notebookId, @sortOrder,
@updatedAt, @deletedAt, @purgedAt, @version)
ON CONFLICT(id, user_id) DO UPDATE SET
sort_order=excluded.sort_order, updated_at=excluded.updated_at,
deleted_at=excluded.deleted_at, purged_at=excluded.purged_at, version=excluded.version
`);
const run = db.transaction(() => { for (const r of rows) stmt.run({ ...r, userId }); });
run();
console.log(`page_notebooks: ${rows.length}`);
}
upsertContexts();
upsertTopics();
upsertHistory();
upsertRatings();
upsertPages();
upsertNotebooks();
upsertPageNotebooks();
// Verify
const counts = db.prepare(`
SELECT
(SELECT COUNT(*) FROM contexts WHERE user_id=?) as contexts,
(SELECT COUNT(*) FROM topics WHERE user_id=?) as topics,
(SELECT COUNT(*) FROM history_entries WHERE user_id=?) as history,
(SELECT COUNT(*) FROM pages WHERE user_id=?) as pages
`).get(userId, userId, userId, userId);
console.log('Final counts:', counts);
db.pragma('integrity_check');
db.close();
console.log('Done.');

View File

@ -1,9 +1,11 @@
import { NodeHtmlMarkdown } from 'node-html-markdown';
export interface CalendarEvent {
id: string;
subject: string;
start: string; // "HH:MM"
end: string; // "HH:MM"
bodyPreview: string;
body: string;
attendees: { name: string; email: string }[];
}
@ -68,7 +70,7 @@ export async function getCalendarEvents(
const start = `${date}T00:00:00`;
const end = `${date}T23:59:59`;
const select = 'id,subject,start,end,bodyPreview,attendees,isAllDay';
const select = 'id,subject,start,end,body,attendees,isAllDay';
const url =
`https://graph.microsoft.com/v1.0/users/${encodeURIComponent(userEmail)}/calendarView` +
`?startDateTime=${encodeURIComponent(start)}` +
@ -90,12 +92,24 @@ export async function getCalendarEvents(
}
console.log(`[graph] calendarView OK`);
const CAUTION_BANNER = /CAUTION:\s*This email is from outside the organization\.[^]*?content is safe\./gi;
function cleanBody(raw: string, contentType: string): string {
const md = contentType === 'html'
? NodeHtmlMarkdown.translate(raw)
: raw;
return md
.replace(CAUTION_BANNER, '')
.replace(/\n{3,}/g, '\n\n')
.trim();
}
type GraphEvent = {
id: string;
subject: string;
start: { dateTime: string };
end: { dateTime: string };
bodyPreview: string;
body: { contentType: string; content: string };
isAllDay: boolean;
attendees: { emailAddress: { name: string; address: string }; type: string }[];
};
@ -109,7 +123,7 @@ export async function getCalendarEvents(
subject: e.subject ?? '',
start: toHHMM(e.start.dateTime),
end: toHHMM(e.end.dateTime),
bodyPreview: e.bodyPreview ?? '',
body: cleanBody(e.body?.content ?? '', e.body?.contentType ?? 'text'),
attendees: (e.attendees ?? [])
.filter((a) => a.emailAddress.address.toLowerCase() !== userEmail.toLowerCase())
.map((a) => ({ name: a.emailAddress.name, email: a.emailAddress.address })),