Revert "Hyphenation: add custom hyphenation rules (#7746)" (#7785)

This reverts commit f25da5d0d5.
pull/7786/head
Frans de Jonge 3 years ago committed by GitHub
parent ecafdbfed8
commit 039947886f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -1 +1 @@
Subproject commit 502bba07e57e652dcf85f0b2a5c6b1d52fbc0f75
Subproject commit 96c002d5260b09148def07913b5e4363d9a58c23

@ -800,7 +800,7 @@ function ReaderDictionary:startSdcv(word, dict_names, fuzzy_search)
-- dummy results
final_results = {
{
dict = "",
dict = _("Not available"),
word = word,
definition = lookup_cancelled and _("Dictionary lookup interrupted.") or _("No results."),
no_result = true,
@ -849,6 +849,22 @@ function ReaderDictionary:stardictLookup(word, dict_names, fuzzy_search, box, li
return
end
-- If the user disabled all the dictionaries, go away.
if dict_names and #dict_names == 0 then
-- Dummy result
local nope = {
{
dict = _("Not available"),
word = word,
definition = _("There are no enabled dictionaries.\nPlease check the 'Dictionary settings' menu."),
no_result = true,
lookup_cancelled = false,
}
}
self:showDict(word, nope, box, link)
return
end
self:showLookupInfo(word, self.lookup_msg_delay)
self._lookup_start_tv = UIManager:getTime()

@ -209,7 +209,7 @@ function ReaderFont:onSetFontSize(new_size)
self.font_size = new_size
self.ui.document:setFontSize(Screen:scaleBySize(new_size))
self.ui:handleEvent(Event:new("UpdatePos"))
Notification:notify(T(_("Font size set to %1."), self.font_size))
Notification:notify(T(_("Font size set to: %1."), self.font_size))
return true
end
@ -217,7 +217,7 @@ function ReaderFont:onSetLineSpace(space)
self.line_space_percent = math.min(200, math.max(50, space))
self.ui.document:setInterlineSpacePercent(self.line_space_percent)
self.ui:handleEvent(Event:new("UpdatePos"))
Notification:notify(T(_("Line spacing set to %1%."), self.line_space_percent))
Notification:notify(T(_("Line spacing set to: %1%."), self.line_space_percent))
return true
end
@ -225,7 +225,7 @@ function ReaderFont:onSetFontBaseWeight(weight)
self.font_base_weight = weight
self.ui.document:setFontBaseWeight(weight)
self.ui:handleEvent(Event:new("UpdatePos"))
Notification:notify(T(_("Font weight set to %1."), optionsutil:getOptionText("SetFontBaseWeight", weight)))
Notification:notify(T(_("Font weight set to: %1."), optionsutil:getOptionText("SetFontBaseWeight", weight)))
return true
end
@ -233,7 +233,7 @@ function ReaderFont:onSetFontHinting(mode)
self.font_hinting = mode
self.ui.document:setFontHinting(mode)
self.ui:handleEvent(Event:new("UpdatePos"))
Notification:notify(T(_("Font hinting set to %1."), optionsutil:getOptionText("SetFontHinting", mode)))
Notification:notify(T(_("Font hinting set to: %1"), optionsutil:getOptionText("SetFontHinting", mode)))
return true
end
@ -241,7 +241,7 @@ function ReaderFont:onSetFontKerning(mode)
self.font_kerning = mode
self.ui.document:setFontKerning(mode)
self.ui:handleEvent(Event:new("UpdatePos"))
Notification:notify(T(_("Font kerning set to %1."), optionsutil:getOptionText("SetFontKerning", mode)))
Notification:notify(T(_("Font kerning set to: %1"), optionsutil:getOptionText("SetFontKerning", mode)))
return true
end
@ -249,7 +249,7 @@ function ReaderFont:onSetWordSpacing(values)
self.word_spacing = values
self.ui.document:setWordSpacing(values)
self.ui:handleEvent(Event:new("UpdatePos"))
Notification:notify(T(_("Word spacing set to %1%, %2%."), values[1], values[2]))
Notification:notify(T(_("Word spacing set to: %1%, %2%"), values[1], values[2]))
return true
end
@ -257,7 +257,7 @@ function ReaderFont:onSetWordExpansion(value)
self.word_expansion = value
self.ui.document:setWordExpansion(value)
self.ui:handleEvent(Event:new("UpdatePos"))
Notification:notify(T(_("Word expansion set to %1%."), value))
Notification:notify(T(_("Word expansion set to: %1%."), value))
return true
end
@ -266,7 +266,7 @@ function ReaderFont:onSetFontGamma(gamma)
self.ui.document:setGammaIndex(self.gamma_index)
local gamma_level = self.ui.document:getGammaLevel()
self.ui:handleEvent(Event:new("RedrawCurrentView"))
Notification:notify(T(_("Font gamma set to %1."), optionsutil:getOptionText("SetFontGamma", gamma_level)))
Notification:notify(T(_("Font gamma set to: %1."), optionsutil:getOptionText("SetFontGamma", gamma_level)))
return true
end

@ -174,20 +174,6 @@ function ReaderHighlight:init()
}
end)
-- User hyphenation dict
self:addToHighlightDialog("11_user_dict", function(_self)
return {
text= _("Hyphenate"),
show_in_highlight_dialog_func = function()
return _self.ui.userHyph and _self.ui.userhyph:isAvailable() and not _self.selected_text.text:find("[ ,;-%.\n]")
end,
callback = function()
_self.ui.userhyph:modifyUserEntry(_self.selected_text.text)
_self:onClose()
end,
}
end)
self.ui:registerPostInitCallback(function()
self.ui.menu:registerToMainMenu(self)
end)

@ -138,19 +138,27 @@ end
function ReaderTypeset:onToggleEmbeddedStyleSheet(toggle)
self:toggleEmbeddedStyleSheet(toggle)
Notification:notify(T( _("Embedded styles are %1."), optionsutil:getOptionText("ToggleEmbeddedStyleSheet", toggle)))
if toggle then
Notification:notify(_("Enabled embedded styles."))
else
Notification:notify(_("Disabled embedded styles."))
end
return true
end
function ReaderTypeset:onToggleEmbeddedFonts(toggle)
self:toggleEmbeddedFonts(toggle)
Notification:notify(T( _("Embedded fonts are %1."), optionsutil:getOptionText("ToggleEmbeddedFonts", toggle)))
if toggle then
Notification:notify(_("Enabled embedded fonts."))
else
Notification:notify(_("Disabled embedded fonts."))
end
return true
end
function ReaderTypeset:onToggleImageScaling(toggle)
self:toggleImageScaling(toggle)
Notification:notify(T( _("Image scaling set to %1."), optionsutil:getOptionText("ToggleImageScaling", toggle)))
Notification:notify(T( _("Image scaling set to: %1"), optionsutil:getOptionText("ToggleImageScaling", toggle)))
return true
end
@ -161,7 +169,7 @@ end
function ReaderTypeset:onSetBlockRenderingMode(mode)
self:setBlockRenderingMode(mode)
Notification:notify(T( _("Render mode set to %1."), optionsutil:getOptionText("SetBlockRenderingMode", mode)))
Notification:notify(T( _("Render mode set to: %1"), optionsutil:getOptionText("SetBlockRenderingMode", mode)))
return true
end
@ -183,7 +191,7 @@ local OBSOLETED_CSS = {
function ReaderTypeset:onSetRenderDPI(dpi)
self:setRenderDPI(dpi)
Notification:notify(T( _("Zoom set to %1."), optionsutil:getOptionText("SetRenderDPI", dpi)))
Notification:notify(T( _("Zoom set to: %1"), optionsutil:getOptionText("SetRenderDPI", dpi)))
return true
end

@ -237,7 +237,6 @@ When the book's language tag is not among our presets, no specific features will
})
self.text_lang_tag = lang_tag
self.ui.document:setTextMainLang(lang_tag)
self.ui:handleEvent(Event:new("TypographyLanguageChanged"))
self.ui:handleEvent(Event:new("UpdatePos"))
end,
hold_callback = function(touchmenu_instance)
@ -428,8 +427,8 @@ These settings will apply to all books with any hyphenation dictionary.
enabled_func = function()
return self.hyphenation and not self.hyph_soft_hyphens_only
end,
separator = true,
})
table.insert(hyphenation_submenu, self.ui.userhyph:getMenuEntry())
table.insert(hyphenation_submenu, {
text_func = function()
-- Show the current language default hyph dict (ie: English_US for zh)
@ -489,7 +488,7 @@ These settings will apply to all books with any hyphenation dictionary.
end,
})
table.insert(hyphenation_submenu, {
text = _("Soft hyphens only"),
text = _("Soft-hyphens only"),
callback = function()
self.hyph_soft_hyphens_only = not self.hyph_soft_hyphens_only
self.hyph_force_algorithmic = false
@ -761,7 +760,6 @@ function ReaderTypography:onReadSettings(config)
logger.dbg("Typography lang: no lang set, using", self.text_lang_tag)
end
self.ui.document:setTextMainLang(self.text_lang_tag)
self.ui:handleEvent(Event:new("TypographyLanguageChanged"))
end
function ReaderTypography:onPreRenderDocument(config)
@ -782,7 +780,6 @@ function ReaderTypography:onPreRenderDocument(config)
self.text_lang_tag = self.book_lang_tag
self.ui.doc_settings:saveSetting("text_lang", self.text_lang_tag)
self.ui.document:setTextMainLang(self.text_lang_tag)
self.ui:handleEvent(Event:new("TypographyLanguageChanged"))
self.ui:handleEvent(Event:new("UpdatePos"))
end,
enabled_func = function()
@ -812,7 +809,6 @@ function ReaderTypography:onPreRenderDocument(config)
end
self.text_lang_tag = self.book_lang_tag
self.ui.document:setTextMainLang(self.text_lang_tag)
self.ui:handleEvent(Event:new("TypographyLanguageChanged"))
end
end

@ -1,228 +0,0 @@
local DataStorage = require("datastorage")
local Event = require("ui/event")
local FFIUtil = require("ffi/util")
local InfoMessage = require("ui/widget/infomessage")
local InputDialog = require("ui/widget/inputdialog")
local UIManager = require("ui/uimanager")
local WidgetContainer = require("ui/widget/container/widgetcontainer")
local lfs = require("libs/libkoreader-lfs")
local logger = require("logger")
local _ = require("gettext")
local T = require("ffi/util").template
local ReaderUserHyph = WidgetContainer:new{
-- return values from setUserHyphenationDict (crengine's UserHyphDict::init())
USER_DICT_RELOAD = 0,
USER_DICT_NOCHANGE = 1,
USER_DICT_MALFORMED = 2,
USER_DICT_ERROR_NOT_SORTED = 3,
}
-- returns path to the user dictionary
function ReaderUserHyph:getDictionaryPath()
return FFIUtil.joinPath(DataStorage:getSettingsDir(),
"user-" .. tostring(self.ui.document:getTextMainLangDefaultHyphDictionary():gsub(".pattern$", "")) .. ".hyph")
end
-- Load the user dictionary suitable for the actual language
-- if reload==true, force a reload
-- Unload is done automatically when a new dictionary is loaded.
function ReaderUserHyph:loadDictionary(name, reload)
if G_reader_settings:isTrue("hyph_user_dict") and lfs.attributes(name, "mode") == "file" then
local ret = self.ui.document:setUserHyphenationDict(name, reload)
-- this should only happen, if a user edits a dictionary by hand or the user messed
-- with the dictionary file by hand. -> Warning and disable.
if ret == self.USER_DICT_ERROR_NOT_SORTED then
UIManager:show(InfoMessage:new{
text = T(_("The user dictionary\n%1\nis not alphabetically sorted.\n\nIt has been disabled."), name),
})
logger.warn("UserHyph: Dictionary " .. name .. " is not sorted alphabetically.")
G_reader_settings:makeFalse("hyph_user_dict")
elseif ret == self.USER_DICT_MALFORMED then
UIManager:show(InfoMessage:new{
text = T(_("The user dictionary\n%1\nhas corrupted entries.\n\nOnly valid entries will be used."), name),
})
logger.warn("UserHyph: Dictionary " .. name .. " has corrupted entries.")
end
else
self.ui.document:setUserHyphenationDict() -- clear crengine user hyph dict
end
end
-- Reload on change of the hyphenation language
function ReaderUserHyph:onTypographyLanguageChanged()
self:loadUserDictionary()
end
-- Reload on "ChangedUserDictionary" event,
-- doesn't load dictionary if filesize and filename haven't changed
-- if reload==true reload
function ReaderUserHyph:loadUserDictionary(reload)
self:loadDictionary(self:isAvailable() and self:getDictionaryPath() or "", reload and true or false)
self.ui:handleEvent(Event:new("UpdatePos"))
end
-- Functions to use with the UI
function ReaderUserHyph:isAvailable()
return G_reader_settings:isTrue("hyph_user_dict") and self:_enabled()
end
function ReaderUserHyph:_enabled()
return self.ui.typography.hyphenation
end
-- add Menu entry
function ReaderUserHyph:getMenuEntry()
return {
text = _("Custom hyphenation rules"),
help_text = _("The hyphenation of a word can be changed from its default by long pressing for 3 seconds and selecting 'Hyphenate'."),
callback = function()
local hyph_user_dict = not G_reader_settings:isTrue("hyph_user_dict")
G_reader_settings:saveSetting("hyph_user_dict", hyph_user_dict)
self:loadUserDictionary() -- not needed to force a reload here
end,
checked_func = function()
return self:isAvailable()
end,
enabled_func = function()
return self:_enabled()
end,
separator = true,
}
end
-- Helper functions for dictionary entries-------------------------------------------
-- checks if suggestion is well formated
function ReaderUserHyph:checkHyphenation(suggestion, word)
if suggestion:find("%-%-") then
return false -- two or more consecutive '-'
end
suggestion = suggestion:gsub("-","")
if self.ui.document:getLowercasedWord(suggestion) == self.ui.document:getLowercasedWord(word) then
return true -- characters match (case insensitive)
end
return false
end
function ReaderUserHyph:updateDictionary(word, hyphenation)
local dict_file = self:getDictionaryPath()
local new_dict_file = dict_file .. ".new"
local new_dict = io.open(new_dict_file, "w")
if not new_dict then
logger.err("UserHyph: could not open " .. new_dict_file)
return
end
local word_lower = self.ui.document:getLowercasedWord(word)
local line
local dict = io.open(dict_file, "r")
if dict then
line = dict:read()
--search entry
while line and self.ui.document:getLowercasedWord(line:sub(1, line:find(";") - 1)) < word_lower do
new_dict:write(line .. "\n")
line = dict:read()
end
-- last word = nil if EOF, else last_word=word if found in file, else last_word is word after the new entry
if line then
local last_word = self.ui.document:getLowercasedWord(line:sub(1, line:find(";") - 1))
if last_word == self.ui.document:getLowercasedWord(word) then
line = nil -- word found
end
else
line = nil -- EOF
end
end
-- write new entry
if hyphenation and hyphenation ~= "" then
new_dict:write(string.format("%s;%s\n", word, hyphenation))
end
-- write old entry if there was one
if line then
new_dict:write(line .. "\n")
end
if dict then
repeat
line = dict:read()
if line then
new_dict:write(line .. "\n")
end
until (not line)
dict:close()
os.remove(dict_file)
end
new_dict:close()
os.rename(new_dict_file, dict_file)
self:loadUserDictionary(true) -- dictionary has changed, force a reload here
end
function ReaderUserHyph:modifyUserEntry(word)
if word:find("[ ,;-%.]") then return end -- no button if more than one word
if not self.ui.document then return end
local suggested_hyphenation = self.ui.document:getHyphenationForWord(word)
local input_dialog
input_dialog = InputDialog:new{
title = T(_("Hyphenate: %1"), word),
description = _("Add hyphenation positions with hyphens ('-') or spaces (' ')."),
input = suggested_hyphenation,
old_hyph_lowercase = self.ui.document:getLowercasedWord(suggested_hyphenation),
input_type = "string",
buttons = {
{
{
text = _("Cancel"),
callback = function()
UIManager:close(input_dialog)
end,
},
{
text = _("Remove"),
callback = function()
UIManager:close(input_dialog)
self:updateDictionary(word)
end,
},
{
text = _("Save"),
is_enter_default = true,
callback = function()
local new_suggestion = input_dialog:getInputText()
new_suggestion = new_suggestion:gsub(" ","-") -- replace spaces with hyphens
new_suggestion = new_suggestion:gsub("^-","") -- remove leading hypenations
new_suggestion = new_suggestion:gsub("-$","") -- remove trailing hypenations
if self:checkHyphenation(new_suggestion, word) then
-- don't save if no changes
if self.ui.document:getLowercasedWord(new_suggestion) ~= input_dialog.old_hyph_lowercase then
self:updateDictionary(word, new_suggestion)
end
UIManager:close(input_dialog)
else
UIManager:show(InfoMessage:new{
text = T(_("Invalid hyphenation!"), self.dict_file),
})
end
end,
},
},
},
}
UIManager:show(input_dialog)
input_dialog:onShowKeyboard()
end
return ReaderUserHyph

@ -715,7 +715,7 @@ function ReaderView:onSetRotationMode(rotation)
self.ui:handleEvent(Event:new("SetDimensions", new_screen_size))
self.ui:onScreenResize(new_screen_size)
self.ui:handleEvent(Event:new("InitScrollPageStates"))
Notification:notify(T(_("Rotation mode set to %1."), optionsutil:getOptionText("SetRotationMode", rotation)))
Notification:notify(T(_("Rotation mode set to: %1"), optionsutil:getOptionText("SetRotationMode", rotation)))
return true
end
@ -849,12 +849,12 @@ function ReaderView:onGammaUpdate(gamma)
if self.page_scroll then
self.ui:handleEvent(Event:new("UpdateScrollPageGamma", gamma))
end
Notification:notify(T(_("Font gamma set to %1."), gamma))
Notification:notify(T(_("Font gamma set to: %1."), gamma))
end
function ReaderView:onFontSizeUpdate(font_size)
self.ui:handleEvent(Event:new("ReZoom", font_size))
Notification:notify(T(_("Font zoom set to %1."), font_size))
Notification:notify(T(_("Font zoom set to: %1."), font_size))
end
function ReaderView:onDefectSizeUpdate()
@ -874,7 +874,7 @@ function ReaderView:onSetViewMode(new_mode)
self.view_mode = new_mode
self.ui.document:setViewMode(new_mode)
self.ui:handleEvent(Event:new("ChangeViewMode"))
Notification:notify(T( _("View mode set to %1."), optionsutil:getOptionText("SetViewMode", new_mode)))
Notification:notify(T( _("View mode set to: %1"), optionsutil:getOptionText("SetViewMode", new_mode)))
end
end

@ -48,7 +48,6 @@ local ReaderStyleTweak = require("apps/reader/modules/readerstyletweak")
local ReaderToc = require("apps/reader/modules/readertoc")
local ReaderTypeset = require("apps/reader/modules/readertypeset")
local ReaderTypography = require("apps/reader/modules/readertypography")
local ReaderUserHyph = require("apps/reader/modules/readeruserhyph")
local ReaderView = require("apps/reader/modules/readerview")
local ReaderWikipedia = require("apps/reader/modules/readerwikipedia")
local ReaderZooming = require("apps/reader/modules/readerzooming")
@ -315,12 +314,6 @@ function ReaderUI:init()
view = self.view,
ui = self
})
-- user hyphenation (must be registered before typography)
self:registerModule("userhyph", ReaderUserHyph:new{
dialog = self.dialog,
view = self.view,
ui = self
})
-- typography menu (replaces previous hyphenation menu / ReaderHyphenation)
self:registerModule("typography", ReaderTypography:new{
dialog = self.dialog,

@ -132,10 +132,11 @@ function Device:init()
device = self,
event_map = require("device/android/event_map"),
handleMiscEv = function(this, ev)
local Event = require("ui/event")
local UIManager = require("ui/uimanager")
logger.dbg("Android application event", ev.code)
if ev.code == C.APP_CMD_SAVE_STATE then
return "SaveState"
UIManager:broadcastEvent(Event:new("SaveSettings"))
elseif ev.code == C.APP_CMD_DESTROY then
UIManager:quit()
elseif ev.code == C.APP_CMD_GAINED_FOCUS
@ -152,7 +153,6 @@ function Device:init()
this.device.screen:resize()
local new_size = this.device.screen:getSize()
logger.info("Resizing screen to", new_size)
local Event = require("ui/event")
local FileManager = require("apps/filemanager/filemanager")
UIManager:broadcastEvent(Event:new("SetDimensions", new_size))
UIManager:broadcastEvent(Event:new("ScreenResize", new_size))
@ -166,10 +166,8 @@ function Device:init()
end
end
-- to-do: keyboard connected, disconnected
elseif ev.code == C.APP_CMD_START then
local Event = require("ui/event")
UIManager:broadcastEvent(Event:new("Resume"))
elseif ev.code == C.APP_CMD_RESUME then
UIManager:broadcastEvent(Event:new("Resume"))
if external.when_back_callback then
external.when_back_callback()
external.when_back_callback = nil
@ -207,14 +205,11 @@ function Device:init()
end
end
end
elseif ev.code == C.APP_CMD_STOP then
local Event = require("ui/event")
elseif ev.code == C.APP_CMD_PAUSE then
UIManager:broadcastEvent(Event:new("Suspend"))
elseif ev.code == C.AEVENT_POWER_CONNECTED then
local Event = require("ui/event")
UIManager:broadcastEvent(Event:new("Charging"))
elseif ev.code == C.AEVENT_POWER_DISCONNECTED then
local Event = require("ui/event")
UIManager:broadcastEvent(Event:new("NotCharging"))
elseif ev.code == C.AEVENT_DOWNLOAD_COMPLETE then
android.ota.isRunning = false
@ -462,9 +457,9 @@ function Device:untar(archive, extract_to)
end
function Device:download(link, name, ok_text)
local UIManager = require("ui/uimanager")
local ConfirmBox = require("ui/widget/confirmbox")
local InfoMessage = require("ui/widget/infomessage")
local UIManager = require("ui/uimanager")
local ok = android.download(link, name)
if ok == C.ADOWNLOAD_EXISTS then
self:install()
@ -484,12 +479,14 @@ function Device:download(link, name, ok_text)
end
function Device:install()
local UIManager = require("ui/uimanager")
local ConfirmBox = require("ui/widget/confirmbox")
local Event = require("ui/event")
local UIManager = require("ui/uimanager")
UIManager:show(ConfirmBox:new{
text = _("Update is ready. Install it now?"),
ok_text = _("Install"),
ok_callback = function()
UIManager:broadcastEvent(Event:new("SaveSettings"))
android.ota.install()
android.ota.isPending = false
end,

@ -488,6 +488,7 @@ local PocketBook626 = PocketBook:new{
local PocketBook627 = PocketBook:new{
model = "PBLux4",
display_dpi = 212,
isAlwaysPortrait = yes,
}
-- PocketBook Touch Lux 5 (628)
@ -576,6 +577,16 @@ local PocketBook740_2 = PocketBook:new{
}
}
-- PocketBook InkPad Color (741)
local PocketBook741 = PocketBook:new{
model = "PBInkPadColor",
display_dpi = 300,
hasColorScreen = yes,
canUseCBB = no, -- 24bpp
isAlwaysPortrait = yes,
usingForcedRotation = landscape_ccw,
}
-- PocketBook Color Lux (801)
local PocketBookColorLux = PocketBook:new{
model = "PBColorLux",
@ -662,6 +673,8 @@ elseif codename == "PB740" then
return PocketBook740
elseif codename == "PB740-2" then
return PocketBook740_2
elseif codename == "PB741" then
return PocketBook741
elseif codename == "PocketBook 840" then
return PocketBook840
elseif codename == "PB1040" then

@ -33,7 +33,9 @@ function M:new(o)
end
end
end
logger.info(o:dump())
if o.is_user_list then
logger.info(o:dump())
end
return o
end
@ -54,7 +56,7 @@ function M:checkMethod(role, method)
end
function M:dump()
local str = (self.is_user_list and "user" or "platform") .. " thirdparty apps\n"
local str = "user defined thirdparty apps\n"
for i, role in ipairs(roles) do
local apps = self[role.."s"]
for index, _ in ipairs(apps or {}) do

@ -980,25 +980,6 @@ function CreDocument:setTextHyphenationSoftHyphensOnly(toggle)
self._document:setStringProperty("crengine.textlang.hyphenation.soft.hyphens.only", toggle and 1 or 0)
end
function CreDocument:setUserHyphenationDict(dict, reload)
logger.dbg("CreDocument: set textlang hyphenation dict", dict or "none")
return self._document:setUserHyphenationDict(dict or "", reload or false)
end
function CreDocument:getHyphenationForWord(word)
if word then
return self._document:getHyphenationForWord(word)
end
return word
end
function CreDocument:getLowercasedWord(word)
if word then
return self._document:getLowercasedWord(word)
end
return word
end
function CreDocument:setTextHyphenationForceAlgorithmic(toggle)
logger.dbg("CreDocument: set textlang hyphenation force algorithmic", toggle)
self._document:setStringProperty("crengine.textlang.hyphenation.force.algorithmic", toggle and 1 or 0)

@ -467,16 +467,16 @@ function UIManager:close(widget, refreshtype, refreshregion, refreshdither)
end
logger.dbg("close widget:", widget.name or widget.id or tostring(widget))
local dirty = false
-- Ensure all the widgets can get onFlushSettings event.
-- First notify the closed widget to save its settings...
widget:handleEvent(Event:new("FlushSettings"))
-- first send close event to widget
-- ...and notify it that it ought to be gone now.
widget:handleEvent(Event:new("CloseWidget"))
-- make it disabled by default and check if any widget wants it disabled or enabled
-- Make sure it's disabled by default and check if there are any widgets that want it disabled or enabled.
Input.disable_double_tap = true
local requested_disable_double_tap = nil
local is_covered = false
local start_idx = 1
-- then remove all references to that widget on stack and refresh
-- Then remove all references to that widget on stack and refresh.
for i = #self._window_stack, 1, -1 do
if self._window_stack[i].widget == widget then
self._dirty[self._window_stack[i].widget] = nil

@ -210,7 +210,6 @@ function DateWidget:onCloseWidget()
UIManager:setDirty(nil, function()
return "ui", self.date_frame.dimen
end)
return true
end
function DateWidget:onShow()

@ -909,7 +909,6 @@ function DictQuickLookup:onCloseWidget()
UIManager:setDirty(nil, function()
return "flashui", nil
end)
return true
end
function DictQuickLookup:onShow()

@ -295,7 +295,6 @@ function DoubleSpinWidget:onCloseWidget()
UIManager:setDirty(nil, function()
return "ui", self.widget_frame.dimen
end)
return true
end
function DoubleSpinWidget:onShow()

@ -583,7 +583,6 @@ function FrontLightWidget:onCloseWidget()
UIManager:setDirty(nil, function()
return "flashui", self.light_frame.dimen
end)
return true
end
function FrontLightWidget:onShow()

@ -850,7 +850,6 @@ function ImageViewer:onCloseWidget()
UIManager:setDirty(nil, function()
return "flashui", self.main_frame.dimen
end)
return true
end
return ImageViewer

@ -206,16 +206,15 @@ function InfoMessage:onCloseWidget()
end
if self.invisible then
-- Still invisible, no setDirty needed
return true
return
end
if self.no_refresh_on_close then
return true
return
end
UIManager:setDirty(nil, function()
return "ui", self[1][1].dimen
end)
return true
end
function InfoMessage:onShow()

@ -391,6 +391,7 @@ function InputDialog:init()
scroll_callback = self._buttons_scroll_callback, -- nil if no Nav or Scroll buttons
scroll = true,
scroll_by_pan = self.scroll_by_pan,
has_nav_bar = self.add_nav_bar,
cursor_at_end = self.cursor_at_end,
readonly = self.readonly,
parent = self,

@ -61,6 +61,7 @@ local InputText = InputContainer:new{
for_measurement_only = nil, -- When the widget is a one-off used to compute text height
do_select = false, -- to start text selection
selection_start_pos = nil, -- selection start position
is_keyboard_hidden = false, -- to be able to show the keyboard again when it was hidden (by VK itself)
}
-- only use PhysicalKeyboard if the device does not have touch screen
@ -121,6 +122,11 @@ if Device:isTouchDevice() or Device:hasDPad() then
function InputText:onTapTextBox(arg, ges)
if self.parent.onSwitchFocus then
self.parent:onSwitchFocus(self)
else
if self.is_keyboard_hidden == true then
self:onShowKeyboard()
self.is_keyboard_hidden = false
end
end
if #self.charlist > 0 then -- Avoid cursor moving within a hint.
local textwidget_offset = self.margin + self.bordersize + self.padding
@ -563,12 +569,21 @@ end
function InputText:onShowKeyboard(ignore_first_hold_release)
Device:startTextInput()
self.keyboard.ignore_first_hold_release = ignore_first_hold_release
UIManager:show(self.keyboard)
return true
end
function InputText:onHideKeyboard()
if not self.has_nav_bar then
UIManager:close(self.keyboard)
Device:stopTextInput()
self.is_keyboard_hidden = true
end
return self.is_keyboard_hiddenend
end
function InputText:onCloseKeyboard()
UIManager:close(self.keyboard)
Device:stopTextInput()

@ -157,7 +157,6 @@ function KeyboardLayoutDialog:onCloseWidget()
UIManager:setDirty(nil, function()
return "ui", self[1][1].dimen
end)
return true
end
return KeyboardLayoutDialog

@ -38,7 +38,6 @@ function LinkBox:onCloseWidget()
UIManager:setDirty(nil, function()
return "partial", self.box
end)
return true
end
function LinkBox:onShow()

@ -381,7 +381,6 @@ function NaturalLightWidget:onCloseWidget()
end)
-- Tell frontlight widget that we're closed
self.fl_widget:naturalLightConfigClose()
return true
end
function NaturalLightWidget:onShow()

@ -182,7 +182,6 @@ function Notification:onCloseWidget()
UIManager:setDirty(nil, function()
return "ui", self.frame.dimen
end)
return true
end
function Notification:onShow()

@ -188,7 +188,6 @@ function OpenWithDialog:onCloseWidget()
UIManager:setDirty(nil, function()
return "ui", self.dialog_frame.dimen
end)
return true
end
return OpenWithDialog

@ -86,7 +86,6 @@ function QRMessage:onCloseWidget()
UIManager:setDirty(nil, function()
return "ui", self[1][1].dimen
end)
return true
end
function QRMessage:onShow()

@ -88,7 +88,6 @@ function ScreenSaverWidget:onCloseWidget()
UIManager:setDirty(nil, function()
return "full", self.main_frame.dimen
end)
return true
end
return ScreenSaverWidget

@ -338,7 +338,6 @@ function SkimToWidget:onCloseWidget()
UIManager:setDirty(nil, function()
return "ui", self.skimto_frame.dimen
end)
return true
end
function SkimToWidget:onShow()

@ -248,7 +248,6 @@ function SpinWidget:onCloseWidget()
UIManager:setDirty(nil, function()
return "ui", self.spin_frame.dimen
end)
return true
end
function SpinWidget:onShow()

@ -229,7 +229,6 @@ function TextViewer:onCloseWidget()
UIManager:setDirty(nil, function()
return "partial", self.frame.dimen
end)
return true
end
function TextViewer:onShow()

@ -195,7 +195,6 @@ function TimeWidget:onCloseWidget()
UIManager:setDirty(nil, function()
return "ui", self.time_frame.dimen
end)
return true
end
function TimeWidget:onShow()

@ -155,7 +155,6 @@ function TrapWidget:onCloseWidget()
return "ui", self.frame.dimen
end)
end
return true
end
return TrapWidget

@ -111,7 +111,7 @@ function VirtualKey:init()
self.keyboard:delToStartOfLine()
end
--self.skiphold = true
elseif self.label =="" then
elseif self.label == "" then
self.callback = function() self.keyboard:leftChar() end
self.hold_callback = function()
self.ignore_key_release = true
@ -127,6 +127,13 @@ function VirtualKey:init()
self.callback = function() self.keyboard:upLine() end
elseif self.label == "" then
self.callback = function() self.keyboard:downLine() end
self.hold_callback = function()
self.ignore_key_release = true
if not self.keyboard:onHideKeyboard() then
-- Keyboard was *not* actually hidden: refresh the key to clear the highlight
self:update_keyboard(false, true)
end
end
else
self.callback = function () self.keyboard:addChar(self.key) end
self.hold_callback = function()
@ -520,6 +527,17 @@ function VirtualKeyPopup:init()
virtual_key.hold_callback = nil
-- close popup on hold release
virtual_key.onHoldReleaseKey = function()
-- NOTE: Check our *parent* key!
if parent_key.ignore_key_release then
parent_key.ignore_key_release = nil
return true
end
Device:performHapticFeedback("LONG_PRESS")
if virtual_key.keyboard.ignore_first_hold_release then
virtual_key.keyboard.ignore_first_hold_release = false
return true
end
virtual_key:onTapSelect(true)
UIManager:close(self)
return true
@ -630,13 +648,19 @@ function VirtualKeyPopup:init()
}
if position_container.dimen.x < 0 then
position_container.dimen.x = 0
-- We effectively move the popup, which means the key underneath our finger may no longer *exactly* be parent_key.
-- Make sure we won't close the popup right away, as that would risk being a *different* key, in order to make that less confusing.
parent_key.ignore_key_release = true
elseif position_container.dimen.x + keyboard_frame.dimen.w > Screen:getWidth() then
position_container.dimen.x = Screen:getWidth() - keyboard_frame.dimen.w
parent_key.ignore_key_release = true
end
if position_container.dimen.y < 0 then
position_container.dimen.y = 0
parent_key.ignore_key_release = true
elseif position_container.dimen.y + keyboard_frame.dimen.h > Screen:getHeight() then
position_container.dimen.y = Screen:getHeight() - keyboard_frame.dimen.h
parent_key.ignore_key_release = true
end
self[1] = position_container
@ -745,6 +769,10 @@ function VirtualKeyboard:onClose()
return true
end
function VirtualKeyboard:onHideKeyboard()
return self.inputbox:onHideKeyboard()
end
function VirtualKeyboard:onPressKey()
self:getFocusItem():handleEvent(Event:new("TapSelect"))
return true
@ -771,7 +799,6 @@ end
function VirtualKeyboard:onCloseWidget()
self:_refresh(false)
return true
end
function VirtualKeyboard:initLayer(layer)

@ -1,4 +1,4 @@
#!/bin/sh
#!/bin/bash
export LC_ALL="en_US.UTF-8"
@ -83,6 +83,9 @@ if [ "${STANDALONE}" != "true" ]; then
[ -x /etc/init.d/connman ] && /etc/init.d/connman stop
fi
CRASH_COUNT=0
CRASH_TS=0
CRASH_PREV_TS=0
# **magic** values to request shell stuff. It starts at 85,
# any number lower than that will exit this script.
RESTART_KOREADER=85
@ -91,18 +94,101 @@ ENTER_QBOOKAPP=87
RETURN_VALUE="${RESTART_KOREADER}"
# Loop forever until KOReader requests a normal exit.
while [ "${RETURN_VALUE}" -ge "${RESTART_KOREADER}" ]; do
while [ "${RETURN_VALUE}" -ne 0 ]; do
# move dictionaries from external storage to koreader private partition.
find /mnt/public/dict -type f -exec mv -v \{\} /mnt/private/koreader/data/dict \; 2>/dev/null
# Do an update check now, so we can actually update KOReader via the "Restart KOReader" menu entry ;).
ko_update_check
if [ ${RETURN_VALUE} -eq ${RESTART_KOREADER} ]; then
# Do an update check now, so we can actually update KOReader via the "Restart KOReader" menu entry ;).
ko_update_check
fi
# run KOReader
./reader.lua "$@" >>crash.log 2>&1
RETURN_VALUE=$?
if [ ${RETURN_VALUE} -ne 0 ] && [ "${RETURN_VALUE}" -ne "${ENTER_USBMS}" ] && [ "${RETURN_VALUE}" -ne "${ENTER_QBOOKAPP}" ] && [ "${RETURN_VALUE}" -ne "${RESTART_KOREADER}" ]; then
# Increment the crash counter
CRASH_COUNT=$((CRASH_COUNT + 1))
CRASH_TS=$(date +'%s')
# Reset it to a first crash if it's been a while since our last crash...
if [ $((CRASH_TS - CRASH_PREV_TS)) -ge 20 ]; then
CRASH_COUNT=1
fi
# Check if the user requested to always abort on crash
if grep -q '\["dev_abort_on_crash"\] = true' 'settings.reader.lua' 2>/dev/null; then
ALWAYS_ABORT="true"
# In which case, make sure we pause on *every* crash
CRASH_COUNT=1
else
ALWAYS_ABORT="false"
fi
# Show a fancy bomb on screen
viewWidth=600
viewHeight=800
FONTH=16
eval "$(./fbink -e | tr ';' '\n' | grep -e viewWidth -e viewHeight -e FONTH | tr '\n' ';')"
# Compute margins & sizes relative to the screen's resolution, so we end up with a similar layout, no matter the device.
# Height @ ~56.7%, w/ a margin worth 1.5 lines
bombHeight=$((viewHeight / 2 + viewHeight / 15))
bombMargin=$((FONTH + FONTH / 2))
# With a little notice at the top of the screen, on a big gray screen of death ;).
./fbink -q -b -c -B GRAY9 -m -y 1 "Don't Panic! (Crash n°${CRASH_COUNT} -> ${RETURN_VALUE})"
if [ ${CRASH_COUNT} -eq 1 ]; then
# Warn that we're waiting on a tap to continue...
./fbink -q -b -O -m -y 2 "Tap the screen to continue."
fi
# U+1F4A3, the hard way, because we can't use \u or \U escape sequences...
./fbink -q -b -O -m -t regular=./fonts/freefont/FreeSerif.ttf,px=${bombHeight},top=${bombMargin} -- $'\xf0\x9f\x92\xa3'
# And then print the tail end of the log on the bottom of the screen...
crashLog="$(tail -n 25 crash.log | sed -e 's/\t/ /g')"
# The idea for the margins being to leave enough room for an fbink -Z bar, small horizontal margins, and a font size based on what 6pt looked like @ 265dpi
./fbink -q -b -O -t regular=./fonts/droid/DroidSansMono.ttf,top=$((viewHeight / 2 + FONTH * 2 + FONTH / 2)),left=$((viewWidth / 60)),right=$((viewWidth / 60)),px=$((viewHeight / 64)) -- "${crashLog}"
# So far, we hadn't triggered an actual screen refresh, do that now, to make sure everything is bundled in a single flashing refresh.
./fbink -q -f -s
# Cue a lemming's faceplant sound effect!
{
echo "!!!!"
echo "Uh oh, something went awry... (Crash n°${CRASH_COUNT}: $(date +'%x @ %X'))"
echo "Running on Linux $(uname -r) ($(uname -v))"
} >>crash.log 2>&1
if [ ${CRASH_COUNT} -lt 5 ] && [ "${ALWAYS_ABORT}" = "false" ]; then
echo "Attempting to restart KOReader . . ." >>crash.log 2>&1
echo "!!!!" >>crash.log 2>&1
fi
# Pause a bit if it's the first crash in a while, so that it actually has a chance of getting noticed ;).
if [ ${CRASH_COUNT} -eq 1 ]; then
# NOTE: We don't actually care about what head reads, we're just using it as a fancy sleep ;).
# i.e., we pause either until the 15s timeout, or until the user touches the screen.
timeout 15 head -c 24 /dev/input/event1 >/dev/null
fi
# Cycle the last crash timestamp
CRASH_PREV_TS=${CRASH_TS}
# But if we've crashed more than 5 consecutive times, exit, because we wouldn't want to be stuck in a loop...
# NOTE: No need to check for ALWAYS_ABORT, CRASH_COUNT will always be 1 when it's true ;).
if [ ${CRASH_COUNT} -ge 5 ]; then
echo "Too many consecutive crashes, aborting . . ." >>crash.log 2>&1
echo "!!!! ! !!!!" >>crash.log 2>&1
break
fi
# If the user requested to always abort on crash, do so.
if [ "${ALWAYS_ABORT}" = "true" ]; then
echo "Aborting . . ." >>crash.log 2>&1
echo "!!!! ! !!!!" >>crash.log 2>&1
break
fi
else
# Reset the crash counter if that was a sane exit/restart
CRASH_COUNT=0
fi
# check if KOReader requested to enter in mass storage mode.
if [ "${RETURN_VALUE}" -eq "${ENTER_USBMS}" ]; then
# NOTE: at this point we're sure that the safemode tool

@ -33,13 +33,16 @@ ko_update_check() {
# NOTE: See frontend/ui/otamanager.lua for a few more details on how we squeeze a percentage out of tar's checkpoint feature
# NOTE: %B should always be 512 in our case, so let stat do part of the maths for us instead of using %s ;).
FILESIZE="$(stat -c %b "${NEWUPDATE}")"
BLOCKS="$((FILESIZE / 20))"
export CPOINTS="$((BLOCKS / 100))"
# shellcheck disable=SC2003
BLOCKS="$(expr "${FILESIZE}" / 20)"
# shellcheck disable=SC2003
CPOINTS="$(expr "${BLOCKS}" / 100)"
export CPOINTS
# NOTE: We don't run as root, but folders created over USBMS are owned by root, which yields fun permission shenanigans...
# c.f., https://github.com/koreader/koreader/issues/7581
KO_PB_TARLOG="/tmp/.koreader.tar"
# shellcheck disable=SC2016
"${KOREADER_DIR}/tar" --no-same-permissions --no-same-owner --checkpoint="${CPOINTS}" --checkpoint-action=exec='printf "%s" $((TAR_CHECKPOINT / CPOINTS)) > ${FBINK_NAMED_PIPE}' -C "/mnt/ext1" -xf "${NEWUPDATE}" 2>"${KO_PB_TARLOG}"
"${KOREADER_DIR}/tar" --no-same-permissions --no-same-owner --checkpoint="${CPOINTS}" --checkpoint-action=exec='printf "%s" $(expr ${TAR_CHECKPOINT} / ${CPOINTS}) > ${FBINK_NAMED_PIPE}' -C "/mnt/ext1" -xf "${NEWUPDATE}" 2>"${KO_PB_TARLOG}"
fail=$?
kill -TERM "${FBINK_PID}"
# As mentioned above, filter out potential chmod & utime failures...
@ -116,10 +119,12 @@ while [ "${RETURN_VALUE}" -ne 0 ]; do
# Did we crash?
if [ "${RETURN_VALUE}" -ne 0 ] && [ "${RETURN_VALUE}" -ne ${KO_RC_RESTART} ]; then
# Increment the crash counter
CRASH_COUNT=$((CRASH_COUNT + 1))
CRASH_TS=$(date +'%s')
# shellcheck disable=SC2003
CRASH_COUNT="$(expr ${CRASH_COUNT} + 1)"
CRASH_TS="$(date +'%s')"
# Reset it to a first crash if it's been a while since our last crash...
if [ $((CRASH_TS - CRASH_PREV_TS)) -ge 20 ]; then
# shellcheck disable=SC2003
if [ "$(expr "${CRASH_TS}" - "${CRASH_PREV_TS}")" -ge 20 ]; then
CRASH_COUNT=1
fi
@ -139,8 +144,10 @@ while [ "${RETURN_VALUE}" -ne 0 ]; do
eval "$("${KOREADER_DIR}/fbink" -e | tr ';' '\n' | grep -e viewWidth -e viewHeight -e FONTH | tr '\n' ';')"
# Compute margins & sizes relative to the screen's resolution, so we end up with a similar layout, no matter the device.
# Height @ ~56.7%, w/ a margin worth 1.5 lines
bombHeight=$((viewHeight / 2 + viewHeight / 15))
bombMargin=$((FONTH + FONTH / 2))
# shellcheck disable=SC2003
bombHeight="$(expr ${viewHeight} / 2 + ${viewHeight} / 15)"
# shellcheck disable=SC2003
bombMargin="$(expr ${FONTH} + ${FONTH} / 2)"
# With a little notice at the top of the screen, on a big gray screen of death ;).
"${KOREADER_DIR}/fbink" -q -b -c -B GRAY9 -m -y 1 "Don't Panic! (Crash n°${CRASH_COUNT} -> ${RETURN_VALUE})"
if [ ${CRASH_COUNT} -eq 1 ]; then
@ -149,11 +156,12 @@ while [ "${RETURN_VALUE}" -ne 0 ]; do
fi
# U+1F4A3, the hard way, because we can't use \u or \U escape sequences...
# shellcheck disable=SC2039,SC3003
"${KOREADER_DIR}/fbink" -q -b -O -m -t regular=${KOREADER_DIR}/fonts/freefont/FreeSerif.ttf,px=${bombHeight},top=${bombMargin} -- $'\xf0\x9f\x92\xa3'
"${KOREADER_DIR}/fbink" -q -b -O -m -t regular=${KOREADER_DIR}/fonts/freefont/FreeSerif.ttf,px="${bombHeight}",top="${bombMargin}" -- $'\xf0\x9f\x92\xa3'
# And then print the tail end of the log on the bottom of the screen...
crashLog="$(tail -n 25 crash.log | sed -e 's/\t/ /g')"
# The idea for the margins being to leave enough room for an fbink -Z bar, small horizontal margins, and a font size based on what 6pt looked like @ 265dpi
"${KOREADER_DIR}/fbink" -q -b -O -t regular=${KOREADER_DIR}/fonts/droid/DroidSansMono.ttf,top=$((viewHeight / 2 + FONTH * 2 + FONTH / 2)),left=$((viewWidth / 60)),right=$((viewWidth / 60)),px=$((viewHeight / 64)) -- "${crashLog}"
# shellcheck disable=SC2003
"${KOREADER_DIR}/fbink" -q -b -O -t regular=${KOREADER_DIR}/fonts/droid/DroidSansMono.ttf,top="$(expr ${viewHeight} / 2 + ${FONTH} '*' 2 + ${FONTH} / 2)",left="$(expr ${viewWidth} / 60)",right="$(expr ${viewWidth} / 60)",px="$(expr ${viewHeight} / 64)" -- "${crashLog}"
# So far, we hadn't triggered an actual screen refresh, do that now, to make sure everything is bundled in a single flashing refresh.
${KOREADER_DIR}/fbink -q -f -s
# Cue a lemming's faceplant sound effect!

@ -612,6 +612,7 @@ end
function OPDSBrowser:createNewDownloadDialog(path, buttons)
self.download_dialog = ButtonDialogTitle:new{
title = T(_("Download folder:\n%1\n\nDownload file type:"), BD.dirpath(path)),
use_info_style = true,
buttons = buttons
}
end
@ -650,10 +651,10 @@ function OPDSBrowser:showDownloads(item)
table.insert(buttons, line)
end
table.insert(buttons, {})
-- Set download folder button.
-- Set download folder and book info buttons.
table.insert(buttons, {
{
text = _("Select another folder"),
text = _("Select folder"),
callback = function()
require("ui/downloadmgr"):new{
onConfirm = function(path)
@ -667,7 +668,18 @@ function OPDSBrowser:showDownloads(item)
end,
}:chooseDir()
end,
}
},
{
text = _("Book information"),
enabled = type(item.content) == "string",
callback = function()
local TextViewer = require("ui/widget/textviewer")
UIManager:show(TextViewer:new{
title = item.text,
text = util.htmlToPlainTextIfHtml(item.content),
})
end,
},
})
self:createNewDownloadDialog(self.getCurrentDownloadDir(), buttons)

@ -72,10 +72,6 @@ function OPDSParser:createFlatXTable(xlex, curr_element)
end
function OPDSParser:parse(text)
-- Murder Calibre's whole "content" block, because luxl doesn't really deal well with various XHTML quirks,
-- as the list of crappy replacements below attests to...
-- There's also a high probability of finding orphaned tags or badly nested ones in there, which will screw everything up.
text = text:gsub('<content type="xhtml">.-</content>', '')
-- luxl doesn't handle XML comments, so strip them
text = text:gsub("<!%-%-.-%-%->", "")
-- luxl is also particular about the syntax for self-closing, empty & orphaned tags...
@ -84,8 +80,18 @@ function OPDSParser:parse(text)
text = text:gsub("<([bh]r)>", "<%1 />")
-- Some OPDS catalogs wrap text in a CDATA section, remove it as it causes parsing problems
text = text:gsub("<!%[CDATA%[(.-)%]%]>", function (s)
return s:gsub( "%p", {["&"] = "&amp;", ["<"] = "&lt;", [">"] = "&gt;" } )
return s:gsub("%p", {["&"] = "&amp;", ["<"] = "&lt;", [">"] = "&gt;"})
end )
-- NOTE: OPDS content tags are likely to contain a bunch of HTML or XHTML. We do *NOT* want to let luxl parse that,
-- because it doesn't really deal well with various XHTML quirks, as the list of crappy replacements above attests to...
-- There's also a high probability of finding orphaned tags or badly nested ones in there, which would screw everything up.
-- In any case, we just want to treat the whole thing as a single text node anyway, so, just mangle the markup to force luxl's hand.
text = text:gsub('<content type=".-">', "<content>")
text = text:gsub("<content>(.-)</content>", function (s)
return '<content type="text">' .. s:gsub("%p", {["<"] = "&lt;", [">"] = "&gt;", ['"'] = "&quot;", ["'"] = "&apos;"}) .. "</content>"
end )
local xlex = luxl.new(text, #text)
return assert(self:createFlatXTable(xlex))
end

Loading…
Cancel
Save