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. dicts caches them per language so the translation's // language detection does not read the same files again. spellMu sync.Mutex checker *spell.Checker checkerLang string dicts map[string]*spell.Dictionary 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. A // path that does not exist yet opens a new script, written there on the // first save: `plotzilla new-script.fountain` creates the file. func (a *App) InitialFile() (*OpenResult, error) { if a.initialPath == "" { return nil, nil } path := a.initialPath a.initialPath = "" if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) { return &OpenResult{Path: path, Document: screenplay.New()}, nil } 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 isFDX(path, data) { 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 } // isFDX reports whether a file is a Final Draft one: the extension says // so, or its content does — a ".txt" export of Final Draft is still FDX. func isFDX(path string, data []byte) bool { if strings.EqualFold(filepath.Ext(path), ".fdx") { return true } head := strings.TrimPrefix(string(data[:min(len(data), 512)]), "\ufeff") return strings.Contains(head, "