403 lines
13 KiB
Python
403 lines
13 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""
|
|
Liest ein Writer-Dokument mit Aenderungsverfolgung und baut daraus ein
|
|
Modell aus Bloecken auf.
|
|
|
|
Ein Block entspricht einer Zeile der spaeteren Synopse und enthaelt
|
|
* Segmente - Textstuecke samt der Redlines, in denen sie liegen
|
|
* Notes - Anmerkungen, die in diesem Bereich verankert sind
|
|
|
|
Grundidee: Der Text wird einmal linear durchlaufen. Waehrend des Durchlaufs
|
|
wird ein Stack der gerade offenen Redlines gefuehrt. Jedes Textstueck wird
|
|
zusammen mit diesem Stack gespeichert. Aus dem Stack laesst sich anschliessend
|
|
jede beliebige Fassung rekonstruieren, ohne das Dokument erneut anzufassen.
|
|
"""
|
|
|
|
import uno
|
|
|
|
|
|
def const(name, default):
|
|
"""UNO-Konstante aufloesen, ohne beim Import zu scheitern.
|
|
|
|
getConstantByName wirft eine RuntimeException, wenn der Typmanager den
|
|
Namen nicht kennt. Geschieht das beim Laden des Moduls, bricht der
|
|
Python-Loader die Registrierung der Erweiterung ab. Deshalb wird hier
|
|
immer auf den dokumentierten Zahlenwert zurueckgefallen.
|
|
"""
|
|
try:
|
|
return uno.getConstantByName(name)
|
|
except Exception:
|
|
return default
|
|
|
|
|
|
def const_opt(name):
|
|
"""Wie const(), liefert aber None statt eines Ersatzwertes."""
|
|
try:
|
|
return uno.getConstantByName(name)
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def enum(type_name, value):
|
|
"""UNO-Enum aufloesen; liefert None, wenn der Typ unbekannt ist."""
|
|
try:
|
|
return uno.Enum(type_name, value)
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
RD_NAME = "com.sun.star.text.RedlineDisplayType.INSERTED_AND_REMOVED"
|
|
|
|
# Ergebnis der einmaligen empirischen Bestimmung (siehe _display_all_value)
|
|
_display_all_cache = None
|
|
|
|
INSERT = "Insert"
|
|
DELETE = "Delete"
|
|
|
|
UNKNOWN_AUTHOR = "\x00unknown" # sprachneutral, Anzeige ueber i18n
|
|
|
|
QUOTE_MAX = 80
|
|
|
|
# Beschreibungen, die LibreOffice selbst in RedlineComment eintraegt. Sie
|
|
# sind keine Anmerkung des Bearbeiters und wuerden die Synopse zumuellen.
|
|
AUTO_COMMENTS = frozenset((
|
|
"kommentar hinzugef\u00fcgt", "kommentar ge\u00e4ndert",
|
|
"kommentar gel\u00f6scht", "kommentar aufgel\u00f6st",
|
|
"comment added", "comment changed", "comment deleted", "comment resolved",
|
|
"attribut ge\u00e4ndert", "attribute changed",
|
|
"absatzformat ge\u00e4ndert", "paragraph formatting changed",
|
|
))
|
|
|
|
|
|
class Segment(object):
|
|
"""Ein Textstueck samt der Redlines, innerhalb derer es liegt."""
|
|
|
|
__slots__ = ("text", "stack")
|
|
|
|
def __init__(self, text, stack):
|
|
self.text = text
|
|
self.stack = stack # tuple((rtype, author), ...)
|
|
|
|
@property
|
|
def inserted_by(self):
|
|
return [a for (t, a) in self.stack if t == INSERT]
|
|
|
|
@property
|
|
def deleted_by(self):
|
|
return [a for (t, a) in self.stack if t == DELETE]
|
|
|
|
@property
|
|
def changed(self):
|
|
return bool(self.stack)
|
|
|
|
# -- Fassungen --------------------------------------------------------
|
|
|
|
def in_original(self):
|
|
"""Stand das Stueck in der Ursprungsfassung?"""
|
|
return not self.inserted_by
|
|
|
|
def in_final(self):
|
|
"""Steht das Stueck in der Endfassung (alle Aenderungen uebernommen)?"""
|
|
return not self.deleted_by
|
|
|
|
def in_version_of(self, author):
|
|
"""Fassung, in der ausschliesslich die Aenderungen von *author* gelten.
|
|
|
|
- Von jemand anderem eingefuegt -> nicht enthalten
|
|
- Von *author* eingefuegt -> enthalten
|
|
- Von *author* geloescht -> nicht enthalten
|
|
- Von jemand anderem geloescht -> bleibt stehen
|
|
"""
|
|
if any(a != author for a in self.inserted_by):
|
|
return False
|
|
if author in self.deleted_by:
|
|
return False
|
|
return True
|
|
|
|
|
|
class Note(object):
|
|
"""Eine Anmerkung zu einer Stelle des Textes."""
|
|
|
|
__slots__ = ("author", "content", "quote", "kind", "resolved", "when")
|
|
|
|
def __init__(self, author, content, kind="annotation"):
|
|
self.author = author or UNKNOWN_AUTHOR
|
|
self.content = (content or "").strip()
|
|
self.quote = ""
|
|
self.kind = kind # "annotation" | "redline"
|
|
self.resolved = False
|
|
self.when = None
|
|
|
|
def short_quote(self):
|
|
q = " ".join(self.quote.split())
|
|
if len(q) > QUOTE_MAX:
|
|
q = q[:QUOTE_MAX - 1].rstrip() + "\u2026"
|
|
return q
|
|
|
|
|
|
class Block(object):
|
|
"""Eine Zeile der Synopse."""
|
|
|
|
__slots__ = ("segments", "notes")
|
|
|
|
def __init__(self):
|
|
self.segments = []
|
|
self.notes = []
|
|
|
|
@property
|
|
def changed(self):
|
|
return any(s.changed for s in self.segments) or bool(self.notes)
|
|
|
|
def notes_of(self, author):
|
|
return [n for n in self.notes if n.author == author]
|
|
|
|
|
|
def _pop(stack, ident):
|
|
"""Entfernt die Redline mit der gegebenen Kennung vom Stack."""
|
|
for i in range(len(stack) - 1, -1, -1):
|
|
if stack[i][0] == ident:
|
|
del stack[i]
|
|
return
|
|
if stack:
|
|
stack.pop()
|
|
|
|
|
|
def _iter_paragraphs(text):
|
|
"""Absaetze eines XText, inklusive der Absaetze in Tabellenzellen."""
|
|
enum = text.createEnumeration()
|
|
while enum.hasMoreElements():
|
|
el = enum.nextElement()
|
|
if el.supportsService("com.sun.star.text.Paragraph"):
|
|
yield el
|
|
elif el.supportsService("com.sun.star.text.TextTable"):
|
|
for name in el.getCellNames():
|
|
for para in _iter_paragraphs(el.getCellByName(name)):
|
|
yield para
|
|
|
|
|
|
def _portion_text(portion):
|
|
try:
|
|
return portion.getString()
|
|
except Exception:
|
|
return ""
|
|
|
|
|
|
def _get(obj, name, default=None):
|
|
try:
|
|
return getattr(obj, name)
|
|
except Exception:
|
|
return default
|
|
|
|
|
|
def _is_annotation(field):
|
|
"""Ist das Textfeld ein Kommentar?"""
|
|
try:
|
|
if field.supportsService("com.sun.star.text.textfield.Annotation"):
|
|
return True
|
|
except Exception:
|
|
pass
|
|
# Aeltere Versionen melden den Dienst nicht zuverlaessig zurueck
|
|
try:
|
|
return _get(field, "Content") is not None and \
|
|
_get(field, "Author") is not None
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def _field_of(portion):
|
|
"""Das Feld eines Portions. Der Name unterscheidet sich je nach Version."""
|
|
for name in ("TextField", "Annotation"):
|
|
field = _get(portion, name)
|
|
if field is not None:
|
|
return field
|
|
return None
|
|
|
|
|
|
def _annotation(portion):
|
|
"""Liefert (Note, Schluessel) zu einem Kommentar-Portion."""
|
|
field = _field_of(portion)
|
|
if field is None or not _is_annotation(field):
|
|
return None, None
|
|
content = (_get(field, "Content", "") or "").strip()
|
|
if not content:
|
|
return None, None # leerer Kommentar sagt nichts aus
|
|
note = Note(_get(field, "Author", ""), content, kind="annotation")
|
|
note.resolved = bool(_get(field, "Resolved", False))
|
|
note.when = _get(field, "DateTimeValue")
|
|
key = _get(field, "Name", "") or ("%s\x00%s" % (note.author, content))
|
|
return note, key
|
|
|
|
|
|
def _display_all_value(doc):
|
|
"""Wert von RedlineDisplayType.INSERTED_AND_REMOVED ermitteln.
|
|
|
|
Bevorzugt wird der Typmanager. Antwortet er nicht, wird der Wert
|
|
gemessen statt geraten: nur in der Anzeigeart mit Einfuegungen *und*
|
|
Loeschungen enthaelt der Dokumenttext beides und ist damit am laengsten.
|
|
Ein geratener Wert waere gefaehrlich - die Reihenfolge der Konstanten
|
|
ist nicht ueber alle Versionen hinweg gleich, und ein Griff daneben
|
|
blendet stillschweigend die Einfuegungen aus.
|
|
"""
|
|
global _display_all_cache
|
|
|
|
value = const_opt(RD_NAME)
|
|
if value is not None:
|
|
return value
|
|
if _display_all_cache is not None:
|
|
return _display_all_cache
|
|
|
|
best, longest, shortest = None, -1, None
|
|
for candidate in (0, 1, 2, 3):
|
|
try:
|
|
doc.setPropertyValue("RedlineDisplayType", candidate)
|
|
length = len(doc.getText().getString())
|
|
except Exception:
|
|
continue
|
|
if length > longest:
|
|
best, longest = candidate, length
|
|
if shortest is None or length < shortest:
|
|
shortest = length
|
|
|
|
if best is not None and shortest is not None and longest > shortest:
|
|
# Nur aussagekraeftig, wenn das Dokument ueberhaupt Aenderungen hat
|
|
_display_all_cache = best
|
|
return best
|
|
|
|
|
|
def _show_all_changes(doc):
|
|
"""Eingefuegten und geloeschten Text sichtbar machen.
|
|
|
|
Nur in dieser Anzeigeart liefert die Portion-Enumeration auch den
|
|
geloeschten Text. Liefert den zuvor eingestellten Wert zurueck.
|
|
"""
|
|
try:
|
|
previous = doc.getPropertyValue("RedlineDisplayType")
|
|
except Exception:
|
|
previous = None
|
|
|
|
value = _display_all_value(doc)
|
|
if value is not None:
|
|
try:
|
|
doc.setPropertyValue("RedlineDisplayType", value)
|
|
except Exception:
|
|
pass
|
|
return previous
|
|
|
|
|
|
def _restore_display(doc, previous, was_modified):
|
|
"""Anzeige und Aenderungsstatus des Quelldokuments wiederherstellen."""
|
|
if previous is not None:
|
|
try:
|
|
doc.setPropertyValue("RedlineDisplayType", previous)
|
|
except Exception:
|
|
pass
|
|
if was_modified is False:
|
|
try:
|
|
doc.setModified(False)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def extract_blocks(doc):
|
|
"""Liefert (blocks, edit_authors, note_authors).
|
|
|
|
Das Quelldokument wird dabei nicht bleibend veraendert: Anzeigeart und
|
|
Aenderungsstatus werden am Ende wiederhergestellt.
|
|
"""
|
|
try:
|
|
was_modified = bool(doc.isModified())
|
|
except Exception:
|
|
was_modified = None
|
|
previous_display = _show_all_changes(doc)
|
|
try:
|
|
return _extract(doc)
|
|
finally:
|
|
_restore_display(doc, previous_display, was_modified)
|
|
|
|
|
|
def _extract(doc):
|
|
stack = [] # [(identifier, rtype, author)]
|
|
edit_authors = set()
|
|
note_authors = set()
|
|
seen_comments = set() # Redline-Kommentare nur einmal uebernehmen
|
|
seen_notes = set() # Kommentare nur einmal uebernehmen
|
|
open_notes = {} # Schluessel -> Note mit noch offenem Textbereich
|
|
blocks = []
|
|
current = Block()
|
|
|
|
for para in _iter_paragraphs(doc.getText()):
|
|
pen = para.createEnumeration()
|
|
while pen.hasMoreElements():
|
|
portion = pen.nextElement()
|
|
ptype = portion.TextPortionType
|
|
|
|
if ptype == "Redline":
|
|
rtype = portion.RedlineType
|
|
if rtype not in (INSERT, DELETE):
|
|
continue # Format/Attribute: ignoriert
|
|
author = portion.RedlineAuthor or UNKNOWN_AUTHOR
|
|
ident = portion.RedlineIdentifier
|
|
edit_authors.add(author)
|
|
|
|
comment = (_get(portion, "RedlineComment", "") or "").strip()
|
|
if (comment and ident not in seen_comments
|
|
and comment.lower() not in AUTO_COMMENTS):
|
|
seen_comments.add(ident)
|
|
note = Note(author, comment, kind="redline")
|
|
current.notes.append(note)
|
|
note_authors.add(note.author)
|
|
|
|
if bool(_get(portion, "IsCollapsed", False)):
|
|
continue # Laenge 0
|
|
if bool(_get(portion, "IsStart", True)):
|
|
stack.append((ident, rtype, author))
|
|
else:
|
|
_pop(stack, ident)
|
|
|
|
elif ptype == "Annotation":
|
|
note, key = _annotation(portion)
|
|
if note is not None and key not in seen_notes:
|
|
seen_notes.add(key)
|
|
current.notes.append(note)
|
|
note_authors.add(note.author)
|
|
open_notes[key] = note # Ende folgt ggf. spaeter
|
|
|
|
elif ptype == "AnnotationEnd":
|
|
_note, key = _annotation(portion)
|
|
open_notes.pop(key, None)
|
|
|
|
else:
|
|
if ptype == "TextField":
|
|
# Je nach Version meldet Writer einen Kommentar nicht als
|
|
# "Annotation", sondern als gewoehnliches Textfeld.
|
|
note, key = _annotation(portion)
|
|
if note is not None:
|
|
if key not in seen_notes:
|
|
seen_notes.add(key)
|
|
current.notes.append(note)
|
|
note_authors.add(note.author)
|
|
continue
|
|
s = _portion_text(portion)
|
|
if s:
|
|
current.segments.append(
|
|
Segment(s, tuple((t, a) for (_i, t, a) in stack)))
|
|
for note in open_notes.values():
|
|
note.quote += s
|
|
|
|
mark = tuple((t, a) for (_i, t, a) in stack)
|
|
if mark:
|
|
# Absatzmarke liegt innerhalb einer Aenderung -> kein Zeilenumbruch
|
|
# in der Synopse, sondern ein Segment, das nur in der jeweils
|
|
# passenden Fassung als Umbruch erscheint.
|
|
current.segments.append(Segment("\n", mark))
|
|
else:
|
|
blocks.append(current)
|
|
current = Block()
|
|
for note in open_notes.values():
|
|
note.quote += " "
|
|
|
|
if current.segments or current.notes:
|
|
blocks.append(current)
|
|
|
|
return blocks, edit_authors, note_authors
|