writeAtomic renamed the temporary file over the target without syncing either: on ext4 with delayed allocation a power cut just after a save could leave a zero-length script, the exact failure autosave exists to prevent. Sync the temp file before the rename and the directory after it. While here, keep an existing file's permissions instead of forcing 0o644, and resolve symlinks first so saving through a link replaces the file it points to, not the link itself (Dropbox/dotfile setups).
434 lines
13 KiB
Go
434 lines
13 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/wailsapp/wails/v2/pkg/runtime"
|
|
|
|
"plotzilla/internal/fdx"
|
|
"plotzilla/internal/fountain"
|
|
"plotzilla/internal/layout"
|
|
"plotzilla/internal/pdf"
|
|
"plotzilla/internal/screenplay"
|
|
"plotzilla/internal/spell"
|
|
)
|
|
|
|
// App is bound to the frontend: every exported method is callable from JS.
|
|
type App struct {
|
|
ctx context.Context
|
|
// dirty decides whether closing the window asks for confirmation. It is
|
|
// written from Wails call goroutines (SetDirty) and read by the GTK
|
|
// close handler: atomic, like spellMu protects the checker.
|
|
dirty atomic.Bool
|
|
initialPath string
|
|
|
|
// Wails runs each call on its own goroutine: spelling calls share one
|
|
// loaded dictionary.
|
|
spellMu sync.Mutex
|
|
checker *spell.Checker
|
|
checkerLang string
|
|
|
|
tr translation
|
|
}
|
|
|
|
type OpenResult struct {
|
|
Path string `json:"path"`
|
|
Document *screenplay.Document `json:"document"`
|
|
}
|
|
|
|
type LayoutInfo struct {
|
|
Pages int `json:"pages"`
|
|
Breaks []layout.Break `json:"breaks"`
|
|
Scenes []layout.ScenePage `json:"scenes"`
|
|
}
|
|
|
|
type SpellInfo struct {
|
|
Languages []string `json:"languages"`
|
|
Default string `json:"default"`
|
|
}
|
|
|
|
type ExportOptions struct {
|
|
SceneNumbers bool `json:"sceneNumbers"`
|
|
TitlePage bool `json:"titlePage"`
|
|
// Revisions prints revision headers and asterisks.
|
|
Revisions bool `json:"revisions"`
|
|
}
|
|
|
|
var scriptFilters = []runtime.FileFilter{
|
|
{DisplayName: "Screenplays (*.fountain, *.fdx)", Pattern: "*.fountain;*.fdx;*.spmd;*.txt"},
|
|
}
|
|
|
|
func NewApp() *App { return &App{} }
|
|
|
|
func (a *App) startup(ctx context.Context) {
|
|
a.ctx = ctx
|
|
followSystemColorScheme()
|
|
}
|
|
|
|
// beforeClose keeps the window open while there are unsaved changes and
|
|
// lets the frontend ask; it quits again once the user has decided.
|
|
func (a *App) beforeClose(ctx context.Context) bool {
|
|
if !a.dirty.Load() {
|
|
return false
|
|
}
|
|
runtime.EventsEmit(a.ctx, "confirm-close")
|
|
return true
|
|
}
|
|
|
|
func (a *App) NewDocument() *screenplay.Document { return screenplay.New() }
|
|
|
|
// InitialFile returns the script passed on the command line, if any.
|
|
func (a *App) InitialFile() (*OpenResult, error) {
|
|
if a.initialPath == "" {
|
|
return nil, nil
|
|
}
|
|
path := a.initialPath
|
|
a.initialPath = ""
|
|
return a.load(path)
|
|
}
|
|
|
|
func (a *App) Open() (*OpenResult, error) {
|
|
path, err := runtime.OpenFileDialog(a.ctx, runtime.OpenDialogOptions{Title: "Open Screenplay", Filters: scriptFilters})
|
|
if err != nil || path == "" {
|
|
return nil, err
|
|
}
|
|
return a.load(path)
|
|
}
|
|
|
|
func (a *App) load(path string) (*OpenResult, error) {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var doc *screenplay.Document
|
|
if strings.EqualFold(filepath.Ext(path), ".fdx") {
|
|
if doc, err = fdx.Read(bytes.NewReader(data)); err != nil {
|
|
return nil, err
|
|
}
|
|
} else {
|
|
doc = fountain.Parse(string(data))
|
|
}
|
|
return &OpenResult{Path: path, Document: doc}, nil
|
|
}
|
|
|
|
// Save writes the document to path, asking for one when empty. It returns
|
|
// the path written, or "" if the user cancelled.
|
|
func (a *App) Save(doc screenplay.Document, path string) (string, error) {
|
|
if path == "" {
|
|
return a.SaveAs(doc, "")
|
|
}
|
|
var buf bytes.Buffer
|
|
if strings.EqualFold(filepath.Ext(path), ".fdx") {
|
|
if err := fdx.Write(&buf, &doc); err != nil {
|
|
return "", err
|
|
}
|
|
} else {
|
|
buf.WriteString(fountain.Write(&doc))
|
|
}
|
|
// The dirty flag is owned by the frontend (SetDirty): an autosave may
|
|
// finish after newer edits were made.
|
|
if err := writeAtomic(path, buf.Bytes()); err != nil {
|
|
return "", err
|
|
}
|
|
return path, nil
|
|
}
|
|
|
|
func (a *App) SaveAs(doc screenplay.Document, current string) (string, error) {
|
|
path, err := a.saveDialog("Save Screenplay", current, ".fountain", scriptFilters)
|
|
if err != nil || path == "" {
|
|
return "", err
|
|
}
|
|
return a.Save(doc, path)
|
|
}
|
|
|
|
func (a *App) ExportPDF(doc screenplay.Document, opts ExportOptions, current string) (string, error) {
|
|
path, err := a.saveDialog("Export PDF", current, ".pdf",
|
|
[]runtime.FileFilter{{DisplayName: "PDF (*.pdf)", Pattern: "*.pdf"}})
|
|
if err != nil || path == "" {
|
|
return "", err
|
|
}
|
|
var pages []layout.Page
|
|
if opts.TitlePage && !doc.TitlePage.IsEmpty() {
|
|
pages = append(pages, layout.TitlePage(doc.TitlePage, doc.Settings.Paper))
|
|
}
|
|
pages = append(pages, layout.Paginate(&doc, layout.Options{SceneNumbers: opts.SceneNumbers}).Pages...)
|
|
pdfOpts := pdf.Options{Title: doc.TitlePage.Title, Author: doc.TitlePage.Author, Paper: doc.Settings.Paper}
|
|
if opts.Revisions {
|
|
pdfOpts.Revisions = doc.Settings.Revisions
|
|
}
|
|
var buf bytes.Buffer
|
|
err = pdf.Render(&buf, pages, pdfOpts)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return path, writeAtomic(path, buf.Bytes())
|
|
}
|
|
|
|
func (a *App) ExportFDX(doc screenplay.Document, current string) (string, error) {
|
|
path, err := a.saveDialog("Export to Final Draft", current, ".fdx",
|
|
[]runtime.FileFilter{{DisplayName: "Final Draft (*.fdx)", Pattern: "*.fdx"}})
|
|
if err != nil || path == "" {
|
|
return "", err
|
|
}
|
|
var buf bytes.Buffer
|
|
if err := fdx.Write(&buf, &doc); err != nil {
|
|
return "", err
|
|
}
|
|
return path, writeAtomic(path, buf.Bytes())
|
|
}
|
|
|
|
// Paginate returns where pages break so the editor can draw them live.
|
|
func (a *App) Paginate(doc screenplay.Document) LayoutInfo {
|
|
res := layout.Paginate(&doc, layout.Options{})
|
|
return LayoutInfo{Pages: len(res.Pages), Breaks: res.Breaks, Scenes: res.Scenes}
|
|
}
|
|
|
|
// ParseFountain converts pasted plain text into elements.
|
|
func (a *App) ParseFountain(text string) []screenplay.Element {
|
|
return fountain.ParseText(text)
|
|
}
|
|
|
|
// RevisionSet returns revision set n (from 1), dated today.
|
|
func (a *App) RevisionSet(n int) screenplay.Revision {
|
|
r := screenplay.RevisionSet(n)
|
|
r.Date = time.Now().Format("01/02/06")
|
|
return r
|
|
}
|
|
|
|
func (a *App) SetDirty(dirty bool) { a.dirty.Store(dirty) }
|
|
|
|
// dataDir is the XDG data directory (~/.local/share).
|
|
func dataDir() string {
|
|
if dir := os.Getenv("XDG_DATA_HOME"); dir != "" {
|
|
return dir
|
|
}
|
|
home, _ := os.UserHomeDir()
|
|
return filepath.Join(home, ".local", "share")
|
|
}
|
|
|
|
// recoveryPath is where untitled scripts are autosaved.
|
|
func recoveryPath() string { return filepath.Join(dataDir(), "plotzilla", "recovery.fountain") }
|
|
|
|
// personalDictionaryPath holds the words added with "Add to Dictionary",
|
|
// one per line, shared by every language.
|
|
func personalDictionaryPath() string { return filepath.Join(dataDir(), "plotzilla", "dictionary.txt") }
|
|
|
|
// SaveRecovery autosaves a script that has no file yet.
|
|
func (a *App) SaveRecovery(doc screenplay.Document) error {
|
|
path := recoveryPath()
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
return err
|
|
}
|
|
return writeAtomic(path, []byte(fountain.Write(&doc)))
|
|
}
|
|
|
|
func (a *App) ClearRecovery() { _ = os.Remove(recoveryPath()) }
|
|
|
|
// Recovery returns the autosaved untitled script left by a previous
|
|
// session, or nil.
|
|
func (a *App) Recovery() (*OpenResult, error) {
|
|
path := recoveryPath()
|
|
if _, err := os.Stat(path); err != nil {
|
|
return nil, nil
|
|
}
|
|
res, err := a.load(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
res.Path = ""
|
|
return res, nil
|
|
}
|
|
|
|
// dictionaryDirs lists where Hunspell dictionaries are installed, in the
|
|
// order Hunspell itself searches them.
|
|
func dictionaryDirs() []string {
|
|
var dirs []string
|
|
if p := os.Getenv("DICPATH"); p != "" {
|
|
dirs = append(dirs, filepath.SplitList(p)...)
|
|
}
|
|
return append(dirs,
|
|
filepath.Join(dataDir(), "hunspell"),
|
|
"/usr/local/share/hunspell",
|
|
"/usr/share/hunspell",
|
|
"/usr/share/myspell",
|
|
"/usr/share/myspell/dicts",
|
|
)
|
|
}
|
|
|
|
// SpellLanguages lists the installed dictionaries and the one matching
|
|
// the system locale.
|
|
func (a *App) SpellLanguages() SpellInfo {
|
|
codes := []string{}
|
|
for _, l := range spell.Available(dictionaryDirs()) {
|
|
codes = append(codes, l.Code)
|
|
}
|
|
locale := os.Getenv("LC_ALL")
|
|
if locale == "" {
|
|
locale = os.Getenv("LANG")
|
|
}
|
|
return SpellInfo{Languages: codes, Default: spell.Preferred(codes, locale)}
|
|
}
|
|
|
|
// spellChecker returns the checker for lang, loading its dictionary the
|
|
// first time it is asked for.
|
|
func (a *App) spellChecker(lang string) (*spell.Checker, error) {
|
|
a.spellMu.Lock()
|
|
defer a.spellMu.Unlock()
|
|
if a.checker != nil && a.checkerLang == lang {
|
|
return a.checker, nil
|
|
}
|
|
langs := spell.Available(dictionaryDirs())
|
|
i := slices.IndexFunc(langs, func(l spell.Language) bool { return l.Code == lang })
|
|
if i < 0 {
|
|
return nil, fmt.Errorf("no %s dictionary installed", lang)
|
|
}
|
|
l := langs[i]
|
|
dict, err := spell.Load(l.Aff, l.Dic)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("loading the %s dictionary: %w", lang, err)
|
|
}
|
|
var personal []string
|
|
if data, err := os.ReadFile(personalDictionaryPath()); err == nil {
|
|
personal = strings.Fields(string(data))
|
|
}
|
|
a.checker, a.checkerLang = spell.NewChecker(dict, personal), lang
|
|
return a.checker, nil
|
|
}
|
|
|
|
// SpellCheck returns the misspelled words of the script.
|
|
func (a *App) SpellCheck(doc screenplay.Document, lang string) ([]spell.Misspelling, error) {
|
|
c, err := a.spellChecker(lang)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return c.Misspellings(&doc), nil
|
|
}
|
|
|
|
func (a *App) SpellSuggest(word, lang string) ([]string, error) {
|
|
c, err := a.spellChecker(lang)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return c.Suggest(word), nil
|
|
}
|
|
|
|
// AddToDictionary accepts word in every language from now on.
|
|
func (a *App) AddToDictionary(word string) error {
|
|
word = strings.TrimSpace(word)
|
|
if word == "" || strings.ContainsAny(word, " \t\n") {
|
|
return nil
|
|
}
|
|
a.spellMu.Lock()
|
|
defer a.spellMu.Unlock()
|
|
path := personalDictionaryPath()
|
|
data, err := os.ReadFile(path)
|
|
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
|
return fmt.Errorf("reading the personal dictionary: %w", err)
|
|
}
|
|
words := strings.Fields(string(data))
|
|
if !slices.Contains(words, word) {
|
|
words = append(words, word)
|
|
slices.Sort(words)
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
return err
|
|
}
|
|
if err := writeAtomic(path, []byte(strings.Join(words, "\n")+"\n")); err != nil {
|
|
return fmt.Errorf("saving the personal dictionary: %w", err)
|
|
}
|
|
}
|
|
if a.checker != nil {
|
|
a.checker.Add(word)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// WindowFrame describes how the in-app header bar should draw the window.
|
|
type WindowFrame struct {
|
|
// Buttons is the desktop's title bar button layout in GTK's
|
|
// gtk-decoration-layout format, e.g. "appmenu:minimize,maximize,close".
|
|
Buttons string `json:"buttons"`
|
|
// Rounded is false when no compositor gives the window transparency.
|
|
Rounded bool `json:"rounded"`
|
|
}
|
|
|
|
// WindowFrame returns the title bar button layout that GTK windows use on
|
|
// this desktop (GNOME, Plasma, Xfce…) so the in-app header bar can place its
|
|
// window buttons like every other window, and whether rounded corners can
|
|
// be drawn.
|
|
func (a *App) WindowFrame() WindowFrame { return systemWindowFrame() }
|
|
|
|
func (a *App) saveDialog(title, current, ext string, filters []runtime.FileFilter) (string, error) {
|
|
dir, name := "", "untitled"+ext
|
|
if current != "" {
|
|
dir = filepath.Dir(current)
|
|
name = strings.TrimSuffix(filepath.Base(current), filepath.Ext(current)) + ext
|
|
}
|
|
path, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{
|
|
Title: title, DefaultDirectory: dir, DefaultFilename: name, Filters: filters,
|
|
})
|
|
if err != nil || path == "" {
|
|
return "", err
|
|
}
|
|
// The GTK dialog does not append the extension on its own.
|
|
if filepath.Ext(path) == "" {
|
|
path += ext
|
|
}
|
|
return path, nil
|
|
}
|
|
|
|
// writeAtomic writes data to path atomically and durably: a temporary file
|
|
// in the same directory is written, given the file's permissions and synced
|
|
// to disk, then renamed over path and the directory itself synced, so a
|
|
// power cut just after a save cannot leave an empty script behind (the very
|
|
// failure autosave, which calls this every 1.2 s, exists to prevent). A
|
|
// symlinked path is resolved first: the file it points to is replaced, not
|
|
// the link. An existing file keeps its permissions; a new one gets 0o644.
|
|
func writeAtomic(path string, data []byte) error {
|
|
if target, err := filepath.EvalSymlinks(path); err == nil {
|
|
path = target
|
|
}
|
|
mode := os.FileMode(0o644)
|
|
if st, err := os.Stat(path); err == nil {
|
|
mode = st.Mode().Perm()
|
|
}
|
|
tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err = tmp.Write(data); err == nil {
|
|
err = tmp.Chmod(mode)
|
|
}
|
|
if err == nil {
|
|
err = tmp.Sync()
|
|
}
|
|
if cerr := tmp.Close(); err == nil {
|
|
err = cerr
|
|
}
|
|
if err != nil {
|
|
os.Remove(tmp.Name())
|
|
return err
|
|
}
|
|
if err := os.Rename(tmp.Name(), path); err != nil {
|
|
os.Remove(tmp.Name())
|
|
return err
|
|
}
|
|
// The rename is a directory entry: sync the directory so it too
|
|
// survives a crash.
|
|
dir, err := os.Open(filepath.Dir(path))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer dir.Close()
|
|
return dir.Sync()
|
|
}
|