core: add support for monographs sync

This commit is contained in:
01zulfi
2025-08-18 13:16:12 +05:00
committed by GitHub
parent 30553ed0f1
commit c759408fdc
10 changed files with 268 additions and 44 deletions

View File

@@ -54,6 +54,29 @@ function PublishView(props: PublishViewProps) {
const publishNote = useStore((store) => store.publish);
const unpublishNote = useStore((store) => store.unpublish);
useEffect(() => {
if (!publishId) return;
(async () => {
const monographId = db.monographs.monograph(note.id);
if (monographId) {
const monograph = await db.monographs.get(monographId);
if (!monograph) return;
setPublishId(monographId);
setIsPasswordProtected(!!monograph.password);
setSelfDestruct(!!monograph.selfDestruct);
if (monograph.password) {
const password = await db.monographs.decryptPassword(
monograph.password
);
if (passwordInput.current) {
passwordInput.current.value = password;
}
}
}
})();
}, [publishId, isPublishing]);
useEffect(() => {
const fileDownloadedEvent = EV.subscribe(
EVENTS.fileDownloaded,
@@ -110,7 +133,13 @@ function PublishView(props: PublishViewProps) {
) : (
<>
{publishId ? (
<Flex mt={1} sx={{ flexDirection: "column", overflow: "hidden" }}>
<Flex
mt={1}
sx={{
flexDirection: "column",
overflow: "hidden"
}}
>
<Text
variant="body"
sx={{ fontWeight: "bold", color: "paragraph" }}
@@ -154,7 +183,12 @@ function PublishView(props: PublishViewProps) {
</Flex>
</Flex>
) : (
<Text variant="body" sx={{ color: "paragraph" }}>
<Text
variant="body"
sx={{
color: "paragraph"
}}
>
{strings.monographDesc()}
</Text>
)}

View File

@@ -36,6 +36,7 @@ import Migrations from "./migrations.js";
import UserManager from "./user-manager.js";
import http from "../utils/http.js";
import { Monographs } from "./monographs.js";
import { Monographs as MonographsCollection } from "../collections/monographs.js";
import { Offers } from "./offers.js";
import { Attachments } from "../collections/attachments.js";
import { Debug } from "./debug.js";
@@ -203,6 +204,7 @@ class Database {
trash = new Trash(this);
sanitizer = new Sanitizer(this.sql);
monographsCollection = new MonographsCollection(this);
notebooks = new Notebooks(this);
tags = new Tags(this);
colors = new Colors(this);
@@ -329,6 +331,7 @@ class Database {
await this.relations.init();
await this.notes.init();
await this.vaults.init();
await this.monographsCollection.init();
await this.trash.init();
@@ -407,6 +410,9 @@ class Database {
EV.publish(EVENTS.userEmailConfirmed);
break;
}
case "triggerSync": {
await this.sync({ type: "fetch" });
}
}
} catch (e) {
console.log("SSE: Unsupported message. Message = ", event.data);

View File

@@ -20,24 +20,23 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import http from "../utils/http.js";
import Constants from "../utils/constants.js";
import Database from "./index.js";
import { Note, isDeleted } from "../types.js";
import { Monograph, Note, isDeleted } from "../types.js";
import { Cipher } from "@notesnook/crypto";
import { isFalse } from "../database/index.js";
import { logger } from "../logger.js";
type BaseMonograph = {
id: string;
title: string;
userId: string;
selfDestruct: boolean;
};
type UnencryptedMonograph = BaseMonograph & {
type MonographApiRequestBase = Omit<
Monograph,
"type" | "dateModified" | "dateCreated" | "datePublished"
>;
type UnencryptedMonograph = MonographApiRequestBase & {
content: string;
};
type EncryptedMonograph = BaseMonograph & {
type EncryptedMonograph = MonographApiRequestBase & {
encryptedContent: Cipher<"base64">;
};
type Monograph = UnencryptedMonograph | EncryptedMonograph;
type MonographApiRequest = (UnencryptedMonograph | EncryptedMonograph) & {
userId: string;
};
export type PublishOptions = { password?: string; selfDestruct?: boolean };
export class Monographs {
@@ -46,24 +45,12 @@ export class Monographs {
async clear() {
this.monographs = [];
await this.db.kv().write("monographs", this.monographs);
await this.db.monographsCollection.collection.clear();
}
async refresh() {
try {
const user = await this.db.user.getUser();
const token = await this.db.tokenManager.getAccessToken();
if (!user || !token || !user.isEmailConfirmed) return;
const monographs = await http.get(
`${Constants.API_HOST}/monographs`,
token
);
await this.db.kv().write("monographs", monographs);
if (monographs) this.monographs = monographs;
} catch (e) {
logger.error(e, "Error while refreshing monographs.");
}
const ids = await this.db.monographsCollection.all.ids();
this.monographs = ids;
}
/**
@@ -109,13 +96,19 @@ export class Monographs {
false
);
const monograph: Monograph = {
const monographPasswordsKey = await this.db.user.getMonographPasswordsKey();
const monograph: MonographApiRequest = {
id: noteId,
title: note.title,
userId: user.id,
selfDestruct: opts.selfDestruct || false,
...(opts.password
? {
password: monographPasswordsKey
? await this.db
.storage()
.encrypt(monographPasswordsKey, opts.password)
: undefined,
encryptedContent: await this.db
.storage()
.encrypt(
@@ -124,6 +117,7 @@ export class Monographs {
)
}
: {
password: undefined,
content: JSON.stringify({
type: content.type,
data: content.data
@@ -132,14 +126,21 @@ export class Monographs {
};
const method = update ? http.patch.json : http.post.json;
const { id } = await method(
`${Constants.API_HOST}/monographs`,
const deviceId = await this.db.kv().read("deviceId");
const { id, datePublished } = await method(
`${Constants.API_HOST}/monographs?deviceId=${deviceId}`,
monograph,
token
);
this.monographs.push(id);
await this.db.monographsCollection.add({
id,
title: monograph.title,
selfDestruct: monograph.selfDestruct,
datePublished: datePublished,
password: monograph.password
});
return id;
}
@@ -156,9 +157,14 @@ export class Monographs {
if (!this.isPublished(noteId))
throw new Error("This note is not published.");
await http.delete(`${Constants.API_HOST}/monographs/${noteId}`, token);
const deviceId = await this.db.kv().read("deviceId");
await http.delete(
`${Constants.API_HOST}/monographs/${noteId}?deviceId=${deviceId}`,
token
);
this.monographs.splice(this.monographs.indexOf(noteId), 1);
await this.db.monographsCollection.collection.softDelete([noteId]);
}
get all() {
@@ -173,6 +179,12 @@ export class Monographs {
}
get(monographId: string) {
return http.get(`${Constants.API_HOST}/monographs/${monographId}`);
return this.db.monographsCollection.collection.get(monographId);
}
async decryptPassword(password: Cipher<"base64">) {
const monographPasswordsKey = await this.db.user.getMonographPasswordsKey();
if (!monographPasswordsKey) return "";
return this.db.storage().decrypt(monographPasswordsKey, password);
}
}

View File

@@ -42,6 +42,7 @@ import {
isTrashItem,
Item,
MaybeDeletedItem,
Monograph,
Note,
Notebook
} from "../../types.js";
@@ -53,6 +54,7 @@ import {
import { DownloadableFile } from "../../database/fs.js";
import { SyncDevices } from "./devices.js";
import { DefaultColors } from "../../collections/colors.js";
import { Monographs } from "../monographs.js";
enum LogLevel {
/** Log level for very low severity diagnostic messages. */
@@ -463,6 +465,19 @@ class Sync {
return true;
});
this.connection.on("SendMonographs", async (monographs) => {
if (this.connection?.state !== HubConnectionState.Connected) return false;
this.db.monographsCollection.collection.put(
monographs.map((m: Monograph) => ({
...m,
type: "monograph"
}))
);
return true;
});
}
private async getKey() {

View File

@@ -43,6 +43,7 @@ const ENDPOINTS = {
class UserManager {
private tokenManager: TokenManager;
private cachedAttachmentKey?: SerializedKey;
private cachedMonographPasswordsKey?: SerializedKey;
constructor(private readonly db: Database) {
this.tokenManager = new TokenManager(this.db.kv);
@@ -459,6 +460,51 @@ class UserManager {
}
}
async getMonographPasswordsKey() {
if (this.cachedMonographPasswordsKey) {
return this.cachedMonographPasswordsKey;
}
try {
let user = await this.getUser();
if (!user) return;
if (!user.monographPasswordsKey) {
const token = await this.tokenManager.getAccessToken();
user = await http.get(`${constants.API_HOST}${ENDPOINTS.user}`, token);
}
if (!user) return;
const userEncryptionKey = await this.getEncryptionKey();
if (!userEncryptionKey) return;
if (!user.monographPasswordsKey) {
const key = await this.db.crypto().generateRandomKey();
user.monographPasswordsKey = await this.db
.storage()
.encrypt(userEncryptionKey, JSON.stringify(key));
await this.updateUser({
monographPasswordsKey: user.monographPasswordsKey
});
return key;
}
const plainData = await this.db
.storage()
.decrypt(userEncryptionKey, user.monographPasswordsKey);
if (!plainData) return;
this.cachedMonographPasswordsKey = JSON.parse(plainData) as SerializedKey;
return this.cachedMonographPasswordsKey;
} catch (e) {
logger.error(e, "Could not get monographs encryption key.");
if (e instanceof Error)
throw new Error(
`Could not get monographs encryption key. Error: ${e.message}`
);
}
}
async sendVerificationEmail(newEmail?: string) {
const token = await this.tokenManager.getAccessToken();
if (!token) return;
@@ -533,7 +579,6 @@ class UserManager {
if (!new_password) throw new Error("New password is required.");
const attachmentsKey = await this.getAttachmentsKey();
data.encryptionKey = data.encryptionKey || (await this.getEncryptionKey());
await this.clearSessions();
@@ -554,13 +599,26 @@ class UserManager {
await this.db.sync({ type: "send", force: true });
if (attachmentsKey) {
const userEncryptionKey = await this.getEncryptionKey();
if (!userEncryptionKey) return;
if (userEncryptionKey) {
const updateUserPayload: Partial<User> = {};
const attachmentsKey = await this.getAttachmentsKey();
if (attachmentsKey) {
user.attachmentsKey = await this.db
.storage()
.encrypt(userEncryptionKey, JSON.stringify(attachmentsKey));
await this.updateUser({ attachmentsKey: user.attachmentsKey });
updateUserPayload.attachmentsKey = user.attachmentsKey;
}
const monographPasswordsKey = await this.getMonographPasswordsKey();
if (monographPasswordsKey) {
user.monographPasswordsKey = await this.db
.storage()
.encrypt(userEncryptionKey, JSON.stringify(monographPasswordsKey));
updateUserPayload.monographPasswordsKey = user.monographPasswordsKey;
}
if (Object.keys(updateUserPayload).length > 0) {
await this.updateUser(updateUserPayload);
}
}
if (new_password)

View File

@@ -0,0 +1,69 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import Database from "../api/index.js";
import { ItemReference, Monograph, Vault } from "../types.js";
import { ICollection } from "./collection.js";
import { SQLCollection } from "../database/sql-collection.js";
import { getId } from "../utils/id.js";
import { isFalse } from "../database/index.js";
import { sql } from "@streetwriters/kysely";
export class Monographs implements ICollection {
name = "monographs";
readonly collection: SQLCollection<"monographs", Monograph>;
constructor(private readonly db: Database) {
this.collection = new SQLCollection(
db.sql,
db.transaction,
"monographs",
db.eventManager,
db.sanitizer
);
}
async init() {
await this.collection.init();
}
get all() {
return this.collection.createFilter<Monograph>(
(qb) => qb.where(isFalse("deleted")),
this.db.options?.batchSize
);
}
async add(monograph: Partial<Monograph>) {
const id = monograph.id || getId();
const oldMonograph = await this.collection.get(id);
const merged: Partial<Monograph> = {
...oldMonograph,
...monograph
};
this.collection.upsert({
id,
title: merged.title,
datePublished: merged.datePublished,
selfDestruct: merged.selfDestruct,
password: merged.password,
type: "monograph"
});
}
}

View File

@@ -46,6 +46,7 @@ import {
ItemReferences,
ItemType,
MaybeDeletedItem,
Monograph,
Note,
Notebook,
Relation,
@@ -90,6 +91,7 @@ export interface DatabaseSchema {
sessioncontent: SQLiteItem<SessionContentItem>;
shortcuts: SQLiteItem<Shortcut>;
vaults: SQLiteItem<Vault>;
monographs: SQLiteItem<Monograph>;
}
export type RawDatabaseSchema = DatabaseSchema & {
@@ -233,7 +235,8 @@ const BooleanProperties: Set<BooleanFields> = new Set([
"remote",
"synced",
"isGeneratedTitle",
"archived"
"archived",
"selfDestruct"
]);
const DataMappers: Partial<Record<ItemType, (row: any) => void>> = {
@@ -266,6 +269,9 @@ const DataMappers: Partial<Record<ItemType, (row: any) => void>> = {
},
vault: (row) => {
if (row.key) row.key = JSON.parse(row.key);
},
monograph: (row) => {
if (row.password) row.password = JSON.parse(row.password);
}
};

View File

@@ -392,6 +392,18 @@ export class NNMigrationProvider implements MigrationProvider {
async up(db) {
await runFTSTablesMigrations(db);
}
},
"a-2025-07-30": {
async up(db) {
await db.schema
.createTable("monographs")
.$call(addBaseColumns)
.addColumn("datePublished", "integer")
.addColumn("title", "text", COLLATE_NOCASE)
.addColumn("selfDestruct", "boolean")
.addColumn("password", "text")
.execute();
}
}
};
}

View File

@@ -701,7 +701,8 @@ const VALID_SORT_OPTIONS: Record<
sessioncontent: [],
settings: [],
shortcuts: [],
vaults: []
vaults: [],
monographs: []
};
function sanitizeSortOptions(type: keyof DatabaseSchema, options: SortOptions) {

View File

@@ -80,6 +80,7 @@ export type Collections = {
sessioncontent: "sessioncontent";
settingsv2: "settingitem";
vaults: "vault";
monographs: "monograph";
/**
* @deprecated only kept here for migration purposes
@@ -110,6 +111,7 @@ export type GroupableItem = ValueOf<
| "settings"
| "settingitem"
| "vault"
| "monograph"
>
>;
@@ -131,6 +133,7 @@ export type ItemMap = {
settingitem: SettingItem;
vault: Vault;
searchResult: HighlightedResult;
monograph: Monograph;
/**
* @deprecated only kept here for migration purposes
@@ -490,6 +493,13 @@ export interface Vault extends BaseItem<"vault"> {
key: Cipher<"base64">;
}
export interface Monograph extends BaseItem<"monograph"> {
title: string;
datePublished: number;
selfDestruct: boolean;
password?: Cipher<"base64">;
}
export type Match = {
prefix: string;
match: string;
@@ -546,6 +556,7 @@ export type User = {
isEmailConfirmed: boolean;
salt: string;
attachmentsKey?: Cipher<"base64">;
monographPasswordsKey?: Cipher<"base64">;
marketingConsent?: boolean;
mfa: {
isEnabled: boolean;