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 } 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) }