528 lines
19 KiB
TypeScript
528 lines
19 KiB
TypeScript
import * as loader from "tc-loader";
|
|
import { Stage } from "tc-loader";
|
|
import { ignorePromise, WritableKeys } from "tc-shared/proto";
|
|
import { LogCategory, logDebug, logError, logInfo, logTrace, logWarn } from "tc-shared/log";
|
|
import { guid } from "tc-shared/crypto/uid";
|
|
import { Registry } from "tc-events";
|
|
import { server_connections } from "tc-shared/ConnectionManager";
|
|
import { defaultConnectProfile, findConnectProfile } from "tc-shared/profiles/ConnectionProfile";
|
|
import { ConnectionState } from "tc-shared/ConnectionHandler";
|
|
import * as _ from "lodash";
|
|
import { getStorageAdapter } from "tc-shared/StorageAdapter";
|
|
|
|
type BookmarkBase = {
|
|
readonly uniqueId: string,
|
|
displayName: string,
|
|
|
|
previousEntry: string | undefined,
|
|
parentEntry: string | undefined,
|
|
};
|
|
|
|
export type BookmarkInfo = BookmarkBase & {
|
|
readonly type: "entry",
|
|
|
|
connectOnStartup: boolean,
|
|
connectProfile: string,
|
|
|
|
serverAddress: string,
|
|
serverPasswordHash: string | undefined,
|
|
|
|
defaultChannel: string | undefined,
|
|
defaultChannelPasswordHash: string | undefined,
|
|
}
|
|
|
|
export type BookmarkDirectory = BookmarkBase & {
|
|
readonly type: "directory",
|
|
}
|
|
|
|
export type BookmarkEntry = BookmarkInfo | BookmarkDirectory;
|
|
|
|
export interface BookmarkEvents {
|
|
notify_bookmark_created: { bookmark: BookmarkEntry },
|
|
notify_bookmark_edited: { bookmark: BookmarkEntry, keys: (keyof BookmarkInfo | keyof BookmarkDirectory)[] },
|
|
notify_bookmark_deleted: { bookmark: BookmarkEntry, children: BookmarkEntry[] },
|
|
notify_bookmarks_imported: { bookmarks: BookmarkEntry[] },
|
|
}
|
|
|
|
export type OrderedBookmarkEntry = {
|
|
entry: BookmarkEntry,
|
|
depth: number,
|
|
childCount: number,
|
|
};
|
|
|
|
const kStorageKey = "bookmarks_v2";
|
|
export class BookmarkManager {
|
|
readonly events: Registry<BookmarkEvents>;
|
|
private readonly registeredBookmarks: BookmarkEntry[];
|
|
private defaultBookmarkCreated: boolean;
|
|
|
|
constructor() {
|
|
this.events = new Registry<BookmarkEvents>();
|
|
this.registeredBookmarks = [];
|
|
this.defaultBookmarkCreated = false;
|
|
}
|
|
|
|
async loadBookmarks() {
|
|
const bookmarksJson = await getStorageAdapter().get(kStorageKey);
|
|
if (typeof bookmarksJson !== "string") {
|
|
const oldBookmarksJson = await getStorageAdapter().get("bookmarks");
|
|
if (typeof oldBookmarksJson === "string") {
|
|
logDebug(LogCategory.BOOKMARKS, tr("Found no new bookmarks but found old bookmarks. Trying to import."));
|
|
try {
|
|
this.importOldBookmarks(oldBookmarksJson);
|
|
logInfo(LogCategory.BOOKMARKS, tr("Successfully imported %d old bookmarks."), this.registeredBookmarks.length);
|
|
await this.saveBookmarks();
|
|
} catch (error) {
|
|
const saveKey = "bookmarks_v1_save_" + Date.now();
|
|
logError(LogCategory.BOOKMARKS, tr("Failed to import old bookmark data. Saving it as %s"), saveKey);
|
|
await getStorageAdapter().set(saveKey, oldBookmarksJson);
|
|
} finally {
|
|
await getStorageAdapter().delete("bookmarks");
|
|
}
|
|
}
|
|
} else {
|
|
try {
|
|
const storageData = JSON.parse(bookmarksJson);
|
|
if (storageData.version !== 2) {
|
|
throw tr("bookmark storage has an invalid version");
|
|
}
|
|
|
|
this.defaultBookmarkCreated = storageData.defaultBookmarkCreated;
|
|
this.registeredBookmarks.slice(0, this.registeredBookmarks.length);
|
|
this.registeredBookmarks.push(...storageData.bookmarks);
|
|
logTrace(LogCategory.BOOKMARKS, tr("Loaded %d bookmarks."), this.registeredBookmarks.length);
|
|
} catch (error) {
|
|
const saveKey = "bookmarks_v2_save_" + Date.now();
|
|
logError(LogCategory.BOOKMARKS, tr("Failed to parse bookmarks. Saving them at %s and using a clean setup."), saveKey);
|
|
await getStorageAdapter().set(saveKey, bookmarksJson);
|
|
await getStorageAdapter().delete(kStorageKey);
|
|
}
|
|
}
|
|
|
|
if (!this.defaultBookmarkCreated && this.registeredBookmarks.length === 0) {
|
|
this.defaultBookmarkCreated = true;
|
|
|
|
logDebug(LogCategory.BOOKMARKS, tr("No bookmarks found. Registering default bookmark."));
|
|
this.createBookmark({
|
|
connectOnStartup: false,
|
|
connectProfile: "default",
|
|
|
|
displayName: "Our LanPart<",
|
|
|
|
parentEntry: undefined,
|
|
previousEntry: undefined,
|
|
|
|
serverAddress: "tea.lp.kle.li",
|
|
serverPasswordHash: undefined,
|
|
|
|
defaultChannel: undefined,
|
|
defaultChannelPasswordHash: undefined,
|
|
});
|
|
}
|
|
}
|
|
|
|
private importOldBookmarks(jsonData: string) {
|
|
const data = JSON.parse(jsonData);
|
|
if (typeof data?.root_bookmark !== "object") {
|
|
throw tr("missing root bookmark");
|
|
}
|
|
|
|
if (!Array.isArray(data?.root_bookmark?.content)) {
|
|
throw tr("Missing root bookmarks content");
|
|
}
|
|
|
|
const registerBookmarks = (parentEntry: string, previousEntry: string, entry: any): string | undefined => {
|
|
if (typeof entry.display_name !== "string") {
|
|
logWarn(LogCategory.BOOKMARKS, tr("Missing display_name in old bookmark entry. Skipping entry."));
|
|
return undefined;
|
|
}
|
|
|
|
if ("content" in entry) {
|
|
/* it was a directory */
|
|
const directory = this.createDirectory({
|
|
previousEntry,
|
|
parentEntry,
|
|
|
|
displayName: entry.display_name,
|
|
});
|
|
|
|
previousEntry = undefined;
|
|
entry.content.forEach(entry => {
|
|
previousEntry = registerBookmarks(directory.uniqueId, previousEntry, entry) || previousEntry;
|
|
});
|
|
} else {
|
|
/* it was a normal entry */
|
|
if (typeof entry.connect_profile !== "string") {
|
|
logWarn(LogCategory.BOOKMARKS, tr("Missing connect_profile in old bookmark entry. Skipping entry."));
|
|
return undefined;
|
|
}
|
|
|
|
if (typeof entry.server_properties?.server_address !== "string") {
|
|
logWarn(LogCategory.BOOKMARKS, tr("Missing server_address in old bookmark entry. Skipping entry."));
|
|
return undefined;
|
|
}
|
|
|
|
if (typeof entry.server_properties?.server_port !== "number") {
|
|
logWarn(LogCategory.BOOKMARKS, tr("Missing server_port in old bookmark entry. Skipping entry."));
|
|
return undefined;
|
|
}
|
|
|
|
let serverAddress;
|
|
if (entry.server_properties.server_address.indexOf(":") !== -1) {
|
|
serverAddress = `[${entry.server_properties.server_address}]`;
|
|
} else {
|
|
serverAddress = entry.server_properties.server_address;
|
|
}
|
|
serverAddress += ":" + entry.server_properties.server_port;
|
|
|
|
return this.createBookmark({
|
|
previousEntry,
|
|
parentEntry,
|
|
|
|
serverAddress,
|
|
serverPasswordHash: entry.server_properties?.server_password_hash,
|
|
|
|
defaultChannel: undefined,
|
|
defaultChannelPasswordHash: undefined,
|
|
|
|
displayName: entry.display_name,
|
|
connectProfile: entry.connect_profile,
|
|
|
|
connectOnStartup: false
|
|
}).uniqueId;
|
|
}
|
|
}
|
|
|
|
let previousEntry = undefined;
|
|
data.root_bookmark.content.forEach(entry => {
|
|
previousEntry = registerBookmarks(undefined, previousEntry, entry) || previousEntry;
|
|
});
|
|
|
|
this.defaultBookmarkCreated = true;
|
|
}
|
|
|
|
async saveBookmarks() {
|
|
await getStorageAdapter().set(kStorageKey, JSON.stringify({
|
|
version: 2,
|
|
bookmarks: this.registeredBookmarks,
|
|
defaultBookmarkCreated: this.defaultBookmarkCreated,
|
|
}));
|
|
}
|
|
|
|
getRegisteredBookmarks(): BookmarkEntry[] {
|
|
return this.registeredBookmarks;
|
|
}
|
|
|
|
getOrderedRegisteredBookmarks(): OrderedBookmarkEntry[] {
|
|
const unorderedBookmarks = this.registeredBookmarks.slice(0);
|
|
const orderedBookmarks: OrderedBookmarkEntry[] = [];
|
|
|
|
const orderTreeLayer = (entries: BookmarkEntry[]): BookmarkEntry[] => {
|
|
if (entries.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
const result = [];
|
|
while (entries.length > 0) {
|
|
let head = entries.find(entry => !entry.previousEntry) || entries[0];
|
|
while (head) {
|
|
result.push(head);
|
|
entries.remove(head);
|
|
head = entries.find(entry => entry.previousEntry === head.uniqueId);
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
const traverseTree = (parentEntry: string | undefined, depth: number): number => {
|
|
const children = unorderedBookmarks.filter(e => e.parentEntry === parentEntry);
|
|
children.forEach(child => unorderedBookmarks.remove(child));
|
|
|
|
const childCount = children.length;
|
|
for (const entry of orderTreeLayer(children)) {
|
|
let orderedEntry: OrderedBookmarkEntry = {
|
|
entry: entry,
|
|
depth: depth,
|
|
childCount: 0
|
|
};
|
|
|
|
orderedBookmarks.push(orderedEntry);
|
|
orderedEntry.childCount = traverseTree(entry.uniqueId, depth + 1);
|
|
}
|
|
|
|
return childCount;
|
|
};
|
|
|
|
traverseTree(undefined, 0);
|
|
|
|
/* Append all broken/unreachable elements */
|
|
while (unorderedBookmarks.length > 0) {
|
|
traverseTree(unorderedBookmarks[0].parentEntry, 0);
|
|
}
|
|
|
|
return orderedBookmarks;
|
|
}
|
|
|
|
findBookmark(uniqueId: string): BookmarkEntry | undefined {
|
|
return this.registeredBookmarks.find(entry => entry.uniqueId === uniqueId);
|
|
}
|
|
|
|
createBookmark(properties: Pick<BookmarkInfo, WritableKeys<BookmarkInfo>>): BookmarkInfo {
|
|
this.validateHangInPoint(properties);
|
|
const bookmark = Object.assign(properties, {
|
|
uniqueId: guid(),
|
|
type: "entry"
|
|
} as BookmarkInfo);
|
|
this.registeredBookmarks.push(bookmark);
|
|
this.events.fire("notify_bookmark_created", { bookmark });
|
|
ignorePromise(this.saveBookmarks());
|
|
return bookmark;
|
|
}
|
|
|
|
editBookmark(uniqueId: string, newValues: Partial<Pick<BookmarkInfo, WritableKeys<BookmarkInfo>>>) {
|
|
this.doEditBookmark(uniqueId, newValues);
|
|
}
|
|
|
|
createDirectory(properties: Pick<BookmarkInfo, WritableKeys<BookmarkDirectory>>): BookmarkDirectory {
|
|
this.validateHangInPoint(properties);
|
|
const bookmark = Object.assign(properties, {
|
|
uniqueId: guid(),
|
|
type: "directory"
|
|
} as BookmarkDirectory);
|
|
this.registeredBookmarks.push(bookmark);
|
|
this.events.fire("notify_bookmark_created", { bookmark });
|
|
ignorePromise(this.saveBookmarks());
|
|
return bookmark;
|
|
}
|
|
|
|
editDirectory(uniqueId: string, newValues: Partial<Pick<BookmarkDirectory, WritableKeys<BookmarkDirectory>>>) {
|
|
this.doEditBookmark(uniqueId, newValues);
|
|
}
|
|
|
|
directoryContents(uniqueId: string): BookmarkEntry[] {
|
|
return this.registeredBookmarks.filter(bookmark => bookmark.parentEntry === uniqueId);
|
|
}
|
|
|
|
deleteEntry(uniqueId: string) {
|
|
const index = this.registeredBookmarks.findIndex(entry => entry.uniqueId === uniqueId);
|
|
if (index === -1) {
|
|
return;
|
|
}
|
|
|
|
const [entry] = this.registeredBookmarks.splice(index, 1);
|
|
const children = [], pendingChildren = [entry];
|
|
|
|
while (pendingChildren[0]) {
|
|
const child = pendingChildren.pop_front();
|
|
children.push(child);
|
|
|
|
const childChildren = this.registeredBookmarks.filter(entry => entry.parentEntry === child.uniqueId);
|
|
pendingChildren.push(...childChildren);
|
|
childChildren.forEach(entry => this.registeredBookmarks.remove(entry));
|
|
}
|
|
|
|
children.pop_front();
|
|
this.events.fire("notify_bookmark_deleted", { bookmark: entry, children });
|
|
ignorePromise(this.saveBookmarks());
|
|
}
|
|
|
|
executeConnect(uniqueId: string, newTab: boolean) {
|
|
const bookmark = this.findBookmark(uniqueId);
|
|
if (!bookmark || bookmark.type !== "entry") {
|
|
return;
|
|
}
|
|
|
|
const connection = newTab ? server_connections.spawnConnectionHandler() : server_connections.getActiveConnectionHandler();
|
|
if (!connection) {
|
|
return;
|
|
}
|
|
|
|
let profile = findConnectProfile(bookmark.connectProfile) || defaultConnectProfile();
|
|
connection.startConnectionNew({
|
|
profile: profile,
|
|
|
|
targetAddress: bookmark.serverAddress,
|
|
|
|
serverPasswordHashed: true,
|
|
serverPassword: bookmark.serverPasswordHash,
|
|
|
|
defaultChannel: bookmark.defaultChannel,
|
|
defaultChannelPassword: bookmark.defaultChannelPasswordHash,
|
|
defaultChannelPasswordHashed: true,
|
|
|
|
token: undefined,
|
|
|
|
nicknameSpecified: false,
|
|
nickname: undefined
|
|
}, false).then(undefined);
|
|
}
|
|
|
|
executeAutoConnect() {
|
|
let newTab = server_connections.getActiveConnectionHandler().connection_state !== ConnectionState.UNCONNECTED;
|
|
for (const entry of this.getOrderedRegisteredBookmarks()) {
|
|
if (entry.entry.type !== "entry") {
|
|
continue;
|
|
}
|
|
|
|
if (!entry.entry.connectOnStartup) {
|
|
continue;
|
|
}
|
|
|
|
this.executeConnect(entry.entry.uniqueId, newTab);
|
|
newTab = true;
|
|
}
|
|
}
|
|
|
|
exportBookmarks(): string {
|
|
return JSON.stringify({
|
|
version: 1,
|
|
bookmarks: this.registeredBookmarks
|
|
});
|
|
}
|
|
|
|
importBookmarks(filePayload: string): number {
|
|
let data;
|
|
try {
|
|
data = JSON.parse(filePayload)
|
|
} catch (error) {
|
|
throw tr("failed to parse bookmarks");
|
|
}
|
|
|
|
if (data?.version !== 1) {
|
|
throw tr("supplied data contains invalid version");
|
|
}
|
|
|
|
const newBookmarks = data.bookmarks as BookmarkEntry[];
|
|
if (!Array.isArray(newBookmarks)) {
|
|
throw tr("missing bookmarks");
|
|
}
|
|
|
|
/* TODO: Validate integrity? */
|
|
for (const knownBookmark of this.registeredBookmarks) {
|
|
const index = newBookmarks.findIndex(entry => entry.uniqueId === knownBookmark.uniqueId);
|
|
if (index === -1) {
|
|
continue;
|
|
}
|
|
|
|
newBookmarks.splice(index, 1);
|
|
}
|
|
|
|
if (newBookmarks.length === 0) {
|
|
return;
|
|
}
|
|
|
|
this.registeredBookmarks.push(...newBookmarks);
|
|
newBookmarks.forEach(entry => this.validateHangInPoint(entry));
|
|
this.events.fire("notify_bookmarks_imported", { bookmarks: newBookmarks });
|
|
return newBookmarks.length;
|
|
}
|
|
|
|
private doEditBookmark(uniqueId: string, newValues: any) {
|
|
const bookmarkInfo = this.findBookmark(uniqueId);
|
|
if (!bookmarkInfo) {
|
|
return;
|
|
}
|
|
|
|
const originalProperties = _.cloneDeep(bookmarkInfo);
|
|
for (const key of Object.keys(newValues)) {
|
|
bookmarkInfo[key] = newValues[key];
|
|
}
|
|
this.validateHangInPoint(bookmarkInfo);
|
|
|
|
const editedKeys = [];
|
|
for (const key of Object.keys(newValues)) {
|
|
if (_.isEqual(bookmarkInfo[key], originalProperties[key])) {
|
|
continue;
|
|
}
|
|
|
|
editedKeys.push(key);
|
|
}
|
|
|
|
if (editedKeys.length === 0) {
|
|
return;
|
|
}
|
|
|
|
ignorePromise(this.saveBookmarks());
|
|
this.events.fire("notify_bookmark_edited", { bookmark: bookmarkInfo, keys: editedKeys });
|
|
}
|
|
|
|
private validateHangInPoint(entry: Partial<BookmarkBase>) {
|
|
if (entry.previousEntry) {
|
|
const previousEntry = this.findBookmark(entry.previousEntry);
|
|
if (!previousEntry) {
|
|
logError(LogCategory.BOOKMARKS, tr("New bookmark previous entry does not exists. Clearing it."));
|
|
entry.previousEntry = undefined;
|
|
} else if (previousEntry.parentEntry !== entry.parentEntry) {
|
|
logWarn(LogCategory.BOOKMARKS, tr("Previous entries parent does not match our entries parent. Updating our parent from %s to %s"), entry.parentEntry, previousEntry.parentEntry);
|
|
entry.parentEntry = previousEntry.parentEntry;
|
|
}
|
|
|
|
|
|
const openList = this.registeredBookmarks.filter(e1 => e1.parentEntry === entry.parentEntry);
|
|
let currentEntry = entry;
|
|
while (true) {
|
|
if (!currentEntry.previousEntry) {
|
|
break;
|
|
}
|
|
|
|
const previousEntry = openList.find(entry => entry.uniqueId === currentEntry.previousEntry);
|
|
if (!previousEntry) {
|
|
logError(LogCategory.BOOKMARKS, tr("Found circular dependency within the previous entry or one of the previous entries does not exists. Clearing out previous entry."));
|
|
entry.previousEntry = undefined;
|
|
break;
|
|
}
|
|
|
|
openList.remove(previousEntry);
|
|
currentEntry = previousEntry;
|
|
}
|
|
}
|
|
|
|
if (entry.parentEntry) {
|
|
const parentEntry = this.findBookmark(entry.parentEntry);
|
|
if (!parentEntry) {
|
|
logError(LogCategory.BOOKMARKS, tr("Missing parent entry %s. Clearing it."), entry.parentEntry);
|
|
entry.parentEntry = undefined;
|
|
}
|
|
|
|
const openList = this.registeredBookmarks.slice();
|
|
let currentEntry = entry;
|
|
while (true) {
|
|
if (!currentEntry.parentEntry) {
|
|
break;
|
|
}
|
|
|
|
const parentEntry = openList.find(entry => entry.uniqueId === currentEntry.parentEntry);
|
|
if (!parentEntry) {
|
|
logError(LogCategory.BOOKMARKS, tr("Found circular dependency within a parent or one of the parents does not exists. Clearing out parent."));
|
|
entry.parentEntry = undefined;
|
|
break;
|
|
}
|
|
|
|
openList.remove(parentEntry);
|
|
currentEntry = parentEntry;
|
|
}
|
|
}
|
|
|
|
if (entry.previousEntry) {
|
|
this.registeredBookmarks.forEach(bookmark => {
|
|
if (bookmark.previousEntry === entry.previousEntry && bookmark !== entry) {
|
|
bookmark.previousEntry = bookmark.uniqueId;
|
|
}
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
export let bookmarks: BookmarkManager;
|
|
|
|
loader.register_task(Stage.JAVASCRIPT_INITIALIZING, {
|
|
name: "initialize bookmarks",
|
|
function: async () => {
|
|
bookmarks = new BookmarkManager();
|
|
await bookmarks.loadBookmarks();
|
|
(window as any).bookmarks = bookmarks;
|
|
},
|
|
priority: 20
|
|
});
|