Compare commits

...

22 Commits

Author SHA1 Message Date
Elian Doran
fcc575c508 feat(standalone/setup): reload after creating new document 2026-03-23 23:05:57 +02:00
Elian Doran
62d6ce08a0 fix(standalone): database initialization slow 2026-03-23 21:35:26 +02:00
Elian Doran
b50127b0d3 fix(client): froca initialization incorrect due to DB init check 2026-03-23 21:29:38 +02:00
Elian Doran
669a58cc0e fix(standalone): database not initialized after first setup 2026-03-23 21:08:56 +02:00
Elian Doran
bf4b5dad5a feat(standalone/setup): set up new document 2026-03-23 21:06:30 +02:00
Elian Doran
39972a9bd7 feat(standalone/setup): basic server sync form 2026-03-23 20:27:44 +02:00
Elian Doran
44f519c1d6 feat(standalone/setup): basic footer 2026-03-23 20:21:47 +02:00
Elian Doran
dd6c5bbf12 chore(standalone/setup): more concise descriptions 2026-03-23 20:10:56 +02:00
Elian Doran
20d4db2608 style(standalone/setup): add a shadow 2026-03-23 20:07:32 +02:00
Elian Doran
3151e86665 feat(standalone/setup): add icons 2026-03-23 20:02:20 +02:00
Elian Doran
96a0d483f5 feat(standalone/setup): add hover effect 2026-03-23 19:50:53 +02:00
Elian Doran
3faefdbc85 feat(standalone/setup): basic styling of cards 2026-03-23 19:47:44 +02:00
Elian Doran
12347d5c4a chore(standalone/setup): basic layout 2026-03-23 19:30:00 +02:00
Elian Doran
4dbaadf9cc chore(standalone/setup): replace properly for hot reload 2026-03-23 19:26:26 +02:00
Elian Doran
2a1c165a54 fix(standalone/setup): translations not initializing due to missing asset path 2026-03-23 19:25:01 +02:00
Elian Doran
939f931809 chore(standalone/setup): setup translation partially 2026-03-23 19:20:30 +02:00
Elian Doran
4fd09bf1f8 chore(standalone/setup): prevent error in froca due to not initialized DB 2026-03-23 19:20:24 +02:00
Elian Doran
3231db3c3f fix(standalone/setup): server API missing when DB not initialized 2026-03-23 19:19:56 +02:00
Elian Doran
c07ea1bfa7 feat(standalone/setup): dedicated setup page with React 2026-03-23 18:59:56 +02:00
Elian Doran
79db638bf4 chore(standalone): get bootstrap to report not initialized state 2026-03-23 18:54:44 +02:00
Elian Doran
794dab2894 chore(standalone): port most of sql_init 2026-03-23 18:49:06 +02:00
Elian Doran
97b303aea6 chore(standalone): remove default seed 2026-03-23 18:34:16 +02:00
21 changed files with 593 additions and 6953 deletions

View File

