Blog | Breno5G

Plugin Para O KoReader

O problema

Recentemente decidi retomar meus estudos, tanto para evoluir na programação quanto para continuar meu desenvolvimento pessoal.
Alguns amigos me recomendaram livros de psicologia, filosofia e outros temas que despertaram meu interesse — e, claro, fui adicionando tudo ao meu Kindle.

Minha ideia era simples: ler no Kindle, marcar os trechos importantes e enviar essas anotações automaticamente para meu vault do Obsidian, onde centralizo meus estudos e notas. Assim eu poderia revisitar facilmente os conceitos mais marcantes de cada leitura.

Mas aí veio o problema.
Meu plano era usar o Readwise para capturar os highlights e enviá-los para o Obsidian através de um plugin simples.
Para minha ingrata surpresa, descobri que o Readwise não captura os destaques de livros adicionados manualmente ao Kindle — apenas os comprados na loja da Amazon.

E eu sabia que os highlights existiam, pois apareciam normalmente ao abrir o livro no aplicativo oficial.

A solução

Eu poderia simplesmente desistir da automação e continuar copiando tudo manualmente.
Ou poderia procurar alguma ferramenta de terceiros que resolvesse o problema.
Mas decidi seguir outro caminho: abandonar de vez o ecossistema fechado da Amazon, desbloquear meu Kindle e criar minha própria solução.

Depois de algumas pesquisas sobre jailbreak, acabei encontrando um artigo sobre o KOReader — um leitor de documentos open source para dispositivos e-ink, com suporte a plugins e ampla personalização. Exatamente o que eu procurava.

Libertada a alma Kindle, instalei o KOReader e parti em busca de documentação sobre como desenvolver plugins.
Mas, para minha surpresa, não havia nenhum “getting started” — nem mesmo uma barra de pesquisa para localizar funções na wiki.

Ou seja: se eu quisesse fazer isso funcionar, teria que explorar o código-fonte e aprender como os antigos faziam.

O plano de ação

Antes de partir para o estudo do código, resolvi definir os requisitos básicos de como esse plugin deve se comportar.

A ideia básica é que o plugin deve:

Assim o plugin poderá se comunicar com qualquer servidor que tenha uma rota para lidar com isso.

Para a parte visual, precisamos de:

Botando a mão na massa

Como ponto de partida, escolhi dois plugins nativos do KOReader como base:

A primeira tarefa a ser feita era descobrir como iniciar o plugin e mostrar um singelo “Hello world” em tela — e, para isso, a estrutura abaixo foi necessária:

 1local InfoMessage = require("ui/widget/infomessage")
 2local UIManager = require("ui/uimanager")
 3local WidgetContainer = require("ui/widget/container/widgetcontainer")
 4local _ = require("gettext")
 5
 6local ObsidianSync = WidgetContainer:extend({
 7	name = "obsidiansync",
 8	is_doc_only = false,
 9})
10
11function ObsidianSync:init()
12	self.ui.menu:registerToMainMenu(self)
13end
14
15function ObsidianSync:addToMainMenu(menu_items)
16	menu_items.obsidiansync = {
17		text = _("Obsidian Sync"),
18		sorting_hint = "tools",
19		sub_item_table = {
20			{
21				text = _("Export Highlights"),
22				callback = function()
23					UIManager:show(InfoMessage:new({
24						text = _("Hello, plugin world"),
25					}))
26				end,
27			},
28		},
29	}
30end
31
32return ObsidianSync

Explicando por alto esse código:
fizemos a inicialização de uma classe chamada ObsidianSync, que herda da classe WidgetContainer todos os métodos e atributos básicos para podermos criar um plugin/widget no KOReader.

Criamos também um método init, que será chamado na inicialização do app e será responsável por adicionar o plugin na interface.

Para finalizar, criamos o método addToMainMenu que, como o nome sugere, fica responsável por adicionar ao menu os elementos visuais do plugin.

Evento para capturar informações basicas

Após criarmos a estrutura básica do plugin, eu precisava descobrir como capturar as informações básicas do livro que estamos mexendo, coisas como título, quantidade de páginas, progresso de leitura e, não menos importante, os highlights.

​Então, para começarmos essa parte, resolvi criar um método de debug que nos retornasse as informações básicas de qual livro estamos, qual a extensão dele e em qual diretório ele está localizado.

 1function ObsidianSync:getFileNameAndExtension(path)
 2	local info = {
 3		dirname = path:match("(.+)/[^/]+$"),
 4		filename = path:match("([^/]+)%."),
 5		extension = path:match("%.([^%.]+)$"),
 6	}
 7
 8	return info
 9end
10
11function ObsidianSync:debug()
12	local info = {}
13
14	table.insert(info, "====== DEBUG INFO ======")
15	table.insert(info, "")
16
17	if not self.ui.document then
18		table.insert(info, "❌ Open a document to proced")
19		self:info(table.concat(info, "\n"))
20		return
21	end
22
23	local fileInfo = self:getFileNameAndExtension(self.ui.document.file)
24	table.insert(info, "✅ Document infos:")
25	table.insert(info, "- Dirname: " .. fileInfo.dirname)
26	table.insert(info, "- Filename: " .. fileInfo.filename)
27	table.insert(info, "- Extension: " .. fileInfo.extension)
28
29	self:info(table.concat(info, "\n"))
30end

Provavelmente, temos alguma abordagem mais simples para pegar os dados do livro, mas nada que um regex e certa força de vontade não resolvam.

Acessando os highlights

