402 lines
11 KiB
Go
402 lines
11 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"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 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 {
|
|
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 = 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
|
|
}
|
|
|
|
func writeAtomic(path string, data []byte) error {
|
|
tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, werr := tmp.Write(data)
|
|
cerr := tmp.Close()
|
|
if err := errors.Join(werr, cerr); err != nil {
|
|
os.Remove(tmp.Name())
|
|
return err
|
|
}
|
|
if err := os.Chmod(tmp.Name(), 0o644); err != nil {
|
|
os.Remove(tmp.Name())
|
|
return err
|
|
}
|
|
return os.Rename(tmp.Name(), path)
|
|
}
|