@@ -4,7 +4,8 @@
*/
import { BootstrapDefinition } from '@triliumnext/commons';
import { entity_changes, getContext, getSharedBootstrapItems, getSql, routes } from '@triliumnext/core';
import { entity_changes, getContext, getSharedBootstrapItems, getSql, routes, sql_init } from '@triliumnext/core';
import { getIconConfig } from '@triliumnext/core/src/services/bootstrap_utils';
import packageJson from '../../package.json' with { type: 'json' };
import { type BrowserRequest, BrowserRouter } from './browser_router';
@@ -178,10 +179,23 @@ function apiResultHandler(_req: any, res: ResultHandlerResponse, result: unknown
}
/**
* No-op auth middleware for standalone — there's no authentication.
* No-op middleware stubs for standalone mode.
*
* In a browser context there is no network authentication, rate limiting,
* or multi-user access, so all auth/rate-limit middleware is a no-op.
*
* `checkAppNotInitialized` still guards setup routes: if the database is
* already initialised the middleware throws so the route handler is never
* reached (mirrors the server behaviour).
*/
function checkApiAuth() {
// No authentication in standalone mode.
function noopMiddleware() {
// No-op.
}
function checkAppNotInitialized() {
if (sql_init.isDbInitialized()) {
throw new Error("App already initialized.");
}
}
/**
@@ -206,11 +220,15 @@ export function registerRoutes(router: BrowserRouter): void {
const apiRoute = createApiRoute(router, true);
routes.buildSharedApiRoutes({
route: createRoute(router),
asyncRoute: createRoute(router),
apiRoute,
asyncApiRoute: createApiRoute(router, false),
apiResultHandler,
checkApiAuth,
checkApiAuthOrElectron: checkApiAuth
checkApiAuth: noopMiddleware,
checkApiAuthOrElectron: noopMiddleware,
checkAppNotInitialized,
checkCredentials: noopMiddleware,
loginRateLimiter: noopMiddleware
});
apiRoute('get', '/bootstrap', bootstrapRoute);
@@ -220,10 +238,22 @@ export function registerRoutes(router: BrowserRouter): void {
apiRoute("get", "/api/system-checks", () => ({ isCpuArchMismatch: false }));
}
function bootstrapRoute() {
function bootstrapRoute(): BootstrapDefinition {
const assetPath = ".";
if (!sql_init.isDbInitialized()) {
return {
dbInitialized: false,
baseApiUrl: "../api/",
assetPath,
themeCssUrl: false,
themeUseNextAsBase: "next",
...getIconConfig(assetPath)
};
}
return {
dbInitialized: true,
...getSharedBootstrapItems(assetPath),
appPath: assetPath,
device: false, // Let the client detect device type.
@@ -248,7 +278,7 @@ function bootstrapRoute() {
instanceName: null,
appCssNoteIds: [],
TRILIUM_SAFE_MODE: false
} satisfies BootstrapDefinition;
};
}
/**

File diff suppressed because one or more lines are too long

View File

@@ -1,8 +1,6 @@
import { type BindableValue, default as sqlite3InitModule } from "@sqlite.org/sqlite-wasm";
import type { DatabaseProvider, RunResult, Statement, Transaction } from "@triliumnext/core";
import demoDbSql from "./db.sql?raw";
// Type definitions for SQLite WASM (the library doesn't export these directly)
type Sqlite3Module = Awaited<ReturnType<typeof sqlite3InitModule>>;
type Sqlite3Database = InstanceType<Sqlite3Module["oo1"]["DB"]>;
@@ -431,32 +429,10 @@ export default class BrowserSqlProvider implements DatabaseProvider {
this.opfsDbPath = undefined; // Not using OPFS
this.db.exec("PRAGMA journal_mode = WAL");
// Initialize with demo data for in-memory databases
// (since they won't persist anyway)
this.initializeDemoDatabase();
const loadTime = performance.now() - startTime;
console.log(`[BrowserSqlProvider] In-memory database created in ${loadTime.toFixed(2)}ms`);
}
/**
* Initialize the database with demo/starter data.
* This should only be called once when creating a new database.
*
* For OPFS databases, this is called automatically only if the database
* doesn't already exist.
*/
initializeDemoDatabase(): void {
this.ensureDb();
console.log("[BrowserSqlProvider] Initializing database with demo data...");
const startTime = performance.now();
this.db!.exec(demoDbSql);
const loadTime = performance.now() - startTime;
console.log(`[BrowserSqlProvider] Demo data loaded in ${loadTime.toFixed(2)}ms`);
}
loadFromBuffer(buffer: Uint8Array): void {
this.ensureSqlite3();
// SQLite WASM can deserialize a database from a byte array

View File

@@ -132,15 +132,6 @@ async function initialize(): Promise<void> {
if (sqlProvider!.isOpfsAvailable()) {
console.log("[Worker] OPFS available, loading persistent database...");
sqlProvider!.loadFromOpfs("/trilium.db");
// Check if database is initialized (schema exists)
if (!sqlProvider!.isDbInitialized()) {
console.log("[Worker] Database not initialized, loading demo data...");
sqlProvider!.initializeDemoDatabase();
console.log("[Worker] Demo data loaded");
} else {
console.log("[Worker] Existing initialized database loaded");
}
} else {
// Fall back to in-memory database (non-persistent)
console.warn("[Worker] OPFS not available, using in-memory database (data will not persist)");
@@ -177,8 +168,16 @@ async function initialize(): Promise<void> {
router = createConfiguredRouter();
console.log("[Worker] Router configured");
console.log("[Worker] Initializing becca...");
await coreModule.becca_loader.beccaLoaded;
// initializeDb runs initDbConnection inside an execution context,
// which resolves dbReady — required before beccaLoaded can settle.
coreModule.sql_init.initializeDb();
if (coreModule.sql_init.isDbInitialized()) {
console.log("[Worker] Database already initialized, loading becca...");
await coreModule.becca_loader.beccaLoaded;
} else {
console.log("[Worker] Database not initialized, skipping becca load (will be loaded during DB initialization)");
}
console.log("[Worker] Initialization complete");
} catch (error) {

View File

@@ -112,6 +112,8 @@ function loadIcons() {
}
function setBodyAttributes() {
if (!glob.dbInitialized) return;
const { device, headingStyle, layoutOrientation, platform, isElectron, hasNativeTitleBar, hasBackgroundEffects, currentLocale } = window.glob;
const classesToSet = [
device,
@@ -132,6 +134,11 @@ function setBodyAttributes() {
}
async function loadScripts() {
if (!glob.dbInitialized) {
await import("./setup.js");
return;
}
switch (glob.device) {
case "mobile":
await import("./mobile.js");

View File

@@ -1,11 +1,11 @@
import appContext from "../components/app_context.js";
import FAttachment, { type FAttachmentRow } from "../entities/fattachment.js";
import FAttribute, { type FAttributeRow } from "../entities/fattribute.js";
import FBlob, { type FBlobRow } from "../entities/fblob.js";
import FBranch, { type FBranchRow } from "../entities/fbranch.js";
import FNote, { type FNoteRow } from "../entities/fnote.js";
import FAttribute, { type FAttributeRow } from "../entities/fattribute.js";
import server from "./server.js";
import appContext from "../components/app_context.js";
import FBlob, { type FBlobRow } from "../entities/fblob.js";
import FAttachment, { type FAttachmentRow } from "../entities/fattachment.js";
import type { Froca } from "./froca-interface.js";
import server from "./server.js";
interface SubtreeResponse {
notes: FNoteRow[];
@@ -44,8 +44,9 @@ class FrocaImpl implements Froca {
}
async loadInitialTree() {
const resp = await server.get<SubtreeResponse>("tree");
if (!glob.dbInitialized) return;
const resp = await server.get<SubtreeResponse>("tree");
// clear the cache only directly before adding new content which is important for e.g., switching to protected session
this.#clear();
this.addResp(resp);
@@ -77,7 +78,7 @@ class FrocaImpl implements Froca {
for (const noteRow of noteRows) {
const { noteId } = noteRow;
let note = this.notes[noteId];
const note = this.notes[noteId];
if (note) {
note.update(noteRow);
@@ -240,9 +241,8 @@ class FrocaImpl implements Froca {
console.trace(`Can't find note '${noteId}'`);
return null;
} else {
return this.notes[noteId];
}
return this.notes[noteId];
})
.filter((note) => !!note) as FNote[];
}
@@ -263,9 +263,8 @@ class FrocaImpl implements Froca {
console.trace(`Can't find note '${noteId}'`);
return null;
} else {
return this.notes[noteId];
}
return this.notes[noteId];
})
.filter((note) => !!note) as FNote[];
}
@@ -338,11 +337,10 @@ class FrocaImpl implements Froca {
attachmentRows = await server.getWithSilentNotFound<FAttachmentRow[]>(`attachments/${attachmentId}/all`);
} catch (e: any) {
if (silentNotFoundError) {
logInfo(`Attachment '${attachmentId}' not found, but silentNotFoundError is enabled: ` + e.message);
logInfo(`Attachment '${attachmentId}' not found, but silentNotFoundError is enabled: ${ e.message}`);
return null;
} else {
throw e;
}
throw e;
}
const attachments = this.processAttachmentRows(attachmentRows);

View File

@@ -1,16 +1,17 @@
import options from "./options.js";
import { type Locale, LOCALE_IDS, setDayjsLocale } from "@triliumnext/commons";
import i18next from "i18next";
import i18nextHttpBackend from "i18next-http-backend";
import server from "./server.js";
import { LOCALE_IDS, setDayjsLocale, type Locale } from "@triliumnext/commons";
import { initReactI18next } from "react-i18next";
import options from "./options.js";
import server from "./server.js";
let locales: Locale[] | null;
/**
* A deferred promise that resolves when translations are initialized.
*/
export let translationsInitializedPromise = $.Deferred();
export const translationsInitializedPromise = $.Deferred();
export async function initLocale() {
const locale = ((options.get("locale") as string) || "en") as LOCALE_IDS;
@@ -34,7 +35,7 @@ export async function initLocale() {
export function getAvailableLocales() {
if (!locales) {
throw new Error("Tried to load list of locales, but localization is not yet initialized.")
throw new Error("Tried to load list of locales, but localization is not yet initialized.");
}
return locales;

111
apps/client/src/setup.css Normal file
View File

@@ -0,0 +1,111 @@
html,
body {
margin: 0;
padding: 0;
width: 100vw;
height: 100vh;
}
body.setup {
margin: 0;
padding: 0;
&>div {
background: var(--left-pane-background-color);
padding: 2em;
width: 600px;
width: 100vw;
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
.setup-container {
background-color: var(--main-background-color);
border-radius: 16px;
padding: 2em;
display: flex;
flex-direction: column;
gap: 2rem;
height: 550px;
width: 700px;
box-shadow: 0px 10px 20px rgba(0, 0, 0, var(--dropdown-shadow-opacity));
.setup-options {
display: flex;
flex-direction: column;
gap: 1rem;
.setup-option-card {
padding: 1.5em;
cursor: pointer;
display: flex;
align-items: center;
gap: 1rem;
&:hover {
background-color: var(--card-background-hover-color);
filter: contrast(105%);
transition: background-color .2s ease-out;
}
.tn-icon {
font-size: 2.5em;
color: var(--muted-text-color);
}
h3 {
font-size: 1.5em;
font-weight: normal;
}
p:last-of-type {
margin-bottom: 0;
color: var(--muted-text-color);
}
}
}
}
.page {
display: flex;
flex-direction: column;
height: 100%;
>main {
flex: 1;
display: flex;
flex-direction: column;
}
>footer {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
border-top: 1px solid var(--main-border-color);
padding-top: 1rem;
}
}
form {
display: flex;
flex-direction: column;
justify-content: center;
flex-grow: 1;
gap: 1rem;
width: 80%;
margin: auto;
}
.form-item-with-icon {
display: flex;
align-items: center;
gap: 0.5rem;
.tn-icon {
font-size: 1.5em;
color: var(--muted-text-color);
}
}
}
}

129
apps/client/src/setup.tsx Normal file
View File

@@ -0,0 +1,129 @@
import "./setup.css";
import { ComponentChildren, render } from "preact";
import { useState } from "preact/hooks";
import { initLocale, t } from "./services/i18n";
import server from "./services/server";
import Button from "./widgets/react/Button";
import { CardFrame } from "./widgets/react/Card";
import FormTextBox from "./widgets/react/FormTextBox";
import Icon from "./widgets/react/Icon";
async function main() {
await initLocale();
const bodyWrapper = document.createElement("div");
document.body.classList.add("setup");
render(<App />, bodyWrapper);
document.body.replaceChildren(bodyWrapper);
}
type State = "firstOptions" | "syncFromDesktop" | "syncFromServer";
function App() {
const [ state, setState ] = useState<State>("syncFromServer");
return (
<div class="setup-container">
{state === "firstOptions" && <SetupOptions setState={setState} />}
{state === "syncFromServer" && <SyncFromServer setState={setState} />}
</div>
);
}
function SetupOptions({ setState }: { setState: (state: State) => void }) {
return (
<div class="page setup-options-container">
<h1>{t("setup.heading")}</h1>
<div class="setup-options">
<SetupOptionCard
icon="bx bx-file-blank"
title={t("setup.new-document")}
description={t("setup.new-document-description")}
onClick={async () => {
await server.post("setup/new-document");
// After creating a new document, we can just reload the page to load it.
location.reload();
}}
/>
<SetupOptionCard
icon="bx bx-server"
title={t("setup.sync-from-server")}
description={t("setup.sync-from-server-description")}
onClick={() => setState("syncFromServer")}
/>
<SetupOptionCard
icon="bx bx-desktop"
title={t("setup.sync-from-desktop")}
description={t("setup.sync-from-desktop-description")}
onClick={() => setState("syncFromDesktop")}
/>
</div>
</div>
);
}
function SyncFromServer({ setState }: { setState: (state: State) => void }) {
const [serverUrl, setServerUrl] = useState("");
const [password, setPassword] = useState("");
function handleFinishSetup() {
server.post("setup/sync-from-server", {
syncServerHost: serverUrl,
password
});
}
return (
<div class="page sync-from-server">
<h1>{t("setup.sync-from-server-page-title")}</h1>
<p>{t("setup.sync-from-server-page-description")}</p>
<main>
<form>
<FormItemWithIcon icon="bx bx-server">
<FormTextBox placeholder="https://example.com" currentValue={serverUrl} onChange={setServerUrl} />
</FormItemWithIcon>
<FormItemWithIcon icon="bx bx-lock">
<FormTextBox placeholder={t("setup.password-placeholder")} type="password" currentValue={password} onChange={setPassword} />
</FormItemWithIcon>
</form>
</main>
<footer>
<Button text={t("setup.button-back")} onClick={() => setState("firstOptions")} kind="lowProfile" />
<Button text={t("setup.button-finish-setup")} kind="primary" onClick={handleFinishSetup} />
</footer>
</div>
);
}
function FormItemWithIcon({ icon, children }: { icon: string; children: ComponentChildren }) {
return (
<div class="form-item-with-icon">
<Icon icon={icon} />
{children}
</div>
);
}
function SetupOptionCard({ title, description, icon, onClick }: { title: string; description: string, icon: string, onClick?: () => void }) {
return (
<CardFrame className="setup-option-card" onClick={onClick}>
<Icon icon={icon} />
<div>
<h3>{title}</h3>
<p>{description}</p>
</div>
</CardFrame>
);
}
main();

View File

@@ -2230,5 +2230,23 @@
"sample_xy": "XY",
"sample_venn": "Venn",
"sample_ishikawa": "Ishikawa"
},
"setup": {
"heading": "Trilium Notes setup",
"new-document": "New document",
"new-document-description": "Start with a clean workspace and begin immediately.",
"sync-from-desktop": "Sync from desktop",
"sync-from-desktop-description": "“Import data from your desktop app on this network.",
"sync-from-server": "Sync from server",
"sync-from-server-description": "Connect to an existing sync server with your credentials.",
"next": "Next",
"init-in-progress": "Document initialization in progress",
"redirecting": "You will be shortly redirected to the application.",
"title": "Setup",
"sync-from-server-page-title": "Connect to sync server",
"sync-from-server-page-description": "Enter your server details below to connect your existing workspace.",
"password-placeholder": "Password",
"button-back": "Back",
"button-finish-setup": "Finish setup"
}
}

View File

@@ -34,7 +34,6 @@ import passwordApiRoute from "./api/password.js";
import recoveryCodes from './api/recovery_codes.js';
import scriptRoute from "./api/script.js";
import senderRoute from "./api/sender.js";
import setupApiRoute from "./api/setup.js";
import systemInfoRoute from "./api/system_info.js";
import totp from './api/totp.js';
// API routes
@@ -83,11 +82,15 @@ function register(app: express.Application) {
routes.buildSharedApiRoutes({
route,
asyncRoute,
apiRoute,
asyncApiRoute,
apiResultHandler,
checkApiAuth: auth.checkApiAuth,
checkApiAuthOrElectron: auth.checkApiAuthOrElectron
checkApiAuthOrElectron: auth.checkApiAuthOrElectron,
checkAppNotInitialized: auth.checkAppNotInitialized,
checkCredentials: auth.checkCredentials,
loginRateLimiter
});
route(PUT, "/api/notes/:noteId/file", [auth.checkApiAuthOrElectron, uploadMiddlewareWithErrorHandling, csrfMiddleware], filesRoute.updateFile, apiResultHandler);
@@ -149,13 +152,6 @@ function register(app: express.Application) {
// docker health check
route(GET, "/api/health-check", [], () => ({ status: "ok" }), apiResultHandler);
// group of the services below are meant to be executed from the outside
route(GET, "/api/setup/status", [], setupApiRoute.getStatus, apiResultHandler);
asyncRoute(PST, "/api/setup/new-document", [auth.checkAppNotInitialized], setupApiRoute.setupNewDocument, apiResultHandler);
asyncRoute(PST, "/api/setup/sync-from-server", [auth.checkAppNotInitialized], setupApiRoute.setupSyncFromServer, apiResultHandler);
route(GET, "/api/setup/sync-seed", [loginRateLimiter, auth.checkCredentials], setupApiRoute.getSyncSeed, apiResultHandler);
asyncRoute(PST, "/api/setup/sync-seed", [auth.checkAppNotInitialized], setupApiRoute.saveSyncSeed, apiResultHandler);
route(PST, "/api/login/sync", [loginRateLimiter], loginApiRoute.loginSync, apiResultHandler);
// this is for entering protected mode so user has to be already logged-in (that's the reason we don't require username)
apiRoute(PST, "/api/login/protected", loginApiRoute.loginToProtectedSession);

View File

@@ -1,120 +0,0 @@
import syncService from "./sync.js";
import log from "./log.js";
import sqlInit from "./sql_init.js";
import optionService from "./options.js";
import syncOptions from "./sync_options.js";
import { request } from "@triliumnext/core";
import appInfo from "./app_info.js";
import { timeLimit } from "./utils.js";
import becca from "../becca/becca.js";
import type { SetupStatusResponse, SetupSyncSeedResponse } from "./api-interface.js";
async function hasSyncServerSchemaAndSeed() {
const response = await requestToSyncServer<SetupStatusResponse>("GET", "/api/setup/status");
if (response.syncVersion !== appInfo.syncVersion) {
throw new Error(
`Could not setup sync since local sync protocol version is ${appInfo.syncVersion} while remote is ${response.syncVersion}. To fix this issue, use same Trilium version on all instances.`
);
}
return response.schemaExists;
}
function triggerSync() {
log.info("Triggering sync.");
// it's ok to not wait for it here
syncService.sync().then((res) => {
if (res.success) {
sqlInit.setDbAsInitialized();
}
});
}
async function sendSeedToSyncServer() {
log.info("Initiating sync to server");
await requestToSyncServer<void>("POST", "/api/setup/sync-seed", {
options: getSyncSeedOptions(),
syncVersion: appInfo.syncVersion
});
// this is a completely new sync, need to reset counters. If this was not a new sync,
// the previous request would have failed.
optionService.setOption("lastSyncedPush", 0);
optionService.setOption("lastSyncedPull", 0);
}
async function requestToSyncServer<T>(method: string, path: string, body?: string | {}): Promise<T> {
const timeout = syncOptions.getSyncTimeout();
return (await timeLimit(
request.exec({
method,
url: syncOptions.getSyncServerHost() + path,
body,
proxy: syncOptions.getSyncProxy(),
timeout: timeout
}),
timeout
)) as T;
}
async function setupSyncFromSyncServer(syncServerHost: string, syncProxy: string, password: string) {
if (sqlInit.isDbInitialized()) {
return {
result: "failure",
error: "DB is already initialized."
};
}
try {
log.info("Getting document options FROM sync server.");
// the response is expected to contain documentId and documentSecret options
const resp = await request.exec<SetupSyncSeedResponse>({
method: "get",
url: `${syncServerHost}/api/setup/sync-seed`,
auth: { password },
proxy: syncProxy,
timeout: 30000 // seed request should not take long
});
if (resp.syncVersion !== appInfo.syncVersion) {
const message = `Could not setup sync since local sync protocol version is ${appInfo.syncVersion} while remote is ${resp.syncVersion}. To fix this issue, use same Trilium version on all instances.`;
log.error(message);
return {
result: "failure",
error: message
};
}
await sqlInit.createDatabaseForSync(resp.options, syncServerHost, syncProxy);
triggerSync();
return { result: "success" };
} catch (e: any) {
log.error(`Sync failed: '${e.message}', stack: ${e.stack}`);
return {
result: "failure",
error: e.message
};
}
}
function getSyncSeedOptions() {
return [becca.getOption("documentId"), becca.getOption("documentSecret")];
}
export default {
hasSyncServerSchemaAndSeed,
triggerSync,
sendSeedToSyncServer,
setupSyncFromSyncServer,
getSyncSeedOptions
};

View File

@@ -1,164 +1,27 @@
import { deferred, type OptionRow } from "@triliumnext/commons";
import { events as eventService } from "@triliumnext/core";
import { type OptionRow } from "@triliumnext/commons";
import { sql_init as coreSqlInit } from "@triliumnext/core";
import fs from "fs";
import { t } from "i18next";
import BBranch from "../becca/entities/bbranch.js";
import BNote from "../becca/entities/bnote.js";
import BOption from "../becca/entities/boption.js";
import backup from "./backup.js";
import cls from "./cls.js";
import config from "./config.js";
import password from "./encryption/password.js";
import hidden_subtree from "./hidden_subtree.js";
import zipImportService from "./import/zip.js";
import log from "./log.js";
import migrationService from "./migration.js";
import optionService from "./options.js";
import port from "./port.js";
import resourceDir from "./resource_dir.js";
import sql from "./sql.js";
import TaskContext from "./task_context.js";
import { isElectron } from "./utils.js";
export const dbReady = deferred<void>();
function schemaExists() {
return !!sql.getValue(/*sql*/`SELECT name FROM sqlite_master
WHERE type = 'table' AND name = 'options'`);
}
function isDbInitialized() {
if (!schemaExists()) {
return false;
}
const initialized = sql.getValue("SELECT value FROM options WHERE name = 'initialized'");
return initialized === "true";
}
async function initDbConnection() {
if (!isDbInitialized()) {
if (isElectron) {
log.info(t("sql_init.db_not_initialized_desktop"));
} else {
log.info(t("sql_init.db_not_initialized_server", { port }));
}
return;
}
await migrationService.migrateIfNecessary();
sql.execute('CREATE TEMP TABLE IF NOT EXISTS "param_list" (`paramId` TEXT NOT NULL PRIMARY KEY)');
sql.execute(`
CREATE TABLE IF NOT EXISTS "user_data"
(
tmpID INT,
username TEXT,
email TEXT,
userIDEncryptedDataKey TEXT,
userIDVerificationHash TEXT,
salt TEXT,
derivedKey TEXT,
isSetup TEXT DEFAULT "false",
UNIQUE (tmpID),
PRIMARY KEY (tmpID)
);`);
dbReady.resolve();
}
/**
* Applies the database schema, creating the necessary tables and importing the demo content.
*
* @param skipDemoDb if set to `true`, then the demo database will not be imported, resulting in an empty root note.
* @throws {Error} if the database is already initialized.
*/
async function createInitialDatabase(skipDemoDb?: boolean) {
if (isDbInitialized()) {
throw new Error("DB is already initialized");
}
const schema = fs.readFileSync(`${resourceDir.DB_INIT_DIR}/schema.sql`, "utf-8");
const demoFile = (!skipDemoDb ? fs.readFileSync(`${resourceDir.DB_INIT_DIR}/demo.zip`) : null);
let rootNote!: BNote;
// We have to import async since options init requires keyboard actions which require translations.
const optionsInitService = (await import("./options_init.js")).default;
const becca_loader = (await import("@triliumnext/core")).becca_loader;
sql.transactional(() => {
log.info("Creating database schema ...");
sql.executeScript(schema);
becca_loader.load();
log.info("Creating root note ...");
rootNote = new BNote({
noteId: "root",
title: "root",
type: "text",
mime: "text/html"
}).save();
rootNote.setContent("");
new BBranch({
noteId: "root",
parentNoteId: "none",
isExpanded: true,
notePosition: 10
}).save();
optionsInitService.initDocumentOptions();
optionsInitService.initNotSyncedOptions(true, {});
optionsInitService.initStartupOptions();
password.resetPassword();
});
// Check hidden subtree.
// This ensures the existence of system templates, for the demo content.
console.log("Checking hidden subtree at first start.");
cls.init(() => hidden_subtree.checkHiddenSubtree());
// Import demo content.
log.info("Importing demo content...");
const dummyTaskContext = new TaskContext("no-progress-reporting", "importNotes", null);
if (demoFile) {
await zipImportService.importZip(dummyTaskContext, demoFile, rootNote);
}
// Post-demo.
sql.transactional(() => {
// this needs to happen after ZIP import,
// the previous solution was to move option initialization here, but then the important parts of initialization
// are not all in one transaction (because ZIP import is async and thus not transactional)
const startNoteId = sql.getValue("SELECT noteId FROM branches WHERE parentNoteId = 'root' AND isDeleted = 0 ORDER BY notePosition");
optionService.setOption(
"openNoteContexts",
JSON.stringify([
{
notePath: startNoteId,
active: true
}
])
);
});
log.info("Schema and initial content generated.");
initDbConnection();
}
const schemaExists = coreSqlInit.schemaExists;
const isDbInitialized = coreSqlInit.isDbInitialized;
const dbReady = coreSqlInit.dbReady;
const setDbAsInitialized = coreSqlInit.setDbAsInitialized;
const initDbConnection = coreSqlInit.initDbConnection;
const initializeDb = coreSqlInit.initializeDb;
export const getDbSize = coreSqlInit.getDbSize;
async function createDatabaseForSync(options: OptionRow[], syncServerHost = "", syncProxy = "") {
log.info("Creating database for sync");
@@ -186,57 +49,6 @@ async function createDatabaseForSync(options: OptionRow[], syncServerHost = "",
log.info("Schema and not synced options generated.");
}
function setDbAsInitialized() {
if (!isDbInitialized()) {
optionService.setOption("initialized", "true");
initDbConnection();
// Emit an event to notify that the database is now initialized
eventService.emit(eventService.DB_INITIALIZED);
log.info("Database initialization completed, emitted DB_INITIALIZED event");
}
}
function optimize() {
if (config.General.readOnly) {
return;
}
log.info("Optimizing database");
const start = Date.now();
sql.execute("PRAGMA optimize");
log.info(`Optimization finished in ${Date.now() - start}ms.`);
}
export function getDbSize() {
return sql.getValue<number>("SELECT page_count * page_size / 1000 as size FROM pragma_page_count(), pragma_page_size()");
}
function initializeDb() {
cls.init(initDbConnection);
dbReady.then(() => {
if (config.General && config.General.noBackup === true) {
log.info("Disabling scheduled backups.");
return;
}
setInterval(() => backup.regularBackup(), 4 * 60 * 60 * 1000);
// kickoff first backup soon after start up
setTimeout(() => backup.regularBackup(), 5 * 60 * 1000);
// optimize is usually inexpensive no-op, so running it semi-frequently is not a big deal
setTimeout(() => optimize(), 60 * 60 * 1000);
setInterval(() => optimize(), 10 * 60 * 60 * 1000);
});
}
export default {
dbReady,
schemaExists,

View File

@@ -312,11 +312,25 @@ export interface DefinitionObject {
inverseRelation?: string;
}
export interface BootstrapDefinition {
device: "mobile" | "desktop" | "print" | false;
csrfToken: string;
/**
* Subset of bootstrap items that are available both in the main client and in the setup page.
*/
interface BootstrapCommonItems {
baseApiUrl: string;
assetPath: string;
themeCssUrl: string | false;
themeUseNextAsBase?: "next" | "next-light" | "next-dark";
iconPackCss: string;
iconRegistry: IconRegistry;
}
/**
* Bootstrap items that the client needs to start up. These are sent by the server in the HTML and made available as `window.glob`.
*/
export type BootstrapDefinition = BootstrapCommonItems & ({
dbInitialized: true;
device: "mobile" | "desktop" | "print" | false;
csrfToken: string;
headingStyle: "plain" | "underline" | "markdown";
layoutOrientation: "vertical" | "horizontal";
platform?: typeof process.platform | "web";
@@ -332,15 +346,13 @@ export interface BootstrapDefinition {
isMainWindow: boolean;
isProtectedSessionAvailable: boolean;
triliumVersion: string;
assetPath: string;
appPath: string;
baseApiUrl: string;
currentLocale: Locale;
isRtl: boolean;
iconPackCss: string;
iconRegistry: IconRegistry;
TRILIUM_SAFE_MODE: boolean;
}
} | {
dbInitialized: false;
});
/**
* Response for /api/setup/status.

View File

@@ -10,6 +10,7 @@ import appInfo from "./services/app_info";
export type * from "./services/sql/types";
export * from "./services/sql/index";
export { default as sql_init } from "./services/sql_init";
export * as protected_session from "./services/protected_session";
export { default as data_encryption } from "./services/encryption/data_encryption"
export * as binary_utils from "./services/utils/binary";

View File

@@ -1,8 +1,6 @@
"use strict";
import sqlInit from "../../services/sql_init.js";
import setupService from "../../services/setup.js";
import log from "../../services/log.js";
import { getLog } from "../../services/log.js";
import appInfo from "../../services/app_info.js";
import type { Request } from "express";
@@ -27,6 +25,7 @@ function setupSyncFromServer(req: Request) {
function saveSyncSeed(req: Request) {
const { options, syncVersion } = req.body;
const log = getLog();
if (appInfo.syncVersion !== syncVersion) {
const message = `Could not setup sync since local sync protocol version is ${appInfo.syncVersion} while remote is ${syncVersion}. To fix this issue, use same Trilium version on all instances.`;
@@ -42,7 +41,7 @@ function saveSyncSeed(req: Request) {
log.info("Saved sync seed.");
sqlInit.createDatabaseForSync(options);
// sqlInit.createDatabaseForSync(options);
}
/**
@@ -74,7 +73,7 @@ function saveSyncSeed(req: Request) {
* - user-password: []
*/
function getSyncSeed() {
log.info("Serving sync seed.");
getLog().info("Serving sync seed.");
return {
options: setupService.getSyncSeedOptions(),

View File

@@ -23,6 +23,7 @@ import syncApiRoute from "./api/sync";
import autocompleteApiRoute from "./api/autocomplete";
import similarNotesRoute from "./api/similar_notes";
import imageRoute from "./api/image";
import setupApiRoute from "./api/setup";
// TODO: Deduplicate with routes.ts
const GET = "get",
@@ -33,14 +34,18 @@ const GET = "get",
interface SharedApiRoutesContext {
route: any;
asyncRoute: any;
apiRoute: any;
asyncApiRoute: any;
checkApiAuth: any;
apiResultHandler: any;
checkApiAuthOrElectron: any;
checkAppNotInitialized: any;
loginRateLimiter: any;
checkCredentials: any;
}
export function buildSharedApiRoutes({ route, apiRoute, asyncApiRoute, checkApiAuth, apiResultHandler, checkApiAuthOrElectron }: SharedApiRoutesContext) {
export function buildSharedApiRoutes({ route, asyncRoute, apiRoute, asyncApiRoute, checkApiAuth, apiResultHandler, checkApiAuthOrElectron, checkAppNotInitialized, checkCredentials, loginRateLimiter }: SharedApiRoutesContext) {
apiRoute(GET, '/api/tree', treeApiRoute.getTree);
apiRoute(PST, '/api/tree/load', treeApiRoute.load);
@@ -111,6 +116,13 @@ export function buildSharedApiRoutes({ route, apiRoute, asyncApiRoute, checkApiA
route(GET, "/api/attachments/:attachmentId/image/:filename", [checkApiAuthOrElectron], imageRoute.returnAttachedImage);
route(GET, "/api/images/:noteId/:filename", [checkApiAuthOrElectron], imageRoute.returnImageFromNote);
// group of the services below are meant to be executed from the outside
route(GET, "/api/setup/status", [], setupApiRoute.getStatus, apiResultHandler);
asyncRoute(PST, "/api/setup/new-document", [checkAppNotInitialized], setupApiRoute.setupNewDocument, apiResultHandler);
asyncRoute(PST, "/api/setup/sync-from-server", [checkAppNotInitialized], setupApiRoute.setupSyncFromServer, apiResultHandler);
route(GET, "/api/setup/sync-seed", [loginRateLimiter, checkCredentials], setupApiRoute.getSyncSeed, apiResultHandler);
asyncRoute(PST, "/api/setup/sync-seed", [checkAppNotInitialized], setupApiRoute.saveSyncSeed, apiResultHandler);
asyncApiRoute(PST, "/api/sync/test", syncApiRoute.testSync);
asyncApiRoute(PST, "/api/sync/now", syncApiRoute.syncNow);
apiRoute(PST, "/api/sync/fill-entity-changes", syncApiRoute.fillEntityChanges);

View File

@@ -7,7 +7,6 @@ import { getCurrentLocale } from "./i18n";
export default function getSharedBootstrapItems(assetPath: string): Pick<BootstrapDefinition, "assetPath" | "headingStyle" | "layoutOrientation" | "maxEntityChangeIdAtLoad" | "maxEntityChangeSyncIdAtLoad" | "isProtectedSessionAvailable" | "iconRegistry" | "iconPackCss" | "currentLocale" | "isRtl"> {
const sql = getSql();
const iconPacks = getIconPacks();
const currentLocale = getCurrentLocale();
return {
@@ -19,6 +18,14 @@ export default function getSharedBootstrapItems(assetPath: string): Pick<Bootstr
isProtectedSessionAvailable: protected_session.isProtectedSessionAvailable(),
currentLocale,
isRtl: !!currentLocale.rtl,
...getIconConfig(assetPath)
}
}
export function getIconConfig(assetPath: string): Pick<BootstrapDefinition, "iconRegistry" | "iconPackCss"> {
const iconPacks = getIconPacks();
return {
iconRegistry: generateIconRegistry(iconPacks),
iconPackCss: iconPacks
.map(p => generateCss(p, p.builtin
@@ -26,5 +33,5 @@ export default function getSharedBootstrapItems(assetPath: string): Pick<Bootstr
: `api/attachments/download/${p.fontAttachmentId}`))
.filter(Boolean)
.join("\n\n"),
}
};
}

View File

@@ -1,14 +1,73 @@
import { deferred } from "@triliumnext/commons";
import { getSql } from "./sql";
import { getLog } from "./log";
import { isElectron } from "./utils";
import { t } from "i18next";
import optionService from "./options";
import eventService from "./events";
import { getContext } from "./context";
import config from "./config";
import BNote from "../becca/entities/bnote";
import BBranch from "../becca/entities/bbranch";
import schema from "../assets/schema.sql?raw";
import hidden_subtree from "./hidden_subtree";
import TaskContext from "./task_context";
export const dbReady = deferred<void>();
// TODO: Proper impl.
setTimeout(() => {
dbReady.resolve();
}, 850);
function schemaExists() {
return !!getSql().getValue(/*sql*/`SELECT name FROM sqlite_master
WHERE type = 'table' AND name = 'options'`);
}
function isDbInitialized() {
return true;
try {
if (!schemaExists()) {
return false;
}
const initialized = getSql().getValue("SELECT value FROM options WHERE name = 'initialized'");
return initialized === "true";
} catch (e) {
return false;
}
}
async function initDbConnection() {
if (!isDbInitialized()) {
const log = getLog();
if (isElectron) {
log.info(t("sql_init.db_not_initialized_desktop"));
} else {
// TODO: Bring back port.
log.info(t("sql_init.db_not_initialized_server", { port: 1234 }));
}
return;
}
//TODO: Renable migration
//await migrationService.migrateIfNecessary();
const sql = getSql();
sql.execute('CREATE TEMP TABLE IF NOT EXISTS "param_list" (`paramId` TEXT NOT NULL PRIMARY KEY)');
sql.execute(`
CREATE TABLE IF NOT EXISTS "user_data"
(
tmpID INT,
username TEXT,
email TEXT,
userIDEncryptedDataKey TEXT,
userIDVerificationHash TEXT,
salt TEXT,
derivedKey TEXT,
isSetup TEXT DEFAULT "false",
UNIQUE (tmpID),
PRIMARY KEY (tmpID)
);`);
dbReady.resolve();
}
async function createDatabaseForSync(a: any, b: string, c: any) {
@@ -16,15 +75,146 @@ async function createDatabaseForSync(a: any, b: string, c: any) {
}
function setDbAsInitialized() {
// Noop.
}
if (!isDbInitialized()) {
optionService.setOption("initialized", "true");
function schemaExists() {
return true;
initDbConnection();
// Emit an event to notify that the database is now initialized
eventService.emit(eventService.DB_INITIALIZED);
getLog().info("Database initialization completed, emitted DB_INITIALIZED event");
}
}
function getDbSize() {
return 1000;
return getSql().getValue<number>("SELECT page_count * page_size / 1000 as size FROM pragma_page_count(), pragma_page_size()");
}
export default { isDbInitialized, createDatabaseForSync, setDbAsInitialized, schemaExists, getDbSize, dbReady };
function optimize() {
if (config.General.readOnly) {
return;
}
const log = getLog();
log.info("Optimizing database");
const start = Date.now();
getSql().execute("PRAGMA optimize");
log.info(`Optimization finished in ${Date.now() - start}ms.`);
}
function initializeDb() {
getContext().init(initDbConnection);
dbReady.then(() => {
// TODO: Re-enable backup.
// if (config.General && config.General.noBackup === true) {
// log.info("Disabling scheduled backups.");
// return;
// }
// setInterval(() => backup.regularBackup(), 4 * 60 * 60 * 1000);
// // kickoff first backup soon after start up
// setTimeout(() => backup.regularBackup(), 5 * 60 * 1000);
// // optimize is usually inexpensive no-op, so running it semi-frequently is not a big deal
// setTimeout(() => optimize(), 60 * 60 * 1000);
// setInterval(() => optimize(), 10 * 60 * 60 * 1000);
});
}
/**
* Applies the database schema, creating the necessary tables and importing the demo content.
*
* @param skipDemoDb if set to `true`, then the demo database will not be imported, resulting in an empty root note.
* @throws {Error} if the database is already initialized.
*/
async function createInitialDatabase(skipDemoDb?: boolean) {
if (isDbInitialized()) {
throw new Error("DB is already initialized");
}
let rootNote!: BNote;
// We have to import async since options init requires keyboard actions which require translations.
const optionsInitService = (await import("./options_init.js")).default;
const becca_loader = (await import("../becca/becca_loader.js")).default;
const sql = getSql();
const log = getLog();
sql.transactional(() => {
log.info("Creating database schema ...");
sql.executeScript(schema);
becca_loader.load();
log.info("Creating root note ...");
rootNote = new BNote({
noteId: "root",
title: "root",
type: "text",
mime: "text/html"
}).save();
rootNote.setContent("");
new BBranch({
noteId: "root",
parentNoteId: "none",
isExpanded: true,
notePosition: 10
}).save();
// Bring in option init.
optionsInitService.initDocumentOptions();
optionsInitService.initNotSyncedOptions(true, {});
optionsInitService.initStartupOptions();
// password.resetPassword();
});
// Check hidden subtree.
// This ensures the existence of system templates, for the demo content.
console.log("Checking hidden subtree at first start.");
getContext().init(() => {
getSql().transactional(() => hidden_subtree.checkHiddenSubtree());
});
// Import demo content.
log.info("Importing demo content...");
const dummyTaskContext = new TaskContext("no-progress-reporting", "importNotes", null);
// if (demoFile) {
// await zipImportService.importZip(dummyTaskContext, demoFile, rootNote);
// }
// Post-demo.
sql.transactional(() => {
// this needs to happen after ZIP import,
// the previous solution was to move option initialization here, but then the important parts of initialization
// are not all in one transaction (because ZIP import is async and thus not transactional)
const startNoteId = sql.getValue("SELECT noteId FROM branches WHERE parentNoteId = 'root' AND isDeleted = 0 ORDER BY notePosition");
optionService.setOption(
"openNoteContexts",
JSON.stringify([
{
notePath: startNoteId,
active: true
}
])
);
});
log.info("Schema and initial content generated.");
initDbConnection();
}
export default { isDbInitialized, createDatabaseForSync, setDbAsInitialized, schemaExists, getDbSize, initDbConnection, dbReady, initializeDb, createInitialDatabase };