Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 17 additions & 4 deletions src/adapters/chrome/background/PresageEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,16 @@ export interface PresageEngineConfig {
}

export class PresageEngine {
private readonly Module: PresageModule;
private readonly lang: string;
public libPresage: Presage;
private libPresageCallback: PresageCallback;
private libPresageCallbackImpl: unknown = {};
private config: PresageEngineConfig;

constructor(Module: PresageModule, config: PresageEngineConfig, lang: string) {
this.Module = Module;
this.lang = lang;
this.config = config;

this.libPresageCallback = {
Expand All @@ -29,10 +33,7 @@ export class PresageEngine {
},
};
this.libPresageCallbackImpl = Module.PresageCallback.implement(this.libPresageCallback);
this.libPresage = new Module.Presage(
this.libPresageCallbackImpl,
`resources_js/${lang}/presage.xml`,
);
this.libPresage = this.createLibPresage();
this.setConfig(config);
}

Expand All @@ -41,6 +42,11 @@ export class PresageEngine {
this.libPresage.config("Presage.Selector.SUGGESTIONS", this.config.numSuggestions.toString());
}

reinitialize(): void {
this.libPresage = this.createLibPresage();
this.setConfig(this.config);
}

predict(predictionInput: string): string[] {
this.libPresageCallback.pastStream = predictionInput;
const predictions: string[] = [];
Expand All @@ -59,4 +65,11 @@ export class PresageEngine {
}
return predictions;
}

private createLibPresage(): Presage {
return new this.Module.Presage(
this.libPresageCallbackImpl,
`resources_js/${this.lang}/presage.xml`,
);
}
}
27 changes: 27 additions & 0 deletions src/adapters/chrome/background/PresageHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ export class PresageHandler {
private timeFormat?: string;
private dateFormat?: string;
private engineNumSuggestions: number;
private textExpansionsSignature = "";
private userDictionarySignature = "";

constructor(Module: PresageModule) {
const engineConfig: PresageEngineConfig = {
Expand Down Expand Up @@ -102,6 +104,12 @@ export class PresageHandler {
}

setConfig(config: PresageConfig): void {
const textExpansionsSignature = JSON.stringify(config.textExpansions ?? []);
const userDictionarySignature = JSON.stringify(config.userDictionaryList ?? []);
const shouldRefreshEngines =
textExpansionsSignature !== this.textExpansionsSignature ||
userDictionarySignature !== this.userDictionarySignature;

this.numSuggestions = config.numSuggestions;
this.engineNumSuggestions = Math.min(
MAX_NUM_SUGGESTIONS,
Expand All @@ -116,6 +124,13 @@ export class PresageHandler {
this.dateFormat = config.dateFormat;
this.userDictionaryList = config.userDictionaryList || [];

if (shouldRefreshEngines) {
this.refreshPresageEngines();
this.resetLastPredictionState();
this.textExpansionsSignature = textExpansionsSignature;
this.userDictionarySignature = userDictionarySignature;
}

this.textExpansionManager.setTextExpansions(config.textExpansions);
this.userDictionaryManager.setUserDictionaryList(this.userDictionaryList);

Expand Down Expand Up @@ -360,4 +375,16 @@ export class PresageHandler {
}
return "";
}

private refreshPresageEngines(): void {
for (const presageEngine of Object.values(this.presageEngines)) {
presageEngine.reinitialize();
}
}

private resetLastPredictionState(): void {
for (const lang of Object.keys(this.presageEngines)) {
this.lastPrediction[lang] = { pastStream: "", templates: [] };
}
}
}
29 changes: 13 additions & 16 deletions src/ui/options/TextAssetsPanel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ export class TextAssetsPanel {
const imported = parsed
.filter((row) => row.length === 2)
.map((row) => [toTextValue(row[0]), toTextValue(row[1])] as TextExpansionEntry);
this.syncPersistedRows(this.mergeExpansions(imported, this.getPersistedExpansions()));
this.syncPersistedRows(this.mergeExpansions(this.getPersistedExpansions(), imported));
this.setSnippetStatus(i18n.get("settings_status_saved"));
this.persistSnippetRows();
});
Expand Down Expand Up @@ -354,15 +354,6 @@ export class TextAssetsPanel {
if (!nextEntry[0]) {
return;
}
const duplicateRow = this.snippetRows.find(
(row) => row.id !== targetRow.id && row.shortcut.trim() === nextEntry[0],
);
if (duplicateRow) {
shortcut.setCustomValidity(i18n.get("text_assets_duplicate_shortcut"));
shortcut.reportValidity();
updateSnippetStatus(i18n.get("text_assets_duplicate_shortcut"), true);
return;
}
targetRow.shortcut = nextEntry[0];
targetRow.text = nextEntry[1];
targetRow.savedShortcut = nextEntry[0];
Expand Down Expand Up @@ -734,16 +725,22 @@ export class TextAssetsPanel {
}

private mergeExpansions(
imported: TextExpansionEntry[],
existing: TextExpansionEntry[],
imported: TextExpansionEntry[],
): TextExpansionEntry[] {
const merged = new Map<string, string>();
[...existing, ...imported].forEach(([shortcut, text]) => {
if (shortcut.trim()) {
merged.set(shortcut.trim(), text);
const seen = new Set<string>();
return [...existing, ...imported].flatMap(([shortcut, text]) => {
const normalizedShortcut = shortcut.trim();
if (!normalizedShortcut) {
return [];
}
const signature = JSON.stringify([normalizedShortcut, text]);
if (seen.has(signature)) {
return [];
}
seen.add(signature);
return [[normalizedShortcut, text]] as TextExpansionEntry[];
});
return Array.from(merged.entries());
}

private getSelectedSnippet(): SnippetRow | null {
Expand Down
51 changes: 51 additions & 0 deletions tests/PresageHandler.live.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { readFileSync } from "node:fs";
import libPresageMod from "../src/third_party/libpresage/libpresage.js";
import { PresageHandler } from "../src/adapters/chrome/background/PresageHandler";

function createLiveConfig(textExpansions: Array<[string, string]>) {
return {
numSuggestions: 5,
engineNumSuggestions: 10,
minWordLengthToPredict: 0,
insertSpaceAfterAutocomplete: true,
autoCapitalize: false,
textExpansions,
timeFormat: "",
dateFormat: "",
userDictionaryList: [],
};
}

async function createLiveHandler(): Promise<PresageHandler> {
const root = process.cwd();
const Module = await libPresageMod({
wasmBinary: readFileSync(`${root}/src/third_party/libpresage/libpresage.wasm`),
locateFile: (name: string) => `${root}/public/third_party/libpresage/${name}`,
});
return new PresageHandler(Module);
}

describe("PresageHandler live text expansion config refresh", () => {
test("refreshes duplicate text expansions after runtime config changes", async () => {
const handler = await createLiveHandler();

handler.setConfig(createLiveConfig([["asap", "as soon as possible"]]));
await expect(handler.runPrediction("asap", "", "textExpander")).resolves.toEqual({
predictions: ["as soon as possible "],
});

handler.setConfig(
createLiveConfig([
["asap", "as soon as possible"],
["asap", "at some available point"],
]),
);

const refreshed = await handler.runPrediction("asap", "", "textExpander");

expect(refreshed.predictions).toHaveLength(2);
expect(refreshed.predictions).toEqual(
expect.arrayContaining(["as soon as possible ", "at some available point "]),
);
});
});
83 changes: 78 additions & 5 deletions tests/TextAssetsPanel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
} from "../src/core/domain/constants";

type SettingsMap = Record<string, unknown>;
type FileReaderCtor = typeof FileReader;

class MockControl {
private readonly handlers: Record<string, Array<(value: unknown) => void>> = {};
Expand Down Expand Up @@ -100,7 +101,7 @@ describe("TextAssetsPanel", () => {
Settings.now = () => Date.now();
});

test("prevents duplicate snippet shortcuts and shows an inline warning", async () => {
test("allows saving multiple snippets with the same shortcut", async () => {
const values: SettingsMap = {
[KEY_TEXT_EXPANSIONS]: [["brb", "be right back"]],
[KEY_USER_DICTIONARY_LIST]: [],
Expand All @@ -125,11 +126,83 @@ describe("TextAssetsPanel", () => {
bodyInput.dispatchEvent(new Event("input", { bubbles: true }));

findButtonByText(root, i18n.get("text_assets_save_snippet")).click();
await flushAsyncWork();

expect(values[KEY_TEXT_EXPANSIONS]).toEqual([
["brb", "be right there"],
["brb", "be right back"],
]);

const snippetRows = root.querySelectorAll<HTMLButtonElement>(".text-assets-list-item");
expect(snippetRows).toHaveLength(2);
snippetRows[0].click();
expect((root.querySelector(".text-assets-editor textarea") as HTMLTextAreaElement).value).toBe(
"be right there",
);
snippetRows[1].click();
expect((root.querySelector(".text-assets-editor textarea") as HTMLTextAreaElement).value).toBe(
"be right back",
);
});

expect(root.textContent).toContain(i18n.get("text_assets_duplicate_shortcut"));
expect(values[KEY_TEXT_EXPANSIONS]).toEqual([["brb", "be right back"]]);
expect(shortcutInput.value).toBe("brb");
expect(bodyInput.value).toBe("be right there");
test("csv import deduplicates exact snippet pairs while keeping same-shortcut variants", async () => {
const values: SettingsMap = {
[KEY_TEXT_EXPANSIONS]: [["brb", "be right back"]],
[KEY_USER_DICTIONARY_LIST]: [],
[KEY_DATE_FORMAT]: "",
[KEY_TIME_FORMAT]: "",
};
const store = createStore(values);
const registry = createRegistry(values);
const root = document.createElement("div");
document.body.appendChild(root);

const originalFileReader = globalThis.FileReader as FileReaderCtor;
class MockFileReader {
public result: string | ArrayBuffer | null = null;
private readonly handlers: Record<string, Array<() => void>> = {};

addEventListener(type: string, handler: () => void): void {
this.handlers[type] = [...(this.handlers[type] || []), handler];
}

readAsText(): void {
this.result = "brb,be right back\nsig,first import\nsig,first import\nsig,second import";
for (const handler of this.handlers.load || []) {
handler();
}
}
}
Object.assign(globalThis, {
FileReader: MockFileReader as unknown as FileReaderCtor,
});

try {
new TextAssetsPanel(root, registry, store);
await flushAsyncWork();

const importInput = root.querySelector<HTMLInputElement>('input[type="file"][accept=".csv"]');
expect(importInput).not.toBeNull();
Object.defineProperty(importInput!, "files", {
configurable: true,
value: [new File(["ignored"], "snippets.csv", { type: "text/csv" })],
});

importInput!.dispatchEvent(new Event("input", { bubbles: true }));
await flushAsyncWork();
importInput!.dispatchEvent(new Event("input", { bubbles: true }));
await flushAsyncWork();

expect(values[KEY_TEXT_EXPANSIONS]).toEqual([
["brb", "be right back"],
["sig", "first import"],
["sig", "second import"],
]);
} finally {
Object.assign(globalThis, {
FileReader: originalFileReader,
});
}
});

test("keeps multiple unsaved snippet drafts independently editable", async () => {
Expand Down
42 changes: 42 additions & 0 deletions tests/e2e/smoke.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1471,6 +1471,48 @@ describeE2E(`E2E Smoke [${BROWSER_TYPE}]`, () => {
suiteTimeout(10000, 15000),
);

test(
"text expansion popup shows duplicate shortcut entries and accepts a non-default selection",
async () => {
await setSettingAndWait(worker, KEY_ENABLED_LANGUAGES, ["textExpander"]);
await setSettingAndWait(worker, KEY_LANGUAGE, "textExpander");
await setSettingAndWait(worker, KEY_TEXT_EXPANSIONS, [
["asap", "as soon as possible"],
["asap", "at some available point"],
]);
await sendConfigChange(browser, worker);

page = await prepareReusableTestPage(browser, page);

const element = await page.$("#test-input");
await element!.type("asap");

const suggestions = await waitForSuggestionTexts(page);
expect(suggestions).toHaveLength(2);
expect(
suggestions.some((suggestion) => /^as soon as possible[ \xa0]$/i.test(suggestion)),
).toBe(true);
expect(
suggestions.some((suggestion) => /^at some available point[ \xa0]$/i.test(suggestion)),
).toBe(true);

const selectedSuggestion = suggestions[1];
await page.keyboard.press("ArrowDown");
await page.keyboard.press("Tab");

const value = await waitUntil(
"selected duplicate text expansion to be accepted",
async () => {
const current = await page.$eval("#test-input", (el) => (el as HTMLInputElement).value);
return current === selectedSuggestion ? current : false;
},
{ timeoutMs: timeoutProfile.suggestionMs, intervalMs: 50 },
);
expect(value).toBe(selectedSuggestion);
},
suiteTimeout(10000, 15000),
);

test(
"options config change command updates grammar rules in runtime storage",
async () => {
Expand Down
Loading