You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
koreader/plugins/texteditor.koplugin/main.lua

623 lines
24 KiB
Lua

local BD = require("ui/bidi")
local ConfirmBox = require("ui/widget/confirmbox")
local DataStorage = require("datastorage")
local Dispatcher = require("dispatcher")
local Font = require("ui/font")
local QRMessage = require("ui/widget/qrmessage")
local InfoMessage = require("ui/widget/infomessage")
local InputDialog = require("ui/widget/inputdialog")
local LuaSettings = require("luasettings")
local Notification = require("ui/widget/notification")
local PathChooser = require("ui/widget/pathchooser")
local Trapper = require("ui/trapper")
local UIManager = require("ui/uimanager")
local WidgetContainer = require("ui/widget/container/widgetcontainer")
local ffiutil = require("ffi/util")
local logger = require("logger")
local util = require("util")
local _ = require("gettext")
local Screen = require("device").screen
local T = ffiutil.template
local TextEditor = WidgetContainer:new{
name = "texteditor",
settings_file = DataStorage:getSettingsDir() .. "/text_editor.lua",
settings = nil, -- loaded only when needed
-- how many to display in menu (10x3 pages minus our 3 default menu items):
history_menu_size = 27,
history_keep_size = 60, -- hom many to keep in settings
normal_font = "x_smallinfofont",
monospace_font = "infont",
min_file_size_warn = 200000, -- warn/ask when opening files bigger than this
}
function TextEditor:onDispatcherRegisterActions()
Dispatcher:registerAction("edit_last_edited_file", { category = "none", event = "OpenLastEditedFile", title = _("Texteditor: open last file"), device = true, separator = true, })
end
function TextEditor:init()
self:onDispatcherRegisterActions()
self.ui.menu:registerToMainMenu(self)
end
function TextEditor:loadSettings()
if self.settings then
return
end
self.settings = LuaSettings:open(self.settings_file)
-- NOTE: addToHistory assigns a new object
self.history = self.settings:readSetting("history") or {}
self.last_view_pos = self.settings:readSetting("last_view_pos") or {}
self.last_path = self.settings:readSetting("last_path") or ffiutil.realpath(DataStorage:getDataDir())
self.font_face = self.settings:readSetting("font_face") or self.normal_font
self.font_size = self.settings:readSetting("font_size") or 20 -- x_smallinfofont default size
-- The font settings could be saved in G_reader_setting if we want them
-- to be re-used by default by InputDialog (on certain conditaions,
-- when fullscreen or condensed or add_nav_bar...)
--
-- Allow users to set their prefered font manually in text_editor.lua
-- (sadly, not via TextEditor itself, as they would be overriden on close)
if self.settings:has("normal_font") then
self.normal_font = self.settings:readSetting("normal_font")
end
if self.settings:has("monospace_font") then
self.monospace_font = self.settings:readSetting("monospace_font")
end
self.auto_para_direction = self.settings:nilOrTrue("auto_para_direction")
self.force_ltr_para_direction = self.settings:isTrue("force_ltr_para_direction")
self.qr_code_export = self.settings:nilOrTrue("qr_code_export")
end
function TextEditor:onFlushSettings()
if self.settings then
self.settings:saveSetting("history", self.history)
self.settings:saveSetting("last_view_pos", self.last_view_pos)
self.settings:saveSetting("last_path", self.last_path)
self.settings:saveSetting("font_face", self.font_face)
self.settings:saveSetting("font_size", self.font_size)
self.settings:saveSetting("auto_para_direction", self.auto_para_direction)
self.settings:saveSetting("force_ltr_para_direction", self.force_ltr_para_direction)
self.settings:saveSetting("qr_code_export", self.qr_code_export)
self.settings:flush()
end
end
function TextEditor:addToMainMenu(menu_items)
menu_items.text_editor = {
text = _("Text editor"),
sub_item_table_func = function()
return self:getSubMenuItems()
end,
}
end
function TextEditor:getSubMenuItems()
self:loadSettings()
self.whenDoneFunc = nil -- discard reference to previous TouchMenu instance
local sub_item_table = {
{
text = _("Text editor settings"),
sub_item_table = {
{
text = _("Set text font size"),
keep_menu_open = true,
callback = function()
local SpinWidget = require("ui/widget/spinwidget")
local font_size = self.font_size
UIManager:show(SpinWidget:new{
width = math.floor(Screen:getWidth() * 0.6),
value = font_size,
value_min = 8,
value_max = 26,
ok_text = _("Set font size"),
title_text = _("Select font size"),
callback = function(spin)
self.font_size = spin.value
end,
})
end,
},
{
text = _("Use monospace font"),
checked_func = function()
return self.font_face == self.monospace_font
end,
callback = function()
if self.font_face == self.monospace_font then
self.font_face = self.normal_font
else
self.font_face = self.monospace_font
end
end,
separator = true,
},
{
text = _("Auto paragraph direction"),
help_text = _([[
Detect the direction of each paragraph in the text: align to the right paragraphs in languages such as Arabic and Hebrew…, while keeping other paragraphs aligned to the left.
If disabled, paragraphs align according to KOReader's language default direction.]]),
checked_func = function()
return self.auto_para_direction
end,
callback = function()
self.auto_para_direction = not self.auto_para_direction
end,
},
{
text = _("Force paragraph direction LTR"),
help_text = _([[
Force all text to be displayed Left-To-Right (LTR) and aligned to the left.
Enable this if you are mostly editing code, HTML, CSS…]]),
enabled_func = BD.rtlUIText, -- only useful for RTL users editing code
checked_func = function()
return BD.rtlUIText() and self.force_ltr_para_direction
end,
callback = function()
self.force_ltr_para_direction = not self.force_ltr_para_direction
end,
},
{
text = _("Enable QR code export"),
help_text = _([[
Export text to QR code, that can be scanned, for example, by a phone.]]),
checked_func = function()
return self.qr_code_export
end,
callback = function()
self.qr_code_export = not self.qr_code_export
end,
},
},
separator = true,
},
{
text = _("Select a file to open"),
keep_menu_open = true,
callback = function(touchmenu_instance)
self:setupWhenDoneFunc(touchmenu_instance)
self:chooseFile()
end,
},
{
text = _("Edit a new empty file"),
keep_menu_open = true,
callback = function(touchmenu_instance)
self:setupWhenDoneFunc(touchmenu_instance)
self:newFile()
end,
separator = true,
},
}
for i=1, math.min(#self.history, self.history_menu_size) do
local file_path = self.history[i]
local directory, filename = util.splitFilePathName(file_path) -- luacheck: no unused
table.insert(sub_item_table, {
text = T("%1. %2", i, BD.filename(filename)),
keep_menu_open = true,
callback = function(touchmenu_instance)
self:setupWhenDoneFunc(touchmenu_instance)
self:checkEditFile(file_path, true)
end,
_texteditor_id = file_path, -- for removal from menu itself
hold_callback = function(touchmenu_instance)
-- Show full path and some info, and propose to remove from history
local text
local attr = lfs.attributes(file_path)
if attr then
local filesize = util.getFormattedSize(attr.size)
local lastmod = os.date("%Y-%m-%d %H:%M", attr.modification)
text = T(_("File path:\n%1\n\nFile size: %2 bytes\nLast modified: %3\n\nRemove this file from text editor history?"),
BD.filepath(file_path), filesize, lastmod)
else
text = T(_("File path:\n%1\n\nThis file does not exist anymore.\n\nRemove it from text editor history?"),
BD.filepath(file_path))
end
UIManager:show(ConfirmBox:new{
text = text,
ok_text = _("Yes"),
cancel_text = _("No"),
ok_callback = function()
self:removeFromHistory(file_path)
-- Also remove from menu itself
for j=1, #sub_item_table do
if sub_item_table[j]._texteditor_id == file_path then
table.remove(sub_item_table, j)
break
end
end
touchmenu_instance:updateItems()
end,
})
end,
})
end
return sub_item_table
end
function TextEditor:setupWhenDoneFunc(touchmenu_instance)
-- This will keep a reference to the TouchMenu instance, that may not
-- get released if file opening is aborted while in the file selection
-- widgets and dialogs (quite complicated to call a resetWhenDoneFunc()
-- in every abort case). But :getSubMenuItems() will release it when
-- the TextEditor menu is opened again.
self.whenDoneFunc = function()
touchmenu_instance.item_table = self:getSubMenuItems()
touchmenu_instance.page = 1
touchmenu_instance:updateItems()
end
end
function TextEditor:execWhenDoneFunc()
if self.whenDoneFunc then
self.whenDoneFunc()
self.whenDoneFunc = nil
end
end
function TextEditor:removeFromHistory(file_path)
for i = #self.history, 1, -1 do
if self.history[i] == file_path then
table.remove(self.history, i)
end
end
self.last_view_pos[file_path] = nil
end
function TextEditor:addToHistory(file_path)
local new_history = {}
table.insert(new_history, file_path)
-- Trim history and cleanup duplicates
local seen = {}
seen[file_path] = true
while #self.history > 0 and #new_history < self.history_keep_size do
local item = table.remove(self.history, 1)
if not seen[item] then
table.insert(new_history, item)
seen[item] = true
end
end
self.history = new_history
end
function TextEditor:newFile()
self:loadSettings()
UIManager:show(ConfirmBox:new{
text = _([[To start editing a new file, you will have to:
- First select a directory
- Then enter a name for the new file
- And start editing it
Do you want to proceed?]]),
ok_text = _("Yes"),
cancel_text = _("No"),
ok_callback = function()
local path_chooser = PathChooser:new{
select_directory = true,
select_file = false,
height = Screen:getHeight(),
path = self.last_path,
onConfirm = function(dir_path)
local file_input
file_input = InputDialog:new{
title = _("Enter filename"),
input = dir_path == "/" and "/" or dir_path .. "/",
buttons = {{
{
text = _("Cancel"),
callback = function()
UIManager:close(file_input)
end,
},
{
text = _("Edit"),
callback = function()
local file_path = file_input:getInputText()
UIManager:close(file_input)
-- Remember last_path
self.last_path = file_path:match("(.*)/")
if self.last_path == "" then self.last_path = "/" end
self:checkEditFile(file_path, false, true)
end,
},
}},
}
UIManager:show(file_input)
file_input:onShowKeyboard()
end,
}
UIManager:show(path_chooser)
end,
})
end
function TextEditor:chooseFile()
self:loadSettings()
local path_chooser = PathChooser:new{
select_file = true,
select_directory = false,
detailed_file_info = true,
height = Screen:getHeight(),
path = self.last_path,
onConfirm = function(file_path)
-- Remember last_path only when we select a file from it
self.last_path = file_path:match("(.*)/")
if self.last_path == "" then self.last_path = "/" end
self:checkEditFile(file_path)
end
}
UIManager:show(path_chooser)
end
function TextEditor:checkEditFile(file_path, from_history, possibly_new_file)
self:loadSettings()
local attr = lfs.attributes(file_path)
if not possibly_new_file and not attr then
UIManager:show(ConfirmBox:new{
text = T(_("This file does not exist anymore:\n\n%1\n\nDo you want to create it and start editing it?"), BD.filepath(file_path)),
ok_text = _("Yes"),
cancel_text = _("No"),
ok_callback = function()
-- go again thru there with possibly_new_file=true
self:checkEditFile(file_path, from_history, true)
end,
})
return
end
if attr then
-- File exists: get its real path with symlink and ../ resolved
file_path = ffiutil.realpath(file_path)
attr = lfs.attributes(file_path)
end
if attr then -- File exists
if attr.mode ~= "file" then
UIManager:show(InfoMessage:new{
text = T(_("This file is not a regular file:\n\n%1"), BD.filepath(file_path))
})
return
end
-- Check if file is writable ("r+b" checks that, and does not
-- update the last mod timestamp, unlike "wb")
-- No need to warn if readonly, the user will know it when we open
-- without keyboard and the Save button says "Read only".
local readonly = true
local file = io.open(file_path, 'r+b')
if file then
file:close()
readonly = false
end
-- Don't check size if coming from history: user had already confirmed it
if not from_history and attr.size > self.min_file_size_warn then
UIManager:show(ConfirmBox:new{
text = T(_("This file is %2:\n\n%1\n\nAre you sure you want to open it?\n\nOpening big files may take some time."),
BD.filepath(file_path), util.getFriendlySize(attr.size)),
ok_text = _("Yes"),
cancel_text = _("No"),
ok_callback = function()
self:editFile(file_path, readonly)
end,
})
else
self:editFile(file_path, readonly)
end
else -- File does not exist
-- Try to create it just to check if writting to it later is possible
local file, err = io.open(file_path, "wb")
if file then
-- Clean it, we'll create it again on Save, and allow closing
-- without saving in case the user has changed his mind.
file:close()
os.remove(file_path)
self:editFile(file_path)
else
UIManager:show(InfoMessage:new{
text = T(_("This file can not be created:\n\n%1\n\nReason: %2"), BD.filepath(file_path), err)
})
return
end
end
end
function TextEditor:readFileContent(file_path)
local file = io.open(file_path, "rb")
if not file then
-- We checked file existence before, so assume it's
-- because it's a new file
return ""
end
local file_content = file:read("*all")
file:close()
return file_content
end
function TextEditor:saveFileContent(file_path, content)
local file, err = io.open(file_path, "wb")
if file then
file:write(content)
file:close()
logger.info("TextEditor: saved file", file_path)
return true
end
logger.info("TextEditor: failed saving file", file_path, ":", err)
return false, err
end
function TextEditor:deleteFile(file_path)
local ok, err = os.remove(file_path)
if ok then
logger.info("TextEditor: deleted file", file_path)
return true
end
logger.info("TextEditor: failed deleting file", file_path, ":", err)
return false, err
end
function TextEditor:editFile(file_path, readonly)
self:addToHistory(file_path)
local directory, filename = util.splitFilePathName(file_path) -- luacheck: no unused
local filename_without_suffix, filetype = util.splitFileNameSuffix(filename) -- luacheck: no unused
local is_lua = filetype:lower() == "lua"
local input
local para_direction_rtl = nil -- use UI language direction
if self.force_ltr_para_direction then
para_direction_rtl = false -- force LTR
end
local buttons_first_row = {} -- First button on first row, that will be filled with Reset|Save|Close
if is_lua then
table.insert(buttons_first_row, {
text = _("Check Lua"),
callback = function()
local parse_error = util.checkLuaSyntax(input:getInputText())
if parse_error then
UIManager:show(InfoMessage:new{
text = T(_("Lua syntax check failed:\n\n%1"), parse_error)
})
else
UIManager:show(Notification:new{
text = T(_("Lua syntax OK")),
})
end
end,
})
end
if self.qr_code_export then
table.insert(buttons_first_row, {
text = _("QR"),
callback = function()
UIManager:show(QRMessage:new{
text = input:getInputText(),
height = Screen:getHeight(),
width = Screen:getWidth()
})
end,
})
end
input = InputDialog:new{
title = filename,
input = self:readFileContent(file_path),
input_face = Font:getFace(self.font_face, self.font_size),
para_direction_rtl = para_direction_rtl,
auto_para_direction = self.auto_para_direction,
fullscreen = true,
condensed = true,
allow_newline = true,
cursor_at_end = false,
readonly = readonly,
add_nav_bar = true,
scroll_by_pan = true,
buttons = {buttons_first_row},
-- Set/save view and cursor position callback
view_pos_callback = function(top_line_num, charpos)
-- This same callback is called with no argument to get initial position,
-- and with arguments to give back final position when closed.
if top_line_num and charpos then
self.last_view_pos[file_path] = {top_line_num, charpos}
else
local prev_pos = self.last_view_pos[file_path]
if type(prev_pos) == "table" and prev_pos[1] and prev_pos[2] then
return prev_pos[1], prev_pos[2]
end
return nil, nil -- no previous position known
end
end,
-- File restoring callback
reset_callback = function(content) -- Will add a Reset button
return self:readFileContent(file_path), _("Text reset to last saved content")
end,
-- Close callback
close_callback = function()
self:execWhenDoneFunc()
end,
-- File saving callback
save_callback = function(content, closing) -- Will add Save/Close buttons
if self.readonly then
-- We shouldn't be called if read-only, but just in case
return false, _("File is read only")
end
if content and #content > 0 then
if not is_lua then
local ok, err = self:saveFileContent(file_path, content)
if ok then
return true, _("File saved")
else
return false, T(_("Failed saving file: %1"), err)
end
end
local parse_error = util.checkLuaSyntax(content)
if not parse_error then
local ok, err = self:saveFileContent(file_path, content)
if ok then
return true, _("Lua syntax OK, file saved")
else
return false, T(_("Failed saving file: %1"), err)
end
end
local save_anyway = Trapper:confirm(T(_([[
Lua syntax check failed:
%1
KOReader may crash if this is saved.
Do you really want to save to this file?
%2]]), parse_error, BD.filepath(file_path)), _("Do not save"), _("Save anyway"))
-- we'll get the safer "Do not save" on tap outside
if save_anyway then
local ok, err = self:saveFileContent(file_path, content)
if ok then
return true, _("File saved")
else
return false, T(_("Failed saving file: %1"), err)
end
else
return false, false -- no need for more InfoMessage
end
else -- If content is empty, propose to delete the file
local delete_file = Trapper:confirm(T(_([[
Text content is empty.
Do you want to keep this file as empty, or do you prefer to delete it?
%1]]), BD.filepath(file_path)), _("Keep empty file"), _("Delete file"))
-- we'll get the safer "Keep empty file" on tap outside
if delete_file then
local ok, err = self:deleteFile(file_path)
if ok then
return true, _("File deleted")
else
return false, T(_("Failed deleting file: %1"), err)
end
else
local ok, err = self:saveFileContent(file_path, content)
if ok then
return true, _("File saved")
else
return false, T(_("Failed saving file: %1"), err)
end
end
end
end,
}
UIManager:show(input)
input:onShowKeyboard()
-- Note about self.readonly:
-- We might have liked to still show keyboard even if readonly, just
-- to use the arrow keys for line by line scrolling with cursor.
-- But it's easier to just let InputDialog and InputText do their
-- own readonly prevention (and on devices where we run as root, we
-- will hardly ever be readonly).
end
-- reopen last edited file. Invokeable with gesture:
function TextEditor:onOpenLastEditedFile()
self:loadSettings()
if #self.history > 0 then
local file_path = self.history[1]
self:checkEditFile(file_path, true)
else
self:chooseFile()
end
end
return TextEditor