feat: init
This commit is contained in:
23
apps/web/core/local-db/utils/constants.ts
Normal file
23
apps/web/core/local-db/utils/constants.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
export const ARRAY_FIELDS = ["label_ids", "assignee_ids", "module_ids"];
|
||||
|
||||
export const BOOLEAN_FIELDS = ["is_draft"];
|
||||
|
||||
export const GROUP_BY_MAP = {
|
||||
state_id: "state_id",
|
||||
priority: "priority",
|
||||
cycle_id: "cycle_id",
|
||||
created_by: "created_by",
|
||||
// Array Props
|
||||
issue_module__module_id: "module_ids",
|
||||
labels__id: "label_ids",
|
||||
assignees__id: "assignee_ids",
|
||||
target_date: "target_date",
|
||||
};
|
||||
|
||||
export const PRIORITY_MAP = {
|
||||
low: 1,
|
||||
medium: 2,
|
||||
high: 3,
|
||||
urgent: 4,
|
||||
none: 0,
|
||||
};
|
||||
30
apps/web/core/local-db/utils/data.utils.ts
Normal file
30
apps/web/core/local-db/utils/data.utils.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { runQuery } from "./query-executor";
|
||||
|
||||
export const getProjectIds = async () => {
|
||||
const q = `select project_id from states where project_id is not null group by project_id`;
|
||||
return await runQuery(q);
|
||||
};
|
||||
|
||||
export const getSubIssues = async (issueId: string) => {
|
||||
const q = `select * from issues where parent_id = '${issueId}'`;
|
||||
return await runQuery(q);
|
||||
};
|
||||
|
||||
export const getSubIssueDistribution = async (issueId: string) => {
|
||||
const q = `select s.'group', group_concat(i.id) as issues from issues i left join states s on s.id = i.state_id where i.parent_id = '${issueId}' group by s.'group'`;
|
||||
|
||||
const result = await runQuery(q);
|
||||
if (!result.length) {
|
||||
return {};
|
||||
}
|
||||
return result.reduce((acc: Record<string, string[]>, item: { group: string; issues: string }) => {
|
||||
acc[item.group] = item.issues.split(",");
|
||||
return acc;
|
||||
}, {});
|
||||
};
|
||||
|
||||
export const getSubIssuesWithDistribution = async (issueId: string) => {
|
||||
const promises = [getSubIssues(issueId), getSubIssueDistribution(issueId)];
|
||||
const [sub_issues, state_distribution] = await Promise.all(promises);
|
||||
return { sub_issues, state_distribution };
|
||||
};
|
||||
67
apps/web/core/local-db/utils/indexes.ts
Normal file
67
apps/web/core/local-db/utils/indexes.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { persistence } from "../storage.sqlite";
|
||||
import { log } from "./utils";
|
||||
|
||||
export const createIssueIndexes = async () => {
|
||||
const columns = [
|
||||
"state_id",
|
||||
"sort_order",
|
||||
// "priority",
|
||||
"priority_proxy",
|
||||
"project_id",
|
||||
"created_by",
|
||||
"cycle_id",
|
||||
"sequence_id",
|
||||
];
|
||||
|
||||
const promises: Promise<any>[] = [];
|
||||
|
||||
promises.push(persistence.db.exec({ sql: `CREATE UNIQUE INDEX issues_issue_id_idx ON issues (id)` }));
|
||||
|
||||
columns.forEach((column) => {
|
||||
promises.push(
|
||||
persistence.db.exec({ sql: `CREATE INDEX issues_issue_${column}_idx ON issues (project_id, ${column})` })
|
||||
);
|
||||
});
|
||||
await Promise.all(promises);
|
||||
};
|
||||
|
||||
export const createIssueMetaIndexes = async () => {
|
||||
// Drop indexes
|
||||
await persistence.db.exec({ sql: `CREATE INDEX issue_meta_all_idx ON issue_meta (issue_id,key,value)` });
|
||||
};
|
||||
|
||||
export const createWorkSpaceIndexes = async () => {
|
||||
const promises: Promise<any>[] = [];
|
||||
// Labels
|
||||
promises.push(persistence.db.exec({ sql: `CREATE INDEX labels_name_idx ON labels (id,name,project_id)` }));
|
||||
// Modules
|
||||
promises.push(persistence.db.exec({ sql: `CREATE INDEX modules_name_idx ON modules (id,name,project_id)` }));
|
||||
// States
|
||||
promises.push(persistence.db.exec({ sql: `CREATE INDEX states_name_idx ON states (id,name,project_id)` }));
|
||||
// Cycles
|
||||
promises.push(persistence.db.exec({ sql: `CREATE INDEX cycles_name_idx ON cycles (id,name,project_id)` }));
|
||||
|
||||
// Members
|
||||
promises.push(persistence.db.exec({ sql: `CREATE INDEX members_name_idx ON members (id,first_name)` }));
|
||||
|
||||
// Estimate Points @todo
|
||||
promises.push(persistence.db.exec({ sql: `CREATE INDEX estimate_points_name_idx ON estimate_points (id,value)` }));
|
||||
// Options
|
||||
promises.push(persistence.db.exec({ sql: `CREATE INDEX options_key_idx ON options (key)` }));
|
||||
|
||||
await Promise.all(promises);
|
||||
};
|
||||
|
||||
const createIndexes = async () => {
|
||||
log("### Creating indexes");
|
||||
const start = performance.now();
|
||||
const promises = [createIssueIndexes(), createIssueMetaIndexes(), createWorkSpaceIndexes()];
|
||||
try {
|
||||
await Promise.all(promises);
|
||||
} catch (e) {
|
||||
console.log((e as Error).message);
|
||||
}
|
||||
log("### Indexes created in", `${performance.now() - start}ms`);
|
||||
};
|
||||
|
||||
export default createIndexes;
|
||||
133
apps/web/core/local-db/utils/load-issues.ts
Normal file
133
apps/web/core/local-db/utils/load-issues.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import type { TIssue } from "@plane/types";
|
||||
import { rootStore } from "@/lib/store-context";
|
||||
import { IssueService } from "@/services/issue";
|
||||
import { persistence } from "../storage.sqlite";
|
||||
import { ARRAY_FIELDS, PRIORITY_MAP } from "./constants";
|
||||
import { issueSchema } from "./schemas";
|
||||
import { log } from "./utils";
|
||||
|
||||
export const PROJECT_OFFLINE_STATUS: Record<string, boolean> = {};
|
||||
|
||||
export const addIssue = async (issue: any) => {
|
||||
if (document.hidden || !rootStore.user.localDBEnabled || !persistence.db) return;
|
||||
await persistence.db.exec("BEGIN;");
|
||||
await stageIssueInserts(issue);
|
||||
await persistence.db.exec("COMMIT;");
|
||||
};
|
||||
|
||||
export const addIssuesBulk = async (issues: any, batchSize = 50) => {
|
||||
if (!rootStore.user.localDBEnabled || !persistence.db) return;
|
||||
if (!issues.length) return;
|
||||
const insertStart = performance.now();
|
||||
await persistence.db.exec("BEGIN;");
|
||||
|
||||
for (let i = 0; i < issues.length; i += batchSize) {
|
||||
const batch = issues.slice(i, i + batchSize);
|
||||
|
||||
const promises = [];
|
||||
for (let j = 0; j < batch.length; j++) {
|
||||
const issue = batch[j];
|
||||
if (!issue.type_id) {
|
||||
issue.type_id = "";
|
||||
}
|
||||
promises.push(stageIssueInserts(issue));
|
||||
}
|
||||
await Promise.all(promises);
|
||||
}
|
||||
await persistence.db.exec("COMMIT;");
|
||||
|
||||
const insertEnd = performance.now();
|
||||
log("Inserted issues in ", `${insertEnd - insertStart}ms`, batchSize, issues.length);
|
||||
};
|
||||
export const deleteIssueFromLocal = async (issue_id: any) => {
|
||||
if (!rootStore.user.localDBEnabled || !persistence.db) return;
|
||||
|
||||
const deleteQuery = `DELETE from issues where id='${issue_id}'`;
|
||||
const deleteMetaQuery = `delete from issue_meta where issue_id='${issue_id}'`;
|
||||
|
||||
await persistence.db.exec("BEGIN;");
|
||||
|
||||
await persistence.db.exec(deleteQuery);
|
||||
await persistence.db.exec(deleteMetaQuery);
|
||||
await persistence.db.exec("COMMIT;");
|
||||
};
|
||||
// @todo: Update deletes the issue description from local. Implement a separate update.
|
||||
export const updateIssue = async (issue: TIssue & { is_local_update: number }) => {
|
||||
if (document.hidden || !rootStore.user.localDBEnabled || !persistence.db) return;
|
||||
|
||||
const issue_id = issue.id;
|
||||
// delete the issue and its meta data
|
||||
await deleteIssueFromLocal(issue_id);
|
||||
await addIssue(issue);
|
||||
};
|
||||
|
||||
export const syncDeletesToLocal = async (workspaceId: string, projectId: string, queries: any) => {
|
||||
if (!rootStore.user.localDBEnabled || !persistence.db) return;
|
||||
|
||||
const issueService = new IssueService();
|
||||
const response = await issueService.getDeletedIssues(workspaceId, projectId, queries);
|
||||
if (Array.isArray(response)) {
|
||||
response.map(async (issue) => deleteIssueFromLocal(issue));
|
||||
}
|
||||
};
|
||||
|
||||
const stageIssueInserts = async (issue: any) => {
|
||||
const issue_id = issue.id;
|
||||
issue.priority_proxy = PRIORITY_MAP[issue.priority as keyof typeof PRIORITY_MAP];
|
||||
|
||||
const keys = Object.keys(issueSchema);
|
||||
const sanitizedIssue = keys.reduce((acc: any, key) => {
|
||||
if (issue[key] || issue[key] === 0) {
|
||||
acc[key] = issue[key];
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const columns = "'" + Object.keys(sanitizedIssue).join("','") + "'";
|
||||
|
||||
const values = Object.values(sanitizedIssue)
|
||||
.map((value) => {
|
||||
if (value === null) {
|
||||
return "";
|
||||
}
|
||||
if (typeof value === "object") {
|
||||
return `'${JSON.stringify(value).replace(/'/g, "''")}'`;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return `'${value.replace(/'/g, "''")}'`;
|
||||
}
|
||||
return value;
|
||||
})
|
||||
.join(", ");
|
||||
|
||||
const query = `INSERT OR REPLACE INTO issues (${columns}) VALUES (${values});`;
|
||||
await persistence.db.exec(query);
|
||||
|
||||
await persistence.db.exec({
|
||||
sql: `DELETE from issue_meta where issue_id='${issue_id}'`,
|
||||
});
|
||||
|
||||
const metaPromises: Promise<any>[] = [];
|
||||
|
||||
ARRAY_FIELDS.forEach((field) => {
|
||||
const values = issue[field];
|
||||
if (values && values.length) {
|
||||
values.forEach((val: any) => {
|
||||
const p = persistence.db.exec({
|
||||
sql: `INSERT OR REPLACE into issue_meta(issue_id,key,value) values (?,?,?) `,
|
||||
bind: [issue_id, field, val],
|
||||
});
|
||||
metaPromises.push(p);
|
||||
});
|
||||
} else {
|
||||
// Added for empty fields?
|
||||
const p = persistence.db.exec({
|
||||
sql: `INSERT OR REPLACE into issue_meta(issue_id,key,value) values (?,?,?) `,
|
||||
bind: [issue_id, field, ""],
|
||||
});
|
||||
metaPromises.push(p);
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(metaPromises);
|
||||
};
|
||||
303
apps/web/core/local-db/utils/load-workspace.ts
Normal file
303
apps/web/core/local-db/utils/load-workspace.ts
Normal file
@@ -0,0 +1,303 @@
|
||||
import { difference } from "lodash-es";
|
||||
import type { IEstimate, IEstimatePoint, IWorkspaceMember, TIssue } from "@plane/types";
|
||||
import { EstimateService } from "@/plane-web/services/project/estimate.service";
|
||||
import { CycleService } from "@/services/cycle.service";
|
||||
import { IssueLabelService } from "@/services/issue/issue_label.service";
|
||||
import { ModuleService } from "@/services/module.service";
|
||||
import { ProjectStateService } from "@/services/project";
|
||||
import { WorkspaceService } from "@/services/workspace.service";
|
||||
import { persistence } from "../storage.sqlite";
|
||||
import { updateIssue } from "./load-issues";
|
||||
import type { Schema } from "./schemas";
|
||||
import { cycleSchema, estimatePointSchema, labelSchema, memberSchema, moduleSchema, stateSchema } from "./schemas";
|
||||
import { log } from "./utils";
|
||||
|
||||
const stageInserts = async (table: string, schema: Schema, data: any) => {
|
||||
const keys = Object.keys(schema);
|
||||
// Pick only the keys that are in the schema
|
||||
const filteredData = keys.reduce((acc: any, key) => {
|
||||
if (data[key] || data[key] === 0) {
|
||||
acc[key] = data[key];
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
const columns = "'" + Object.keys(filteredData).join("','") + "'";
|
||||
// Add quotes to column names
|
||||
|
||||
const values = Object.values(filteredData)
|
||||
.map((value) => {
|
||||
if (value === null) {
|
||||
return "";
|
||||
}
|
||||
if (typeof value === "object") {
|
||||
return `'${JSON.stringify(value).replace(/'/g, "''")}'`;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return `'${value.replace(/'/g, "''")}'`;
|
||||
}
|
||||
return value;
|
||||
})
|
||||
.join(", ");
|
||||
const query = `INSERT OR REPLACE INTO ${table} (${columns}) VALUES (${values});`;
|
||||
await persistence.db.exec(query);
|
||||
};
|
||||
|
||||
const batchInserts = async (data: any[], table: string, schema: Schema, batchSize = 500) => {
|
||||
for (let i = 0; i < data.length; i += batchSize) {
|
||||
const batch = data.slice(i, i + batchSize);
|
||||
for (let j = 0; j < batch.length; j++) {
|
||||
const item = batch[j];
|
||||
await stageInserts(table, schema, item);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const getLabels = async (workspaceSlug: string) => {
|
||||
const issueLabelService = new IssueLabelService();
|
||||
const objects = await issueLabelService.getWorkspaceIssueLabels(workspaceSlug);
|
||||
|
||||
return objects;
|
||||
};
|
||||
|
||||
export const getModules = async (workspaceSlug: string) => {
|
||||
const moduleService = new ModuleService();
|
||||
const objects = await moduleService.getWorkspaceModules(workspaceSlug);
|
||||
return objects;
|
||||
};
|
||||
|
||||
export const getCycles = async (workspaceSlug: string) => {
|
||||
const cycleService = new CycleService();
|
||||
|
||||
const objects = await cycleService.getWorkspaceCycles(workspaceSlug);
|
||||
return objects;
|
||||
};
|
||||
|
||||
export const getStates = async (workspaceSlug: string) => {
|
||||
const stateService = new ProjectStateService();
|
||||
const objects = await stateService.getWorkspaceStates(workspaceSlug);
|
||||
return objects;
|
||||
};
|
||||
|
||||
export const getEstimatePoints = async (workspaceSlug: string) => {
|
||||
const estimateService = new EstimateService();
|
||||
const estimates = await estimateService.fetchWorkspaceEstimates(workspaceSlug);
|
||||
let objects: IEstimatePoint[] = [];
|
||||
(estimates || []).forEach((estimate: IEstimate) => {
|
||||
if (estimate?.points) {
|
||||
objects = objects.concat(estimate.points);
|
||||
}
|
||||
});
|
||||
return objects;
|
||||
};
|
||||
|
||||
export const getMembers = async (workspaceSlug: string) => {
|
||||
const workspaceService = new WorkspaceService();
|
||||
const members = await workspaceService.fetchWorkspaceMembers(workspaceSlug);
|
||||
const objects = members.map((member: IWorkspaceMember) => member.member);
|
||||
return objects;
|
||||
};
|
||||
|
||||
const syncLabels = async (currentLabels: any) => {
|
||||
const currentIdList = currentLabels.map((label: any) => label.id);
|
||||
const existingLabels = await persistence.db.exec("SELECT id FROM labels;");
|
||||
|
||||
const existingIdList = existingLabels ? existingLabels.map((label: any) => label.id) : [];
|
||||
|
||||
const deletedIds = difference(existingIdList, currentIdList);
|
||||
|
||||
await syncIssuesWithDeletedLabels(deletedIds as string[]);
|
||||
};
|
||||
|
||||
export const syncIssuesWithDeletedLabels = async (deletedLabelIds: string[]) => {
|
||||
if (!deletedLabelIds.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Ideally we should use recursion to fetch all the issues, but 10000 issues is more than enough for now.
|
||||
const issues = await persistence.getIssues("", "", { labels: deletedLabelIds.join(","), cursor: "10000:0:0" }, {});
|
||||
if (issues?.results && Array.isArray(issues.results)) {
|
||||
const promises = issues.results.map(async (issue: TIssue) => {
|
||||
const updatedIssue = {
|
||||
...issue,
|
||||
label_ids: issue.label_ids.filter((id: string) => !deletedLabelIds.includes(id)),
|
||||
is_local_update: 1,
|
||||
};
|
||||
// We should await each update because it uses a transaction. But transaction are handled in the query executor.
|
||||
updateIssue(updatedIssue);
|
||||
});
|
||||
await Promise.all(promises);
|
||||
}
|
||||
};
|
||||
|
||||
const syncModules = async (currentModules: any) => {
|
||||
const currentIdList = currentModules.map((module: any) => module.id);
|
||||
const existingModules = await persistence.db.exec("SELECT id FROM modules;");
|
||||
const existingIdList = existingModules ? existingModules.map((module: any) => module.id) : [];
|
||||
const deletedIds = difference(existingIdList, currentIdList);
|
||||
await syncIssuesWithDeletedModules(deletedIds as string[]);
|
||||
};
|
||||
|
||||
export const syncIssuesWithDeletedModules = async (deletedModuleIds: string[]) => {
|
||||
if (!deletedModuleIds.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const issues = await persistence.getIssues("", "", { module: deletedModuleIds.join(","), cursor: "10000:0:0" }, {});
|
||||
if (issues?.results && Array.isArray(issues.results)) {
|
||||
const promises = issues.results.map(async (issue: TIssue) => {
|
||||
const updatedIssue = {
|
||||
...issue,
|
||||
module_ids: issue.module_ids?.filter((id: string) => !deletedModuleIds.includes(id)) || [],
|
||||
is_local_update: 1,
|
||||
};
|
||||
updateIssue(updatedIssue);
|
||||
});
|
||||
await Promise.all(promises);
|
||||
}
|
||||
};
|
||||
|
||||
const syncCycles = async (currentCycles: any) => {
|
||||
const currentIdList = currentCycles.map((cycle: any) => cycle.id);
|
||||
const existingCycles = await persistence.db.exec("SELECT id FROM cycles;");
|
||||
const existingIdList = existingCycles ? existingCycles.map((cycle: any) => cycle.id) : [];
|
||||
const deletedIds = difference(existingIdList, currentIdList);
|
||||
await syncIssuesWithDeletedCycles(deletedIds as string[]);
|
||||
};
|
||||
|
||||
export const syncIssuesWithDeletedCycles = async (deletedCycleIds: string[]) => {
|
||||
if (!deletedCycleIds.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const issues = await persistence.getIssues("", "", { cycle: deletedCycleIds.join(","), cursor: "10000:0:0" }, {});
|
||||
if (issues?.results && Array.isArray(issues.results)) {
|
||||
const promises = issues.results.map(async (issue: TIssue) => {
|
||||
const updatedIssue = {
|
||||
...issue,
|
||||
cycle_id: null,
|
||||
is_local_update: 1,
|
||||
};
|
||||
updateIssue(updatedIssue);
|
||||
});
|
||||
await Promise.all(promises);
|
||||
}
|
||||
};
|
||||
|
||||
const syncStates = async (currentStates: any) => {
|
||||
const currentIdList = currentStates.map((state: any) => state.id);
|
||||
const existingStates = await persistence.db.exec("SELECT id FROM states;");
|
||||
const existingIdList = existingStates ? existingStates.map((state: any) => state.id) : [];
|
||||
const deletedIds = difference(existingIdList, currentIdList);
|
||||
await syncIssuesWithDeletedStates(deletedIds as string[]);
|
||||
};
|
||||
|
||||
export const syncIssuesWithDeletedStates = async (deletedStateIds: string[]) => {
|
||||
if (!deletedStateIds.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const issues = await persistence.getIssues("", "", { state: deletedStateIds.join(","), cursor: "10000:0:0" }, {});
|
||||
if (issues?.results && Array.isArray(issues.results)) {
|
||||
const promises = issues.results.map(async (issue: TIssue) => {
|
||||
const updatedIssue = {
|
||||
...issue,
|
||||
state_id: null,
|
||||
is_local_update: 1,
|
||||
};
|
||||
updateIssue(updatedIssue);
|
||||
});
|
||||
await Promise.all(promises);
|
||||
}
|
||||
};
|
||||
|
||||
const syncMembers = async (currentMembers: any) => {
|
||||
const currentIdList = currentMembers.map((member: any) => member.id);
|
||||
const existingMembers = await persistence.db.exec("SELECT id FROM members;");
|
||||
const existingIdList = existingMembers ? existingMembers.map((member: any) => member.id) : [];
|
||||
const deletedIds = difference(existingIdList, currentIdList);
|
||||
await syncIssuesWithDeletedMembers(deletedIds as string[]);
|
||||
};
|
||||
|
||||
export const syncIssuesWithDeletedMembers = async (deletedMemberIds: string[]) => {
|
||||
if (!deletedMemberIds.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const issues = await persistence.getIssues(
|
||||
"",
|
||||
"",
|
||||
{ assignees: deletedMemberIds.join(","), cursor: "10000:0:0" },
|
||||
{}
|
||||
);
|
||||
if (issues?.results && Array.isArray(issues.results)) {
|
||||
const promises = issues.results.map(async (issue: TIssue) => {
|
||||
const updatedIssue = {
|
||||
...issue,
|
||||
assignee_ids: issue.assignee_ids.filter((id: string) => !deletedMemberIds.includes(id)),
|
||||
is_local_update: 1,
|
||||
};
|
||||
updateIssue(updatedIssue);
|
||||
});
|
||||
await Promise.all(promises);
|
||||
}
|
||||
};
|
||||
|
||||
export const loadWorkSpaceData = async (workspaceSlug: string) => {
|
||||
if (!persistence.db || !persistence.db.exec) {
|
||||
return;
|
||||
}
|
||||
log("Loading workspace data");
|
||||
const promises = [];
|
||||
promises.push(getLabels(workspaceSlug));
|
||||
promises.push(getModules(workspaceSlug));
|
||||
promises.push(getCycles(workspaceSlug));
|
||||
promises.push(getStates(workspaceSlug));
|
||||
promises.push(getEstimatePoints(workspaceSlug));
|
||||
promises.push(getMembers(workspaceSlug));
|
||||
const [labels, modules, cycles, states, estimates, members] = await Promise.all(promises);
|
||||
|
||||
// @todo: we don't need this manual sync here, when backend adds these changes to issue activity and updates the updated_at of the issue.
|
||||
await syncLabels(labels);
|
||||
await syncModules(modules);
|
||||
await syncCycles(cycles);
|
||||
await syncStates(states);
|
||||
// TODO: Not handling sync estimates yet, as we don't know the new estimate point assigned.
|
||||
// Backend should update the updated_at of the issue when estimate point is updated, or we should have realtime sync on the issues table.
|
||||
// await syncEstimates(estimates);
|
||||
await syncMembers(members);
|
||||
|
||||
const start = performance.now();
|
||||
|
||||
await persistence.db.exec("BEGIN;");
|
||||
await persistence.db.exec("DELETE FROM labels WHERE 1=1;");
|
||||
await batchInserts(labels, "labels", labelSchema);
|
||||
await persistence.db.exec("COMMIT;");
|
||||
|
||||
await persistence.db.exec("BEGIN;");
|
||||
await persistence.db.exec("DELETE FROM modules WHERE 1=1;");
|
||||
await batchInserts(modules, "modules", moduleSchema);
|
||||
await persistence.db.exec("COMMIT;");
|
||||
|
||||
await persistence.db.exec("BEGIN;");
|
||||
await persistence.db.exec("DELETE FROM cycles WHERE 1=1;");
|
||||
await batchInserts(cycles, "cycles", cycleSchema);
|
||||
await persistence.db.exec("COMMIT;");
|
||||
|
||||
await persistence.db.exec("BEGIN;");
|
||||
await persistence.db.exec("DELETE FROM states WHERE 1=1;");
|
||||
await batchInserts(states, "states", stateSchema);
|
||||
await persistence.db.exec("COMMIT;");
|
||||
|
||||
await persistence.db.exec("BEGIN;");
|
||||
await persistence.db.exec("DELETE FROM estimate_points WHERE 1=1;");
|
||||
await batchInserts(estimates, "estimate_points", estimatePointSchema);
|
||||
await persistence.db.exec("COMMIT;");
|
||||
|
||||
await persistence.db.exec("BEGIN;");
|
||||
await persistence.db.exec("DELETE FROM members WHERE 1=1;");
|
||||
await batchInserts(members, "members", memberSchema);
|
||||
await persistence.db.exec("COMMIT;");
|
||||
|
||||
const end = performance.now();
|
||||
log("Time taken to load workspace data", end - start);
|
||||
};
|
||||
169
apps/web/core/local-db/utils/query-constructor.ts
Normal file
169
apps/web/core/local-db/utils/query-constructor.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import {
|
||||
getFilteredRowsForGrouping,
|
||||
getIssueFieldsFragment,
|
||||
getMetaKeys,
|
||||
getOrderByFragment,
|
||||
singleFilterConstructor,
|
||||
translateQueryParams,
|
||||
} from "./query.utils";
|
||||
import { log } from "./utils";
|
||||
export const SPECIAL_ORDER_BY = {
|
||||
labels__name: "labels",
|
||||
"-labels__name": "labels",
|
||||
assignees__first_name: "members",
|
||||
"-assignees__first_name": "members",
|
||||
issue_module__module__name: "modules",
|
||||
"-issue_module__module__name": "modules",
|
||||
issue_cycle__cycle__name: "cycles",
|
||||
"-issue_cycle__cycle__name": "cycles",
|
||||
state__name: "states",
|
||||
"-state__name": "states",
|
||||
estimate_point__key: "estimate_point",
|
||||
"-estimate_point__key": "estimate_point",
|
||||
};
|
||||
export const issueFilterQueryConstructor = (workspaceSlug: string, projectId: string, queries: any) => {
|
||||
const {
|
||||
cursor,
|
||||
per_page,
|
||||
group_by,
|
||||
sub_group_by,
|
||||
order_by = "-created_at",
|
||||
...otherProps
|
||||
} = translateQueryParams(queries);
|
||||
|
||||
const [pageSize, page, offset] = cursor.split(":");
|
||||
|
||||
let sql = "";
|
||||
|
||||
const fieldsFragment = getIssueFieldsFragment();
|
||||
|
||||
if (sub_group_by) {
|
||||
const orderByString = getOrderByFragment(order_by);
|
||||
sql = getFilteredRowsForGrouping(projectId, queries);
|
||||
sql += `, ranked_issues AS ( SELECT fi.*,
|
||||
ROW_NUMBER() OVER (PARTITION BY group_id, sub_group_id ${orderByString}) as rank,
|
||||
COUNT(*) OVER (PARTITION by group_id, sub_group_id) as total_issues from fi)
|
||||
SELECT ri.*, ${fieldsFragment}
|
||||
FROM ranked_issues ri
|
||||
JOIN issues i ON ri.id = i.id
|
||||
WHERE rank <= ${per_page}
|
||||
|
||||
`;
|
||||
|
||||
return sql;
|
||||
}
|
||||
if (group_by) {
|
||||
const orderByString = getOrderByFragment(order_by);
|
||||
sql = getFilteredRowsForGrouping(projectId, queries);
|
||||
sql += `, ranked_issues AS ( SELECT fi.*,
|
||||
ROW_NUMBER() OVER (PARTITION BY group_id ${orderByString}) as rank,
|
||||
COUNT(*) OVER (PARTITION by group_id) as total_issues FROM fi)
|
||||
SELECT ri.*, ${fieldsFragment}
|
||||
FROM ranked_issues ri
|
||||
JOIN issues i ON ri.id = i.id
|
||||
WHERE rank <= ${per_page}
|
||||
`;
|
||||
|
||||
return sql;
|
||||
}
|
||||
|
||||
if (order_by && Object.keys(SPECIAL_ORDER_BY).includes(order_by)) {
|
||||
const name = order_by.replace("-", "");
|
||||
const orderByString = getOrderByFragment(order_by, "i.");
|
||||
|
||||
sql = `WITH sorted_issues AS (`;
|
||||
sql += getFilteredRowsForGrouping(projectId, queries);
|
||||
sql += `SELECT fi.* , `;
|
||||
if (order_by.includes("assignee")) {
|
||||
sql += ` s.first_name as ${name} `;
|
||||
} else if (order_by.includes("estimate")) {
|
||||
sql += ` s.key as ${name} `;
|
||||
} else {
|
||||
sql += ` s.name as ${name} `;
|
||||
}
|
||||
sql += `FROM fi `;
|
||||
if (order_by && Object.keys(SPECIAL_ORDER_BY).includes(order_by)) {
|
||||
if (order_by.includes("cycle")) {
|
||||
sql += `
|
||||
LEFT JOIN cycles s on fi.cycle_id = s.id`;
|
||||
}
|
||||
if (order_by.includes("estimate_point__key")) {
|
||||
sql += `
|
||||
LEFT JOIN estimate_points s on fi.estimate_point = s.id`;
|
||||
}
|
||||
if (order_by.includes("state")) {
|
||||
sql += `
|
||||
LEFT JOIN states s on fi.state_id = s.id`;
|
||||
}
|
||||
if (order_by.includes("label")) {
|
||||
sql += `
|
||||
LEFT JOIN issue_meta sm ON fi.id = sm.issue_id AND sm.key = 'label_ids'
|
||||
LEFT JOIN labels s ON s.id = sm.value`;
|
||||
}
|
||||
if (order_by.includes("module")) {
|
||||
sql += `
|
||||
LEFT JOIN issue_meta sm ON fi.id = sm.issue_id AND sm.key = 'module_ids'
|
||||
LEFT JOIN modules s ON s.id = sm.value`;
|
||||
}
|
||||
|
||||
if (order_by.includes("assignee")) {
|
||||
sql += `
|
||||
LEFT JOIN issue_meta sm ON fi.id = sm.issue_id AND sm.key = 'assignee_ids'
|
||||
LEFT JOIN members s ON s.id = sm.value`;
|
||||
}
|
||||
|
||||
sql += ` ORDER BY ${name} ASC NULLS LAST`;
|
||||
}
|
||||
sql += `)`;
|
||||
|
||||
sql += `SELECT ${fieldsFragment}, group_concat(si.${name}) as ${name} from sorted_issues si JOIN issues i ON si.id = i.id
|
||||
`;
|
||||
sql += ` group by i.id ${orderByString} LIMIT ${pageSize} OFFSET ${offset * 1 + page * pageSize};`;
|
||||
|
||||
return sql;
|
||||
}
|
||||
|
||||
const filterJoinFields = getMetaKeys(queries);
|
||||
const orderByString = getOrderByFragment(order_by);
|
||||
|
||||
sql = `SELECT ${fieldsFragment}`;
|
||||
if (otherProps.state_group) {
|
||||
sql += `, states.'group' as state_group`;
|
||||
}
|
||||
sql += ` from issues i
|
||||
`;
|
||||
|
||||
if (otherProps.state_group) {
|
||||
sql += `LEFT JOIN states ON i.state_id = states.id `;
|
||||
}
|
||||
filterJoinFields.forEach((field: string) => {
|
||||
const value = otherProps[field] || "";
|
||||
sql += ` INNER JOIN issue_meta ${field} ON i.id = ${field}.issue_id AND ${field}.key = '${field}' AND ${field}.value IN ('${value.split(",").join("','")}')
|
||||
`;
|
||||
});
|
||||
|
||||
sql += ` WHERE 1=1 `;
|
||||
if (projectId) {
|
||||
sql += ` AND i.project_id = '${projectId}' `;
|
||||
}
|
||||
sql += ` ${singleFilterConstructor(otherProps)} group by i.id `;
|
||||
sql += orderByString;
|
||||
|
||||
// Add offset and paging to query
|
||||
sql += ` LIMIT ${pageSize} OFFSET ${offset * 1 + page * pageSize};`;
|
||||
|
||||
return sql;
|
||||
};
|
||||
|
||||
export const issueFilterCountQueryConstructor = (workspaceSlug: string, projectId: string, queries: any) => {
|
||||
//@todo Very crude way to extract count from the actual query. Needs to be refactored
|
||||
// Remove group by from the query to fallback to non group query
|
||||
const { group_by, sub_group_by, order_by, ...otherProps } = queries;
|
||||
let sql = issueFilterQueryConstructor(workspaceSlug, projectId, otherProps);
|
||||
const fieldsFragment = getIssueFieldsFragment();
|
||||
|
||||
sql = sql.replace(`SELECT ${fieldsFragment}`, "SELECT COUNT(DISTINCT i.id) as total_count");
|
||||
// Remove everything after group by i.id
|
||||
sql = `${sql.split("group by i.id")[0]};`;
|
||||
return sql;
|
||||
};
|
||||
11
apps/web/core/local-db/utils/query-executor.ts
Normal file
11
apps/web/core/local-db/utils/query-executor.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { persistence } from "../storage.sqlite";
|
||||
|
||||
export const runQuery = async (sql: string) => {
|
||||
const data = await persistence.db?.exec({
|
||||
sql,
|
||||
rowMode: "object",
|
||||
returnValue: "resultRows",
|
||||
});
|
||||
|
||||
return data;
|
||||
};
|
||||
38
apps/web/core/local-db/utils/query-sanitizer.ts.ts
Normal file
38
apps/web/core/local-db/utils/query-sanitizer.ts.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
// plane constants
|
||||
import { EUserPermissions } from "@plane/constants";
|
||||
import type { TIssueParams } from "@plane/types";
|
||||
// root store
|
||||
import { rootStore } from "@/lib/store-context";
|
||||
|
||||
export const sanitizeWorkItemQueries = (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
queries: Partial<Record<TIssueParams, string | boolean>> | undefined
|
||||
): Partial<Record<TIssueParams, string | boolean>> | undefined => {
|
||||
// Get current project details and user id and role for the project
|
||||
const currentProject = rootStore.projectRoot.project.getProjectById(projectId);
|
||||
const currentUserId = rootStore.user.data?.id;
|
||||
const currentUserRole = rootStore.user.permission.getProjectRoleByWorkspaceSlugAndProjectId(workspaceSlug, projectId);
|
||||
|
||||
// Only apply this restriction for guests when guest_view_all_features is disabled
|
||||
if (
|
||||
currentUserId &&
|
||||
currentUserRole === EUserPermissions.GUEST &&
|
||||
currentProject?.guest_view_all_features === false
|
||||
) {
|
||||
// Sanitize the created_by filter if it doesn't exist or if it exists and includes the current user id
|
||||
const existingCreatedByFilter = queries?.created_by;
|
||||
const shouldApplyFilter =
|
||||
!existingCreatedByFilter ||
|
||||
(typeof existingCreatedByFilter === "string" && existingCreatedByFilter.includes(currentUserId));
|
||||
|
||||
if (shouldApplyFilter) {
|
||||
queries = {
|
||||
...queries,
|
||||
created_by: currentUserId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return queries;
|
||||
};
|
||||
357
apps/web/core/local-db/utils/query.utils.ts
Normal file
357
apps/web/core/local-db/utils/query.utils.ts
Normal file
@@ -0,0 +1,357 @@
|
||||
import { ARRAY_FIELDS, GROUP_BY_MAP, PRIORITY_MAP } from "./constants";
|
||||
import { SPECIAL_ORDER_BY } from "./query-constructor";
|
||||
import { issueSchema } from "./schemas";
|
||||
import { wrapDateTime } from "./utils";
|
||||
|
||||
export const translateQueryParams = (queries: any) => {
|
||||
const {
|
||||
group_by,
|
||||
layout,
|
||||
sub_group_by,
|
||||
labels,
|
||||
assignees,
|
||||
state,
|
||||
cycle,
|
||||
module,
|
||||
priority,
|
||||
type,
|
||||
issue_type,
|
||||
...otherProps
|
||||
} = queries;
|
||||
|
||||
const order_by = queries.order_by;
|
||||
if (state) otherProps.state_id = state;
|
||||
if (cycle) otherProps.cycle_id = cycle;
|
||||
if (module) otherProps.module_ids = module;
|
||||
if (labels) otherProps.label_ids = labels;
|
||||
if (assignees) otherProps.assignee_ids = assignees;
|
||||
if (group_by) otherProps.group_by = GROUP_BY_MAP[group_by as keyof typeof GROUP_BY_MAP];
|
||||
if (sub_group_by) otherProps.sub_group_by = GROUP_BY_MAP[sub_group_by as keyof typeof GROUP_BY_MAP];
|
||||
if (priority) {
|
||||
otherProps.priority_proxy = priority
|
||||
.split(",")
|
||||
.map((priority: string) => PRIORITY_MAP[priority as keyof typeof PRIORITY_MAP])
|
||||
.join(",");
|
||||
}
|
||||
if (type) {
|
||||
otherProps.state_group = type === "backlog" ? "backlog" : "unstarted,started";
|
||||
}
|
||||
if (issue_type) {
|
||||
otherProps.type_id = issue_type;
|
||||
}
|
||||
|
||||
if (order_by?.includes("priority")) {
|
||||
otherProps.order_by = order_by.replace("priority", "priority_proxy");
|
||||
}
|
||||
|
||||
// Fix invalid orderby when switching from spreadsheet layout
|
||||
if (layout !== "spreadsheet" && Object.keys(SPECIAL_ORDER_BY).includes(order_by)) {
|
||||
otherProps.order_by = "sort_order";
|
||||
}
|
||||
// For each property value, replace None with empty string
|
||||
Object.keys(otherProps).forEach((key) => {
|
||||
if (otherProps[key] === "None") {
|
||||
otherProps[key] = "";
|
||||
}
|
||||
});
|
||||
|
||||
return otherProps;
|
||||
};
|
||||
|
||||
export const getOrderByFragment = (order_by: string, table = "") => {
|
||||
let orderByString = "";
|
||||
if (!order_by) return orderByString;
|
||||
|
||||
if (order_by.startsWith("-")) {
|
||||
orderByString += ` ORDER BY ${wrapDateTime(order_by.slice(1))} DESC NULLS LAST, ${table}sequence_id DESC`;
|
||||
} else {
|
||||
orderByString += ` ORDER BY ${wrapDateTime(order_by)} ASC NULLS LAST, ${table}sequence_id DESC`;
|
||||
}
|
||||
return orderByString;
|
||||
};
|
||||
|
||||
export const isMetaJoinRequired = (groupBy: string, subGroupBy: string) =>
|
||||
ARRAY_FIELDS.includes(groupBy) || ARRAY_FIELDS.includes(subGroupBy);
|
||||
|
||||
export const getMetaKeysFragment = (queries: any) => {
|
||||
const { group_by, sub_group_by, ...otherProps } = translateQueryParams(queries);
|
||||
|
||||
const fields: Set<string> = new Set();
|
||||
if (ARRAY_FIELDS.includes(group_by)) {
|
||||
fields.add(group_by);
|
||||
}
|
||||
|
||||
if (ARRAY_FIELDS.includes(sub_group_by)) {
|
||||
fields.add(sub_group_by);
|
||||
}
|
||||
|
||||
const keys = Object.keys(otherProps);
|
||||
|
||||
keys.forEach((field: string) => {
|
||||
if (ARRAY_FIELDS.includes(field)) {
|
||||
fields.add(field);
|
||||
}
|
||||
});
|
||||
|
||||
const sql = ` ('${Array.from(fields).join("','")}')`;
|
||||
|
||||
return sql;
|
||||
};
|
||||
|
||||
export const getMetaKeys = (queries: any): string[] => {
|
||||
const { group_by, sub_group_by, ...otherProps } = translateQueryParams(queries);
|
||||
|
||||
const fields: Set<string> = new Set();
|
||||
if (ARRAY_FIELDS.includes(group_by)) {
|
||||
fields.add(group_by);
|
||||
}
|
||||
|
||||
if (ARRAY_FIELDS.includes(sub_group_by)) {
|
||||
fields.add(sub_group_by);
|
||||
}
|
||||
|
||||
const keys = Object.keys(otherProps);
|
||||
|
||||
keys.forEach((field: string) => {
|
||||
if (ARRAY_FIELDS.includes(field)) {
|
||||
fields.add(field);
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(fields);
|
||||
};
|
||||
|
||||
const areJoinsRequired = (queries: any) => {
|
||||
const { group_by, sub_group_by, ...otherProps } = translateQueryParams(queries);
|
||||
|
||||
if (ARRAY_FIELDS.includes(group_by) || ARRAY_FIELDS.includes(sub_group_by)) {
|
||||
return true;
|
||||
}
|
||||
if (Object.keys(otherProps).some((field) => ARRAY_FIELDS.includes(field))) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// Apply filters to the query
|
||||
export const getFilteredRowsForGrouping = (projectId: string, queries: any) => {
|
||||
const { group_by, sub_group_by, ...otherProps } = translateQueryParams(queries);
|
||||
|
||||
const filterJoinFields = getMetaKeys(otherProps);
|
||||
|
||||
const temp = getSingleFilterFields(queries);
|
||||
const issueTableFilterFields = temp.length ? "," + temp.join(",") : "";
|
||||
|
||||
const joinsRequired = areJoinsRequired(queries);
|
||||
|
||||
let sql = "";
|
||||
if (!joinsRequired) {
|
||||
sql = `WITH fi as (SELECT i.id,i.created_at, i.sequence_id ${issueTableFilterFields}`;
|
||||
if (group_by) {
|
||||
if (group_by === "target_date") {
|
||||
sql += `, date(i.${group_by}) as group_id`;
|
||||
} else {
|
||||
sql += `, i.${group_by} as group_id`;
|
||||
}
|
||||
}
|
||||
if (sub_group_by) {
|
||||
sql += `, i.${sub_group_by} as sub_group_id`;
|
||||
}
|
||||
sql += ` FROM issues i `;
|
||||
if (otherProps.state_group) {
|
||||
sql += `LEFT JOIN states ON i.state_id = states.id `;
|
||||
}
|
||||
sql += `WHERE 1=1 `;
|
||||
if (projectId) {
|
||||
sql += ` AND i.project_id = '${projectId}'
|
||||
`;
|
||||
}
|
||||
sql += `${singleFilterConstructor(otherProps)})
|
||||
`;
|
||||
return sql;
|
||||
}
|
||||
|
||||
sql = `WITH fi AS (`;
|
||||
sql += `SELECT i.id,i.created_at,i.sequence_id ${issueTableFilterFields} `;
|
||||
if (group_by) {
|
||||
if (ARRAY_FIELDS.includes(group_by)) {
|
||||
sql += `, ${group_by}.value as group_id
|
||||
`;
|
||||
} else if (group_by === "target_date") {
|
||||
sql += `, date(i.${group_by}) as group_id
|
||||
`;
|
||||
} else {
|
||||
sql += `, i.${group_by} as group_id
|
||||
`;
|
||||
}
|
||||
}
|
||||
if (sub_group_by) {
|
||||
if (ARRAY_FIELDS.includes(sub_group_by)) {
|
||||
sql += `, ${sub_group_by}.value as sub_group_id
|
||||
`;
|
||||
} else {
|
||||
sql += `, i.${sub_group_by} as sub_group_id
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
sql += ` from issues i
|
||||
`;
|
||||
if (otherProps.state_group) {
|
||||
sql += `LEFT JOIN states ON i.state_id = states.id `;
|
||||
}
|
||||
filterJoinFields.forEach((field: string) => {
|
||||
sql += ` INNER JOIN issue_meta ${field} ON i.id = ${field}.issue_id AND ${field}.key = '${field}' AND ${field}.value IN ('${otherProps[field].split(",").join("','")}')
|
||||
`;
|
||||
});
|
||||
|
||||
// If group by field is not already joined, join it
|
||||
if (ARRAY_FIELDS.includes(group_by) && !filterJoinFields.includes(group_by)) {
|
||||
sql += ` LEFT JOIN issue_meta ${group_by} ON i.id = ${group_by}.issue_id AND ${group_by}.key = '${group_by}'
|
||||
`;
|
||||
}
|
||||
if (ARRAY_FIELDS.includes(sub_group_by) && !filterJoinFields.includes(sub_group_by)) {
|
||||
sql += ` LEFT JOIN issue_meta ${sub_group_by} ON i.id = ${sub_group_by}.issue_id AND ${sub_group_by}.key = '${sub_group_by}'
|
||||
`;
|
||||
}
|
||||
|
||||
sql += ` WHERE 1=1 `;
|
||||
if (projectId) {
|
||||
sql += ` AND i.project_id = '${projectId}'
|
||||
`;
|
||||
}
|
||||
sql += singleFilterConstructor(otherProps);
|
||||
|
||||
sql += `)
|
||||
`;
|
||||
return sql;
|
||||
};
|
||||
|
||||
export const singleFilterConstructor = (queries: any) => {
|
||||
const {
|
||||
order_by,
|
||||
cursor,
|
||||
per_page,
|
||||
group_by,
|
||||
sub_group_by,
|
||||
state_group,
|
||||
sub_issue,
|
||||
target_date,
|
||||
start_date,
|
||||
...filters
|
||||
} = translateQueryParams(queries);
|
||||
|
||||
let sql = "";
|
||||
if (!sub_issue) {
|
||||
sql += ` AND parent_id IS NULL
|
||||
`;
|
||||
}
|
||||
if (target_date) {
|
||||
sql += createDateFilter("target_date", target_date);
|
||||
}
|
||||
if (start_date) {
|
||||
sql += createDateFilter("start_date", start_date);
|
||||
}
|
||||
if (state_group) {
|
||||
sql += ` AND state_group in ('${state_group.split(",").join("','")}')
|
||||
`;
|
||||
}
|
||||
const keys = Object.keys(filters);
|
||||
|
||||
keys.forEach((key) => {
|
||||
const value = filters[key] ? filters[key].split(",") : "";
|
||||
if (!ARRAY_FIELDS.includes(key)) {
|
||||
if (!value) {
|
||||
sql += ` AND ${key} IS NULL`;
|
||||
return;
|
||||
}
|
||||
sql += ` AND ${key} in ('${value.join("','")}')
|
||||
`;
|
||||
}
|
||||
});
|
||||
//
|
||||
|
||||
return sql;
|
||||
};
|
||||
|
||||
const createDateFilter = (key: string, q: string) => {
|
||||
let sql = " ";
|
||||
// get todays date in YYYY-MM-DD format
|
||||
const queries = q.split(",");
|
||||
const customRange: string[] = [];
|
||||
let isAnd = true;
|
||||
queries.forEach((query: string) => {
|
||||
const [date, type, from] = query.split(";");
|
||||
if (from) {
|
||||
// Assuming type is always after
|
||||
let after = "";
|
||||
const [_length, unit] = date.split("_");
|
||||
const length = parseInt(_length);
|
||||
|
||||
if (unit === "weeks") {
|
||||
// get date in yyyy-mm-dd format one week from now
|
||||
after = new Date(new Date().setDate(new Date().getDate() + length * 7)).toISOString().split("T")[0];
|
||||
}
|
||||
if (unit === "months") {
|
||||
after = new Date(new Date().setDate(new Date().getDate() + length * 30)).toISOString().split("T")[0];
|
||||
}
|
||||
sql += ` ${isAnd ? "AND" : "OR"} ${key} >= date('${after}')`;
|
||||
isAnd = false;
|
||||
// sql += ` AND ${key} ${type === "after" ? ">=" : "<="} date('${date}', '${today}')`;
|
||||
} else {
|
||||
customRange.push(query);
|
||||
}
|
||||
});
|
||||
|
||||
if (customRange.length === 2) {
|
||||
const end = customRange.find((date) => date.includes("before"))?.split(";")[0];
|
||||
const start = customRange.find((date) => date.includes("after"))?.split(";")[0];
|
||||
if (end && start) {
|
||||
sql += ` ${isAnd ? "AND" : "OR"} ${key} BETWEEN date('${start}') AND date('${end}')`;
|
||||
}
|
||||
}
|
||||
if (customRange.length === 1) {
|
||||
sql += ` AND ${key}=date('${customRange[0].split(";")[0]}')`;
|
||||
}
|
||||
|
||||
return sql;
|
||||
};
|
||||
const getSingleFilterFields = (queries: any) => {
|
||||
const { order_by, cursor, per_page, group_by, sub_group_by, sub_issue, state_group, ...otherProps } =
|
||||
translateQueryParams(queries);
|
||||
|
||||
const fields = new Set();
|
||||
|
||||
if (order_by && !order_by.includes("created_at") && !Object.keys(SPECIAL_ORDER_BY).includes(order_by))
|
||||
fields.add(order_by.replace("-", ""));
|
||||
|
||||
const keys = Object.keys(otherProps);
|
||||
|
||||
keys.forEach((field: string) => {
|
||||
if (!ARRAY_FIELDS.includes(field)) {
|
||||
fields.add(field);
|
||||
}
|
||||
});
|
||||
|
||||
if (order_by?.includes("state__name")) {
|
||||
fields.add("state_id");
|
||||
}
|
||||
if (order_by?.includes("cycle__name")) {
|
||||
fields.add("cycle_id");
|
||||
}
|
||||
if (state_group) {
|
||||
fields.add("states.'group' as state_group");
|
||||
}
|
||||
if (order_by?.includes("estimate_point__key")) {
|
||||
fields.add("estimate_point");
|
||||
}
|
||||
return Array.from(fields);
|
||||
};
|
||||
|
||||
export const getIssueFieldsFragment = () => {
|
||||
const { description_html, ...filtered } = issueSchema;
|
||||
const keys = Object.keys(filtered);
|
||||
const sql = ` ${keys.map((key) => `i.${key}`).join(`,
|
||||
`)}`;
|
||||
return sql;
|
||||
};
|
||||
136
apps/web/core/local-db/utils/schemas.ts
Normal file
136
apps/web/core/local-db/utils/schemas.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
export type Schema = {
|
||||
[key: string]: string;
|
||||
};
|
||||
|
||||
export const issueSchema: Schema = {
|
||||
id: "TEXT UNIQUE",
|
||||
name: "TEXT",
|
||||
state_id: "TEXT",
|
||||
sort_order: "REAL",
|
||||
completed_at: "TEXT",
|
||||
estimate_point: "REAL",
|
||||
priority: "TEXT",
|
||||
priority_proxy: "INTEGER",
|
||||
start_date: "TEXT",
|
||||
target_date: "TEXT",
|
||||
sequence_id: "INTEGER",
|
||||
project_id: "TEXT",
|
||||
parent_id: "TEXT",
|
||||
created_at: "TEXT",
|
||||
updated_at: "TEXT",
|
||||
created_by: "TEXT",
|
||||
updated_by: "TEXT",
|
||||
is_draft: "INTEGER",
|
||||
archived_at: "TEXT",
|
||||
state__group: "TEXT",
|
||||
sub_issues_count: "INTEGER",
|
||||
cycle_id: "TEXT",
|
||||
link_count: "INTEGER",
|
||||
attachment_count: "INTEGER",
|
||||
type_id: "TEXT",
|
||||
label_ids: "TEXT",
|
||||
assignee_ids: "TEXT",
|
||||
module_ids: "TEXT",
|
||||
description_html: "TEXT",
|
||||
is_local_update: "INTEGER",
|
||||
};
|
||||
|
||||
export const issueMetaSchema: Schema = {
|
||||
issue_id: "TEXT",
|
||||
key: "TEXT",
|
||||
value: "TEXT",
|
||||
};
|
||||
export const moduleSchema: Schema = {
|
||||
id: "TEXT UNIQUE",
|
||||
workspace_id: "TEXT",
|
||||
project_id: "TEXT",
|
||||
name: "TEXT",
|
||||
description: "TEXT",
|
||||
description_text: "TEXT",
|
||||
description_html: "TEXT",
|
||||
start_date: "TEXT",
|
||||
target_date: "TEXT",
|
||||
status: "TEXT",
|
||||
lead_id: "TEXT",
|
||||
member_ids: "TEXT",
|
||||
view_props: "TEXT",
|
||||
sort_order: "INTEGER",
|
||||
external_source: "TEXT",
|
||||
external_id: "TEXT",
|
||||
logo_props: "TEXT",
|
||||
total_issues: "INTEGER",
|
||||
cancelled_issues: "INTEGER",
|
||||
completed_issues: "INTEGER",
|
||||
started_issues: "INTEGER",
|
||||
unstarted_issues: "INTEGER",
|
||||
backlog_issues: "INTEGER",
|
||||
created_at: "TEXT",
|
||||
updated_at: "TEXT",
|
||||
archived_at: "TEXT",
|
||||
};
|
||||
|
||||
export const labelSchema: Schema = {
|
||||
id: "TEXT UNIQUE",
|
||||
name: "TEXT",
|
||||
color: "TEXT",
|
||||
parent: "TEXT",
|
||||
project_id: "TEXT",
|
||||
workspace_id: "TEXT",
|
||||
sort_order: "INTEGER",
|
||||
};
|
||||
|
||||
export const cycleSchema: Schema = {
|
||||
id: "TEXT UNIQUE",
|
||||
workspace_id: "TEXT",
|
||||
project_id: "TEXT",
|
||||
name: "TEXT",
|
||||
description: "TEXT",
|
||||
start_date: "TEXT",
|
||||
end_date: "TEXT",
|
||||
owned_by_id: "TEXT",
|
||||
view_props: "TEXT",
|
||||
sort_order: "INTEGER",
|
||||
external_source: "TEXT",
|
||||
external_id: "TEXT",
|
||||
progress_snapshot: "TEXT",
|
||||
logo_props: "TEXT",
|
||||
total_issues: "INTEGER",
|
||||
cancelled_issues: "INTEGER",
|
||||
completed_issues: "INTEGER",
|
||||
started_issues: "INTEGER",
|
||||
unstarted_issues: "INTEGER",
|
||||
backlog_issues: "INTEGER",
|
||||
};
|
||||
|
||||
export const stateSchema: Schema = {
|
||||
id: "TEXT UNIQUE",
|
||||
project_id: "TEXT",
|
||||
workspace_id: "TEXT",
|
||||
name: "TEXT",
|
||||
color: "TEXT",
|
||||
group: "TEXT",
|
||||
default: "BOOLEAN",
|
||||
description: "TEXT",
|
||||
sequence: "INTEGER",
|
||||
};
|
||||
|
||||
export const estimatePointSchema: Schema = {
|
||||
id: "TEXT UNIQUE",
|
||||
key: "TEXT",
|
||||
value: "REAL",
|
||||
};
|
||||
|
||||
export const memberSchema: Schema = {
|
||||
id: "TEXT UNIQUE",
|
||||
first_name: "TEXT",
|
||||
last_name: "TEXT",
|
||||
avatar: "TEXT",
|
||||
is_bot: "BOOLEAN",
|
||||
display_name: "TEXT",
|
||||
email: "TEXT",
|
||||
};
|
||||
|
||||
export const optionsSchema: Schema = {
|
||||
key: "TEXT UNIQUE",
|
||||
value: "TEXT",
|
||||
};
|
||||
41
apps/web/core/local-db/utils/tables.ts
Normal file
41
apps/web/core/local-db/utils/tables.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { persistence } from "../storage.sqlite";
|
||||
import type { Schema } from "./schemas";
|
||||
import {
|
||||
labelSchema,
|
||||
moduleSchema,
|
||||
issueMetaSchema,
|
||||
issueSchema,
|
||||
stateSchema,
|
||||
cycleSchema,
|
||||
estimatePointSchema,
|
||||
memberSchema,
|
||||
optionsSchema,
|
||||
} from "./schemas";
|
||||
import { log } from "./utils";
|
||||
|
||||
const createTableSQLfromSchema = (tableName: string, schema: Schema) => {
|
||||
let sql = `CREATE TABLE IF NOT EXISTS ${tableName} (`;
|
||||
sql += Object.keys(schema)
|
||||
.map((key) => `'${key}' ${schema[key]}`)
|
||||
.join(", ");
|
||||
sql += `);`;
|
||||
log("#####", sql);
|
||||
return sql;
|
||||
};
|
||||
|
||||
export const createTables = async () => {
|
||||
//@todo use promise.all or send all statements in one go
|
||||
await persistence.db.exec("BEGIN;");
|
||||
|
||||
await persistence.db.exec(createTableSQLfromSchema("issues", issueSchema));
|
||||
await persistence.db.exec(createTableSQLfromSchema("issue_meta", issueMetaSchema));
|
||||
await persistence.db.exec(createTableSQLfromSchema("modules", moduleSchema));
|
||||
await persistence.db.exec(createTableSQLfromSchema("labels", labelSchema));
|
||||
await persistence.db.exec(createTableSQLfromSchema("states", stateSchema));
|
||||
await persistence.db.exec(createTableSQLfromSchema("cycles", cycleSchema));
|
||||
await persistence.db.exec(createTableSQLfromSchema("estimate_points", estimatePointSchema));
|
||||
await persistence.db.exec(createTableSQLfromSchema("members", memberSchema));
|
||||
await persistence.db.exec(createTableSQLfromSchema("options", optionsSchema));
|
||||
|
||||
await persistence.db.exec("COMMIT;");
|
||||
};
|
||||
206
apps/web/core/local-db/utils/utils.ts
Normal file
206
apps/web/core/local-db/utils/utils.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import { pick } from "lodash-es";
|
||||
import type { TIssue } from "@plane/types";
|
||||
import { rootStore } from "@/lib/store-context";
|
||||
import { persistence } from "../storage.sqlite";
|
||||
import { updateIssue } from "./load-issues";
|
||||
|
||||
export const log = (...args: any) => {
|
||||
if ((window as any).DEBUG) {
|
||||
console.log(...args);
|
||||
}
|
||||
};
|
||||
export const logError = (e: any) => {
|
||||
if (e?.result?.errorClass === "SQLite3Error") {
|
||||
e = parseSQLite3Error(e);
|
||||
}
|
||||
console.error(e);
|
||||
};
|
||||
export const logInfo = console.info;
|
||||
|
||||
export const addIssueToPersistanceLayer = async (issue: TIssue) => {
|
||||
try {
|
||||
const issuePartial = pick({ ...JSON.parse(JSON.stringify(issue)) }, [
|
||||
"id",
|
||||
"name",
|
||||
"state_id",
|
||||
"sort_order",
|
||||
"completed_at",
|
||||
"estimate_point",
|
||||
"priority",
|
||||
"start_date",
|
||||
"target_date",
|
||||
"sequence_id",
|
||||
"project_id",
|
||||
"parent_id",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"created_by",
|
||||
"updated_by",
|
||||
"is_draft",
|
||||
"archived_at",
|
||||
"state__group",
|
||||
"cycle_id",
|
||||
"link_count",
|
||||
"attachment_count",
|
||||
"sub_issues_count",
|
||||
"assignee_ids",
|
||||
"label_ids",
|
||||
"module_ids",
|
||||
"type_id",
|
||||
"description_html",
|
||||
]);
|
||||
await updateIssue({ ...issuePartial, is_local_update: 1 });
|
||||
} catch (e) {
|
||||
logError("Error while adding issue to db");
|
||||
}
|
||||
};
|
||||
|
||||
export const updatePersistentLayer = async (issueIds: string | string[]) => {
|
||||
if (typeof issueIds === "string") {
|
||||
issueIds = [issueIds];
|
||||
}
|
||||
issueIds.forEach(async (issueId) => {
|
||||
const dbIssue = await persistence.getIssue(issueId);
|
||||
const issue = rootStore.issue.issues.getIssueById(issueId);
|
||||
const updatedIssue = dbIssue ? { ...dbIssue, ...issue } : issue;
|
||||
|
||||
if (updatedIssue) {
|
||||
addIssueToPersistanceLayer(updatedIssue);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const wrapDateTime = (field: string) => {
|
||||
const DATE_TIME_FIELDS = ["created_at", "updated_at", "completed_at", "start_date", "target_date"];
|
||||
|
||||
if (DATE_TIME_FIELDS.includes(field)) {
|
||||
return `datetime(${field})`;
|
||||
}
|
||||
return field;
|
||||
};
|
||||
|
||||
export const getGroupedIssueResults = (issueResults: (TIssue & { group_id?: string; total_issues: number })[]): any => {
|
||||
const groupedResults: {
|
||||
[key: string]: {
|
||||
results: TIssue[];
|
||||
total_results: number;
|
||||
};
|
||||
} = {};
|
||||
|
||||
for (const issue of issueResults) {
|
||||
const { group_id, total_issues } = issue;
|
||||
const groupId = group_id ? group_id : "None";
|
||||
if (groupedResults?.[groupId] !== undefined && Array.isArray(groupedResults?.[groupId]?.results)) {
|
||||
groupedResults?.[groupId]?.results.push(issue);
|
||||
} else {
|
||||
groupedResults[groupId] = { results: [issue], total_results: total_issues };
|
||||
}
|
||||
}
|
||||
|
||||
return groupedResults;
|
||||
};
|
||||
|
||||
export const getSubGroupedIssueResults = (
|
||||
issueResults: (TIssue & { group_id?: string; total_issues: number; sub_group_id?: string })[]
|
||||
): any => {
|
||||
const subGroupedResults: {
|
||||
[key: string]: {
|
||||
results: {
|
||||
[key: string]: {
|
||||
results: TIssue[];
|
||||
total_results: number;
|
||||
};
|
||||
};
|
||||
total_results: number;
|
||||
};
|
||||
} = {};
|
||||
|
||||
for (const issue of issueResults) {
|
||||
const { group_id, total_issues, sub_group_id } = issue;
|
||||
const groupId = group_id ? group_id : "None";
|
||||
const subGroupId = sub_group_id ? sub_group_id : "None";
|
||||
|
||||
if (subGroupedResults?.[groupId] === undefined) {
|
||||
subGroupedResults[groupId] = { results: {}, total_results: 0 };
|
||||
}
|
||||
|
||||
if (
|
||||
subGroupedResults[groupId].results[subGroupId] !== undefined &&
|
||||
Array.isArray(subGroupedResults[groupId].results[subGroupId]?.results)
|
||||
) {
|
||||
subGroupedResults[groupId].results[subGroupId]?.results.push(issue);
|
||||
} else {
|
||||
subGroupedResults[groupId].results[subGroupId] = { results: [issue], total_results: total_issues };
|
||||
}
|
||||
}
|
||||
|
||||
const groupByKeys = Object.keys(subGroupedResults);
|
||||
|
||||
for (const groupByKey of groupByKeys) {
|
||||
let totalIssues = 0;
|
||||
const groupedResults = subGroupedResults[groupByKey]?.results ?? {};
|
||||
const subGroupByKeys = Object.keys(groupedResults);
|
||||
|
||||
for (const subGroupByKey of subGroupByKeys) {
|
||||
const subGroupedResultsCount = groupedResults[subGroupByKey].total_results ?? 0;
|
||||
totalIssues += subGroupedResultsCount;
|
||||
}
|
||||
|
||||
subGroupedResults[groupByKey].total_results = totalIssues;
|
||||
}
|
||||
|
||||
return subGroupedResults;
|
||||
};
|
||||
|
||||
export const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
const parseSQLite3Error = (error: any) => {
|
||||
error.result = JSON.stringify(error.result);
|
||||
return error;
|
||||
};
|
||||
|
||||
export const isChrome = () => {
|
||||
const userAgent = navigator.userAgent;
|
||||
return userAgent.includes("Chrome") && !userAgent.includes("Edg") && !userAgent.includes("OPR");
|
||||
};
|
||||
|
||||
export const clearOPFS = async (force = false) => {
|
||||
const storageManager = window.navigator.storage;
|
||||
const root = await storageManager.getDirectory();
|
||||
|
||||
if (force && isChrome()) {
|
||||
await (root as any).remove({ recursive: true });
|
||||
return;
|
||||
}
|
||||
// ts-ignore
|
||||
for await (const entry of (root as any)?.values()) {
|
||||
if (entry.kind === "directory" && entry.name.startsWith(".ahp-")) {
|
||||
// A lock with the same name as the directory protects it from
|
||||
// being deleted.
|
||||
|
||||
if (force) {
|
||||
// don't wait for the lock
|
||||
try {
|
||||
await root.removeEntry(entry.name, { recursive: true });
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
} else {
|
||||
await navigator.locks.request(entry.name, { ifAvailable: true }, async (lock) => {
|
||||
if (lock) {
|
||||
log?.(`Deleting temporary directory ${entry.name}`);
|
||||
try {
|
||||
await root.removeEntry(entry.name, { recursive: true });
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
} else {
|
||||
log?.(`Temporary directory ${entry.name} is in use`);
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
root.removeEntry(entry.name);
|
||||
}
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user