Skip to content

Commit

Permalink
Add Wiktionary lookup
Browse files Browse the repository at this point in the history
  • Loading branch information
johnfactotum committed Sep 22, 2023
1 parent caa2cd3 commit 7e0e03b
Show file tree
Hide file tree
Showing 4 changed files with 252 additions and 28 deletions.
21 changes: 10 additions & 11 deletions src/book-viewer.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import './navbar.js'
import {
AnnotationPopover, AnnotationModel, BookmarkModel, exportAnnotations,
} from './annotations.js'
import { SelectionPopover } from './selection-tools.js'
import { ImageViewer } from './image-viewer.js'
import { makeBookInfoWindow } from './book-info.js'
import { getURIStore, getBookList } from './library.js'
Expand Down Expand Up @@ -577,12 +578,6 @@ GObject.registerClass({
grab_focus() { return this.#webView.grab_focus() }
})

const SelectionPopover = GObject.registerClass({
GTypeName: 'FoliateSelectionPopover',
Template: pkg.moduleuri('ui/selection-popover.ui'),
}, class extends Gtk.PopoverMenu {
})

const autohide = (revealer, shouldStayVisible) => {
const show = () => revealer.reveal_child = true
const hide = () => revealer.reveal_child = false
Expand Down Expand Up @@ -951,7 +946,7 @@ export const BookViewer = GObject.registerClass({
this.#data.storage.set('lastLocation', cfi)
}
}
#showSelection({ type, value, text, pos: { point, dir } }) {
#showSelection({ type, value, text, lang, pos: { point, dir } }) {
if (type === 'annotation') return new Promise(resolve => {
const annotation = this.#data.annotations.get(value)
const popover = utils.connect(new AnnotationPopover({ annotation }), {
Expand Down Expand Up @@ -991,10 +986,14 @@ export const BookViewer = GObject.registerClass({
},
'print': () => resolve('print'),
}))
// it seems `closed` is emitted before the actions are run
// so it needs the timeout
popover.connect('closed', () => setTimeout(() =>
resolved ? null : resolve(), 0))
utils.connect(popover, {
'show-popover': (_, popover) =>
this._view.showPopover(popover, point, dir),
'run-tool': () => ({ text, lang }),
// it seems `closed` is emitted before the actions are run
// so it needs the timeout
'closed': () => setTimeout(() => resolved ? null : resolve(), 0),
})
this._view.showPopover(popover, point, dir)
})
}
Expand Down
13 changes: 10 additions & 3 deletions src/reader/reader.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@ const getSelectionRange = doc => {
return range
}

const getLang = el => {
const lang = el.lang || el?.getAttributeNS?.('http://www.w3.org/XML/1998/namespace', 'lang')
if (lang) return lang
if (el.parentElement) return getLang(el.parentElement)
}

const blobToBase64 = blob => new Promise(resolve => {
const reader = new FileReader()
reader.readAsDataURL(blob)
Expand Down Expand Up @@ -363,7 +369,8 @@ class Reader {
if (!range) return
const pos = getPosition(range)
const value = this.view.getCFI(index, range)
this.#showSelection({ range, value, pos })
const lang = getLang(range.commonAncestorContainer)
this.#showSelection({ range, lang, value, pos })
})

if (!this.view.isFixedLayout)
Expand All @@ -386,9 +393,9 @@ class Reader {
this.#showSelection({ range, value, pos })
})
}
#showSelection({ range, value, pos }) {
#showSelection({ range, lang, value, pos }) {
const text = range.toString()
globalThis.showSelection({ type: 'selection', text, value, pos })
globalThis.showSelection({ type: 'selection', text, lang, value, pos })
.then(action => {
if (action === 'copy') getHTML(range).then(html =>
emit({ type: 'selection', action, text, html }))
Expand Down
232 changes: 232 additions & 0 deletions src/selection-tools.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
import Gtk from 'gi://Gtk'
import Gio from 'gi://Gio'
import GObject from 'gi://GObject'
import WebKit from 'gi://WebKit'
import Gdk from 'gi://Gdk'
import { gettext as _ } from 'gettext'

import * as utils from './utils.js'
import { WebView } from './webview.js'

const tools = {
'dictionary': {
label: _('Dictionary'),
run: ({ text, lang }) => {
const { language } = new Intl.Locale(lang)
return `
<base href="https://en.wiktionary.org/wiki/Wiktionary:Main_Page">
<style>
html, body {
color-scheme: light dark;
font: menu;
}
a:any-link {
color: highlight;
}
h2 {
font-size: smaller;
}
ul, ol {
padding-inline-start: 2em;
}
ul {
margin: .5em 0;
font-style: italic;
opacity: .75;
font-size: smaller;
list-style: none;
}
h1 {
font-size: larger;
padding-inline-end: 1em;
display: inline;
}
hgroup p {
font-size: smaller;
display: inline;
}
footer {
font-size: smaller;
opacity: .6;
display: none;
}
[data-state="loaded"] footer {
display: block;
}
[data-state="error"] main {
display: flex;
position: absolute;
inset: 0;
width: 100%;
height: 100%;
text-align: center;
justify-content: center;
align-items: center;
}
</style>
<main></main>
<footer><p>${_('From <a id="link">Wiktionary</a>, released under the <a href="https://creativecommons.org/licenses/by-sa/4.0/">CC BY-SA License</a>.')}</footer>
<script>
const main = document.querySelector('main')
const footer = document.querySelector('footer')
const link = document.querySelector('#link')
const wiktionary = (word, language, languageName) => {
document.body.dataset.state = 'loading'
return fetch('https://en.wiktionary.org/api/rest_v1/page/definition/' + encodeURI(word))
.then(res => res.ok ? res.json() : Promise.reject(new Error()))
.then(json => {
const results = language ? json[language]
: languageName ? Object.values(json)
.find(x => x.some(x => x.language === languageName))
: json['en']
const hgroup = document.createElement('hgroup')
const h1 = document.createElement('h1')
h1.innerText = word
const p = document.createElement('p')
p.innerText = results[0].language
hgroup.append(h1, p)
main.append(hgroup)
for (const { partOfSpeech, definitions } of results) {
const h2 = document.createElement('h2')
h2.innerText = partOfSpeech
const ol = document.createElement('ol')
main.append(h2, ol)
for (const { definition, examples } of definitions) {
const li = document.createElement('li')
li.innerHTML = definition
ol.append(li)
const ul = document.createElement('ul')
li.append(ul)
if (examples) for (const example of examples) {
const li = document.createElement('li')
li.innerHTML = example
ul.append(li)
}
}
}
link.href = '/wiki/' + word
document.body.dataset.state = 'loaded'
})
.catch(e => {
console.error(e)
const lower = word.toLocaleLowerCase(language)
if (lower !== word) return wiktionary(lower, language)
else {
const div = document.createElement('div')
const h1 = document.createElement('h1')
h1.innerText = decodeURIComponent("${encodeURIComponent(_('No Definitions Found'))}")
const p = document.createElement('p')
p.innerHTML = \`<a href="https://en.wiktionary.org/w/index.php?search=${encodeURIComponent(text)}">${_('Search on Wiktionary')}</a>\`
div.append(h1, p)
main.append(div)
document.body.dataset.state = 'error'
}
})
}
// see https://en.wiktionary.org/wiki/Wiktionary:Namespace
const wikiNamespaces = [
'Media', 'Special', 'Talk', 'User', 'Wiktionary', 'File', 'MediaWiki',
'Template', 'Help', 'Category',
'Summary', 'Appendix', 'Concordance', 'Index', 'Rhymes', 'Transwiki',
'Thesaurus', 'Citations', 'Sign',
]
main.addEventListener('click', e => {
const { target } = e
if (target.tagName === 'A') {
const href = target.getAttribute('href')
if (href.startsWith('/wiki/')) {
const [word, languageName] = href.replace('/wiki/', '').split('#')
if (wikiNamespaces.every(namespace => !word.startsWith(namespace + ':')
&& !word.startsWith(namespace + '_talk:'))) {
e.preventDefault()
main.replaceChildren()
wiktionary(word.replaceAll('_', ' '), null, languageName)
}
}
}
})
wiktionary(decodeURIComponent("${encodeURIComponent(text)}"),
decodeURIComponent("${encodeURIComponent(language)}"))
</script>`
},
},
}

const SelectionToolPopover = GObject.registerClass({
GTypeName: 'FoliateSelectionToolPopover',
}, class extends Gtk.Popover {
#webView = utils.connect(new WebView({
settings: new WebKit.Settings({
enable_write_console_messages_to_stdout: true,
enable_back_forward_navigation_gestures: false,
enable_hyperlink_auditing: false,
enable_html5_database: false,
enable_html5_local_storage: false,
}),
}), {
'decide-policy': (_, decision, type) => {
switch (type) {
case WebKit.PolicyDecisionType.NAVIGATION_ACTION:
case WebKit.PolicyDecisionType.NEW_WINDOW_ACTION: {
const { uri } = decision.navigation_action.get_request()
if (!uri.startsWith('foliate:')) {
decision.ignore()
Gtk.show_uri(null, uri, Gdk.CURRENT_TIME)
return true
}
}
}
},
})
constructor(params) {
super(params)
Object.assign(this, {
width_request: 300,
height_request: 300,
})
this.child = this.#webView
this.#webView.set_background_color(new Gdk.RGBA())
}
load(html) {
this.#webView.loadHTML(html, 'foliate:selection-tool')
.then(() => this.#webView.opacity = 1)
.catch(e => console.error(e))
}
})

