Start message loading classes

This commit is contained in:
Melon 2023-11-25 09:40:13 +00:00
parent d937f18a19
commit 1be9ad77b5
Signed by: melon
GPG Key ID: 6C9D970C50D26A25
9 changed files with 382 additions and 209 deletions

View File

@ -2,33 +2,20 @@
import {onMount} from "svelte";
import {getBearer, loginStore} from "./stores/login";
import {openLoginPopup} from "./utils/login-popup";
import type {ImapFolder, ImapMessage} from "./types/imap";
import type {TreeFolder, RootFolder, FolderSelection} from "./types/internal";
import type {ImapMessage} from "./types/imap";
import TreePath from "./components/TreePath.svelte";
import MailView from "./components/MailView.svelte";
import FolderView from "./components/FolderView.svelte";
import type {Folders} from "lucide-svelte";
import {WS} from "./logic/ws";
import {FolderManager, MailFolder} from "./logic/folder";
import {MessageManager} from "./logic/message";
let mainWS: WebSocket;
let mainWS: WS;
$: window.mainWS = mainWS;
// Setup root folders
let INBOX: RootFolder = {role: "Inbox", name: "Inbox", path: "INBOX", attr: new Set(), children: []};
let DRAFTS: RootFolder = {role: "Drafts", name: "Drafts", path: "", attr: new Set(["\\Drafts"]), children: []};
let SENT: RootFolder = {role: "Sent", name: "Sent", path: "", attr: new Set(["\\Sent"]), children: []};
let ARCHIVE: RootFolder = {role: "Archive", name: "Archive", path: "", attr: new Set(["\\Archive"]), children: []};
let JUNK: RootFolder = {role: "Junk", name: "Junk", path: "", attr: new Set(["\\Junk"]), children: []};
let TRASH: RootFolder = {role: "Trash", name: "Trash", path: "", attr: new Set(["\\Trash"]), children: []};
let fm: FolderManager = new FolderManager();
let mm: MessageManager = new MessageManager();
// Setup map to find special folders
let ROOT: Map<string, RootFolder> = new Map();
ROOT.set("Drafts", DRAFTS);
ROOT.set("Sent", SENT);
ROOT.set("Archive", ARCHIVE);
ROOT.set("Junk", JUNK);
ROOT.set("Trash", TRASH);
let folders: RootFolder[] = [];
let inboxOption: string = "*";
let inboxOptions: string[] = [];
@ -36,183 +23,26 @@
let messageLookup: Map<string, ImapMessage> = new Map();
window.messageLookup = messageLookup;
let currentFolder: FolderSelection = {name: "Inbox", obj: INBOX};
let folderMessages: ImapMessage[] = [];
$: folderMessages = messageList.get(currentFolder.obj.path);
let currentFolder: MailFolder = fm.INBOX;
let folderMessages: Promise<ImapMessage[]>;
let currentMessage: ImapMessage | null = null;
function changeSelectedFolder(p: FolderSelection) {
function changeSelectedFolder(p: MailFolder) {
currentFolder = p;
grabFolderMessages(p.obj);
}
function grabFolderMessages(p: TreeFolder) {
let msgs = messageList.get(p.path);
if (msgs == undefined) {
mainWS.send(JSON.stringify({action: "fetch", args: [p.path, "1", "10", "10"]}));
return;
}
folderMessages = msgs;
console.log("changeSelectedFolder", p);
folderMessages = mm.fetchMessages(p.path, 1, 10, 10);
}
$: console.log("Selected", currentFolder);
function countChar(s: string, c: string) {
let result = 0;
for (let i = 0; i < s.length; i++) if (s[i] == c) result++;
return result;
}
function connectWS() {
mainWS = new WebSocket(import.meta.env.VITE_IMAP_LOTUS);
mainWS.addEventListener("open", () => {
mainWS.send(JSON.stringify({token: getBearer().slice(7)}));
});
mainWS.addEventListener("message", e => {
let j = JSON.parse(e.data);
if (j.auth === "ok") {
mainWS.send(JSON.stringify({action: "list", args: ["", "*"]}));
}
if (j.type === "list") {
// === Example output of list command ===
// let j = {
// type: "list",
// value: [
// {Attributes: ["\\HasChildren", "\\UnMarked", "\\Archive"], Delimiter: "/", Name: "Archive"},
// {Attributes: ["\\HasNoChildren", "\\UnMarked", "\\Junk"], Delimiter: "/", Name: "Junk"},
// {Attributes: ["\\HasChildren", "\\Trash"], Delimiter: "/", Name: "Trash"},
// {Attributes: ["\\HasNoChildren", "\\UnMarked"], Delimiter: "/", Name: "INBOX/status"},
// {Attributes: ["\\HasNoChildren", "\\UnMarked"], Delimiter: "/", Name: "INBOX/hello"},
// {Attributes: ["\\HasNoChildren", "\\UnMarked"], Delimiter: "/", Name: "INBOX/hi"},
// {Attributes: ["\\Noselect", "\\HasChildren"], Delimiter: "/", Name: "INBOX/sub"},
// {Attributes: ["\\HasNoChildren"], Delimiter: "/", Name: "INBOX/sub/folder"},
// {Attributes: ["\\HasNoChildren", "\\UnMarked", "\\Drafts"], Delimiter: "/", Name: "Drafts"},
// {Attributes: ["\\HasNoChildren", "\\Sent"], Delimiter: "/", Name: "Sent"},
// {Attributes: ["\\HasChildren"], Delimiter: "/", Name: "INBOX"},
// ],
// };
let imapFolders = j.value as ImapFolder[];
// Remove no-select folders
imapFolders = imapFolders.filter(x => !x.Attributes.includes("\\Noselect"));
// Sort shorter paths first so parent folders are registered before children
imapFolders = imapFolders.sort((a, b) => countChar(a.Name, a.Delimiter) - countChar(b.Name, b.Delimiter));
// Store reference to special folders
let ROOTKEYS: Map<string, RootFolder> = new Map();
imapFolders.forEach(x => {
// Find inbox folder
if (x.Name === "INBOX") {
x.Attributes.forEach(x => {
INBOX.attr.add(x);
});
ROOTKEYS.set(x.Name, INBOX);
return; // continue imapFolders loop
}
// Test for all special folder attributes
for (let [k, v] of ROOT.entries()) {
if (x.Attributes.includes("\\" + k)) {
v.name = x.Name;
x.Attributes.forEach(x => {
v.attr.add(x);
});
v.path = x.Name;
// map name to root key
ROOTKEYS.set(x.Name, v);
return; // continue imapFolders loop
}
}
let n = x.Name.indexOf(x.Delimiter);
if (n == -1) {
console.error("No parent folder wtf??", x.Name);
return;
}
let parent = x.Name.slice(0, n);
let pObj: TreeFolder | undefined = ROOTKEYS.get(parent);
if (pObj == undefined) {
console.error("Parent is not a root folder??", x.Name);
return;
}
let pIdx = n + 1;
for (let i = pIdx; i < x.Name.length; i++) {
if (x.Name[i] != x.Delimiter) continue;
// find child matching current slice
let nextObj: TreeFolder | undefined = pObj?.children.find(x2 => {
// check if folder matches current slice
return x2.name === x.Name.slice(pIdx, i);
});
// if no slice is found try a bigger slice
if (nextObj == undefined) continue;
// move into child folder
pObj = nextObj;
pIdx = i + 1;
}
// no parent was found at all
if (pObj == undefined) {
console.error("Parent folder does not exist??", x.Name);
return;
}
// add child to current parent
pObj.children.push({
name: x.Name.slice(pIdx),
path: x.Name,
attr: new Set(x.Attributes),
children: [],
});
});
// output special folders in order
folders = [INBOX, DRAFTS, SENT, ARCHIVE, JUNK, TRASH];
}
if (j.type == "fetch") {
// {
// type: "fetch",
// value: [
// {
// $Body: {},
// BodyStructure: null,
// Envelope: {
// Date: "2023-09-10T20:54:09-04:00",
// Subject: "This is an email subject",
// From: [{PersonalName: "A Cool User", AtDomainList: "", MailboxName: "test", HostName: "example.com"}],
// Sender: [{PersonalName: "A Cool User", AtDomainList: "", MailboxName: "test", HostName: "example.com"}],
// ReplyTo: [{PersonalName: "A Cool User", AtDomainList: "", MailboxName: "test", HostName: "example.com"}],
// To: [{PersonalName: "Internal", AtDomainList: "", MailboxName: "melon+hi", HostName: "example.org"}],
// Cc: null,
// Bcc: null,
// InReplyTo: "",
// MessageId: "\u003c950124.162336@example.com\u003e",
// },
// Flags: ["\\Seen", "nonjunk"],
// InternalDate: "2023-09-10T20:54:10-04:00",
// Items: ["UID", "FLAGS", "INTERNALDATE", "ENVELOPE"],
// SeqNum: 1,
// Size: 0,
// Uid: 18,
// }
// ]
// };
}
});
}
function removeLoginSession() {
$loginStore = null;
localStorage.removeItem("login-session");
}
onMount(() => {
connectWS();
mainWS = new WS(import.meta.env.VITE_IMAP_LOTUS, getBearer().slice(7), fm, mm);
});
</script>
@ -241,12 +71,16 @@
<div id="login-view">Please login to continue</div>
{:else}
<div id="sidebar">
{#each folders as folder}
{#each fm.ROOT as folder}
<TreePath data={folder} selected={currentFolder.name} on:select={n => changeSelectedFolder(n.detail)} />
{/each}
</div>
<div id="folder-view">
<FolderView folder={currentFolder} messages={folderMessages} />
{#await folderMessages}
<div>Loading messages</div>
{:then x}
<FolderView folder={currentFolder} messages={x} />
{/await}
</div>
<div id="message-view">
<div style="padding:8px;background-color:#bb7900;">Warning: This is currently still under development</div>
@ -287,7 +121,9 @@
height: 70px;
padding: 0 32px;
background-color: #2c2c2c;
box-shadow: 0 4px 8px #0003, 0 6px 20px #00000030;
box-shadow:
0 4px 8px #0003,
0 6px 20px #00000030;
gap: 16px;
z-index: 1;
position: relative;
@ -350,10 +186,20 @@
padding: 16px;
}
#option-view {
#folder-view {
box-sizing: border-box;
overflow-y: auto;
height: 100%;
width: 250px;
flex-grow: 1;
flex-shrink: 0;
}
#message-view {
box-sizing: border-box;
overflow-y: auto;
width: 100%;
height: 100%;
flex-grow: 1;
}
}
@ -361,7 +207,9 @@
footer {
padding: 8px;
background-color: #2c2c2c;
box-shadow: 0 -4px 8px #0003, 0 -6px 20px #00000030;
box-shadow:
0 -4px 8px #0003,
0 -6px 20px #00000030;
display: flex;
flex-direction: row;
justify-content: space-between;

View File

@ -1,8 +1,8 @@
<script lang="ts">
import {slide} from "svelte/transition";
import type {FolderSelection, RootFolder, TreeFolder} from "../types/internal";
import {Archive, ChevronRight, Flame, Folder, Inbox, Send, Text, Trash2} from "lucide-svelte";
import {createEventDispatcher} from "svelte";
import type { MailFolder, RootMailFolder } from "../logic/folder";
let folderIcons: {[key: string]: any} = {
Inbox: Inbox,
@ -15,12 +15,12 @@
const dispatch = createEventDispatcher();
export let data: TreeFolder;
export let data: MailFolder;
let expanded: boolean = false;
export let treeOffset: number = 0;
export let selected: string = "";
function isRootFolder(obj: any): obj is RootFolder {
function isRootFolder(obj: any): obj is RootMailFolder {
return "role" in obj;
}
@ -34,8 +34,8 @@
return full.slice(data.name.length + 1);
}
function triggerSelected(n?: FolderSelection) {
dispatch("select", n == undefined ? {name: data.name, obj: data} : n);
function triggerSelected(n?: MailFolder) {
dispatch("select", n == undefined ? data : n);
}
</script>
@ -53,7 +53,7 @@
data={child}
treeOffset={treeOffset + 1}
selected={getNextSelected(selected)}
on:select={n => triggerSelected({name: data.name + "/" + n.detail.name, obj: n.detail.obj})}
on:select={n => triggerSelected(n.detail)}
/>
{/each}
</div>

115
src/logic/folder.ts Normal file
View File

@ -0,0 +1,115 @@
import type {ImapFolder} from "../types/imap";
export class MailFolder {
name: string; // Local name
path: string; // Global path
attr: Set<string>;
children: Array<MailFolder>;
constructor(name: string, path: string, attr: Set<string>) {
this.name = name;
this.path = path;
this.attr = attr;
this.children = new Array();
}
addAttrs(a: string[]) {
a.forEach(x => this.attr.add(x));
}
addChild(f: MailFolder) {
this.children.push(f);
}
}
export class RootMailFolder extends MailFolder {
role: string;
constructor(name: string, role: string, path: string, attr: Set<string>) {
super(name, path, attr);
this.role = role;
}
}
export class FolderManager {
public INBOX: RootMailFolder = new RootMailFolder("Inbox", "Inbox", "INBOX", new Set(""));
public DRAFTS: RootMailFolder = new RootMailFolder("Drafts", "Drafts", "~", new Set(["\\Drafts"]));
public SENT: RootMailFolder = new RootMailFolder("Sent", "Sent", "~", new Set(["\\Sent"]));
public ARCHIVE: RootMailFolder = new RootMailFolder("Archive", "Archive", "~", new Set(["\\Archive"]));
public JUNK: RootMailFolder = new RootMailFolder("Junk", "Junk", "~", new Set(["\\Junk"]));
public TRASH: RootMailFolder = new RootMailFolder("Trash", "Trash", "~", new Set(["\\Trash"]));
public ROOT = [this.INBOX, this.DRAFTS, this.SENT, this.ARCHIVE, this.JUNK, this.TRASH];
private ROOT_SPECIAL = {
Drafts: this.DRAFTS,
Sent: this.SENT,
Archive: this.ARCHIVE,
Junk: this.JUNK,
Trash: this.TRASH,
};
// Store reference to special folders
private ROOT_PATHS: Map<string, RootMailFolder> = new Map();
constructor() {}
resolveFolder(x: ImapFolder) {
// Find inbox folder
if (x.Name === "INBOX") {
this.INBOX.addAttrs(x.Attributes);
this.ROOT_PATHS.set(x.Name, this.INBOX);
return; // continue imapFolders loop
}
// Test for all special folder attributes
for (let [k, v] of Object.entries(this.ROOT_SPECIAL)) {
if (x.Attributes.includes("\\" + k)) {
v.name = x.Name;
x.Attributes.forEach(x => {
v.attr.add(x);
});
v.path = x.Name;
// map name to root key
this.ROOT_PATHS.set(x.Name, v);
return; // continue imapFolders loop
}
}
let n = x.Name.indexOf(x.Delimiter);
if (n == -1) {
console.error("No parent folder wtf??", x.Name);
return;
}
let parent = x.Name.slice(0, n);
let pObj: MailFolder | undefined = this.ROOT_PATHS.get(parent);
if (pObj == undefined) {
console.error("Parent is not a root folder??", x.Name);
return;
}
let pIdx = n + 1;
for (let i = pIdx; i < x.Name.length; i++) {
if (x.Name[i] != x.Delimiter) continue;
// find child matching current slice
let nextObj: MailFolder | undefined = pObj?.children.find(x2 => {
// check if folder matches current slice
return x2.name === x.Name.slice(pIdx, i);
});
// if no slice is found try a bigger slice
if (nextObj == undefined) continue;
// move into child folder
pObj = nextObj;
pIdx = i + 1;
}
// no parent was found at all
if (pObj == undefined) {
console.error("Parent folder does not exist??", x.Name);
return;
}
// add child to current parent
pObj.children.push(new MailFolder(x.Name.slice(pIdx), x.Name, new Set(x.Attributes)));
}
}

83
src/logic/message.ts Normal file
View File

@ -0,0 +1,83 @@
import type {ImapEnvelope, ImapMessage} from "../types/imap";
import {in2mins} from "../utils/in2mins";
import type {WS} from "./ws";
export class MailMessage {
expires: Date;
__Body: unknown;
Envelope: ImapEnvelope;
Flags: Set<string> = new Set();
InternalDate: Date;
Items: string[];
SeqNum: number;
Size: number;
Uid: number;
constructor(m: ImapMessage) {
this.expires = in2mins();
this.__Body = m.$Body;
this.Envelope = m.Envelope;
m.Flags.forEach(x => this.Flags.add(x));
this.InternalDate = new Date(m.InternalDate);
this.Items = m.Items;
this.SeqNum = m.SeqNum;
this.Size = m.Size;
this.Uid = m.Uid;
}
}
export class MessageManager {
ws?: WS;
fetchCalls: Map<number, {path: string; trigger: (msgs: MailMessage[]) => void}> = new Map(); // key = unique
store: Map<string, FolderMessageManager> = new Map(); // key = path -> seqnum
setWS(ws: WS) {
this.ws = ws;
window.__mm_store = this.store;
}
fetchMessages(path: string, start: number, end: number, limit: number): Promise<MailMessage[]> {
let now = Date.now();
let s = this.store.get(path);
if (s?.expires || 0 < now) {
return new Promise((res, rej) => {
this.fetchCalls.set(now, {
path: path,
trigger: (msgs: MailMessage[]) => {
console.log("Fetch call triggered");
res(msgs);
},
});
this.ws?.send("fetch", {sync: now, path, start, end, limit});
});
}
return Promise.resolve([...(s?.store.values() || [])]);
}
fetchFolder(path: string): Promise<MailMessage[]> {
return this.fetchMessages(path, 1, 100, 100);
}
updateMessage(sync: number, m: MailMessage[]) {
let call = this.fetchCalls.get(sync);
if (!call) return;
this.fetchCalls.delete(sync);
let s = this.store.get(call.path);
if (!s) {
s = new FolderMessageManager();
this.store.set(call.path, s);
}
s.saveIncomingMessages(m);
call.trigger([...s.store.values()]);
}
}
export class FolderMessageManager {
expires: Date = in2mins();
store: Map<number, MailMessage> = new Map(); // key = seqnum
saveIncomingMessages(m: MailMessage[]) {
m.forEach(x => this.store.set(x.SeqNum, x));
}
}

100
src/logic/ws.ts Normal file
View File

@ -0,0 +1,100 @@
import type {ImapFolder, ImapMessage} from "../types/imap";
import {countChar} from "../utils/count-char";
import type {FolderManager, RootMailFolder} from "./folder";
import {MailMessage, type MessageManager} from "./message";
export class WS {
ws: WebSocket;
fm: FolderManager;
mm: MessageManager;
constructor(address: string, token: string, fm: FolderManager, mm: MessageManager) {
this.ws = new WebSocket(address);
this.ws.addEventListener("open", () => {
this.ws.send(JSON.stringify({token: token}));
});
this.ws.addEventListener("message", e => {
this.handleMessage(e);
});
this.fm = fm;
this.mm = mm;
mm.setWS(this);
}
send(action: string, args: any) {
this.ws.send(JSON.stringify({action, args}));
}
handleMessage(e: MessageEvent<any>) {
let j = JSON.parse(e.data);
if (j.auth === "ok") {
this.ws.send(JSON.stringify({action: "list", args: ["", "*"]}));
}
if (j.type === "list") {
// === Example output of list command ===
// let j = {
// type: "list",
// value: [
// {Attributes: ["\\HasChildren", "\\UnMarked", "\\Archive"], Delimiter: "/", Name: "Archive"},
// {Attributes: ["\\HasNoChildren", "\\UnMarked", "\\Junk"], Delimiter: "/", Name: "Junk"},
// {Attributes: ["\\HasChildren", "\\Trash"], Delimiter: "/", Name: "Trash"},
// {Attributes: ["\\HasNoChildren", "\\UnMarked"], Delimiter: "/", Name: "INBOX/status"},
// {Attributes: ["\\HasNoChildren", "\\UnMarked"], Delimiter: "/", Name: "INBOX/hello"},
// {Attributes: ["\\HasNoChildren", "\\UnMarked"], Delimiter: "/", Name: "INBOX/hi"},
// {Attributes: ["\\Noselect", "\\HasChildren"], Delimiter: "/", Name: "INBOX/sub"},
// {Attributes: ["\\HasNoChildren"], Delimiter: "/", Name: "INBOX/sub/folder"},
// {Attributes: ["\\HasNoChildren", "\\UnMarked", "\\Drafts"], Delimiter: "/", Name: "Drafts"},
// {Attributes: ["\\HasNoChildren", "\\Sent"], Delimiter: "/", Name: "Sent"},
// {Attributes: ["\\HasChildren"], Delimiter: "/", Name: "INBOX"},
// ],
// };
let imapFolders = j.value as ImapFolder[];
// Remove no-select folders
imapFolders = imapFolders.filter(x => !x.Attributes.includes("\\Noselect"));
// Sort shorter paths first so parent folders are registered before children
imapFolders = imapFolders.sort((a, b) => countChar(a.Name, a.Delimiter) - countChar(b.Name, b.Delimiter));
imapFolders.forEach(x => {
this.fm.resolveFolder(x);
});
}
if (j.type == "fetch") {
// {
// type: "fetch",
// sync: 0,
// value: [
// {
// $Body: {},
// BodyStructure: null,
// Envelope: {
// Date: "2023-09-10T20:54:09-04:00",
// Subject: "This is an email subject",
// From: [{PersonalName: "A Cool User", AtDomainList: "", MailboxName: "test", HostName: "example.com"}],
// Sender: [{PersonalName: "A Cool User", AtDomainList: "", MailboxName: "test", HostName: "example.com"}],
// ReplyTo: [{PersonalName: "A Cool User", AtDomainList: "", MailboxName: "test", HostName: "example.com"}],
// To: [{PersonalName: "Internal", AtDomainList: "", MailboxName: "melon+hi", HostName: "example.org"}],
// Cc: null,
// Bcc: null,
// InReplyTo: "",
// MessageId: "\u003c950124.162336@example.com\u003e",
// },
// Flags: ["\\Seen", "nonjunk"],
// InternalDate: "2023-09-10T20:54:10-04:00",
// Items: ["UID", "FLAGS", "INTERNALDATE", "ENVELOPE"],
// SeqNum: 1,
// Size: 0,
// Uid: 18,
// }
// ]
// };
let imapMsg = j.value as ImapMessage[];
this.mm.updateMessage(
j.sync,
imapMsg.map(x => new MailMessage(x)),
);
}
}
}

View File

@ -1,15 +0,0 @@
export interface TreeFolder {
name: string; // Local name
path: string; // Global path
attr: Set<string>;
children: TreeFolder[];
}
export interface RootFolder extends TreeFolder {
role: string;
}
export interface FolderSelection {
name: string;
obj: TreeFolder;
}

5
src/utils/count-char.ts Normal file
View File

@ -0,0 +1,5 @@
export function countChar(s: string, c: string) {
let result = 0;
for (let i = 0; i < s.length; i++) if (s[i] == c) result++;
return result;
}

5
src/utils/in2mins.ts Normal file
View File

@ -0,0 +1,5 @@
export function in2mins(): Date {
let future = new Date();
future.setMinutes(future.getMinutes() + 2);
return future;
}

View File

@ -160,6 +160,7 @@ func apiServer(verify mjwt.Verifier) {
c.WriteMessage(websocket.TextMessage, []byte(`{"auth":"ok"}`))
continue
} else if vAct, ok := m["action"]; ok {
args := m["args"]
switch vAct.(string) {
case "list":
log.Println(m)
@ -188,6 +189,37 @@ func apiServer(verify mjwt.Verifier) {
`))
}
continue
case "fetch":
c.WriteMessage(websocket.TextMessage, []byte(fmt.Sprintf(`
{
"type": "fetch",
"sync": %f,
"value": [
{
"$Body": {},
"BodyStructure": null,
"Envelope": {
"Date": "2023-09-10T20:54:09-04:00",
"Subject": "This is an email subject",
"From": [{"PersonalName": "A Cool User", "AtDomainList": "", "MailboxName": "test", "HostName": "example.com"}],
"Sender": [{"PersonalName": "A Cool User", "AtDomainList": "", "MailboxName": "test", "HostName": "example.com"}],
"ReplyTo": [{"PersonalName": "A Cool User", "AtDomainList": "", "MailboxName": "test", "HostName": "example.com"}],
"To": [{"PersonalName": "Internal", "AtDomainList": "", "MailboxName": "melon+hi", "HostName": "example.org"}],
"Cc": null,
"Bcc": null,
"InReplyTo": "",
"MessageId": "\u003c950124.162336@example.com\u003e"
},
"Flags": ["\\Seen", "nonjunk"],
"InternalDate": "2023-09-10T20:54:10-04:00",
"Items": ["UID", "FLAGS", "INTERNALDATE", "ENVELOPE"],
"SeqNum": 1,
"Size": 0,
"Uid": 18
}
]
}
`, args.(map[string]any)["sync"])))
}
}
}