Agora que temos acesso às informações mais básicas do livro atual, precisamos descobrir onde o KoReader salva as informações que queremos do livro.

​Depois de alguns minutos de pesquisa e testes, acabei vendo que na mesma pasta onde o livro está salvo é gerada uma outra pasta chamada filename.sdr e, nessa pasta, é criado um arquivo metadata.{extensão do livro}.lua que contém todas as informações básicas sobre o livro.

​Analisando o arquivo metadata, encontrei a chave annotation que contém todos os highlights e informações dele salvos.

 1function ObsidianSync:getSDRData()
 2	if not self.ui.document then
 3		self:info("❌ Open a document to proced")
 4		return
 5	end
 6
 7	local fileInfo = self:getFileNameAndExtension()
 8	local chunk, err =
 9		loadfile(fileInfo.dirname .. "/" .. fileInfo.filename .. ".sdr/metadata." .. fileInfo.extension .. ".lua")
10	if not chunk then
11		self:info("❌ Error to open sdr: " .. err)
12		return
13	end
14
15	local metadata = chunk()
16	self:info(metadata["annotations"][1]["text"])
17end

Projetando a comunicação com o servidor

Agora que temos os dados que precisamos, falta apenas conseguirmos nos comunicar com o servidor e enviar os arquivos.

​Pensei em N formas que poderíamos fazer essa conexão, mas resolvi focar primeiro no básico e depois incrementar, caso necessário. Então, para começar, precisamos apenas do IP da máquina onde o servidor estará rodando e da PORTA onde o serviço vai estar escutando.

​Antes de tentar a comunicação com o servidor, resolvi criar um arquivo de configuração básico para que o usuário possa preencher essas informações uma única vez e possa reutilizar nas próximas sessões de leitura.

 1local ObsidianSync = WidgetContainer:extend({
 2	name = "obsidiansync",
 3	is_doc_only = false,
 4
 5	defaults = {
 6		address = "127.0.0.1",
 7		port = 9090,
 8		password = "",
 9	},
10})
11
12function ObsidianSync:configure(touchmenu_instance)
13	local MultiInputDialog = require("ui/widget/multiinputdialog")
14	local url_dialog
15
16	local current_settings = self.settings
17		or G_reader_settings:readSetting("obsidiansync_settings", ObsidianSync.defaults)
18
19	local obsidian_url_address = current_settings.address
20	local obsidian_url_port = current_settings.port
21
22	url_dialog = MultiInputDialog:new({
23		title = _("Set custom obsidian address"),
24		fields = {
25			{
26				text = obsidian_url_address,
27				input_type = "string",
28				hint = _("IP Address"),
29			},
30			{
31				text = tostring(obsidian_url_port),
32				input_type = "number",
33				hint = _("Port"),
34			},
35		},
36		buttons = {
37			{
38				{
39					text = _("Cancel"),
40					id = "close",
41					callback = function()
42						UIManager:close(url_dialog)
43					end,
44				},
45				{
46					text = _("OK"),
47					callback = function()
48						local fields = url_dialog:getFields()
49						if fields[1] ~= "" then
50							local port = tonumber(fields[2])
51							if not port or port < 1 or port > 65355 then
52								port = ObsidianSync.defaults.port
53							end
54
55							-- Preserva o device_id existente ao salvar
56							local new_settings = {
57								address = fields[1],
58								port = port,
59								device_id = self.settings.device_id,
60							}
61							G_reader_settings:saveSetting("obsidiansync_settings", new_settings)
62							self.settings = new_settings
63							self:showNotification(_("✅ Settings saved!"))
64						end
65						UIManager:close(url_dialog)
66						if touchmenu_instance then
67							touchmenu_instance:updateItems()
68						end
69					end,
70				},
71			},
72		},
73	})
74	UIManager:show(url_dialog)
75	url_dialog:onShowKeyboard()
76end

Esse método fica responsável por criar os inputs e salvar os dados preenchidos pelo usuário num arquivo de configuração que permanece independente do livro ou sessão atual.

​Agora que temos os dados de para onde enviar os arquivos, podemos apenas pegar o metadata e enviá-lo para o servidor — o metadata é basicamente um objeto (table) e iremos deixar o servidor lidar com os dados que ele quer ou não.

 1function ObsidianSync:sendToServer()
 2	local json = require("json")
 3
 4	local metadata = self:getSDRData()
 5	if not metadata then
 6		return
 7	end
 8
 9	local body, err = json.encode(metadata)
10	if not body then
11		self:showNotification(_("❌ Error encoding JSON: ") .. (err or _("unknown")), 5)
12		return
13	end
14
15	local device_id = self:getDeviceID()
16	if not device_id or device_id == "" then
17		self:showNotification(_("❌ Error: Could not get device ID."), 5)
18		return
19	end
20
21	local settings = self.settings or G_reader_settings:readSetting("obsidiansync_settings", ObsidianSync.defaults)
22	local url = "http://" .. settings.address .. ":" .. settings.port .. "/sync"
23
24	self:showNotification(_("Syncing with server..."), 2)
25
26	UIManager:scheduleIn(0.25, function()
27		self:_doSyncRequest(url, body, device_id)
28	end)
29end

Aqui pegamos todo o metadata e, com as configurações salvas do usuário, nos comunicamos com o servidor na rota /sync para finalizarmos a sincronização dos highlights.

Conclusão

Chegamos ao final do desenvolvimento do plugin (ao menos a parte do KoReader) e o código completo está disponível no meu github no repositório koreadersync.koplugin.

#books #code