Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat auto fill note #13

Merged
merged 9 commits into from
Apr 8, 2024
53 changes: 53 additions & 0 deletions src/app/content/ankiWeb/helpers/fillForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { INote } from '@entities/note'

export function fillForm(note: INote) {
const ankiFaceElement = document.querySelector<HTMLDivElement>(
'body > div > main > form > div:nth-child(1) > div > div',
)
const ankiBackElement = document.querySelector<HTMLDivElement>(
'body > div > main > form > div:nth-child(2) > div > div',
)

if (ankiFaceElement) {
ankiFaceElement.innerText = getFrontText(ankiFaceElement.innerText, note)
ankiFaceElement.dispatchEvent(new Event('input', { bubbles: true }))
}

if (ankiBackElement) {
ankiBackElement.innerText = getBackText(ankiBackElement.innerText, note)
ankiBackElement.dispatchEvent(new Event('input', { bubbles: true }))
}
}

function getFrontText(initialText: string, note: INote) {
return (
initialText +
' ' +
note.text +
(note.transcription ? '\n' + note.transcription : '') +
(note.context ? '\n' + note.context : '')
)
}

function getBackText(initialText: string, note: INote) {
const blankedHintText = getBlankedHintText(note.text)
return (
initialText +
' ' +
(note.translation ? note.translation : '') +
(blankedHintText ? '\n' + blankedHintText : '')
)
}

function getBlankedHintText(text: string) {
return text
.split(' ')
.map(
(word) =>
word[0] +
Array(word.length - 1)
.fill(' _')
.join(''),
)
.join(' ')
}
9 changes: 9 additions & 0 deletions src/app/content/ankiWeb/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,10 @@
import { autoFillFormHandler } from '@features/note/FillFlashcardForm'

import { browser } from '@shared/browser'

import { fillForm } from './helpers/fillForm'

console.log('anki web content code')
browser.runtime.onConnect.addListener(function (port) {
autoFillFormHandler(port, fillForm)
})
18 changes: 18 additions & 0 deletions src/entities/tab/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { atom, onMount, task } from 'nanostores'

import { ITab, browser } from '@shared/browser'
import { addToast, getErrorToast } from '@shared/ui/Toast'

export const $activeTab = atom<ITab | null>(null)

onMount($activeTab, () => {
task(async () => {
const getResult = await browser.tabs.getActiveTab()

if (getResult.data) {
$activeTab.set(getResult.data)
} else {
addToast(getErrorToast(getResult.error))
}
})
})
44 changes: 44 additions & 0 deletions src/features/note/FillFlashcardForm/api/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { INote } from '@entities/note'

import { PortEmitter, PortReceiver } from '@shared/browser'

interface Request {
message: 'FILL_FORM'
data: INote
}

interface Response {
message: 'FILL_FORM_RESULT'
data: INote
}

export const autoFillForm = (port: PortEmitter, note: INote) => {
port.postMessage<Request>({ message: 'FILL_FORM', data: note })
}

export const autoFillFormHandler = (
port: PortReceiver,
callback: (note: INote) => void,
) => {
port.onMessage<Request>((request) => {
if (request.message === 'FILL_FORM') {
callback(request.data)

port.postMessage<Response>({
message: 'FILL_FORM_RESULT',
data: request.data,
})
}
})
}

export const autoFillFormResult = (
port: PortEmitter,
callback: (response: Response['data']) => void,
) => {
port.onMessage<Response>((response) => {
if (response.message === 'FILL_FORM_RESULT') {
callback(response.data)
}
})
}
3 changes: 3 additions & 0 deletions src/features/note/FillFlashcardForm/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from './api'
export * from './ui'
export * from './model'
1 change: 1 addition & 0 deletions src/features/note/FillFlashcardForm/model/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { $ankiPort } from './store'
25 changes: 25 additions & 0 deletions src/features/note/FillFlashcardForm/model/store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { computed } from 'nanostores'

import { $activeTab } from '@entities/tab'

import { PortEmitter } from '@shared/browser'

export const $ankiPort = computed(
$activeTab,
(activeTab): PortEmitter | null => {
if (!activeTab?.id) return null
const isAnkiTab = activeTab
? activeTab.url === 'https://ankiuser.net/add'
: false

if (isAnkiTab) {
const port = new PortEmitter({
tabId: activeTab.id,
connectInfo: { name: 'anki' },
})

return port
}
return null
},
)
53 changes: 53 additions & 0 deletions src/features/note/FillFlashcardForm/ui/FillFlashcardForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { useStore } from '@nanostores/preact'
import { useEffect, useState } from 'preact/hooks'

import { INote } from '@entities/note'

import { browser } from '@shared/browser'
import { useResetAfterDelay } from '@shared/libs/useResetAfterDelay'
import { Button } from '@shared/ui/Button'
import { AddCardIcon } from '@shared/ui/icons/AddCardIcon'
import { DoneIcon } from '@shared/ui/icons/DoneIcon'

import { autoFillForm, autoFillFormResult } from '../api'
import { $ankiPort } from '../model/store'

interface Props {
note: INote
}