const getSelectionToolPopover = utils.memoize(() => new SelectionToolPopover())

export const SelectionPopover = GObject.registerClass({
GTypeName: 'FoliateSelectionPopover',
Template: pkg.moduleuri('ui/selection-popover.ui'),
Signals: {
'show-popover': { param_types: [Gtk.Popover.$gtype] },
'run-tool': { return_type: GObject.TYPE_JSOBJECT },
},
}, class extends Gtk.PopoverMenu {
constructor(params) {
super(params)
const model = this.menu_model
const section = new Gio.Menu()
model.insert_section(1, null, section)

const group = new Gio.SimpleActionGroup()
this.insert_action_group('selection-tools', group)

for (const [name, tool] of Object.entries(tools)) {
const action = new Gio.SimpleAction({ name })
action.connect('activate', () => {
const popover = getSelectionToolPopover()
Promise.resolve(tool.run(this.emit('run-tool')))
.then(x => popover.load(x))
.catch(e => console.error(e))
this.emit('show-popover', popover)
})
group.add_action(action)
section.append(tool.label, `selection-tools.${name}`)
}
}
})
14 changes: 0 additions & 14 deletions src/ui/selection-popover.ui
Original file line number Diff line number Diff line change
Expand Up @@ -19,20 +19,6 @@
<attribute name="verb-icon">edit-find-symbolic</attribute>
</item>
</section>
<section>
<item>
<attribute name="label" translatable="yes">Dictionary</attribute>
<attribute name="action">selection.dictionary</attribute>
</item>
<item>
<attribute name="label" translatable="yes">Wikipedia</attribute>
<attribute name="action">selection.wikipedia</attribute>
</item>
<item>
<attribute name="label" translatable="yes">Translate</attribute>
<attribute name="action">selection.translate</attribute>
</item>
</section>
<section>
<item>
<attribute name="label" translatable="yes">Speak</attribute>
Expand Down

0 comments on commit 7e0e03b

Please sign in to comment.