export const FillFlashcardForm = ({ note }: Props) => {
const ankiPort = useStore($ankiPort)
const [isFormFilled, setIsFormFilled] = useState(false)
const resetAfterDelay = useResetAfterDelay({
reset: () => setIsFormFilled(false),
})

useEffect(() => {
ankiPort &&
autoFillFormResult(ankiPort, (data) => {
if (data.id === note.id) {
setIsFormFilled(true)
resetAfterDelay()
}
})
}, [ankiPort])

const fillAnkiForm = () => {
ankiPort && autoFillForm(ankiPort, note)
}

return (
<Button
variant="secondary"
startIcon={isFormFilled ? <DoneIcon /> : <AddCardIcon />}
aria-label={
isFormFilled
? 'Form filled'
: browser.i18n.getMessage('FILL_ANKI_BUTTON', note.text)
}
onClick={fillAnkiForm}
disabled={!ankiPort}
/>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const FillFlashcardFormNotAvailable = () => (
<span>Filling anki available only on anki page</span>
)
2 changes: 2 additions & 0 deletions src/features/note/FillFlashcardForm/ui/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { FillFlashcardForm } from './FillFlashcardForm'
export { FillFlashcardFormNotAvailable } from './FillFlashcardFormNotAvailable'
34 changes: 34 additions & 0 deletions src/shared/browser/chrome.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Result } from '@shared/libs/operationResult'

import { PortReceiver } from './port'
import { IBrowser } from './types'

export const chromeBrowser: IBrowser = {
Expand Down Expand Up @@ -87,4 +88,37 @@ export const chromeBrowser: IBrowser = {
return chrome.i18n.getMessage(key, substitutions)
},
},

runtime: {
onConnect: {
addListener: (callback) => {
chrome.runtime.onConnect.addListener((port) => {
callback(new PortReceiver(port))
})
},
},
},

tabs: {
getActiveTab: () => {
return new Promise((resolve) => {
chrome.tabs.query(
{ active: true, currentWindow: true },
function (tabs) {
const tab = tabs[0]
if (tab?.id && tab?.url) {
resolve(Result.Success({ id: tab.id, url: tab.url }))
} else {
resolve(
Result.Error({
type: 'ERROR_CAN_NOT_GET_ACTIVE_TAB',
error: null,
}),
)
}
},
)
})
},
},
}
2 changes: 2 additions & 0 deletions src/shared/browser/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,5 @@ import { chromeBrowser } from './chrome'
import { IBrowser } from './types'

export const browser: IBrowser = chromeBrowser
export type { ITab } from './types'
export * from './port'
79 changes: 79 additions & 0 deletions src/shared/browser/port/chromePort.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { IConnectionProps, IPort } from './types'

export class ChromePortEmitter implements IPort {
name: string

private browserPort: chrome.runtime.Port | null

constructor(readonly connectionProps: IConnectionProps) {
this.browserPort = this.connect()
this.name = this.browserPort?.name ?? ''
}

private connect() {
const browserPort = chrome.tabs.connect(
this.connectionProps.tabId,
this.connectionProps.connectInfo,
)

browserPort.onDisconnect.addListener(() => {
this.browserPort = null
})

return browserPort
}

postMessage<T>(msg: T) {
if (!this.browserPort) {
this.browserPort = this.connect()
}

this.browserPort.postMessage(msg)
}

onMessage<T>(callback: (msg: T) => void) {
if (!this.browserPort) {
this.browserPort = this.connect()
}

this.browserPort.onMessage.addListener((msg) => {
callback(msg)
})
}

onDisconnect(callback: () => void) {
this.browserPort?.onDisconnect.addListener(() => {
callback()
})
}
}

export class ChromePortReceiver implements IPort {
name: string
private browserPort: chrome.runtime.Port | null

constructor(receiver: chrome.runtime.Port) {
this.name = receiver.name
this.browserPort = receiver

this.browserPort.onDisconnect.addListener(() => {
this.browserPort = null
})
}

postMessage<T>(msg: T) {
this.browserPort?.postMessage(msg)
}

onMessage<T>(callback: (msg: T) => void) {
this.browserPort?.onMessage.addListener((msg) => {
callback(msg)
})
}

onDisconnect(callback: () => void) {
this.browserPort?.onDisconnect.addListener(() => {
callback()
})
}
}
4 changes: 4 additions & 0 deletions src/shared/browser/port/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export {
ChromePortEmitter as PortEmitter,
ChromePortReceiver as PortReceiver,
} from './chromePort'
11 changes: 11 additions & 0 deletions src/shared/browser/port/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export interface IConnectionProps {
tabId: number
connectInfo: { name: string }
}

export interface IPort {
name: string
onMessage: <T>(callback: (msg: T) => void) => void
postMessage: <T>(_msg: T) => void
onDisconnect: (callback: () => void) => void
}
15 changes: 15 additions & 0 deletions src/shared/browser/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import type { TResult } from '@shared/libs/operationResult'

import { PortReceiver } from '.'

export interface IBrowser {
storage: {
local: {
Expand All @@ -23,4 +25,17 @@ export interface IBrowser {
i18n: {
getMessage: (key: string, substitutions?: string | string[]) => string
}

runtime: {
onConnect: {
addListener: (callback: (port: PortReceiver) => void) => void
}
}

tabs: {
getActiveTab: () => Promise<TResult<ITab>>
}
}

export type ITab = { id: number; url: string }
export type IActiveTabInfo = { tabId: number }
Loading
Loading